← Commit history

v0.9.59: (1) place_footprint accepts legacy (module ...) footprints (many chip-fetcher/EAGLE parts) — normalize to (footprint), so they can be placed on a preview board. (2) FP-editor 3D viewer now opens via the reverse-bridge plugin (the FP editor is a pcbnew frame — resolve its frame_index + live 3D-Viewer menu id), replacing the broken Alt+3 path that raised UnboundLocalError on VK_MENU and needed stolen focus. (3) kicad_click guards against pixel coords passed with relative=True (was clicking off-screen → could crash KiCad).

John Lauer ·0bad62a7ce ·3mo ago ·parent 417f923
7 files changed +783−709
adom-bridge-kicad-manifest.json+4−4
@@ -1,10 +1,10 @@ {   "manifest_version": 1,   "name": "kicad",-  "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,+  "version": "0.9.59",+  "url": "https://wiki.adom.inc/blob/app/adom-desktop-kicad-bridge/adom-bridge-kicad-v0.9.59.zip",+  "sha256": "f8affb58064486c62cdeef1c0c5eb441cbaed92b1557af09b676e925f8d2e2c7",+  "size": 643494,   "verbPrefixes": [     "kicad_"   ],
adom-bridge-kicad-v0.9.59.zipadded
⋯ 1 unchanged line ⋯
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "kicad",   "displayName": "KiCad EDA",-  "version": "0.9.58",+  "version": "0.9.59",   "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+20
@@ -452,6 +452,26 @@ def try_wm_command_via_bridge(exe_name: str, menu_id: int, frame_index: int = 0,     }  +def resolve_frame_index_via_bridge(exe_name: str, title_contains: str,+                                   pid: int | None = None, timeout: float = 10.0) -> int | None:+    """Return the index of the plugin's frame whose title contains `title_contains`+    (case-insensitive), or None. In KiCad 10 the Footprint Editor is a frame INSIDE+    the pcbnew process, so `list_frames` exposes it at some index (usually 1 when a+    board is also open); `wm_command` honors that frame_index even though+    `get_menu_ids` currently only reports frame 0."""+    meta, err = _resolve_plugin(exe_name, pid)+    if err:+        return None+    rpc = call(meta["port"], "list_frames", {}, timeout=timeout)+    if "error" in rpc:+        return None+    needle = title_contains.lower()+    for i, fr in enumerate((rpc.get("result") or {}).get("frames", [])):+        if needle in str(fr.get("frameTitle", "")).lower():+            return i+    return None++ 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
handlers/kicad_ui.py+693−674
@@ -1,674 +1,693 @@-"""Win32-based tools for interacting with KiCad's UI.--Windows: provides screenshot, click, and keyboard input against KiCad windows-(project manager, eeschema, pcbnew, footprint editor, symbol editor, 3D viewer,-and their dialogs). Mirrors the Fusion 360 UI handler at-plugins/fusion360/handlers/fusion_ui.py.--macOS: handlers return "not implemented" responses. Faithful UI scripting-against another app on macOS requires Accessibility-framework permission,-which we don't request. The screenshot path is handled by the Tauri app's-CoreGraphics implementation in src-tauri/src/screenshot/macos.rs instead.-"""--import ctypes-import ctypes.wintypes-import os-import struct-import sys-import time--IS_WINDOWS = sys.platform == "win32"-IS_MACOS = sys.platform == "darwin"--# ctypes.Structure / Union / WINFUNCTYPE are cross-platform; only ctypes.windll-# is Windows-only. So the module-level INPUT/MOUSEINPUT/etc. structs below-# load fine on macOS — they just never get used because handlers branch-# at the entry point.-if IS_WINDOWS:-    user32 = ctypes.windll.user32-    gdi32 = ctypes.windll.gdi32--    # Enable DPI awareness so GetWindowRect returns physical pixel coordinates-    try:-        ctypes.windll.shcore.SetProcessDpiAwareness(2)-    except Exception:-        try:-            user32.SetProcessDPIAware()-        except Exception:-            pass-else:-    # Sentinels so module-level expressions referencing user32/gdi32 don't-    # NameError. Helper functions that touch these are guarded by IS_WINDOWS-    # at call time via the handler entry points.-    user32 = None-    gdi32 = None--# --- Constants -----BI_RGB = 0-DIB_RGB_COLORS = 0-GW_OWNER = 4-PW_RENDERFULLCONTENT = 0x00000002--INPUT_MOUSE = 0-INPUT_KEYBOARD = 1-MOUSEEVENTF_ABSOLUTE = 0x8000-MOUSEEVENTF_MOVE = 0x0001-MOUSEEVENTF_LEFTDOWN = 0x0002-MOUSEEVENTF_LEFTUP = 0x0004-KEYEVENTF_KEYUP = 0x0002--VK_MAP = {-    "enter": 0x0D, "return": 0x0D,-    "escape": 0x1B, "esc": 0x1B,-    "tab": 0x09,-    "space": 0x20,-    "up": 0x26, "down": 0x28, "left": 0x25, "right": 0x27,-    "backspace": 0x08, "delete": 0x2E,-    "home": 0x24, "end": 0x23,-    "pageup": 0x21, "pagedown": 0x22,-    "f1": 0x70, "f2": 0x71, "f3": 0x72, "f4": 0x73,-    "f5": 0x74, "f6": 0x75, "f7": 0x76, "f8": 0x77,-    "f9": 0x78, "f10": 0x79, "f11": 0x7A, "f12": 0x7B,-}--SCREENSHOT_DIR = "C:/tmp/adom-desktop-screenshots"--# KiCad window title keywords (wxWidgets windows use class wxWindowNR)-KICAD_TITLE_KEYWORDS = [-    "KiCad", "Symbol Editor", "Footprint Editor", "3D Viewer",-    "eeschema", "pcbnew", "Schematic Editor", "PCB Editor",-]--# Keep callback references alive to prevent GC-_callbacks = []---# --- SendInput structures -----class MOUSEINPUT(ctypes.Structure):-    _fields_ = [-        ("dx", ctypes.wintypes.LONG),-        ("dy", ctypes.wintypes.LONG),-        ("mouseData", ctypes.wintypes.DWORD),-        ("dwFlags", ctypes.wintypes.DWORD),-        ("time", ctypes.wintypes.DWORD),-        ("dwExtraInfo", ctypes.POINTER(ctypes.c_ulong)),-    ]---class KEYBDINPUT(ctypes.Structure):-    _fields_ = [-        ("wVk", ctypes.wintypes.WORD),-        ("wScan", ctypes.wintypes.WORD),-        ("dwFlags", ctypes.wintypes.DWORD),-        ("time", ctypes.wintypes.DWORD),-        ("dwExtraInfo", ctypes.POINTER(ctypes.c_ulong)),-    ]---class _INPUT_UNION(ctypes.Union):-    _fields_ = [-        ("mi", MOUSEINPUT),-        ("ki", KEYBDINPUT),-    ]---class INPUT(ctypes.Structure):-    _fields_ = [-        ("type", ctypes.wintypes.DWORD),-        ("union", _INPUT_UNION),-    ]---def _send_input(*inputs):-    """Send one or more INPUT structures via SendInput."""-    n = len(inputs)-    arr = (INPUT * n)(*inputs)-    user32.SendInput(n, ctypes.pointer(arr), ctypes.sizeof(INPUT))---# --- Window enumeration -----def _get_window_title(hwnd) -> str:-    length = user32.GetWindowTextLengthW(hwnd)-    if length <= 0:-        return ""-    buf = ctypes.create_unicode_buffer(length + 1)-    user32.GetWindowTextW(hwnd, buf, length + 1)-    return buf.value---def _get_class_name(hwnd) -> str:-    buf = ctypes.create_unicode_buffer(256)-    user32.GetClassNameW(hwnd, buf, 256)-    return buf.value---def _get_window_rect(hwnd) -> dict:-    rect = ctypes.wintypes.RECT()-    user32.GetWindowRect(hwnd, ctypes.byref(rect))-    return {-        "left": rect.left, "top": rect.top,-        "right": rect.right, "bottom": rect.bottom,-        "width": rect.right - rect.left,-        "height": rect.bottom - rect.top,-    }---def _find_all_kicad_windows() -> list:-    """Find all visible KiCad-related windows.--    Returns list of dicts with hwnd, title, className, rect, ownerHwnd.-    """-    results = []-    seen = set()-    WNDENUMPROC = ctypes.WINFUNCTYPE(-        ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM-    )--    def callback(hwnd, _lparam):-        if not user32.IsWindowVisible(hwnd):-            return True-        title = _get_window_title(hwnd)-        class_name = _get_class_name(hwnd)--        # Match KiCad windows by title keywords or wxWindowNR class with owner-        is_kicad = any(kw.lower() in title.lower() for kw in KICAD_TITLE_KEYWORDS)--        # Also catch #32770 (Win32 dialog) windows owned by KiCad-        if not is_kicad and class_name == "#32770":-            owner = user32.GetWindow(hwnd, GW_OWNER)-            if owner and owner not in seen:-                # Check if owner is a KiCad window-                owner_title = _get_window_title(owner)-                is_kicad = any(kw.lower() in owner_title.lower() for kw in KICAD_TITLE_KEYWORDS)--        if is_kicad and hwnd not in seen:-            seen.add(hwnd)-            owner = user32.GetWindow(hwnd, GW_OWNER)-            results.append({-                "hwnd": hwnd,-                "title": title,-                "className": class_name,-                "rect": _get_window_rect(hwnd),-                "ownerHwnd": owner if owner else 0,-            })-        return True--    cb = WNDENUMPROC(callback)-    _callbacks.append(cb)-    user32.EnumWindows(cb, 0)-    _callbacks.remove(cb)-    return results---def _find_foreground_kicad_hwnd() -> int:-    """Find the foreground KiCad window, or the first KiCad window found."""-    fg = user32.GetForegroundWindow()-    if fg:-        title = _get_window_title(fg)-        if any(kw.lower() in title.lower() for kw in KICAD_TITLE_KEYWORDS):-            return fg--    # Fallback: first KiCad window-    windows = _find_all_kicad_windows()-    if windows:-        return windows[0]["hwnd"]-    return 0---# --- Screenshot -----def _write_bmp(path: str, width: int, height: int, pixel_data: bytes):-    """Write a BMP file from raw BGR pixel data (bottom-up)."""-    row_size_24 = (width * 3 + 3) & ~3-    pixel_size_24 = row_size_24 * height-    file_size = 14 + 40 + pixel_size_24--    with open(path, "wb") as f:-        f.write(b"BM")-        f.write(struct.pack("<I", file_size))-        f.write(struct.pack("<HH", 0, 0))-        f.write(struct.pack("<I", 14 + 40))-        f.write(struct.pack("<I", 40))-        f.write(struct.pack("<i", width))-        f.write(struct.pack("<i", height))-        f.write(struct.pack("<HH", 1, 24))-        f.write(struct.pack("<I", BI_RGB))-        f.write(struct.pack("<I", pixel_size_24))-        f.write(struct.pack("<ii", 2835, 2835))-        f.write(struct.pack("<II", 0, 0))--        src_row_size = width * 4-        for y in range(height):-            row_start = y * src_row_size-            row_24 = bytearray()-            for x in range(width):-                px = row_start + x * 4-                row_24 += pixel_data[px:px + 3]-            padding = row_size_24 - len(row_24)-            if padding > 0:-                row_24 += b"\x00" * padding-            f.write(bytes(row_24))---def _screenshot_hwnd(hwnd: int, label: str = "") -> dict:-    """Capture a window by HWND using PrintWindow. Returns {success, savedTo, sizeKB}."""-    rect = _get_window_rect(hwnd)-    width = rect["width"]-    height = rect["height"]--    if width <= 0 or height <= 0:-        return {"success": False, "error": f"Invalid rect for hwnd {hwnd}"}--    hdc_screen = user32.GetDC(hwnd)-    hdc_mem = gdi32.CreateCompatibleDC(hdc_screen)-    hbmp = gdi32.CreateCompatibleBitmap(hdc_screen, width, height)-    old_bmp = gdi32.SelectObject(hdc_mem, hbmp)-    user32.PrintWindow(hwnd, hdc_mem, PW_RENDERFULLCONTENT)--    class BITMAPINFOHEADER(ctypes.Structure):-        _fields_ = [-            ("biSize", ctypes.wintypes.DWORD),-            ("biWidth", ctypes.wintypes.LONG),-            ("biHeight", ctypes.wintypes.LONG),-            ("biPlanes", ctypes.wintypes.WORD),-            ("biBitCount", ctypes.wintypes.WORD),-            ("biCompression", ctypes.wintypes.DWORD),-            ("biSizeImage", ctypes.wintypes.DWORD),-            ("biXPelsPerMeter", ctypes.wintypes.LONG),-            ("biYPelsPerMeter", ctypes.wintypes.LONG),-            ("biClrUsed", ctypes.wintypes.DWORD),-            ("biClrImportant", ctypes.wintypes.DWORD),-        ]--    bmi = BITMAPINFOHEADER()-    bmi.biSize = ctypes.sizeof(BITMAPINFOHEADER)-    bmi.biWidth = width-    bmi.biHeight = height-    bmi.biPlanes = 1-    bmi.biBitCount = 32-    bmi.biCompression = BI_RGB--    buf_size = width * height * 4-    pixel_buf = ctypes.create_string_buffer(buf_size)-    gdi32.GetDIBits(hdc_mem, hbmp, 0, height, pixel_buf, ctypes.byref(bmi), DIB_RGB_COLORS)--    gdi32.SelectObject(hdc_mem, old_bmp)-    gdi32.DeleteObject(hbmp)-    gdi32.DeleteDC(hdc_mem)-    user32.ReleaseDC(hwnd, hdc_screen)--    os.makedirs(SCREENSHOT_DIR, exist_ok=True)-    timestamp = int(time.time() * 1000)-    # Slugify the label so filenames have NO spaces/punctuation (clean for a-    # cloud caller to pull). v0.8.8: "pcb_editor pic_programmer Read Only" -> "pcb-editor-pic-programmer-read-only".-    slug = "".join(c if (c.isalnum() or c in "._-") else "-" for c in (label or "")).strip("-").lower()-    while "--" in slug:-        slug = slug.replace("--", "-")-    suffix = f"-{slug}" if slug else ""--    # v0.8.8: default to PNG (container-side callers want PNG; was lossless WEBP,-    # which forced a host-side pull + convert). WEBP only on explicit fmt="webp".-    try:-        from PIL import Image-        img = Image.frombytes("RGBA", (width, height), pixel_buf.raw, "raw", "BGRA")-        img = img.transpose(Image.FLIP_TOP_BOTTOM)-        MAX_DIM = 1568-        if max(width, height) > MAX_DIM:-            ratio = MAX_DIM / max(width, height)-            img = img.resize((int(width * ratio), int(height * ratio)), Image.LANCZOS)-        path = os.path.join(SCREENSHOT_DIR, f"kicad{suffix}-{timestamp}.png")-        img.save(path, "PNG", optimize=True)-    except ImportError:-        path = os.path.join(SCREENSHOT_DIR, f"kicad{suffix}-{timestamp}.bmp")-        _write_bmp(path, width, height, pixel_buf.raw)--    size_kb = os.path.getsize(path) / 1024-    posix = path.replace("\\", "/")-    return {-        "success": True,-        # `fullPath` is the field the CLI's universal screenshot auto-pull keys on —-        # it copies the host file to the cloud container's /tmp/ad-shots/ and adds-        # localFullPath, so a cloud caller can Read it directly (no pull_file hop).-        # `savedTo` kept for back-compat.-        "savedTo": posix,-        "fullPath": posix,-        "sizeKB": round(size_kb, 1),-    }---# --- Public handler functions -----def handle_screenshot_all(kicad_info: dict, args: dict) -> dict:-    """Screenshot all KiCad windows (main + editors + dialogs).--    Windows: enumerates windows via Win32 and captures each via PrintWindow.-    macOS: not implemented at the plugin level. The Tauri app exposes-    desktop_screenshot_screen / desktop_screenshot_window via CoreGraphics —-    callers should use those instead.-    """-    if IS_MACOS:-        return {-            "success": False,-            "error": "kicad_screenshot_all is not implemented on macOS",-            "_hint": ("Use desktop_screenshot_screen or desktop_screenshot_window "-                      "(via the Tauri app) — those use CoreGraphics and capture "-                      "any visible window including KiCad."),-        }-    if not IS_WINDOWS:-        return {"success": False, "error": f"screenshot_all not implemented on {sys.platform!r}"}--    from handlers.close_windows import handle_window_info--    info = handle_window_info(kicad_info, {})-    data = info.get("data", {})--    # Collect windows in order: editors first, project manager, dialogs last-    windows = []-    for ed in data.get("editors", []):-        windows.append({"hwnd": ed["hwnd"], "title": ed["title"], "type": "editor"})-    pm = data.get("projectManager")-    if pm:-        windows.append({"hwnd": pm["hwnd"], "title": pm["title"], "type": "main"})-    for dlg in data.get("modalDialogs", []):-        windows.append({"hwnd": dlg["hwnd"], "title": dlg["title"], "type": "dialog"})--    if not windows:-        return {"success": False, "error": "No KiCad windows found."}--    screenshots = []-    for w in windows:-        hwnd = w["hwnd"]-        title = w["title"]-        win_type = w["type"]--        # Sanitize title for filename-        safe_title = "".join(c if c.isalnum() or c in "-_ " else "" for c in title)[:40].strip()-        label = f"{win_type}-{safe_title}" if safe_title else win_type--        result = _screenshot_hwnd(hwnd, label)-        entry = {-            "type": win_type,-            "title": title,-            "hwnd": hwnd,-        }-        if result.get("success"):-            entry["savedTo"] = result["savedTo"]-            entry["fullPath"] = result.get("fullPath", result["savedTo"])-            entry["sizeKB"] = result["sizeKB"]-        else:-            entry["error"] = result.get("error", "screenshot failed")--        screenshots.append(entry)--    return {-        "success": True,-        "output": f"Captured {len(screenshots)} KiCad window(s)",-        "data": {"screenshots": screenshots},-    }---# v0.7.1+: modifier VKs for combo parsing in handle_send_key. The four standard-# Windows modifiers; combos like "ctrl+shift+f10" or "alt+3" decompose into a-# list of these wrapping the base key.-_MODIFIER_VK = {-    "ctrl": 0x11, "control": 0x11,-    "alt": 0x12, "menu": 0x12,-    "shift": 0x10,-    "win": 0x5B, "super": 0x5B, "lwin": 0x5B,-}---def _parse_modifier_combo(key: str) -> tuple[list[int], str] | None:-    """Parse a 'ctrl+alt+f10' style combo.--    Returns (modifier_vks, base_key) where base_key is the trailing token after-    the last "+". Modifiers must come BEFORE the base key. Returns None if `key`-    isn't a combo (no "+" or only one token).-    """-    if "+" not in key:-        return None-    tokens = [t.strip().lower() for t in key.split("+") if t.strip()]-    if len(tokens) < 2:-        return None-    *mods, base = tokens-    mod_vks: list[int] = []-    for m in mods:-        vk = _MODIFIER_VK.get(m)-        if vk is None:-            # Not a known modifier — bail and let the caller treat the whole-            # thing as an unknown key.-            return None-        mod_vks.append(vk)-    return mod_vks, base---def handle_send_key(kicad_info: dict, args: dict) -> dict:-    """Send a keystroke to the foreground KiCad window.--    Supports modifier-combo syntax (v0.7.1+): "ctrl+s", "alt+3", "shift+f10",-    "ctrl+shift+s". Modifiers: ctrl/control, alt/menu, shift, win/super/lwin.-    The base key after the last "+" is parsed via the same VK_MAP / single-char-    path as standalone keys.--    Windows-only: macOS UI scripting against another app requires Accessibility-    permission, which we don't request. Ask the user to interact directly.-    """-    if not IS_WINDOWS:-        return {-            "success": False,-            "error": "kicad_send_key is not implemented on macOS",-            "_hint": ("On macOS, ask the user to press the key in the KiCad window "-                      "directly. UI scripting needs Accessibility permission."),-        }--    key = args.get("key", "").strip()-    if not key:-        return {"success": False, "error": "Missing required arg: key"}--    target_hwnd = args.get("hwnd")-    if target_hwnd:-        target_hwnd = int(target_hwnd)-    else:-        target_hwnd = _find_foreground_kicad_hwnd()--    if not target_hwnd:-        return {"success": False, "error": "No KiCad window found to send key to."}--    title = _get_window_title(target_hwnd)--    # Bring to front-    user32.SetForegroundWindow(target_hwnd)-    time.sleep(0.1)--    # v0.7.1+: detect modifier combos first. If "key" parses as one (e.g.-    # "alt+3"), wrap the base key in mod-down/up events. Falls through to the-    # original path if it's a single key or unknown-modifier combo.-    combo = _parse_modifier_combo(key)-    if combo is not None:-        mod_vks, base = combo-        # Resolve the base key to a VK via the same path as a standalone key.-        base_vk = VK_MAP.get(base)-        base_shift = False-        if base_vk is None and len(base) == 1:-            vk_scan = user32.VkKeyScanW(ord(base))-            base_vk = vk_scan & 0xFF-            base_shift = bool((vk_scan >> 8) & 0x01)-            if base_vk == 0xFF:-                return {"success": False, "error": f"Cannot map base character '{base}' to a virtual key."}-        if base_vk is None:-            return {"success": False, "error": f"Unknown base key '{base}' in combo '{key}'."}--        # Build the input sequence: ALL modifiers down, base down + up, modifiers up-        # (reverse order on the way up so the OS sees clean nesting).-        inputs: list[INPUT] = []-        # If the base char itself needs Shift AND Shift wasn't already in the combo,-        # add it. (Caller intent: "shift+s" already has shift; "alt+!" should add shift.)-        if base_shift and 0x10 not in mod_vks:-            mod_vks = mod_vks + [0x10]-        for vk in mod_vks:-            d = INPUT(); d.type = INPUT_KEYBOARD; d.union.ki.wVk = vk-            inputs.append(d)-        kd = INPUT(); kd.type = INPUT_KEYBOARD; kd.union.ki.wVk = base_vk-        inputs.append(kd)-        ku = INPUT(); ku.type = INPUT_KEYBOARD; ku.union.ki.wVk = base_vk-        ku.union.ki.dwFlags = KEYEVENTF_KEYUP-        inputs.append(ku)-        for vk in reversed(mod_vks):-            u = INPUT(); u.type = INPUT_KEYBOARD; u.union.ki.wVk = vk-            u.union.ki.dwFlags = KEYEVENTF_KEYUP-            inputs.append(u)--        _send_input(*inputs)-        return {-            "success": True,-            "output": f"Sent '{key}' to {title}",-            "data": {"key": key, "modifiers": mod_vks, "baseVk": base_vk,-                     "baseShift": base_shift, "hwnd": target_hwnd, "title": title},-        }--    key_lower = key.lower().strip()-    vk = VK_MAP.get(key_lower)--    if vk is None:-        if len(key) == 1:-            vk_scan = user32.VkKeyScanW(ord(key))-            vk = vk_scan & 0xFF-            shift = (vk_scan >> 8) & 0x01-            if vk == 0xFF:-                return {"success": False, "error": f"Cannot map character '{key}' to a virtual key."}--            inputs = []-            if shift:-                s_down = INPUT()-                s_down.type = INPUT_KEYBOARD-                s_down.union.ki.wVk = 0x10-                inputs.append(s_down)--            kd = INPUT()-            kd.type = INPUT_KEYBOARD-            kd.union.ki.wVk = vk-            inputs.append(kd)--            ku = INPUT()-            ku.type = INPUT_KEYBOARD-            ku.union.ki.wVk = vk-            ku.union.ki.dwFlags = KEYEVENTF_KEYUP-            inputs.append(ku)--            if shift:-                s_up = INPUT()-                s_up.type = INPUT_KEYBOARD-                s_up.union.ki.wVk = 0x10-                s_up.union.ki.dwFlags = KEYEVENTF_KEYUP-                inputs.append(s_up)--            _send_input(*inputs)-            return {"success": True,-                    "output": f"Sent '{key}' to {title}",-                    "data": {"key": key, "vk": vk, "shift": bool(shift),-                             "hwnd": target_hwnd, "title": title}}-        else:-            return {"success": False, "error": f"Unknown key: '{key}'. Use a named key (enter/escape/tab/space/f1-f12), a single character, or a modifier combo like 'alt+3', 'ctrl+s', 'ctrl+shift+s' (modifiers: ctrl/control, alt/menu, shift, win/super)."}--    kd = INPUT()-    kd.type = INPUT_KEYBOARD-    kd.union.ki.wVk = vk--    ku = INPUT()-    ku.type = INPUT_KEYBOARD-    ku.union.ki.wVk = vk-    ku.union.ki.dwFlags = KEYEVENTF_KEYUP--    _send_input(kd, ku)-    return {"success": True,-            "output": f"Sent '{key_lower}' to {title}",-            "data": {"key": key_lower, "vk": vk,-                     "hwnd": target_hwnd, "title": title}}---def handle_click(kicad_info: dict, args: dict) -> dict:-    """Click at coordinates within a KiCad window.--    Windows-only: macOS UI scripting against another app requires Accessibility-    permission, which we don't request. Ask the user to click directly.-    """-    if not IS_WINDOWS:-        return {-            "success": False,-            "error": "kicad_click is not implemented on macOS",-            "_hint": ("On macOS, ask the user to click in the KiCad window directly. "-                      "UI scripting needs Accessibility permission."),-        }--    hwnd = args.get("hwnd")-    if not hwnd:-        return {"success": False, "error": "Missing required arg: hwnd"}-    hwnd = int(hwnd)--    x = args.get("x")-    y = args.get("y")-    if x is None or y is None:-        return {"success": False, "error": "Missing required args: x, y"}--    relative = args.get("relative", True)--    title = _get_window_title(hwnd)-    if not title:-        return {"success": False, "error": f"HWND {hwnd} is not a valid window."}--    rect = _get_window_rect(hwnd)-    width = rect["width"]-    height = rect["height"]--    # Bring to front-    user32.SetForegroundWindow(hwnd)-    time.sleep(0.1)--    if relative:-        screen_x = rect["left"] + int(float(x) * width)-        screen_y = rect["top"] + int(float(y) * height)-    else:-        screen_x = rect["left"] + int(x)-        screen_y = rect["top"] + int(y)--    # Convert to absolute coordinates for SendInput (0-65535 range)-    screen_w = user32.GetSystemMetrics(0)-    screen_h = user32.GetSystemMetrics(1)-    abs_x = int(screen_x * 65535 / screen_w)-    abs_y = int(screen_y * 65535 / screen_h)--    move = INPUT()-    move.type = INPUT_MOUSE-    move.union.mi.dx = abs_x-    move.union.mi.dy = abs_y-    move.union.mi.dwFlags = MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE--    down = INPUT()-    down.type = INPUT_MOUSE-    down.union.mi.dx = abs_x-    down.union.mi.dy = abs_y-    down.union.mi.dwFlags = MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE | MOUSEEVENTF_LEFTDOWN--    up = INPUT()-    up.type = INPUT_MOUSE-    up.union.mi.dx = abs_x-    up.union.mi.dy = abs_y-    up.union.mi.dwFlags = MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE | MOUSEEVENTF_LEFTUP--    _send_input(move, down, up)--    return {-        "success": True,-        "output": f"Clicked ({screen_x}, {screen_y}) on {title}",-        "data": {-            "clickedAt": {"screenX": screen_x, "screenY": screen_y},-            "windowOffset": {"x": screen_x - rect["left"], "y": screen_y - rect["top"]},-            "relative": relative,-            "hwnd": hwnd,-            "title": title,-        },-    }+"""Win32-based tools for interacting with KiCad's UI.
+
+Windows: provides screenshot, click, and keyboard input against KiCad windows
+(project manager, eeschema, pcbnew, footprint editor, symbol editor, 3D viewer,
+and their dialogs). Mirrors the Fusion 360 UI handler at
+plugins/fusion360/handlers/fusion_ui.py.
+
+macOS: handlers return "not implemented" responses. Faithful UI scripting
+against another app on macOS requires Accessibility-framework permission,
+which we don't request. The screenshot path is handled by the Tauri app's
+CoreGraphics implementation in src-tauri/src/screenshot/macos.rs instead.
+"""
+
+import ctypes
+import ctypes.wintypes
+import os
+import struct
+import sys
+import time
+
+IS_WINDOWS = sys.platform == "win32"
+IS_MACOS = sys.platform == "darwin"
+
+# ctypes.Structure / Union / WINFUNCTYPE are cross-platform; only ctypes.windll
+# is Windows-only. So the module-level INPUT/MOUSEINPUT/etc. structs below
+# load fine on macOS — they just never get used because handlers branch
+# at the entry point.
+if IS_WINDOWS:
+    user32 = ctypes.windll.user32
+    gdi32 = ctypes.windll.gdi32
+
+    # Enable DPI awareness so GetWindowRect returns physical pixel coordinates
+    try:
+        ctypes.windll.shcore.SetProcessDpiAwareness(2)
+    except Exception:
+        try:
+            user32.SetProcessDPIAware()
+        except Exception:
+            pass
+else:
+    # Sentinels so module-level expressions referencing user32/gdi32 don't
+    # NameError. Helper functions that touch these are guarded by IS_WINDOWS
+    # at call time via the handler entry points.
+    user32 = None
+    gdi32 = None
+
+# --- Constants ---
+
+BI_RGB = 0
+DIB_RGB_COLORS = 0
+GW_OWNER = 4
+PW_RENDERFULLCONTENT = 0x00000002
+
+INPUT_MOUSE = 0
+INPUT_KEYBOARD = 1
+MOUSEEVENTF_ABSOLUTE = 0x8000
+MOUSEEVENTF_MOVE = 0x0001
+MOUSEEVENTF_LEFTDOWN = 0x0002
+MOUSEEVENTF_LEFTUP = 0x0004
+KEYEVENTF_KEYUP = 0x0002
+
+VK_MAP = {
+    "enter": 0x0D, "return": 0x0D,
+    "escape": 0x1B, "esc": 0x1B,
+    "tab": 0x09,
+    "space": 0x20,
+    "up": 0x26, "down": 0x28, "left": 0x25, "right": 0x27,
+    "backspace": 0x08, "delete": 0x2E,
+    "home": 0x24, "end": 0x23,
+    "pageup": 0x21, "pagedown": 0x22,
+    "f1": 0x70, "f2": 0x71, "f3": 0x72, "f4": 0x73,
+    "f5": 0x74, "f6": 0x75, "f7": 0x76, "f8": 0x77,
+    "f9": 0x78, "f10": 0x79, "f11": 0x7A, "f12": 0x7B,
+}
+
+SCREENSHOT_DIR = "C:/tmp/adom-desktop-screenshots"
+
+# KiCad window title keywords (wxWidgets windows use class wxWindowNR)
+KICAD_TITLE_KEYWORDS = [
+    "KiCad", "Symbol Editor", "Footprint Editor", "3D Viewer",
+    "eeschema", "pcbnew", "Schematic Editor", "PCB Editor",
+]
+
+# Keep callback references alive to prevent GC
+_callbacks = []
+
+
+# --- SendInput structures ---
+
+class MOUSEINPUT(ctypes.Structure):
+    _fields_ = [
+        ("dx", ctypes.wintypes.LONG),
+        ("dy", ctypes.wintypes.LONG),
+        ("mouseData", ctypes.wintypes.DWORD),
+        ("dwFlags", ctypes.wintypes.DWORD),
+        ("time", ctypes.wintypes.DWORD),
+        ("dwExtraInfo", ctypes.POINTER(ctypes.c_ulong)),
+    ]
+
+
+class KEYBDINPUT(ctypes.Structure):
+    _fields_ = [
+        ("wVk", ctypes.wintypes.WORD),
+        ("wScan", ctypes.wintypes.WORD),
+        ("dwFlags", ctypes.wintypes.DWORD),
+        ("time", ctypes.wintypes.DWORD),
+        ("dwExtraInfo", ctypes.POINTER(ctypes.c_ulong)),
+    ]
+
+
+class _INPUT_UNION(ctypes.Union):
+    _fields_ = [
+        ("mi", MOUSEINPUT),
+        ("ki", KEYBDINPUT),
+    ]
+
+
+class INPUT(ctypes.Structure):
+    _fields_ = [
+        ("type", ctypes.wintypes.DWORD),
+        ("union", _INPUT_UNION),
+    ]
+
+
+def _send_input(*inputs):
+    """Send one or more INPUT structures via SendInput."""
+    n = len(inputs)
+    arr = (INPUT * n)(*inputs)
+    user32.SendInput(n, ctypes.pointer(arr), ctypes.sizeof(INPUT))
+
+
+# --- Window enumeration ---
+
+def _get_window_title(hwnd) -> str:
+    length = user32.GetWindowTextLengthW(hwnd)
+    if length <= 0:
+        return ""
+    buf = ctypes.create_unicode_buffer(length + 1)
+    user32.GetWindowTextW(hwnd, buf, length + 1)
+    return buf.value
+
+
+def _get_class_name(hwnd) -> str:
+    buf = ctypes.create_unicode_buffer(256)
+    user32.GetClassNameW(hwnd, buf, 256)
+    return buf.value
+
+
+def _get_window_rect(hwnd) -> dict:
+    rect = ctypes.wintypes.RECT()
+    user32.GetWindowRect(hwnd, ctypes.byref(rect))
+    return {
+        "left": rect.left, "top": rect.top,
+        "right": rect.right, "bottom": rect.bottom,
+        "width": rect.right - rect.left,
+        "height": rect.bottom - rect.top,
+    }
+
+
+def _find_all_kicad_windows() -> list:
+    """Find all visible KiCad-related windows.
+
+    Returns list of dicts with hwnd, title, className, rect, ownerHwnd.
+    """
+    results = []
+    seen = set()
+    WNDENUMPROC = ctypes.WINFUNCTYPE(
+        ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM
+    )
+
+    def callback(hwnd, _lparam):
+        if not user32.IsWindowVisible(hwnd):
+            return True
+        title = _get_window_title(hwnd)
+        class_name = _get_class_name(hwnd)
+
+        # Match KiCad windows by title keywords or wxWindowNR class with owner
+        is_kicad = any(kw.lower() in title.lower() for kw in KICAD_TITLE_KEYWORDS)
+
+        # Also catch #32770 (Win32 dialog) windows owned by KiCad
+        if not is_kicad and class_name == "#32770":
+            owner = user32.GetWindow(hwnd, GW_OWNER)
+            if owner and owner not in seen:
+                # Check if owner is a KiCad window
+                owner_title = _get_window_title(owner)
+                is_kicad = any(kw.lower() in owner_title.lower() for kw in KICAD_TITLE_KEYWORDS)
+
+        if is_kicad and hwnd not in seen:
+            seen.add(hwnd)
+            owner = user32.GetWindow(hwnd, GW_OWNER)
+            results.append({
+                "hwnd": hwnd,
+                "title": title,
+                "className": class_name,
+                "rect": _get_window_rect(hwnd),
+                "ownerHwnd": owner if owner else 0,
+            })
+        return True
+
+    cb = WNDENUMPROC(callback)
+    _callbacks.append(cb)
+    user32.EnumWindows(cb, 0)
+    _callbacks.remove(cb)
+    return results
+
+
+def _find_foreground_kicad_hwnd() -> int:
+    """Find the foreground KiCad window, or the first KiCad window found."""
+    fg = user32.GetForegroundWindow()
+    if fg:
+        title = _get_window_title(fg)
+        if any(kw.lower() in title.lower() for kw in KICAD_TITLE_KEYWORDS):
+            return fg
+
+    # Fallback: first KiCad window
+    windows = _find_all_kicad_windows()
+    if windows:
+        return windows[0]["hwnd"]
+    return 0
+
+
+# --- Screenshot ---
+
+def _write_bmp(path: str, width: int, height: int, pixel_data: bytes):
+    """Write a BMP file from raw BGR pixel data (bottom-up)."""
+    row_size_24 = (width * 3 + 3) & ~3
+    pixel_size_24 = row_size_24 * height
+    file_size = 14 + 40 + pixel_size_24
+
+    with open(path, "wb") as f:
+        f.write(b"BM")
+        f.write(struct.pack("<I", file_size))
+        f.write(struct.pack("<HH", 0, 0))
+        f.write(struct.pack("<I", 14 + 40))
+        f.write(struct.pack("<I", 40))
+        f.write(struct.pack("<i", width))
+        f.write(struct.pack("<i", height))
+        f.write(struct.pack("<HH", 1, 24))
+        f.write(struct.pack("<I", BI_RGB))
+        f.write(struct.pack("<I", pixel_size_24))
+        f.write(struct.pack("<ii", 2835, 2835))
+        f.write(struct.pack("<II", 0, 0))
+
+        src_row_size = width * 4
+        for y in range(height):
+            row_start = y * src_row_size
+            row_24 = bytearray()
+            for x in range(width):
+                px = row_start + x * 4
+                row_24 += pixel_data[px:px + 3]
+            padding = row_size_24 - len(row_24)
+            if padding > 0:
+                row_24 += b"\x00" * padding
+            f.write(bytes(row_24))
+
+
+def _screenshot_hwnd(hwnd: int, label: str = "") -> dict:
+    """Capture a window by HWND using PrintWindow. Returns {success, savedTo, sizeKB}."""
+    rect = _get_window_rect(hwnd)
+    width = rect["width"]
+    height = rect["height"]
+
+    if width <= 0 or height <= 0:
+        return {"success": False, "error": f"Invalid rect for hwnd {hwnd}"}
+
+    hdc_screen = user32.GetDC(hwnd)
+    hdc_mem = gdi32.CreateCompatibleDC(hdc_screen)
+    hbmp = gdi32.CreateCompatibleBitmap(hdc_screen, width, height)
+    old_bmp = gdi32.SelectObject(hdc_mem, hbmp)
+    user32.PrintWindow(hwnd, hdc_mem, PW_RENDERFULLCONTENT)
+
+    class BITMAPINFOHEADER(ctypes.Structure):
+        _fields_ = [
+            ("biSize", ctypes.wintypes.DWORD),
+            ("biWidth", ctypes.wintypes.LONG),
+            ("biHeight", ctypes.wintypes.LONG),
+            ("biPlanes", ctypes.wintypes.WORD),
+            ("biBitCount", ctypes.wintypes.WORD),
+            ("biCompression", ctypes.wintypes.DWORD),
+            ("biSizeImage", ctypes.wintypes.DWORD),
+            ("biXPelsPerMeter", ctypes.wintypes.LONG),
+            ("biYPelsPerMeter", ctypes.wintypes.LONG),
+            ("biClrUsed", ctypes.wintypes.DWORD),
+            ("biClrImportant", ctypes.wintypes.DWORD),
+        ]
+
+    bmi = BITMAPINFOHEADER()
+    bmi.biSize = ctypes.sizeof(BITMAPINFOHEADER)
+    bmi.biWidth = width
+    bmi.biHeight = height
+    bmi.biPlanes = 1
+    bmi.biBitCount = 32
+    bmi.biCompression = BI_RGB
+
+    buf_size = width * height * 4
+    pixel_buf = ctypes.create_string_buffer(buf_size)
+    gdi32.GetDIBits(hdc_mem, hbmp, 0, height, pixel_buf, ctypes.byref(bmi), DIB_RGB_COLORS)
+
+    gdi32.SelectObject(hdc_mem, old_bmp)
+    gdi32.DeleteObject(hbmp)
+    gdi32.DeleteDC(hdc_mem)
+    user32.ReleaseDC(hwnd, hdc_screen)
+
+    os.makedirs(SCREENSHOT_DIR, exist_ok=True)
+    timestamp = int(time.time() * 1000)
+    # Slugify the label so filenames have NO spaces/punctuation (clean for a
+    # cloud caller to pull). v0.8.8: "pcb_editor pic_programmer Read Only" -> "pcb-editor-pic-programmer-read-only".
+    slug = "".join(c if (c.isalnum() or c in "._-") else "-" for c in (label or "")).strip("-").lower()
+    while "--" in slug:
+        slug = slug.replace("--", "-")
+    suffix = f"-{slug}" if slug else ""
+
+    # v0.8.8: default to PNG (container-side callers want PNG; was lossless WEBP,
+    # which forced a host-side pull + convert). WEBP only on explicit fmt="webp".
+    try:
+        from PIL import Image
+        img = Image.frombytes("RGBA", (width, height), pixel_buf.raw, "raw", "BGRA")
+        img = img.transpose(Image.FLIP_TOP_BOTTOM)
+        MAX_DIM = 1568
+        if max(width, height) > MAX_DIM:
+            ratio = MAX_DIM / max(width, height)
+            img = img.resize((int(width * ratio), int(height * ratio)), Image.LANCZOS)
+        path = os.path.join(SCREENSHOT_DIR, f"kicad{suffix}-{timestamp}.png")
+        img.save(path, "PNG", optimize=True)
+    except ImportError:
+        path = os.path.join(SCREENSHOT_DIR, f"kicad{suffix}-{timestamp}.bmp")
+        _write_bmp(path, width, height, pixel_buf.raw)
+
+    size_kb = os.path.getsize(path) / 1024
+    posix = path.replace("\\", "/")
+    return {
+        "success": True,
+        # `fullPath` is the field the CLI's universal screenshot auto-pull keys on —
+        # it copies the host file to the cloud container's /tmp/ad-shots/ and adds
+        # localFullPath, so a cloud caller can Read it directly (no pull_file hop).
+        # `savedTo` kept for back-compat.
+        "savedTo": posix,
+        "fullPath": posix,
+        "sizeKB": round(size_kb, 1),
+    }
+
+
+# --- Public handler functions ---
+
+def handle_screenshot_all(kicad_info: dict, args: dict) -> dict:
+    """Screenshot all KiCad windows (main + editors + dialogs).
+
+    Windows: enumerates windows via Win32 and captures each via PrintWindow.
+    macOS: not implemented at the plugin level. The Tauri app exposes
+    desktop_screenshot_screen / desktop_screenshot_window via CoreGraphics —
+    callers should use those instead.
+    """
+    if IS_MACOS:
+        return {
+            "success": False,
+            "error": "kicad_screenshot_all is not implemented on macOS",
+            "_hint": ("Use desktop_screenshot_screen or desktop_screenshot_window "
+                      "(via the Tauri app) — those use CoreGraphics and capture "
+                      "any visible window including KiCad."),
+        }
+    if not IS_WINDOWS:
+        return {"success": False, "error": f"screenshot_all not implemented on {sys.platform!r}"}
+
+    from handlers.close_windows import handle_window_info
+
+    info = handle_window_info(kicad_info, {})
+    data = info.get("data", {})
+
+    # Collect windows in order: editors first, project manager, dialogs last
+    windows = []
+    for ed in data.get("editors", []):
+        windows.append({"hwnd": ed["hwnd"], "title": ed["title"], "type": "editor"})
+    pm = data.get("projectManager")
+    if pm:
+        windows.append({"hwnd": pm["hwnd"], "title": pm["title"], "type": "main"})
+    for dlg in data.get("modalDialogs", []):
+        windows.append({"hwnd": dlg["hwnd"], "title": dlg["title"], "type": "dialog"})
+
+    if not windows:
+        return {"success": False, "error": "No KiCad windows found."}
+
+    screenshots = []
+    for w in windows:
+        hwnd = w["hwnd"]
+        title = w["title"]
+        win_type = w["type"]
+
+        # Sanitize title for filename
+        safe_title = "".join(c if c.isalnum() or c in "-_ " else "" for c in title)[:40].strip()
+        label = f"{win_type}-{safe_title}" if safe_title else win_type
+
+        result = _screenshot_hwnd(hwnd, label)
+        entry = {
+            "type": win_type,
+            "title": title,
+            "hwnd": hwnd,
+        }
+        if result.get("success"):
+            entry["savedTo"] = result["savedTo"]
+            entry["fullPath"] = result.get("fullPath", result["savedTo"])
+            entry["sizeKB"] = result["sizeKB"]
+        else:
+            entry["error"] = result.get("error", "screenshot failed")
+
+        screenshots.append(entry)
+
+    return {
+        "success": True,
+        "output": f"Captured {len(screenshots)} KiCad window(s)",
+        "data": {"screenshots": screenshots},
+    }
+
+
+# v0.7.1+: modifier VKs for combo parsing in handle_send_key. The four standard
+# Windows modifiers; combos like "ctrl+shift+f10" or "alt+3" decompose into a
+# list of these wrapping the base key.
+_MODIFIER_VK = {
+    "ctrl": 0x11, "control": 0x11,
+    "alt": 0x12, "menu": 0x12,
+    "shift": 0x10,
+    "win": 0x5B, "super": 0x5B, "lwin": 0x5B,
+}
+
+
+def _parse_modifier_combo(key: str) -> tuple[list[int], str] | None:
+    """Parse a 'ctrl+alt+f10' style combo.
+
+    Returns (modifier_vks, base_key) where base_key is the trailing token after
+    the last "+". Modifiers must come BEFORE the base key. Returns None if `key`
+    isn't a combo (no "+" or only one token).
+    """
+    if "+" not in key:
+        return None
+    tokens = [t.strip().lower() for t in key.split("+") if t.strip()]
+    if len(tokens) < 2:
+        return None
+    *mods, base = tokens
+    mod_vks: list[int] = []
+    for m in mods:
+        vk = _MODIFIER_VK.get(m)
+        if vk is None:
+            # Not a known modifier — bail and let the caller treat the whole
+            # thing as an unknown key.
+            return None
+        mod_vks.append(vk)
+    return mod_vks, base
+
+
+def handle_send_key(kicad_info: dict, args: dict) -> dict:
+    """Send a keystroke to the foreground KiCad window.
+
+    Supports modifier-combo syntax (v0.7.1+): "ctrl+s", "alt+3", "shift+f10",
+    "ctrl+shift+s". Modifiers: ctrl/control, alt/menu, shift, win/super/lwin.
+    The base key after the last "+" is parsed via the same VK_MAP / single-char
+    path as standalone keys.
+
+    Windows-only: macOS UI scripting against another app requires Accessibility
+    permission, which we don't request. Ask the user to interact directly.
+    """
+    if not IS_WINDOWS:
+        return {
+            "success": False,
+            "error": "kicad_send_key is not implemented on macOS",
+            "_hint": ("On macOS, ask the user to press the key in the KiCad window "
+                      "directly. UI scripting needs Accessibility permission."),
+        }
+
+    key = args.get("key", "").strip()
+    if not key:
+        return {"success": False, "error": "Missing required arg: key"}
+
+    target_hwnd = args.get("hwnd")
+    if target_hwnd:
+        target_hwnd = int(target_hwnd)
+    else:
+        target_hwnd = _find_foreground_kicad_hwnd()
+
+    if not target_hwnd:
+        return {"success": False, "error": "No KiCad window found to send key to."}
+
+    title = _get_window_title(target_hwnd)
+
+    # Bring to front
+    user32.SetForegroundWindow(target_hwnd)
+    time.sleep(0.1)
+
+    # v0.7.1+: detect modifier combos first. If "key" parses as one (e.g.
+    # "alt+3"), wrap the base key in mod-down/up events. Falls through to the
+    # original path if it's a single key or unknown-modifier combo.
+    combo = _parse_modifier_combo(key)
+    if combo is not None:
+        mod_vks, base = combo
+        # Resolve the base key to a VK via the same path as a standalone key.
+        base_vk = VK_MAP.get(base)
+        base_shift = False
+        if base_vk is None and len(base) == 1:
+            vk_scan = user32.VkKeyScanW(ord(base))
+            base_vk = vk_scan & 0xFF
+            base_shift = bool((vk_scan >> 8) & 0x01)
+            if base_vk == 0xFF:
+                return {"success": False, "error": f"Cannot map base character '{base}' to a virtual key."}
+        if base_vk is None:
+            return {"success": False, "error": f"Unknown base key '{base}' in combo '{key}'."}
+
+        # Build the input sequence: ALL modifiers down, base down + up, modifiers up
+        # (reverse order on the way up so the OS sees clean nesting).
+        inputs: list[INPUT] = []
+        # If the base char itself needs Shift AND Shift wasn't already in the combo,
+        # add it. (Caller intent: "shift+s" already has shift; "alt+!" should add shift.)
+        if base_shift and 0x10 not in mod_vks:
+            mod_vks = mod_vks + [0x10]
+        for vk in mod_vks:
+            d = INPUT(); d.type = INPUT_KEYBOARD; d.union.ki.wVk = vk
+            inputs.append(d)
+        kd = INPUT(); kd.type = INPUT_KEYBOARD; kd.union.ki.wVk = base_vk
+        inputs.append(kd)
+        ku = INPUT(); ku.type = INPUT_KEYBOARD; ku.union.ki.wVk = base_vk
+        ku.union.ki.dwFlags = KEYEVENTF_KEYUP
+        inputs.append(ku)
+        for vk in reversed(mod_vks):
+            u = INPUT(); u.type = INPUT_KEYBOARD; u.union.ki.wVk = vk
+            u.union.ki.dwFlags = KEYEVENTF_KEYUP
+            inputs.append(u)
+
+        _send_input(*inputs)
+        return {
+            "success": True,
+            "output": f"Sent '{key}' to {title}",
+            "data": {"key": key, "modifiers": mod_vks, "baseVk": base_vk,
+                     "baseShift": base_shift, "hwnd": target_hwnd, "title": title},
+            "_hint": ("Sent (back-compat chord path). For modifier chords prefer AD's "
+                      "desktop_press_key — it owns full chord parsing and AD's focus-first "
+                      "delivery. Keep kicad_send_key for single keys to an EXACT hwnd (e.g. "
+                      "Enter/Escape to a specific dialog)."),
+        }
+
+    key_lower = key.lower().strip()
+    vk = VK_MAP.get(key_lower)
+
+    if vk is None:
+        if len(key) == 1:
+            vk_scan = user32.VkKeyScanW(ord(key))
+            vk = vk_scan & 0xFF
+            shift = (vk_scan >> 8) & 0x01
+            if vk == 0xFF:
+                return {"success": False, "error": f"Cannot map character '{key}' to a virtual key."}
+
+            inputs = []
+            if shift:
+                s_down = INPUT()
+                s_down.type = INPUT_KEYBOARD
+                s_down.union.ki.wVk = 0x10
+                inputs.append(s_down)
+
+            kd = INPUT()
+            kd.type = INPUT_KEYBOARD
+            kd.union.ki.wVk = vk
+            inputs.append(kd)
+
+            ku = INPUT()
+            ku.type = INPUT_KEYBOARD
+            ku.union.ki.wVk = vk
+            ku.union.ki.dwFlags = KEYEVENTF_KEYUP
+            inputs.append(ku)
+
+            if shift:
+                s_up = INPUT()
+                s_up.type = INPUT_KEYBOARD
+                s_up.union.ki.wVk = 0x10
+                s_up.union.ki.dwFlags = KEYEVENTF_KEYUP
+                inputs.append(s_up)
+
+            _send_input(*inputs)
+            return {"success": True,
+                    "output": f"Sent '{key}' to {title}",
+                    "data": {"key": key, "vk": vk, "shift": bool(shift),
+                             "hwnd": target_hwnd, "title": title}}
+        else:
+            return {"success": False, "error": f"Unknown key: '{key}'. Use a named key (enter/escape/tab/space/f1-f12), a single character, or a modifier combo like 'alt+3', 'ctrl+s', 'ctrl+shift+s' (modifiers: ctrl/control, alt/menu, shift, win/super)."}
+
+    kd = INPUT()
+    kd.type = INPUT_KEYBOARD
+    kd.union.ki.wVk = vk
+
+    ku = INPUT()
+    ku.type = INPUT_KEYBOARD
+    ku.union.ki.wVk = vk
+    ku.union.ki.dwFlags = KEYEVENTF_KEYUP
+
+    _send_input(kd, ku)
+    return {"success": True,
+            "output": f"Sent '{key_lower}' to {title}",
+            "data": {"key": key_lower, "vk": vk,
+                     "hwnd": target_hwnd, "title": title}}
+
+
+def handle_click(kicad_info: dict, args: dict) -> dict:
+    """Click at coordinates within a KiCad window.
+
+    Windows-only: macOS UI scripting against another app requires Accessibility
+    permission, which we don't request. Ask the user to click directly.
+    """
+    if not IS_WINDOWS:
+        return {
+            "success": False,
+            "error": "kicad_click is not implemented on macOS",
+            "_hint": ("On macOS, ask the user to click in the KiCad window directly. "
+                      "UI scripting needs Accessibility permission."),
+        }
+
+    hwnd = args.get("hwnd")
+    if not hwnd:
+        return {"success": False, "error": "Missing required arg: hwnd"}
+    hwnd = int(hwnd)
+
+    x = args.get("x")
+    y = args.get("y")
+    if x is None or y is None:
+        return {"success": False, "error": "Missing required args: x, y"}
+
+    relative = args.get("relative", True)
+
+    # Footgun guard: relative coords are FRACTIONS of the window (0.0–1.0). If a caller
+    # leaves relative=True (the default) but passes pixel values (e.g. x=676), the math
+    # below lands the click FAR off-screen — which at best misses and at worst hits a
+    # random window's close/controls and can take KiCad down. Refuse clearly instead.
+    if relative:
+        try:
+            if float(x) > 1.0 or float(y) > 1.0 or float(x) < 0 or float(y) < 0:
+                return {"success": False,
+                        "error": f"relative=True expects x/y as fractions 0.0–1.0 of the window, "
+                                 f"but got x={x}, y={y}. Pass fractions, or set relative=false to use pixels.",
+                        "_hint": "e.g. center = {\"x\":0.5,\"y\":0.5}; toolbar button ≈ {\"x\":0.35,\"y\":0.09}. "
+                                 "For exact pixel coords add \"relative\": false."}
+        except (TypeError, ValueError):
+            return {"success": False, "error": f"x/y must be numbers, got x={x!r}, y={y!r}"}
+
+    title = _get_window_title(hwnd)
+    if not title:
+        return {"success": False, "error": f"HWND {hwnd} is not a valid window."}
+
+    rect = _get_window_rect(hwnd)
+    width = rect["width"]
+    height = rect["height"]
+
+    # Bring to front
+    user32.SetForegroundWindow(hwnd)
+    time.sleep(0.1)
+
+    if relative:
+        screen_x = rect["left"] + int(float(x) * width)
+        screen_y = rect["top"] + int(float(y) * height)
+    else:
+        screen_x = rect["left"] + int(x)
+        screen_y = rect["top"] + int(y)
+
+    # Convert to absolute coordinates for SendInput (0-65535 range)
+    screen_w = user32.GetSystemMetrics(0)
+    screen_h = user32.GetSystemMetrics(1)
+    abs_x = int(screen_x * 65535 / screen_w)
+    abs_y = int(screen_y * 65535 / screen_h)
+
+    move = INPUT()
+    move.type = INPUT_MOUSE
+    move.union.mi.dx = abs_x
+    move.union.mi.dy = abs_y
+    move.union.mi.dwFlags = MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE
+
+    down = INPUT()
+    down.type = INPUT_MOUSE
+    down.union.mi.dx = abs_x
+    down.union.mi.dy = abs_y
+    down.union.mi.dwFlags = MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE | MOUSEEVENTF_LEFTDOWN
+
+    up = INPUT()
+    up.type = INPUT_MOUSE
+    up.union.mi.dx = abs_x
+    up.union.mi.dy = abs_y
+    up.union.mi.dwFlags = MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE | MOUSEEVENTF_LEFTUP
+
+    _send_input(move, down, up)
+
+    return {
+        "success": True,
+        "output": f"Clicked ({screen_x}, {screen_y}) on {title}",
+        "data": {
+            "clickedAt": {"screenX": screen_x, "screenY": screen_y},
+            "windowOffset": {"x": screen_x - rect["left"], "y": screen_y - rect["top"]},
+            "relative": relative,
+            "hwnd": hwnd,
+            "title": title,
+        },
+    }
handlers/open_footprint_editor.py+49−29
@@ -685,43 +685,63 @@ 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-        # 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)+        fp_pathways: list[str] = []+        # BACKGROUND path (preferred): in KiCad 10 the Footprint Editor is a frame+        # INSIDE the pcbnew process, so the reverse-bridge plugin can drive it. Find+        # that frame's index (list_frames) and post the live-resolved "3D Viewer"+        # menu id to it (wm_command honors frame_index). No foreground, no keystroke.+        # (Replaces the old Alt+3 path, which needed focus we deliberately don't take+        # — and referenced VK_MENU as a bare global, raising UnboundLocalError because+        # the pcbnew branch below assigns VK_MENU, making it function-local.)         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_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)+            from handlers.bridge_client import (resolve_frame_index_via_bridge,+                                                resolve_menu_id_via_bridge,+                                                try_wm_command_via_bridge)+            fp_fi = resolve_frame_index_via_bridge("pcbnew", "Footprint Editor")+            if fp_fi is not None:+                mid = resolve_menu_id_via_bridge("pcbnew", "3D Viewer", frame_index=fp_fi) or 20568+                fp_pathways.append(f"plugin(frame={fp_fi},menuid={mid})")+                r = try_wm_command_via_bridge(exe_name="pcbnew", menu_id=mid, frame_index=fp_fi)+                if r.get("success"):+                    time.sleep(3.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": "plugin",+                            "pathwaysTried": fp_pathways,+                        }+            else:+                fp_pathways.append("plugin-no-fp-frame")+        except Exception as e:  # pylint: disable=broad-except+            fp_pathways.append(f"plugin-error:{type(e).__name__}")++        # Opt-in disruptive fallback: Alt+3 keystroke (needs the FP window foregrounded,+        # so it steals focus) — only when the caller explicitly allows it.+        if args.get("allowFocusSteal"):+            VK_MENU_LOCAL = 0x12+            VK_3 = 0x33+            user32.SetForegroundWindow(fp_hwnd)+            time.sleep(0.2)+            try:+                user32.keybd_event(VK_MENU_LOCAL, 0, 0, 0)+                user32.keybd_event(VK_3, 0, 0, 0)+                user32.keybd_event(VK_3, 0, KEYEVENTF_KEYUP, 0)+            finally:+                user32.keybd_event(VK_MENU_LOCAL, 0, KEYEVENTF_KEYUP, 0)+            fp_pathways.append("alt3_focussteal")+            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": f"fp_wm_command_{cmd_id}",+                    "pathway": "fp_alt3",+                    "pathwaysTried": fp_pathways,                 } -        # FP path failed silently (WM_COMMAND posted but no 3D Viewer appeared).+        # FP path failed (plugin frame absent AND no focus-steal fallback).         # If caller asked for 'auto' AND a PCB editor is also open, fall through         # to PCB attempt rather than dead-ending here.         if editor_choice == "auto" and pcb_hwnd is None:
handlers/place_footprint.py+16−1
@@ -48,8 +48,16 @@ def handle_place_footprint(kicad_info: dict, args: dict) -> dict:         fp_text = base64.b64decode(file_content).decode("utf-8", "replace").strip()     except Exception as e:         return {"success": False, "error": f"could not decode fileContent: {e}"}+    # Accept legacy `(module ...)` footprints (KiCad ≤5 / EAGLE-imported / many+    # chip-fetcher parts still ship these). KiCad auto-migrates them on load, but the+    # board *file* parser wants the modern `(footprint ...)` keyword, so normalize:+    # rename the wrapper and quote the top-level (layer X) token the splice relies on.+    if fp_text.startswith("(module"):+        fp_text = re.sub(r'^\(module\b', '(footprint', fp_text, count=1)+        # quote an unquoted top-level layer name: (layer F.Cu) -> (layer "F.Cu")+        fp_text = re.sub(r'\(layer\s+([A-Za-z][\w.]*)\s*\)', r'(layer "\1")', fp_text, count=1)     if not fp_text.startswith("(footprint"):-        return {"success": False, "error": "fileContent is not a .kicad_mod (must start with '(footprint')"}+        return {"success": False, "error": "fileContent is not a .kicad_mod (must start with '(footprint' or '(module')"}      fp_name = args.get("footprintName") or ""     if not fp_name:@@ -91,9 +99,16 @@ def handle_place_footprint(kicad_info: dict, args: dict) -> dict:                 # which KILLS the child on timeout. Popen fire-and-forget, DETACHED_PROCESS                 # so pcbnew outlives the bridge and inherits no console handle.                 flags = 0x00000008 if sys.platform == "win32" else 0  # DETACHED_PROCESS+                _si = None+                try:+                    from handlers import win_focus+                    _si = win_focus.background_startupinfo()  # SW_SHOWNOACTIVATE — open behind the user+                except Exception:+                    _si = None                 subprocess.Popen(                     [pcbnew_exe, str(out_path)],                     creationflags=flags,+                    startupinfo=_si,                     stdin=subprocess.DEVNULL,                     stdout=subprocess.DEVNULL,                     stderr=subprocess.DEVNULL,