app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
← Commit history
KiCad on Linux: X11/XWayland window layer, AT-SPI menus and dialogs, local screenshots, background launch, KiCad in its own scope, setup-wizard tables, ngspice on Linux; docs/linux.md; battery OS-aware
29 files changed
+2491−65
README.md+2−1RELEASE_NOTES.md+29SKILL.md+7−3bridge.json+7−3docs/comparison.md+3−3docs/linux.md+87rust/Cargo.lock+659−2rust/README.md+1−1rust/crates/kicad-bridge/src/main.rs+1rust/crates/kicad-bridge/src/verbs_maint.rs+10−4rust/crates/kicad-bridge/src/verbs_sim.rs+9−4rust/crates/kicad-bridge/src/verbs_std_more.rs+7rust/crates/kicad-bridge/src/verbs_windows.rs+119−3rust/crates/kicad-core/src/install.rs+42rust/crates/kicad-core/src/pcb.rs+12−1rust/crates/kicad-core/src/sch_render.rs+23rust/crates/kicad-core/src/spice.rs+8−1rust/crates/kicad-platform/Cargo.toml+5−1rust/crates/kicad-platform/examples/linux_probe.rs+17rust/crates/kicad-platform/src/lib.rs+16rust/crates/kicad-platform/src/linux.rs+169−11rust/crates/kicad-platform/src/linux/atspi.rs+493rust/crates/kicad-platform/src/linux/x11.rs+637rust/crates/kicad-platform/src/macos.rs+1rust/crates/kicad-platform/src/ngspice.rs+73−8rust/crates/kicad-platform/src/windows.rs+1skills/kicad-bridge-contributing/SKILL.md+2−1skills/kicad-bridge-test/run_verb_tests.py+50−17tools/build_native_release.py+1−1README.md+2−1@@ -48,6 +48,7 @@ How the dock card, the dashboard and this demo fit together: [docs/dashboard.md] | guide | what it covers | |---|---|+| [KiCad on Linux](docs/linux.md) | The same bridge on Ubuntu, Debian, Fedora and other distributions, Wayland or X11: what you need, how windows, screenshots, menus and dialogs work there, what differs from Windows, and which verbs are Linux-ready. | | [Dashboard](docs/dashboard.md) | The dock card, the dashboard and how the demo fits in. | | [How the bridge compares](docs/comparison.md) | Feature matrix against every notable KiCad MCP server and AI tool, with the gaps on both sides. | | [The Rust port plan](docs/rust-port-plan.md) | One dependency-free binary: what moves to KiCad's API, what ab already does, what PowerShell goes away, phases and risks. |@@ -651,7 +652,7 @@ Bridge provisions everything; there is nothing to install by hand. - **Python** ≥ 3.11 (Bridge provisions it), stdlib only, plus the bundled `certs/cacert.pem`. - **KiCad** ≥ 7.0 (host app; `kicad_upgrade` installs it if absent).-- **OS:** Windows (macOS/Linux detection stubs exist; the GUI surfaces are Windows-first).+- **OS:** Windows and Linux (x86_64, Wayland or X11; see [KiCad on Linux](docs/linux.md)). A macOS build is in progress. The bridge binds `ADOM_BIND_HOST` on a Bridge-assigned port, never `0.0.0.0`. Auto-updates via `updateManifestUrl` in `bridge.json`.
RELEASE_NOTES.md+29@@ -10,6 +10,35 @@ unnamed. These entries were reconstructed after the fact from the work they contain, which is exactly the situation the mandatory-notes gate now prevents. Treat them as accurate but secondhand. +## Next release: KiCad on Linux (draft; version and date set at publish)++NATIVE (Rust) build. The Linux build drives KiCad's windows, menus and dialogs like the Windows build, on Wayland and X11 desktops. Guide: docs/linux.md.++- **Windows on Linux:** KiCad 10 runs as an X11 application everywhere, under XWayland on a Wayland desktop. The bridge reads its windows from the X server:+ - `kicad_state`, `kicad_window_info`, `kicad_open_editors` and `kicad_document_info` list every KiCad window with its kind, process, owner, size and state;+ - close, placement, the background guard and the 3D viewer verbs work;+ - `kicad_readiness.platform` now says `windowControl: true` and gives the `session`, `wayland` or `x11`.+- **Screenshots on Linux:** `kicad_screenshot_all`, `kicad_state` and dialog evidence read each window's own pixels, so nothing moves and nothing takes focus (`capturedBy: "local:x11"`, PNGs in the bridge's data folder). Windows still captures through Adom Bridge.+- **Menus and dialogs on Linux:** the accessibility bus (AT-SPI) is used where Windows uses UI Automation:+ - `kicad_menu_items` and menu invocation;+ - chords through `kicad_send_key` (`alt+3` opens the 3D viewer in the background);+ - dialog text, buttons and check boxes, and the automatic dialog sweep.+- **Symbol and footprint editors on Linux:** `kicad_open_symbol_editor`, `kicad_show_symbol`, `kicad_open_footprint_editor` and `kicad_show_footprint` load the part through the library tree's search field and its selected row (accessibility bus), in the background.+- **Simulation on Linux:** `kicad_simulate` runs ngspice through the distribution's libngspice; before, it answered that it needed Windows.+- **KiCad in its own scope on Linux:** each KiCad the bridge starts runs in its own systemd scope. Before, an out-of-memory KiCad took Adom Bridge down with it, because it ran inside Adom Bridge's service.+- **Opens in the background on Linux:** KiCad no longer takes keyboard focus from the window you are typing in when the bridge starts it (it did on GNOME Wayland).+- **No setup wizard on a fresh KiCad 10:** before the first launch the bridge writes any missing global library table, including KiCad 10's new `design-block-lib-table`. Without that table KiCad 10 opens its modal "KiCad Setup" wizard on every start; a distribution's KiCad package may not ship the file. Existing tables are never touched. `kicad_prewarm` does the same, and on Linux skips the Windows-only taskbar step instead of failing.+- **Fixes on Linux:**+ - a KiCad on a merged-/usr distribution is listed once, not twice (`/bin` and `/usr/bin`);+ - `kicad_document_info` reads the editor's file from its command line;+ - a lock file left by a killed KiCad is cleared again, as on Windows. Before, the bridge could not read this machine's name, so the lock stayed, and every closed-file edit answered `board_open`;+ - Adom Bridge's host-app detection also finds snap and Flatpak KiCad.+- **Limits on Linux:**+ - a single key reaches KiCad only when its window already has focus (`not_focused` otherwise);+ - coordinate clicks are not available;+ - a modal dialog that opens because the bridge pressed a menu item or button holds the accessibility bus until it closes. It is read from its screenshot, which is why `kicad_teardrops` is not Linux-ready yet.+- **Verb battery:** runs against Linux boxes too, with OS-aware paths, and scratch copies pushed with send_files instead of shell commands.+ ## 1.0.98 (2026-10-31 17:28 UTC) NATIVE (Rust) build. 1.0.98: the standard's document_info.
SKILL.md+7−3@@ -1,7 +1,7 @@ --- name: adom-desktop-kicad public: true-description: "Launch and drive KiCad on the user's laptop (KiCad 7/8/9/10 supported, multi-version side-by-side) from this container via the adom-bridge CLI. Open schematics/boards/symbols/footprints, run DRC, install sym-lib-table / fp-lib-table libraries, show the 3D viewer, capture KiCad window screenshots, send keyboard shortcuts and click coordinates. Trigger words: kicad, open schematic, open board, open pcb, open symbol, open footprint, KiCad symbol editor, KiCad footprint editor, KiCad 3D viewer, run DRC, install KiCad library, sym-lib-table, fp-lib-table, KiCad screenshot, kicad_send_key, kicad_click, kicad_window_info, kicad_screenshot_all, close KiCad, kicad versions, kicad_list_versions, KiCad bridge, PCM, Plugin and Content Manager, PCM repository, kicad_pcm_install, kicad_pcm_add_repository, PCM update."+description: "Launch and drive KiCad on the user's laptop (KiCad 7/8/9/10 supported, multi-version side-by-side) from this container via the adom-bridge CLI. Open schematics/boards/symbols/footprints, run DRC, install sym-lib-table / fp-lib-table libraries, show the 3D viewer, capture KiCad window screenshots, send keyboard shortcuts and click coordinates. Trigger words: kicad, open schematic, open board, open pcb, open symbol, open footprint, KiCad symbol editor, KiCad footprint editor, KiCad 3D viewer, run DRC, install KiCad library, sym-lib-table, fp-lib-table, KiCad screenshot, kicad_send_key, kicad_click, kicad_window_info, kicad_screenshot_all, close KiCad, kicad versions, kicad_list_versions, KiCad bridge, PCM, Plugin and Content Manager, PCM repository, kicad_pcm_install, kicad_pcm_add_repository, PCM update, KiCad on Linux, KiCad on Wayland." --- # adom-bridge: KiCad bridge@@ -77,7 +77,7 @@ If you pass a version that's not installed, the bridge returns a hint listing th ## "What EDA tools do I have?": readiness & host-app detection -**KiCad is the user's HOST APP. Adom Bridge *detects* it, it won't silently auto-install it as a prewarm/dependency** (Bridge v1.9.47+). But if KiCad isn't found, **do NOT hand the user a manual download**: offer to install it for them and run `kicad_upgrade '{}'`. That verb installs the latest stable from the official downloads.kicad.org installer (silent `/S`, Windows only; may raise a UAC prompt) and **installs from scratch, not just upgrades**. The distinction: Bridge-core never auto-installs a host app, but a user-requested, agent-run install is exactly right.+**KiCad is the user's HOST APP. Adom Bridge *detects* it, it won't silently auto-install it as a prewarm/dependency** (Bridge v1.9.47+). But if KiCad isn't found, **do NOT hand the user a manual download**: offer to install it for them and run `kicad_upgrade '{}'`. That verb installs the latest stable and **installs from scratch, not just upgrades**: on Windows from the official downloads.kicad.org installer (silent `/S`; may raise a UAC prompt), on Linux the Flathub app per user (`flatpak install --user`, no root; a distro package needs `sudo`, which the bridge never runs, so it gives the user the command). The distinction: Bridge-core never auto-installs a host app, but a user-requested, agent-run install is exactly right. - To answer **"what EDA tools / host apps do I have?"** across every bridge, call the Bridge-level verb **`adom-bridge bridge_readiness '{}'`**: Bridge aggregates each bridge's host-app detection (KiCad, Fusion 360, …) into one report. Start here for any "do I have X installed?" question. - For **KiCad specifically**, `adom-bridge kicad_readiness '{}'` is a zero-side-effect read-only probe: `{hostApp, detected, ready, versionCount, defaultVersion, versions[]}`. It does not install the plugin or spawn subprocesses, and works even when KiCad is absent (`detected:false`). Use `kicad_list_versions` when you need the full per-version detail (paths, IPC API, lib tables).@@ -420,7 +420,11 @@ Two contract points shared by all of them. A removal is verified after the commi ## The native build (1.0.0+) -From 1.0.0 the bridge is one Windows executable with no runtime to provision: `kicad_readiness.platform` reports what this build can do (`windowControl`, `uiAutomation`, `dialogSweep`, `focusEtiquette`, `ipcApi`, `silentInstall`) and a verb the OS cannot serve yet answers `not_supported_on_platform` with the owner named. Every window verb reports the rung it used as `mechanism` (`menu`, `uia`, `post`, `spawn`, `existing`, `ipc`). Two facts worth knowing: KiCad runs one IPC API server per machine and the first KiCad process takes it, so a standalone PCB editor opened beside the project manager has none (`no_pcb_frame` says so and names the remedy); and the in-KiCad Python plugin is retired (`kicad_bridge_status`, `kicad_bridge_call`, `kicad_install_plugin` answer `retired: true`), which is what keeps this bridge working when KiCad 11 removes SWIG scripting.+From 1.0.0 the bridge is one executable per OS (Windows, and Linux from the same zip) with no runtime to provision: `kicad_readiness.platform` reports what this build can do (`windowControl`, `uiAutomation`, `dialogSweep`, `focusEtiquette`, `ipcApi`, `silentInstall`, and on Linux `session`: `wayland` or `x11`) and a verb the OS cannot serve yet answers `not_supported_on_platform` with the owner named. Every window verb reports the rung it used as `mechanism` (`menu`, `uia`, `post`, `spawn`, `existing`, `ipc`). Two facts worth knowing: KiCad runs one IPC API server per machine and the first KiCad process takes it, so a standalone PCB editor opened beside the project manager has none (`no_pcb_frame` says so and names the remedy); and the in-KiCad Python plugin is retired (`kicad_bridge_status`, `kicad_bridge_call`, `kicad_install_plugin` answer `retired: true`), which is what keeps this bridge working when KiCad 11 removes SWIG scripting.++### On Linux++The same verbs run on a Linux desktop (Wayland or X11; KiCad 10 from the distro, the PPA, snap or Flatpak). KiCad's windows are read from the X server (XWayland on Wayland), screenshots come from the window's own pixels (`capturedBy: "local:x11"`), and menus, dialog text and buttons go through the accessibility bus (AT-SPI), all in the background. Differences an AI should know: a single key (`kicad_send_key {key:"escape"}`) reaches KiCad only when its window already has focus (`not_focused` otherwise; chords and `menu` run the menu item in the background instead); `kicad_click` is not available; a modal dialog opened by the bridge's own menu or button press cannot be read from the bus, so read it from its screenshot (`kicad_teardrops` is not Linux-ready for that reason); `kicad_simulate` needs the distribution's libngspice. Details and the per-verb table: `docs/linux.md` on this page. ## Exports, linting and settings
bridge.json+7−3@@ -424,7 +424,11 @@ ], "linux": [ "/usr/bin/kicad",- "/usr/local/bin/kicad"+ "/usr/local/bin/kicad",+ "/snap/bin/kicad",+ "~/.local/bin/kicad",+ "/var/lib/flatpak/exports/bin/org.kicad.KiCad",+ "~/.local/share/flatpak/exports/bin/org.kicad.KiCad" ] }, "installVerb": "kicad_upgrade",@@ -452,8 +456,8 @@ "reason": "macOS port pending \u2014 kicad-cli verbs work, but the Win32 window-automation handlers (kicad_send_key, kicad_click, kicad_screenshot_all) need a Quartz/AppKit equivalent." }, "linux": {- "supported": false,- "reason": "Linux port pending \u2014 kicad-cli works, but Win32 window automation doesn't translate to X11/Wayland."+ "supported": true,+ "notes": "Native build (kicad-bridge) in the release zip; see docs/linux.md." } }, "watcher": {
docs/comparison.md+3−3@@ -129,7 +129,7 @@ Y = yes, P = partial, N = no. Marks for other tools come from their own document | Auto-update with an early-access tier | Y | N | N | N | N | N | P | N | N | N | unchanged | | Supported uninstall | Y | N | Y | P | N | N | N | N | N | N | unchanged | | Supply-chain hygiene (signed binaries or OpenSSF badge, SBOM, checksums) | P (signed exe) | N | P | N | N | N | Y | N | N | N | unchanged |-| Runs on macOS and Linux | N (6) | Y | Y | Y | Y | Y | Y | P | P | Y | unchanged |+| Runs on macOS and Linux | P (6) | Y | Y | Y | Y | Y | Y | P | P | Y | unchanged | | Backed by a funded organization | Y (NSF) | N | P (commercial license) | N | N | Y (Seeed) | N | N | N | N | unchanged | | Contributions handled by paid staff, with measured turnaround | Y (7) | P (one maintainer, many contributors) | P | P | N (idle) | P | P | P | P | P | unchanged | @@ -143,7 +143,7 @@ Y = yes, P = partial, N = no. Marks for other tools come from their own document (5) [Adom SPICE](https://wiki.adom.inc/adom/adom-spice-skillpack) runs one study in ngspice (in the container), LTspice or PSpice for TI (on a Windows PC through Adom Bridge), or all three on one set of axes, with an agreement table that names the engine out of line, parallel Monte Carlo, and a ledger of every run's time and AI tokens. PSpice for TI is the only way to run the encrypted PSpice models many power-device vendors ship, and no other tool here drives it. The AI writes the study (netlists, signals, measurements). kicad-happy goes further on the other end, generating testbenches automatically from subcircuits it recognises in a schematic, and kicad-mcp-pro runs operating point, AC, transient and DC sweep analyses in ngspice straight from the KiCad project. -(6) The bridge has macOS and Linux platform layers, but each needs an owner's build and test before we mark it supported.+(6) Linux: supported, Wayland and X11, with window state, screenshots, menus and dialogs ([KiCad on Linux](linux.md)). macOS: a platform layer exists but needs its owner's build and test before we mark it supported. (7) The KiCad Bridge page has had 118 issues since June; 105 are closed, half of them within 16.5 hours, and issues from people other than the project lead closed in a median of 12.2 hours. Three developers on staff review pull requests across the Adom bridges. All 20 pull requests so far came from our own contributors and merged in a median of 8 minutes; we have not yet had one from outside, and we would like to. @@ -193,7 +193,7 @@ We have asked the KiCad developers for the second list, with measurements and vi ## Where others lead -- **Cross-platform.** Every other tool runs on macOS and Linux. Our Linux build (KiCad from the distro, snap or Flatpak) is on the insiders tier since 1.0.58, without the window verbs yet; the macOS build is a separate port.+- **Cross-platform.** Every other tool runs on macOS and Linux. Our Linux build (KiCad from the distro, the PPA, snap or Flatpak) runs on Wayland and X11 desktops with window state, screenshots, menus and dialogs ([KiCad on Linux](linux.md)); the macOS build is a separate port. - **Breadth of design checks.** kicad-mcp-pro covers signal integrity, power integrity, EMC and DFM cost with fast estimates, and kicad-happy audits power trees, ESD and EMC and generates SPICE testbenches automatically. We have no PDN, EMC or ESD checks yet. - **Supply-chain hygiene.** kicad-mcp-pro's OpenSSF Silver, SBOMs and checksums set the bar; we sign our executable but publish no SBOM yet. - **Community size.** KiCAD-MCP-Server has thousands of stars and many outside contributors. We live on our own wiki rather than GitHub, which makes us harder to find, and we have no outside pull requests yet.
docs/linux.mdadded+87@@ -0,0 +1,87 @@+# KiCad on Linux++The KiCad Bridge runs on Linux from the same release as Windows: one zip carries `kicad-bridge.exe` and+`kicad-bridge` (x86_64, glibc 2.39 or newer: Ubuntu 24.04, Debian 13, Fedora 40 and later), and Adom+Bridge starts the one for its OS. Every verb answers on Linux. Verbs that have no Linux path yet say so+with `errorCode: not_supported_on_platform` and a hint; none of them pretends.++## What you need++- **Adom Bridge for Linux** (the AppImage), signed in, running in your desktop session.+- **KiCad 10** from any of:+ - your distribution or the official PPA (Ubuntu): `sudo add-apt-repository ppa:kicad/kicad-10.0-releases && sudo apt install kicad`;+ - Flathub: `flatpak install --user flathub org.kicad.KiCad` (no root needed; `kicad_upgrade` does this for you);+ - snap, or a build of your own under `/usr/local`.+- A desktop session: GNOME, KDE, Cinnamon, Xfce and others, on **Wayland or X11**.++`kicad_readiness` tells you what it found: `installs[]` (one row per KiCad, with its source: distro+package, Flatpak, snap, PATH) and `platform`:++```json+"platform": {"os": "linux", "session": "wayland", "windowControl": true, "uiAutomation": true,+ "dialogSweep": true, "ipcApi": true, "kicadCli": true, "silentInstall": false}+```++## How it works++| What | How, on Linux |+|---|---|+| KiCad's windows | KiCad 10 is an X11 application everywhere: on a Wayland desktop it runs under XWayland. The bridge reads its windows from the X server (title, kind, process, owner, size, minimized and maximized state, stacking, focus), and closes, places and screenshots them there. Pure Rust, no X libraries needed. |+| Screenshots | The bridge reads a window's own pixels, so a KiCad window behind other windows still captures, nothing moves and nothing takes focus. Images land in the bridge's data folder (`screenshots/`) with the same fields as on Windows (`fullPath`, `safePath`, `coordMap`, `capturedBy: "local:x11"`). |+| Menus, dialog text and buttons | The desktop accessibility bus (AT-SPI), Linux's counterpart of Windows UI Automation. The bridge walks KiCad's menu bar and invokes items, reads a dialog's message, presses its buttons and ticks its check boxes, all without raising a window. `kicad_send_key` with a chord such as `alt+3` runs the menu item that carries it. The symbol and footprint editors load a part by typing its name into the library tree's search field and activating the row it selects. |+| Simulation | `kicad_simulate` runs ngspice through the distribution's `libngspice` (the library KiCad's own simulator uses); install it with `sudo apt install libngspice0` (Debian, Ubuntu) or `sudo dnf install libngspice` (Fedora) if it is missing. |+| KiCad's own processes | Each KiCad the bridge starts runs in its own systemd scope (`systemd-run --user --scope`), so it outlives a bridge restart, and a KiCad that crashes or runs out of memory never takes Adom Bridge down with it. |+| Live board edits | KiCad's IPC API, the same as on Windows. The bridge turns the API server on in KiCad's settings before the first launch. A Flatpak KiCad shares `/tmp` with the host through the launchers the bridge writes, so its API socket is the host's `/tmp/kicad/api.sock`. |+| Headless verbs | `kicad-cli` (DRC, ERC, exports, renders, netlists, lint) and file edits work the same on every OS. |+| Opening in the background | KiCad opens without taking keyboard focus from what you are typing in. Ask for the foreground with `foreground: true` and a `foregroundReason`, as on Windows. |+| First launch | Before KiCad starts for the first time the bridge writes any missing global library table (`sym-lib-table`, `fp-lib-table`, and KiCad 10's `design-block-lib-table`) from KiCad's own template, and marks the setup wizard done, so no "KiCad Setup" wizard covers the board. Files that exist are never touched. |++## What differs from Windows++- **Keys.** KiCad ignores synthetic key events on Linux, so `kicad_send_key` with a single key reaches+ a KiCad window only when that window already has the keyboard focus (the bridge never moves focus to+ type). Otherwise it answers `not_focused`. Use a chord or the `menu` argument (both run the menu item+ in the background), or the verb built for the job.+- **Clicks** at coordinates (`kicad_click`) are not available; use the menu, dialog-button and+ editing verbs instead.+- **Modal dialogs opened by the bridge itself.** A dialog that opens because the bridge pressed a menu+ item or a button (for example *Edit Teardrops*, or the "Are you sure?" box behind a Cancel) holds+ KiCad's accessibility bus until it closes, so the bridge cannot read or press it. It still screenshots+ it for you and closes it with Escape (Cancel). Dialogs KiCad opens on its own (warnings, the setup+ wizard) read and press normally. This is why `kicad_teardrops` is not Linux-ready yet.+- **Taskbar shortcuts** (`kicad_taskbar_repair`) are a Windows feature; `kicad_prewarm` skips that+ step on Linux.+- **Installing KiCad.** `kicad_upgrade` installs the Flathub build per user. A distribution package needs+ `sudo`, which the bridge never runs; it tells you the command instead.+- **On a Wayland desktop** X cannot see native Wayland windows (a browser, a terminal). KiCad's own+ windows are always visible to the bridge; `foreground` reads 0 while a Wayland window has focus.++## Which verbs are Linux-ready++Measured with the bridge's own verb battery (`skills/kicad-bridge-test/run_verb_tests.py`, every catalog verb)+through Adom Bridge on Ubuntu 24.04, GNOME on Wayland, KiCad 10.0.8 from the official PPA: **349 of 357 verbs+pass**, with no foreground grabs.++| Verbs | On Linux |+|---|---|+| Headless: DRC, ERC, every export and render, netlists, lint, libraries, footprint and symbol files, project and schematic edits, board file edits, rules, checks, fab, PCM | Ready, the same as Windows. |+| Live board through the IPC API: routing, zones, pours, silkscreen batches, selection, placement, 3D view control | Ready. |+| Windows: `kicad_launch`, `kicad_open_board`, `kicad_open_schematic`, `kicad_open_project`, `kicad_open_3d_viewer`, `kicad_state`, `kicad_window_info`, `kicad_open_editors`, `kicad_document_info`, `kicad_screenshot_all`, the close verbs, `kicad_foreground` | Ready (X11 and XWayland). |+| Menus and dialogs: `kicad_menu_items`, menu chords through `kicad_send_key`, `kicad_dismiss_dialogs`, the automatic dialog sweep | Ready (accessibility bus). |+| Library editors: `kicad_open_symbol_editor`, `kicad_show_symbol`, `kicad_open_footprint_editor`, `kicad_show_footprint` | Ready (accessibility bus). |+| `kicad_simulate` | Ready with the distribution's libngspice installed. |+| `kicad_height_check`, the 3D view of `kicad_library_views` | Ready; they need KiCad's 3D models (`kicad-packages3d` on Debian and Ubuntu, or the Flatpak, which includes them). |+| `kicad_send_key` with a single key | Only when the KiCad window already has focus; otherwise `not_focused`. |+| `kicad_click` | Not available on Linux (`not_supported_on_platform`). |+| `kicad_teardrops` | Not yet: it drives a modal dialog that it opens itself (see above). |+| `kicad_taskbar_repair` | Windows only (`not_supported_on_platform`). |+| `kicad_enable_software_opengl` | Windows only: Linux uses the system's Mesa, which already renders without a GPU. |++## Troubleshooting++| Symptom | Fix |+|---|---|+| `windowControl: false` in readiness | The bridge has no `DISPLAY`. Adom Bridge must run inside your desktop session (its autostart does this). |+| Menu or dialog verbs answer "no D-Bus session bus" or "accessibility bus did not answer" | Install `at-spi2-core` (every GNOME, KDE and Cinnamon desktop has it) and run Adom Bridge from the desktop session. |+| A Flatpak KiCad never answers the IPC API | Open KiCad through the bridge (`kicad_open_board`), which uses its own launchers that share `/tmp`; a KiCad started from the app menu keeps its socket inside the sandbox. |+| No KiCad found | `kicad_readiness` lists where it looked. Install from the list above, or `kicad_upgrade` for the Flatpak. |
rust/Cargo.lock+659−2@@ -50,6 +50,143 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" +[[package]]+name = "async-broadcast"+version = "0.7.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"+dependencies = [+ "event-listener",+ "event-listener-strategy",+ "futures-core",+ "pin-project-lite",+]++[[package]]+name = "async-channel"+version = "2.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"+dependencies = [+ "concurrent-queue",+ "event-listener-strategy",+ "futures-core",+ "pin-project-lite",+]++[[package]]+name = "async-executor"+version = "1.14.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"+dependencies = [+ "async-task",+ "concurrent-queue",+ "fastrand",+ "futures-lite",+ "pin-project-lite",+ "slab",+]++[[package]]+name = "async-io"+version = "2.6.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"+dependencies = [+ "autocfg",+ "cfg-if",+ "concurrent-queue",+ "futures-io",+ "futures-lite",+ "parking",+ "polling",+ "rustix",+ "slab",+ "windows-sys 0.61.2",+]++[[package]]+name = "async-lock"+version = "3.4.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"+dependencies = [+ "event-listener",+ "event-listener-strategy",+ "pin-project-lite",+]++[[package]]+name = "async-process"+version = "2.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"+dependencies = [+ "async-channel",+ "async-io",+ "async-lock",+ "async-signal",+ "async-task",+ "blocking",+ "cfg-if",+ "event-listener",+ "futures-lite",+ "rustix",+]++[[package]]+name = "async-recursion"+version = "1.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451"+dependencies = [+ "proc-macro2",+ "quote",+ "syn 3.0.5",+]++[[package]]+name = "async-signal"+version = "0.2.14"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"+dependencies = [+ "async-io",+ "async-lock",+ "atomic-waker",+ "cfg-if",+ "futures-core",+ "futures-io",+ "rustix",+ "signal-hook-registry",+ "slab",+ "windows-sys 0.61.2",+]++[[package]]+name = "async-task"+version = "4.7.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"++[[package]]+name = "async-trait"+version = "0.1.92"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"+dependencies = [+ "proc-macro2",+ "quote",+ "syn 3.0.5",+]++[[package]]+name = "atomic-waker"+version = "1.1.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"+ [[package]] name = "autocfg" version = "1.5.1"@@ -74,6 +211,25 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +[[package]]+name = "bitflags"+version = "2.13.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"++[[package]]+name = "blocking"+version = "1.7.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa"+dependencies = [+ "async-channel",+ "async-task",+ "futures-io",+ "futures-lite",+ "piper",+]+ [[package]] name = "bumpalo" version = "3.20.3"@@ -136,6 +292,15 @@ dependencies = [ "cc", ] +[[package]]+name = "concurrent-queue"+version = "2.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"+dependencies = [+ "crossbeam-utils",+]+ [[package]] name = "core-foundation-sys" version = "0.8.7"@@ -151,6 +316,12 @@ dependencies = [ "cfg-if", ] +[[package]]+name = "crossbeam-utils"+version = "0.8.23"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"+ [[package]] name = "data-url" version = "0.3.2"@@ -163,6 +334,49 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +[[package]]+name = "endi"+version = "1.1.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"++[[package]]+name = "enumflags2"+version = "0.7.12"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"+dependencies = [+ "enumflags2_derive",+ "serde",+]++[[package]]+name = "enumflags2_derive"+version = "0.7.12"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"+dependencies = [+ "proc-macro2",+ "quote",+ "syn 2.0.119",+]++[[package]]+name = "equivalent"+version = "1.0.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"++[[package]]+name = "errno"+version = "0.3.14"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"+dependencies = [+ "libc",+ "windows-sys 0.59.0",+]+ [[package]] name = "euclid" version = "0.22.14"@@ -172,6 +386,32 @@ dependencies = [ "num-traits", ] +[[package]]+name = "event-listener"+version = "5.4.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"+dependencies = [+ "parking",+ "pin-project-lite",+]++[[package]]+name = "event-listener-strategy"+version = "0.5.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"+dependencies = [+ "event-listener",+ "pin-project-lite",+]++[[package]]+name = "fastrand"+version = "2.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"+ [[package]] name = "fdeflate" version = "0.3.7"@@ -210,6 +450,25 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +[[package]]+name = "futures-io"+version = "0.3.34"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"++[[package]]+name = "futures-lite"+version = "2.6.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"+dependencies = [+ "fastrand",+ "futures-core",+ "futures-io",+ "parking",+ "pin-project-lite",+]+ [[package]] name = "futures-task" version = "0.3.34"@@ -228,6 +487,16 @@ dependencies = [ "slab", ] +[[package]]+name = "gethostname"+version = "1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"+dependencies = [+ "rustix",+ "windows-link 0.2.1",+]+ [[package]] name = "getrandom" version = "0.2.17"@@ -239,6 +508,35 @@ dependencies = [ "wasi", ] +[[package]]+name = "getrandom"+version = "0.4.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"+dependencies = [+ "cfg-if",+ "libc",+ "r-efi",+]++[[package]]+name = "hashbrown"+version = "0.17.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"++[[package]]+name = "hermit-abi"+version = "0.5.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"++[[package]]+name = "hex"+version = "0.4.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"+ [[package]] name = "http" version = "1.5.0"@@ -291,6 +589,16 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" +[[package]]+name = "indexmap"+version = "2.14.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"+dependencies = [+ "equivalent",+ "hashbrown",+]+ [[package]] name = "itertools" version = "0.14.0"@@ -365,6 +673,8 @@ dependencies = [ "uiautomation", "windows 0.61.3", "winreg",+ "x11rb",+ "zbus", ] [[package]]@@ -384,6 +694,12 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]]+name = "linux-raw-sys"+version = "0.12.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"+ [[package]] name = "log" version = "0.4.34"@@ -396,6 +712,15 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]]+name = "memoffset"+version = "0.9.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"+dependencies = [+ "autocfg",+]+ [[package]] name = "miniz_oxide" version = "0.8.9"@@ -451,6 +776,22 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]]+name = "ordered-stream"+version = "0.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"+dependencies = [+ "futures-core",+ "pin-project-lite",+]++[[package]]+name = "parking"+version = "2.2.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"+ [[package]] name = "percent-encoding" version = "2.3.2"@@ -469,19 +810,53 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]]+name = "piper"+version = "0.2.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"+dependencies = [+ "atomic-waker",+ "fastrand",+ "futures-io",+]+ [[package]] name = "png" version = "0.17.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" dependencies = [- "bitflags",+ "bitflags 1.3.2", "crc32fast", "fdeflate", "flate2", "miniz_oxide 0.8.9", ] +[[package]]+name = "polling"+version = "3.11.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"+dependencies = [+ "cfg-if",+ "concurrent-queue",+ "hermit-abi",+ "pin-project-lite",+ "rustix",+ "windows-sys 0.61.2",+]++[[package]]+name = "proc-macro-crate"+version = "3.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"+dependencies = [+ "toml_edit",+]+ [[package]] name = "proc-macro2" version = "1.0.107"@@ -541,6 +916,12 @@ dependencies = [ "proc-macro2", ] +[[package]]+name = "r-efi"+version = "6.0.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"+ [[package]] name = "resvg" version = "0.45.1"@@ -572,7 +953,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if",- "getrandom",+ "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0",@@ -584,6 +965,19 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +[[package]]+name = "rustix"+version = "1.1.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa"+dependencies = [+ "bitflags 2.13.2",+ "errno",+ "libc",+ "linux-raw-sys",+ "windows-sys 0.59.0",+]+ [[package]] name = "rustls" version = "0.23.44"@@ -668,12 +1062,33 @@ dependencies = [ "zmij", ] +[[package]]+name = "serde_repr"+version = "0.1.21"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"+dependencies = [+ "proc-macro2",+ "quote",+ "syn 3.0.5",+]+ [[package]] name = "shlex" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]]+name = "signal-hook-registry"+version = "1.4.8"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"+dependencies = [+ "errno",+ "libc",+]+ [[package]] name = "simd-adler32" version = "0.3.10"@@ -754,6 +1169,19 @@ dependencies = [ "unicode-ident", ] +[[package]]+name = "tempfile"+version = "3.27.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"+dependencies = [+ "fastrand",+ "getrandom 0.4.3",+ "once_cell",+ "rustix",+ "windows-sys 0.59.0",+]+ [[package]] name = "thiserror" version = "2.0.20"@@ -833,6 +1261,78 @@ dependencies = [ "syn 3.0.5", ] +[[package]]+name = "toml_datetime"+version = "1.1.2+spec-1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053"+dependencies = [+ "serde_core",+]++[[package]]+name = "toml_edit"+version = "0.25.17+spec-1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c"+dependencies = [+ "indexmap",+ "toml_datetime",+ "toml_parser",+ "winnow",+]++[[package]]+name = "toml_parser"+version = "1.1.5+spec-1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c"+dependencies = [+ "winnow",+]++[[package]]+name = "tracing"+version = "0.1.44"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"+dependencies = [+ "pin-project-lite",+ "tracing-attributes",+ "tracing-core",+]++[[package]]+name = "tracing-attributes"+version = "0.1.31"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"+dependencies = [+ "proc-macro2",+ "quote",+ "syn 2.0.119",+]++[[package]]+name = "tracing-core"+version = "0.1.36"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"+dependencies = [+ "once_cell",+]++[[package]]+name = "uds_windows"+version = "1.2.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"+dependencies = [+ "memoffset",+ "tempfile",+ "windows-sys 0.61.2",+]+ [[package]] name = "uiautomation" version = "0.24.4"@@ -925,6 +1425,17 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" +[[package]]+name = "uuid"+version = "1.29.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514"+dependencies = [+ "js-sys",+ "serde_core",+ "wasm-bindgen",+]+ [[package]] name = "version_check" version = "0.9.5"@@ -1190,6 +1701,15 @@ dependencies = [ "windows-targets", ] +[[package]]+name = "windows-sys"+version = "0.61.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"+dependencies = [+ "windows-link 0.2.1",+]+ [[package]] name = "windows-targets" version = "0.52.6"@@ -1272,6 +1792,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]]+name = "winnow"+version = "1.0.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"+dependencies = [+ "memchr",+]+ [[package]] name = "winreg" version = "0.55.0"@@ -1282,12 +1811,99 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]]+name = "x11rb"+version = "0.13.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"+dependencies = [+ "gethostname",+ "rustix",+ "x11rb-protocol",+]++[[package]]+name = "x11rb-protocol"+version = "0.13.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"+ [[package]] name = "xmlwriter" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" +[[package]]+name = "zbus"+version = "5.19.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907"+dependencies = [+ "async-broadcast",+ "async-executor",+ "async-io",+ "async-lock",+ "async-process",+ "async-recursion",+ "async-task",+ "async-trait",+ "blocking",+ "enumflags2",+ "event-listener",+ "futures-core",+ "futures-lite",+ "hex",+ "libc",+ "ordered-stream",+ "rustix",+ "serde",+ "serde_repr",+ "tracing",+ "uds_windows",+ "uuid",+ "windows-sys 0.61.2",+ "winnow",+ "zbus_macros",+ "zbus_names",+ "zvariant",+]++[[package]]+name = "zbus_macros"+version = "5.19.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"+dependencies = [+ "proc-macro-crate",+ "proc-macro2",+ "quote",+ "syn 3.0.5",+ "zbus_names",+ "zvariant",+ "zvariant_utils",+]++[[package]]+name = "zbus_names"+version = "4.3.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd"+dependencies = [+ "serde",+ "winnow",+ "zvariant",+]++[[package]]+name = "zcheapstr"+version = "1.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "939f186d9ecebe044315a5cb6d6f78568bbc21152adc954d5a0c2f58bdbc9920"+dependencies = [+ "serde",+]+ [[package]] name = "zeroize" version = "1.9.0"@@ -1305,3 +1921,44 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"++[[package]]+name = "zvariant"+version = "5.15.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7"+dependencies = [+ "endi",+ "enumflags2",+ "serde",+ "winnow",+ "zcheapstr",+ "zvariant_derive",+ "zvariant_utils",+]++[[package]]+name = "zvariant_derive"+version = "5.15.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d"+dependencies = [+ "proc-macro-crate",+ "proc-macro2",+ "quote",+ "syn 3.0.5",+ "zvariant_utils",+]++[[package]]+name = "zvariant_utils"+version = "4.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3"+dependencies = [+ "proc-macro2",+ "quote",+ "serde",+ "syn 3.0.5",+ "winnow",+]
rust/README.md+1−1@@ -6,7 +6,7 @@ The Rust port of the Adom KiCad Bridge. One binary per OS, no runtime to provisi | Crate | What | OS-specific? | |---|---|---|-| `crates/kicad-platform` | The `Platform` trait and exactly one implementation compiled in per target: `windows.rs`, `macos.rs` (Kyle), `linux.rs` (Barrett). Install discovery, settings and documents dirs, quiet child processes; later window control, UI automation, dialog sweep and focus etiquette. | Yes, this is the only crate that is |+| `crates/kicad-platform` | The `Platform` trait and exactly one implementation compiled in per target: `windows.rs` (+ `win/`), `macos.rs` (Kyle), `linux.rs` (+ `linux/x11.rs` for windows and screenshots, `linux/atspi.rs` for menus, dialogs and buttons through the accessibility bus). Install discovery, settings and documents dirs, quiet child processes; later window control, UI automation, dialog sweep and focus etiquette. | Yes, this is the only crate that is | | `crates/kicad-core` | Detection ordering, the kicad-cli runner and report parsers, KiCad file editing (phase 1b), IPC client (phase 2). Talks to the OS only through `kicad_platform::native()`. | No | | `crates/kicad-bridge` | The binary: HTTP server (`/status`, `/command`), the verb catalog and the verb groups (one file per group, registered in `groups.rs`). | No |
rust/crates/kicad-bridge/src/main.rs+1@@ -263,6 +263,7 @@ fn handle(mut req: tiny_http::Request, started: Instant, request_count: u64, ver /// `--ngspice-worker`: `{dll, cwd, lines, codemodels, commands?}` on stdin, kicad_platform::ngspice::run's answer (or `{error}`) on stdout. fn ngspice_worker() -> i32 { use std::io::Read;+ kicad_platform::ngspice::limit_worker_memory(1536 * 1024 * 1024); let mut input = String::new(); let _ = std::io::stdin().read_to_string(&mut input); let job: Value = serde_json::from_str(&input).unwrap_or(Value::Null);
rust/crates/kicad-bridge/src/verbs_maint.rs+10−4@@ -230,13 +230,19 @@ pub fn prewarm() -> Value { steps.push(json!({"step": "config", "version": inst.version, "ok": true, "skipped": "KiCad is running; it rewrites its own settings on exit"})); continue; }+ let tables = kicad_core::install::ensure_global_tables(&cfg, Some(inst.base_dir.as_str()).filter(|b| !b.is_empty()), &inst.version); let seeded = kicad_core::kicad_common::seed_first_run(&cfg); let wizard = kicad_core::kicad_common::mark_first_run_done(&cfg); steps.push(json!({"step": "config", "version": inst.version, "ok": seeded.errors.is_empty() && wizard.is_ok(),- "written": seeded.written, "merged": seeded.merged, "setupWizardMarkedDone": wizard.as_ref().ok(), "errors": seeded.errors, "wizardError": wizard.err()}));+ "written": seeded.written, "merged": seeded.merged, "libTables": tables, "setupWizardMarkedDone": wizard.as_ref().ok(), "errors": seeded.errors, "wizardError": wizard.err()}));+ }+ if native().capabilities().os == "windows" {+ let tb = taskbar_repair(&info.version);+ steps.push(json!({"step": "taskbar", "ok": tb["success"].as_bool().unwrap_or(false), "written": tb["written"], "already": tb["already"], "missing": tb["missing"]}));+ } else {+ // Start Menu shortcuts and their AUMIDs are a Windows thing; nothing to repair elsewhere.+ steps.push(json!({"step": "taskbar", "ok": true, "skipped": format!("no taskbar shortcuts to repair on {}", native().capabilities().os)})); }- let tb = taskbar_repair(&info.version);- steps.push(json!({"step": "taskbar", "ok": tb["success"].as_bool().unwrap_or(false), "written": tb["written"], "already": tb["already"], "missing": tb["missing"]})); let ok = steps.iter().all(|s| s["ok"].as_bool().unwrap_or(false)); json!({"success": ok, "ok": ok, "warmed": true, "steps": steps, "seconds": (started.elapsed().as_secs_f64() * 100.0).round() / 100.0, "output": format!("KiCad {} ready: CLI warm, first-run config seeded, setup wizard marked done, taskbar shortcuts checked{}.", info.version, if running { " (config skipped: KiCad is running)" } else { "" })})@@ -264,7 +270,7 @@ pub fn taskbar_repair(version: &str) -> Value { }, }) }- Err(e) => json!({"success": false, "errorCode": if e.contains("not supported") { "not_supported_on_platform" } else { "platform_error" }, "error": e}),+ Err(e) => json!({"success": false, "errorCode": if e.contains("not supported") || e.contains(": not implemented on ") { "not_supported_on_platform" } else { "platform_error" }, "error": e, "_hint": "Taskbar shortcuts and their AUMIDs are a Windows feature; there is nothing to repair on this OS."}), } }
rust/crates/kicad-bridge/src/verbs_sim.rs+9−4@@ -33,7 +33,7 @@ pub static VERBS: &[Verb] = &[ example: "kicad_simulate {\"schPath\":\"C:/Users/john/proj/filter.kicad_sch\",\"analysis\":{\"type\":\"ac\",\"sweep\":\"dec\",\"points\":20,\"start\":10,\"stop\":1000000},\"probes\":[{\"net\":\"OUT\"}]}", hint: "signals[{signal, x, y, xUnit, yUnit, phase?}] is the standard's answer (#23): each probe's own points, at most points of them with the first, last and extreme points kept; AC as dB with unwrapped phase in degrees. engine names the ngspice and KiCad versions. probes are nets ({net: \"OUT\"}, or V(/OUT)) and source currents ({current: \"V1\"}); without probes every node and branch comes back (at most 24). With sweep (a part's value \"R4\" or a model parameter \"Q1.BF\"; lin steps by step, dec/oct take step points per decade/octave) or temperatures (°C), ngspice runs once per value (alter/altermod, .temp; at most 50 runs) and each run's signals carry step (\"R4 = 300\", \"T = 85\"): group by signal + step. monteCarlo draws every resistor, capacitor, inductor and DC source whose kind has a tolerance again for each run (uniform, or gaussian with sigma tol/3), each run's step listing the values drawn (the same seed draws the same). A noise analysis answers onoise and inoise (V/rtHz) and totals {onoise, inoise} in V. The top-level sweep is time, frequency or the DC sweep of the first run. An AC probe has db and phaseDeg. csvPath holds the same points. Read log for ngspice's own messages when a run fails.", related: &["kicad_export_spice", "kicad_build_schematic", "kicad_sch_set_property"],- pitfalls: &["Every part needs a SPICE model: R, C and L work from their values; sources come from the Simulation_SPICE library; ICs need a model file (Sim.Library). A part without one is left out of the run and named in notSimulated; a sheet with no modelled part refuses no_model.", "Runs on Windows through KiCad's ngspice.dll.", "A long transient with a tiny step returns many points: points downsamples the reply, not the run.", "A swept part must be in the netlist, and a model parameter's .model must be in the netlist or a file it includes: otherwise bad_args, never one run passed off as the sweep."],+ pitfalls: &["Every part needs a SPICE model: R, C and L work from their values; sources come from the Simulation_SPICE library; ICs need a model file (Sim.Library). A part without one is left out of the run and named in notSimulated; a sheet with no modelled part refuses no_model.", "Runs through KiCad's ngspice library: ngspice.dll beside KiCad on Windows, the distribution's libngspice on Linux.", "A long transient with a tiny step returns many points: points downsamples the reply, not the run.", "A swept part must be in the netlist, and a model parameter's .model must be in the netlist or a file it includes: otherwise bad_args, never one run passed off as the sweep."], }, ]; @@ -139,13 +139,18 @@ fn simulate(state: &mut State, args: &Value) -> Result<Value, Value> { } // the parts KiCad wrote as stubs are left out of the run (and named in notSimulated) let lines = spice::prepare(&spice::strip_unmodelled(&text).0, &analyses).map_err(|e| fail("bad_args", e, "analysis: \".tran 10u 5m\", \".ac dec 20 10 1meg\", \".dc V1 0 5 0.1\" or \".op\"."))?;- let dll = cli_path.parent().unwrap_or(Path::new(".")).join("ngspice.dll");+ let dll = kicad_platform::native().ngspice_library(&cli_path); if !dll.is_file() {- return Err(fail("ngspice_missing", format!("{} is not there", norm(&dll)), "KiCad's simulator library ships with KiCad 7 and later on Windows; reinstall KiCad (kicad_upgrade)."));+ let hint = if kicad_platform::native().capabilities().os == "linux" {+ "KiCad's simulator uses the distribution's ngspice library: install it (Debian/Ubuntu: sudo apt install libngspice0; Fedora: sudo dnf install libngspice), then run again."+ } else {+ "KiCad's simulator library ships with KiCad 7 and later on Windows; reinstall KiCad (kicad_upgrade)."+ };+ return Err(fail("ngspice_missing", format!("{} is not there", norm(&dll)), hint)); } let cwd = sch.parent().unwrap_or(Path::new(".")).to_path_buf(); // KiCad's XSPICE code models (<kicad>/lib/ngspice/*.cm), loaded the way KiCad's simulator loads them- let cm_dir = cli_path.parent().and_then(Path::parent).map(|b| b.join("lib").join("ngspice")).unwrap_or_default();+ let cm_dir = kicad_platform::native().ngspice_codemodel_dir(&cli_path); let mut codemodels: Vec<String> = std::fs::read_dir(&cm_dir).into_iter().flatten().flatten().map(|e| e.path()).filter(|p| p.extension().map(|x| x == "cm").unwrap_or(false)).map(|p| norm(&p)).collect(); codemodels.sort(); // a sweep (#23): ngspice has no .step, so one run per value, the value set with alter/altermod and the
rust/crates/kicad-bridge/src/verbs_std_more.rs+7@@ -405,6 +405,13 @@ fn document_info() -> Value { let untitled = name == "untitled"; // the editor's command line (Adom Bridge's process_list), or the project manager's for an editor it hosts let cmd_of = |pid: u32| -> Option<String> {+ // Linux: the editor's arguments are in /proc, no ab round trip (each one quoted, for doc_arg).+ if let Ok(raw) = std::fs::read(format!("/proc/{pid}/cmdline")) {+ let args: Vec<String> = raw.split(|b| *b == 0).filter(|a| !a.is_empty()).map(|a| format!("\"{}\"", String::from_utf8_lossy(a))).collect();+ if !args.is_empty() {+ return Some(args.join(" "));+ }+ } let r = kicad_core::ab::call("process_list", json!({}), Duration::from_secs(15)).ok()?; let v = kicad_core::ab::unwrap_payload(&r.raw); let ps = v.get("processes").or_else(|| v.get("data")).and_then(Value::as_array)?.clone();
rust/crates/kicad-bridge/src/verbs_windows.rs+119−3@@ -567,9 +567,19 @@ fn skip_setup_wizard() { if !native().processes(&["kicad.exe", "pcbnew.exe", "eeschema.exe"]).unwrap_or_default().is_empty() { return; }- let versions: Vec<String> = kicad_core::detect::detect().installs.iter().map(|i| i.version.clone()).collect();- for v in versions {+ let installs: Vec<(String, String)> = kicad_core::detect::detect().installs.iter().map(|i| (i.version.clone(), i.base_dir.clone())).collect();+ for (v, base) in installs { if let Some(cfg) = native().config_dir(&v) {+ for line in kicad_core::install::ensure_global_tables(&cfg, Some(base.as_str()).filter(|b| !b.is_empty()), &v) {+ eprintln!("[kicad-bridge] KiCad {v}: {line}");+ }+ // A KiCad installed after the bridge's prewarm (a distro package, a Flatpak) has never+ // been seeded: without kicad.json its setup wizard opens, modal, on the first launch.+ // Seeding writes only files that are absent, so a user's own settings are never touched.+ if !cfg.join("kicad.json").is_file() {+ let s = kicad_common::seed_first_run(&cfg);+ eprintln!("[kicad-bridge] KiCad {v}: first-run config seeded before the first launch (written {:?}, merged {:?}, errors {:?})", s.written, s.merged, s.errors);+ } match kicad_common::mark_first_run_done(&cfg) { Ok(true) => eprintln!("[kicad-bridge] KiCad {v}: setup wizard marked done (kicad.json first_run_shown), it will not pop up again"), Ok(false) => {}@@ -650,6 +660,13 @@ pub(crate) fn invalidate() { } fn list_all_windows() -> Result<(Vec<Win>, &'static str), Value> {+ // Linux: the X server is the authority on KiCad's windows (pid, owner, state); ab's list is+ // the fallback when the bridge cannot open the display itself.+ if native().capabilities().os == "linux" {+ if let Ok(rows) = native().list_windows() {+ return Ok((rows.iter().filter(|w| w.visible).enumerate().map(|(z, w)| Win::from_info(w, z)).collect(), "local:x11"));+ }+ } match ab::desktop_list_windows() { Ok(l) if !(l.windows.is_empty() && l.degraded) => { let mut rows: Vec<Win> = l.windows.iter().map(|w| Win {@@ -1069,7 +1086,11 @@ fn dismiss_dialog(hwnd: u64) -> String { /// Capture a dialog BEFORE we expire it, so the AI can be shown what was auto-closed. fn screenshot_dialog(hwnd: u64, title: &str) -> String {- ab::desktop_screenshot_window(hwnd, &format!("Capture the KiCad dialog '{title}' before the bridge expires it, as evidence for the calling AI"), None).map(|s| s.full_path).unwrap_or_default()+ match ab::desktop_screenshot_window(hwnd, &format!("Capture the KiCad dialog '{title}' before the bridge expires it, as evidence for the calling AI"), None) {+ Ok(s) => s.full_path,+ Err(_) if native().capabilities().os == "linux" => local_screenshot(hwnd, &format!("dialog-{title}"), None).ok().and_then(|v| v["fullPath"].as_str().map(String::from)).unwrap_or_default(),+ Err(_) => String::new(),+ } } /// Persist the Cairo (software) canvas in every kicad_common.json so KiCad stops@@ -1395,6 +1416,14 @@ fn navigate_tree(frame: u64, filter: &str, verify: Verify<'_>) -> (bool, Value, let mut steps: Vec<String> = Vec::new(); let mut trace = json!({"filter": filter, "editorHwnd": frame, "titleBefore": title_of(frame)}); let mut mechanism = "post";+ // Linux: GTK draws the tree pane itself (no child windows), so the filter is the accessible "Search"+ // field: its text set through the accessibility bus, then its activate action, which is the Enter KiCad's+ // search box answers by loading the match it selected. Background, no focus.+ if native().capabilities().os == "linux" {+ let ok = linux_tree_nav(frame, filter, verify, &mut steps);+ trace["steps"] = json!(steps);+ return (ok, trace, "atspi");+ } let Some(edit) = ensure_tree_search(frame, &mut steps) else { steps.push("search Edit under a 'searchCtrl' parent NOT FOUND (library tree pane hidden, or a different child layout)".into()); trace["steps"] = json!(steps);@@ -1493,6 +1522,43 @@ fn navigate_tree(frame: u64, filter: &str, verify: Verify<'_>) -> (bool, Value, (false, trace, mechanism) } +/// navigate_tree on Linux (see there): the library tree's accessible Search field.+fn linux_tree_nav(frame: u64, filter: &str, verify: Verify<'_>, steps: &mut Vec<String>) -> bool {+ let title_ok = || verify_title(&title_of(frame), verify);+ for attempt in 1..=2 {+ // a fresh editor loads every library first and answers nothing meanwhile+ let mut set = native().uia_set_value(frame, "Search", filter);+ let mut waited = 0;+ while set.is_err() && waited < 20 {+ sleep_ms(1000);+ waited += 1;+ set = native().uia_set_value(frame, "Search", filter);+ }+ match &set {+ Ok(()) => steps.push(format!("attempt {attempt}: filter {filter:?} set in the Search field (AT-SPI)")),+ Err(e) => {+ steps.push(format!("attempt {attempt}: could not set the Search field: {e}"));+ return false;+ }+ }+ sleep_ms(1400); // LIB_TREE search debounce + tree rebuild+ // the search selects its best match; activating that row loads it (as a double-click does)+ match native().tree_activate_selected(frame) {+ Ok(m) => steps.push(format!("attempt {attempt}: selected tree row activated ({m})")),+ Err(e) => steps.push(format!("attempt {attempt}: selected tree row not activated: {e}")),+ }+ for _ in 0..10 {+ sleep_ms(500);+ if title_ok() {+ steps.push(format!("title confirms: {:?}", title_of(frame)));+ return true;+ }+ }+ steps.push(format!("attempt {attempt}: title still {:?}", title_of(frame)));+ }+ false+}+ // ── Opening an editor frame: menu walk, then UIA ────────────────────────────── struct Budget {@@ -2897,12 +2963,62 @@ fn canvas_probe(hwnd: u64) -> Value { pub(crate) fn screenshot_hwnd(hwnd: u64, label: &str, resize_max: Option<u32>) -> Value { let mut v = match ab::desktop_screenshot_window(hwnd, &format!("Capture the KiCad window {label} as evidence for the calling AI"), resize_max) { Ok(shot) => shot.to_json(),+ // Linux: KiCad's windows are X windows the bridge can read itself, so a screenshot does+ // not depend on ab's desktop capture (which a Wayland desktop may not offer).+ Err(e) if native().capabilities().os == "linux" => match local_screenshot(hwnd, label, resize_max) {+ Ok(mut l) => {+ l["abError"] = json!(e.message());+ l+ }+ Err(le) => json!({"success": false, "error": format!("{}; local capture: {le}", e.message()), "abUnavailable": e.is_unavailable()}),+ }, Err(e) => json!({"success": false, "error": e.message(), "abUnavailable": e.is_unavailable(), "_hint": "The native build takes every screenshot through ab's desktop_screenshot_window; with ab unreachable no image is written. Only the canvas probe (canvas.rendered) answers locally."}), }; v["canvas"] = canvas_probe(hwnd); v } +/// Where the bridge writes the screenshots it takes itself: <data dir>/screenshots.+fn local_shot_dir() -> PathBuf {+ std::env::var_os("ADOM_BRIDGE_DATA_DIR")+ .filter(|d| !d.is_empty())+ .map(PathBuf::from)+ .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share/adom-kicad-bridge")))+ .unwrap_or_else(std::env::temp_dir)+ .join("screenshots")+}++/// A window's own pixels to a PNG, with no ab round trip (the window does not move or focus).+/// The reply has the fields of ab's capture (savedTo, fullPath, safePath, coordMap) so every+/// caller reads it the same way.+fn local_screenshot(hwnd: u64, label: &str, resize_max: Option<u32>) -> Result<Value, String> {+ let cap = native().capture_window(hwnd)?;+ let rect = native().window_info(hwnd)?.map(|w| w.rect).unwrap_or((0, 0, cap.width as i32, cap.height as i32));+ let dir = local_shot_dir();+ let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0);+ let safe_label: String = label.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).take(60).collect();+ let full = dir.join(format!("{stamp}-{safe_label}.png"));+ let (fw, fh) = kicad_core::sch_render::save_rgb_png(cap.width, cap.height, &cap.rgb, None, &full)?;+ let mut out = json!({+ "success": true, "savedTo": norm_path(&full), "fullPath": norm_path(&full), "capturedBy": "local:x11",+ "fullWidth": fw, "fullHeight": fh, "sizeKB": std::fs::metadata(&full).map(|m| m.len() / 1024).unwrap_or(0),+ "coordMap": {"windowX": rect.0, "windowY": rect.1, "imageScale": 1.0, "note": "image pixel (px, py) is screen (windowX + px, windowY + py)"},+ });+ if let Some(m) = resize_max.filter(|m| *m > 0 && *m < cap.width) {+ let small = dir.join(format!("{stamp}-{safe_label}-safe.png"));+ let (sw, sh) = kicad_core::sch_render::save_rgb_png(cap.width, cap.height, &cap.rgb, Some(m), &small)?;+ out["safePath"] = json!(norm_path(&small));+ out["safeWidth"] = json!(sw);+ out["safeHeight"] = json!(sh);+ out["coordMap"]["safeScale"] = json!(sw as f64 / fw as f64);+ }+ Ok(out)+}++fn norm_path(p: &Path) -> String {+ p.to_string_lossy().replace('\\', "/")+}+ fn screenshot_all(state: &mut State) -> Value { let info = window_info(state, &json!({})); if info["success"] != json!(true) {
rust/crates/kicad-core/src/install.rs+42@@ -333,6 +333,30 @@ pub fn heal_lib_tables(config_dir: &Path, base_dir: Option<&str>) -> Value { Value::Object(out) } +/// Create each MISSING global library table (sym-lib-table, fp-lib-table and, for KiCad 10,+/// design-block-lib-table) in `config_dir`, from KiCad's own template when it ships one, else+/// as an empty table. Without all of them KiCad 10 opens its modal "KiCad Setup" wizard ("some+/// of its configuration files are missing") on launch; a distro package's template may not+/// ship design-block-lib-table. Existing tables are never touched. Returns one line per table written.+pub fn ensure_global_tables(config_dir: &Path, base_dir: Option<&str>, version: &str) -> Vec<String> {+ let mut out = Vec::new();+ let kicad10 = crate::updates::version_tuple(version).first().copied().unwrap_or(0) >= 10;+ for (name, root) in [("sym-lib-table", "sym_lib_table"), ("fp-lib-table", "fp_lib_table"), ("design-block-lib-table", "design_block_lib_table")] {+ if name == "design-block-lib-table" && !kicad10 {+ continue;+ }+ let dest = config_dir.join(name);+ if dest.exists() {+ continue;+ }+ out.push(match libraries::ensure_lib_table(&dest, root, base_dir) {+ Ok(()) => format!("global {name} created (it was missing; KiCad's setup wizard opens without it)"),+ Err(e) => format!("could not create the global {name}: {e}"),+ });+ }+ out+}+ /// The dirs KiCad creates on its first launch, created ahead of it so an install /// works on a never-launched KiCad: `<config>/`, `<user>/symbols`, /// `<user>/footprints`, then the lib tables seeded and healed. Only runs when the@@ -2291,6 +2315,24 @@ mod tests { assert!(b64_decode("a*k=").is_err()); } + #[test]+ fn missing_global_tables_are_created_and_existing_ones_kept() {+ let dir = std::env::temp_dir().join(format!("kicad-core-tables-{}", std::process::id()));+ let _ = std::fs::remove_dir_all(&dir);+ std::fs::create_dir_all(&dir).unwrap();+ std::fs::write(dir.join("sym-lib-table"), "(sym_lib_table\n (version 7)\n (lib (name \"Mine\"))\n)\n").unwrap();+ let lines = ensure_global_tables(&dir, None, "10.0");+ assert_eq!(lines.len(), 2, "fp and design-block written, sym kept: {lines:?}");+ assert!(std::fs::read_to_string(dir.join("sym-lib-table")).unwrap().contains("Mine"));+ assert!(std::fs::read_to_string(dir.join("design-block-lib-table")).unwrap().starts_with("(design_block_lib_table"));+ assert!(ensure_global_tables(&dir, None, "10.0").is_empty(), "idempotent");+ let nine = dir.join("nine");+ let lines = ensure_global_tables(&nine, None, "9.0");+ assert_eq!(lines.len(), 2);+ assert!(!nine.join("design-block-lib-table").exists(), "KiCad 9 has no design-block table");+ let _ = std::fs::remove_dir_all(&dir);+ }+ #[test] fn heal_lib_tables_seeds_and_merges() { let bx = fake_box("heal");
rust/crates/kicad-core/src/pcb.rs+12−1@@ -1311,7 +1311,7 @@ fn clear_stale_lock(path: &Path) { if crate::install_kicad::kicad_running().0 != Some(false) { return; }- let here = std::env::var("COMPUTERNAME").or_else(|_| std::env::var("HOSTNAME")).unwrap_or_default();+ let here = this_hostname(); let text = std::fs::read_to_string(&lck).unwrap_or_default(); let host = serde_json::from_str::<Value>(&text).ok().and_then(|v| v["hostname"].as_str().map(str::to_string)); // a lock that names no machine is KiCad's own on this one; one that names a machine must name this one@@ -1320,6 +1320,17 @@ fn clear_stale_lock(path: &Path) { } } +/// This machine's name as KiCad writes it into a lock file. Windows exports COMPUTERNAME; on Linux+/// HOSTNAME is a shell variable a service never sees, so the kernel's own name is read instead.+pub fn this_hostname() -> String {+ std::env::var("COMPUTERNAME")+ .or_else(|_| std::env::var("HOSTNAME"))+ .ok()+ .filter(|h| !h.is_empty())+ .or_else(|| std::fs::read_to_string("/proc/sys/kernel/hostname").ok().map(|h| h.trim().to_string()))+ .unwrap_or_default()+}+ /// A loaded board: path, the exact text on disk and the parse of it. #[derive(Debug)] pub struct Loaded {
rust/crates/kicad-core/src/sch_render.rs+23@@ -258,6 +258,29 @@ pub fn save_png(p: &Pixmap, path: &Path) -> Result<(), String> { crate::libraries::write_atomic(path, &bytes) } +/// Write top-down RGB8 pixels as a PNG, scaled down (nearest pixel) so the width is at most+/// `max_width`. Returns the written (width, height).+pub fn save_rgb_png(width: u32, height: u32, rgb: &[u8], max_width: Option<u32>, path: &Path) -> Result<(u32, u32), String> {+ if width == 0 || height == 0 || rgb.len() < (width * height * 3) as usize {+ return Err(format!("bad image: {width}x{height} with {} bytes", rgb.len()));+ }+ let scale = max_width.filter(|m| *m > 0 && *m < width).map(|m| m as f64 / width as f64).unwrap_or(1.0);+ let (w, h) = (((width as f64 * scale).round() as u32).max(1), ((height as f64 * scale).round() as u32).max(1));+ let mut pm = Pixmap::new(w, h).ok_or_else(|| format!("cannot allocate a {w}x{h} image"))?;+ let data = pm.data_mut();+ for y in 0..h {+ let sy = ((y as f64 / scale) as u32).min(height - 1);+ for x in 0..w {+ let sx = ((x as f64 / scale) as u32).min(width - 1);+ let si = ((sy * width + sx) * 3) as usize;+ let di = ((y * w + x) * 4) as usize;+ data[di..di + 4].copy_from_slice(&[rgb[si], rgb[si + 1], rgb[si + 2], 255]);+ }+ }+ save_png(&pm, path)?;+ Ok((w, h))+}+ pub fn load_png(path: &Path) -> Result<Pixmap, String> { Pixmap::load_png(path).map_err(|e| format!("cannot read {}: {e}", path.display())) }
rust/crates/kicad-core/src/spice.rs+8−1@@ -341,7 +341,13 @@ pub fn analysis_text(a: &Value) -> Result<String, String> { if !["dec", "oct", "lin"].contains(&sweep) { return Err("an ac sweep is dec, oct or lin".into()); }- Ok(format!(".ac {sweep} {} {} {}", num("points")? as u64, g(num("start")?), g(num("stop")?)))+ let (start, stop) = (num("start")?, num("stop")?);+ // One frequency: a decade or octave sweep from f to f gives no points on some ngspice+ // versions (42, the Ubuntu 24.04 library); one linear point at f is the same answer everywhere.+ if start == stop && sweep != "lin" {+ return Ok(format!(".ac lin 1 {} {}", g(start), g(stop)));+ }+ Ok(format!(".ac {sweep} {} {} {}", num("points")? as u64, g(start), g(stop))) } "tran" => Ok(match a.get("start").and_then(Value::as_f64) { Some(st) => format!(".tran {} {} {}", g(num("step")?), g(num("stop")?), g(st)),@@ -511,6 +517,7 @@ mod tests { #[test] fn the_standard_shape_of_analyses_probes_and_signals() { assert_eq!(analysis_text(&json!({"type": "ac", "sweep": "dec", "points": 20, "start": 10, "stop": 1000000})).unwrap(), ".ac dec 20 10 1000000");+ assert_eq!(analysis_text(&json!({"type": "ac", "sweep": "dec", "points": 1, "start": 1000, "stop": 1000})).unwrap(), ".ac lin 1 1000 1000", "one frequency is one linear point"); assert_eq!(analysis_text(&json!({"type": "tran", "step": 1e-5, "stop": 0.005})).unwrap(), ".tran 0.00001 0.005"); assert_eq!(analysis_text(&json!({"type": "dc", "source": "V1", "start": 0, "stop": 5, "step": 0.1})).unwrap(), ".dc V1 0 5 0.1"); assert_eq!(analysis_text(&json!({"type": "op"})).unwrap(), ".op");
rust/crates/kicad-platform/Cargo.toml+5−1@@ -43,5 +43,9 @@ uiautomation = "0.24" # macOS-only crates go here (Kyle). Candidates: objc2, core-foundation, accessibility. [target.'cfg(target_os = "macos")'.dependencies] -# Linux-only crates go here (Barrett). Candidates: x11rb, zbus (portal screenshots), atspi.+# Linux-only crates go here. x11rb is pure Rust (no libX11/libxcb link): KiCad's windows are X11+# windows on X11 desktops and XWayland windows on Wayland desktops (src/linux/x11.rs). [target.'cfg(target_os = "linux")'.dependencies]+x11rb = { version = "0.13", default-features = false, features = ["screensaver", "xtest"] }+# AT-SPI (the accessibility bus, Linux's UI Automation): dialog text, buttons, menus. Pure Rust D-Bus.+zbus = { version = "5", default-features = false, features = ["blocking-api", "async-io"] }
rust/crates/kicad-platform/examples/linux_probe.rsadded+17@@ -0,0 +1,17 @@+//! Developer probe (Linux): time the window layer against a live desktop.+//! cargo run -p kicad-platform --example linux_probe -- <list|children|menu|click> [hwnd] [label]+fn main() {+ let a: Vec<String> = std::env::args().collect();+ let p = kicad_platform::native();+ let h: u64 = a.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);+ let t = std::time::Instant::now();+ match a.get(1).map(String::as_str).unwrap_or("list") {+ "list" => for w in p.list_windows().unwrap() { println!("{} {:?} {} pid={} owner={} {:?}", w.hwnd, w.title, w.class_name, w.pid, w.owner, w.rect) },+ "children" => for c in p.child_windows(h).unwrap() { println!("{} {} {:?}", c.hwnd, c.class_name, c.title) },+ "menu" => { let m = p.menu_tree(h).unwrap(); println!("{} items; first: {:?}", m.len(), m.iter().take(5).map(|i| (i.path.join(" > "), i.accel.clone())).collect::<Vec<_>>()) }+ "click" => println!("{:?}", p.click_button(h, &[a.get(3).map(String::as_str).unwrap_or("cancel")])),+ "key" => println!("{:?}", p.post_key(h, a.get(3).and_then(|s| u16::from_str_radix(s.trim_start_matches("0x"), 16).ok()).unwrap_or(0x0D), None)),+ other => eprintln!("unknown {other}"),+ }+ eprintln!("took {:?}", t.elapsed());+}
rust/crates/kicad-platform/src/lib.rs+16@@ -50,6 +50,10 @@ pub struct Capabilities { pub ipc_api: bool, #[serde(rename = "silentInstall")] pub silent_install: bool,+ /// Linux: the desktop session, "x11" or "wayland" ("none" without a display). KiCad's+ /// windows are X windows in both (XWayland on Wayland). Empty, and omitted, elsewhere.+ #[serde(skip_serializing_if = "str::is_empty")]+ pub session: &'static str, } /// One top-level (or child) window as the OS reports it.@@ -176,6 +180,9 @@ pub trait Platform: Sync + Send { fn uia_set_checked(&self, _hwnd: u64, _name: &str, _on: bool) -> Result<String, String> { nope("uia_set_checked") } /// UI Automation: names of descendants matching a substring (bounded). fn uia_find(&self, _hwnd: u64, _name: &str, _limit: usize) -> Result<Vec<String>, String> { nope("uia_find") }+ /// Activate (as a double-click does) the selected row of the first tree in the window: the+ /// library tree of the symbol and footprint editors. Linux (AT-SPI); Windows navigates with keys.+ fn tree_activate_selected(&self, _hwnd: u64) -> Result<String, String> { nope("tree_activate_selected") } fn foreground(&self) -> Result<u64, String> { nope("foreground") } /// The window on top of the Z order (first visible, non-minimized, titled top-level /// window), which is what the user sees covering everything else; it can differ from@@ -200,6 +207,15 @@ pub trait Platform: Sync + Send { fn gl_canvas_rect(&self, _hwnd: u64) -> Result<Option<(i32, i32, i32, i32)>, String> { nope("gl_canvas_rect") } /// Seconds since the user last touched keyboard or mouse. fn seconds_since_input(&self) -> Result<f64, String> { nope("seconds_since_input") }+ /// KiCad's ngspice shared library for kicad_simulate, given the install's kicad-cli: Windows ships+ /// `<bin>/ngspice.dll` beside it; Linux uses the system's libngspice.+ fn ngspice_library(&self, kicad_cli: &Path) -> PathBuf {+ kicad_cli.parent().unwrap_or(Path::new(".")).join("ngspice.dll")+ }+ /// Where KiCad's XSPICE code models (*.cm) live: `<kicad>/lib/ngspice` on Windows.+ fn ngspice_codemodel_dir(&self, kicad_cli: &Path) -> PathBuf {+ kicad_cli.parent().and_then(Path::parent).map(|b| b.join("lib").join("ngspice")).unwrap_or_default()+ } /// Set process DPI awareness once at startup (no-op where irrelevant). fn init_process(&self) {}
rust/crates/kicad-platform/src/linux.rs+169−11@@ -1,17 +1,25 @@-//! Linux implementation (owner: Barrett). Headless parts are complete: kicad-cli on PATH,-//! /usr and /usr/local, snap and flatpak locations, XDG config, ~/Documents/KiCad/<ver>.-//! Window control on Linux depends on the session (X11 versus Wayland portals); add it-//! here in phase 3 and flip the capability flags.+//! Linux implementation. Headless parts: kicad-cli on PATH, /usr and /usr/local, snap and+//! flatpak locations, XDG config, ~/Documents/KiCad/<ver>. Window control: `x11.rs`, the+//! X server's view of KiCad's windows, which covers X11 desktops and Wayland desktops alike+//! (KiCad runs under XWayland there).++mod atspi;+mod x11;+use x11::session_type; use std::path::{Path, PathBuf}; use std::process::Command; use serde_json::{json, Value}; -use crate::{install_from_cli, major_minor, Capabilities, KicadInstall, Platform, ProcessInfo};+use crate::{install_from_cli, major_minor, Capabilities, Capture, KicadInstall, MenuItem, Placement, Platform, ProcessInfo, WindowInfo}; pub struct Native; +/// The background guard (setting `backgroundGuard`), off by default as on Windows (wiki #114).+/// KiCad still opens in the background on Linux without it: see spawn_background.+static GUARD_ENABLED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);+ fn home() -> Option<PathBuf> { std::env::var("HOME").ok().map(PathBuf::from) }@@ -20,14 +28,17 @@ impl Platform for Native { fn capabilities(&self) -> Capabilities { Capabilities { os: "linux",- owner: "Barrett (Ubuntu build of the bridge)",+ owner: "the KiCad bridge's Linux build", kicad_cli: true,- window_control: false,- ui_automation: false,- dialog_sweep: false,+ // KiCad's windows are X windows on every Linux desktop (XWayland on Wayland).+ window_control: std::env::var_os("DISPLAY").is_some_and(|d| !d.is_empty()),+ // AT-SPI on the session's accessibility bus (atspi.rs): dialog text and buttons, menus.+ ui_automation: std::env::var_os("DBUS_SESSION_BUS_ADDRESS").is_some(),+ dialog_sweep: std::env::var_os("DBUS_SESSION_BUS_ADDRESS").is_some(), focus_etiquette: false, ipc_api: true, silent_install: false,+ session: session_type(), } } fn cli_file_name(&self) -> &'static str {@@ -59,7 +70,10 @@ impl Platform for Native { } let have_flatpak = !out.is_empty(); for (c, source) in candidates {- if out.iter().any(|i: &KicadInstall| i.kicad_cli == crate::norm(&c)) {+ // One binary seen twice is one install: /bin is a link to /usr/bin on merged-/usr+ // distros (Ubuntu, Debian, Fedora), and PATH names both.+ let real = std::fs::canonicalize(&c).unwrap_or_else(|_| c.clone());+ if out.iter().any(|i: &KicadInstall| i.kicad_cli == crate::norm(&c) || std::fs::canonicalize(&i.kicad_cli).is_ok_and(|p| p == real)) { continue; } // A user's own wrapper around the same Flatpak adds nothing over ours, and theirs@@ -132,8 +146,30 @@ impl Platform for Native { } fn spawn_background(&self, exe: &Path, args: &[String], env: &[(String, String)], log: Option<&Path>) -> Result<u32, String> { use std::os::unix::process::CommandExt;- let mut cmd = Command::new(exe);+ // Its own systemd scope where the user session has systemd: the bridge, and Adom Bridge+ // with it, run inside Adom Bridge's service, and a KiCad left in that service is part of it.+ // When the kernel's OOM killer picked a large KiCad there, systemd stopped the whole service+ // and Adom Bridge went down with it (measured on a 6 GB VM). In its own scope KiCad+ // also outlives a bridge restart. systemd-run --scope execs the program, so the pid is KiCad's.+ let mut cmd = match user_systemd_run() {+ Some(run) => {+ let name = exe.file_name().map(|n| n.to_string_lossy().chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').collect::<String>()).unwrap_or_default();+ let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0);+ let mut c = Command::new(run);+ c.args(["--user", "--scope", "--quiet", "--collect", &format!("--unit=adom-kicad-{name}-{stamp}"), "--"]).arg(exe);+ c+ }+ None => Command::new(exe),+ }; cmd.args(args).stdin(std::process::Stdio::null());+ // Open in the background: GTK stamps its first windows with the user time named in the+ // startup id, and the window manager (Mutter, Muffin, KWin) does not give focus to a+ // window whose user time is older than the user's last input. Without this a KiCad the+ // bridge starts takes keyboard focus from whatever the user is typing in (measured on+ // GNOME Wayland). A foreground the caller asks for, with a reason, is a separate request.+ if !env.iter().any(|(k, _)| k == "DESKTOP_STARTUP_ID") {+ cmd.env("DESKTOP_STARTUP_ID", "adom-kicad-bridge_TIME1");+ } for (k, v) in env { cmd.env(k, v); }@@ -171,6 +207,119 @@ impl Platform for Native { Ok(()) } + // ---- kicad_simulate: KiCad on Linux links the distribution's libngspice ----++ fn ngspice_library(&self, _kicad_cli: &Path) -> PathBuf {+ const DIRS: &[&str] = &["/usr/lib/x86_64-linux-gnu", "/usr/lib64", "/usr/lib", "/usr/local/lib", "/usr/local/lib64"];+ DIRS.iter()+ .flat_map(|d| ["libngspice.so.0", "libngspice.so"].map(|n| Path::new(d).join(n)))+ .find(|p| p.exists())+ .unwrap_or_else(|| PathBuf::from("/usr/lib/x86_64-linux-gnu/libngspice.so.0"))+ }+ fn ngspice_codemodel_dir(&self, _kicad_cli: &Path) -> PathBuf {+ ["/usr/lib/x86_64-linux-gnu/ngspice", "/usr/lib64/ngspice", "/usr/lib/ngspice", "/usr/local/lib/ngspice"].iter().map(PathBuf::from).find(|p| p.is_dir()).unwrap_or_default()+ }++ // ---- Window control (x11.rs) ----++ fn list_windows(&self) -> Result<Vec<WindowInfo>, String> {+ x11::list_windows()+ }+ fn window_info(&self, h: u64) -> Result<Option<WindowInfo>, String> {+ x11::window_info(h)+ }+ fn child_windows(&self, h: u64) -> Result<Vec<WindowInfo>, String> {+ let mut out = x11::child_windows(h)?;+ // A dialog's message lines, from the accessibility bus, as "Static" children: the shape the+ // dialog-body reader takes on every OS. (Frames are skipped: their trees are thousands deep.)+ if x11::window_info(h)?.is_some_and(|w| w.owner != 0) {+ if let Ok(texts) = atspi::dialog_texts(h) {+ out.extend(texts.into_iter().map(|t| WindowInfo { hwnd: 0, title: t, class_name: "Static".into(), owner: h, visible: true, ..Default::default() }));+ }+ }+ Ok(out)+ }+ fn post_key(&self, h: u64, vk: u16, ch: Option<char>) -> Result<(), String> {+ x11::post_key(h, vk, ch)+ }+ fn click_button(&self, dialog: u64, labels: &[&str]) -> Result<String, String> {+ atspi::click_button(dialog, labels)+ }+ fn uia_invoke(&self, h: u64, name: &str) -> Result<String, String> {+ atspi::invoke(h, name)+ }+ fn uia_set_value(&self, h: u64, name: &str, text: &str) -> Result<(), String> {+ atspi::set_value(h, name, text)+ }+ fn uia_set_checked(&self, h: u64, name: &str, on: bool) -> Result<String, String> {+ atspi::set_checked(h, name, on)+ }+ fn uia_find(&self, h: u64, name: &str, limit: usize) -> Result<Vec<String>, String> {+ atspi::find_names(h, name, limit)+ }+ fn tree_activate_selected(&self, h: u64) -> Result<String, String> {+ atspi::tree_activate_selected(h)+ }+ fn menu_tree(&self, h: u64) -> Result<Vec<MenuItem>, String> {+ atspi::menu_tree(h)+ }+ fn menu_invoke(&self, h: u64, id: u32) -> Result<(), String> {+ atspi::menu_invoke(h, id)+ }+ fn responsive(&self, h: u64, _timeout_ms: u32) -> Result<bool, String> {+ // X has no WM_NULL: a window answers unless its process is stopped (x11 marks that `hung`).+ Ok(x11::window_info(h)?.is_some_and(|w| !w.hung))+ }+ fn post_close(&self, h: u64) -> Result<(), String> {+ x11::post_close(h)+ }+ fn foreground(&self) -> Result<u64, String> {+ x11::foreground()+ }+ fn z_top(&self) -> Result<u64, String> {+ x11::z_top()+ }+ fn push_to_background(&self, h: u64) -> Result<(), String> {+ x11::push_to_background(h)+ }+ fn show_no_activate(&self, h: u64) -> Result<(), String> {+ x11::show_no_activate(h)+ }+ fn bring_to_front(&self, h: u64) -> Result<bool, String> {+ x11::activate(h, 800)+ }+ fn etiquette_set_enabled(&self, on: bool) {+ GUARD_ENABLED.store(on, std::sync::atomic::Ordering::Relaxed)+ }+ fn etiquette_enabled(&self) -> bool {+ GUARD_ENABLED.load(std::sync::atomic::Ordering::Relaxed)+ }+ fn is_shell_window(&self, h: u64) -> bool {+ h != 0 && x11::is_shell_window(h)+ }+ fn request_foreground(&self, h: u64) -> bool {+ // 0 is "a window X cannot see" (a native Wayland app had focus): nothing to ask for.+ h != 0 && x11::activate(h, 300).unwrap_or(false)+ }+ fn placement(&self, h: u64) -> Result<Placement, String> {+ x11::placement(h)+ }+ fn set_placement(&self, h: u64, p: &Placement) -> Result<(), String> {+ x11::set_placement(h, p)+ }+ fn on_a_monitor(&self, rect: (i32, i32, i32, i32)) -> Result<bool, String> {+ x11::on_a_monitor(rect)+ }+ fn capture_window(&self, h: u64) -> Result<Capture, String> {+ x11::capture_window(h)+ }+ fn gl_canvas_rect(&self, h: u64) -> Result<Option<(i32, i32, i32, i32)>, String> {+ x11::gl_canvas_rect(h)+ }+ fn seconds_since_input(&self) -> Result<f64, String> {+ x11::seconds_since_input()+ }+ // ---- Phase 3b: kicad_upgrade. No silent installer path here: KiCad on Linux comes // from the distro package, the PPA, snap or flatpak. The verb explains that; these four keep the facts honest. @@ -230,6 +379,15 @@ impl Platform for Native { } } +/// systemd-run, when this user's systemd instance is up (its private socket exists).+fn user_systemd_run() -> Option<PathBuf> {+ let rt = std::env::var_os("XDG_RUNTIME_DIR").filter(|v| !v.is_empty())?;+ if !Path::new(&rt).join("systemd/private").exists() {+ return None;+ }+ ["/usr/bin/systemd-run", "/bin/systemd-run"].iter().map(PathBuf::from).find(|p| p.is_file())+}+ /// Field `n` after the `(comm)` of /proc/<pid>/stat: 0 = state, 1 = ppid. fn stat_field(stat: &str, n: usize) -> Option<String> { stat.get(stat.rfind(')')? + 1..)?.split_whitespace().nth(n).map(str::to_string)
rust/crates/kicad-platform/src/linux/atspi.rsadded+493@@ -0,0 +1,493 @@+//! KiCad's widgets on Linux through AT-SPI, the desktop accessibility bus: the Linux+//! counterpart of UI Automation on Windows. wxGTK builds KiCad's menus, dialogs, buttons,+//! labels and check boxes from GTK widgets, and GTK publishes every one of them on the+//! accessibility bus with its name, role, state and actions. The bridge reads a dialog's+//! text and presses its buttons, walks and invokes the menu bar, and sets text fields this+//! way, all without moving, raising or focusing a window.+//!+//! The X window (from x11.rs) is matched to its AT-SPI frame by process id and title.+//! Pure Rust D-Bus (zbus, blocking API).++use std::collections::VecDeque;+use std::sync::Mutex;+use std::time::{Duration, Instant};++use zbus::blocking::Connection;+use zbus::zvariant::{OwnedObjectPath, OwnedValue};++use crate::{MenuItem, WindowInfo};++const ACCESSIBLE: &str = "org.a11y.atspi.Accessible";+const ACTION: &str = "org.a11y.atspi.Action";+const TEXT: &str = "org.a11y.atspi.Text";+const EDITABLE: &str = "org.a11y.atspi.EditableText";+const COMPONENT: &str = "org.a11y.atspi.Component";+const ROOT: &str = "/org/a11y/atspi/accessible/root";+/// A walk never visits more than this many nodes (a whole editor frame is ~3000).+const MAX_NODES: usize = 8000;+/// ... nor runs longer than this.+const WALK_DEADLINE: Duration = Duration::from_secs(8);+/// One D-Bus call never waits longer than this: a busy or frozen application must not hold a verb.+const CALL_TIMEOUT: Duration = Duration::from_secs(3);++// AT-SPI state bits (atspi-constants.h, AtspiStateType).+const STATE_CHECKED: u32 = 4;+const STATE_ENABLED: u32 = 8;+const STATE_SHOWING: u32 = 25;++static BUS: Mutex<Option<Connection>> = Mutex::new(None);++/// Processes whose accessibility calls just timed out, and when. A GTK application stops+/// answering the bus while a GTK message box runs its own loop; skip it for a while instead+/// of paying the call timeout on every attempt.+static SILENT: Mutex<Vec<(u32, Instant)>> = Mutex::new(Vec::new());+const SILENT_FOR: Duration = Duration::from_secs(5);++fn is_silent(pid: u32) -> bool {+ let mut g = SILENT.lock().unwrap_or_else(|p| p.into_inner());+ g.retain(|(_, t)| t.elapsed() < SILENT_FOR);+ g.iter().any(|(p, _)| *p == pid)+}++fn mark_silent(pid: u32) {+ let mut g = SILENT.lock().unwrap_or_else(|p| p.into_inner());+ g.retain(|(p, _)| *p != pid);+ g.push((pid, Instant::now()));+}++/// One accessible object: (D-Bus name of its application, object path).+#[derive(Clone, Debug, PartialEq, Eq)]+pub struct Node {+ pub bus: String,+ pub path: OwnedObjectPath,+}++fn a11y_bus() -> Result<Connection, String> {+ let mut g = BUS.lock().unwrap_or_else(|p| p.into_inner());+ if let Some(c) = g.as_ref() {+ // still alive? (the a11y bus restarts with the session)+ if c.call_method(Some("org.freedesktop.DBus"), "/org/freedesktop/DBus", Some("org.freedesktop.DBus"), "GetId", &()).is_ok() {+ return Ok(c.clone());+ }+ }+ let session = zbus::blocking::connection::Builder::session()+ .and_then(|b| b.method_timeout(CALL_TIMEOUT).build())+ .map_err(|e| format!("no D-Bus session bus (DBUS_SESSION_BUS_ADDRESS): {e}"))?;+ let addr: String = session+ .call_method(Some("org.a11y.Bus"), "/org/a11y/bus", Some("org.a11y.Bus"), "GetAddress", &())+ .and_then(|m| m.body().deserialize())+ .map_err(|e| format!("the accessibility bus (org.a11y.Bus) did not answer: {e}. Is at-spi2-core installed and the desktop session running?"))?;+ let c = zbus::blocking::connection::Builder::address(addr.as_str())+ .and_then(|b| b.method_timeout(CALL_TIMEOUT).build())+ .map_err(|e| format!("cannot connect to the accessibility bus at {addr}: {e}"))?;+ *g = Some(c.clone());+ Ok(c)+}++struct Walker {+ c: Connection,+}++impl Walker {+ fn new() -> Result<Self, String> {+ Ok(Walker { c: a11y_bus()? })+ }+ fn call<B: serde::Serialize + zbus::zvariant::DynamicType, R: for<'d> serde::Deserialize<'d> + zbus::zvariant::Type>(&self, n: &Node, iface: &str, method: &str, body: &B) -> Result<R, String> {+ self.c+ .call_method(Some(n.bus.as_str()), n.path.as_str(), Some(iface), method, body)+ .map_err(|e| format!("{iface}.{method}: {e}"))?+ .body()+ .deserialize::<R>()+ .map_err(|e| format!("{iface}.{method} reply: {e}"))+ }+ fn prop(&self, n: &Node, iface: &str, name: &str) -> Result<OwnedValue, String> {+ self.c+ .call_method(Some(n.bus.as_str()), n.path.as_str(), Some("org.freedesktop.DBus.Properties"), "Get", &(iface, name))+ .map_err(|e| format!("{iface}.{name}: {e}"))?+ .body()+ .deserialize::<OwnedValue>()+ .map_err(|e| e.to_string())+ }+ fn name(&self, n: &Node) -> String {+ self.prop(n, ACCESSIBLE, "Name").ok().and_then(|v| String::try_from(v).ok()).unwrap_or_default()+ }+ fn role(&self, n: &Node) -> String {+ self.call::<_, String>(n, ACCESSIBLE, "GetRoleName", &()).unwrap_or_default()+ }+ fn children(&self, n: &Node) -> Vec<Node> {+ self.call::<_, Vec<(String, OwnedObjectPath)>>(n, ACCESSIBLE, "GetChildren", &()).unwrap_or_default().into_iter().map(|(bus, path)| Node { bus, path }).collect()+ }+ fn states(&self, n: &Node) -> u64 {+ let v: Vec<u32> = self.call(n, ACCESSIBLE, "GetState", &()).unwrap_or_default();+ v.first().copied().unwrap_or(0) as u64 | (v.get(1).copied().unwrap_or(0) as u64) << 32+ }+ fn has_state(&self, n: &Node, bit: u32) -> bool {+ self.states(n) & (1u64 << bit) != 0+ }+ /// (name, keybinding) of every action.+ fn actions(&self, n: &Node) -> Vec<(String, String)> {+ self.call::<_, Vec<(String, String, String)>>(n, ACTION, "GetActions", &()).unwrap_or_default().into_iter().map(|(name, _desc, key)| (name, key)).collect()+ }+ fn do_action(&self, n: &Node, preferred: &[&str]) -> Result<String, String> {+ let acts = self.actions(n);+ if acts.is_empty() {+ return Err("not_invokable: the control has no AT-SPI action".into());+ }+ // GTK names its actions "click", ATK's table cells "Activate": match without case+ let i = preferred.iter().find_map(|p| acts.iter().position(|(a, _)| a.eq_ignore_ascii_case(p))).unwrap_or(0);+ let ok: bool = self.call(n, ACTION, "DoAction", &(i as i32))?;+ if ok {+ Ok(acts[i].0.clone())+ } else {+ Err(format!("the control refused its {:?} action", acts[i].0))+ }+ }+ fn text(&self, n: &Node) -> Option<String> {+ let count = self.prop(n, TEXT, "CharacterCount").ok().and_then(|v| i32::try_from(v).ok())?;+ self.call::<_, String>(n, TEXT, "GetText", &(0i32, count)).ok()+ }+ fn extents(&self, n: &Node) -> Option<(i32, i32, i32, i32)> {+ self.call::<_, (i32, i32, i32, i32)>(n, COMPONENT, "GetExtents", &(0u32,)).ok()+ }+ fn pid_of_bus(&self, bus: &str) -> Option<u32> {+ self.c+ .call_method(Some("org.freedesktop.DBus"), "/org/freedesktop/DBus", Some("org.freedesktop.DBus"), "GetConnectionUnixProcessID", &(bus,))+ .ok()?+ .body()+ .deserialize::<u32>()+ .ok()+ }+ /// Breadth-first walk under `top`, calling `visit(node, role, name, depth, path)` with the+ /// names of the ancestors; `visit` returns false to stop.+ fn walk(&self, top: &Node, mut visit: impl FnMut(&Node, &str, &str, &[String]) -> bool) {+ let started = Instant::now();+ let mut q: VecDeque<(Node, Vec<String>)> = VecDeque::from([(top.clone(), Vec::new())]);+ let mut seen = 0usize;+ while let Some((n, trail)) = q.pop_front() {+ seen += 1;+ if seen > MAX_NODES || started.elapsed() > WALK_DEADLINE {+ return;+ }+ let role = self.role(&n);+ let name = self.name(&n);+ if !visit(&n, &role, &name, &trail) {+ return;+ }+ let mut t = trail.clone();+ t.push(name);+ for c in self.children(&n) {+ q.push_back((c, t.clone()));+ }+ }+ }+}++const WINDOW_ROLES: &[&str] = &["frame", "dialog", "alert", "window", "file chooser"];++/// The AT-SPI frame of an X window: same process, same title (the window's on-screen box+/// breaks a tie between equal titles).+fn frame_of(w: &Walker, win: &WindowInfo) -> Result<Node, String> {+ if win.pid == 0 {+ return Err(format!("window {} has no _NET_WM_PID, so its accessible frame cannot be found", win.hwnd));+ }+ if is_silent(win.pid) {+ return Err(format!("not_answering: process {} is not answering the accessibility bus right now (a GTK message box blocks it); read the dialog from its screenshot", win.pid));+ }+ let registry = Node { bus: "org.a11y.atspi.Registry".into(), path: OwnedObjectPath::try_from(ROOT).map_err(|e| e.to_string())? };+ let mut candidates = Vec::new();+ for app in w.children(®istry) {+ if w.pid_of_bus(&app.bus) != Some(win.pid) {+ continue;+ }+ // A window that just opened keeps KiCad busy for a moment: one timeout is "busy", two in a+ // row (with a pause between) is a GTK message box holding the application.+ let timed_out = |e: &str| e.contains("imed out") || e.contains("imeout");+ let mut got = w.call::<_, Vec<(String, OwnedObjectPath)>>(&app, ACCESSIBLE, "GetChildren", &());+ if got.as_ref().err().is_some_and(|e| timed_out(e)) {+ std::thread::sleep(Duration::from_millis(1500));+ got = w.call::<_, Vec<(String, OwnedObjectPath)>>(&app, ACCESSIBLE, "GetChildren", &());+ }+ let tops = match got {+ Ok(v) => v.into_iter().map(|(bus, path)| Node { bus, path }).collect::<Vec<_>>(),+ Err(e) if timed_out(&e) => {+ mark_silent(win.pid);+ return Err(format!("not_answering: process {} did not answer the accessibility bus within {:?} (a GTK message box blocks it); read the dialog from its screenshot", win.pid, CALL_TIMEOUT));+ }+ Err(_) => Vec::new(),+ };+ for top in tops {+ if WINDOW_ROLES.contains(&w.role(&top).as_str()) && w.name(&top) == win.title {+ candidates.push(top);+ }+ }+ }+ match candidates.len() {+ 0 => Err(format!(+ "control_not_found: no accessible window titled {:?} in process {} on the accessibility bus (GTK publishes its widgets there when at-spi2 runs in the session)",+ win.title, win.pid+ )),+ 1 => Ok(candidates.remove(0)),+ _ => {+ let (x, y, ww, hh) = win.rect;+ let score = |n: &Node| w.extents(n).map(|(a, b, c, d)| (a - x).abs() + (b - y).abs() + (c - ww).abs() + (d - hh).abs()).unwrap_or(i32::MAX);+ candidates.sort_by_key(score);+ Ok(candidates.remove(0))+ }+ }+}++fn frame_for(h: u64) -> Result<(Walker, Node), String> {+ let win = super::x11::window_info(h)?.ok_or_else(|| format!("window {h} is gone"))?;+ let w = Walker::new()?;+ let f = frame_of(&w, &win)?;+ Ok((w, f))+}++const SELECTION: &str = "org.a11y.atspi.Selection";++/// The selected row of the window's first tree (or list), activated the way a double-click does:+/// GTK answers the cell's "activate" action with row-activated.+pub fn tree_activate_selected(h: u64) -> Result<String, String> {+ let (w, f) = frame_for(h)?;+ let mut tree = None;+ w.walk(&f, |n, role, _, _| {+ if matches!(role, "tree table" | "tree" | "table" | "list") {+ tree = Some(n.clone());+ return false;+ }+ true+ });+ let tree = tree.ok_or_else(|| format!("control_not_found: no tree in window {h}"))?;+ let count: i32 = w.prop(&tree, SELECTION, "NSelectedChildren").ok().and_then(|v| i32::try_from(v).ok()).unwrap_or(0);+ if count < 1 {+ return Err("nothing_selected: the tree has no selected row".into());+ }+ let (bus, path): (String, OwnedObjectPath) = w.call(&tree, SELECTION, "GetSelectedChild", &(0i32,))?;+ let cell = Node { bus, path };+ let act = w.do_action(&cell, &["activate", "click"])?;+ Ok(format!("{act}:selected row"))+}++/// GTK writes a mnemonic as "_Save"; wx's "&Save" can survive in a name too.+fn clean(label: &str) -> String {+ label.replace(['_', '&'], "").trim().to_lowercase()+}++const BUTTON_ROLES: &[&str] = &["push button", "toggle button", "button"];++/// The dialog's message text: its labels, in order (the Linux stand-in for Win32 Static children).+pub fn dialog_texts(h: u64) -> Result<Vec<String>, String> {+ let (w, f) = frame_for(h)?;+ let mut out = Vec::new();+ w.walk(&f, |n, role, name, _| {+ // only what is on screen: a wizard keeps its other pages' labels in the tree, hidden+ if (role == "label" || role == "static") && w.has_state(n, STATE_SHOWING) {+ let t = if name.is_empty() { w.text(n).unwrap_or_default() } else { name.to_string() };+ if !t.trim().is_empty() {+ out.push(t);+ }+ }+ out.len() < 64+ });+ Ok(out)+}++/// Press a button whose caption is EXACTLY one of `labels` (mnemonic stripped, any case).+pub fn click_button(h: u64, labels: &[&str]) -> Result<String, String> {+ let (w, f) = frame_for(h)?;+ let wanted: Vec<String> = labels.iter().map(|l| clean(l)).collect();+ let mut hit: Option<(Node, String)> = None;+ let mut seen = Vec::new();+ w.walk(&f, |n, role, name, _| {+ if BUTTON_ROLES.contains(&role) {+ if wanted.contains(&clean(name)) && w.has_state(n, STATE_ENABLED) {+ hit = Some((n.clone(), name.to_string()));+ return false;+ }+ if !name.is_empty() {+ seen.push(name.to_string());+ }+ }+ true+ });+ let Some((n, name)) = hit else {+ return Err(format!("control_not_found: no button {labels:?} on {h} (buttons seen: {seen:?})"));+ };+ w.do_action(&n, &["click", "press", "activate"])?;+ Ok(format!("atspi_click:{name}"))+}++/// The first descendant whose name contains `name` (any case), preferring one with an action.+fn find(w: &Walker, f: &Node, name: &str, want: impl Fn(&Walker, &Node, &str) -> bool) -> Option<(Node, String)> {+ let needle = name.to_lowercase();+ let mut exact: Option<(Node, String)> = None;+ let mut first: Option<(Node, String)> = None;+ w.walk(f, |n, role, nm, _| {+ if nm.is_empty() || !nm.to_lowercase().contains(&needle) {+ return true;+ }+ if want(w, n, role) {+ if clean(nm) == clean(name) {+ exact = Some((n.clone(), nm.to_string()));+ return false;+ }+ first.get_or_insert((n.clone(), nm.to_string()));+ }+ true+ });+ exact.or(first)+}++pub fn invoke(h: u64, name: &str) -> Result<String, String> {+ let (w, f) = frame_for(h)?;+ let Some((n, found)) = find(&w, &f, name, |w, n, _| !w.actions(n).is_empty()) else {+ return Err(format!("control_not_found: no descendant of {h} named like {name:?} with an action"));+ };+ let act = w.do_action(&n, &["click", "press", "activate", "toggle"])?;+ Ok(format!("{act}:{found}"))+}++pub fn find_names(h: u64, name: &str, limit: usize) -> Result<Vec<String>, String> {+ let (w, f) = frame_for(h)?;+ let needle = name.to_lowercase();+ let mut out = Vec::new();+ w.walk(&f, |_, _, nm, _| {+ if !nm.is_empty() && nm.to_lowercase().contains(&needle) {+ out.push(nm.to_string());+ }+ out.len() < limit+ });+ Ok(out)+}++/// Replace a text field's contents: the field named like `name`, or (empty name) the first+/// editable text field in the window.+pub fn set_value(h: u64, name: &str, text: &str) -> Result<(), String> {+ let (w, f) = frame_for(h)?;+ let editable = |w: &Walker, n: &Node, _role: &str| w.call::<_, Vec<String>>(n, ACCESSIBLE, "GetInterfaces", &()).unwrap_or_default().iter().any(|i| i == EDITABLE);+ let target = if name.is_empty() {+ let mut t = None;+ w.walk(&f, |n, role, _, _| {+ if editable(&w, n, role) {+ t = Some((n.clone(), String::new()));+ return false;+ }+ true+ });+ t+ } else {+ find(&w, &f, name, |w, n, role| editable(w, n, role))+ };+ let Some((n, found)) = target else {+ return Err(format!("control_not_found: no editable text field named like {name:?} in {h}"));+ };+ let ok: bool = w.call(&n, EDITABLE, "SetTextContents", &(text,))?;+ if ok {+ Ok(())+ } else {+ Err(format!("not_settable: the field {found:?} refused the text"))+ }+}++/// Set the check box captioned exactly `name` on or off.+pub fn set_checked(h: u64, name: &str, on: bool) -> Result<String, String> {+ let (w, f) = frame_for(h)?;+ let want = clean(name);+ let mut hit = None;+ w.walk(&f, |n, role, nm, _| {+ if (role == "check box" || role == "toggle button" || role == "check menu item") && clean(nm) == want {+ hit = Some((n.clone(), nm.to_string()));+ return false;+ }+ true+ });+ let Some((n, found)) = hit else {+ return Err(format!("control_not_found: no check box {name:?} in {h}"));+ };+ if w.has_state(&n, STATE_CHECKED) == on {+ return Ok(format!("unchanged:{found}"));+ }+ w.do_action(&n, &["click", "toggle", "press"])?;+ Ok(format!("set:{found}={}", if on { "on" } else { "off" }))+}++/// GTK's "<Primary><Shift>e" or "<Control>s" keybinding as KiCad's "Ctrl+Shift+E".+fn accel_text(binding: &str) -> Option<String> {+ // AT-SPI's keybinding is "mnemonic;full-path;accelerator"; the accelerator is the last field.+ let acc = binding.rsplit(';').next().unwrap_or(binding).trim();+ if acc.is_empty() {+ return None;+ }+ let mut mods = Vec::new();+ let mut rest = acc;+ while let Some(stripped) = rest.strip_prefix('<') {+ let end = stripped.find('>')?;+ let m = match &stripped[..end].to_lowercase()[..] {+ "control" | "ctrl" | "primary" => "Ctrl",+ "shift" => "Shift",+ "alt" | "mod1" => "Alt",+ "super" | "meta" => "Meta",+ _ => "",+ };+ if !m.is_empty() {+ mods.push(m);+ }+ rest = &stripped[end + 1..];+ }+ if rest.is_empty() {+ return None;+ }+ let key = if rest.chars().count() == 1 { rest.to_uppercase() } else { rest.to_string() };+ mods.push(&key);+ Some(mods.join("+"))+}++fn menu_nodes(w: &Walker, f: &Node) -> Vec<(Node, MenuItem)> {+ let mut out = Vec::new();+ let mut bar_seen = false;+ w.walk(f, |n, role, name, trail| {+ if role == "menu bar" {+ bar_seen = true;+ }+ if (role == "menu item" || role == "check menu item" || role == "radio menu item") && !name.is_empty() {+ // the path from below the menu bar: drop the frame, filler and bar names (empty)+ let mut path: Vec<String> = trail.iter().filter(|s| !s.is_empty()).skip(1).cloned().collect();+ path.push(name.to_string());+ let accel = w.actions(n).first().and_then(|(_, k)| accel_text(k));+ out.push((n.clone(), MenuItem { path, id: out.len() as u32, accel }));+ }+ true+ });+ let _ = bar_seen;+ out+}++pub fn menu_tree(h: u64) -> Result<Vec<MenuItem>, String> {+ let (w, f) = frame_for(h)?;+ let items: Vec<MenuItem> = menu_nodes(&w, &f).into_iter().map(|(_, m)| m).collect();+ if items.is_empty() {+ return Err(format!("no menu items under window {h} on the accessibility bus"));+ }+ Ok(items)+}++/// Invoke menu item `id` from menu_tree (ids are positions in that walk, stable while the menus are).+pub fn menu_invoke(h: u64, id: u32) -> Result<(), String> {+ let (w, f) = frame_for(h)?;+ let nodes = menu_nodes(&w, &f);+ let (n, _) = nodes.get(id as usize).ok_or_else(|| format!("menu item {id} not found (the menu has {} items)", nodes.len()))?;+ w.do_action(n, &["click", "activate"]).map(|_| ())+}++#[cfg(test)]+mod tests {+ #[test]+ fn gtk_accelerators_read_like_kicads() {+ assert_eq!(super::accel_text("<Control>s").as_deref(), Some("Ctrl+S"));+ assert_eq!(super::accel_text("_File;;<Primary><Shift>e").as_deref(), Some("Ctrl+Shift+E"));+ assert_eq!(super::accel_text("<Alt>3").as_deref(), Some("Alt+3"));+ assert_eq!(super::accel_text("F5").as_deref(), Some("F5"));+ assert_eq!(super::accel_text("").as_deref(), None);+ assert_eq!(super::clean("_Save"), "save");+ }+}
rust/crates/kicad-platform/src/linux/x11.rsadded+637@@ -0,0 +1,637 @@+//! KiCad's windows on Linux, through the X server.+//!+//! KiCad 10 is a wxGTK application that runs on X11 even inside a Wayland session: KiCad+//! sets `GDK_BACKEND=x11` itself, so on GNOME, KDE and other Wayland desktops its frames,+//! editors and dialogs are XWayland windows. One X11 layer therefore covers both kinds of+//! session. The window manager publishes the EWMH properties read here (`_NET_CLIENT_LIST`,+//! `_NET_WM_PID`, `_NET_WM_STATE`, `_NET_ACTIVE_WINDOW`, ...), and every request that changes+//! a window goes to the window manager as a client message, the way a pager or taskbar asks.+//!+//! What X cannot see: windows of native Wayland applications (a browser, a terminal) are not+//! in the X window list, so on a Wayland desktop `foreground()` is 0 while one of those has+//! focus, and `z_top()` only orders X windows. The bridge's KiCad windows are always visible.+//!+//! Pure Rust (x11rb's own protocol implementation): nothing links libX11 or libxcb.++use std::sync::Mutex;+use std::time::{Duration, Instant};++use x11rb::connection::{Connection, RequestConnection};+use x11rb::errors::{ConnectionError, ReplyError, ReplyOrIdError};+use x11rb::protocol::xproto::{+ AtomEnum, ClientMessageEvent, ConfigureWindowAux, ConnectionExt as _, EventMask, GetPropertyReply, ImageFormat, ImageOrder, MapState, StackMode, Window,+};+use x11rb::rust_connection::RustConnection;++use crate::{Capture, Placement, WindowInfo};++x11rb::atom_manager! {+ pub Atoms: AtomsCookie {+ UTF8_STRING,+ WM_STATE,+ WM_CHANGE_STATE,+ _NET_CLIENT_LIST,+ _NET_CLIENT_LIST_STACKING,+ _NET_ACTIVE_WINDOW,+ _NET_WM_NAME,+ _NET_WM_PID,+ _NET_WM_STATE,+ _NET_WM_STATE_HIDDEN,+ _NET_WM_STATE_MAXIMIZED_VERT,+ _NET_WM_STATE_MAXIMIZED_HORZ,+ _NET_WM_STATE_MODAL,+ _NET_WM_WINDOW_TYPE,+ _NET_WM_WINDOW_TYPE_DIALOG,+ _NET_WM_WINDOW_TYPE_DESKTOP,+ _NET_WM_WINDOW_TYPE_DOCK,+ _NET_CLOSE_WINDOW,+ _NET_RESTACK_WINDOW,+ _NET_FRAME_EXTENTS,+ _NET_SUPPORTING_WM_CHECK,+ }+}++struct X {+ conn: RustConnection,+ root: Window,+ atoms: Atoms,+ screen_w: u16,+ screen_h: u16,+}++static X: Mutex<Option<X>> = Mutex::new(None);++/// The kind of graphical session the bridge runs in: "wayland", "x11" or "none".+pub fn session_type() -> &'static str {+ let t = std::env::var("XDG_SESSION_TYPE").unwrap_or_default().to_lowercase();+ if t == "wayland" || std::env::var_os("WAYLAND_DISPLAY").is_some_and(|v| !v.is_empty()) {+ return "wayland";+ }+ if t == "x11" || std::env::var_os("DISPLAY").is_some_and(|v| !v.is_empty()) {+ return "x11";+ }+ "none"+}++/// The X cookie file to try when XAUTHORITY is unset or stale. GNOME's Mutter writes+/// `$XDG_RUNTIME_DIR/.mutter-Xwaylandauth.*` for XWayland; most X11 sessions use ~/.Xauthority.+fn fallback_xauthority() -> Option<String> {+ let runtime = std::env::var("XDG_RUNTIME_DIR").ok().filter(|s| !s.is_empty()).or_else(|| {+ let uid = std::fs::read_to_string("/proc/self/status").ok()?.lines().find(|l| l.starts_with("Uid:"))?.split_whitespace().nth(1)?.to_string();+ Some(format!("/run/user/{uid}"))+ });+ if let Some(dir) = runtime {+ if let Ok(rd) = std::fs::read_dir(&dir) {+ let mut c: Vec<_> = rd.flatten().filter(|e| e.file_name().to_string_lossy().starts_with(".mutter-Xwaylandauth.")).collect();+ c.sort_by_key(|e| std::cmp::Reverse(e.metadata().and_then(|m| m.modified()).ok()));+ if let Some(e) = c.first() {+ return Some(e.path().to_string_lossy().to_string());+ }+ }+ }+ let home = std::env::var("HOME").ok()?;+ let p = format!("{home}/.Xauthority");+ std::path::Path::new(&p).is_file().then_some(p)+}++fn connect() -> Result<X, String> {+ if std::env::var_os("DISPLAY").is_none_or(|v| v.is_empty()) {+ // The standard "not implemented on <os>" form, so a verb answers not_supported_on_platform+ // (readiness already says windowControl: false without a display).+ return Err(format!("window control: not implemented on linux without a desktop session (DISPLAY is not set in the bridge's environment; session: {})", session_type()));+ }+ let (conn, screen) = match RustConnection::connect(None) {+ Ok(c) => c,+ Err(first) => {+ // XAUTHORITY missing or pointing at an old session's cookie: try the session's own.+ let current = std::env::var("XAUTHORITY").unwrap_or_default();+ match fallback_xauthority().filter(|p| *p != current) {+ Some(p) => {+ std::env::set_var("XAUTHORITY", &p);+ RustConnection::connect(None).map_err(|e| format!("X display {}: {first}; with XAUTHORITY={p}: {e}", std::env::var("DISPLAY").unwrap_or_default()))?+ }+ None => return Err(format!("X display {}: {first}", std::env::var("DISPLAY").unwrap_or_default())),+ }+ }+ };+ let s = &conn.setup().roots[screen];+ let (root, screen_w, screen_h) = (s.root, s.width_in_pixels, s.height_in_pixels);+ let atoms = Atoms::new(&conn).map_err(|e| e.to_string())?.reply().map_err(|e| e.to_string())?;+ Ok(X { conn, root, atoms, screen_w, screen_h })+}++/// Run `f` on the shared connection, connecting on first use and once more after the+/// server went away (XWayland restarts with the session).+fn with<T>(f: impl Fn(&X) -> Result<T, XErr>) -> Result<T, String> {+ let mut guard = X.lock().unwrap_or_else(|p| p.into_inner());+ for attempt in 0..2 {+ if guard.is_none() {+ *guard = Some(connect()?);+ }+ match f(guard.as_ref().unwrap()) {+ Ok(v) => return Ok(v),+ Err(XErr::Conn(e)) => {+ *guard = None;+ if attempt == 1 {+ return Err(format!("X connection lost: {e}"));+ }+ }+ Err(XErr::Other(e)) => return Err(e),+ }+ }+ Err("X connection lost".into())+}++pub enum XErr {+ Conn(String),+ Other(String),+}+impl From<ConnectionError> for XErr {+ fn from(e: ConnectionError) -> Self {+ XErr::Conn(e.to_string())+ }+}+impl From<ReplyError> for XErr {+ fn from(e: ReplyError) -> Self {+ match e {+ ReplyError::ConnectionError(c) => XErr::Conn(c.to_string()),+ ReplyError::X11Error(x) => XErr::Other(format!("X error {:?}", x.error_kind)),+ }+ }+}+impl From<ReplyOrIdError> for XErr {+ fn from(e: ReplyOrIdError) -> Self {+ match e {+ ReplyOrIdError::ConnectionError(c) => XErr::Conn(c.to_string()),+ other => XErr::Other(other.to_string()),+ }+ }+}++fn win(h: u64) -> Result<Window, XErr> {+ u32::try_from(h).ok().filter(|w| *w != 0).ok_or_else(|| XErr::Other(format!("{h} is not an X window id")))+}++impl X {+ fn prop(&self, w: Window, name: u32, ty: impl Into<u32>, len: u32) -> Result<Option<GetPropertyReply>, XErr> {+ match self.conn.get_property(false, w, name, ty, 0, len)?.reply() {+ Ok(r) if r.format != 0 => Ok(Some(r)),+ Ok(_) => Ok(None),+ Err(ReplyError::X11Error(_)) => Ok(None), // window gone+ Err(e) => Err(e.into()),+ }+ }+ fn windows_prop(&self, w: Window, name: u32) -> Result<Vec<Window>, XErr> {+ Ok(self.prop(w, name, AtomEnum::WINDOW, 4096)?.and_then(|r| r.value32().map(|v| v.collect())).unwrap_or_default())+ }+ fn cardinal(&self, w: Window, name: u32, ty: impl Into<u32>) -> Result<Option<u32>, XErr> {+ Ok(self.prop(w, name, ty, 1)?.and_then(|r| r.value32().and_then(|mut v| v.next())))+ }+ fn atoms_prop(&self, w: Window, name: u32) -> Result<Vec<u32>, XErr> {+ Ok(self.prop(w, name, AtomEnum::ATOM, 64)?.and_then(|r| r.value32().map(|v| v.collect())).unwrap_or_default())+ }+ fn title(&self, w: Window) -> Result<String, XErr> {+ if let Some(r) = self.prop(w, self.atoms._NET_WM_NAME, self.atoms.UTF8_STRING, 1024)? {+ return Ok(String::from_utf8_lossy(&r.value).to_string());+ }+ // WM_NAME is Latin-1 (STRING) or COMPOUND_TEXT; KiCad sets _NET_WM_NAME, others may not.+ Ok(self.prop(w, AtomEnum::WM_NAME.into(), AtomEnum::ANY, 1024)?.map(|r| r.value.iter().map(|b| *b as char).collect()).unwrap_or_default())+ }+ /// WM_CLASS is "instance\0class\0"; the class ("pcbnew", "kicad", "eeschema") is what we report.+ fn class(&self, w: Window) -> Result<String, XErr> {+ let Some(r) = self.prop(w, AtomEnum::WM_CLASS.into(), AtomEnum::STRING, 256)? else { return Ok(String::new()) };+ let parts: Vec<&[u8]> = r.value.split(|b| *b == 0).filter(|p| !p.is_empty()).collect();+ Ok(parts.get(1).or(parts.first()).map(|p| String::from_utf8_lossy(p).to_string()).unwrap_or_default())+ }+ /// The client area in root coordinates (x, y, width, height).+ fn rect(&self, w: Window) -> Result<Option<(i32, i32, i32, i32)>, XErr> {+ let g = match self.conn.get_geometry(w)?.reply() {+ Ok(g) => g,+ Err(ReplyError::X11Error(_)) => return Ok(None),+ Err(e) => return Err(e.into()),+ };+ let t = match self.conn.translate_coordinates(w, self.root, 0, 0)?.reply() {+ Ok(t) => t,+ Err(ReplyError::X11Error(_)) => return Ok(None),+ Err(e) => return Err(e.into()),+ };+ Ok(Some((t.dst_x as i32, t.dst_y as i32, g.width as i32, g.height as i32)))+ }+ fn viewable(&self, w: Window) -> Result<Option<bool>, XErr> {+ match self.conn.get_window_attributes(w)?.reply() {+ Ok(a) => Ok(Some(a.map_state == MapState::VIEWABLE)),+ Err(ReplyError::X11Error(_)) => Ok(None),+ Err(e) => Err(e.into()),+ }+ }+ fn info(&self, w: Window) -> Result<Option<WindowInfo>, XErr> {+ let Some(viewable) = self.viewable(w)? else { return Ok(None) };+ let state = self.atoms_prop(w, self.atoms._NET_WM_STATE)?;+ let a = &self.atoms;+ let minimized = state.contains(&a._NET_WM_STATE_HIDDEN) || self.cardinal(w, a.WM_STATE, a.WM_STATE)? == Some(3);+ let maximized = state.contains(&a._NET_WM_STATE_MAXIMIZED_VERT) && state.contains(&a._NET_WM_STATE_MAXIMIZED_HORZ);+ let pid = self.cardinal(w, a._NET_WM_PID, AtomEnum::CARDINAL)?.unwrap_or(0);+ let mut owner = self.cardinal(w, AtomEnum::WM_TRANSIENT_FOR.into(), AtomEnum::WINDOW)?.unwrap_or(0) as u64;+ // A dialog the toolkit left without a transient-for is still a dialog: own it to the root,+ // so the dialog test (owner != 0) holds without naming a parent that does not exist.+ if owner == 0 && (state.contains(&a._NET_WM_STATE_MODAL) || self.atoms_prop(w, a._NET_WM_WINDOW_TYPE)?.contains(&a._NET_WM_WINDOW_TYPE_DIALOG)) {+ owner = self.root as u64;+ }+ let Some(rect) = self.rect(w)? else { return Ok(None) };+ Ok(Some(WindowInfo {+ hwnd: w as u64,+ title: self.title(w)?,+ class_name: self.class(w)?,+ pid,+ exe: exe_of(pid),+ visible: viewable && !minimized,+ owner,+ rect,+ minimized,+ maximized,+ hung: pid != 0 && stopped(pid),+ }))+ }+ /// The managed top-level windows, topmost first.+ fn stacking(&self) -> Result<Vec<Window>, XErr> {+ let mut v = self.windows_prop(self.root, self.atoms._NET_CLIENT_LIST_STACKING)?;+ if v.is_empty() {+ v = self.windows_prop(self.root, self.atoms._NET_CLIENT_LIST)?;+ }+ v.reverse();+ Ok(v)+ }+ fn send_wm(&self, w: Window, ty: u32, data: [u32; 5]) -> Result<(), XErr> {+ let ev = ClientMessageEvent::new(32, w, ty, data);+ self.conn.send_event(false, self.root, EventMask::SUBSTRUCTURE_REDIRECT | EventMask::SUBSTRUCTURE_NOTIFY, ev)?;+ self.conn.flush()?;+ Ok(())+ }+ fn active(&self) -> Result<u64, XErr> {+ Ok(self.cardinal(self.root, self.atoms._NET_ACTIVE_WINDOW, AtomEnum::WINDOW)?.unwrap_or(0) as u64)+ }+}++fn exe_of(pid: u32) -> String {+ if pid == 0 {+ return String::new();+ }+ std::fs::read_link(format!("/proc/{pid}/exe"))+ .ok()+ .and_then(|p| p.file_name().map(|s| s.to_string_lossy().trim_end_matches(" (deleted)").to_string()))+ .or_else(|| std::fs::read_to_string(format!("/proc/{pid}/comm")).ok().map(|s| s.trim().to_string()))+ .unwrap_or_default()+}++/// A stopped (SIGSTOP / traced) process cannot answer its windows: the closest X has to "hung".+fn stopped(pid: u32) -> bool {+ std::fs::read_to_string(format!("/proc/{pid}/stat")).ok().and_then(|s| s.get(s.rfind(')')? + 2..)?.chars().next()).is_some_and(|c| c == 'T' || c == 't')+}++// ---- the Platform methods ------------------------------------------------------------++pub fn list_windows() -> Result<Vec<WindowInfo>, String> {+ with(|x| {+ let mut out = Vec::new();+ for w in x.stacking()? {+ if let Some(i) = x.info(w)? {+ if i.visible {+ out.push(i);+ }+ }+ }+ Ok(out)+ })+}++pub fn window_info(h: u64) -> Result<Option<WindowInfo>, String> {+ if h == 0 || u32::try_from(h).is_err() {+ return Ok(None);+ }+ with(|x| x.info(h as u32))+}++/// Every X sub-window under a frame. GTK draws its widgets itself, so these are the native+/// sub-windows only (the OpenGL canvas is one); they carry no text.+pub fn child_windows(h: u64) -> Result<Vec<WindowInfo>, String> {+ with(|x| {+ let top = win(h)?;+ let mut out = Vec::new();+ let mut queue = vec![top];+ while let Some(p) = queue.pop() {+ let tree = match x.conn.query_tree(p)?.reply() {+ Ok(t) => t,+ Err(ReplyError::X11Error(_)) => continue,+ Err(e) => return Err(e.into()),+ };+ for c in tree.children {+ queue.push(c);+ let Some(rect) = x.rect(c)? else { continue };+ let visible = x.viewable(c)?.unwrap_or(false);+ out.push(WindowInfo { hwnd: c as u64, title: x.title(c)?, class_name: x.class(c)?, owner: p as u64, rect, visible, ..Default::default() });+ }+ }+ Ok(out)+ })+}++pub fn post_close(h: u64) -> Result<(), String> {+ with(|x| {+ let w = win(h)?;+ // source 2 = a pager: the window manager sends the client WM_DELETE_WINDOW, as a click on its close button does.+ x.send_wm(w, x.atoms._NET_CLOSE_WINDOW, [0, 2, 0, 0, 0])+ })+}++pub fn foreground() -> Result<u64, String> {+ with(|x| x.active())+}++/// The top of the X stacking order: first visible, non-minimized, titled window of a useful size.+pub fn z_top() -> Result<u64, String> {+ with(|x| {+ for w in x.stacking()? {+ let Some(i) = x.info(w)? else { continue };+ if i.visible && !i.title.is_empty() && i.rect.2 >= 50 && i.rect.3 >= 50 {+ return Ok(w as u64);+ }+ }+ Ok(0)+ })+}++pub fn push_to_background(h: u64) -> Result<(), String> {+ with(|x| {+ let w = win(h)?;+ // _NET_RESTACK_WINDOW: source 2, no sibling, Below = lowest in its layer. No activation change.+ x.send_wm(w, x.atoms._NET_RESTACK_WINDOW, [2, 0, u32::from(StackMode::BELOW), 0, 0])?;+ // A window manager without _NET_RESTACK_WINDOW still honours a ConfigureRequest.+ x.conn.configure_window(w, &ConfigureWindowAux::new().stack_mode(StackMode::BELOW))?;+ x.conn.flush()?;+ Ok(())+ })+}++/// Map a withdrawn window without asking for focus. A minimized window stays minimized:+/// un-minimizing activates it on every X window manager.+pub fn show_no_activate(h: u64) -> Result<(), String> {+ with(|x| {+ let w = win(h)?;+ if x.viewable(w)? == Some(false) && !x.atoms_prop(w, x.atoms._NET_WM_STATE)?.contains(&x.atoms._NET_WM_STATE_HIDDEN) {+ x.conn.map_window(w)?;+ x.conn.flush()?;+ }+ Ok(())+ })+}++/// Ask the window manager to activate `h` (as a taskbar does), then wait briefly to see it hold.+pub fn activate(h: u64, wait_ms: u64) -> Result<bool, String> {+ let current = with(|x| {+ let w = win(h)?;+ let current = x.active()?;+ // un-minimize first: _NET_ACTIVE_WINDOW restores a hidden window on most WMs, WM_CHANGE_STATE on the rest+ if x.atoms_prop(w, x.atoms._NET_WM_STATE)?.contains(&x.atoms._NET_WM_STATE_HIDDEN) {+ x.send_wm(w, x.atoms.WM_CHANGE_STATE, [1, 0, 0, 0, 0])?;+ }+ x.send_wm(w, x.atoms._NET_ACTIVE_WINDOW, [2, 0, current as u32, 0, 0])?;+ Ok(current)+ })?;+ if current == h {+ return Ok(true);+ }+ let until = Instant::now() + Duration::from_millis(wait_ms);+ while Instant::now() < until {+ if foreground()? == h {+ return Ok(true);+ }+ std::thread::sleep(Duration::from_millis(50));+ }+ Ok(foreground()? == h)+}++pub fn placement(h: u64) -> Result<Placement, String> {+ with(|x| {+ let w = win(h)?;+ let (px, py, width, height) = x.rect(w)?.ok_or_else(|| XErr::Other(format!("window {h} is gone")))?;+ let st = x.atoms_prop(w, x.atoms._NET_WM_STATE)?;+ let maximized = st.contains(&x.atoms._NET_WM_STATE_MAXIMIZED_VERT) && st.contains(&x.atoms._NET_WM_STATE_MAXIMIZED_HORZ);+ Ok(Placement { x: px, y: py, width, height, maximized })+ })+}++pub fn set_placement(h: u64, p: &Placement) -> Result<(), String> {+ with(|x| {+ let w = win(h)?;+ let a = &x.atoms;+ // _NET_WM_STATE: action 1 = add, 0 = remove, both maximize atoms, source 2.+ let action = u32::from(p.maximized);+ x.send_wm(w, a._NET_WM_STATE, [action, a._NET_WM_STATE_MAXIMIZED_VERT, a._NET_WM_STATE_MAXIMIZED_HORZ, 2, 0])?;+ if !p.maximized && p.width > 0 && p.height > 0 {+ // The client area was reported, so position the client: subtract the frame the WM adds.+ let ext = x.prop(w, a._NET_FRAME_EXTENTS, AtomEnum::CARDINAL, 4)?.and_then(|r| r.value32().map(|v| v.collect::<Vec<u32>>())).unwrap_or_default();+ let (left, top) = (ext.first().copied().unwrap_or(0) as i32, ext.get(2).copied().unwrap_or(0) as i32);+ let aux = ConfigureWindowAux::new().x(p.x - left).y(p.y - top).width(p.width as u32).height(p.height as u32);+ x.conn.configure_window(w, &aux)?;+ }+ x.conn.flush()?;+ Ok(())+ })+}++pub fn on_a_monitor(rect: (i32, i32, i32, i32)) -> Result<bool, String> {+ with(|x| {+ let (sx, sy) = (x.screen_w as i32, x.screen_h as i32);+ let (l, t, w, h) = rect;+ Ok(w > 0 && h > 0 && l < sx && t < sy && l + w > 0 && t + h > 0)+ })+}++/// The window's pixels, top-down RGB8. With a compositing window manager (GNOME, KDE,+/// Cinnamon, and XWayland always) a covered window still reads back its own contents.+pub fn capture_window(h: u64) -> Result<Capture, String> {+ with(|x| {+ let w = win(h)?;+ if x.atoms_prop(w, x.atoms._NET_WM_STATE)?.contains(&x.atoms._NET_WM_STATE_HIDDEN) {+ return Err(XErr::Other(format!("window {h} is minimized; it has no pixels to read (show it first)")));+ }+ let g = x.conn.get_geometry(w)?.reply()?;+ let (width, height) = (g.width, g.height);+ if width == 0 || height == 0 {+ return Err(XErr::Other(format!("window {h} has an empty rect")));+ }+ // GetImage replies are bounded by the maximum request length; read in bands of rows.+ let setup = x.conn.setup();+ let max_bytes = (x.conn.maximum_request_bytes().max(262_144) - 64) as u32;+ let rows = (max_bytes / (width as u32 * 4)).clamp(1, height as u32) as u16;+ let bpp = setup.pixmap_formats.iter().find(|f| f.depth == g.depth).map(|f| f.bits_per_pixel).unwrap_or(32);+ if bpp != 32 && bpp != 24 {+ return Err(XErr::Other(format!("window depth {} ({bpp} bits per pixel) is not a true-colour visual", g.depth)));+ }+ let lsb = setup.image_byte_order == ImageOrder::LSB_FIRST;+ let mut rgb = Vec::with_capacity(width as usize * height as usize * 3);+ let mut y = 0u16;+ while y < height {+ let band = rows.min(height - y);+ let img = x.conn.get_image(ImageFormat::Z_PIXMAP, w, 0, y as i16, width, band, !0)?.reply()?;+ let step = (bpp / 8) as usize;+ let stride = img.data.len() / band as usize;+ for r in 0..band as usize {+ let row = &img.data[r * stride..];+ for c in 0..width as usize {+ let p = &row[c * step..c * step + step];+ // LSB first: B G R (X); MSB first: (X) R G B.+ let (rr, gg, b) = if lsb { (p[2], p[1], p[0]) } else { (p[step - 3], p[step - 2], p[step - 1]) };+ rgb.extend_from_slice(&[rr, gg, b]);+ }+ }+ y += band;+ }+ Ok(Capture { width: width as u32, height: height as u32, rgb })+ })+}++/// The OpenGL canvas: wxGLCanvas is a native X sub-window; the largest viewable one is the canvas.+pub fn gl_canvas_rect(h: u64) -> Result<Option<(i32, i32, i32, i32)>, String> {+ let frame = window_info(h)?.ok_or_else(|| format!("window {h} is gone"))?;+ let best = child_windows(h)?.into_iter().filter(|c| c.visible && c.rect.2 >= 100 && c.rect.3 >= 100).max_by_key(|c| c.rect.2 as i64 * c.rect.3 as i64);+ Ok(best.map(|c| (c.rect.0 - frame.rect.0, c.rect.1 - frame.rect.1, c.rect.2, c.rect.3)))+}++/// Seconds since the user last touched keyboard or mouse. Mutter (GNOME, Wayland or X11)+/// answers for the whole session through its idle monitor; other desktops through the+/// X screensaver extension (X input only).+pub fn seconds_since_input() -> Result<f64, String> {+ let mut cmd = std::process::Command::new("gdbus");+ cmd.args(["call", "--session", "--dest", "org.gnome.Mutter.IdleMonitor", "--object-path", "/org/gnome/Mutter/IdleMonitor/Core", "--method", "org.gnome.Mutter.IdleMonitor.GetIdletime", "--timeout", "2"]);+ cmd.stdin(std::process::Stdio::null()).stderr(std::process::Stdio::null());+ if let Ok(o) = cmd.output() {+ // "(uint64 1234,)"+ let s = String::from_utf8_lossy(&o.stdout);+ if let Some(ms) = s.split_whitespace().nth(1).and_then(|t| t.trim_end_matches([',', ')']).parse::<u64>().ok()) {+ return Ok(ms as f64 / 1000.0);+ }+ }+ with(|x| {+ use x11rb::protocol::screensaver::ConnectionExt as _;+ if x.conn.extension_information(x11rb::protocol::screensaver::X11_EXTENSION_NAME)?.is_none() {+ return Err(XErr::Other("no idle clock: neither Mutter's idle monitor nor the X screensaver extension answered".into()));+ }+ let r = x.conn.screensaver_query_info(x.root)?.reply()?;+ Ok(r.ms_since_user_input as f64 / 1000.0)+ })+}++/// Is a window manager running that speaks EWMH (the bridge's window verbs need one)?+#[allow(dead_code)]+pub fn wm_name() -> Result<Option<String>, String> {+ with(|x| {+ let Some(check) = x.cardinal(x.root, x.atoms._NET_SUPPORTING_WM_CHECK, AtomEnum::WINDOW)? else { return Ok(None) };+ Ok(Some(x.title(check)?).filter(|s| !s.is_empty()))+ })+}++/// The desktop's own windows (desktop icons, panels, docks): never a foreground target.+pub fn is_shell_window(h: u64) -> bool {+ with(|x| {+ let w = win(h)?;+ let t = x.atoms_prop(w, x.atoms._NET_WM_WINDOW_TYPE)?;+ Ok(t.contains(&x.atoms._NET_WM_WINDOW_TYPE_DESKTOP) || t.contains(&x.atoms._NET_WM_WINDOW_TYPE_DOCK))+ })+ .unwrap_or(false)+}++/// The X keysym for a Windows virtual-key code (the bridge's key names resolve to these).+fn keysym_for(vk: u16, ch: Option<char>) -> Option<u32> {+ if let Some(c) = ch {+ // Latin-1 characters are their own keysym; others are 0x01000000 + code point.+ let u = c as u32;+ return Some(if (0x20..=0x7e).contains(&u) || (0xa0..=0xff).contains(&u) { u } else { 0x0100_0000 + u });+ }+ Some(match vk {+ 0x08 => 0xff08, // Back+ 0x09 => 0xff09, // Tab+ 0x0D => 0xff0d, // Return+ 0x1B => 0xff1b, // Escape+ 0x20 => 0x20, // space+ 0x21 => 0xff55, // Prior+ 0x22 => 0xff56, // Next+ 0x23 => 0xff57, // End+ 0x24 => 0xff50, // Home+ 0x25 => 0xff51, // Left+ 0x26 => 0xff52, // Up+ 0x27 => 0xff53, // Right+ 0x28 => 0xff54, // Down+ 0x2D => 0xff63, // Insert+ 0x2E => 0xffff, // Delete+ 0x30..=0x39 => vk as u32, // 0-9+ 0x41..=0x5A => vk as u32 + 0x20, // a-z+ 0x70..=0x7B => 0xffbe + (vk as u32 - 0x70), // F1-F12+ 0x6B => 0xffab, // KP_Add+ 0x6D => 0xffad, // KP_Subtract+ 0xBB => 0x3d, // = (VK_OEM_PLUS)+ 0xBD => 0x2d, // - (VK_OEM_MINUS)+ _ => return None,+ })+}++/// Does X keyboard focus sit inside `w` (on it or on one of its sub-windows)?+fn focus_inside(x: &X, w: Window) -> Result<bool, XErr> {+ let mut f = x.conn.get_input_focus()?.reply()?.focus;+ for _ in 0..32 {+ if f == w {+ return Ok(true);+ }+ if f == x11rb::NONE || f == 1 || f == x.root {+ return Ok(false); // None / PointerRoot / the root+ }+ match x.conn.query_tree(f)?.reply() {+ Ok(t) => f = t.parent,+ Err(_) => return Ok(false),+ }+ }+ Ok(false)+}++/// A key press and release for one window. GTK ignores synthetic (XSendEvent) keys, so the key+/// goes through XTest, and ONLY while X keyboard focus already sits in that window: the key then+/// reaches nothing else, and no focus or stacking changes. On a Wayland desktop XTest input stays+/// inside XWayland, so the user's Wayland windows never see it. Otherwise this refuses.+pub fn post_key(h: u64, vk: u16, ch: Option<char>) -> Result<(), String> {+ use x11rb::protocol::xproto::{KEY_PRESS_EVENT, KEY_RELEASE_EVENT};+ use x11rb::protocol::xtest::ConnectionExt as _;+ let sym = keysym_for(vk, ch).ok_or_else(|| format!("no X keysym for virtual key {vk:#x}"))?;+ with(|x| {+ let w = win(h)?;+ if x.conn.extension_information(x11rb::protocol::xtest::X11_EXTENSION_NAME)?.is_none() {+ return Err(XErr::Other("the X server has no XTEST extension".into()));+ }+ if !focus_inside(x, w)? {+ return Err(XErr::Other(format!(+ "not_focused: window {h} does not have the X keyboard focus. KiCad (GTK) ignores synthetic key events, so a key reaches only the focused KiCad window; the bridge never moves focus to type"+ )));+ }+ let setup = x.conn.setup();+ let (min, max) = (setup.min_keycode, setup.max_keycode);+ let map = x.conn.get_keyboard_mapping(min, max - min + 1)?.reply()?;+ let per = (map.keysyms_per_keycode as usize).max(1);+ let found = map.keysyms.chunks(per).enumerate().find_map(|(i, syms)| syms.iter().position(|s| *s == sym).filter(|l| *l <= 1).map(|l| (min + i as u8, l == 1)));+ let (code, shift) = found.ok_or_else(|| XErr::Other(format!("keysym {sym:#x} is not on this keyboard map")))?;+ let shift_code = map.keysyms.chunks(per).position(|syms| syms.first() == Some(&0xffe1)).map(|i| min + i as u8);+ let press = |ty: u8, c: u8| x.conn.xtest_fake_input(ty, c, x11rb::CURRENT_TIME, x.root, 0, 0, 0);+ if shift {+ if let Some(sc) = shift_code {+ press(KEY_PRESS_EVENT, sc)?;+ }+ }+ press(KEY_PRESS_EVENT, code)?;+ press(KEY_RELEASE_EVENT, code)?;+ if shift {+ if let Some(sc) = shift_code {+ press(KEY_RELEASE_EVENT, sc)?;+ }+ }+ x.conn.flush()?;+ Ok(())+ })+}
rust/crates/kicad-platform/src/macos.rs+1@@ -28,6 +28,7 @@ impl Platform for Native { focus_etiquette: false, ipc_api: false, silent_install: false,+ session: "", } } fn cli_file_name(&self) -> &'static str {
rust/crates/kicad-platform/src/ngspice.rs+73−8@@ -1,7 +1,7 @@-//! A SPICE simulation through the ngspice shared library KiCad installs (`<kicad>/bin/ngspice.dll`), the+//! A SPICE simulation through the ngspice shared library KiCad uses (`<kicad>/bin/ngspice.dll` on Windows, the system's `libngspice.so.0` on Linux), the //! engine behind KiCad's own simulator (wiki #89). ngspice keeps global state and can bring its host down //! on a bad circuit, so the bridge runs this in a child process of itself (`kicad-bridge --ngspice-worker`)-//! and only ever once per process. Windows only for now: the library names its API in sharedspice.h.+//! and only ever once per process. The library names its API in sharedspice.h. use serde_json::{json, Value}; @@ -16,13 +16,40 @@ pub fn run(dll: &std::path::Path, lines: &[String], cwd: &std::path::Path, codem imp::run(dll, lines, cwd, codemodels, commands) } -#[cfg(windows)]+/// Bound the ngspice worker's memory before the library loads. ngspice can allocate without end on+/// some degenerate analyses (measured: one single-point AC sweep on ngspice 42 grew a worker to 3.6 GB+/// and the kernel's OOM killer then stopped Adom Bridge's whole service). Capped, the allocation fails+/// inside the worker, which is a child of the bridge and dies alone. Linux: RLIMIT_AS. Elsewhere: no-op.+pub fn limit_worker_memory(bytes: u64) {+ #[cfg(target_os = "linux")]+ {+ #[repr(C)]+ struct Rlimit {+ cur: u64,+ max: u64,+ }+ extern "C" {+ fn setrlimit(resource: i32, rlim: *const Rlimit) -> i32;+ }+ const RLIMIT_AS: i32 = 9;+ let r = Rlimit { cur: bytes, max: bytes };+ // SAFETY: a valid pointer to a properly laid out rlimit for the duration of the call.+ unsafe { setrlimit(RLIMIT_AS, &r) };+ }+ #[cfg(not(target_os = "linux"))]+ let _ = bytes;+}++#[cfg(any(windows, unix))] mod imp { use super::*; use std::ffi::{c_char, c_int, c_void, CStr, CString}; use std::sync::Mutex;+ #[cfg(windows)] use windows::core::PCWSTR;+ #[cfg(windows)] use windows::Win32::Foundation::HMODULE;+ #[cfg(windows)] use windows::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryExW, LOAD_WITH_ALTERED_SEARCH_PATH}; #[repr(C)]@@ -71,17 +98,55 @@ mod imp { 0 } + #[cfg(windows)] fn sym<T: Copy>(lib: HMODULE, name: &str) -> Result<T, String> { let c = CString::new(name).unwrap(); let p = unsafe { GetProcAddress(lib, windows::core::PCSTR(c.as_ptr() as *const u8)) }.ok_or(format!("ngspice has no {name}"))?; Ok(unsafe { std::mem::transmute_copy(&p) }) }+ #[cfg(windows)]+ fn open(dll: &std::path::Path) -> Result<HMODULE, String> {+ let wide: Vec<u16> = dll.as_os_str().to_string_lossy().encode_utf16().chain(std::iter::once(0)).collect();+ // the altered search path finds the library's own dependencies beside it, in KiCad's bin folder+ unsafe { LoadLibraryExW(PCWSTR(wide.as_ptr()), None, LOAD_WITH_ALTERED_SEARCH_PATH) }.map_err(|e| format!("cannot load {}: {e}", dll.display()))+ }++ // Unix: the C library's dynamic loader (glibc and macOS export these from libc).+ #[cfg(unix)]+ extern "C" {+ fn dlopen(filename: *const c_char, flag: c_int) -> *mut c_void;+ fn dlsym(handle: *mut c_void, symbol: *const c_char) -> *mut c_void;+ fn dlerror() -> *const c_char;+ }+ #[cfg(unix)]+ type Handle = *mut c_void;+ #[cfg(unix)]+ fn open(dll: &std::path::Path) -> Result<Handle, String> {+ const RTLD_NOW: c_int = 2;+ let c = CString::new(dll.to_string_lossy().as_bytes()).map_err(|e| e.to_string())?;+ // SAFETY: a NUL-terminated path; the handle stays open for the worker process's life (ngspice keeps state).+ let h = unsafe { dlopen(c.as_ptr(), RTLD_NOW) };+ if h.is_null() {+ let e = unsafe { dlerror() };+ let why = if e.is_null() { String::new() } else { unsafe { CStr::from_ptr(e) }.to_string_lossy().into_owned() };+ return Err(format!("cannot load {}: {why}", dll.display()));+ }+ Ok(h)+ }+ #[cfg(unix)]+ fn sym<T: Copy>(lib: Handle, name: &str) -> Result<T, String> {+ let c = CString::new(name).unwrap();+ // SAFETY: a valid handle from dlopen and a NUL-terminated name; T is the matching fn pointer type.+ let p = unsafe { dlsym(lib, c.as_ptr()) };+ if p.is_null() {+ return Err(format!("ngspice has no {name}"));+ }+ Ok(unsafe { std::mem::transmute_copy(&p) })+ } pub fn run(dll: &std::path::Path, lines: &[String], cwd: &std::path::Path, codemodels: &[std::path::PathBuf], commands: &[String]) -> Result<Value, String> { std::env::set_current_dir(cwd).map_err(|e| format!("cannot work in {}: {e}", cwd.display()))?;- let wide: Vec<u16> = dll.as_os_str().to_string_lossy().encode_utf16().chain(std::iter::once(0)).collect();- // the altered search path finds the library's own dependencies beside it, in KiCad's bin folder- let lib = unsafe { LoadLibraryExW(PCWSTR(wide.as_ptr()), None, LOAD_WITH_ALTERED_SEARCH_PATH) }.map_err(|e| format!("cannot load {}: {e}", dll.display()))?;+ let lib = open(dll)?; let init: Init = sym(lib, "ngSpice_Init")?; let circ: Circ = sym(lib, "ngSpice_Circ")?; let command: Command = sym(lib, "ngSpice_Command")?;@@ -186,10 +251,10 @@ mod imp { } } -#[cfg(not(windows))]+#[cfg(not(any(windows, unix)))] mod imp { use super::*; pub fn run(_dll: &std::path::Path, _lines: &[String], _cwd: &std::path::Path, _codemodels: &[std::path::PathBuf], _commands: &[String]) -> Result<Value, String> {- Err("simulation runs through KiCad's ngspice.dll, on Windows for now".into())+ Err("simulation needs a dynamic loader (Windows or Unix)".into()) } }
rust/crates/kicad-platform/src/windows.rs+1@@ -157,6 +157,7 @@ impl Platform for Native { focus_etiquette: true, // phase 4: win/etiquette.rs ipc_api: false, // phase 2 silent_install: true, // phase 3b: kicad_upgrade (win/install.rs)+ session: "", } } fn cli_file_name(&self) -> &'static str {
skills/kicad-bridge-contributing/SKILL.md+2−1@@ -36,7 +36,8 @@ Layout you will touch: |---|---| | `rust/crates/kicad-bridge/src/verbs_*.rs` | The verbs: catalog entry (name, input, hint, pitfalls) + dispatch. One file per verb group. | | `rust/crates/kicad-core/src/` | Platform-neutral logic: board parsing, IPC (`ipc.rs`), DRC, zones, DSN, libraries. Unit tests live next to the code. |-| `rust/crates/kicad-platform/src/win/` | Windows specifics: windows, focus, menus, UIA, capture. Stubs for macOS and Linux beside it. |+| `rust/crates/kicad-platform/src/win/` | Windows specifics: windows, focus, menus, UIA, capture. |+| `rust/crates/kicad-platform/src/linux.rs`, `linux/` | Linux: install discovery (distro, PPA, snap, Flatpak), `x11.rs` (KiCad's windows through the X server, XWayland on Wayland desktops: list, state, close, place, capture), `atspi.rs` (menus, dialog text, buttons, check boxes and text fields through AT-SPI). Behaviour and limits: docs/linux.md. | | `skills/*/SKILL.md` | What an AI reads. Change the skill when you change a contract. | | `docs/` | Design notes and acceptance checklists (`docs/native-*.md`). | | `RELEASE_NOTES.md`, `rust/Cargo.toml` | Version and notes; the owner bumps these, you do not need to. |
skills/kicad-bridge-test/run_verb_tests.py+50−17@@ -44,13 +44,32 @@ from pathlib import Path AI_THREAD = "kicad-bridge verbtest %s/%d" % (os.uname().nodename if hasattr(os, "uname") else "box", os.getpid()) REPO = Path(__file__).resolve().parents[2] PACK = REPO / "tour-pack-rp2040" / "template"+# Verbs with no path on Linux, and the honest answer each gives there (docs/linux.md)+LINUX_HONEST = {+ "click": ["not_supported_on_platform"],+ "send_key": ["not_focused"], # a single key reaches only a focused KiCad window+ "taskbar_repair": ["not_supported_on_platform"],+}+# Fixtures the scratch phases copy fresh onto the box (send_files works the same on every OS)+PACK_FILES = [str(PACK / f) for f in ("rp2040-breakout.kicad_pcb", "rp2040-breakout.kicad_sch", "rp2040-breakout.kicad_pro", "rp2040-breakout.kicad_prl")]+CK_LOCAL = str(REPO / "demo" / "routing" / "esc" / "esc-g431-fable-placed-planes.kicad_pcb")+ROUTE_FIXTURE = str(REPO / "rust" / "crates" / "kicad-core" / "tests" / "fixtures" / "konnect-route" / "route-fixture.kicad_pcb") WINDIR = "C:/Users/{user}/Documents/adom-kicad-verbtest"+LINUXDIR = "/home/{user}/Documents/adom-kicad-verbtest" # The sample PCM repository on this page (docs/pcm.md): three anonymous files, no login. PCM_REPO_URL = "https://wiki.adom.inc/api/pages/adom/kicad-bridge/files/pcm/repository.json" # ---------------------------------------------------------------- CLI plumbing +# --push-with CMD: copy each fixture with `CMD <dest dir> <file>...` (for example an scp wrapper) instead of+# Adom Bridge's send_files. For a test box whose Adom Bridge asks a human to approve every file write.+PUSH_WITH = ""++ def ab(target: str, verb: str, args: dict, timeout: int = 150) -> dict:+ if verb == "send_files" and PUSH_WITH:+ p = subprocess.run(shlex.split(PUSH_WITH) + [args["dest"]] + list(args["filePaths"]), capture_output=True, text=True, timeout=timeout)+ return {"success": p.returncode == 0, "status": "ok" if p.returncode == 0 else "error", "pushedWith": PUSH_WITH, "error": p.stderr[-300:]} cmd = ["adom-bridge", "--ai-thread", AI_THREAD, "--target", target, verb, json.dumps(args)] try:@@ -463,12 +482,16 @@ def main(): ap.add_argument("--no-stealth", action="store_true", help="don't keep KiCad behind the user's window during GUI phases") ap.add_argument("--out", default="/tmp/kicad-verbtest")+ ap.add_argument("--push-with", default="",+ help="copy fixtures with `CMD <dest> <files...>` instead of send_files (a box whose ab gates file writes)") ap.add_argument("--ai-thread", default="", help="caller identity (default: kicad-bridge verbtest <host>/<pid>)") ap.add_argument("--close-open-kicad", action="store_true", help="allow the run to close KiCad editors that are already open (never ones with unsaved changes)") ap.add_argument("--wait-minutes", type=float, default=30.0, help="how long to wait for another battery/thread to finish driving this box (0 = do not wait)") a = ap.parse_args()+ global PUSH_WITH+ PUSH_WITH = a.push_with outdir = Path(a.out); outdir.mkdir(parents=True, exist_ok=True) global AI_THREAD@@ -489,30 +512,34 @@ def main(): else: aliases = {} - # discover the Windows user for paths+ # discover the desktop user (and OS) for paths user = "john"+ box_os = "windows" if not a.plan:- # The Windows user comes from the box, never from an assumption (#78):- # kicad_diagnostics carries C:/Users/<name>/... paths in every reply.+ # The user comes from the box, never from an assumption (#78): kicad_diagnostics carries+ # C:/Users/<name>/... (Windows) or /home/<name>/... (Linux) paths in every reply. import re as _re+ box_os = ((ab(a.target, "kicad_readiness", {}, timeout=90).get("platform") or {}).get("os") or "windows") found = None for probe in (("kicad_diagnostics", {}), ("status", {})): raw = json.dumps(ab(a.target, probe[0], probe[1], timeout=90))- m = _re.search(r"Users[\\\\/]+([^\\\\/\"]+)[\\\\/]", raw)+ pat = r"/home/([^/\"]+)/" if box_os == "linux" else r"Users[\\\\/]+([^\\\\/\"]+)[\\\\/]"+ m = _re.search(pat, raw) if m and m.group(1).lower() not in ("public", "default"): found = m.group(1) break if found: user = found else:- print("FATAL: could not learn the Windows user from kicad_diagnostics/status; pass --plan"); return 2- base = WINDIR.format(user=user)+ print(f"FATAL: could not learn the {box_os} user from kicad_diagnostics/status; pass --plan"); return 2+ home = f"/home/{user}" if box_os == "linux" else f"C:/Users/{user}"+ base = LINUXDIR.format(user=user) if box_os == "linux" else WINDIR.format(user=user) w = { "board": f"{base}/rp2040-breakout.kicad_pcb", "sch": f"{base}/rp2040-breakout.kicad_sch", "pro": f"{base}/rp2040-breakout.kicad_pro", "board_copy": f"{base}/format-upgrade-copy.kicad_pcb",- "symlib": f"C:/Users/{user}/Documents/KiCad/10.0/symbols/Adom.kicad_sym",+ "symlib": f"{home}/Documents/KiCad/10.0/symbols/Adom.kicad_sym", "out": base + "/out", "templib": base + "/AdomVerbTest.kicad_sym", "board_copy_src": base + "/copy/rp2040-breakout.kicad_pcb",@@ -889,6 +916,12 @@ def main(): passed = bool(check(r)) except Exception: passed = False+ # A verb that has no path on this OS answers it honestly (docs/linux.md): a SKIP with the+ # reason, never a pass, and a fail if it answers anything else.+ honest = LINUX_HONEST.get(verb) if box_os == "linux" else None+ if not passed and honest and (r.get("errorCode") in honest or any(h in str(r.get("error") or "") for h in honest)):+ results[verb] = {"result": "SKIP", "reason": f"linux: answers {honest[0]} by design (docs/linux.md)", "response_head": json.dumps(r)[:400]}+ return results[verb] = {"result": "PASS" if passed else "FAIL", "elapsed": None, "response_head": json.dumps(r)[:400],@@ -1685,8 +1718,8 @@ def main(): # 1.0.88: KiCad 10 design variants on a copy of the rp2040 sample: make Lite with two parts not fitted, put one back, # KiCad's own netlist for the variant agreeing each time, then remove it vp = base + "/variants/rp2040-breakout.kicad_pro"- ab(a.target, "shell_execute", {"command": "rmdir /s /q \"" + (base + "/variants").replace("/", "\\") + "\" 2>nul & xcopy /i /q /y \"" + base.replace("/", "\\") + "\\rp2040-breakout.*\" \"" + (base + "/variants").replace("/", "\\") + "\"",- "reason": "kicad-bridge verb test: a scratch copy of the rp2040 sample for kicad_variants"}, timeout=120)+ # kicad-bridge verb test: a scratch copy of the rp2040 sample for kicad_variants: pushed from this repo (send_files), the same on Windows and Linux, no shell approval+ ab(a.target, "send_files", {"filePaths": PACK_FILES, "dest": base + "/variants"}, timeout=200) v1 = C("variants", {"projectPath": vp, "name": "Lite", "notFitted": ["C1", "C2"], "description": "verb test"}, 200) v2 = C("variants", {"projectPath": vp, "name": "Lite", "fitted": ["C2"]}, 200) v3 = C("variants", {"projectPath": vp, "name": "Lite", "remove": True}, 200)@@ -1698,8 +1731,8 @@ def main(): # 1.0.90 (#29): swap_footprint on a copy of the rp2040 board (C1 onto a 0402 capacitor, nets kept by pad number); # plate_vias and normalize_bodies on the land-pattern library; symbol_mode's two body styles on KiCad's 74LS00 sw_dir = base + "/swap"- ab(a.target, "shell_execute", {"command": "rmdir /s /q \"" + sw_dir.replace("/", "\\") + "\" 2>nul & xcopy /i /q /y \"" + base.replace("/", "\\") + "\\rp2040-breakout.*\" \"" + sw_dir.replace("/", "\\") + "\"",- "reason": "kicad-bridge verb test: a scratch copy of the rp2040 board for kicad_swap_footprint"}, timeout=120)+ # kicad-bridge verb test: a scratch copy of the rp2040 board for kicad_swap_footprint: pushed from this repo (send_files), the same on Windows and Linux, no shell approval+ ab(a.target, "send_files", {"filePaths": PACK_FILES, "dest": sw_dir}, timeout=200) sp = C("swap_footprint", {"filePath": sw_dir + "/rp2040-breakout.kicad_pcb", "ref": "C1", "footprint": "Capacitor_SMD:C_0402_1005Metric"}, 200) own("swap_footprint", sp, ok(sp) and sp.get("to") == "Capacitor_SMD:C_0402_1005Metric" and sp.get("netsKeptOnPads") == ["1", "2"], "C1 did not take the 0402 footprint with its nets") # 1.0.94 (standard 0.29.0): export_3d (glb with the parts, a bare-board stl, obj refused by name), edit_pours on a@@ -1712,8 +1745,8 @@ def main(): and ok(s3) and s3.get("components") is False and s3.get("bytes", 0) > 1000 and o3.get("errorCode") == "not_supported" and o3.get("instead"), "the glb or bare stl was not written, or obj was not refused by name") pd = base + "/pours"- ab(a.target, "shell_execute", {"command": "rmdir /s /q \"" + pd.replace("/", "\\") + "\" 2>nul & mkdir \"" + pd.replace("/", "\\") + "\" & copy /y \"" + CK.replace("/", "\\") + "\" \"" + pd.replace("/", "\\") + "\"",- "reason": "kicad-bridge verb test: a scratch copy of the ESC board's planes for kicad_edit_pours"}, timeout=120)+ # kicad-bridge verb test: a scratch copy of the ESC board's planes for kicad_edit_pours: pushed from this repo (send_files), the same on Windows and Linux, no shell approval+ ab(a.target, "send_files", {"filePaths": [CK_LOCAL], "dest": pd}, timeout=200) pb = pd + "/esc-g431-fable-placed-planes.kicad_pcb" ps = ab(a.target, "kicad_pour_state", {"filePath": pb, "source": "file", "geometry": False}, timeout=120) gz = next((z for z in ps.get("zones") or [] if z.get("net") == "GND" and z.get("id")), {})@@ -1737,8 +1770,8 @@ def main(): # 1.0.95 (engine 0.24.0): cross_check on a copy of the rp2040 project with C1 taken off the board: C1 is on the # sheet but not the board, and every other part matches xd = base + "/xcheck"- ab(a.target, "shell_execute", {"command": "rmdir /s /q \"" + xd.replace("/", "\\") + "\" 2>nul & xcopy /i /q /y \"" + base.replace("/", "\\") + "\\rp2040-breakout.*\" \"" + xd.replace("/", "\\") + "\"",- "reason": "kicad-bridge verb test: a scratch copy of the rp2040 project for kicad_cross_check"}, timeout=120)+ # kicad-bridge verb test: a scratch copy of the rp2040 project for kicad_cross_check: pushed from this repo (send_files), the same on Windows and Linux, no shell approval+ ab(a.target, "send_files", {"filePaths": PACK_FILES, "dest": xd}, timeout=200) x0 = C("cross_check", {"projectPath": xd + "/rp2040-breakout.kicad_pro", "source": "file"}, 200) ab(a.target, "kicad_delete_footprint", {"filePath": xd + "/rp2040-breakout.kicad_pcb", "refs": ["C1"], "source": "file"}, timeout=120) x1 = C("cross_check", {"projectPath": xd + "/rp2040-breakout.kicad_pro", "source": "file"}, 200)@@ -1766,8 +1799,8 @@ def main(): own("routing_drc", rd, ok(rd) and rd.get("clean") is False and (rd.get("unconnected") or 0) > 0 and all(v.get("rule") and v.get("at") for v in rd.get("violations") or []), "the ESC board's DRC did not come back not-clean with every violation placed") twd = base + "/tw"- ab(a.target, "shell_execute", {"command": "rmdir /s /q \"" + twd.replace("/", "\\") + "\" 2>nul & mkdir \"" + twd.replace("/", "\\") + "\" & copy /y \"" + (base + "/checks/route-fixture.kicad_pcb").replace("/", "\\") + "\" \"" + twd.replace("/", "\\") + "\"",- "reason": "kicad-bridge verb test: a scratch copy of the routed fixture for kicad_set_track_width"}, timeout=120)+ # kicad-bridge verb test: a scratch copy of the routed fixture for kicad_set_track_width: pushed from this repo (send_files), the same on Windows and Linux, no shell approval+ ab(a.target, "send_files", {"filePaths": [ROUTE_FIXTURE], "dest": twd}, timeout=200) tw = C("set_track_width", {"filePath": twd + "/route-fixture.kicad_pcb", "nets": ["SIG_A"], "widthMm": 0.4, "layer": "top", "source": "file"}, 300) tr = ab(a.target, "kicad_routing_state", {"filePath": twd + "/route-fixture.kicad_pcb", "source": "file", "model": "standard"}, timeout=120) widths = {(t.get("layer"), t.get("width")) for t in tr.get("tracks") or [] if t.get("net") == "SIG_A"}
tools/build_native_release.py+1−1@@ -29,7 +29,7 @@ TARGET = "x86_64-pc-windows-gnu" PLATFORMS = { "windows": {"supported": True, "notes": "Pre-built x64 kicad-bridge.exe ships in the zip."}, "macos": {"supported": False, "notes": "Kyle's build: cargo build --release on a Mac, crates/kicad-platform/src/macos.rs."},- "linux": {"supported": True, "notes": "x86_64 kicad-bridge (glibc 2.39+: Ubuntu 24.04, Debian 13 and newer) ships in the same zip. kicad-cli verbs, file edits and the KiCad IPC API work; window verbs (screenshots, keys, menus) answer not_supported_on_platform for now. KiCad from the distro, snap or Flatpak (kicad_upgrade installs the Flathub app with --user). Wiki #127."},+ "linux": {"supported": True, "notes": "x86_64 kicad-bridge (glibc 2.39+: Ubuntu 24.04, Debian 13, Fedora 40 and newer) ships in the same zip. Wayland or X11 desktops. kicad-cli verbs, file edits, the KiCad IPC API, window state and screenshots (X11/XWayland), menus, dialog text and buttons (AT-SPI) work; single keys need the KiCad window to have focus and coordinate clicks are not available. KiCad from the distro, the PPA, snap or Flatpak (kicad_upgrade installs the Flathub app with --user). docs/linux.md."}, } def authenticode_size(path):