app
Altium - the Altium Bridge
Public Made by Adomby adom
Adom Bridge for Altium Designer: a resident DelphiScript agent gives the AI eval, the 1,100-command process launcher, all 8 EDA surfaces with screenshots, compile/ERC, DRC, OutJob outputs, native SchLib/PcbLib authoring with STEP 3D bodies, and dialog handling by PID.
← Commit history
0.2.0: the bridge is Rust end to end — all 112 verbs dispatch from altium-bridge.exe (spawn.kind exe); Python runtime no longer ships. Instance layer, background proof capture, standing reaper, taskbar signals, measured-progress store (history-compatible), seat rules, surfaces, library authoring, import_part, outputs incl. the DRC modal dance, review_board's 14 checks fixture-tested, rcs catalog, a365 OAuth/PKCE. Verb lint reads the Rust dispatch; version drift refuses the build.
26 files changed
+6265−453
CHANGELOG.md+12adom-bridge-altium-manifest.json+4−4bridge-rs/Cargo.lock+740bridge-rs/Cargo.toml+6−1bridge-rs/src/a365.rs+592bridge-rs/src/agent.rs+113−11bridge-rs/src/dialogs.rs+253−77bridge-rs/src/health.rs+6−39bridge-rs/src/instance.rs+363bridge-rs/src/library.rs+615bridge-rs/src/license.rs+263bridge-rs/src/lifecycle.rs+121−47bridge-rs/src/main.rs+20−3bridge-rs/src/misc.rs+441bridge-rs/src/outputs.rs+354bridge-rs/src/progress.rs+385bridge-rs/src/review.rs+589bridge-rs/src/shot.rs+107bridge-rs/src/support.rs+207bridge-rs/src/surfaces.rs+334bridge-rs/src/taskbar.rs+216bridge-rs/src/verbs.rs+355−159package.json+2−2scripts/release.py+65−26src/BRIDGE_VERSION+1−1src/bridge.json+101−83CHANGELOG.md+12@@ -1,5 +1,17 @@ # Changelog +## 0.2.0 — 2026-09-02+- **The bridge is Rust, end to end.** John: *"let's finish getting everything into the rust code. i don't like that we're half rust half python now."* All 112 verbs now dispatch from `bridge-rs` (`altium-bridge.exe`, `spawn.kind: "exe"` per ab's SDK); the Python runtime no longer ships. What crossed over, at behavioural parity with 0.1.41:+ - the **instance layer** (`instance:<pid|key|word>`, `ambiguous_instance` refusal, per-instance agent workspaces `agent/<key>`, instance-aware licence gate incl. the Develop absent-tail rule);+ - **background proof capture** (restore-minimized-into-background → capture → hand the foreground back; sub-400px slivers refused as `proof_unavailable`);+ - the **standing dialog reaper** (catalog safe-actions only, lone-OK acknowledgement rule, never a save prompt, `dialog_log` ring) and the in-flight `-R` modal watcher;+ - **taskbar signals** (driving badge, measured-estimate progress bar, 5-minute idle auto-clear) and the **measured-progress store** — same on-disk paths/JSON as the Python bridge, so each machine's history survives the engine swap;+ - the **seat rules** (force-close refuses without `acceptSeatLoss`, seat hints on every licence-shaped failure, UIA-first licence page driving via `UseLicenseButton`);+ - all **surfaces, library authoring, `import_part`** (zero-model attach still fails loudly), `install_library_bundle` (hardened unzip in Rust), **outputs** (compile/ERC, OutJobs, STEP via the extension, the DRC modal dance), **review_board** (all 14 checks as pure functions, now fixture-tested), **cross_check**, the **rcs command catalog**, and the **Altium 365 OAuth/GraphQL lane** (PKCE per RFC 7636, verified against its test vector).+- The verb lint now reads the RUST dispatch (`verbs.rs` KNOWN) against `bridge.json`, and the release also refuses on version drift across `bridge.json` / `BRIDGE_VERSION` / `package.json` / `main.rs`. `cargo test` (38 tests, incl. the agent-generation digest interop test that keeps Rust and the DelphiScript template in lockstep) runs before every build.+- Two Rust-side views that existed but were never declared — `altium_board_summary`, `altium_rules_by_kind` — are now in `bridge.json` (declared-vs-dispatchable is exactly what the lint exists for).+- Still intentionally not Rust: the DelphiScript agent template (it runs INSIDE Altium; that lane is Pascal by nature) and the container-side converters (`tools/kicad2altium.py`, `tools/symbol_modes.py`), which never ship in the bridge zip.+ ## 0.1.41 — 2026-09-01 - **The tab favicon actually reaches the tab now.** 0.1.40 shipped the correct line-mark `favicon.svg`, but the dashboard's icon route aliased EVERY icon path — `/favicon.svg` included — to `dock-altium.svg`, which is a 60 KB SVG wrapping an embedded PNG raster. Hydrogen's webview reads the page's favicon after load (hydrogen-tab-icons) and renders nothing for that blob, which is precisely the icon-less tab John reported twice. The favicon route now serves `ui/favicon.svg` (the monochrome #e6edf3 line mark, same file as the header icon); `/dock-altium.svg` keeps serving the dock card art. - **`install.sh` now installs `favicon.svg`.** Its asset list was explicit, so 0.1.40's new file never landed in `~/.local/share/adom/altium-bridge/` — the pkg carried it and the installer dropped it.
adom-bridge-altium-manifest.json+4−4@@ -1,9 +1,9 @@ { "name": "altium",- "version": "0.1.41",- "url": "https://wiki.adom.inc/download/adom/altium-bridge/0.1.41/adom-bridge-altium-v0.1.41.zip",- "sha256": "92e588a89f54f329f0f9cc38aa99c8595189e2db2121fc43fe9ed733097018f7",- "size": 135933,+ "version": "0.2.0",+ "url": "https://wiki.adom.inc/download/adom/altium-bridge/0.2.0/adom-bridge-altium-v0.2.0.zip",+ "sha256": "2c425b77f908edd849829b2a36153f9d9a8e17363eed7e1728e0a70cb93ce992",+ "size": 1403462, "verbPrefixes": [ "altium_" ],
bridge-rs/Cargo.lockadded+740@@ -0,0 +1,740 @@+# This file is automatically @generated by Cargo.+# It is not intended for manual editing.+version = 4++[[package]]+name = "adler2"+version = "2.0.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"++[[package]]+name = "altium-bridge"+version = "0.2.0"+dependencies = [+ "getrandom",+ "serde",+ "serde_json",+ "tiny_http",+ "ureq",+ "zip",+]++[[package]]+name = "ascii"+version = "1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"++[[package]]+name = "base64"+version = "0.22.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"++[[package]]+name = "byteorder"+version = "1.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"++[[package]]+name = "cc"+version = "1.4.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273"+dependencies = [+ "find-msvc-tools",+ "shlex",+]++[[package]]+name = "cfg-if"+version = "1.0.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"++[[package]]+name = "chunked_transfer"+version = "1.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"++[[package]]+name = "crc32fast"+version = "1.5.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550"+dependencies = [+ "cfg-if",+]++[[package]]+name = "crossbeam-utils"+version = "0.8.22"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"++[[package]]+name = "displaydoc"+version = "0.2.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"+dependencies = [+ "proc-macro2",+ "quote",+ "syn 3.0.4",+]++[[package]]+name = "find-msvc-tools"+version = "0.1.11"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"++[[package]]+name = "flate2"+version = "1.1.10"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"+dependencies = [+ "crc32fast",+ "miniz_oxide",+]++[[package]]+name = "form_urlencoded"+version = "1.2.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"+dependencies = [+ "percent-encoding",+]++[[package]]+name = "getrandom"+version = "0.2.17"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"+dependencies = [+ "cfg-if",+ "libc",+ "wasi",+]++[[package]]+name = "httpdate"+version = "1.0.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"++[[package]]+name = "icu_collections"+version = "2.3.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"+dependencies = [+ "displaydoc",+ "potential_utf",+ "utf8_iter",+ "yoke",+ "zerofrom",+ "zerovec",+]++[[package]]+name = "icu_locale_core"+version = "2.3.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"+dependencies = [+ "displaydoc",+ "litemap",+ "tinystr",+ "writeable",+ "zerovec",+]++[[package]]+name = "icu_normalizer"+version = "2.3.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"+dependencies = [+ "icu_collections",+ "icu_normalizer_data",+ "icu_properties",+ "icu_provider",+ "smallvec",+ "zerovec",+]++[[package]]+name = "icu_normalizer_data"+version = "2.3.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"++[[package]]+name = "icu_properties"+version = "2.3.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"+dependencies = [+ "displaydoc",+ "icu_collections",+ "icu_locale_core",+ "icu_properties_data",+ "icu_provider",+ "zerotrie",+ "zerovec",+]++[[package]]+name = "icu_properties_data"+version = "2.3.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"++[[package]]+name = "icu_provider"+version = "2.3.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"+dependencies = [+ "displaydoc",+ "icu_locale_core",+ "writeable",+ "yoke",+ "zerofrom",+ "zerotrie",+ "zerovec",+]++[[package]]+name = "idna"+version = "1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"+dependencies = [+ "idna_adapter",+ "smallvec",+ "utf8_iter",+]++[[package]]+name = "idna_adapter"+version = "1.2.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"+dependencies = [+ "icu_normalizer",+ "icu_properties",+]++[[package]]+name = "itoa"+version = "1.0.18"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"++[[package]]+name = "libc"+version = "0.2.189"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"++[[package]]+name = "litemap"+version = "0.8.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"++[[package]]+name = "log"+version = "0.4.34"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"++[[package]]+name = "memchr"+version = "2.8.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"++[[package]]+name = "miniz_oxide"+version = "0.9.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"+dependencies = [+ "adler2",+ "simd-adler32",+]++[[package]]+name = "once_cell"+version = "1.21.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"++[[package]]+name = "percent-encoding"+version = "2.3.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"++[[package]]+name = "potential_utf"+version = "0.1.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"+dependencies = [+ "zerovec",+]++[[package]]+name = "proc-macro2"+version = "1.0.107"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"+dependencies = [+ "unicode-ident",+]++[[package]]+name = "quote"+version = "1.0.47"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"+dependencies = [+ "proc-macro2",+]++[[package]]+name = "ring"+version = "0.17.14"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"+dependencies = [+ "cc",+ "cfg-if",+ "getrandom",+ "libc",+ "untrusted",+ "windows-sys",+]++[[package]]+name = "rustls"+version = "0.23.43"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"+dependencies = [+ "log",+ "once_cell",+ "ring",+ "rustls-pki-types",+ "rustls-webpki",+ "subtle",+ "zeroize",+]++[[package]]+name = "rustls-pki-types"+version = "1.15.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"+dependencies = [+ "zeroize",+]++[[package]]+name = "rustls-webpki"+version = "0.103.15"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"+dependencies = [+ "ring",+ "rustls-pki-types",+ "untrusted",+]++[[package]]+name = "serde"+version = "1.0.229"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"+dependencies = [+ "serde_core",+ "serde_derive",+]++[[package]]+name = "serde_core"+version = "1.0.229"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"+dependencies = [+ "serde_derive",+]++[[package]]+name = "serde_derive"+version = "1.0.229"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"+dependencies = [+ "proc-macro2",+ "quote",+ "syn 3.0.4",+]++[[package]]+name = "serde_json"+version = "1.0.151"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"+dependencies = [+ "itoa",+ "memchr",+ "serde",+ "serde_core",+ "zmij",+]++[[package]]+name = "shlex"+version = "2.0.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"++[[package]]+name = "simd-adler32"+version = "0.3.10"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"++[[package]]+name = "smallvec"+version = "1.15.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"++[[package]]+name = "stable_deref_trait"+version = "1.2.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"++[[package]]+name = "subtle"+version = "2.6.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"++[[package]]+name = "syn"+version = "2.0.119"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"+dependencies = [+ "proc-macro2",+ "quote",+ "unicode-ident",+]++[[package]]+name = "syn"+version = "3.0.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"+dependencies = [+ "proc-macro2",+ "quote",+ "unicode-ident",+]++[[package]]+name = "synstructure"+version = "0.13.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"+dependencies = [+ "proc-macro2",+ "quote",+ "syn 2.0.119",+]++[[package]]+name = "tiny_http"+version = "0.12.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"+dependencies = [+ "ascii",+ "chunked_transfer",+ "httpdate",+ "log",+]++[[package]]+name = "tinystr"+version = "0.8.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"+dependencies = [+ "displaydoc",+ "zerovec",+]++[[package]]+name = "unicode-ident"+version = "1.0.24"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"++[[package]]+name = "untrusted"+version = "0.9.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"++[[package]]+name = "ureq"+version = "2.12.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"+dependencies = [+ "base64",+ "log",+ "once_cell",+ "rustls",+ "rustls-pki-types",+ "serde",+ "serde_json",+ "url",+ "webpki-roots 0.26.11",+]++[[package]]+name = "url"+version = "2.5.8"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"+dependencies = [+ "form_urlencoded",+ "idna",+ "percent-encoding",+ "serde",+]++[[package]]+name = "utf8_iter"+version = "1.0.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"++[[package]]+name = "wasi"+version = "0.11.1+wasi-snapshot-preview1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"++[[package]]+name = "webpki-roots"+version = "0.26.11"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"+dependencies = [+ "webpki-roots 1.0.9",+]++[[package]]+name = "webpki-roots"+version = "1.0.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"+dependencies = [+ "rustls-pki-types",+]++[[package]]+name = "windows-sys"+version = "0.52.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"+dependencies = [+ "windows-targets",+]++[[package]]+name = "windows-targets"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"+dependencies = [+ "windows_aarch64_gnullvm",+ "windows_aarch64_msvc",+ "windows_i686_gnu",+ "windows_i686_gnullvm",+ "windows_i686_msvc",+ "windows_x86_64_gnu",+ "windows_x86_64_gnullvm",+ "windows_x86_64_msvc",+]++[[package]]+name = "windows_aarch64_gnullvm"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"++[[package]]+name = "windows_aarch64_msvc"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"++[[package]]+name = "windows_i686_gnu"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"++[[package]]+name = "windows_i686_gnullvm"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"++[[package]]+name = "windows_i686_msvc"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"++[[package]]+name = "windows_x86_64_gnu"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"++[[package]]+name = "windows_x86_64_gnullvm"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"++[[package]]+name = "windows_x86_64_msvc"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"++[[package]]+name = "writeable"+version = "0.6.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"++[[package]]+name = "yoke"+version = "0.8.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"+dependencies = [+ "stable_deref_trait",+ "yoke-derive",+ "zerofrom",+]++[[package]]+name = "yoke-derive"+version = "0.8.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"+dependencies = [+ "proc-macro2",+ "quote",+ "syn 2.0.119",+ "synstructure",+]++[[package]]+name = "zerofrom"+version = "0.1.8"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"+dependencies = [+ "zerofrom-derive",+]++[[package]]+name = "zerofrom-derive"+version = "0.1.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"+dependencies = [+ "proc-macro2",+ "quote",+ "syn 2.0.119",+ "synstructure",+]++[[package]]+name = "zeroize"+version = "1.9.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"++[[package]]+name = "zerotrie"+version = "0.2.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"+dependencies = [+ "displaydoc",+ "yoke",+ "zerofrom",+]++[[package]]+name = "zerovec"+version = "0.11.8"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"+dependencies = [+ "yoke",+ "zerofrom",+ "zerovec-derive",+]++[[package]]+name = "zerovec-derive"+version = "0.11.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"+dependencies = [+ "proc-macro2",+ "quote",+ "syn 3.0.4",+]++[[package]]+name = "zip"+version = "0.6.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261"+dependencies = [+ "byteorder",+ "crc32fast",+ "crossbeam-utils",+ "flate2",+]++[[package]]+name = "zmij"+version = "1.0.23"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
bridge-rs/Cargo.toml+6−1@@ -9,7 +9,12 @@ license = "MIT" tiny_http = "0.12" serde = { version = "1", features = ["derive"] } serde_json = "1"-ureq = { version = "2", default-features = false, features = ["json"] }+# tls: the a365 lane speaks HTTPS to auth.altium.com / the workspace; loopback ab calls are unaffected+ureq = { version = "2", default-features = false, features = ["json", "tls"] }+# PKCE verifier entropy (a365 OAuth) — never roll your own randomness for a secret+getrandom = "0.2"+# install_library_bundle unpacks staged zips; deflate only (pure Rust), no bzip2 C dependency+zip = { version = "0.6", default-features = false, features = ["deflate"] } [profile.release] opt-level = "z"
bridge-rs/src/a365.rsadded+592@@ -0,0 +1,592 @@+//! Altium 365: OAuth/PKCE (the public-client ActionWait flow from Altium's+//! `AltiumDeveloper/a365-auth` spec) and API primitives (GraphQL, Files Service upload).+//! Deliberately no Altium Designer dependency: the cloud lane works while X2.exe is closed.+//! Tokens live in the bridge cache (a365.json) and NEVER travel in verb responses.+use crate::agent;+use serde_json::{json, Value};+use std::sync::Mutex;+use std::time::Duration;++const AUTH_BASE: &str = "https://auth.altium.com";+const ACTIONWAIT_ENDPOINT: &str = "https://actionwait.altium.com/await";++fn token_endpoint() -> String { format!("{AUTH_BASE}/connect/token") }+fn revocation_endpoint() -> String { format!("{AUTH_BASE}/connect/revocation") }+fn redirect_uri() -> String { format!("{AUTH_BASE}/api/AuthComplete") }++// ---- config store ------------------------------------------------------------++fn cfg_path() -> std::path::PathBuf {+ agent::cache_dir().join("a365.json")+}++fn load() -> Value {+ std::fs::read_to_string(cfg_path()).ok()+ .and_then(|s| serde_json::from_str(&s).ok())+ .unwrap_or_else(|| json!({}))+}++/// Atomic local-only persistence; tokens never travel in verb responses.+fn save(cfg: &Value) {+ let p = cfg_path();+ let _ = std::fs::create_dir_all(p.parent().unwrap_or(std::path::Path::new(".")));+ let tmp = p.with_extension("tmp");+ if std::fs::write(&tmp, cfg.to_string()).is_ok() {+ let _ = std::fs::rename(&tmp, &p);+ }+}++fn access_token(cfg: &Value) -> Option<String> {+ cfg.get("token").or_else(|| cfg.get("accessToken")).and_then(|t| t.as_str()).map(str::to_string)+}++// ---- crypto / encoding helpers -----------------------------------------------++fn b64url(raw: &[u8]) -> String {+ const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";+ let mut out = String::with_capacity(raw.len().div_ceil(3) * 4);+ for chunk in raw.chunks(3) {+ let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];+ let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;+ out.push(T[(n >> 18) as usize & 63] as char);+ out.push(T[(n >> 12) as usize & 63] as char);+ if chunk.len() > 1 { out.push(T[(n >> 6) as usize & 63] as char); }+ if chunk.len() > 2 { out.push(T[n as usize & 63] as char); }+ }+ out+}++fn b64url_decode(s: &str) -> Option<Vec<u8>> {+ let val = |c: u8| -> Option<u32> {+ Some(match c {+ b'A'..=b'Z' => (c - b'A') as u32,+ b'a'..=b'z' => (c - b'a' + 26) as u32,+ b'0'..=b'9' => (c - b'0' + 52) as u32,+ b'-' | b'+' => 62,+ b'_' | b'/' => 63,+ _ => return None,+ })+ };+ let bytes: Vec<u8> = s.bytes().filter(|b| *b != b'=').collect();+ let mut out = Vec::with_capacity(bytes.len() * 3 / 4);+ for chunk in bytes.chunks(4) {+ let mut n: u32 = 0;+ for (i, b) in chunk.iter().enumerate() {+ n |= val(*b)? << (18 - 6 * i);+ }+ out.push((n >> 16) as u8);+ if chunk.len() > 2 { out.push((n >> 8) as u8); }+ if chunk.len() > 3 { out.push(n as u8); }+ }+ Some(out)+}++fn sha256_raw(data: &[u8]) -> Vec<u8> {+ let hex = crate::lifecycle::sha256_hex(data);+ (0..hex.len()).step_by(2).map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap_or(0)).collect()+}++fn random_bytes(n: usize) -> Result<Vec<u8>, String> {+ let mut buf = vec![0u8; n];+ getrandom::getrandom(&mut buf).map_err(|e| format!("no OS entropy: {e}"))?;+ Ok(buf)+}++fn uuid4() -> Result<String, String> {+ let b = random_bytes(16)?;+ Ok(format!(+ "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-4{:x}{:02x}-{:x}{:x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",+ b[0], b[1], b[2], b[3], b[4], b[5], b[6] & 0x0f, b[7], 8 + (b[8] & 3), b[8] & 0x0f, b[9],+ b[10], b[11], b[12], b[13], b[14], b[15]+ ))+}++fn urlencode(s: &str) -> String {+ s.bytes().map(|b| match b {+ b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => (b as char).to_string(),+ b' ' => "+".into(),+ _ => format!("%{b:02X}"),+ }).collect()+}++/// Non-sensitive JWT metadata for display only; never for authorization.+fn unverified_claim_summary(access_token: &str) -> Value {+ let part = match access_token.split('.').nth(1) {+ Some(p) => p,+ None => return json!({}),+ };+ let claims: Value = match b64url_decode(part).and_then(|b| serde_json::from_slice(&b).ok()) {+ Some(c) => c,+ None => return json!({}),+ };+ let mut out = serde_json::Map::new();+ for k in ["iss", "client_id", "workspaceId", "scope", "exp"] {+ if let Some(v) = claims.get(k) {+ out.insert(k.into(), v.clone());+ }+ }+ Value::Object(out)+}++// ---- HTTP helpers ------------------------------------------------------------++/// (status, parsed JSON body). Network failure -> Err(message).+fn request_json(req: ureq::Request, body: RequestBody) -> Result<(u16, Value), String> {+ let resp = match body {+ RequestBody::Form(fields) => {+ let pairs: Vec<(&str, &str)> = fields.iter().map(|(k, v)| (*k, v.as_str())).collect();+ req.send_form(&pairs)+ }+ RequestBody::Json(v) => req.send_json(v),+ RequestBody::Bytes(b) => req.send_bytes(&b),+ };+ let (status, text) = match resp {+ Ok(r) => (r.status(), r.into_string().unwrap_or_default()),+ Err(ureq::Error::Status(code, r)) => (code, r.into_string().unwrap_or_default()),+ Err(e) => return Err(format!("Altium request failed: {e}")),+ };+ let data = if text.is_empty() { json!({}) } else {+ serde_json::from_str(&text).unwrap_or_else(|_| json!({"_raw": text}))+ };+ Ok((status, data))+}++enum RequestBody {+ Form(Vec<(&'static str, String)>),+ Json(Value),+ Bytes(Vec<u8>),+}++// ---- OAuth state -------------------------------------------------------------++static OAUTH: Mutex<Option<Value>> = Mutex::new(None);+static OAUTH_CANCELLED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);++fn oauth_state() -> Value {+ OAUTH.lock().ok().and_then(|g| g.clone()).unwrap_or_else(|| json!({"status": "idle"}))+}++fn set_oauth(v: Value) {+ if let Ok(mut g) = OAUTH.lock() {+ *g = Some(v);+ }+}++// ---- verbs -------------------------------------------------------------------++/// Store the workspace domain + a Bearer token (created in the workspace web UI under+/// Admin → Developer on plans that expose it).+pub fn configure(args: &Value) -> Value {+ let mut cfg = load();+ for k in ["workspace", "token"] {+ if let Some(v) = args.get(k).and_then(|x| x.as_str()) {+ cfg[k] = json!(v);+ }+ }+ if cfg.get("workspace").and_then(|w| w.as_str()).unwrap_or("").is_empty() {+ return json!({"success": false, "error": "workspace is required, e.g. 'john-john-3.365.altium.com'"});+ }+ save(&cfg);+ json!({"success": true, "workspace": cfg.get("workspace"), "hasToken": cfg.get("token").map(|t| !t.is_null()).unwrap_or(false),+ "_hint": "altium_a365_status to verify; altium_a365_query {query} to run GraphQL."})+}++pub fn status(_args: &Value) -> Value {+ let cfg = load();+ let ws = cfg.get("workspace").and_then(|w| w.as_str()).map(str::to_string);+ let mut out = json!({"success": true, "configured": ws.is_some(), "workspace": ws,+ "hasToken": access_token(&cfg).is_some(),+ "hasRefreshToken": cfg.get("refreshToken").map(|t| !t.is_null()).unwrap_or(false),+ "clientConfigured": cfg.get("clientId").map(|t| !t.is_null()).unwrap_or(false),+ "endpoint": cfg.get("workspace").and_then(|w| w.as_str()).map(|w| format!("https://{w}/svc/napi/gateway/graphql/"))});+ if access_token(&cfg).is_none() {+ out["_hint"] = json!("No access token. Preferred path: register an Altium public OAuth client, then altium_a365_oauth_begin {clientId, workspace}; open authorizeUrl only after the verb returns. Manual Admin → Developer tokens remain supported by altium_a365_configure.");+ return out;+ }+ let r = query(&json!({"query": "{ desProjects(first: 1) { totalCount } }"}));+ out["tokenLive"] = json!(r.get("success").and_then(|b| b.as_bool()).unwrap_or(false));+ out["probe"] = r.get("data").or_else(|| r.get("error")).cloned().unwrap_or(Value::Null);+ out+}++pub fn query(args: &Value) -> Value {+ let cfg = load();+ let ws = cfg.get("workspace").and_then(|w| w.as_str()).unwrap_or("").to_string();+ let token = access_token(&cfg);+ if ws.is_empty() || token.is_none() {+ return json!({"success": false, "errorCode": "a365_not_configured",+ "_hint": "altium_a365_oauth_begin {clientId, workspace} or altium_a365_configure {workspace, token}"});+ }+ let q = match args.get("query").and_then(|x| x.as_str()) {+ Some(q) if !q.is_empty() => q.to_string(),+ _ => return json!({"success": false, "error": "query (GraphQL) is required",+ "_hint": "e.g. { desProjects(first:10){ nodes { name } } }"}),+ };+ let host = ws.trim_start_matches("https://").trim_start_matches("http://").trim_end_matches('/');+ let timeout = args.get("timeout").and_then(|t| t.as_u64()).unwrap_or(60);+ let req = ureq::post(&format!("https://{host}/svc/napi/gateway/graphql/"))+ .timeout(Duration::from_secs(timeout))+ .set("Authorization", &format!("Bearer {}", token.unwrap()));+ let body = json!({"query": q, "variables": args.get("variables").cloned().unwrap_or(json!({}))});+ match request_json(req, RequestBody::Json(body)) {+ Err(e) => json!({"success": false, "errorCode": "a365_network_error", "error": e,+ "_hint": "Check altium_a365_status; refresh or sign in again if the token expired."}),+ Ok((s, _)) if s != 200 => json!({"success": false, "errorCode": "a365_graphql_http",+ "error": format!("GraphQL returned HTTP {s}."),+ "_hint": "Check altium_a365_status; refresh or sign in again if the token expired."}),+ Ok((_, data)) => json!({"success": data.get("errors").is_none(),+ "data": data.get("data"), "errors": data.get("errors"),+ "_hint": "Explore the schema at https://<workspace>/api/voyager."}),+ }+}++/// Start ActionWait BEFORE returning the browser URL (Altium SPEC section 4.4).+pub fn oauth_begin(args: &Value) -> Value {+ let cfg = load();+ let client_id = args.get("clientId").or_else(|| cfg.get("clientId")).and_then(|x| x.as_str()).unwrap_or("").trim().to_string();+ let workspace = args.get("workspace").or_else(|| cfg.get("workspace")).and_then(|x| x.as_str()).unwrap_or("").to_string();+ if workspace.is_empty() {+ return json!({"success": false, "errorCode": "a365_workspace_required",+ "error": "workspace is required, e.g. john-john-3.365.altium.com"});+ }+ if client_id.is_empty() {+ return json!({"success": false, "errorCode": "a365_client_id_required",+ "error": "An Altium-issued OAuth client ID is required.",+ "_hint": "Altium OAuth clients are issued through Developer Center enrollment; do not reuse a first-party client ID."});+ }+ if oauth_state().get("status").and_then(|s| s.as_str()) == Some("waiting") {+ return json!({"success": false, "errorCode": "a365_oauth_in_progress",+ "_hint": "altium_a365_oauth_status; cancel the old flow before starting another."});+ }+ let select_workspace = args.get("selectWorkspace").and_then(|s| s.as_str()).unwrap_or("strict").to_string();+ if !matches!(select_workspace.as_str(), "none" | "strict" | "optional") {+ return json!({"success": false, "errorCode": "a365_bad_workspace_selection",+ "error": "selectWorkspace must be none, strict, or optional."});+ }+ let verifier = match random_bytes(32) {+ Ok(b) => b64url(&b),+ Err(e) => return json!({"success": false, "errorCode": "a365_entropy", "error": e}),+ };+ let challenge = b64url(&sha256_raw(verifier.as_bytes()));+ let wait_token = match uuid4() {+ Ok(u) => u,+ Err(e) => return json!({"success": false, "errorCode": "a365_entropy", "error": e}),+ };+ let mut params = vec![+ ("client_id", client_id.clone()),+ ("response_type", "code".to_string()),+ ("scope", "openid profile offline_access".to_string()),+ ("redirect_uri", redirect_uri()),+ ("code_challenge", challenge),+ ("code_challenge_method", "S256".to_string()),+ ("state", wait_token.clone()),+ ];+ if select_workspace != "none" {+ params.push(("selectWorkspace", select_workspace));+ }+ let qs = params.iter().map(|(k, v)| format!("{k}={}", urlencode(v))).collect::<Vec<_>>().join("&");+ let authorize_url = format!("{AUTH_BASE}/connect/authorize?{qs}");+ let timeout = args.get("timeout").and_then(|t| t.as_u64()).unwrap_or(300);+ OAUTH_CANCELLED.store(false, std::sync::atomic::Ordering::SeqCst);+ set_oauth(json!({"status": "waiting", "workspace": workspace,+ "startedAt": crate::support::now() as i64, "expiresInSec": timeout}));+ let (cid, ws, wt, ver) = (client_id, workspace, wait_token, verifier);+ let _ = std::thread::Builder::new().name("altium-a365-oauth".into()).spawn(move || {+ oauth_worker(&cid, &ws, &wt, &ver, timeout);+ });+ json!({"success": true, "status": "waiting", "workspace": oauth_state().get("workspace"),+ "authorizeUrl": authorize_url,+ "_hint": "ActionWait is listening now. Open authorizeUrl in the user's existing browser, then poll altium_a365_oauth_status. Tokens never appear in verb output."})+}++fn oauth_worker(client_id: &str, workspace: &str, wait_token: &str, verifier: &str, overall_timeout: u64) {+ // Long-poll ActionWait for the one-time code. HTTP 408 = reconnect; 410 = terminal.+ let deadline = std::time::Instant::now() + Duration::from_secs(overall_timeout);+ let code = loop {+ if OAUTH_CANCELLED.load(std::sync::atomic::Ordering::SeqCst) {+ set_oauth(json!({"status": "cancelled"}));+ return;+ }+ if std::time::Instant::now() >= deadline {+ set_oauth(json!({"status": "error", "errorCode": "a365_oauth_timeout", "error": "Timed out waiting for Altium sign-in."}));+ return;+ }+ let req = ureq::post(ACTIONWAIT_ENDPOINT).timeout(Duration::from_secs(75));+ match request_json(req, RequestBody::Json(json!({"token": wait_token}))) {+ Err(e) => {+ set_oauth(json!({"status": "error", "errorCode": "a365_network_error", "error": e}));+ return;+ }+ Ok((408, _)) => continue,+ Ok((410, _)) => {+ set_oauth(json!({"status": "error", "errorCode": "a365_actionwait_gone", "error": "ActionWait token was consumed or superseded."}));+ return;+ }+ Ok((200, data)) => {+ let result = data.get("data").cloned().unwrap_or(Value::Null);+ let code = result.get("code").and_then(|c| c.as_str()).unwrap_or("").to_string();+ let state = result.get("state").and_then(|s| s.as_str()).unwrap_or("").to_string();+ if code.is_empty() || state.is_empty() {+ set_oauth(json!({"status": "error", "errorCode": "a365_actionwait_shape", "error": "ActionWait response omitted code or state."}));+ return;+ }+ if state != wait_token {+ set_oauth(json!({"status": "error", "errorCode": "a365_state_mismatch", "error": "OAuth state mismatch; authorization was rejected."}));+ return;+ }+ break code;+ }+ Ok((s, _)) => {+ set_oauth(json!({"status": "error", "errorCode": "a365_actionwait_http", "error": format!("ActionWait returned HTTP {s}.")}));+ return;+ }+ }+ };+ // exchange the code+ let req = ureq::post(&token_endpoint()).timeout(Duration::from_secs(60));+ let fields = vec![+ ("grant_type", "authorization_code".to_string()),+ ("code", code),+ ("code_verifier", verifier.to_string()),+ ("redirect_uri", redirect_uri()),+ ("client_id", client_id.to_string()),+ ];+ let tokens = match request_json(req, RequestBody::Form(fields)) {+ Ok((200, data)) if data.get("access_token").is_some() => data,+ Ok((s, data)) => {+ let oe = data.get("error").and_then(|e| e.as_str()).unwrap_or("").to_string();+ set_oauth(json!({"status": "error", "errorCode": "a365_token_exchange_failed",+ "error": format!("Token exchange failed ({}).", if oe.is_empty() { s.to_string() } else { oe })}));+ return;+ }+ Err(e) => {+ set_oauth(json!({"status": "error", "errorCode": "a365_network_error", "error": e}));+ return;+ }+ };+ let at = tokens.get("access_token").and_then(|t| t.as_str()).unwrap_or("").to_string();+ let mut cfg = load();+ cfg["workspace"] = json!(workspace);+ cfg["clientId"] = json!(client_id);+ cfg["token"] = json!(at);+ cfg["accessToken"] = json!(at);+ if let Some(rt) = tokens.get("refresh_token").and_then(|t| t.as_str()) {+ cfg["refreshToken"] = json!(rt);+ }+ cfg["tokenEndpoint"] = json!(token_endpoint());+ cfg["expiresAt"] = json!(crate::support::now() as i64 + tokens.get("expires_in").and_then(|e| e.as_i64()).unwrap_or(0));+ save(&cfg);+ set_oauth(json!({"status": "authorized", "workspace": workspace,+ "claims": unverified_claim_summary(&at)}));+}++pub fn oauth_status(_args: &Value) -> Value {+ let mut state = oauth_state();+ let status = state.get("status").and_then(|s| s.as_str()).unwrap_or("idle").to_string();+ state["success"] = json!(status != "error");+ if status == "waiting" {+ state["_hint"] = json!("Complete the Altium consent page in the browser; ActionWait is already listening.");+ } else if status == "authorized" {+ state["_hint"] = json!("OAuth is live. altium_a365_library_overview proves the workspace-scoped token.");+ }+ state+}++pub fn oauth_cancel(_args: &Value) -> Value {+ if oauth_state().get("status").and_then(|s| s.as_str()) != Some("waiting") {+ return json!({"success": true, "alreadyStopped": true, "status": oauth_state().get("status").cloned().unwrap_or(json!("idle"))});+ }+ OAUTH_CANCELLED.store(true, std::sync::atomic::Ordering::SeqCst);+ json!({"success": true, "status": "cancelling", "_hint": "Poll altium_a365_oauth_status."})+}++pub fn refresh(_args: &Value) -> Value {+ let mut cfg = load();+ let client_id = cfg.get("clientId").and_then(|c| c.as_str()).unwrap_or("").to_string();+ let refresh_token = cfg.get("refreshToken").and_then(|c| c.as_str()).unwrap_or("").to_string();+ if client_id.is_empty() || refresh_token.is_empty() {+ return json!({"success": false, "errorCode": "a365_refresh_not_configured",+ "_hint": "Complete altium_a365_oauth_begin first with offline_access."});+ }+ let endpoint = cfg.get("tokenEndpoint").and_then(|t| t.as_str()).map(str::to_string).unwrap_or_else(token_endpoint);+ let req = ureq::post(&endpoint).timeout(Duration::from_secs(60));+ let fields = vec![+ ("grant_type", "refresh_token".to_string()),+ ("refresh_token", refresh_token),+ ("client_id", client_id),+ ];+ let tokens = match request_json(req, RequestBody::Form(fields)) {+ Ok((200, data)) if data.get("access_token").is_some() => data,+ Ok((s, data)) => {+ let oe = data.get("error").and_then(|e| e.as_str()).unwrap_or("").to_string();+ return json!({"success": false, "errorCode": "a365_refresh_failed",+ "error": format!("Token refresh failed ({}).", if oe.is_empty() { s.to_string() } else { oe })});+ }+ Err(e) => return json!({"success": false, "errorCode": "a365_network_error", "error": e}),+ };+ let at = tokens.get("access_token").and_then(|t| t.as_str()).unwrap_or("").to_string();+ cfg["token"] = json!(at);+ cfg["accessToken"] = json!(at);+ if let Some(rt) = tokens.get("refresh_token").and_then(|t| t.as_str()) {+ cfg["refreshToken"] = json!(rt);+ }+ cfg["expiresAt"] = json!(crate::support::now() as i64 + tokens.get("expires_in").and_then(|e| e.as_i64()).unwrap_or(0));+ save(&cfg);+ json!({"success": true, "workspace": cfg.get("workspace"),+ "claims": unverified_claim_summary(&at),+ "_hint": "Access token refreshed; rotated refresh token (if any) was stored atomically."})+}++/// Revoke the refresh grant and remove all locally cached OAuth tokens.+pub fn logout(_args: &Value) -> Value {+ let mut cfg = load();+ let refresh_token = cfg.get("refreshToken").and_then(|c| c.as_str()).unwrap_or("").to_string();+ let client_id = cfg.get("clientId").and_then(|c| c.as_str()).unwrap_or("").to_string();+ let mut revoked = false;+ if !refresh_token.is_empty() && !client_id.is_empty() {+ let endpoint = cfg.get("revocationEndpoint").and_then(|t| t.as_str()).map(str::to_string).unwrap_or_else(revocation_endpoint);+ let req = ureq::post(&endpoint).timeout(Duration::from_secs(60));+ let fields = vec![+ ("token", refresh_token),+ ("token_type_hint", "refresh_token".to_string()),+ ("client_id", client_id),+ ];+ match request_json(req, RequestBody::Form(fields)) {+ Ok((200, _)) => revoked = true,+ Ok((s, _)) => return json!({"success": false, "errorCode": "a365_revoke_failed",+ "error": format!("Token revocation returned HTTP {s}."),+ "_hint": "The local grant was retained because remote revocation did not succeed."}),+ Err(e) => return json!({"success": false, "errorCode": "a365_network_error", "error": e,+ "_hint": "The local grant was retained because remote revocation did not succeed."}),+ }+ }+ if let Some(o) = cfg.as_object_mut() {+ for k in ["token", "accessToken", "refreshToken", "expiresAt"] {+ o.remove(k);+ }+ }+ save(&cfg);+ json!({"success": true, "revoked": revoked, "workspace": cfg.get("workspace"),+ "_hint": "OAuth tokens were removed from the bridge cache."})+}++/// Upload an opaque mutation input through the documented Files Service.+pub fn files_upload(args: &Value) -> Value {+ let cfg = load();+ let ws = cfg.get("workspace").and_then(|w| w.as_str()).unwrap_or("").to_string();+ let token = access_token(&cfg);+ if ws.is_empty() || token.is_none() {+ return json!({"success": false, "errorCode": "a365_not_configured",+ "_hint": "Complete altium_a365_oauth_begin first."});+ }+ let file_path = match args.get("filePath").and_then(|f| f.as_str()) {+ Some(f) if !f.is_empty() => f.to_string(),+ _ => return json!({"success": false, "errorCode": "a365_file_required", "error": "filePath is required"}),+ };+ let max_bytes = args.get("maxBytes").and_then(|m| m.as_u64()).unwrap_or(100 * 1024 * 1024);+ let meta = match std::fs::metadata(&file_path) {+ Ok(m) if m.is_file() => m,+ _ => return json!({"success": false, "errorCode": "a365_file_not_found",+ "error": format!("Upload file does not exist: {file_path}")}),+ };+ if meta.len() > max_bytes {+ return json!({"success": false, "errorCode": "a365_file_too_large",+ "error": format!("Upload is {} bytes; limit is {max_bytes} bytes.", meta.len())});+ }+ let content = match std::fs::read(&file_path) {+ Ok(c) => c,+ Err(e) => return json!({"success": false, "errorCode": "a365_file_not_found", "error": format!("{e}")}),+ };+ let fname = std::path::Path::new(&file_path).file_name()+ .map(|n| n.to_string_lossy().replace('"', "_"))+ .unwrap_or_else(|| "upload".into());+ let boundary = format!("----adom-a365-{}", b64url(&random_bytes(16).unwrap_or_default()).to_lowercase());+ let mut body = format!(+ "--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{fname}\"\r\nContent-Type: application/octet-stream\r\n\r\n"+ ).into_bytes();+ body.extend_from_slice(&content);+ body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());+ let host = ws.trim_start_matches("https://").trim_start_matches("http://").trim_end_matches('/');+ let timeout = args.get("timeout").and_then(|t| t.as_u64()).unwrap_or(120);+ let req = ureq::post(&format!("https://{host}/svc/napi/files/File/Upload"))+ .timeout(Duration::from_secs(timeout))+ .set("Content-Type", &format!("multipart/form-data; boundary={boundary}"))+ .set("Authorization", &format!("Bearer {}", token.unwrap()));+ match request_json(req, RequestBody::Bytes(body)) {+ Err(e) => json!({"success": false, "errorCode": "a365_network_error", "error": e}),+ Ok((200, data)) => {+ let file_id = data.get("_raw").and_then(|r| r.as_str())+ .map(|s| s.trim().trim_matches('"').to_string())+ .or_else(|| data.as_str().map(str::to_string))+ .unwrap_or_else(|| data.to_string().trim_matches('"').to_string());+ if file_id.is_empty() || file_id == "{}" {+ return json!({"success": false, "errorCode": "a365_file_upload_failed", "error": "Files Service returned an empty id."});+ }+ json!({"success": true, "fileId": file_id, "expiresAfterHoursInactive": 24,+ "_hint": "Temporary opaque file ID. Pass it to the matching library mutation; do not parse or persist it."})+ }+ Ok((s, _)) => json!({"success": false, "errorCode": "a365_file_upload_failed",+ "error": format!("Files Service returned HTTP {s}.")}),+ }+}++pub fn library_overview(_args: &Value) -> Value {+ query(&json!({"query": "query GetLibraryOverview { desLibrary { name description updatedAt components { nodes { id name } } footprints { totalCount } symbols { totalCount } } }"}))+}++pub fn library_schema(args: &Value) -> Value {+ let q = "query LibrarySchema {\n __schema {\n queryType { fields { name description } }\n mutationType { fields { name description args { name type { kind name ofType { kind name } } } } }\n }\n}";+ let result = query(&json!({"query": q, "timeout": args.get("timeout").and_then(|t| t.as_u64()).unwrap_or(60)}));+ if result.get("success").and_then(|b| b.as_bool()) != Some(true) {+ return result;+ }+ let schema = result.get("data").and_then(|d| d.get("__schema")).cloned().unwrap_or(json!({}));+ let pattern = ["lib", "component", "symbol", "footprint", "part", "datasheet"];+ let relevant = |fields: Option<&Value>| -> Vec<Value> {+ fields.and_then(|f| f.as_array()).map(|a| {+ a.iter().filter(|f| {+ let name = f.get("name").and_then(|n| n.as_str()).unwrap_or("").to_lowercase();+ pattern.iter().any(|p| name.contains(p))+ }).cloned().collect()+ }).unwrap_or_default()+ };+ json!({"success": true,+ "queries": relevant(schema.get("queryType").and_then(|t| t.get("fields"))),+ "mutations": relevant(schema.get("mutationType").and_then(|t| t.get("fields"))),+ "_hint": "Read-only schema map. Choose a mutation only after its input types and rollback/delete mutation are understood."})+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn b64url_roundtrip_and_jwt_claims() {+ assert_eq!(b64url(b"foobar"), "Zm9vYmFy");+ assert_eq!(b64url_decode("Zm9vYmFy").unwrap(), b"foobar");+ // urlsafe chars+ let raw: Vec<u8> = vec![0xfb, 0xff, 0xbe];+ let enc = b64url(&raw);+ assert!(!enc.contains('+') && !enc.contains('/'));+ assert_eq!(b64url_decode(&enc).unwrap(), raw);+ // a fake JWT: header.payload.sig with payload {"iss":"x","exp":1}+ let payload = b64url(br#"{"iss":"x","exp":1,"secret":"never"}"#);+ let claims = unverified_claim_summary(&format!("h.{payload}.s"));+ assert_eq!(claims["iss"], json!("x"));+ assert!(claims.get("secret").is_none(), "only allow-listed claims may surface");+ }++ #[test]+ fn pkce_challenge_matches_reference() {+ // RFC 7636 appendix B vector+ let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";+ let challenge = b64url(&sha256_raw(verifier.as_bytes()));+ assert_eq!(challenge, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");+ }++ #[test]+ fn urlencode_is_form_safe() {+ assert_eq!(urlencode("a b+c/d"), "a+b%2Bc%2Fd");+ assert_eq!(urlencode("openid profile offline_access"), "openid+profile+offline_access");+ }+}
bridge-rs/src/agent.rs+113−11@@ -3,21 +3,99 @@ //! request.payload optional multi-line body (statements for eval, specs, ...) //! response_<rid>.json the agent's reply; `data.ok` decides success //! hb.txt heartbeat the loop refreshes every tick+//!+//! The workspace is PER INSTANCE (`agent/<key>/`): two Altiums polling one inbox means whichever+//! notices first eats the other's request and answers it from the wrong board (measured+//! 2026-09-01 running Designer + Designer Develop for two AI threads). No selected instance =+//! the legacy single-instance folder, so one-Altium users see no change.+use crate::instance; use serde_json::{json, Value}; use std::path::PathBuf; use std::time::{Duration, Instant, SystemTime}; -pub fn workspace_dir() -> PathBuf {+/// Two different questions, two thresholds (measured 2026-08-29):+/// responsive = hb < 3 s (idle-polling right now); alive = hb < 60 s (the loop exists but may be+/// INSIDE a long RunProcess and cannot heartbeat until it returns). Treating "busy" as "dead" and+/// starting a second loop raises "Another script executing now" on the user's screen.+pub const ALIVE_SEC: u64 = 60;+pub const RESPONSIVE_SEC: u64 = 3;++pub fn cache_dir() -> PathBuf { let local = std::env::var("LOCALAPPDATA").unwrap_or_else(|_| r"C:\Users\Public".into());- PathBuf::from(local).join("Adom Bridge").join("bridges-cache").join("altium").join("agent")+ PathBuf::from(local).join("Adom Bridge").join("bridges-cache").join("altium")+}++fn agent_root() -> PathBuf {+ cache_dir().join("agent")+}++pub fn workspace_dir() -> PathBuf {+ let root = agent_root();+ let d = match instance::selected().and_then(|i| i.get("key").and_then(|k| k.as_str()).map(str::to_string)) {+ Some(key) => root.join(key),+ None => root,+ };+ let _ = std::fs::create_dir_all(&d);+ d+}++fn hb_age(ws: &std::path::Path) -> Option<Duration> {+ std::fs::metadata(ws.join("hb.txt"))+ .and_then(|m| m.modified())+ .ok()+ .and_then(|t| SystemTime::now().duration_since(t).ok()) } pub fn is_alive() -> bool {- let hb = workspace_dir().join("hb.txt");- match std::fs::metadata(&hb).and_then(|m| m.modified()) {- Ok(t) => SystemTime::now().duration_since(t).map(|d| d < Duration::from_secs(8)).unwrap_or(false),- Err(_) => false,+ hb_age(&workspace_dir()).map(|d| d < Duration::from_secs(ALIVE_SEC)).unwrap_or(false)+}++pub fn is_responsive() -> bool {+ hb_age(&workspace_dir()).map(|d| d < Duration::from_secs(RESPONSIVE_SEC)).unwrap_or(false)+}++/// Heartbeat parsed: first line = tick, then k=v (gen=, phase counters...).+pub fn heartbeat() -> Option<Value> {+ let ws = workspace_dir();+ let raw = std::fs::read_to_string(ws.join("hb.txt")).ok()?;+ let age = hb_age(&ws)?;+ let mut out = json!({"ageSec": (age.as_millis() as f64) / 1000.0});+ let mut lines = raw.lines();+ if let Some(first) = lines.next() {+ if let Ok(t) = first.trim().parse::<u64>() {+ out["tick"] = json!(t);+ } }+ for l in lines {+ if let Some((k, v)) = l.split_once('=') {+ out[k.trim()] = json!(v.trim());+ }+ }+ Some(out)+}++/// Is ANY agent loop alive in ANY workspace (legacy root or any per-instance subfolder)?+/// A `-R` script lane must refuse if so: Altium raises "Another script executing now" for the+/// second script REGARDLESS of which workspace the first one polls. Returns the workspace path+/// of the live loop so the refusal can name it.+pub fn any_agent_alive() -> Option<(PathBuf, f64)> {+ let root = agent_root();+ let mut candidates = vec![root.clone()];+ if let Ok(rd) = std::fs::read_dir(&root) {+ for e in rd.flatten() {+ if e.path().is_dir() {+ candidates.push(e.path());+ }+ }+ }+ for ws in candidates {+ if let Some(age) = hb_age(&ws) {+ if age < Duration::from_secs(ALIVE_SEC) {+ return Some((ws, (age.as_millis() as f64) / 1000.0));+ }+ }+ }+ None } fn rid() -> String {@@ -27,7 +105,16 @@ fn rid() -> String { pub fn call(command: &str, params: &[(&str, String)], payload: Option<&str>, timeout: Duration) -> Value { if !is_alive() {- return json!({"success": false, "errorCode": "agent_not_running", "_hint": "altium_agent_start (the Python bridge bootstraps the agent; the Rust bridge reuses a running one)"});+ // parity with the Python bridge: bootstrap on demand, so a cold box needs no ceremony+ let started = crate::lifecycle::start(Duration::from_secs(45));+ if started.get("success").and_then(|b| b.as_bool()) != Some(true) {+ let mut out = started;+ if let Some(o) = out.as_object_mut() {+ o.entry("errorCode").or_insert(json!("agent_not_running"));+ o.insert("_hint".into(), json!("the resident agent could not be bootstrapped — see this reply's fields; altium_agent_recover if the engine is halted behind a modal"));+ }+ return out;+ } } let ws = workspace_dir(); let id = rid();@@ -41,8 +128,9 @@ pub fn call(command: &str, params: &[(&str, String)], payload: Option<&str>, tim let inbox = ws.join("request.txt"); let t0 = Instant::now(); while inbox.exists() && t0.elapsed() < timeout {- std::thread::sleep(Duration::from_millis(20));+ std::thread::sleep(Duration::from_millis(5)); }+ let t_inbox = t0.elapsed().as_millis() as u64; if inbox.exists() { return json!({"success": false, "errorCode": "agent_busy", "error": "previous request still in the inbox"}); }@@ -59,9 +147,13 @@ pub fn call(command: &str, params: &[(&str, String)], payload: Option<&str>, tim if let Err(e) = std::fs::rename(&tmp, &inbox) { return json!({"success": false, "error": format!("cannot post request: {e}")}); }+ let t_post = t0.elapsed().as_millis() as u64; let resp = ws.join(format!("response_{id}.json"));+ let mut t_seen: Option<u64> = None;+ let mut parse_retries = 0u32; while t0.elapsed() < timeout { if resp.exists() {+ if t_seen.is_none() { t_seen = Some(t0.elapsed().as_millis() as u64); } if let Ok(raw) = std::fs::read_to_string(&resp) { if let Ok(mut v) = serde_json::from_str::<Value>(&raw) { let _ = std::fs::remove_file(&resp);@@ -69,15 +161,25 @@ pub fn call(command: &str, params: &[(&str, String)], payload: Option<&str>, tim if let Some(o) = v.as_object_mut() { o.insert("success".into(), json!(ok)); o.insert("roundtripMs".into(), json!(t0.elapsed().as_millis() as u64));+ o.insert("_timing".into(), json!({"inboxWaitMs": t_inbox, "postedAtMs": t_post, "responseSeenAtMs": t_seen, "parseRetries": parse_retries, "totalMs": t0.elapsed().as_millis() as u64})); } return v; } }- std::thread::sleep(Duration::from_millis(50));+ parse_retries += 1;+ std::thread::sleep(Duration::from_millis(5)); continue; }- std::thread::sleep(Duration::from_millis(10));+ std::thread::sleep(Duration::from_millis(2)); }- json!({"success": false, "errorCode": "timed_out", "error": format!("agent did not answer within {}s", timeout.as_secs()),+ json!({"success": false, "errorCode": "agent_timeout", "error": format!("agent did not answer within {}s", timeout.as_secs()), "agentAlive": is_alive(), "_hint": "a slow Altium process, or the engine halted on a modal — altium_check_dialogs / altium_agent_recover"}) }++/// A call that must NOT bootstrap the agent (status probes). Answers agent_not_running instead.+pub fn call_no_autostart(command: &str, params: &[(&str, String)], timeout: Duration) -> Value {+ if !is_alive() {+ return json!({"success": false, "errorCode": "agent_not_running"});+ }+ call(command, params, None, timeout)+}
bridge-rs/src/dialogs.rs+253−77@@ -1,94 +1,158 @@-//! Modal detection through ab. A dialog on Altium's main process is not just noise: it FREEZES the-//! resident script loop (the engine is single-threaded), so "the agent stopped answering" and-//! "a modal is up" are the same event. Every verb that times out should report what is on screen.-use crate::ab;+//! Modal detection, classification and the STANDING REAPER — ported from the Python bridge's+//! `dialogs.py`. A dialog on Altium's main process FREEZES the resident script loop (the engine is+//! single-threaded), so "the agent stopped answering" and "a modal is up" are the same event.+//!+//! Scoped by PID via the instance snapshot, never by title: a title match once swept up four+//! "RemoteApp security warning" windows that had nothing to do with Altium (2026-09-01) — and+//! pressing buttons in someone else's application is exactly what this bridge must never do.+//! When an instance is selected, the sweep is scoped to it: with two Altiums running, an unscoped+//! sweep lets one AI thread see — and clear — a modal belonging to the other thread's Altium.+use crate::{ab, instance}; use serde_json::{json, Value};+use std::sync::atomic::{AtomicBool, Ordering};+use std::sync::Mutex;+use std::time::Duration; -/// Dialog window classes, from the Python bridge's measured list. NOTE `#32770` is the generic-/// Win32 dialog class — half the desktop uses it — so class alone is NEVER enough to decide a-/// window is Altium's. See `scan()`. const DIALOG_CLASSES: [&str; 5] = ["TMessageForm", "#32770", "TForm", "TMessageDialog", "TForm_RunScriptParameters"]; -/// Every dialog owned by an Altium process.-///-/// Scoped by PID, not by title. The first cut of this matched window titles containing "Error" /-/// "Warning" and immediately picked up four **"RemoteApp security warning"** windows that had-/// nothing to do with Altium (2026-09-01, on John's desktop) — which `dismiss_dialogs` would then-/// have clicked OK on. Pressing buttons in someone else's application is exactly the kind of thing-/// this bridge must never do, so: ask ab which pids are Altium (`process_list`), enumerate only-/// those windows (`desktop_enum_windows {pids}`), and let class + "not the main window" decide.-pub fn scan() -> Vec<Value> {- scan_checked().unwrap_or_default()-}+/// title/body substring -> (category, safe default action or "" = none, meaning).+/// A safeAction of "" means the reaper must NEVER touch it (save prompts carry decisions).+const CATALOG: &[(&str, &str, &str, &str)] = &[+ ("another script executing", "script_busy", "OK",+ "A -R script request arrived while the agent loop (or another script) was resident. Dismiss; route work through the agent."),+ ("continue execution?", "script_runtime_error", "Cancel",+ "A script hit a runtime error. Cancel stops it cleanly; OK continues past the error."),+ ("undeclared identifier", "script_compile_error", "OK",+ "The script failed to compile; the engine is not halted after OK."),+ ("unknown identifier", "script_compile_error", "OK",+ "Compile error in RunScriptText text (statements only, no Var)."),+ ("select item to run", "run_script_picker", "Cancel",+ "Altium could not resolve the -R RunScript target (unit did not compile, or wrong ProcName) and opened its Run Script picker. Cancel it, fix the script."),+ ("another instance of", "second_instance_busy", "OK",+ "A helper X2 could not hand its -R request to the running instance because that instance is busy (a modal is open)."),+ ("do you want to save", "save_prompt", "",+ "Unsaved changes on close. Decide: Yes saves, No discards. Never default this."),+ ("save changes", "save_prompt", "", "Unsaved changes. Decide explicitly."),+ ("could not start vault explorer", "altium_extension_broken", "OK",+ "Altium's own VaultExplorer extension fails to load at startup. NOT caused by the bridge. Informational — dismissing it costs nothing, and the standing reaper does so automatically."),+ ("file type not recognised", "unsupported_document", "OK",+ "Altium was handed a path it does not open. From the bridge that means a bad open call (the bridge refuses unsupported extensions up front); at STARTUP it means Altium is restoring a document from the last session that it can no longer open. Safe to dismiss."),+ ("file type not recognized", "unsupported_document", "OK", "Same as 'not recognised' (US spelling)."),+ ("license maximum usage reached", "licence_seat_stranded", "OK",+ "The seat is held by an instance that never released it — almost always an Altium that was KILLED rather than closed. The licence page will still show Seats 0/1, which looks free and is not. FIX: close every other Altium POLITELY (altium_close, never process_kill), then altium_license {action:'use_license'}. Waiting does not clear it."),+ ("license renewal failed", "licence_seat_stranded", "OK",+ "Altium could not renew the On-Demand lease. Same cause and fix as 'maximum usage reached': a killed instance is still holding the seat. This dialog RE-FIRES on a timer until the seat is reclaimed, so clearing it is not the fix."),+ ("cannot focus a disabled or invisible window", "altium_internal", "Send and Close",+ "Altium's own crash-report dialog, usually raised when a window's state changed while it was focusing a document view. Its only button SENDS a report to Altium — try the title-bar X first if the user's data matters."),+ ("unsaved changes", "save_prompt", "",+ "Names the document(s) at risk — READ IT. On a shared machine the document may belong to ANOTHER AI thread; Save preserves their work, Don't Save destroys it. Never default this."),+ ("license", "licence", "", "Licence prompt — read it; usually informational."),+ ("trial", "licence", "", "Trial notice."),+]; -/// `Err(reason)` when we could not LOOK — which must never be reported to a user as "clean".-/// (2026-09-01: this called `process_list {nameContains}`, which ab rejects as bad_args. The error-/// yielded an empty pid list, the empty list yielded an empty dialog list, and `check_dialogs`-/// cheerfully answered "clean" while a Warning sat on John's screen. A failed look and an empty-/// screen must not share a return value.)-pub fn scan_checked() -> Result<Vec<Value>, String> {- let pl = ab::call("process_list", json!({"nameFilter": "X2"}), &[], 15);- if pl.get("success").and_then(|b| b.as_bool()) == Some(false) {- return Err(format!("process_list failed: {}", pl.get("error").and_then(|e| e.as_str()).unwrap_or("unknown")));+pub fn classify(title: &str, body: &str) -> (String, Option<String>, String) {+ let hay = format!("{title} {body}").to_lowercase();+ for (needle, cat, action, meaning) in CATALOG {+ if hay.contains(needle) {+ let act = if action.is_empty() { None } else { Some(action.to_string()) };+ return (cat.to_string(), act, meaning.to_string());+ } }- let procs = pl.get("processes").and_then(|a| a.as_array()).cloned().unwrap_or_default();- let pids: Vec<u64> = procs.iter().filter_map(|p| p.get("pid").and_then(|v| v.as_u64())).collect();- if pids.is_empty() {- return Ok(Vec::new()); // Altium genuinely is not running+ ("unknown".into(), None, "Not in the catalog — read the text and decide; never blind-dismiss.".into())+}++/// The painted message via UIA (Altium's TMessageForm has no Static children; WM_GETTEXT sees+/// only button labels, which is why classification must go through ab's desktop_ui_text).+fn dialog_text(hwnd: i64) -> String {+ let r = ab::call("desktop_ui_text", json!({"hwnd": hwnd}), &[], 10);+ match r.get("text").or_else(|| r.get("texts")) {+ Some(Value::String(s)) => s.clone(),+ Some(Value::Array(a)) => a.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(" "),+ _ => String::new(), }- let r = ab::call("desktop_enum_windows", json!({"pids": pids}), &[], 20);- if r.get("success").and_then(|b| b.as_bool()) == Some(false) {- return Err(format!("desktop_enum_windows failed: {}", r.get("error").and_then(|e| e.as_str()).unwrap_or("unknown")));+}++fn buttons(hwnd: i64) -> Vec<String> {+ let r = ab::call("desktop_find_controls", json!({"hwnd": hwnd, "role": "button"}), &[], 10);+ r.get("controls")+ .and_then(|a| a.as_array())+ .map(|a| {+ a.iter()+ .filter_map(|c| c.get("text").or_else(|| c.get("name")).and_then(|t| t.as_str()))+ .map(|t| t.replace('&', ""))+ .collect()+ })+ .unwrap_or_default()+}++/// Every dialog owned by an Altium process — classified, instance-scoped when one is selected.+pub fn scan() -> Vec<Value> {+ scan_scoped(false)+}++/// `main_pid_only` keeps only dialogs owned by the selected/first MAIN instance's process —+/// the ones that freeze the agent (helper X2 dialogs are flagged, not blocking).+pub fn scan_scoped(main_pid_only: bool) -> Vec<Value> {+ scan_impl(main_pid_only, instance::selected().and_then(|i| i.get("pid").and_then(|p| p.as_u64())))+}++fn scan_impl(main_pid_only: bool, sel_pid: Option<u64>) -> Vec<Value> {+ let mut wins = instance::altium_windows();+ if let Some(pid) = sel_pid {+ wins.retain(|w| w.get("pid").and_then(|p| p.as_u64()) == Some(pid)); }- let wins = r.get("windows").and_then(|a| a.as_array()).cloned().unwrap_or_default();+ let main = wins.iter().find(|w| w.get("kind").and_then(|k| k.as_str()) == Some("main")).cloned();+ let main_pid = main.as_ref().and_then(|m| m.get("pid").and_then(|p| p.as_u64()));+ let main_disabled = main+ .as_ref()+ .map(|m| m.get("enabled").and_then(|b| b.as_bool()) == Some(false))+ .unwrap_or(false); let mut out = Vec::new(); for w in wins {- let title = w.get("title").and_then(|s| s.as_str()).unwrap_or("").to_string();- let class = w.get("className").and_then(|s| s.as_str()).unwrap_or("").to_string();+ if w.get("kind").and_then(|k| k.as_str()) == Some("main") {+ continue;+ }+ let class = w.get("className").and_then(|s| s.as_str()).unwrap_or("");+ let kind = w.get("kind").and_then(|k| k.as_str()).unwrap_or("other");+ let pid = w.get("pid").and_then(|p| p.as_u64());+ // Any enabled top-level window while the MAIN window is disabled IS the modal, whatever+ // its class (measured: TForm_RunScriptParameters "Select Item To Run").+ let modal_by_state = main_disabled+ && w.get("enabled").and_then(|b| b.as_bool()).unwrap_or(true)+ && pid == main_pid+ && !matches!(class, "TClientBarControl" | "TThemedNotificationPopup" | "TAnimatedNotificationControl");+ if !DIALOG_CLASSES.iter().any(|c| c.eq_ignore_ascii_case(class))+ && !matches!(kind, "dialog" | "script_error_modal")+ && !modal_by_state+ {+ continue;+ } let hwnd = match w.get("hwnd").and_then(|h| h.as_i64()) { Some(h) => h, None => continue, };- let visible = w.get("visible").and_then(|b| b.as_bool()).unwrap_or(true);- if !visible || title.is_empty() || is_main_window(&title) {- continue;- }- if !DIALOG_CLASSES.iter().any(|c| c.eq_ignore_ascii_case(&class)) {+ let title = w.get("title").and_then(|s| s.as_str()).unwrap_or("").to_string();+ let btns = buttons(hwnd);+ let body = dialog_text(hwnd);+ let (category, safe_action, meaning) = classify(&title, &body);+ let owned_by_main = main_pid.is_some() && pid == main_pid;+ if main_pid_only && !owned_by_main { continue; }- out.push(json!({"hwnd": hwnd, "title": title, "className": class,- "pid": w.get("pid").cloned().unwrap_or(Value::Null),- "text": dialog_text(hwnd), "buttons": buttons(hwnd)}));+ out.push(json!({"hwnd": hwnd, "pid": pid, "title": title, "className": class,+ "body": body, "buttons": btns,+ "category": category, "safeAction": safe_action, "meaning": meaning,+ "ownedByMainInstance": owned_by_main,+ "rect": w.get("rect").cloned().unwrap_or(Value::Null)})); }- Ok(out)-}--/// Windows that merely CONTAIN the app name are the app itself, never a modal.-fn is_main_window(title: &str) -> bool {- crate::health::parse_title(title).is_some()+ out } -/// The message body — this is what tells a save prompt apart from a compile error.-fn dialog_text(hwnd: i64) -> Value {- let r = ab::call("desktop_ui_text", json!({"hwnd": hwnd}), &[], 10);- r.get("text")- .cloned()- .or_else(|| r.get("texts").cloned())- .unwrap_or(Value::Null)-}--fn buttons(hwnd: i64) -> Value {- let r = ab::call("desktop_find_controls", json!({"hwnd": hwnd, "role": "button"}), &[], 10);- r.get("controls")- .and_then(|a| a.as_array())- .map(|a| {- Value::Array(- a.iter()- .filter_map(|c| c.get("text").or_else(|| c.get("name")).cloned())- .collect(),- )- })- .unwrap_or(Value::Null)+/// Press a named button on a dialog through UIA (no focus steal, no cursor).+pub fn press(hwnd: i64, button: &str) -> Value {+ let r = ab::call("desktop_ui_click", json!({"hwnd": hwnd, "name": button, "role": "button"}), &[], 15);+ let ok = r.get("success").and_then(|b| b.as_bool()).unwrap_or(false);+ json!({"success": ok, "clicked": button, "hwnd": hwnd, "via": "uia", "ab": r}) } /// Press a named button on every detected dialog. `only_titles` narrows it (e.g. just "Error").@@ -102,14 +166,126 @@ pub fn dismiss(button: &str, only_titles: Option<&[String]>) -> Value { continue; } }+ if let Some(hwnd) = d.get("hwnd").and_then(|h| h.as_i64()) {+ let r = press(hwnd, button);+ pressed.push(json!({"title": title, "hwnd": hwnd, "button": button,+ "ok": r.get("success").cloned().unwrap_or(json!(false))}));+ }+ }+ json!({"success": true, "detected": found.len(), "pressed": pressed,+ "_hint": "John's rule: a dialog the USER has to dismiss is a defect in this bridge — pre-empt it, don't clean up after it."})+}++// ---- the standing reaper (John, 2026-09-01: "always pre-empt these from ever showing up") ----+//+// Runs continuously from bridge start and presses the catalog's SAFE action the moment a known+// nuisance appears — no verb needs to be in flight (Altium's own startup warnings appear when+// nothing else would ever catch them). HARD RULES: only catalog-safe actions — never `unknown`,+// never a save prompt, never anything naming a document; one press per hwnd; everything logged.++static REAPED: Mutex<Vec<Value>> = Mutex::new(Vec::new());+static REAPED_HWNDS: Mutex<Vec<i64>> = Mutex::new(Vec::new());+static REAPER_RUNNING: AtomicBool = AtomicBool::new(false);++pub fn reaped_log(limit: usize) -> Vec<Value> {+ REAPED+ .lock()+ .map(|g| g.iter().rev().take(limit).rev().cloned().collect())+ .unwrap_or_default()+}++pub fn reap_once() -> usize {+ let mut n = 0;+ // The reaper is a background service: it sweeps EVERY Altium pid, deliberately ignoring the+ // per-verb instance selection (mutating that global from this thread would race a mid-flight+ // verb). Catalog-safe actions carry no decisions, so acting on another thread's nuisance+ // dialog helps rather than harms.+ let dialogs = scan_impl(false, None);+ for d in dialogs {+ let mut act = d.get("safeAction").and_then(|a| a.as_str()).map(str::to_string);+ let mut cat = d.get("category").and_then(|c| c.as_str()).unwrap_or("unknown").to_string();+ // Altium PAINTS its dialog messages: when even UIA text is empty, classification cannot+ // see them. What CAN be trusted is the button set: a dialog whose ONLY button is OK+ // carries NO DECISION — dismissing it can acknowledge, never choose. Every dialog that+ // asks something has >= 2 buttons.+ let labels: Vec<String> = d+ .get("buttons")+ .and_then(|b| b.as_array())+ .map(|a| a.iter().filter_map(|x| x.as_str()).map(|s| s.trim().to_lowercase()).collect())+ .unwrap_or_default();+ let lone_ok = labels == ["ok"];+ if act.is_none() && lone_ok {+ act = Some("OK".into());+ cat = "single_ok_acknowledgement".into();+ }+ let act = match act {+ Some(a) if cat != "unknown" && cat != "save_prompt" => a,+ _ => continue,+ };+ if cat != "single_ok_acknowledgement" && labels.len() > 1 && !labels.contains(&act.to_lowercase()) {+ continue; // the catalog's action is not even on this dialog — do not improvise+ } let hwnd = match d.get("hwnd").and_then(|h| h.as_i64()) { Some(h) => h, None => continue, };- let r = ab::call("desktop_ui_click", json!({"hwnd": hwnd, "name": button, "role": "button"}), &[], 15);- pressed.push(json!({"title": title, "hwnd": hwnd, "button": button,- "ok": r.get("success").and_then(|b| b.as_bool()).unwrap_or(false)}));+ if REAPED_HWNDS.lock().map(|g| g.contains(&hwnd)).unwrap_or(true) {+ continue;+ }+ let ok = press(hwnd, &act).get("success").and_then(|b| b.as_bool()).unwrap_or(false);+ if let Ok(mut g) = REAPED_HWNDS.lock() {+ g.push(hwnd);+ let len = g.len();+ if len > 500 {+ g.drain(..len - 500);+ }+ }+ if let Ok(mut g) = REAPED.lock() {+ g.push(json!({"at": std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|x| x.as_secs()).unwrap_or(0),+ "title": d.get("title").cloned().unwrap_or(Value::Null), "category": cat,+ "pressed": act, "ok": ok,+ "note": "pre-empted by the standing reaper — the user never had to deal with it"}));+ let len = g.len();+ if len > 200 {+ g.drain(..len - 200);+ }+ }+ n += 1;+ }+ n+}++/// Start the standing reaper. Idempotent.+pub fn start_reaper() -> bool {+ if REAPER_RUNNING.swap(true, Ordering::SeqCst) {+ return false;+ }+ std::thread::Builder::new()+ .name("altium-dialog-reaper".into())+ .spawn(|| loop {+ let _ = std::panic::catch_unwind(|| reap_once());+ std::thread::sleep(Duration::from_millis(1500));+ })+ .is_ok()+}++#[cfg(test)]+mod tests {+ use super::classify;++ #[test]+ fn catalog_classification() {+ let (cat, act, _) = classify("Warning", "Could not start Vault Explorer because ...");+ assert_eq!(cat, "altium_extension_broken");+ assert_eq!(act.as_deref(), Some("OK"));+ let (cat, act, _) = classify("Confirm", "Do you want to save changes to Board1.PcbDoc?");+ assert_eq!(cat, "save_prompt");+ assert!(act.is_none(), "a save prompt must never carry a safe action");+ let (cat, act, _) = classify("Information", "License renewal failed. ...");+ assert_eq!(cat, "licence_seat_stranded");+ assert_eq!(act.as_deref(), Some("OK"));+ let (cat, act, _) = classify("Something", "never seen before");+ assert_eq!(cat, "unknown");+ assert!(act.is_none()); }- json!({"success": true, "detected": found.len(), "pressed": pressed,- "_hint": "John's rule: a dialog the USER has to dismiss is a defect in this bridge — pre-empt it, don't clean up after it."}) }
bridge-rs/src/health.rs+6−39@@ -1,5 +1,5 @@ //! /health — Altium detection through ab's window enumeration (no Win32 in the bridge).-use crate::{ab, agent, BRIDGE_VERSION};+use crate::{agent, BRIDGE_VERSION}; use serde_json::{json, Value}; /// Parse "<doc> - Altium Designer[ Develop][ (26.9.1)][ - Trial]" (issue #2: edition without parens).@@ -33,53 +33,20 @@ pub fn parse_title(t: &str) -> Option<Value> { Some(json!({"edition": edition, "adVersion": version, "licenceState": lic, "document": doc})) } -static CACHE: std::sync::Mutex<Option<(std::time::Instant, Value)>> = std::sync::Mutex::new(None);--/// Licence/window state via ab, cached 5 s: an ab round trip costs ~400-800 ms and every verb gates on it.+/// Licence/window state — the instance layer's view (instance-aware, Develop absent-tail rule,+/// snapshot-cached). Kept here as the name every verb historically gates on. pub fn licence_state() -> Value {- if let Ok(g) = CACHE.lock() {- if let Some((t, v)) = g.as_ref() {- if t.elapsed() < std::time::Duration::from_secs(5) { return v.clone(); }- }+ let v = crate::instance::licence_state();+ if v.get("running").and_then(|b| b.as_bool()) == Some(false) && crate::instance::ab_unreachable() {+ return json!({"running": false, "abUnreachable": true}); }- let v = licence_state_uncached();- if let Ok(mut g) = CACHE.lock() { *g = Some((std::time::Instant::now(), v.clone())); } v } -fn licence_state_uncached() -> Value {- let r = ab::call("desktop_find_window", json!({"titleContains": "Altium Designer"}), &[], 8);- let wins: Vec<Value> = r.get("windows").and_then(|m| m.as_array()).cloned()- .or_else(|| r.get("matches").and_then(|m| m.as_array()).cloned())- .or_else(|| r.get("best").filter(|b| b.is_object()).map(|b| vec![b.clone()]))- .unwrap_or_default();- for w in wins {- if let Some(t) = w.get("title").and_then(|t| t.as_str()) {- if let Some(mut p) = parse_title(t) {- let lic = p.get("licenceState").and_then(|l| l.as_str()).map(|s| s.to_string());- let licensed = lic.as_deref().map(|l| l != "No License");- if let Some(o) = p.as_object_mut() {- o.insert("running".into(), json!(true));- o.insert("hwnd".into(), w.get("hwnd").cloned().unwrap_or(Value::Null));- o.insert("licensed".into(), json!(licensed));- o.insert("state".into(), json!(lic));- }- return p;- }- }- }- let ab_down = r.get("errorCode").and_then(|c| c.as_str()) == Some("ab_unreachable");- json!({"running": false, "abUnreachable": ab_down})-}- pub fn is_running() -> bool { licence_state().get("running").and_then(|b| b.as_bool()).unwrap_or(false) } -pub fn main_hwnd() -> Option<i64> {- licence_state().get("hwnd").and_then(|h| h.as_i64())-}- pub fn report() -> Value { let lic = licence_state(); let running = lic.get("running").and_then(|b| b.as_bool()).unwrap_or(false);
bridge-rs/src/instance.rsadded+363@@ -0,0 +1,363 @@+//! More than one Altium can run at once (Designer beside Designer Develop, often driven by two+//! different AI threads). Everything here treats "the Altium" as a CHOICE, not a given — ported+//! from the Python bridge's `altium_detect.py`, where three single-instance assumptions were each+//! a live bug within an hour of running two Altiums (hardcoded exe path, first-main-window licence+//! gate, unscoped dialog sweep).+//!+//! All window/process facts come from ab (`process_list`, `desktop_enum_windows`); the bridge+//! re-implements no Win32.+use crate::ab;+use crate::health::parse_title;+use serde_json::{json, Value};+use std::sync::Mutex;+use std::time::{Duration, Instant};++/// The instance the CURRENT verb acts on. Set once per dispatch (like Python's+/// `agent.use_instance`); background services must not rely on it.+static SELECTED: Mutex<Option<Value>> = Mutex::new(None);++pub fn select(inst: Option<Value>) {+ if let Ok(mut g) = SELECTED.lock() {+ *g = inst;+ }+}++pub fn selected() -> Option<Value> {+ SELECTED.lock().ok().and_then(|g| g.clone())+}++/// Stable per-instance key for the agent workspace folder: the install folder name ("AD26",+/// "ADDevelop") survives a restart, which a pid does not. Same-install twins fall back to pid.+pub fn key_for(exe: Option<&str>, pid: u64) -> String {+ if let Some(e) = exe {+ let norm = e.replace('/', "\\");+ let parts: Vec<&str> = norm.split('\\').filter(|q| !q.is_empty()).collect();+ if parts.len() >= 2 {+ return parts[parts.len() - 2].to_string();+ }+ }+ format!("pid{pid}")+}++/// Executable path out of ab's reported command line (never a constant: Develop and Designer+/// live in different install folders, and a -R helper must target the SELECTED instance's image).+pub fn exe_from_command_line(cl: &str) -> Option<String> {+ let cl = cl.trim();+ if cl.is_empty() {+ return None;+ }+ if let Some(rest) = cl.strip_prefix('"') {+ return rest.split('"').next().map(str::to_string);+ }+ cl.split(' ').next().map(str::to_string)+}++// ---- cached window snapshot --------------------------------------------------+// Two ab round trips cost ~1 s and half a dozen helpers want the same answer inside one verb.++struct Snapshot {+ at: Instant,+ procs: Vec<Value>,+ windows: Vec<Value>,+ ab_unreachable: bool,+}++static SNAP: Mutex<Option<Snapshot>> = Mutex::new(None);+const SNAP_TTL: Duration = Duration::from_secs(2);++/// Was ab reachable on the freshest snapshot? (/health must tell "Altium not running" apart+/// from "cannot even ask".)+pub fn ab_unreachable() -> bool {+ let _ = snapshot();+ SNAP.lock().ok().and_then(|g| g.as_ref().map(|s| s.ab_unreachable)).unwrap_or(false)+}++fn snapshot() -> (Vec<Value>, Vec<Value>) {+ if let Ok(g) = SNAP.lock() {+ if let Some(s) = g.as_ref() {+ if s.at.elapsed() < SNAP_TTL {+ return (s.procs.clone(), s.windows.clone());+ }+ }+ }+ let pl = ab::call("process_list", json!({"nameFilter": "X2"}), &[], 15);+ let ab_down = pl.get("errorCode").and_then(|c| c.as_str()) == Some("ab_unreachable");+ let procs = pl.get("processes").and_then(|a| a.as_array()).cloned().unwrap_or_default();+ let pids: Vec<u64> = procs.iter().filter_map(|p| p.get("pid").and_then(|v| v.as_u64())).collect();+ let windows = if pids.is_empty() {+ Vec::new()+ } else {+ ab::call("desktop_enum_windows", json!({"pids": pids}), &[], 20)+ .get("windows")+ .and_then(|a| a.as_array())+ .cloned()+ .unwrap_or_default()+ };+ if let Ok(mut g) = SNAP.lock() {+ *g = Some(Snapshot { at: Instant::now(), procs: procs.clone(), windows: windows.clone(), ab_unreachable: ab_down });+ }+ (procs, windows)+}++pub fn invalidate_snapshot() {+ if let Ok(mut g) = SNAP.lock() {+ *g = None;+ }+}++/// Normalise ab's rect (object or [l,t,r,b] array) to {left,top,width,height}.+pub fn norm_rect(w: &Value) -> Value {+ match w.get("rect") {+ Some(Value::Array(a)) if a.len() == 4 => {+ let g = |i: usize| a[i].as_i64().unwrap_or(0);+ json!({"left": g(0), "top": g(1), "width": g(2) - g(0), "height": g(3) - g(1)})+ }+ Some(Value::Object(o)) => {+ if o.contains_key("width") {+ Value::Object(o.clone())+ } else {+ let g = |k: &str| o.get(k).and_then(|v| v.as_i64()).unwrap_or(0);+ json!({"left": g("left"), "top": g("top"),+ "width": g("right") - g("left"), "height": g("bottom") - g("top")})+ }+ }+ _ => Value::Null,+ }+}++/// Every visible top-level window owned by an X2.exe, classified like the Python bridge's+/// `altium_windows()`: kind = main | script_error_modal | dialog | other, mains first.+pub fn altium_windows() -> Vec<Value> {+ let (_, wins) = snapshot();+ let mut out: Vec<Value> = Vec::new();+ for w in wins {+ let visible = w.get("visible").and_then(|b| b.as_bool()).unwrap_or(true);+ let title = w.get("title").and_then(|s| s.as_str()).unwrap_or("").to_string();+ let class = w.get("className").and_then(|s| s.as_str()).unwrap_or("").to_string();+ let rect = norm_rect(&w);+ let tiny = rect.get("width").and_then(|v| v.as_i64()).unwrap_or(999) < 20+ && rect.get("height").and_then(|v| v.as_i64()).unwrap_or(999) < 20;+ if !visible || tiny {+ continue;+ }+ let mut row = json!({+ "hwnd": w.get("hwnd").cloned().unwrap_or(Value::Null),+ "pid": w.get("pid").cloned().unwrap_or(Value::Null),+ "title": title, "className": class, "visible": visible,+ "enabled": w.get("enabled").cloned().unwrap_or(json!(true)),+ "rect": rect,+ });+ let kind = if let Some(p) = parse_title(&row["title"].as_str().unwrap_or("").to_string()) {+ for k in ["edition", "adVersion", "document"] {+ row[k] = p.get(k).cloned().unwrap_or(Value::Null);+ }+ row["licenceState"] = p.get("licenceState").cloned().unwrap_or(Value::Null);+ "main"+ } else if row["title"] == json!("X2") {+ "script_error_modal"+ } else if matches!(row["className"].as_str(), Some("TMessageForm") | Some("#32770")) {+ "dialog"+ } else {+ "other"+ };+ row["kind"] = json!(kind);+ out.push(row);+ }+ let order = |k: &str| match k {+ "main" => 0,+ "script_error_modal" => 1,+ "dialog" => 2,+ _ => 3,+ };+ out.sort_by_key(|w| order(w.get("kind").and_then(|k| k.as_str()).unwrap_or("other")));+ out+}++/// Licence read from a main window's parsed title, with the two measured quirks:+/// "Viewer" counts as unlicensed, and Develop with NO tail is LICENSED (Develop only writes a+/// tail — "- Viewer" — when it does not hold the seat; 2026-09-01, ADD imported five parts while+/// the naive read reported it unlicensed).+fn licence_of(win: &Value) -> (Option<bool>, Option<String>) {+ let st = win.get("licenceState").and_then(|s| s.as_str()).map(str::to_string);+ match st {+ Some(s) => {+ let l = s.to_lowercase();+ (Some(l != "no license" && l != "no licence" && l != "viewer"), Some(s))+ }+ None if win.get("edition").and_then(|e| e.as_str()) == Some("Develop") => {+ (Some(true), Some("Licensed (Develop reports no licence tail)".into()))+ }+ None => (None, None),+ }+}++/// Every running Altium, each with the identity a caller needs to choose between them.+pub fn instances() -> Vec<Value> {+ let (procs, _) = snapshot();+ let exe_of = |pid: u64| -> Option<String> {+ procs+ .iter()+ .find(|p| p.get("pid").and_then(|v| v.as_u64()) == Some(pid))+ .and_then(|p| p.get("commandLine").and_then(|c| c.as_str()))+ .and_then(exe_from_command_line)+ };+ let mut out = Vec::new();+ let mut seen = std::collections::BTreeSet::new();+ for w in altium_windows() {+ if w.get("kind").and_then(|k| k.as_str()) != Some("main") {+ continue;+ }+ let pid = match w.get("pid").and_then(|p| p.as_u64()) {+ Some(p) if !seen.contains(&p) => p,+ _ => continue,+ };+ seen.insert(pid);+ let exe = exe_of(pid);+ let (licensed, st) = licence_of(&w);+ out.push(json!({+ "pid": pid,+ "key": key_for(exe.as_deref(), pid),+ "edition": w.get("edition").and_then(|e| e.as_str()).unwrap_or("Designer"),+ "adVersion": w.get("adVersion").cloned().unwrap_or(Value::Null),+ "licenceState": st, "licensed": licensed,+ "exe": exe, "hwnd": w.get("hwnd").cloned().unwrap_or(Value::Null),+ "document": w.get("document").cloned().unwrap_or(Value::Null),+ "title": w.get("title").cloned().unwrap_or(Value::Null),+ }));+ }+ out.sort_by_key(|i| i.get("pid").and_then(|p| p.as_u64()).unwrap_or(0));+ out+}++/// Pick the instance a verb should act on.+/// Ok(inst) — act on it. Err(refusal) — return the refusal verbatim (ambiguity must STOP here,+/// or one thread's verb lands in another thread's Altium; not-running is Err too, with its code).+pub fn resolve(selector: Option<&Value>) -> Result<Value, Value> {+ let found = instances();+ if found.is_empty() {+ return Err(json!({"success": false, "error": "no Altium is running",+ "errorCode": "altium_not_running", "instances": []}));+ }+ let sel = match selector {+ None | Some(Value::Null) => String::new(),+ Some(Value::String(s)) => s.trim().to_lowercase(),+ Some(v) => v.to_string().trim_matches('"').to_lowercase(),+ };+ if sel.is_empty() || sel == "auto" {+ if found.len() == 1 {+ return Ok(found[0].clone());+ }+ return Err(json!({"success": false,+ "error": format!("{} Altium instances are running — say which one", found.len()),+ "errorCode": "ambiguous_instance", "instances": found,+ "_hint": "pass instance: a pid, a key (e.g. 'ADDevelop'), or a word from the edition/title (e.g. 'develop')"}));+ }+ for i in &found {+ let pid = i.get("pid").and_then(|p| p.as_u64()).map(|p| p.to_string()).unwrap_or_default();+ let key = i.get("key").and_then(|k| k.as_str()).unwrap_or("").to_lowercase();+ if sel == pid || sel == key {+ return Ok(i.clone());+ }+ }+ for i in &found {+ let hay = format!(+ "{} {} {}",+ i.get("edition").and_then(|e| e.as_str()).unwrap_or(""),+ i.get("key").and_then(|k| k.as_str()).unwrap_or(""),+ i.get("title").and_then(|t| t.as_str()).unwrap_or("")+ )+ .to_lowercase();+ if hay.contains(&sel) {+ return Ok(i.clone());+ }+ }+ Err(json!({"success": false, "error": format!("no running Altium matches instance={sel:?}"),+ "errorCode": "instance_not_found", "instances": found}))+}++/// The main window of the SELECTED instance, or the only one. Instance-awareness has to reach+/// this far down: `licence_state()` is built on it and every `_need_licensed` gate on that.+pub fn main_window() -> Option<Value> {+ let mains: Vec<Value> = altium_windows()+ .into_iter()+ .filter(|w| w.get("kind").and_then(|k| k.as_str()) == Some("main"))+ .collect();+ if mains.is_empty() {+ return None;+ }+ if let Some(sel) = selected() {+ if let Some(pid) = sel.get("pid").and_then(|p| p.as_u64()) {+ for w in &mains {+ if w.get("pid").and_then(|p| p.as_u64()) == Some(pid) {+ return Some(w.clone());+ }+ }+ }+ }+ Some(mains[0].clone())+}++/// Parsed from the selected instance's main window title (the cheapest truthful source).+pub fn licence_state() -> Value {+ let mw = match main_window() {+ Some(w) => w,+ None => return json!({"running": false, "licensed": null, "state": null}),+ };+ let (licensed, st) = licence_of(&mw);+ json!({"running": true, "licensed": licensed, "state": st,+ "adVersion": mw.get("adVersion").cloned().unwrap_or(Value::Null),+ "edition": mw.get("edition").cloned().unwrap_or(Value::Null),+ "document": mw.get("document").cloned().unwrap_or(Value::Null),+ "hwnd": mw.get("hwnd").cloned().unwrap_or(Value::Null),+ "pid": mw.get("pid").cloned().unwrap_or(Value::Null)})+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn key_from_install_folder_survives_restart() {+ assert_eq!(key_for(Some(r"C:\Program Files\Altium\AD26\X2.EXE"), 1), "AD26");+ assert_eq!(key_for(Some(r"C:\Program Files\Altium\ADDevelop\X2.EXE"), 2), "ADDevelop");+ assert_eq!(key_for(Some("C:/Program Files/Altium/AD26/X2.EXE"), 3), "AD26");+ assert_eq!(key_for(None, 4242), "pid4242");+ }++ #[test]+ fn exe_out_of_quoted_and_bare_command_lines() {+ assert_eq!(+ exe_from_command_line(r#""C:\Program Files\Altium\AD26\X2.EXE" -restart"#).as_deref(),+ Some(r"C:\Program Files\Altium\AD26\X2.EXE")+ );+ assert_eq!(exe_from_command_line(r"C:\X2.EXE -x").as_deref(), Some(r"C:\X2.EXE"));+ assert_eq!(exe_from_command_line(""), None);+ }++ #[test]+ fn develop_absent_tail_means_licensed() {+ let w = json!({"edition": "Develop", "licenceState": null});+ let (lic, st) = licence_of(&w);+ assert_eq!(lic, Some(true));+ assert!(st.unwrap().contains("Develop"));+ // ...but "- Viewer" on Develop means NOT licensed+ let w = json!({"edition": "Develop", "licenceState": "Viewer"});+ assert_eq!(licence_of(&w).0, Some(false));+ // Designer with no tail stays unknown+ let w = json!({"edition": null, "licenceState": null});+ assert_eq!(licence_of(&w).0, None);+ // Viewer / No License are unlicensed; anything else licensed+ assert_eq!(licence_of(&json!({"licenceState": "No License"})).0, Some(false));+ assert_eq!(licence_of(&json!({"licenceState": "Trial"})).0, Some(true));+ }++ #[test]+ fn rect_array_and_object_forms_normalise() {+ let a = json!({"rect": [10, 20, 110, 220]});+ assert_eq!(norm_rect(&a), json!({"left": 10, "top": 20, "width": 100, "height": 200}));+ let o = json!({"rect": {"left": 1, "top": 2, "right": 11, "bottom": 22}});+ assert_eq!(norm_rect(&o), json!({"left": 1, "top": 2, "width": 10, "height": 20}));+ let w = json!({"rect": {"left": 1, "top": 2, "width": 3, "height": 4}});+ assert_eq!(norm_rect(&w)["width"], json!(3));+ }+}
bridge-rs/src/library.rsadded+615@@ -0,0 +1,615 @@+//! Library authoring — thin views over the agent's library commands, plus the two fat lanes:+//! `import_part` (one call imports a whole part: footprint spec, STEP body, symbol modes, models,+//! params, save) and `install_library_bundle` (the no-AI web-control lane: staged zip →+//! Installed Libraries, hardened for untrusted zips).+use crate::support::{self, s, s_or, timeout_of};+use crate::{agent, shot};+use serde_json::{json, Value};+use std::time::Duration;++/// `_lib_call`: gate, optionally open a library first, then one agent command.+fn lib_call(args: &Value, cmd: &str, params: Vec<(&str, String)>, open_path: Option<&str>, tdef: u64) -> Value {+ if let Some(g) = support::need_licensed() {+ return g;+ }+ if let Some(p) = open_path {+ let r = support::agent_open(p, 60);+ if r.get("success").and_then(|b| b.as_bool()) != Some(true) {+ return r;+ }+ }+ agent::call(cmd, ¶ms, None, timeout_of(args, tdef))+}++/// `_sch_lib_verb`: gate, open args.libraryPath if given (best effort), one agent command.+fn sch_lib_verb(args: &Value, cmd: &str, params: Vec<(&str, String)>, tdef: u64) -> Value {+ if let Some(g) = support::need_licensed() {+ return g;+ }+ if let Some(p) = s(args, "libraryPath") {+ support::agent_open(&p, 60);+ }+ agent::call(cmd, ¶ms, None, timeout_of(args, tdef))+}++fn with_shot(mut r: Value) -> Value {+ if r.get("success").and_then(|b| b.as_bool()) == Some(true) {+ r["shot"] = shot::shot_of_main();+ } else {+ r["shot"] = Value::Null;+ }+ r["windowEtiquette"] = json!("background");+ r+}++pub fn create_footprint(args: &Value) -> Value {+ if let Some(g) = support::need_licensed() {+ return g;+ }+ for k in ["name", "libraryPath"] {+ if s(args, k).unwrap_or_default().is_empty() {+ return json!({"success": false, "error": format!("{k} is required"),+ "_hint": "pads: 'des,x_mil,y_mil,w_mil,h_mil,rect|round,hole_mil;...'"});+ }+ }+ let r = agent::call("create_footprint",+ &[("name", s_or(args, "name", "")), ("libraryPath", s_or(args, "libraryPath", "")),+ ("description", s_or(args, "description", "")), ("pads", s_or(args, "pads", ""))],+ None, timeout_of(args, 90));+ let mut r = with_shot(r);+ if r.get("_hint").is_none() {+ r["_hint"] = json!("Footprint registered in the (new or opened) PcbLib and made current; the library is NOT saved yet — altium_save {filePath} or altium_run_command Client:Save... shot.image shows it.");+ }+ r+}++pub fn create_symbol(args: &Value) -> Value {+ if let Some(g) = support::need_licensed() {+ return g;+ }+ for k in ["name", "libraryPath"] {+ if s(args, k).unwrap_or_default().is_empty() {+ return json!({"success": false, "error": format!("{k} is required"),+ "_hint": "pins: 'des,name,left|right|top|bottom,offset_mil;...'"});+ }+ }+ let r = agent::call("create_symbol",+ &[("name", s_or(args, "name", "")), ("libraryPath", s_or(args, "libraryPath", "")),+ ("description", s_or(args, "description", "")), ("designator", s_or(args, "designator", "U")),+ ("body_w_mil", s_or(args, "bodyWidthMil", "800")), ("body_h_mil", s_or(args, "bodyHeightMil", "600")),+ ("pins", s_or(args, "pins", ""))],+ None, timeout_of(args, 90));+ let mut r = with_shot(r);+ if r.get("_hint").is_none() {+ r["_hint"] = json!("Symbol added to the SchLib and made current (library unsaved). shot.image shows it.");+ }+ r+}++pub fn build_footprint(args: &Value) -> Value {+ if let Some(g) = support::need_licensed() {+ return g;+ }+ if s(args, "libraryPath").unwrap_or_default().is_empty() || s(args, "spec").unwrap_or_default().is_empty() {+ return json!({"success": false, "error": "libraryPath (.PcbLib) and spec (Windows path to a kicad2altium spec) are required"});+ }+ with_shot(agent::call("build_footprint",+ &[("libraryPath", s_or(args, "libraryPath", "")), ("spec", s_or(args, "spec", ""))],+ None, timeout_of(args, 240)))+}++pub fn build_symbol_modes(args: &Value) -> Value {+ if let Some(g) = support::need_licensed() {+ return g;+ }+ if s(args, "libraryPath").unwrap_or_default().is_empty() || s(args, "spec").unwrap_or_default().is_empty() {+ return json!({"success": false, "error": "libraryPath (.SchLib) and spec (Windows path to a symbol_modes spec) are required"});+ }+ with_shot(agent::call("build_symbol_modes",+ &[("libraryPath", s_or(args, "libraryPath", "")), ("spec", s_or(args, "spec", ""))],+ None, timeout_of(args, 240)))+}++pub fn add_body(args: &Value) -> Value {+ let lib = match s(args, "libraryPath") {+ Some(l) if !l.is_empty() => l,+ _ => return json!({"success": false, "error": "libraryPath (.PcbLib) is required",+ "_hint": "step:'C:/…/part.step' for a real model, else bwMil/bhMil/hMil/soMil for an extruded box"}),+ };+ let mut r = lib_call(args, "add_body",+ vec![("name", s_or(args, "footprintName", "")), ("step", s_or(args, "step", "")),+ ("bw_mil", s_or(args, "bwMil", "100")), ("bh_mil", s_or(args, "bhMil", "100")),+ ("h_mil", s_or(args, "hMil", "40")), ("so_mil", s_or(args, "soMil", "0"))],+ Some(&lib), 90);+ if r.get("success").and_then(|b| b.as_bool()) == Some(true) {+ // canvas-added bodies land 50000 mil from the pads in the stored frame; realign+ let n = agent::call("normalize_bodies", &[("name", s_or(args, "footprintName", ""))], None, Duration::from_secs(90));+ r["normalized"] = n.get("data").and_then(|d| d.get("shifts")).cloned().unwrap_or(Value::Null);+ r["shot"] = shot::shot_of_main();+ } else {+ r["shot"] = Value::Null;+ }+ r+}++pub fn normalize_bodies(args: &Value) -> Value {+ sch_lib_verb(args, "normalize_bodies", vec![("name", s_or(args, "footprintName", ""))], 120)+}++pub fn set_params(args: &Value) -> Value {+ let lib = match s(args, "libraryPath") {+ Some(l) if !l.is_empty() => l,+ _ => return json!({"success": false, "error": "libraryPath (.SchLib) is required",+ "_hint": "params:'LCSC Part #=C123;Manufacturer=TI' footprint:'SOT-23' comment:'10k'"}),+ };+ let params = match args.get("params") {+ Some(Value::Object(m)) => m.iter()+ .map(|(k, v)| format!("{k}={}", v.as_str().map(str::to_string).unwrap_or(v.to_string())))+ .collect::<Vec<_>>().join(";"),+ Some(Value::String(p)) => p.clone(),+ _ => String::new(),+ };+ let mut r = lib_call(args, "set_params",+ vec![("name", s_or(args, "symbolName", "")), ("params", params),+ ("footprint", s_or(args, "footprint", "")), ("comment", s_or(args, "comment", ""))],+ Some(&lib), 90);+ if r.get("_hint").is_none() {+ r["_hint"] = json!("Parameters set on the symbol (unsaved) — altium_save {filePath: libraryPath}. LCSC Part # / Manufacturer Part Number are the JLC/BOM keys.");+ }+ r+}++pub fn set_visibility(args: &Value) -> Value {+ let vis = if args.get("visible").and_then(|b| b.as_bool()).unwrap_or(true) { "1" } else { "0" };+ sch_lib_verb(args, "set_visibility",+ vec![("name", s_or(args, "symbolName", "")), ("param", s_or(args, "param", "")), ("visible", vis.into())], 90)+}++pub fn read_symbol(args: &Value) -> Value {+ sch_lib_verb(args, "read_symbol", vec![("name", s_or(args, "symbolName", ""))], 90)+}++pub fn clone_symbol(args: &Value) -> Value {+ sch_lib_verb(args, "clone_symbol", vec![("from", s_or(args, "from", "")), ("to", s_or(args, "to", ""))], 90)+}++pub fn rename_symbol(args: &Value) -> Value {+ sch_lib_verb(args, "rename_symbol", vec![("from", s_or(args, "from", "")), ("to", s_or(args, "to", ""))], 90)+}++pub fn rename_footprint(args: &Value) -> Value {+ sch_lib_verb(args, "rename_footprint", vec![("from", s_or(args, "from", "")), ("to", s_or(args, "to", ""))], 90)+}++pub fn remove_symbol(args: &Value) -> Value {+ sch_lib_verb(args, "remove_symbol", vec![("name", s_or(args, "symbolName", ""))], 60)+}++pub fn remove_footprint(args: &Value) -> Value {+ sch_lib_verb(args, "remove_footprint", vec![("name", s_or(args, "footprintName", ""))], 90)+}++pub fn copy_footprint(args: &Value) -> Value {+ sch_lib_verb(args, "copy_footprint", vec![("from", s_or(args, "from", "")), ("to", s_or(args, "to", ""))], 90)+}++pub fn cleanup_libs(args: &Value) -> Value {+ sch_lib_verb(args, "cleanup_libs", vec![], 90)+}++pub fn xcopy_footprint(args: &Value) -> Value {+ if let Some(g) = support::need_licensed() {+ return g;+ }+ if let Some(p) = s(args, "toLib") {+ support::agent_open(&p, 60);+ }+ let to = s(args, "to").or_else(|| s(args, "from")).unwrap_or_default();+ agent::call("xcopy_footprint",+ &[("fromLib", s_or(args, "fromLib", "")), ("from", s_or(args, "from", "")), ("to", to)],+ None, timeout_of(args, 120))+}++pub fn swap_footprint(args: &Value) -> Value {+ if let Some(g) = support::need_licensed() {+ return g;+ }+ if let Some(p) = s(args, "pcbPath") {+ support::agent_open(&p, 60);+ }+ let mut r = agent::call("swap_footprint",+ &[("footprint", s_or(args, "footprintName", "")), ("assigns", s_or(args, "assigns", "")),+ ("libraryPath", s_or(args, "libraryPath", ""))],+ None, timeout_of(args, 240));+ r["windowEtiquette"] = json!("background");+ r+}++/// The editor paints its OWN mode: Comp.DisplayMode changes data only (measured 2026-08-30,+/// frames identical). Drive the editor's process: Mode0, then Next × mode.+pub fn symbol_mode(args: &Value) -> Value {+ if let Some(g) = support::need_licensed() {+ return g;+ }+ if let Some(p) = s(args, "libraryPath") {+ support::agent_open(&p, 60);+ }+ let mode = args.get("mode").and_then(|v| v.as_u64()).unwrap_or(0);+ agent::call("run_process", &[("process", "Sch:ToggleComponentDisplayMode".into()), ("params", "Action=Mode0".into())], None, Duration::from_secs(30));+ for _ in 0..mode {+ agent::call("run_process", &[("process", "Sch:ToggleComponentDisplayMode".into()), ("params", "Action=Next".into())], None, Duration::from_secs(30));+ }+ let r = agent::call("symbol_mode", &[("mode", mode.to_string())], None, timeout_of(args, 60));+ agent::call("run_process", &[("process", "Sch:Zoom".into()), ("params", "Action=All".into())], None, Duration::from_secs(30));+ std::thread::sleep(Duration::from_millis(800));+ with_shot(r)+}++pub fn install_library(args: &Value) -> Value {+ let lib = match s(args, "libraryPath") {+ Some(l) if !l.is_empty() => l,+ _ => return json!({"success": false, "error": "libraryPath is required (.IntLib/.SchLib/.PcbLib/.DbLib)"}),+ };+ let mut r = lib_call(args, "install_library", vec![("path", lib)], None, 90);+ if r.get("_hint").is_none() {+ r["_hint"] = json!("Registered in Altium's Installed Libraries (IntegratedLibraryManager.InstallLibrary).");+ }+ r+}++pub fn compile_intlib(args: &Value) -> Value {+ let p = match s(args, "libpkgPath") {+ Some(l) if !l.is_empty() => l,+ _ => return json!({"success": false, "error": "libpkgPath (.LibPkg) is required; output = destination dir"}),+ };+ lib_call(args, "compile_intlib", vec![("path", p), ("output", s_or(args, "output", ""))], None, 300)+}++pub fn extract_intlib(args: &Value) -> Value {+ let p = match s(args, "intlibPath") {+ Some(l) if !l.is_empty() => l,+ _ => return json!({"success": false, "error": "intlibPath (.IntLib) is required"}),+ };+ let mut r = lib_call(args, "extract_intlib", vec![("path", p)], None, 300);+ if r.get("_hint").is_none() {+ r["_hint"] = json!("sources[] are the editable .SchLib/.PcbLib Altium extracted next to the IntLib (now open in the workspace).");+ }+ r+}++pub fn place_on_sch(args: &Value) -> Value {+ if let Some(g) = support::need_licensed() {+ return g;+ }+ let lib = match s(args, "libraryPath") {+ Some(l) if !l.is_empty() => l,+ _ => return json!({"success": false, "error": "libraryPath (.SchLib) required"}),+ };+ support::agent_open(&lib, 60);+ with_shot(agent::call("place_on_sch",+ &[("sch", s_or(args, "schPath", "")), ("libref", s_or(args, "symbolName", "CH340G"))],+ None, timeout_of(args, 90)))+}++pub fn place_on_pcb(args: &Value) -> Value {+ if let Some(g) = support::need_licensed() {+ return g;+ }+ let lib = match s(args, "libraryPath") {+ Some(l) if !l.is_empty() => l,+ _ => return json!({"success": false, "error": "libraryPath (.PcbLib) required"}),+ };+ support::agent_open(&lib, 60);+ with_shot(agent::call("place_on_pcb",+ &[("pcb", s_or(args, "pcbPath", "")), ("footprint", s_or(args, "footprintName", ""))],+ None, timeout_of(args, 90)))+}++pub fn test_board(args: &Value) -> Value {+ if let Some(g) = support::need_licensed() {+ return g;+ }+ if let Some(p) = s(args, "libraryPath") {+ support::agent_open(&p, 60);+ }+ let mut r = agent::call("test_board",+ &[("footprint", s_or(args, "footprintName", "")), ("wMm", s_or(args, "wMm", "15")),+ ("hMm", s_or(args, "hMm", "15")), ("pcb", s_or(args, "pcbPath", "")),+ ("assigns", s_or(args, "assigns", "")), ("stubs", s_or(args, "stubs", ""))],+ None, timeout_of(args, 240));+ r["windowEtiquette"] = json!("background");+ r+}++/// Sanitise a value for the agent's model spec: models split on ';', fields on '|' — an unescaped+/// separator in a DESCRIPTION silently truncates the spec.+fn spec_safe(v: &str) -> String {+ v.replace(';', ",").replace('|', "/")+}++fn params_spec(params: &Value) -> String {+ match params {+ Value::Object(m) => m.iter()+ .map(|(k, v)| format!("{k}={}", v.as_str().map(str::to_string).unwrap_or(v.to_string()).replace(';', ",")))+ .collect::<Vec<_>>().join(";"),+ _ => String::new(),+ }+}++/// Attach N PCB footprint models (Altium's footprint-variant mechanism) + parameters to a SchLib+/// component. NOT idempotent — each call APPENDS another implementation (known Altium defect);+/// repair = remove_symbol + re-import.+pub fn add_models(args: &Value) -> Value {+ if let Some(g) = support::need_licensed() {+ return g;+ }+ let lib = s(args, "libraryPath").unwrap_or_default();+ let name = s(args, "symbolName").unwrap_or_default();+ if lib.is_empty() || name.is_empty() {+ return json!({"success": false, "error": "libraryPath (.SchLib) and symbolName are required"});+ }+ let models: Vec<Value> = args.get("models").and_then(|m| m.as_array()).cloned().unwrap_or_default();+ let spec = models.iter().map(|m| format!("{}|{}|{}",+ m.get("name").and_then(|v| v.as_str()).unwrap_or(""),+ m.get("pcblib").and_then(|v| v.as_str()).unwrap_or(""),+ spec_safe(m.get("description").and_then(|v| v.as_str()).unwrap_or(""))))+ .collect::<Vec<_>>().join(";");+ let pspec = params_spec(args.get("params").unwrap_or(&Value::Null));+ let mut r = agent::call("add_models",+ &[("libraryPath", lib), ("name", name), ("models", spec), ("params", pspec),+ ("comment", s_or(args, "comment", "")), ("description", s_or(args, "description", ""))],+ None, timeout_of(args, 120));+ if r.get("success").and_then(|b| b.as_bool()) == Some(true) {+ r["shot"] = shot::shot_of_main();+ } else {+ r["shot"] = Value::Null;+ }+ r["_aiHint"] = json!("add_models APPENDS an implementation each call — calling it twice leaves duplicate footprint models on one symbol. Repair = altium_remove_symbol + re-import. read_symbol does not report models (models:null is a reporting gap, not a missing link).");+ r+}++/// Barrett's fat verb, on the fast lane: one call imports a whole part.+/// Args: schLib, pcbLib, symbolSpec (symbol_modes spec path) OR pins, footprintSpec+/// (kicad2altium spec path), step, params{}, comment, description.+pub fn import_part(args: &Value) -> Value {+ if let Some(g) = support::need_licensed() {+ return g;+ }+ let mut steps: Vec<Value> = Vec::new();+ let mut fp_name: Option<String> = None;+ let pcb_lib = s(args, "pcbLib").unwrap_or_default();+ let sch_lib = s(args, "schLib").unwrap_or_default();+ if let Some(spec) = s(args, "footprintSpec").filter(|v| !v.is_empty()) {+ let r = agent::call("build_footprint", &[("libraryPath", pcb_lib.clone()), ("spec", spec)], None, Duration::from_secs(240));+ if r.get("success").and_then(|b| b.as_bool()) != Some(true) {+ let mut out = r;+ out["success"] = json!(false);+ out["failedAt"] = json!("build_footprint");+ return out;+ }+ fp_name = r.get("data").and_then(|d| d.get("footprint")).and_then(|f| f.as_str()).map(str::to_string);+ steps.push(json!({"build_footprint": fp_name}));+ }+ if let (Some(step), Some(fp)) = (s(args, "step").filter(|v| !v.is_empty()), fp_name.clone()) {+ let r = agent::call("add_body",+ &[("libraryPath", pcb_lib.clone()), ("name", fp.clone()), ("step", step),+ ("bw_mil", "100".into()), ("bh_mil", "100".into()), ("h_mil", "40".into()), ("so_mil", "0".into())],+ None, Duration::from_secs(180));+ steps.push(json!({"add_body": r.get("success").and_then(|b| b.as_bool()).unwrap_or(false)}));+ agent::call("normalize_bodies", &[("name", fp)], None, Duration::from_secs(90));+ }+ let mut sym_name = s(args, "symbolName");+ if let Some(spec) = s(args, "symbolSpec").filter(|v| !v.is_empty()) {+ support::agent_open(&sch_lib, 60);+ let r = agent::call("build_symbol_modes", &[("libraryPath", sch_lib.clone()), ("spec", spec)], None, Duration::from_secs(240));+ if r.get("success").and_then(|b| b.as_bool()) != Some(true) {+ let mut out = r;+ out["success"] = json!(false);+ out["failedAt"] = json!("build_symbol_modes");+ out["steps"] = json!(steps);+ return out;+ }+ sym_name = r.get("data").and_then(|d| d.get("libref")).and_then(|f| f.as_str()).map(str::to_string);+ steps.push(json!({"build_symbol_modes": sym_name}));+ }+ if let (Some(sym), Some(fp)) = (sym_name.clone(), fp_name.clone()) {+ let desc_raw = s_or(args, "description", "");+ let desc: String = spec_safe(&desc_raw).chars().take(80).collect();+ let r = agent::call("add_models",+ &[("libraryPath", sch_lib.clone()), ("name", sym.clone()),+ ("models", format!("{fp}|{pcb_lib}|{desc}")),+ ("params", params_spec(args.get("params").unwrap_or(&Value::Null))),+ ("comment", s(args, "comment").unwrap_or_else(|| sym.clone())),+ ("description", desc_raw)],+ None, Duration::from_secs(120));+ let d = r.get("data").cloned().unwrap_or(json!({}));+ let ok = r.get("success").and_then(|b| b.as_bool()) == Some(true);+ steps.push(json!({"add_models": ok, "models": d.get("models"), "params": d.get("params")}));+ let models_n = d.get("models").and_then(|m| m.as_i64()).unwrap_or(0);+ if ok && models_n == 0 {+ // attaching ZERO models is a silent failure: the symbol places with no footprint+ let mut out = r;+ out["success"] = json!(false);+ out["failedAt"] = json!("add_models");+ out["steps"] = json!(steps);+ out["error"] = json!("add_models attached 0 footprint models — the symbol would place with no footprint. Check the model spec separators (';' between models, '|' between name|pcblib|description).");+ return out;+ }+ }+ for lib in [&pcb_lib, &sch_lib] {+ if !lib.is_empty() {+ support::agent_open(lib, 60);+ agent::call("run_process",+ &[("process", "WorkspaceManager:SaveObject".into()), ("params", "ObjectKind=FocusedDocument".into())],+ None, Duration::from_secs(60));+ }+ }+ json!({"success": true, "symbol": sym_name, "footprint": fp_name, "steps": steps,+ "_hint": "One-call part import (Barrett's import_part on the resident lane). Verify with altium_read_symbol + altium_pcb_report."})+}++// ---- install_library_bundle: the no-AI web-control lane ----------------------++fn sanitise_lib_name(name: &str) -> String {+ name.chars().map(|c| if c.is_ascii_alphanumeric() || "_.-".contains(c) { c } else { '_' }).collect()+}++fn valid_lib_name(name: &str) -> bool {+ let mut chars = name.chars();+ match chars.next() {+ Some(c) if c.is_ascii_alphanumeric() => {}+ _ => return false,+ }+ name.len() <= 64 && name.chars().all(|c| c.is_ascii_alphanumeric() || " ._-".contains(c))+}++/// Unpack a staged zip (<Lib>.SchLib + <Lib>.PcbLib [+ .IntLib], regardless of a wrapping+/// folder), install into the user's Installed Libraries, optionally return a proof frame.+/// Hardened for untrusted zips: size caps, zip-slip refusal.+pub fn install_library_bundle(args: &Value) -> Value {+ if let Some(g) = support::need_licensed() {+ return g;+ }+ let zp = match s(args, "zipPath") {+ Some(z) if !z.is_empty() => z,+ _ => return json!({"success": false, "error": "zipPath is required (stage the bundle with write_file / write_file_chunk first)"}),+ };+ let name = s_or(args, "libraryName", "");+ if !name.is_empty() && !valid_lib_name(&name) {+ return json!({"success": false, "error": "libraryName must match ^[A-Za-z0-9][A-Za-z0-9 ._-]{0,63}$"});+ }+ let meta = match std::fs::metadata(&zp) {+ Ok(m) if m.is_file() => m,+ _ => return json!({"success": false, "failedAt": "unpack", "error": format!("staged zip not found: {zp}")}),+ };+ if meta.len() > 268_435_456 {+ return json!({"success": false, "failedAt": "unpack", "error": "zip over 256MB"});+ }+ let public = std::env::var("PUBLIC").unwrap_or_else(|_| r"C:\Users\Public".into());+ let stem = std::path::Path::new(&zp).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();+ let dest = std::path::Path::new(&public).join("AdomAltiumLibs")+ .join(sanitise_lib_name(if name.is_empty() { &stem } else { &name }));+ let _ = std::fs::remove_dir_all(&dest);+ if let Err(e) = std::fs::create_dir_all(&dest) {+ return json!({"success": false, "failedAt": "unpack", "error": format!("cannot create {}: {e}", dest.display())});+ }+ let file = match std::fs::File::open(&zp) {+ Ok(f) => f,+ Err(e) => return json!({"success": false, "failedAt": "unpack", "error": format!("{e}")}),+ };+ let mut z = match zip::ZipArchive::new(file) {+ Ok(z) => z,+ Err(_) => return json!({"success": false, "failedAt": "unpack", "error": "not a valid zip"}),+ };+ let mut total: u64 = 0;+ for i in 0..z.len() {+ let mut entry = match z.by_index(i) {+ Ok(e) => e,+ Err(_) => return json!({"success": false, "failedAt": "unpack", "error": "not a valid zip"}),+ };+ total += entry.size();+ if total > 536_870_912 {+ return json!({"success": false, "failedAt": "unpack", "error": "unpacked over 512MB"});+ }+ let raw = entry.name().replace('\\', "/");+ let parts: Vec<&str> = raw.split('/').collect();+ if raw.starts_with('/') || parts.iter().any(|p| *p == "..") || (raw.len() > 1 && raw.as_bytes()[1] == b':') {+ return json!({"success": false, "failedAt": "unpack", "error": format!("zip slip: {raw}")});+ }+ let target = parts.iter().fold(dest.clone(), |acc, p| acc.join(p));+ if entry.is_dir() {+ let _ = std::fs::create_dir_all(&target);+ continue;+ }+ if let Some(parent) = target.parent() {+ let _ = std::fs::create_dir_all(parent);+ }+ match std::fs::File::create(&target) {+ Ok(mut out) => {+ if std::io::copy(&mut entry, &mut out).is_err() {+ return json!({"success": false, "failedAt": "unpack", "error": format!("cannot write {raw}")});+ }+ }+ Err(e) => return json!({"success": false, "failedAt": "unpack", "error": format!("cannot write {raw}: {e}")}),+ }+ }+ let mut schlibs = Vec::new();+ let mut pcblibs = Vec::new();+ let mut intlibs = Vec::new();+ let mut stack = vec![dest.clone()];+ while let Some(d) = stack.pop() {+ if let Ok(rd) = std::fs::read_dir(&d) {+ for e in rd.flatten() {+ let p = e.path();+ if p.is_dir() {+ stack.push(p);+ continue;+ }+ let lower = p.to_string_lossy().to_lowercase();+ if lower.ends_with(".schlib") { schlibs.push(p); }+ else if lower.ends_with(".pcblib") { pcblibs.push(p); }+ else if lower.ends_with(".intlib") { intlibs.push(p); }+ }+ }+ }+ if schlibs.is_empty() && pcblibs.is_empty() && intlibs.is_empty() {+ return json!({"success": false, "failedAt": "unpack", "error": "bundle holds no .SchLib/.PcbLib/.IntLib"});+ }+ let to_install: Vec<&std::path::PathBuf> = if !intlibs.is_empty() {+ intlibs.iter().collect()+ } else {+ schlibs.iter().chain(pcblibs.iter()).collect()+ };+ let mut installed = Vec::new();+ for lib in to_install {+ let winpath = lib.to_string_lossy().replace('/', "\\");+ let ir = agent::call("install_library", &[("path", winpath)], None, Duration::from_secs(120));+ installed.push(json!({"lib": lib.file_name().map(|n| n.to_string_lossy().to_string()),+ "ok": ir.get("success").and_then(|b| b.as_bool()).unwrap_or(false)}));+ }+ let st = agent::call("state", &[], None, Duration::from_secs(30));+ let running = st.get("windows").map(|w| !w.is_null()).unwrap_or(false)+ || st.get("data").and_then(|d| d.get("windows")).map(|w| !w.is_null()).unwrap_or(false);+ let mut out = json!({"success": true,+ "library": if name.is_empty() { stem } else { name },+ "symbols": schlibs.len(), "footprints": pcblibs.len(), "intlibs": intlibs.len(),+ "installed": installed, "altiumRunning": running, "restartNeeded": running,+ "_hint": "Registered in Installed Libraries. A running Altium predates the install and needs a restart to see the new library — the page/AI tells the user; the verb never closes their Altium."});+ if args.get("capture").and_then(|b| b.as_bool()) == Some(true) {+ if !schlibs.is_empty() && !running {+ agent::call("show_library", &[("path", schlibs[0].to_string_lossy().replace('/', "\\"))], None, Duration::from_secs(90));+ let frame = shot::shot_of_main();+ if let Some(img) = frame.get("image").and_then(|i| i.as_str()) {+ out["thumbnailDataUrl"] = json!(format!("data:image/png;base64,{img}"));+ out["shot"] = frame.clone();+ }+ if args.get("closeEditor").and_then(|b| b.as_bool()) == Some(true) {+ agent::call("run_process", &[("process", "Client:CloseDocument".into()), ("params", "".into())], None, Duration::from_secs(30));+ }+ } else {+ out["proofSkipped"] = json!("Altium was already running; its library tables predate this install.");+ }+ }+ out+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn model_spec_separators_are_sanitised() {+ assert_eq!(spec_safe("a;b|c"), "a,b/c");+ assert_eq!(params_spec(&json!({"MPN": "X;1", "Val": "10k"})), "MPN=X,1;Val=10k");+ }++ #[test]+ fn library_name_validation_matches_python() {+ assert!(valid_lib_name("UTA Longhorn Racing"));+ assert!(valid_lib_name("Lib_1.2-x"));+ assert!(!valid_lib_name(" leading-space"));+ assert!(!valid_lib_name("bad/slash"));+ assert!(!valid_lib_name(""));+ assert_eq!(sanitise_lib_name("a b/c"), "a_b_c");+ }+}
bridge-rs/src/license.rsadded+263@@ -0,0 +1,263 @@+//! Licence lifecycle. The License Management page is a WebView2 whose DOM IS in the a11y tree,+//! so the quiet lane works (John: "you keep turning on the cursor" → UIA FIRST, no cursor, no+//! foreground); the measured pixel table is only the fallback. The seat rules live in+//! `support.rs` — one On-Demand seat serves every Altium app on the machine.+use crate::support::{self, s_or};+use crate::{ab, agent, instance, shot};+use serde_json::{json, Value};+use std::time::Duration;++/// image-space on a 1400x840 safe capture of a maximised main window (measured 2026-08-29)+fn page_coords(action: &str) -> Option<(i64, i64)> {+ Some(match action {+ "refresh" => (1288, 178),+ "use_license" => (1252, 388),+ "start_trial" => (788, 607),+ "sign_out" => (386, 179),+ _ => return None,+ })+}++/// The SAME actions by accessible name / automationId — the quiet lane (measured 2026-09-01).+fn page_name(action: &str) -> &str {+ match action {+ "refresh" => "Refresh",+ "use_license" => "Use License",+ "start_trial" => "Start Trial",+ "sign_out" => "Sign Out",+ other => other,+ }+}++fn page_automation_id(action: &str) -> Option<&'static str> {+ match action {+ "use_license" => Some("UseLicenseButton"),+ _ => None,+ }+}++/// UIA first (quiet), image-space click only if UIA cannot reach the control.+fn click_by_name_then_pixels(hwnd: i64, name: &str, automation_id: Option<&str>, pixel: impl FnOnce() -> Value) -> Value {+ let mut args = json!({"hwnd": hwnd, "name": name, "role": "button"});+ if let Some(id) = automation_id {+ args["automationId"] = json!(id);+ }+ let r = ab::call("desktop_ui_click", args, &[], 30);+ if r.get("success").and_then(|b| b.as_bool()) == Some(true) {+ let mut out = r;+ out["quiet"] = json!(true);+ out["windowEtiquette"] = json!("background");+ out["lane"] = json!("uia");+ return out;+ }+ let mut out = pixel();+ out["lane"] = json!("image_click");+ out["windowEtiquette"] = json!("foreground");+ out["_uiaError"] = r.get("error").or_else(|| r.get("_hint")).cloned().unwrap_or(Value::Null);+ out+}++/// Drive the License Management page. Every call returns a capture so the caller can see the+/// state and click anything else itself.+pub fn license(args: &Value) -> Value {+ let lic = instance::licence_state();+ if lic.get("running").and_then(|b| b.as_bool()) != Some(true) {+ return json!({"success": false, "errorCode": "altium_not_running", "_hint": "altium_launch first."});+ }+ let hwnd = lic.get("hwnd").and_then(|h| h.as_i64()).unwrap_or(0);+ let action = s_or(args, "action", "show");+ if agent::is_alive() {+ agent::call_no_autostart("run_process", &[("process", "Client:LicenseSubscriptionNotification".into()), ("params", "".into())], Duration::from_secs(15));+ // bring the License Management view up via its process launcher (background)+ agent::call_no_autostart("run_process", &[("process", "Client:MyAccount".into()), ("params", "".into())], Duration::from_secs(20));+ }+ std::thread::sleep(Duration::from_secs(1));+ let win = ab::call("desktop_screenshot_window", json!({"hwnd": hwnd}), &[], 20);+ let mut frame = shot::shot_of_main();+ let shot_id = win.get("coordMap").and_then(|c| c.get("shotId")).or_else(|| win.get("shotId")).cloned();+ if frame.is_object() {+ frame["shotId"] = shot_id.clone().unwrap_or(Value::Null);+ frame["coordMap"] = win.get("coordMap").cloned().unwrap_or(Value::Null);+ }+ let mut out = json!({"success": true, "licenceState": instance::licence_state().get("state"),+ "shot": frame, "windowEtiquette": "background"});+ if let Some((x, y)) = page_coords(&action) {+ let sid = shot_id.clone();+ let r = click_by_name_then_pixels(hwnd, page_name(&action), page_automation_id(&action), move || {+ match sid {+ Some(id) if !id.is_null() =>+ ab::call("desktop_click", json!({"space": "image", "shotId": id, "x": x, "y": y, "hwnd": hwnd}), &[], 20),+ _ => json!({"success": false, "error": "no shotId for the pixel fallback"}),+ }+ });+ out["windowEtiquette"] = r.get("windowEtiquette").cloned().unwrap_or(json!("foreground"));+ out["clicked"] = json!({"action": action, "lane": r.get("lane"), "result": r});+ std::thread::sleep(Duration::from_secs(2));+ instance::invalidate_snapshot();+ out["licenceStateAfter"] = instance::licence_state().get("state").cloned().unwrap_or(Value::Null);+ out["shotAfter"] = ab::call("desktop_screenshot_window", json!({"hwnd": hwnd}), &[], 20);+ }+ out["_hint"] = json!("actions: show | refresh | use_license | start_trial | sign_out. Coordinates were measured on a maximised window; verify against shot.image before trusting a click. start_trial opens the browser workspace-creation flow (name, terms, Activate) — see the altium-install skill.");+ out+}++/// Plain-language answer to 'what Altium licence am I on?'.+pub fn license_status(_args: &Value) -> Value {+ let lic = instance::licence_state();+ if lic.get("running").and_then(|b| b.as_bool()) != Some(true) {+ return json!({"success": true, "running": false, "licensed": null,+ "summary": "Altium is not running. altium_launch (or altium_ensure_licensed) first.",+ "_hint": "altium_launch first."});+ }+ let state = lic.get("state").and_then(|st| st.as_str()).unwrap_or("Unknown").to_string();+ let licensed = lic.get("licensed").and_then(|b| b.as_bool());+ let page = license(&json!({"action": "show"}));+ let mut seat_stuck = Value::Null;+ let summary = if licensed == Some(true) {+ format!("Licensed and in use: {state}.")+ } else if matches!(state.to_lowercase().as_str(), "no license" | "no licence") {+ seat_stuck = json!("possible");+ "Signed in but NOT currently using a licence. If the License Management page shows 'no seats available', the single seat is still leased by a prior/crashed session — it frees on its own, or Sign Out + Sign In forces the server to release it, then Use license. altium_ensure_licensed automates the recovery.".to_string()+ } else {+ format!("Licence state: {state}.")+ };+ json!({"success": true, "running": true, "licensed": licensed, "licenceState": state,+ "seatStuck": seat_stuck, "summary": summary, "shot": page.get("shot"),+ "_hint": "Structured state from the window title; read shot.image for the product row (product, Trial/Full, days left, seat n/n, licence code). Trial info lives ONLY on the page."})+}++/// One call to get the user usable: launch if needed, claim a seat, or name the human step.+pub fn ensure_licensed(args: &Value) -> Value {+ let mut steps: Vec<Value> = Vec::new();+ let mut lic = instance::licence_state();+ if lic.get("running").and_then(|b| b.as_bool()) != Some(true) {+ let wait = args.get("launchWaitSec").and_then(|v| v.as_u64()).unwrap_or(240);+ let lr = crate::lifecycle::launch(Duration::from_secs(wait));+ steps.push(json!({"launch": lr.get("success"), "detail": lr.get("error")}));+ instance::invalidate_snapshot();+ lic = instance::licence_state();+ }+ if lic.get("licensed").and_then(|b| b.as_bool()) == Some(true) {+ return json!({"success": true, "alreadyLicensed": true, "licenceState": lic.get("state"),+ "steps": steps, "summary": format!("Already licensed: {:?}.", lic.get("state").and_then(|st| st.as_str()).unwrap_or("?"))});+ }+ // try to use an available seat+ let used = license(&json!({"action": "use_license"}));+ steps.push(json!({"use_license": used.get("licenceStateAfter").or_else(|| used.get("licenceState"))}));+ std::thread::sleep(Duration::from_secs(2));+ instance::invalidate_snapshot();+ let lic = instance::licence_state();+ if lic.get("licensed").and_then(|b| b.as_bool()) == Some(true) {+ return json!({"success": true, "licenceState": lic.get("state"), "steps": steps,+ "summary": format!("Acquired a seat: {:?}.", lic.get("state").and_then(|st| st.as_str()).unwrap_or("?"))});+ }+ // no seat: it is leased elsewhere (crash/other machine)+ if args.get("allowSignInReset").and_then(|b| b.as_bool()) == Some(true) {+ let so = license(&json!({"action": "sign_out"}));+ steps.push(json!({"sign_out": so.get("licenceStateAfter")}));+ return json!({"success": false, "errorCode": "signed_out_for_reset", "steps": steps,+ "summary": "No seat was free, so I signed you out. Sign back in (browser flow) and the server will hand the seat back; then altium_ensure_licensed again.",+ "needsHuman": "browser sign-in", "shot": so.get("shotAfter")});+ }+ if args.get("allowTrial").and_then(|b| b.as_bool()) == Some(true) {+ let tr = license(&json!({"action": "start_trial"}));+ steps.push(json!({"start_trial": "opened the trial/workspace flow in the browser"}));+ return json!({"success": false, "errorCode": "trial_needs_browser", "steps": steps,+ "summary": "Started the free-trial flow — finish it in the browser (name the workspace, accept terms, Activate), then altium_ensure_licensed again.",+ "needsHuman": "browser trial activation", "shot": tr.get("shot")});+ }+ let st = license_status(&json!({}));+ support::seat_hints(+ json!({"success": false, "errorCode": "no_seat_available", "steps": steps,+ "summary": "A licence exists but its single seat is in use (a prior or crashed session still holds it). Retry shortly, or call again with allowSignInReset:true to Sign Out + back in, which forces the server to release it.",+ "seatStuck": true, "shot": st.get("shot")}),+ instance::licence_state().get("state").and_then(|x| x.as_str()),+ instance::instances().len())+}++/// Onboarding: nothing → working trial. Returns the ONE next action; human steps are named,+/// not faked (the elevated installer's EULA click sits behind the UIPI wall).+pub fn setup_trial(_args: &Value) -> Value {+ let inst = support::first_install();+ if inst.is_none() {+ return json!({"success": false, "stage": "install",+ "summary": "Altium Designer is not installed. Use the altium-install skill: it downloads AltiumDesignerSetup, verifies the signature and drives the wizard — but the installer self-elevates, so a non-elevated bridge cannot click past the EULA (UIPI wall); the user clicks Next there.",+ "needsHuman": "installer EULA click", "nextAction": "run the altium-install skill"});+ }+ let lic = instance::licence_state();+ if lic.get("running").and_then(|b| b.as_bool()) != Some(true) {+ return json!({"success": true, "stage": "launch", "installed": true,+ "summary": "Altium is installed but not running. Launching it next.",+ "nextAction": "altium_launch (or altium_ensure_licensed)"});+ }+ let ss = signin_status(&json!({}));+ let signed_in = ss.get("account").map(|v| !v.is_null()).unwrap_or(false)+ || ss.get("user").map(|v| !v.is_null()).unwrap_or(false)+ || ss.get("signedIn").and_then(|b| b.as_bool()).unwrap_or(false);+ if !signed_in && lic.get("licensed").and_then(|b| b.as_bool()) == Some(false) {+ return json!({"success": false, "stage": "signin", "installed": true, "running": true,+ "summary": "Altium is open but no account is signed in. Sign in at the account menu (browser flow) with a free Altium account, then start the trial.",+ "needsHuman": "browser sign-in", "nextAction": "sign in, then altium_setup_trial again"});+ }+ if lic.get("licensed").and_then(|b| b.as_bool()) == Some(true) {+ return json!({"success": true, "stage": "done", "licenceState": lic.get("state"),+ "summary": format!("Already set up and licensed: {:?}.", lic.get("state").and_then(|st| st.as_str()).unwrap_or("?"))});+ }+ // signed in, not licensed -> start the trial+ ensure_licensed(&json!({"allowTrial": true}))+}++/// Account + workspace connection as Altium itself reports it.+pub fn signin_status(_args: &Value) -> Value {+ let lic = instance::licence_state();+ let mut out = json!({"success": true, "running": lic.get("running"),+ "licensed": lic.get("licensed"), "licenceState": lic.get("state")});+ if lic.get("running").and_then(|b| b.as_bool()) != Some(true) {+ out["_hint"] = json!("altium_launch first.");+ return out;+ }+ if agent::is_alive() {+ let st = agent::call_no_autostart("run_process", &[("process", "Client:CurrentUser".into()), ("params", "".into())], Duration::from_secs(15));+ out["currentUserProcess"] = st.get("data").cloned().unwrap_or(Value::Null);+ }+ // A whole-window UIA tree takes >45 s on this box (even scoped), so ask one exact question+ // instead: is there a menu item literally named "Not Connected"?+ let hwnd = lic.get("hwnd").cloned().unwrap_or(Value::Null);+ let r = ab::call("desktop_find_control", json!({"hwnd": hwnd, "name": "Not Connected", "role": "menuitem"}), &[], 25);+ let found = r.get("success").and_then(|b| b.as_bool()).unwrap_or(false)+ && (r.get("best").map(|b| !b.is_null()).unwrap_or(false) || r.get("control").map(|c| !c.is_null()).unwrap_or(false));+ out["workspaceMenu"] = json!(if found { "Not Connected" } else { "(connected: the menu shows the workspace name)" });+ out["workspaceConnected"] = json!(!found);+ out["workspace"] = Value::Null;+ out["_hint"] = json!("Signed-in account is on the License Management page (altium_license {action:'show'}). 'Not Connected' = no Altium 365 workspace attached: altium_connect_workspace opens the picker.");+ out+}++/// Open Altium's workspace picker (the 'Not Connected' menu). Its list is a custom control with+/// no UIA children, so the verb returns a screenshot with coordMap for the caller to click.+pub fn connect_workspace(_args: &Value) -> Value {+ let lic = instance::licence_state();+ if lic.get("running").and_then(|b| b.as_bool()) != Some(true) {+ return json!({"success": false, "errorCode": "altium_not_running"});+ }+ let hwnd = lic.get("hwnd").cloned().unwrap_or(Value::Null);+ ab::call("desktop_ui_expand", json!({"hwnd": hwnd, "name": "Not Connected", "role": "menuitem"}), &[], 25);+ let mut picker: Option<Value> = None;+ for _ in 0..8 {+ std::thread::sleep(Duration::from_millis(400));+ instance::invalidate_snapshot();+ picker = instance::altium_windows().into_iter()+ .find(|w| w.get("className").and_then(|c| c.as_str()) == Some("TWorkspacesForm"));+ if picker.is_some() {+ break;+ }+ }+ let picker = match picker {+ Some(p) => p,+ None => return json!({"success": false, "errorCode": "picker_not_open",+ "_hint": "Altium may already be connected (menu text is the workspace name) — altium_signin_status."}),+ };+ let shot = ab::call("desktop_screenshot_window", json!({"hwnd": picker.get("hwnd")}), &[], 20);+ json!({"success": true, "picker": picker, "shot": shot, "windowEtiquette": "background",+ "_hint": "The picker lists candidate workspaces; click the wanted row with ab desktop_click {space:'image', shotId, x, y}. It is a popup — it closes on focus loss."})+}
bridge-rs/src/lifecycle.rs+121−47@@ -117,19 +117,25 @@ fn resident_generation() -> Option<String> { // ---- ab-backed helpers ------------------------------------------------------- fn x2_exe() -> Option<String> {- let r = ab::call("desktop_list_windows", json!({}), &[], 15);- let _ = &r; // windows do not carry the exe path; fall back to the well-known install roots+ // The SELECTED instance's own image first — Develop and Designer live in different install+ // folders, and the launcher hardcoding AD26 sent a Develop-targeted -R lane to Designer+ // (live bug, 2026-09-01).+ if let Some(exe) = crate::instance::selected().and_then(|i| i.get("exe").and_then(|e| e.as_str()).map(str::to_string)) {+ return Some(exe);+ }+ if let Some(exe) = crate::instance::instances().first().and_then(|i| i.get("exe").and_then(|e| e.as_str()).map(str::to_string)) {+ return Some(exe);+ } for root in ["C:\\Program Files\\Altium\\AD26", "C:\\Program Files\\Altium\\AD25", "C:\\Program Files\\Altium\\AD24"] { let p = format!("{root}\\X2.EXE"); if std::path::Path::new(&p).exists() { return Some(p); } }- // last resort: ask a running instance let pl = ab::call("process_list", json!({"nameFilter": "X2"}), &[], 15); pl.get("processes") .and_then(|a| a.as_array())- .and_then(|a| a.iter().find_map(|p| p.get("path").and_then(|s| s.as_str()).map(str::to_string)))+ .and_then(|a| a.iter().find_map(|p| p.get("commandLine").and_then(|s| s.as_str()).and_then(crate::instance::exe_from_command_line))) } /// Spawn the X2 helper. Process spawning is NOT desktop-shaped, and ab's direct API refuses@@ -279,41 +285,141 @@ fn kill_stray_helpers() -> Vec<u64> { // ---- the -R lane ------------------------------------------------------------- -/// Forward `RunScript` to the running instance. REFUSED while the agent is resident.-/// ONE SHOT: on timeout the helper is killed, the busy notice reaped, and the caller told —-/// never call this in a retry loop.-pub fn run_script_lane(proc_name: &str, timeout: Duration) -> Value {- if agent::is_alive() {+pub fn x2_exe_pub() -> Option<String> {+ x2_exe()+}++pub fn spawn_helper_pub(exe: &str, arg: &str) -> Result<std::process::Child, String> {+ spawn_helper(exe, arg)+}++/// The -R lane against a CALLER-SUPPLIED .PrjScr (altium_run_script). Same one-shot rules.+pub fn run_script_lane_in(proc_name: &str, prjscr: Option<&str>, timeout: Duration) -> Value {+ match prjscr {+ Some(p) => run_script_lane_impl(proc_name, p.to_string(), timeout),+ None => run_script_lane(proc_name, timeout),+ }+}++/// -R RunScriptText: STATEMENTS ONLY (Begin ... End;). Refused while any agent is resident.+pub fn run_script_text_lane(text: &str, timeout: Duration) -> Value {+ if let Some((ws, age)) = agent::any_agent_alive() { return json!({"success": false, "errorCode": "agent_resident",- "error": "the Adom agent loop is resident; a second script would raise Altium's 'Another script executing now' modal",- "_hint": "send the work through the agent (eval / run_command), or agent_stop first"});+ "liveWorkspace": ws.to_string_lossy(), "heartbeatAgeSec": age,+ "error": "a resident agent loop is already running - a second script would raise Altium's 'Another script executing now' modal on the user's screen",+ "_hint": "Use altium_eval (goes through the agent) while the agent is up; altium_agent_stop first if you truly need the text lane."}); } let exe = match x2_exe() { Some(e) => e, None => return json!({"success": false, "errorCode": "host_app_not_installed", "hostApp": "Altium Designer"}), };+ if text.contains('"') {+ return json!({"success": false, "error": "RunScriptText text cannot contain double quotes (the -R arg is double-quoted); use single quotes for strings."});+ }+ let arg = format!("-RScriptingSystem:RunScriptText(Text=\"{text}\")");+ let mut child = match spawn_helper(&exe, &arg) {+ Ok(c) => c,+ Err(e) => return json!({"success": false, "error": format!("could not launch X2 helper: {e}")}),+ };+ let t0 = Instant::now();+ if wait_helper(&mut child, timeout) {+ return json!({"success": true, "helperSec": t0.elapsed().as_secs_f64()});+ }+ let _ = child.kill();+ let _ = child.wait();+ let reaped = reap_busy_notice(Duration::from_secs(4));+ json!({"success": false, "errorCode": "instance_busy", "busyNoticeReaped": reaped,+ "_hint": "altium_check_dialogs, then retry."})+}++/// Forward `RunScript` to the running instance. REFUSED while the agent is resident.+/// ONE SHOT: on timeout the helper is killed, the busy notice reaped, and the caller told —+/// never call this in a retry loop.+pub fn run_script_lane(proc_name: &str, timeout: Duration) -> Value { let prj = match prjscr_path() { Some(p) => p.to_string_lossy().to_string(), None => return json!({"success": false, "errorCode": "template_missing"}), };+ run_script_lane_impl(proc_name, prj, timeout)+}++fn run_script_lane_impl(proc_name: &str, prj: String, timeout: Duration) -> Value {+ // ANY live loop refuses the lane, not just this instance's: Altium raises "Another script+ // executing now" for the second script regardless of which workspace the first one polls+ // (an upgraded bridge leaves the OLD loop heartbeating under the legacy path).+ if let Some((ws, age)) = agent::any_agent_alive() {+ return json!({"success": false, "errorCode": "agent_resident",+ "error": "an Adom agent loop is resident; a second script would raise Altium's 'Another script executing now' modal",+ "liveWorkspace": ws.to_string_lossy(), "heartbeatAgeSec": age,+ "_hint": "send the work through the agent (eval / run_command), or stop the loop polling liveWorkspace first (altium_agent_stop, with instance: if it is another instance's)"});+ }+ let exe = match x2_exe() {+ Some(e) => e,+ None => return json!({"success": false, "errorCode": "host_app_not_installed", "hostApp": "Altium Designer"}),+ }; let arg = format!("-RScriptingSystem:RunScript(ProjectName=\"{prj}\"|ProcName=\"{proc_name}\")"); let mut child = match spawn_helper(&exe, &arg) { Ok(c) => c, Err(e) => return json!({"success": false, "error": format!("could not launch X2 helper: {e}")}), };+ // PRE-EMPT: watch for OUR OWN modals for as long as the helper is in flight and press OK the+ // instant one appears — the user should never have to find it. Anything not ours (a save+ // prompt, a licence question) is left strictly alone.+ let (watcher, cleared) = start_modal_watcher(); let t0 = Instant::now(); if wait_helper(&mut child, timeout) {- return json!({"success": true, "helperSec": t0.elapsed().as_secs_f64(), "arg": arg});+ watcher.store(true, std::sync::atomic::Ordering::Relaxed);+ let mut out = json!({"success": true, "helperSec": t0.elapsed().as_secs_f64(), "arg": arg});+ let c = cleared.lock().map(|g| g.clone()).unwrap_or_default();+ if !c.is_empty() {+ out["modalsPreempted"] = json!(c);+ }+ return out; } let _ = child.kill(); let _ = child.wait();+ watcher.store(true, std::sync::atomic::Ordering::Relaxed); let reaped = reap_busy_notice(Duration::from_secs(4)); json!({"success": false, "errorCode": "instance_busy", "error": "the -R helper never exited — the running instance did not accept the request", "busyNotice": reaped,+ "modalsPreempted": cleared.lock().map(|g| g.clone()).unwrap_or_default(), "_hint": "ONE SHOT: do not retry this lane. agent_recover frees a halted engine first."}) } +/// Modals this bridge CAUSES. Only these are auto-cleared while an operation is in flight;+/// anything else (a save prompt, a licence question) belongs to the user.+const OUR_MODALS: [&str; 4] = ["another script executing", "another instance of",+ "is busy at the moment", "scripting system is not able"];++fn start_modal_watcher() -> (std::sync::Arc<std::sync::atomic::AtomicBool>, std::sync::Arc<std::sync::Mutex<Vec<Value>>>) {+ let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));+ let cleared = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));+ let (s2, c2) = (stop.clone(), cleared.clone());+ let _ = std::thread::Builder::new().name("altium-lane-modal-watcher".into()).spawn(move || {+ while !s2.load(std::sync::atomic::Ordering::Relaxed) {+ for d in crate::dialogs::scan() {+ let blob = format!("{} {}",+ d.get("title").and_then(|t| t.as_str()).unwrap_or(""),+ d.get("body").and_then(|b| b.as_str()).unwrap_or("")).to_lowercase();+ if !OUR_MODALS.iter().any(|k| blob.contains(k)) {+ continue;+ }+ if let Some(hw) = d.get("hwnd").and_then(|h| h.as_i64()) {+ ab::call("desktop_ui_click", json!({"hwnd": hw, "name": "OK", "role": "button"}), &[], 8);+ if let Ok(mut g) = c2.lock() {+ g.push(json!({"title": d.get("title"),+ "body": d.get("body").and_then(|b| b.as_str()).map(|b| b.chars().take(120).collect::<String>()),+ "clearedInFlight": true}));+ }+ }+ }+ std::thread::sleep(Duration::from_millis(400));+ }+ });+ (stop, cleared)+}+ // ---- start / stop / recover -------------------------------------------------- pub fn start(wait: Duration) -> Value {@@ -461,44 +567,12 @@ pub fn launch(wait: Duration) -> Value { "error": format!("no Altium main window after {}s", wait.as_secs())}) } -/// Polite close: WM_CLOSE via ab, report a save prompt instead of answering it.-/// `force` taskkills — which strands the On-Demand licence lease, so it is opt-in only.-pub fn close(force: bool, wait: Duration) -> Value {- if !crate::health::is_running() {- return json!({"success": true, "wasRunning": false});- }- if agent::is_alive() {- stop();- }- let hw = crate::health::main_hwnd();- if let Some(h) = hw {- ab::call("desktop_set_window_state", json!({"hwnd": h, "state": "close", "reason": "close Altium Designer"}), &[], 20);- }- let t0 = Instant::now();- while t0.elapsed() < wait {- if !crate::health::is_running() {- return json!({"success": true, "closedInSec": t0.elapsed().as_secs_f64()});- }- let dl = crate::dialogs::scan();- if !dl.is_empty() {- if force {- ab::call("process_kill", json!({"name": "X2.EXE", "reason": "forced close past a save prompt"}), &[], 20);- return json!({"success": true, "forced": true,- "dialogs": dl,- "_warning": "a forced kill strands the On-Demand licence lease — reclaim it with license {action:'use'}"});- }- return json!({"success": false, "errorCode": "blocked_by_dialog", "dialogs": dl,- "error": "a dialog (probably a save prompt) is blocking the close",- "_hint": "answer it, or close {force:true} to taskkill (strands the licence lease)"});- }- std::thread::sleep(Duration::from_millis(400));- }- json!({"success": false, "errorCode": "close_timeout", "error": format!("still running after {}s", wait.as_secs())})-}+// NOTE: application close lives in misc::close (the seat-guarded verb); the old lifecycle-level+// close was removed when it landed — one close path, one seat rule. // ---- sha256 (no external crate) ---------------------------------------------- -fn sha256_hex(data: &[u8]) -> String {+pub fn sha256_hex(data: &[u8]) -> String { const K: [u32; 64] = [ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
bridge-rs/src/main.rs+20−3@@ -1,18 +1,30 @@ //! altium-bridge (Rust) — spawned by ab with `--port <n>`, binds ADOM_BIND_HOST. //! GET /health|/status -> {status, led, summary, tooltip, bridgeVersion, licence} //! POST /command {command, args} -> verb envelope. Everything desktop-shaped goes to ab.+mod a365; mod ab; mod agent;-mod dialogs; mod board;+mod dialogs; mod health;+mod instance;+mod library;+mod license; mod lifecycle;+mod misc;+mod outputs;+mod progress;+mod review;+mod shot;+mod support;+mod surfaces;+mod taskbar; mod verbs; use std::time::Instant; use tiny_http::{Header, Method, Response, Server}; -pub const BRIDGE_VERSION: &str = "0.2.0-rs";+pub const BRIDGE_VERSION: &str = "0.2.0"; fn json_response(status: u16, body: &serde_json::Value) -> Response<std::io::Cursor<Vec<u8>>> { let data = serde_json::to_vec(body).unwrap_or_else(|_| b"{}".to_vec());@@ -33,6 +45,11 @@ fn main() { let server = Server::http(format!("{host}:{port}")).expect("bind"); let bound = server.server_addr(); println!("[altium] bridge {BRIDGE_VERSION} listening on {bound}");+ // Standing services, started separately so one failing cannot take the other down:+ // the dialog reaper pre-empts Altium's nuisance modals (John: "always pre-empt these from+ // ever showing up"), the idle watcher clears the taskbar overlay after 5 quiet minutes.+ dialogs::start_reaper();+ taskbar::start_idle_watcher(); for mut req in server.incoming_requests() { let t0 = Instant::now(); let path = req.url().to_string();@@ -54,7 +71,7 @@ fn main() { Ok(v) => { let command = v.get("command").and_then(|c| c.as_str()).unwrap_or("").to_string(); let vargs = v.get("args").cloned().unwrap_or_else(|| serde_json::json!({}));- let mut out = verbs::dispatch(&command, &vargs, &forwarded);+ let mut out = verbs::handle(&command, &vargs, &forwarded); if let Some(o) = out.as_object_mut() { o.insert("durationMs".into(), serde_json::json!(t0.elapsed().as_millis() as u64)); o.entry("bridgeVersion").or_insert(serde_json::json!(BRIDGE_VERSION));
bridge-rs/src/misc.rsadded+441@@ -0,0 +1,441 @@+//! Odds and ends: readiness/status, instances, dialog_log, state, diagnostics, the process-+//! launcher catalog (list_commands), launch/close with the seat guard, uninstall.+use crate::support::{self, s, s_or};+use crate::{ab, agent, dialogs, instance, lifecycle, progress, taskbar};+use serde_json::{json, Value};+use std::time::Duration;++pub fn readiness(_args: &Value) -> Value {+ let inst = support::first_install();+ let lic = instance::licence_state();+ let alive = agent::is_alive();+ let running = lic.get("running").and_then(|b| b.as_bool()).unwrap_or(false);+ let licensed = lic.get("licensed").and_then(|b| b.as_bool());+ let ready = inst.is_some() && running && licensed == Some(true) && alive;+ let hint = if inst.is_none() {+ "Not installed — see altium_launch's hint to install.".to_string()+ } else if !running {+ "Altium is installed but not running: altium_launch (cold start ~60s, measured).".to_string()+ } else if licensed == Some(false) {+ format!("Altium is running but the title says {:?} — scripting is refused without a licence. altium_license '{{}}' drives the trial/On-Demand flow.",+ lic.get("state").and_then(|st| st.as_str()).unwrap_or("?"))+ } else if !alive {+ "Licensed and running; the agent loop is not resident — altium_agent_start (≈8s), then drive.".to_string()+ } else {+ "Ready. altium_state for the picture, altium_eval / altium_run_command to act.".to_string()+ };+ json!({"success": true, "hostApp": support::HOST_APP, "installed": inst.is_some(), "install": inst,+ "running": running, "licensed": licensed, "licenceState": lic.get("state"),+ "adVersion": lic.get("adVersion"), "agentAlive": alive, "ready": ready,+ "engine": "rust", "_hint": hint})+}++pub fn status(args: &Value) -> Value {+ let mut r = readiness(args);+ r["windows"] = json!(instance::altium_windows());+ r["dialogs"] = json!(dialogs::scan());+ r["reapedDialogs"] = json!(dialogs::reaped_log(5));+ r+}++pub fn diagnostics(_args: &Value) -> Value {+ let env: serde_json::Map<String, Value> = ["LOCALAPPDATA", "APPDATA", "USERPROFILE", "ProgramFiles", "ADOM_BIND_HOST", "ADOM_DIRECT_API_URL"]+ .iter()+ .map(|k| (k.to_string(), std::env::var(k).map(Value::from).unwrap_or(Value::Null)))+ .collect();+ json!({"success": true, "engine": "rust", "env": env,+ "installs": support::detect_installs(),+ "windows": instance::altium_windows(),+ "agent": agent_status(&json!({})),+ "cacheDir": agent::cache_dir().to_string_lossy()})+}++pub fn agent_status(_args: &Value) -> Value {+ let alive = agent::is_alive();+ let mut out = json!({"success": true, "alive": alive, "responsive": agent::is_responsive(),+ "heartbeat": agent::heartbeat(), "workspace": agent::workspace_dir().to_string_lossy(),+ "engine": "rust"});+ if alive && !agent::is_responsive() {+ out["_hint"] = json!("Loop is alive but busy inside a long Altium process (no heartbeat while a RunProcess runs). Wait; do not start a second agent.");+ }+ out+}++/// Every running Altium, and how to address it. Each instance gets its own agent workspace+/// (`bridges-cache/altium/agent/<key>`), so two resident loops never share an inbox.+pub fn instances_verb(_args: &Value) -> Value {+ let root = agent::cache_dir().join("agent");+ let found: Vec<Value> = instance::instances().into_iter().map(|mut i| {+ let key = i.get("key").and_then(|k| k.as_str()).unwrap_or("").to_string();+ let ws = root.join(&key);+ let alive = std::fs::metadata(ws.join("hb.txt")).and_then(|m| m.modified()).ok()+ .and_then(|t| std::time::SystemTime::now().duration_since(t).ok())+ .map(|d| d < Duration::from_secs(agent::ALIVE_SEC))+ .unwrap_or(false);+ i["agentAlive"] = json!(alive);+ i["agentWorkspace"] = json!(ws.to_string_lossy());+ i+ }).collect();+ let n = found.len();+ let out = json!({"success": true, "count": n, "instances": found,+ "_hint": if n > 1 {+ "Pass instance:<pid|key|word> to any verb to target one. With several running, verbs without it fail with errorCode ambiguous_instance."+ } else {+ "One Altium running — `instance` is optional."+ }});+ support::seat_hints(out, None, n)+}++/// What the standing dialog reaper has pre-empted.+pub fn dialog_log(args: &Value) -> Value {+ let limit = args.get("limit").and_then(|l| l.as_u64()).unwrap_or(25) as usize;+ json!({"success": true, "reaped": dialogs::reaped_log(limit), "running": true,+ "_hint": "Anything NOT listed here that reached the user is either a save prompt (never auto-answered, by design) or a dialog missing from the catalog — report it so it can be added with a safe action."})+}++/// Screenshot ARRAY of every Altium window + logical state from the agent.+pub fn state(args: &Value) -> Value {+ let wins = instance::altium_windows();+ let mut shots: Vec<Value> = Vec::new();+ if args.get("screenshots").and_then(|b| b.as_bool()).unwrap_or(true) {+ for w in &wins {+ let kind = w.get("kind").and_then(|k| k.as_str()).unwrap_or("");+ if !matches!(kind, "main" | "dialog" | "script_error_modal") {+ continue;+ }+ if let Some(hwnd) = w.get("hwnd").and_then(|h| h.as_i64()) {+ let r = ab::call("desktop_screenshot_base64", json!({"hwnd": hwnd}), &[], 15);+ if let Some(img) = r.get("image").or_else(|| r.get("base64")).and_then(|i| i.as_str()) {+ shots.push(json!({"title": w.get("title"), "hwnd": hwnd, "kind": kind,+ "image": img, "width": r.get("width"), "height": r.get("height")}));+ }+ }+ }+ }+ let logical = if agent::is_alive() {+ agent::call_no_autostart("state", &[], Duration::from_secs(15))+ } else {+ Value::Null+ };+ json!({"success": true, "licence": instance::licence_state(), "windows": wins,+ "dialogs": dialogs::scan(), "shots": shots, "logical": logical, "focusEvents": [],+ "agentAlive": agent::is_alive(),+ "drivingBadge": {"active": taskbar::badge_active()},+ "_hint": "shots[] are base64 PNGs of every Altium window (render inline). logical.data.documents lists open docs (agent required)."})+}++pub fn progress_verb(_args: &Value) -> Value {+ json!({"success": true, "active": progress::live(false, ""), "history": progress::history(),+ "verbTimes": progress::verb_report(40)})+}++pub fn uninstall_verb(args: &Value) -> Value {+ let mut removed: Vec<String> = Vec::new();+ if agent::is_alive() {+ lifecycle::stop();+ }+ let d = agent::cache_dir();+ if d.exists() && args.get("dryRun").and_then(|b| b.as_bool()) != Some(true) {+ let _ = std::fs::remove_dir_all(&d);+ removed.push(d.to_string_lossy().to_string());+ }+ json!({"success": true, "removed": removed, "dryRun": args.get("dryRun").and_then(|b| b.as_bool()).unwrap_or(false),+ "note": "Altium Designer itself and the user's designs are never touched."})+}++// ---- launch / close (seat-aware) ---------------------------------------------++pub fn launch(args: &Value) -> Value {+ let inst = match support::first_install() {+ Some(i) => i,+ None => return support::not_installed(),+ };+ let lic = instance::licence_state();+ if lic.get("running").and_then(|b| b.as_bool()) == Some(true) {+ return json!({"success": true, "alreadyRunning": true, "licence": lic,+ "windowEtiquette": "background", "_hint": "Already running — nothing launched."});+ }+ let t0 = support::now();+ progress::begin("cold_start", "cold", "launching Altium Designer", "bridge");+ let exe = inst.get("exe").and_then(|e| e.as_str()).unwrap_or("").to_string();+ if let Err(e) = lifecycle::spawn_helper_pub(&exe, "") {+ progress::end("cold_start", "bridge");+ return json!({"success": false, "error": format!("launch failed: {e}")});+ }+ let wait = args.get("waitSec").and_then(|v| v.as_f64()).unwrap_or(180.0);+ while support::now() - t0 < wait {+ instance::invalidate_snapshot();+ let lic = instance::licence_state();+ if lic.get("running").and_then(|b| b.as_bool()) == Some(true) {+ progress::record("cold_start", support::now() - t0, "cold");+ progress::end("cold_start", "bridge");+ return json!({"success": true, "startedInSec": support::elapsed_round(t0), "licence": lic,+ "windowEtiquette": "foreground",+ "_hint": "Altium's own launch foregrounds itself (the one legitimate foreground). Next: altium_readiness; if unlicensed, altium_license."});+ }+ std::thread::sleep(Duration::from_secs(1));+ }+ progress::end("cold_start", "bridge");+ json!({"success": false, "errorCode": "launch_timeout", "error": format!("no Altium main window after {wait}s")})+}++fn strand_refusal(extra: Value) -> Value {+ let lic = instance::licence_state();+ let mut out = json!({"success": false, "errorCode": "would_strand_licence_seat",+ "licenceState": lic.get("state"),+ "error": "force:true kills Altium and STRANDS its licence seat — no other Altium will be able to claim it afterwards, and the licence page will report it as free while refusing every claim.",+ "_hint": "Try altium_close with no force first: a polite WM_CLOSE releases the seat. If it reports blocked_by_dialog, READ the dialog (an Unsaved Changes prompt names the documents — on a shared machine one may belong to another AI thread) and answer it. Only if you truly must kill: repeat with acceptSeatLoss:true."});+ if let (Some(o), Some(e)) = (out.as_object_mut(), extra.as_object()) {+ for (k, v) in e {+ o.insert(k.clone(), v.clone());+ }+ }+ support::seat_hints(out, lic.get("state").and_then(|st| st.as_str()), instance::instances().len())+}++fn kill_instance_pids() -> Vec<u64> {+ let mut killed = Vec::new();+ for i in instance::instances() {+ if let Some(pid) = i.get("pid").and_then(|p| p.as_u64()) {+ let r = ab::call("process_kill", json!({"pid": pid, "reason": "forced altium_close with acceptSeatLoss"}), &[], 20);+ if r.get("success").and_then(|b| b.as_bool()).unwrap_or(false) {+ killed.push(pid);+ }+ }+ }+ killed+}++pub fn close(args: &Value) -> Value {+ let lic = instance::licence_state();+ if lic.get("running").and_then(|b| b.as_bool()) != Some(true) {+ return json!({"success": true, "wasRunning": false});+ }+ let force = args.get("force").and_then(|b| b.as_bool()).unwrap_or(false);+ let accept = args.get("acceptSeatLoss").and_then(|b| b.as_bool()).unwrap_or(false);+ // The INTENT to force is the risk, not whether a dialog happens to block: force:true KILLS+ // the process, which strands the On-Demand seat. Refuse up front.+ if force && !accept && lic.get("licensed").and_then(|b| b.as_bool()) != Some(false) {+ return strand_refusal(json!({}));+ }+ if agent::is_alive() {+ lifecycle::stop();+ }+ if let Some(h) = lic.get("hwnd").and_then(|h| h.as_i64()) {+ ab::call("desktop_set_window_state", json!({"hwnd": h, "state": "close", "reason": "polite close of Altium Designer (releases the licence seat)"}), &[], 20);+ }+ let t0 = support::now();+ let wait = args.get("waitSec").and_then(|v| v.as_f64()).unwrap_or(20.0);+ while support::now() - t0 < wait {+ instance::invalidate_snapshot();+ if instance::instances().is_empty() {+ return json!({"success": true, "closedInSec": support::elapsed_round(t0)});+ }+ let mut dl = dialogs::scan_scoped(true);+ if !dl.is_empty() {+ if force {+ if !accept {+ return strand_refusal(json!({"dialogs": dl}));+ }+ let killed = kill_instance_pids();+ return json!({"success": true, "forced": true, "killedPids": killed,+ "dismissedDialogs": dl.iter().map(|d| d.get("title").cloned().unwrap_or(Value::Null)).collect::<Vec<_>>(),+ "_seatWarning": format!("The licence seat is now STRANDED. {}", support::SEAT_STRANDED_FIX),+ "_hint": "force:true kills even when a dialog blocks the polite close (e.g. the 'script stopped on a breakpoint' Error that refuses WM_CLOSE)."});+ }+ crate::shot::attach_dialog_shots(&mut dl);+ return json!({"success": false, "errorCode": "close_blocked_by_dialog", "dialogs": dl,+ "_hint": "Altium asked something on close (save?). Read dialogs[] and answer via altium_dismiss_dialogs {hwnd,button}, or pass force:true (with acceptSeatLoss:true — a kill strands the seat)."});+ }+ std::thread::sleep(Duration::from_millis(500));+ }+ if force && accept {+ let killed = kill_instance_pids();+ return json!({"success": true, "forced": true, "killedPids": killed,+ "_seatWarning": format!("The licence seat is now STRANDED. {}", support::SEAT_STRANDED_FIX)});+ }+ json!({"success": false, "errorCode": "close_timeout",+ "_hint": "still running — a modal may be blocking WM_CLOSE (altium_check_dialogs); force:true + acceptSeatLoss:true taskkills and strands the seat"})+}++// ---- the process-launcher catalog (list_commands) ----------------------------+//+// Every menu/toolbar action in Altium is a "process launcher" line in a System/*.rcs table:+// PL PLPCB:Undo Command='PCB:Undo' Caption='&Undo' Shortcut1='Ctrl+Z' ... End+// AD 26.9.1 ships 47 .rcs files -> 3,205 launchers -> 1,109 distinct commands.++static RCS_CACHE: std::sync::Mutex<Option<(std::time::Instant, String, Value)>> = std::sync::Mutex::new(None);++/// Parse `Key='value'` attributes ('' = escaped quote) off a launcher line.+fn parse_attrs(rest: &str) -> Vec<(String, String)> {+ let bytes = rest.as_bytes();+ let mut out = Vec::new();+ let mut i = 0;+ while i < bytes.len() {+ // find identifier start+ while i < bytes.len() && !bytes[i].is_ascii_alphanumeric() && bytes[i] != b'_' {+ i += 1;+ }+ let ks = i;+ while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {+ i += 1;+ }+ if i >= bytes.len() || ks == i {+ break;+ }+ if bytes[i] != b'=' || i + 1 >= bytes.len() || bytes[i + 1] != b'\'' {+ continue;+ }+ let key = rest[ks..i].to_string();+ i += 2; // past ='+ let vs = i;+ loop {+ if i >= bytes.len() {+ return out; // unterminated — drop the tail+ }+ if bytes[i] == b'\'' {+ if i + 1 < bytes.len() && bytes[i + 1] == b'\'' {+ i += 2; // escaped ''+ continue;+ }+ break;+ }+ i += 1;+ }+ out.push((key, rest[vs..i].replace("''", "'")));+ i += 1;+ }+ out+}++fn parse_rcs_dir(system_dir: &str) -> Value {+ if let Ok(g) = RCS_CACHE.lock() {+ if let Some((t, dir, v)) = g.as_ref() {+ if dir == system_dir && t.elapsed() < Duration::from_secs(3600) {+ return v.clone();+ }+ }+ }+ let mut commands: std::collections::BTreeMap<String, Value> = std::collections::BTreeMap::new();+ let mut files = 0;+ let mut launchers = 0;+ if let Ok(rd) = std::fs::read_dir(system_dir) {+ let mut paths: Vec<_> = rd.flatten().map(|e| e.path())+ .filter(|p| p.extension().map(|e| e.to_string_lossy().to_lowercase() == "rcs").unwrap_or(false))+ .collect();+ paths.sort();+ for rcs in paths {+ files += 1;+ let Ok(bytes) = std::fs::read(&rcs) else { continue };+ let text: String = bytes.iter().map(|b| *b as char).collect(); // latin-1+ let fname = rcs.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();+ for line in text.lines() {+ let t = line.trim_start();+ if t.starts_with("//") || !t.starts_with("PL") {+ continue;+ }+ let Some(rest) = t.trim_end().strip_suffix("End") else { continue };+ // PL<word> <id> <attrs>+ let mut it = rest.splitn(3, char::is_whitespace).filter(|q| !q.is_empty());+ let (Some(_pl), Some(_id), Some(attrs_str)) = (it.next(), it.next(), it.next()) else { continue };+ launchers += 1;+ let attrs = parse_attrs(attrs_str);+ let cmd = attrs.iter().find(|(k, _)| k == "Command").map(|(_, v)| v.clone()).unwrap_or_default();+ if cmd.is_empty() {+ continue;+ }+ let server = cmd.split(':').next().unwrap_or("").to_string();+ let e = commands.entry(cmd.clone()).or_insert_with(|| json!({+ "command": cmd, "server": server, "captions": [], "shortcuts": [],+ "params": [], "descriptions": [], "sources": []}));+ let push_unique = |e: &mut Value, list: &str, v: String| {+ if v.is_empty() {+ return;+ }+ let arr = e[list].as_array_mut().unwrap();+ if !arr.iter().any(|x| x.as_str() == Some(v.as_str())) {+ arr.push(json!(v));+ }+ };+ for (k, list) in [("Caption", "captions"), ("Description", "descriptions"), ("Params", "params")] {+ let mut v = attrs.iter().find(|(a, _)| a == k).map(|(_, v)| v.clone()).unwrap_or_default();+ if k == "Caption" {+ v = v.replace('&', "");+ }+ push_unique(e, list, v);+ }+ for k in ["Shortcut1", "Shortcut2"] {+ let v = attrs.iter().find(|(a, _)| a == k).map(|(_, v)| v.clone()).unwrap_or_default();+ push_unique(e, "shortcuts", v);+ }+ push_unique(e, "sources", fname.clone());+ }+ }+ }+ let mut servers: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();+ for c in commands.values() {+ *servers.entry(c.get("server").and_then(|st| st.as_str()).unwrap_or("").to_string()).or_default() += 1;+ }+ let data = json!({"systemDir": system_dir, "files": files, "launchers": launchers,+ "distinctCommands": commands.len(), "servers": servers,+ "commands": commands.into_iter().collect::<serde_json::Map<_, _>>()});+ if let Ok(mut g) = RCS_CACHE.lock() {+ *g = Some((std::time::Instant::now(), system_dir.to_string(), data.clone()));+ }+ data+}++pub fn list_commands(args: &Value) -> Value {+ let inst = match support::first_install() {+ Some(i) => i,+ None => return support::not_installed(),+ };+ let system_dir = inst.get("system_dir").and_then(|d| d.as_str()).unwrap_or("").to_string();+ let data = parse_rcs_dir(&system_dir);+ let q = s_or(args, "query", "").to_lowercase();+ let server = s(args, "server");+ let limit = args.get("limit").and_then(|l| l.as_u64()).unwrap_or(50) as usize;+ let mut hits = Vec::new();+ if let Some(cmds) = data.get("commands").and_then(|c| c.as_object()) {+ for (cmd, e) in cmds {+ if let Some(srv) = &server {+ if e.get("server").and_then(|st| st.as_str()).map(|x| !x.eq_ignore_ascii_case(srv)).unwrap_or(true) {+ continue;+ }+ }+ if !q.is_empty() {+ let hay = format!("{cmd} {} {} {}",+ e["captions"].to_string(), e["descriptions"].to_string(), e["params"].to_string()).to_lowercase();+ if !hay.contains(&q) {+ continue;+ }+ }+ let mut hit = serde_json::Map::new();+ for k in ["command", "server", "captions", "shortcuts", "params", "descriptions"] {+ hit.insert(k.into(), e.get(k).cloned().unwrap_or(Value::Null));+ }+ hits.push(Value::Object(hit));+ if hits.len() >= limit {+ break;+ }+ }+ }+ json!({"success": true, "query": q, "server": server, "count": hits.len(),+ "total": data.get("distinctCommands"), "servers": data.get("servers"), "hits": hits,+ "_hint": "Run one with altium_run_command {command:'PCB:Zoom', params:'Action=All'} (goes through the agent). Params are Name=Value|Name=Value."})+}++#[cfg(test)]+mod tests {+ use super::parse_attrs;++ #[test]+ fn rcs_attr_parser_handles_escapes() {+ let attrs = parse_attrs("Command='PCB:Undo' Caption='&Undo' Shortcut1='Ctrl+Z' Description='It''s undo' Params=''");+ let get = |k: &str| attrs.iter().find(|(a, _)| a == k).map(|(_, v)| v.clone());+ assert_eq!(get("Command").as_deref(), Some("PCB:Undo"));+ assert_eq!(get("Caption").as_deref(), Some("&Undo"));+ assert_eq!(get("Description").as_deref(), Some("It's undo"));+ assert_eq!(get("Params").as_deref(), Some(""));+ }+}
bridge-rs/src/outputs.rsadded+354@@ -0,0 +1,354 @@+//! Outputs and project ops: compile/ERC, OutJobs (gerber/BOM), STEP export via Barrett's+//! AdomBridge extension, the DRC modal dance, and the -R script lanes.+use crate::support::{self, s, s_or, timeout_of};+use crate::{ab, agent, dialogs, instance, lifecycle, shot};+use serde_json::{json, Value};+use std::time::Duration;++fn ensure_project(args: &Value) -> (String, Value) {+ let path = s(args, "projectPath").or_else(|| s(args, "filePath")).unwrap_or_else(support::demo_project);+ let r = agent::call("open", &[("path", path.clone())], None, timeout_of(args, 60));+ (path, r)+}++pub fn compile(args: &Value) -> Value {+ if let Some(g) = support::need_licensed() {+ return g;+ }+ let t0 = support::now();+ let (path, r) = ensure_project(args);+ if r.get("success").and_then(|b| b.as_bool()) != Some(true) {+ return r;+ }+ let c = agent::call("compile",+ &[("objectKind", s_or(args, "objectKind", "Project")), ("limit", s_or(args, "limit", "200"))],+ None, timeout_of(args, 120));+ if c.get("success").and_then(|b| b.as_bool()) != Some(true) {+ return c;+ }+ let data = c.get("data").cloned().unwrap_or(json!({}));+ let msgs = data.get("messages").and_then(|m| m.as_array()).cloned().unwrap_or_default();+ let mut by_class: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();+ for m in &msgs {+ let k = m.get("class").and_then(|c| c.as_str()).unwrap_or("?").to_string();+ *by_class.entry(k).or_default() += 1;+ }+ json!({"success": true, "project": path, "messageCount": data.get("messageCount"),+ "byClass": by_class, "messages": msgs, "elapsedSec": support::elapsed_round(t0),+ "windowEtiquette": "background",+ "_hint": "messages[] is Altium's Messages panel after Compile (ERC + connectivity). Zero messages = clean."})+}++pub fn run_outjob(args: &Value) -> Value {+ if let Some(g) = support::need_licensed() {+ return g;+ }+ let t0 = support::now();+ let outjob = s(args, "outjobPath").or_else(|| s(args, "filePath")).unwrap_or_else(support::demo_outjob_fab);+ let medium = s_or(args, "medium", "Folder Structure");+ let proj = s(args, "projectPath")+ .or_else(|| if outjob.starts_with(support::EXAMPLES_DIR) { Some(support::demo_project()) } else { None });+ if let Some(p) = &proj {+ let r = support::agent_open(p, 60);+ if r.get("success").and_then(|b| b.as_bool()) != Some(true) {+ return r;+ }+ }+ let o = agent::call("outjob",+ &[("path", outjob.clone()), ("medium", medium.clone()), ("objectKind", s_or(args, "objectKind", "OutputBatch"))],+ None, timeout_of(args, 300));+ let mut dl = dialogs::scan_scoped(true);+ shot::attach_dialog_shots(&mut dl);+ // what the OutJob wrote under "Project Outputs for <project>"+ let base = std::path::Path::new(proj.as_deref().unwrap_or(&outjob));+ let stem = base.file_stem().map(|q| q.to_string_lossy().to_string()).unwrap_or_default();+ let pdir = base.parent().map(|p| p.join(format!("Project Outputs for {stem}")));+ let (out_dir, files) = match pdir {+ Some(d) if d.exists() => {+ let mut files = Vec::new();+ let mut stack = vec![d.clone()];+ while let Some(q) = stack.pop() {+ if let Ok(rd) = std::fs::read_dir(&q) {+ for e in rd.flatten() {+ let p = e.path();+ if p.is_dir() {+ stack.push(p);+ } else if let Ok(rel) = p.strip_prefix(&d) {+ files.push(rel.to_string_lossy().to_string());+ }+ }+ }+ }+ files.sort();+ files.truncate(60);+ (Some(d.to_string_lossy().to_string()), files)+ }+ _ => (None, Vec::new()),+ };+ let ok = o.get("success").and_then(|b| b.as_bool()).unwrap_or(false) && dl.is_empty();+ json!({"success": ok, "outjob": outjob, "medium": medium, "agent": o.get("data"),+ "outputDir": out_dir, "files": files, "dialogs": dl,+ "elapsedSec": support::elapsed_round(t0), "windowEtiquette": "background",+ "_hint": "files[] is what the OutJob wrote under 'Project Outputs for <project>'. A dialog means a medium needed a decision (printer/PDF path) — read dialogs[].shot."})+}++pub fn export_gerber(args: &Value) -> Value {+ let mut a = args.clone();+ if a.get("outjobPath").is_none() {+ a["outjobPath"] = json!(support::demo_outjob_fab());+ }+ if a.get("medium").is_none() {+ a["medium"] = json!("Folder Structure");+ }+ let mut r = run_outjob(&a);+ r["surface"] = json!("gerber");+ r+}++pub fn export_bom(args: &Value) -> Value {+ let mut a = args.clone();+ if a.get("outjobPath").is_none() {+ a["outjobPath"] = json!(support::demo_outjob_asm());+ }+ if a.get("medium").is_none() {+ a["medium"] = json!("Folder Structure");+ }+ let mut r = run_outjob(&a);+ r["surface"] = json!("bom");+ r+}++// ---- the AdomBridge .NET extension (Barrett's) — parameterized processes -----++/// SPACE-FREE: Altium's -R parser splits on whitespace.+const EXT_REQ_DIR: &str = r"C:\Users\Public\AdomAltium";++pub const EXT_STATUS_NOTE: &str = "MEASURED 2026-08-29 on AD 26.9.1: the AdomBridge server never loads into the RUNNING instance — `X2 -RAdomBridge:*` helpers wait on Altium's startup mutex ~65-90 s and then host the server themselves, and RunProcess('AdomBridge:Ping') from inside the agent is silently ignored (unknown process). It also coincided with a 'Could not start Vault Explorer' warning at startup, so it was unregistered again. Keep the verbs; they fail loudly until the extension is rebuilt/verified for AD 26.";++fn extension_installed() -> bool {+ let base = std::path::Path::new(r"C:\ProgramData\Altium");+ let Ok(rd) = std::fs::read_dir(base) else { return false };+ for e in rd.flatten() {+ let name = e.file_name().to_string_lossy().to_string();+ if name.starts_with("Altium Designer")+ && e.path().join("Extensions").join("AdomBridge").join("AdomBridge.dll").exists()+ {+ return true;+ }+ }+ false+}++/// Extension directory shipped beside the exe (release packs `extension/` next to it).+fn ext_dir() -> std::path::PathBuf {+ std::env::current_exe()+ .ok()+ .and_then(|p| p.parent().map(|d| d.join("extension")))+ .unwrap_or_else(|| std::path::PathBuf::from("extension"))+}++/// X2.EXE -RAdomBridge:<Process>(Req=<file>) with the parameters in a request file at a+/// space-free path. The extension writes its result file; only the filesystem is proof.+fn ext_dispatch(process: &str, req_lines: &[String], result_file: &str, timeout: Duration) -> Value {+ if !extension_installed() {+ return json!({"success": false, "errorCode": "extension_not_installed",+ "_hint": "altium_install_extension (Altium closed), restart Altium, retry."});+ }+ let exe = match lifecycle::x2_exe_pub() {+ Some(e) => e,+ None => return support::not_installed(),+ };+ let _ = std::fs::create_dir_all(EXT_REQ_DIR);+ let req = format!("{EXT_REQ_DIR}\\{}_req.txt", process.to_lowercase());+ let res = format!("{EXT_REQ_DIR}\\{result_file}");+ let _ = std::fs::remove_file(&res);+ if let Err(e) = std::fs::write(&req, format!("{}\n", req_lines.join("\n"))) {+ return json!({"success": false, "error": format!("cannot write request file: {e}")});+ }+ let mut child = match lifecycle::spawn_helper_pub(&exe, &format!("-RAdomBridge:{process}(Req={req})")) {+ Ok(c) => c,+ Err(e) => return json!({"success": false, "error": format!("could not launch X2 helper: {e}")}),+ };+ let t0 = std::time::Instant::now();+ while t0.elapsed() < timeout {+ if std::path::Path::new(&res).exists() {+ std::thread::sleep(Duration::from_millis(300));+ let body = std::fs::read_to_string(&res).unwrap_or_default();+ return json!({"success": true, "result": body, "elapsedSec": (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0});+ }+ std::thread::sleep(Duration::from_millis(500));+ }+ let _ = child.kill();+ let _ = child.wait();+ json!({"success": false, "errorCode": "extension_timeout",+ "elapsedSec": (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0,+ "_hint": format!("No result file. {EXT_STATUS_NOTE}")})+}++/// Headless STEP with real holes/copper/models — Barrett's AdomBridge:ExportStep.+pub fn export_step(args: &Value) -> Value {+ if let Some(g) = support::need_licensed() {+ return g;+ }+ let pcb = s(args, "filePath").unwrap_or_else(support::demo_pcb);+ let out = s(args, "output").unwrap_or_else(|| {+ let stem = pcb.rsplit_once('.').map(|(a, _)| a.to_string()).unwrap_or_else(|| pcb.clone());+ format!("{stem}.step")+ });+ let lines = vec![+ format!("PcbDoc={pcb}"),+ format!("Out={out}"),+ format!("Holes={}", s_or(args, "holes", "All")),+ format!("PadHoles={}", if args.get("padHoles").and_then(|b| b.as_bool()).unwrap_or(true) { "true" } else { "false" }),+ format!("Copper={}", s_or(args, "copper", "None")),+ format!("Models={}", s_or(args, "models", "Step")),+ format!("Folded={}", if args.get("folded").and_then(|b| b.as_bool()).unwrap_or(false) { "true" } else { "false" }),+ ];+ let mut r = ext_dispatch("ExportStep", &lines, "adombridge_exportstep.txt", timeout_of(args, 300));+ let exists = std::path::Path::new(&out).exists();+ r["output"] = json!(out);+ r["outputExists"] = json!(exists);+ r["outputBytes"] = json!(if exists { std::fs::metadata(&out).map(|m| m.len()).unwrap_or(0) } else { 0 });+ if r.get("success").and_then(|b| b.as_bool()) == Some(true) && !exists {+ r["success"] = json!(false);+ r["errorCode"] = json!("step_not_written");+ }+ if r.get("_hint").is_none() {+ r["_hint"] = json!("Copper=All bakes traces as solids (30x bigger file); Holes=All gives real drilled geometry.");+ }+ r+}++pub fn import_kicad(args: &Value) -> Value {+ let file = match s(args, "filePath") {+ Some(f) if !f.is_empty() => f,+ _ => return json!({"success": false, "error": "filePath (.kicad_sym / .kicad_sch / .kicad_pcb) is required"}),+ };+ let lines = vec![format!("Mode={}", s_or(args, "mode", "Import")), format!("File={file}")];+ let mut r = ext_dispatch("ImportKiCad", &lines, "adombridge_importkicad.txt", timeout_of(args, 300));+ if r.get("_hint").is_none() {+ r["_hint"] = json!("Output lands in 'Imported <stem>' beside the source. Footprints are NOT imported by Altium's KiCad importer — link one with altium_set_params {footprint}.");+ }+ r+}++pub fn install_extension(args: &Value) -> Value {+ let installer = ext_dir().join("Install-AdomBridge.ps1");+ if !installer.exists() {+ return json!({"success": false, "error": format!("installer missing at {}", installer.display())});+ }+ let remove = args.get("remove").and_then(|b| b.as_bool()).unwrap_or(false);+ if !instance::instances().is_empty() && !remove {+ return json!({"success": false, "errorCode": "altium_running",+ "error": "Altium must be CLOSED to install/update the extension (it holds AdomBridge.dll open).",+ "_hint": "altium_close, then altium_install_extension, then altium_launch. Put extension/REMOVE-AdomBridge.cmd on the Desktop first — a failed extension load stops Altium starting."});+ }+ let mut cmd = std::process::Command::new("powershell");+ cmd.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File"])+ .arg(&installer)+ .arg("-Source")+ .arg(ext_dir().join("AdomBridge"));+ if remove {+ cmd.arg("-Remove");+ }+ #[cfg(windows)]+ {+ use std::os::windows::process::CommandExt;+ cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW+ }+ match cmd.output() {+ Ok(p) => {+ let stdout = String::from_utf8_lossy(&p.stdout).to_string();+ let stderr = String::from_utf8_lossy(&p.stderr).to_string();+ let ok = p.status.success() && !stderr.to_lowercase().contains("denied");+ json!({"success": ok, "removed": remove,+ "output": stdout.chars().rev().take(1200).collect::<String>().chars().rev().collect::<String>(),+ "stderr": stderr.chars().rev().take(400).collect::<String>().chars().rev().collect::<String>(),+ "_hint": "Start Altium — extensions load at startup. Then altium_export_step / altium_import_kicad."})+ }+ Err(e) => json!({"success": false, "error": format!("powershell failed to start: {e}")}),+ }+}++pub fn extension_ping(_args: &Value) -> Value {+ let mut r = ext_dispatch("Ping", &["Msg=hello".to_string()], "adombridge_ping.txt", Duration::from_secs(30));+ r["installedOnDisk"] = json!(extension_installed());+ r+}++// ---- DRC: the modal dance ----------------------------------------------------++/// PCB:DesignRuleCheck opens a MODAL — the agent blocks inside RunProcess until it closes.+/// The bridge drives the dialog's 'Run Design Rule Check' via ab UIA in the background, then+/// reads the violations off the board through the agent.+pub fn run_drc(args: &Value) -> Value {+ if let Some(g) = support::need_licensed() {+ return g;+ }+ let t0 = support::now();+ let path = s(args, "filePath").unwrap_or_else(support::demo_pcb);+ let r = support::agent_open(&path, 60);+ if r.get("success").and_then(|b| b.as_bool()) != Some(true) {+ return r;+ }+ // fire the process WITHOUT waiting for the agent (the modal freezes the loop)+ agent::call("run_process", &[("process", "PCB:DesignRuleCheck".into()), ("params", "".into())], None, Duration::from_secs(6));+ let mut dlg: Option<Value> = None;+ for _ in 0..30 {+ instance::invalidate_snapshot();+ let hit = instance::altium_windows().into_iter().find(|w| {+ w.get("kind").and_then(|k| k.as_str()) != Some("main")+ && w.get("title").and_then(|t| t.as_str()).map(|t| t.to_lowercase().contains("rule")).unwrap_or(false)+ });+ if hit.is_some() {+ dlg = hit;+ break;+ }+ std::thread::sleep(Duration::from_millis(500));+ }+ let mut steps = vec![json!({"dialog": dlg.as_ref().and_then(|d| d.get("title")).cloned(),+ "hwnd": dlg.as_ref().and_then(|d| d.get("hwnd")).cloned()})];+ let dlg = match dlg {+ Some(d) => d,+ None => return json!({"success": false, "errorCode": "drc_dialog_not_found", "steps": steps,+ "_hint": "PCB:DesignRuleCheck did not open its dialog — is a PCB the focused document? altium_window_info."}),+ };+ let hwnd = dlg.get("hwnd").and_then(|h| h.as_i64()).unwrap_or(0);+ let click = ab::call("desktop_ui_click", json!({"hwnd": hwnd, "contains": "Run Design Rule Check", "role": "button"}), &[], 30);+ steps.push(json!({"uiClick": "Run Design Rule Check", "result": click}));+ // wait for the agent to come back (the modal closed) and DRC to finish+ let wait = timeout_of(args, 180);+ let t1 = std::time::Instant::now();+ while t1.elapsed() < wait {+ if agent::is_responsive() {+ break;+ }+ std::thread::sleep(Duration::from_millis(500));+ }+ // the DRC report (HTML) opens as a new document and takes focus — put the board back first+ support::agent_open(&path, 60);+ let v = agent::call("violations", &[("limit", s_or(args, "limit", "200"))], None, Duration::from_secs(60));+ let data = v.get("data").cloned().unwrap_or(json!({}));+ steps.push(json!({"violations": {"success": v.get("success"),+ "error": data.get("error").or_else(|| v.get("error")), "ms": v.get("roundtripMs")}}));+ let mut dl = dialogs::scan_scoped(true);+ shot::attach_dialog_shots(&mut dl);+ let ok = v.get("success").and_then(|b| b.as_bool()).unwrap_or(false) && dl.is_empty();+ json!({"success": ok, "board": path, "violationCount": data.get("violationCount"),+ "violations": data.get("violations"), "steps": steps, "dialogs": dl,+ "elapsedSec": support::elapsed_round(t0), "windowEtiquette": "background",+ "_hint": "violations[] are the DRC markers on the board (rule, description, location). A DRC report document may also have opened — altium_state shows it."})+}++/// One-shot: run ProcName in a .PrjScr through the -R lane (agent must NOT be resident).+pub fn run_script(args: &Value) -> Value {+ if let Some(g) = support::need_licensed() {+ return g;+ }+ let prj = match s(args, "prjscr") {+ Some(p) if !p.is_empty() => p,+ _ => return json!({"success": false, "error": "prjscr (absolute path to a .PrjScr on this machine) is required"}),+ };+ let proc = s_or(args, "proc", "Main");+ lifecycle::run_script_lane_in(&proc, Some(&prj), timeout_of(args, 60))+}
bridge-rs/src/progress.rsadded+385@@ -0,0 +1,385 @@+//! The MEASURED-progress contract (wiki issue #32), ported from `progress.py`. John's rule:+//! every slow verb reports measured progress, never "can take a couple of minutes". Estimates come+//! from THIS machine's own history — the store paths and JSON shapes are byte-compatible with the+//! Python bridge's, so a machine's measured history survives the engine swap.+use serde_json::{json, Value};+use std::collections::BTreeMap;+use std::path::PathBuf;+use std::sync::Mutex;+use std::time::{SystemTime, UNIX_EPOCH};++pub const PREFIX: &str = "altium";+const KEEP: usize = 10; // rolling window per key (fusion parity)+const RUNLOG_MAX_BYTES: u64 = 4 * 1024 * 1024;++static LOCK: Mutex<()> = Mutex::new(());++/// Shipped defaults, used ONLY to seed a machine with no history of its own — each a measured+/// figure from this fleet, not a guess.+fn default_seconds(phase: &str) -> Option<f64> {+ Some(match phase {+ "cold_start" => 60.0,+ "warm_start" => 1.0,+ "agent_start" => 8.0,+ "show_symbol" => 12.0,+ "show_footprint" => 20.0,+ "show_3d_chip" => 25.0,+ "show_library" => 12.0,+ "show_schematic" => 15.0,+ "show_2d_board" => 15.0,+ "show_3d_board" => 30.0,+ "show_project" => 10.0,+ "install_plugin" => 6.0,+ "run_outjob" => 60.0,+ "export" => 8.0,+ "demo" => 210.0,+ _ => return None,+ })+}++fn store_dir() -> PathBuf {+ let base = std::env::var("USERPROFILE")+ .or_else(|_| std::env::var("HOME"))+ .unwrap_or_else(|_| ".".into());+ let d = PathBuf::from(base).join(".adom");+ let _ = std::fs::create_dir_all(&d);+ d+}++fn timings_path() -> PathBuf {+ store_dir().join("altium-bridge-timings.json")+}++fn runlog_path() -> PathBuf {+ store_dir().join("altium-verb-times.jsonl")+}++fn load() -> BTreeMap<String, Vec<f64>> {+ std::fs::read_to_string(timings_path())+ .ok()+ .and_then(|s| serde_json::from_str::<BTreeMap<String, Vec<f64>>>(&s).ok())+ .unwrap_or_default()+}++fn save(data: &BTreeMap<String, Vec<f64>>) {+ if let Ok(s) = serde_json::to_string_pretty(data) {+ let _ = std::fs::write(timings_path(), s);+ }+}++fn key(phase: &str, start_kind: &str) -> String {+ format!("{phase}:{}", if start_kind.is_empty() { "warm" } else { start_kind })+}++fn pct(samples: &[f64], q: f64) -> f64 {+ if samples.is_empty() {+ return 0.0;+ }+ let mut s = samples.to_vec();+ s.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));+ let i = ((q * (s.len() as f64 - 1.0)).round() as usize).min(s.len() - 1);+ (s[i] * 100.0).round() / 100.0+}++/// Add one real measurement. This is what makes confidence "measured".+pub fn record(phase: &str, seconds: f64, start_kind: &str) {+ if !(0.0..=7200.0).contains(&seconds) {+ return;+ }+ let _g = LOCK.lock();+ let mut data = load();+ let k = key(phase, start_kind);+ let samples = data.entry(k).or_default();+ samples.push((seconds * 100.0).round() / 100.0);+ let len = samples.len();+ if len > KEEP {+ samples.drain(..len - KEEP);+ }+ save(&data);+}++/// (estimatedSec, confidence) for this phase ON THIS MACHINE.+/// 3+ samples -> p50 "measured"; 1-2 -> blend with the shipped seed, "typical";+/// a bare seed -> "typical"; nothing at all -> (0, "unknown") = indeterminate marquee, never a+/// guessed number wearing a measurement's clothes.+pub fn estimate(phase: &str, start_kind: &str) -> (f64, String) {+ let data = load();+ let samples = data.get(&key(phase, start_kind)).cloned().unwrap_or_default();+ if samples.len() >= 3 {+ return (pct(&samples, 0.5), "measured".into());+ }+ let default = default_seconds(phase);+ if !samples.is_empty() {+ let base = default.unwrap_or(*samples.last().unwrap());+ let avg = samples.iter().sum::<f64>() / samples.len() as f64;+ return ((((avg + base) / 2.0) * 100.0).round() / 100.0, "typical".into());+ }+ if let Some(d) = default {+ return (d, "typical".into());+ }+ (0.0, "unknown".into())+}++/// Everything we know, for describe/status: p50 + p90 + sample counts.+pub fn history() -> Value {+ let mut out = serde_json::Map::new();+ for (k, samples) in load() {+ if samples.is_empty() {+ continue;+ }+ let (phase, kind) = k.split_once(':').unwrap_or((k.as_str(), "warm"));+ let entry = out.entry(phase.to_string()).or_insert_with(|| json!({}));+ entry[kind] = json!({"samples": samples.len(), "p50": pct(&samples, 0.5), "p90": pct(&samples, 0.9)});+ }+ Value::Object(out)+}++fn now_secs() -> f64 {+ SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0)+}++/// Build the progress block in the shape ab consumes verbatim. Formulas adopted from fusion's+/// timings.py (John: copy the method, do not redesign): 0->80 across the estimate, 80->95+/// asymptotic when overdue, the two meeting at elapsed == estimate so percent never walks+/// backwards at the crossover.+pub fn block(phase: &str, step_label: &str, started_at: f64, percent: Option<i64>,+ start_kind: &str, blocked_by: Option<&str>, done: bool) -> Value {+ let elapsed = (now_secs() - started_at).max(0.0);+ let (est0, mut confidence) = estimate(phase, start_kind);+ let mut est = est0;+ let overdue = est0 > 0.0 && elapsed > est0;+ if overdue {+ est = (elapsed * 1.25 * 10.0).round() / 10.0;+ if confidence == "measured" {+ confidence = "typical".into();+ }+ if elapsed > 2.0 * est0 {+ confidence = "unknown".into();+ }+ }+ let mut percent = percent;+ if done {+ percent = Some(100);+ est = (elapsed * 100.0).round() / 100.0;+ } else if percent.is_none() {+ percent = if est0 <= 0.0 {+ None+ } else if !overdue {+ Some((80.0 * elapsed / est0) as i64)+ } else {+ let over = elapsed - est0;+ Some(80 + (15.0 * (over / (over + est0))) as i64)+ };+ }+ let eta = percent.map(|_| ((est - elapsed).max(0.0) * 10.0).round() / 10.0);+ let mut step_label = step_label.to_string();+ if !done && est0 > 0.0 && overdue && confidence == "unknown" {+ step_label = format!("{step_label} (taking far longer than this machine ever has)");+ }+ if let Some(b) = blocked_by {+ step_label = format!("waiting for you: {b}");+ }+ let mut out = json!({+ "phase": format!("{PREFIX}.{phase}"),+ "stepLabel": step_label,+ "percent": percent,+ "elapsedSec": (elapsed * 10.0).round() / 10.0,+ "estimatedSec": (est * 10.0).round() / 10.0,+ "etaSec": eta,+ "confidence": confidence,+ "startKind": start_kind,+ });+ if !done && est0 > 0.0 {+ out["overdue"] = json!(overdue);+ }+ if let Some(b) = blocked_by {+ out["blockedBy"] = json!(b);+ }+ if done {+ out["measured"] = json!({"phase": format!("{PREFIX}.{phase}"), "seconds": (elapsed * 100.0).round() / 100.0, "startKind": start_kind});+ }+ out+}++// ---- live, in-flight progress (in-memory on purpose: a stale frame surviving a restart lies) --++struct Live {+ started_at: f64,+ start_kind: String,+ step_label: String,+ percent: Option<i64>,+ blocked_by: Option<String>,+ seq: u64,+}++static LIVE: Mutex<Vec<((String, String), Live)>> = Mutex::new(Vec::new());+static LIVE_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);++pub fn begin(phase: &str, start_kind: &str, step_label: &str, caller: &str) {+ let seq = LIVE_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;+ if let Ok(mut g) = LIVE.lock() {+ g.retain(|(k, _)| !(k.0 == caller && k.1 == phase));+ g.push(((caller.to_string(), phase.to_string()),+ Live { started_at: now_secs(), start_kind: start_kind.into(),+ step_label: step_label.into(), percent: None, blocked_by: None, seq }));+ }+}++/// Advance the visible sub-step of an in-flight phase — the live streaming contract. Kept as a+/// public API for verbs that report sub-steps; not every verb does yet.+#[allow(dead_code)]+pub fn step(phase: &str, step_label: &str, percent: Option<i64>, blocked_by: Option<&str>, caller: &str) {+ if let Ok(mut g) = LIVE.lock() {+ let hit = g.iter_mut().find(|(k, _)| k.0 == caller && k.1 == phase).map(|(_, v)| v);+ let hit = match hit {+ Some(v) => Some(v),+ None => {+ // fall back to the only in-flight entry for this phase; never guess among several+ let mut it = g.iter_mut().filter(|(k, _)| k.1 == phase);+ match (it.next(), it.next()) {+ (Some((_, v)), None) => Some(v),+ _ => None,+ }+ }+ };+ if let Some(v) = hit {+ v.step_label = step_label.into();+ v.blocked_by = blocked_by.map(str::to_string);+ if let Some(p) = percent {+ // never let a bar go backwards: a bar that retreats reads as broken+ v.percent = Some(p.max(v.percent.unwrap_or(0)));+ }+ }+ }+}++pub fn end(phase: &str, caller: &str) {+ if let Ok(mut g) = LIVE.lock() {+ g.retain(|(k, _)| !(k.0 == caller && k.1 == phase));+ }+}++/// Every phase currently in flight, as full progress blocks (percent/ETA recomputed at read time;+/// a step's explicit percent is a monotonic FLOOR, so the bar always advances).+pub fn live(mine_only: bool, asker: &str) -> Vec<Value> {+ let snapshot: Vec<(String, String, f64, String, String, Option<i64>, Option<String>, u64)> =+ LIVE.lock()+ .map(|g| {+ g.iter()+ .map(|((who, phase), v)| {+ (who.clone(), phase.clone(), v.started_at, v.start_kind.clone(),+ v.step_label.clone(), v.percent, v.blocked_by.clone(), v.seq)+ })+ .collect()+ })+ .unwrap_or_default();+ let mut out = Vec::new();+ for (who, phase, started_at, start_kind, step_label, floor, blocked_by, seq) in snapshot {+ if mine_only && who != asker {+ continue;+ }+ let mut b = block(&phase, &step_label, started_at, None, &start_kind, blocked_by.as_deref(), false);+ if let Some(f) = floor {+ let derived = b.get("percent").and_then(|p| p.as_i64());+ b["percent"] = json!(derived.map(|d| d.max(f)).unwrap_or(f));+ }+ b["seq"] = json!(seq);+ b["caller"] = json!(who);+ out.push(b);+ }+ out+}++// ---- per-verb run times (fusion parity) --------------------------------------++/// Append one verb run to the historical log AND the rolling window. Called at the dispatch+/// chokepoint for EVERY verb. Never fails loudly.+pub fn log_run(verb: &str, seconds: f64, ok: bool) {+ if !(0.0..=7200.0).contains(&seconds) {+ return; // a clock jump is not a sample+ }+ record(&format!("verb:{verb}"), seconds, "warm");+ let p = runlog_path();+ if let Ok(m) = std::fs::metadata(&p) {+ if m.len() > RUNLOG_MAX_BYTES {+ let _ = std::fs::rename(&p, p.with_extension("jsonl.1"));+ }+ }+ let line = json!({"at": now_secs() as i64, "verb": verb, "seconds": (seconds * 100.0).round() / 100.0, "ok": ok, "note": ""});+ use std::io::Write;+ if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&p) {+ let _ = writeln!(f, "{line}");+ }+}++/// Aggregate the historical log: per verb count / p50 / p90 / max / failures, p90 descending —+/// p90, not p50, is the column for hunting slow paths.+pub fn verb_report(limit: usize) -> Value {+ let raw = match std::fs::read_to_string(runlog_path()) {+ Ok(s) => s,+ Err(_) => return json!({}),+ };+ let mut runs: BTreeMap<String, (Vec<f64>, u64)> = BTreeMap::new();+ for line in raw.lines() {+ if let Ok(r) = serde_json::from_str::<Value>(line) {+ let verb = r.get("verb").and_then(|v| v.as_str()).unwrap_or("?").to_string();+ let e = runs.entry(verb).or_default();+ e.0.push(r.get("seconds").and_then(|s| s.as_f64()).unwrap_or(0.0));+ if r.get("ok").and_then(|b| b.as_bool()) != Some(true) {+ e.1 += 1;+ }+ }+ }+ let mut rows: Vec<(String, Value, f64)> = runs+ .into_iter()+ .map(|(verb, (secs, fails))| {+ let p90 = pct(&secs, 0.9);+ let row = json!({"count": secs.len(), "p50": pct(&secs, 0.5), "p90": p90,+ "max": secs.iter().cloned().fold(f64::NAN, f64::max), "failures": fails});+ (verb, row, p90)+ })+ .collect();+ rows.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));+ let mut out = serde_json::Map::new();+ for (verb, row, _) in rows.into_iter().take(limit) {+ out.insert(verb, row);+ }+ Value::Object(out)+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn percentile_and_estimate_ladder() {+ assert_eq!(pct(&[3.0, 1.0, 2.0], 0.5), 2.0);+ assert_eq!(pct(&[], 0.5), 0.0);+ // a phase with a seed but no samples is "typical", never "unknown"+ let (e, c) = estimate("show_symbol_never_recorded_xyz", "warm");+ assert_eq!((e, c.as_str()), (0.0, "unknown"));+ }++ #[test]+ fn block_percent_meets_at_crossover() {+ // with no estimate the bar is indeterminate, never 0%+ let b = block("no_such_phase_zz", "working", now_secs() - 5.0, None, "warm", None, false);+ assert!(b["percent"].is_null());+ // done carries the measured actuals+ let b = block("no_such_phase_zz", "done", now_secs() - 5.0, None, "warm", None, true);+ assert_eq!(b["percent"], json!(100));+ assert!(b["measured"]["seconds"].as_f64().unwrap() >= 4.9);+ }++ #[test]+ fn live_floor_is_monotonic() {+ begin("t_phase", "warm", "starting", "tester");+ step("t_phase", "half way", Some(50), None, "tester");+ step("t_phase", "later", Some(30), None, "tester"); // lower explicit % must not retreat+ let l = live(true, "tester");+ assert_eq!(l.len(), 1);+ assert!(l[0]["percent"].as_i64().unwrap() >= 50);+ end("t_phase", "tester");+ assert!(live(true, "tester").is_empty());+ }+}
bridge-rs/src/review.rsadded+589@@ -0,0 +1,589 @@+//! Design review: 14 checks, each a PURE FUNCTION over data the bridge already reads — adding+//! one costs no new Altium API surface (a wrong identifier in the agent halts the script engine+//! behind a modal on the user's screen). Checks needing geometry Altium has not been asked for+//! (outline polygon, silkscreen overlap, ratsnest) are deliberately absent rather than guessed;+//! `notImplemented` in the reply names them so nobody mistakes silence for a clean board.+use crate::board::natural_cmp;+use crate::support::{self, timeout_of};+use crate::agent;+use serde_json::{json, Value};++const PLACEHOLDER_VALUES: [&str; 12] = ["comment", "value", "*", "?", "n/a", "na", "tbd", "xxx",+ "component", "designator", "part", "unknown"];++/// Adom house rule (John, standing): Mouser is the PREFERRED vendor, and every component carries+/// Mouser + DigiKey + LCSC part numbers.+const VENDOR_PATTERNS: [(&str, &[&str]); 3] = [+ ("Mouser", &["mouser"]),+ ("DigiKey", &["digikey", "digi-key", "digi key"]),+ ("LCSC", &["lcsc", "jlcpcb"]),+];++/// Designator prefixes that legitimately carry no vendor part number.+const NON_PURCHASED_PREFIXES: [&str; 8] = ["TP", "FID", "MH", "H", "LOGO", "NET", "PWR", "GND"];++/// Rules whose absence a fabricator will notice before you do: (kind ordinal, name).+const CRITICAL_RULE_KINDS: [(i64, &str); 4] = [(0, "Clearance"), (2, "Width"), (42, "HoleSize"), (15, "ShortCircuit")];++pub const NOT_IMPLEMENTED: [(&str, &str); 6] = [+ ("pads_near_board_edge", "needs per-PAD geometry; components_near_board_edge measures component origins instead, which is a weaker claim"),+ ("outline_shape_checks", "the outline read carries a BOUNDING BOX, not the polygon — a part inside the box but outside an L-shaped or slotted outline is not caught"),+ ("unrouted_connections", "needs the ratsnest"),+ ("silkscreen_over_pad", "needs per-layer primitive geometry"),+ ("via_antennas", "needs via/track topology"),+ ("missing_decoupling", "needs net-to-part topology"),+];++// ---- data access helpers -----------------------------------------------------++fn components(src: &Value) -> Vec<Value> {+ src.get("components").and_then(|c| c.get("components")).and_then(|a| a.as_array()).cloned().unwrap_or_default()+}++fn str_of<'a>(v: &'a Value, k: &str) -> &'a str {+ v.get(k).and_then(|x| x.as_str()).unwrap_or("")+}++fn finding(check: &str, severity: &str, key: (&str, &str), message: String) -> Value {+ let mut f = json!({"check": check, "severity": severity, "message": message});+ if !key.1.is_empty() {+ f[key.0] = json!(key.1);+ }+ f+}++fn is_purchased(designator: &str) -> bool {+ let prefix: String = designator.to_uppercase().chars().filter(|c| c.is_ascii_alphabetic()).collect();+ !NON_PURCHASED_PREFIXES.contains(&prefix.as_str())+}++fn params_of(comp: &Value) -> Vec<(String, String)> {+ comp.get("params").and_then(|p| p.as_array()).map(|a| {+ a.iter().filter_map(|p| {+ let name = p.get("name").and_then(|n| n.as_str())?.trim().to_lowercase();+ let value = p.get("value").and_then(|v| v.as_str()).unwrap_or("").trim().to_string();+ if name.is_empty() { None } else { Some((name, value)) }+ }).collect()+ }).unwrap_or_default()+}++// ---- the checks --------------------------------------------------------------++/// error — a component still carrying Altium's default text instead of a real value/MPN.+fn chk_placeholder_values(board: &Value, _sheet: Option<&Value>) -> Vec<Value> {+ components(board).iter().filter_map(|c| {+ let com = str_of(c, "comment").trim().to_string();+ if PLACEHOLDER_VALUES.contains(&com.to_lowercase().as_str()) {+ Some(finding("placeholder_values", "error", ("designator", str_of(c, "designator")),+ format!("{} has placeholder Comment {com:?} — it will reach the BOM as-is.", str_of(c, "designator"))))+ } else { None }+ }).collect()+}++/// warning — a net with exactly one pad connects to nothing.+fn chk_single_pin_nets(board: &Value, _sheet: Option<&Value>) -> Vec<Value> {+ board.get("nets").and_then(|n| n.get("nets")).and_then(|a| a.as_array()).map(|nets| {+ nets.iter().filter_map(|n| {+ if n.get("pads").and_then(|p| p.as_i64()) == Some(1) {+ let name = str_of(n, "name");+ Some(finding("single_pin_nets", "warning", ("net", name),+ format!("Net {name:?} has a single pad — it connects to nothing.")))+ } else { None }+ }).collect()+ }).unwrap_or_default()+}++/// info — a mechanical layer that is enabled but carries nothing.+fn chk_unused_mechanical_layers(board: &Value, _sheet: Option<&Value>) -> Vec<Value> {+ let occupied: std::collections::BTreeSet<String> = board.get("occupancy").and_then(|o| o.get("occupancy"))+ .and_then(|a| a.as_array())+ .map(|a| a.iter().filter_map(|l| l.get("layerId").and_then(|i| i.as_str()).map(str::to_string)).collect())+ .unwrap_or_default();+ board.get("layers").and_then(|l| l.get("layers")).and_then(|a| a.as_array()).map(|layers| {+ layers.iter().filter_map(|l| {+ if str_of(l, "kind") != "mechanical" || l.get("displayed").and_then(|d| d.as_bool()) != Some(true) {+ return None;+ }+ if occupied.contains(str_of(l, "id")) {+ return None;+ }+ let name = str_of(l, "name");+ Some(finding("unused_mechanical_layers", "info", ("layer", name),+ format!("Mechanical layer {name:?} is enabled but carries no primitives.")))+ }).collect()+ }).unwrap_or_default()+}++/// info — a placed component whose origin is off the placement grid (5 mil default).+fn chk_off_grid_components(board: &Value, _sheet: Option<&Value>) -> Vec<Value> {+ const GRID_MIL: f64 = 5.0;+ components(board).iter().filter_map(|c| {+ let x = c.get("xMil").and_then(|v| v.as_f64())?;+ let y = c.get("yMil").and_then(|v| v.as_f64())?;+ if (x.round() as i64) % (GRID_MIL as i64) != 0 || (y.round() as i64) % (GRID_MIL as i64) != 0 {+ Some(finding("off_grid_components", "info", ("designator", str_of(c, "designator")),+ format!("{} sits at ({x}, {y}) mil — off the {GRID_MIL} mil grid.", str_of(c, "designator"))))+ } else { None }+ }).collect()+}++/// error — the same designator twice on one sheet/board.+fn chk_duplicate_designators(board: &Value, sheet: Option<&Value>) -> Vec<Value> {+ let mut out = Vec::new();+ let sources: Vec<(&Value, &str)> = sheet.map(|sv| (sv, "sheet")).into_iter()+ .chain(std::iter::once((board, "board")))+ .collect();+ for (src, label) in sources {+ if src.is_null() {+ continue;+ }+ let mut seen: std::collections::BTreeMap<String, u32> = std::collections::BTreeMap::new();+ for c in components(src) {+ let d = str_of(&c, "designator").to_string();+ if !d.is_empty() {+ *seen.entry(d).or_default() += 1;+ }+ }+ for (d, n) in seen {+ if n > 1 {+ out.push(finding("duplicate_designators", "error", ("designator", &d),+ format!("Designator {d} occurs {n} times on the {label}.")));+ }+ }+ }+ out+}++/// The board extent, or None. Only source == "boardOutline" is trustworthy for off-board claims.+fn outline_box(board: &Value) -> Option<(f64, f64, f64, f64)> {+ let o = board.get("outline")?;+ if o.get("ok").and_then(|b| b.as_bool()) != Some(true) || str_of(o, "source") != "boardOutline" {+ return None;+ }+ Some((+ o.get("leftMil")?.as_f64()?,+ o.get("rightMil")?.as_f64()?,+ o.get("bottomMil")?.as_f64()?,+ o.get("topMil")?.as_f64()?,+ ))+}++/// error — a placed component outside the board extent (a BOUNDING BOX test, and it says so).+fn chk_components_outside_board(board: &Value, _sheet: Option<&Value>) -> Vec<Value> {+ let Some((l, r, b, t)) = outline_box(board) else { return Vec::new() };+ components(board).iter().filter_map(|c| {+ let x = c.get("xMil").and_then(|v| v.as_f64())?;+ let y = c.get("yMil").and_then(|v| v.as_f64())?;+ if x < l || x > r || y < b || y > t {+ Some(finding("components_outside_board_outline", "error", ("designator", str_of(c, "designator")),+ format!("{} sits at ({x}, {y}) mil, outside the board bounding box ({l}..{r}, {b}..{t} mil).", str_of(c, "designator"))))+ } else { None }+ }).collect()+}++/// warning — a component ORIGIN within 20 mil of the board edge (origins, not pads — named for+/// what it measures).+fn chk_components_near_board_edge(board: &Value, _sheet: Option<&Value>) -> Vec<Value> {+ const MARGIN_MIL: f64 = 20.0;+ let Some((l, r, b, t)) = outline_box(board) else { return Vec::new() };+ components(board).iter().filter_map(|c| {+ let x = c.get("xMil").and_then(|v| v.as_f64())?;+ let y = c.get("yMil").and_then(|v| v.as_f64())?;+ if x < l || x > r || y < b || y > t {+ return None; // already reported as outside+ }+ let d = (x - l).min(r - x).min(y - b).min(t - y);+ if d < MARGIN_MIL {+ Some(finding("components_near_board_edge", "warning", ("designator", str_of(c, "designator")),+ format!("{} origin is {} mil from the board edge (under the {MARGIN_MIL} mil margin).", str_of(c, "designator"), d as i64)))+ } else { None }+ }).collect()+}++/// warning — a purchased component missing Mouser / DigiKey / LCSC part numbers (Mouser first:+/// it is the preferred vendor). Test points/fiducials/mounting holes are exempt.+fn chk_vendor_part_numbers(_board: &Value, sheet: Option<&Value>) -> Vec<Value> {+ let Some(sheet) = sheet else { return Vec::new() };+ components(sheet).iter().filter_map(|c| {+ let des = str_of(c, "designator");+ if !is_purchased(des) {+ return None;+ }+ let blob = params_of(c).iter().map(|(k, v)| format!("{k} {v}")).collect::<Vec<_>>().join(" ");+ let missing: Vec<&str> = VENDOR_PATTERNS.iter()+ .filter(|(_, keys)| !keys.iter().any(|k| blob.contains(k)))+ .map(|(vendor, _)| *vendor)+ .collect();+ if missing.is_empty() {+ return None;+ }+ let mut f = finding("vendor_part_numbers", "warning", ("designator", des),+ format!("{des} has no {} part number (house rule: Mouser preferred, all three required).", missing.join(" / ")));+ f["missing"] = json!(missing);+ Some(f)+ }).collect()+}++/// info — a purchased component with no datasheet link.+fn chk_missing_datasheets(_board: &Value, sheet: Option<&Value>) -> Vec<Value> {+ let Some(sheet) = sheet else { return Vec::new() };+ components(sheet).iter().filter_map(|c| {+ let des = str_of(c, "designator");+ if !is_purchased(des) {+ return None;+ }+ let has = params_of(c).iter().any(|(k, _)| {+ matches!(k.as_str(), "datasheet" | "helpurl" | "datasheet link" | "datasheeturl") || k.contains("datasheet")+ });+ if has {+ None+ } else {+ Some(finding("missing_datasheets", "info", ("designator", des),+ format!("{des} has no Datasheet parameter.")))+ }+ }).collect()+}++/// error — a schematic component still carrying default text in Comment (caught at the source,+/// where the BOM is actually generated from).+fn chk_schematic_placeholders(_board: &Value, sheet: Option<&Value>) -> Vec<Value> {+ let Some(sheet) = sheet else { return Vec::new() };+ components(sheet).iter().filter_map(|c| {+ let com = str_of(c, "comment").trim().to_string();+ if PLACEHOLDER_VALUES.contains(&com.to_lowercase().as_str()) {+ Some(finding("schematic_placeholder_values", "error", ("designator", str_of(c, "designator")),+ format!("{} on the sheet has placeholder Comment {com:?}.", str_of(c, "designator"))))+ } else { None }+ }).collect()+}++fn rules(board: &Value) -> Vec<Value> {+ board.get("rules").and_then(|r| r.get("rules")).and_then(|a| a.as_array()).cloned().unwrap_or_default()+}++/// warning/error — a design rule that exists but is switched OFF. A disabled Clearance or+/// ShortCircuit rule is worse than a missing one: the board LOOKS ruled and DRC passes.+fn chk_disabled_design_rules(board: &Value, _sheet: Option<&Value>) -> Vec<Value> {+ rules(board).iter().filter_map(|r| {+ if r.get("enabled").and_then(|e| e.as_bool()) != Some(false) {+ return None;+ }+ let kind = r.get("kind").and_then(|k| k.as_i64()).unwrap_or(-1);+ let kind_name = crate::board::rule_kind_name(kind);+ let sev = if CRITICAL_RULE_KINDS.iter().any(|(k, _)| *k == kind) { "error" } else { "warning" };+ let name = str_of(r, "name");+ Some(finding("disabled_design_rules", sev, ("rule", name),+ format!("Design rule {name:?} ({kind_name}) is DISABLED — DRC will pass without ever applying it.")))+ }).collect()+}++/// error — no ENABLED rule of a kind a fabricator relies on.+fn chk_missing_critical_rules(board: &Value, _sheet: Option<&Value>) -> Vec<Value> {+ let all = rules(board);+ if all.is_empty() {+ return Vec::new(); // no rule data at all is not the same as no rules+ }+ let have: std::collections::BTreeSet<i64> = all.iter()+ .filter(|r| r.get("enabled").and_then(|e| e.as_bool()) != Some(false))+ .filter_map(|r| r.get("kind").and_then(|k| k.as_i64()))+ .collect();+ CRITICAL_RULE_KINDS.iter().filter_map(|(kind, name)| {+ if have.contains(kind) {+ None+ } else {+ Some(finding("missing_critical_rules", "error", ("rule", name),+ format!("No enabled {name} rule on this board.")))+ }+ }).collect()+}++/// info — two enabled rules of the same kind sharing a priority (Altium resolves ties by+/// internal order, which is not readable off the board).+fn chk_duplicate_rule_priority(board: &Value, _sheet: Option<&Value>) -> Vec<Value> {+ let mut seen: std::collections::BTreeMap<(String, String), Vec<String>> = std::collections::BTreeMap::new();+ for r in rules(board) {+ if r.get("enabled").and_then(|e| e.as_bool()) == Some(false) {+ continue;+ }+ let key = (r.get("kind").map(|k| k.to_string()).unwrap_or_default(),+ r.get("priority").map(|p| p.to_string()).unwrap_or_default());+ seen.entry(key).or_default().push(str_of(&r, "name").to_string());+ }+ seen.into_iter().filter_map(|((kind, prio), mut names)| {+ if names.len() < 2 {+ return None;+ }+ names.sort();+ let kind_name = kind.parse::<i64>().map(crate::board::rule_kind_name).unwrap_or(kind.clone());+ Some(finding("duplicate_rule_priority", "info", ("rule", &names.join(", ")),+ format!("{} enabled {kind_name} rules share priority {prio} ({}) — which one wins is not readable from the board.", names.len(), names.join(", "))))+ }).collect()+}++/// warning — a placed component whose footprint PATTERN is empty (no library identity).+fn chk_components_without_footprint(board: &Value, _sheet: Option<&Value>) -> Vec<Value> {+ components(board).iter().filter_map(|c| {+ if str_of(c, "pattern").trim().is_empty() {+ Some(finding("components_without_footprint", "warning", ("designator", str_of(c, "designator")),+ format!("{} has no footprint pattern name — it cannot be matched back to a library.", str_of(c, "designator"))))+ } else { None }+ }).collect()+}++// (id, fn). The ID is what appears in a finding's `check` field, so `checks:["..."]` filters on+// the name the caller actually sees — deriving it from a function name was a live trap.+type Check = fn(&Value, Option<&Value>) -> Vec<Value>;+pub const BOARD_CHECKS: [(&str, Check); 14] = [+ ("placeholder_values", chk_placeholder_values),+ ("single_pin_nets", chk_single_pin_nets),+ ("unused_mechanical_layers", chk_unused_mechanical_layers),+ ("off_grid_components", chk_off_grid_components),+ ("duplicate_designators", chk_duplicate_designators),+ ("components_outside_board_outline", chk_components_outside_board),+ ("components_near_board_edge", chk_components_near_board_edge),+ // schematic-side: need includeSchematic:true, and quietly return nothing without it+ ("vendor_part_numbers", chk_vendor_part_numbers),+ ("missing_datasheets", chk_missing_datasheets),+ ("schematic_placeholder_values", chk_schematic_placeholders),+ // design-rule hygiene, from the rules the board read already carries+ ("disabled_design_rules", chk_disabled_design_rules),+ ("missing_critical_rules", chk_missing_critical_rules),+ ("duplicate_rule_priority", chk_duplicate_rule_priority),+ ("components_without_footprint", chk_components_without_footprint),+];++const SHEET_CHECKS: [&str; 4] = ["vendor_part_numbers", "missing_datasheets",+ "schematic_placeholder_values", "duplicate_designators"];++/// Run the checks over already-read data. Pure — this is what the fixture tests exercise.+pub fn run_checks(board: &Value, sheet: Option<&Value>, wanted: Option<&Vec<String>>) -> (Vec<Value>, Vec<String>, Value) {+ let have_board = board.is_object() && !board.as_object().map(|o| o.is_empty()).unwrap_or(true);+ let mut findings = Vec::new();+ let mut ran = Vec::new();+ let mut skipped = serde_json::Map::new();+ for (name, f) in BOARD_CHECKS.iter() {+ if let Some(w) = wanted {+ if !w.iter().any(|x| x == name) {+ continue;+ }+ }+ let needs_sheet = SHEET_CHECKS.contains(name);+ if !have_board && !needs_sheet {+ skipped.insert(name.to_string(), json!("no board read"));+ continue;+ }+ if needs_sheet && sheet.is_none() && *name != "duplicate_designators" {+ skipped.insert(name.to_string(), json!("no schematic read — pass includeSchematic:true or sheet:<path>"));+ continue;+ }+ ran.push(name.to_string());+ findings.extend(f(board, sheet));+ }+ let sev_order = |s: &str| match s { "error" => 0, "warning" => 1, "info" => 2, _ => 9 };+ findings.sort_by(|a, b| {+ let sa = sev_order(a.get("severity").and_then(|s| s.as_str()).unwrap_or(""));+ let sb = sev_order(b.get("severity").and_then(|s| s.as_str()).unwrap_or(""));+ sa.cmp(&sb)+ .then_with(|| str_of(a, "check").cmp(str_of(b, "check")))+ .then_with(|| {+ let ka = [str_of(a, "designator"), str_of(a, "net"), str_of(a, "layer")].concat();+ let kb = [str_of(b, "designator"), str_of(b, "net"), str_of(b, "layer")].concat();+ natural_cmp(&ka, &kb)+ })+ });+ (findings, ran, Value::Object(skipped))+}++/// The verb: one agent round trip per document; all analysis in the bridge. Returns PARTIAL+/// results rather than nothing — a review that fails wholesale because one document was not+/// current is a review nobody runs.+pub fn review_board(args: &Value) -> Value {+ if let Some(g) = support::need_licensed() {+ return g;+ }+ let timeout = timeout_of(args, 90);+ let open = |path: &str| -> Option<Value> {+ if let Some(bad) = support::validate_openable(path) {+ return Some(bad);+ }+ let o = agent::call("open", &[("path", path.to_string())], None, timeout);+ let d = o.get("data").cloned().unwrap_or(json!({}));+ if d.get("ok").and_then(|b| b.as_bool()) == Some(false) {+ return Some(json!({"error": d.get("error")}));+ }+ None+ };+ let mut board_data = json!({});+ let mut sheet_data: Option<Value> = None;+ let mut board_err: Option<String> = None;+ let mut sheet_err: Option<String> = None;+ if let Some(bp) = crate::support::s(args, "board") {+ if let Some(e) = open(&bp) {+ let mut out = e;+ out["success"] = json!(false);+ out["stage"] = json!("board");+ return out;+ }+ }+ let r = agent::call("read_board",+ &[("sections", "components,nets,layers,copper,rules,occupancy,outline".into()),+ ("limit", crate::support::s_or(args, "limit", "2000"))],+ None, timeout);+ let bd = r.get("data").cloned().unwrap_or(json!({}));+ if bd.get("ok").and_then(|b| b.as_bool()) == Some(true) {+ board_data = bd;+ } else {+ board_err = Some(bd.get("error").and_then(|e| e.as_str()).unwrap_or("board read failed").to_string());+ }+ let want_sheet = args.get("includeSchematic").and_then(|b| b.as_bool()) == Some(true) || args.get("sheet").is_some();+ if want_sheet {+ if let Some(sp) = crate::support::s(args, "sheet") {+ if let Some(e) = open(&sp) {+ sheet_err = e.get("error").and_then(|x| x.as_str()).map(str::to_string).or(Some("could not open sheet".into()));+ }+ }+ if sheet_err.is_none() {+ let sr = agent::call("read_schematic", &[("sections", "components".into()), ("params", "1".into())], None, timeout);+ let sd = sr.get("data").cloned().unwrap_or(json!({}));+ if sd.get("ok").and_then(|b| b.as_bool()) == Some(true) {+ sheet_data = Some(sd);+ } else {+ sheet_err = Some(sd.get("error").and_then(|e| e.as_str()).unwrap_or("schematic read failed").to_string());+ }+ }+ }+ let have_board = board_err.is_none();+ if !have_board && sheet_data.is_none() {+ return json!({"success": false, "boardError": board_err, "schematicError": sheet_err,+ "_hint": "Nothing could be read. Pass board:<path> and/or sheet:<path> — Altium holds only ONE document current, so the review activates each in turn."});+ }+ let wanted: Option<Vec<String>> = args.get("checks").and_then(|c| c.as_array())+ .map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect());+ let unknown: Vec<String> = wanted.as_ref().map(|w| {+ w.iter().filter(|x| !BOARD_CHECKS.iter().any(|(n, _)| n == &x.as_str())).cloned().collect()+ }).unwrap_or_default();+ let board_for_checks = if have_board { board_data.clone() } else { json!({}) };+ let (findings, ran, mut skipped) = run_checks(&board_for_checks, sheet_data.as_ref(), wanted.as_ref());+ if let (Some(err), Some(obj)) = (&board_err, skipped.as_object_mut()) {+ for (_, v) in obj.iter_mut() {+ if v == &json!("no board read") {+ *v = json!(format!("no board read ({err})"));+ }+ }+ }+ let mut counts: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();+ for f in &findings {+ *counts.entry(f.get("severity").and_then(|s| s.as_str()).unwrap_or("?").to_string()).or_default() += 1;+ }+ let mut out = json!({"success": true, "board": board_data.get("board"),+ "sheet": sheet_data.as_ref().and_then(|s| s.get("document")),+ "findings": findings, "counts": counts, "checksRun": ran,+ "availableChecks": BOARD_CHECKS.iter().map(|(n, _)| *n).collect::<Vec<_>>(),+ "notImplemented": NOT_IMPLEMENTED.iter().cloned().collect::<std::collections::BTreeMap<_, _>>(),+ "_hint": "A clean result means the checks that RAN found nothing. `skipped` lists checks with no data; `notImplemented` lists what this review cannot see yet."});+ if skipped.as_object().map(|o| !o.is_empty()).unwrap_or(false) {+ out["skipped"] = skipped;+ }+ if !unknown.is_empty() {+ out["unknownChecks"] = json!(unknown);+ }+ if let Some(e) = board_err {+ out["boardError"] = json!(e);+ }+ if let Some(e) = sheet_err {+ out["schematicError"] = json!(e);+ }+ out+}++#[cfg(test)]+mod tests {+ use super::*;++ fn fixture_board() -> Value {+ let raw = std::fs::read_to_string("fixtures/read_board.json").expect("fixture");+ let v: Value = serde_json::from_str(&raw).expect("fixture json");+ v.get("raw").cloned().unwrap_or(v)+ }++ #[test]+ fn checks_run_clean_over_the_recorded_board() {+ let board = fixture_board();+ let (findings, ran, skipped) = run_checks(&board, None, None);+ // board-side checks all ran; sheet-side were skipped (no sheet given)+ assert!(ran.iter().any(|r| r == "placeholder_values"));+ assert!(skipped.get("vendor_part_numbers").is_some());+ // findings on a real board are plausible, but every one must carry the visible fields+ for f in &findings {+ assert!(f.get("check").is_some() && f.get("severity").is_some() && f.get("message").is_some());+ }+ }++ #[test]+ fn placeholder_and_duplicate_detection() {+ let board = json!({"components": {"components": [+ {"designator": "R1", "comment": "Comment"},+ {"designator": "R1", "comment": "10k"},+ {"designator": "U2", "comment": "STM32"},+ ]}});+ let (findings, _, _) = run_checks(&board, None, Some(&vec!["placeholder_values".into(), "duplicate_designators".into()]));+ assert!(findings.iter().any(|f| f["check"] == json!("placeholder_values") && f["designator"] == json!("R1")));+ assert!(findings.iter().any(|f| f["check"] == json!("duplicate_designators")));+ // errors sort before warnings before info+ let sevs: Vec<&str> = findings.iter().map(|f| f["severity"].as_str().unwrap()).collect();+ let mut sorted = sevs.clone();+ sorted.sort_by_key(|s| match *s { "error" => 0, "warning" => 1, _ => 2 });+ assert_eq!(sevs, sorted);+ }++ #[test]+ fn vendor_rule_needs_the_sheet_and_exempts_non_purchased() {+ let sheet = json!({"components": {"components": [+ {"designator": "U1", "params": [{"name": "Mouser Part #", "value": "123"}]},+ {"designator": "TP1", "params": []},+ ]}});+ let (findings, _, _) = run_checks(&json!({}), Some(&sheet), Some(&vec!["vendor_part_numbers".into()]));+ // U1 misses DigiKey + LCSC; TP1 is exempt+ assert_eq!(findings.len(), 1);+ assert_eq!(findings[0]["designator"], json!("U1"));+ let missing = findings[0]["missing"].as_array().unwrap();+ assert_eq!(missing.len(), 2);+ // Mouser preferred: the message names what is MISSING, and Mouser is present here+ assert!(!findings[0]["message"].as_str().unwrap().contains("no Mouser"));+ }++ #[test]+ fn outline_bounding_box_checks_refuse_weak_sources() {+ let board = json!({+ "outline": {"ok": true, "source": "boardBounds", "leftMil": 0, "rightMil": 1000, "bottomMil": 0, "topMil": 1000},+ "components": {"components": [{"designator": "U9", "xMil": 5000, "yMil": 5000}]}+ });+ // boardBounds is everything on the board incl. strays — a much weaker claim; no findings+ let (findings, _, _) = run_checks(&board, None, Some(&vec!["components_outside_board_outline".into()]));+ assert!(findings.is_empty());+ let board = json!({+ "outline": {"ok": true, "source": "boardOutline", "leftMil": 0, "rightMil": 1000, "bottomMil": 0, "topMil": 1000},+ "components": {"components": [{"designator": "U9", "xMil": 5000, "yMil": 5000},+ {"designator": "U8", "xMil": 995, "yMil": 500}]}+ });+ let (findings, _, _) = run_checks(&board, None, None);+ assert!(findings.iter().any(|f| f["check"] == json!("components_outside_board_outline") && f["designator"] == json!("U9")));+ assert!(findings.iter().any(|f| f["check"] == json!("components_near_board_edge") && f["designator"] == json!("U8")));+ }++ #[test]+ fn rule_hygiene_checks() {+ let board = json!({"components": {"components": []}, "rules": {"rules": [+ {"name": "Clearance", "kind": 0, "priority": 1, "enabled": false},+ {"name": "Width1", "kind": 2, "priority": 1, "enabled": true},+ {"name": "Width2", "kind": 2, "priority": 1, "enabled": true},+ ]}});+ let (findings, _, _) = run_checks(&board, None, None);+ // disabled critical rule = error; missing HoleSize + ShortCircuit + Clearance(enabled) = errors+ assert!(findings.iter().any(|f| f["check"] == json!("disabled_design_rules") && f["severity"] == json!("error")));+ let missing: Vec<&str> = findings.iter().filter(|f| f["check"] == json!("missing_critical_rules"))+ .map(|f| f["rule"].as_str().unwrap()).collect();+ assert!(missing.contains(&"Clearance") && missing.contains(&"HoleSize") && missing.contains(&"ShortCircuit"));+ assert!(findings.iter().any(|f| f["check"] == json!("duplicate_rule_priority")));+ }+}
bridge-rs/src/shot.rsadded+107@@ -0,0 +1,107 @@+//! Background proof capture. John's etiquette (2026-09-01): never park an Altium window+//! off-screen — a user reaching for Altium must find it where they left it. If it is minimized,+//! restore it INTO THE BACKGROUND (bottom of the z-order), capture, and hand the user's+//! foreground straight back (~0.6 s end to end). A capture under MIN_PROOF_WIDTH px is a+//! minimized-window sliver, not evidence — refused as proofUnavailable rather than returned.+use crate::{ab, instance};+use serde_json::{json, Value};++/// Anything narrower is a minimized-window sliver, not a screenshot.+pub const MIN_PROOF_WIDTH: i64 = 400;++fn is_minimized(rect: &Value) -> bool {+ if !rect.is_object() {+ return false;+ }+ let g = |k: &str| rect.get(k).and_then(|v| v.as_i64()).unwrap_or(0);+ g("left") <= -30000 || g("top") <= -30000 || (g("width") < MIN_PROOF_WIDTH && g("height") < 200)+}++/// Restore a minimized window and push it BEHIND the user's windows, then hand the foreground+/// straight back. Returns what it had to do, so a caller can report honestly whether the user's+/// screen was touched.+pub fn make_capturable(hwnd: i64) -> Value {+ let mut out = json!({"restored": false, "sentToBack": false, "foregroundReturned": false});+ let before = ab::call("desktop_get_foreground", json!({}), &[], 8).get("hwnd").and_then(|h| h.as_i64());+ let rect = instance::altium_windows()+ .into_iter()+ .find(|w| w.get("hwnd").and_then(|h| h.as_i64()) == Some(hwnd))+ .map(|w| w.get("rect").cloned().unwrap_or(Value::Null))+ .unwrap_or(Value::Null);+ if is_minimized(&rect) {+ ab::call("desktop_set_window_state", json!({"hwnd": hwnd, "state": "restore",+ "reason": "restore a minimized Altium into the background for a proof capture"}), &[], 10);+ out["restored"] = json!(true);+ instance::invalidate_snapshot();+ }+ ab::call("desktop_set_window_state", json!({"hwnd": hwnd, "state": "bottom",+ "reason": "keep Altium behind the user's windows"}), &[], 10);+ out["sentToBack"] = json!(true);+ let after = ab::call("desktop_get_foreground", json!({}), &[], 8).get("hwnd").and_then(|h| h.as_i64());+ if let Some(b) = before {+ if after != Some(b) {+ ab::call("desktop_raise_window", json!({"hwnd": b,+ "reason": "hand the user's window back after restoring Altium into the background"}), &[], 10);+ out["foregroundReturned"] = json!(true);+ }+ out["userForeground"] = json!(b);+ }+ out+}++/// A capture of the selected Altium's main window, or Null — never a sliver-as-proof.+pub fn shot_of_main() -> Value {+ let mw = match instance::main_window() {+ Some(w) => w,+ None => return Value::Null,+ };+ let hwnd = match mw.get("hwnd").and_then(|h| h.as_i64()) {+ Some(h) => h,+ None => return Value::Null,+ };+ let title = mw.get("title").cloned().unwrap_or(Value::Null);+ let prepared = make_capturable(hwnd);+ let r = ab::call("desktop_screenshot_base64", json!({"hwnd": hwnd}), &[], 20);+ let image = r.get("image").or_else(|| r.get("base64")).and_then(|i| i.as_str()).map(str::to_string);+ match image {+ Some(img) => {+ let w = r.get("width").and_then(|v| v.as_i64());+ let h = r.get("height").and_then(|v| v.as_i64());+ if let Some(wpx) = w {+ if wpx < MIN_PROOF_WIDTH {+ return json!({"title": title, "hwnd": hwnd, "proofUnavailable": true,+ "reason": format!("the Altium window is minimized (captured {}x{}) — Windows does not render a minimized window, so there is no image to prove anything", wpx, h.unwrap_or(0)),+ "prepared": prepared});+ }+ }+ json!({"title": title, "hwnd": hwnd, "image": img, "width": w, "height": h, "prepared": prepared})+ }+ None => Value::Null,+ }+}++/// Altium PAINTS its dialog text (no Static child, not in WM_GETTEXT) — the only way to READ a+/// dialog is to look at it. Attach a base64 PNG per dialog row.+pub fn attach_dialog_shots(dialogs: &mut [Value]) {+ for d in dialogs.iter_mut() {+ if let Some(hwnd) = d.get("hwnd").and_then(|h| h.as_i64()) {+ let r = ab::call("desktop_screenshot_base64", json!({"hwnd": hwnd}), &[], 10);+ if let Some(img) = r.get("image").or_else(|| r.get("base64")).and_then(|i| i.as_str()) {+ d["shot"] = json!(img);+ }+ }+ }+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn minimized_detection() {+ assert!(is_minimized(&json!({"left": -32000, "top": -32000, "width": 160, "height": 28})));+ assert!(is_minimized(&json!({"left": 100, "top": 100, "width": 237, "height": 39})));+ assert!(!is_minimized(&json!({"left": 0, "top": 0, "width": 1920, "height": 1080})));+ assert!(!is_minimized(&Value::Null));+ }+}
bridge-rs/src/support.rsadded+207@@ -0,0 +1,207 @@+//! Shared verb plumbing: the licence gate, seat hints, openable-document validation, install+//! detection, demo constants and small arg helpers. Ported from `server.py`'s top half.+use crate::{agent, dialogs, instance, shot};+use serde_json::{json, Value};+use std::time::Duration;++// ---- arg helpers (every verb speaks loosely-typed JSON) ----------------------++pub fn s(args: &Value, k: &str) -> Option<String> {+ args.get(k).map(|v| match v {+ Value::String(x) => x.clone(),+ Value::Bool(b) => if *b { "1".into() } else { "0".into() },+ other => other.to_string(),+ })+}++pub fn s_or(args: &Value, k: &str, default: &str) -> String {+ s(args, k).unwrap_or_else(|| default.to_string())+}++pub fn f_or(args: &Value, k: &str, default: f64) -> f64 {+ args.get(k)+ .and_then(|v| v.as_f64().or_else(|| v.as_str().and_then(|x| x.parse().ok())))+ .unwrap_or(default)+}++pub fn timeout_of(args: &Value, default: u64) -> Duration {+ Duration::from_secs_f64(f_or(args, "timeout", default as f64).max(1.0))+}++pub fn now() -> f64 {+ std::time::SystemTime::now()+ .duration_since(std::time::UNIX_EPOCH)+ .map(|d| d.as_secs_f64())+ .unwrap_or(0.0)+}++pub fn elapsed_round(t0: f64) -> f64 {+ ((now() - t0) * 10.0).round() / 10.0+}++// ---- licence-seat hints (John, 2026-09-01: "ai hints are way more valuable ... the AI is bad+// at reading skills") ----------------------------------------------------------++pub const SEAT_RULE: &str = "One On-Demand seat serves EVERY Altium app on this machine — Altium Designer and Altium Designer Develop share it. Only one can be licensed at a time.";++pub const SEAT_STRANDED_FIX: &str = "If a claim fails while the licence page shows Seats 0/1 (looks free, is not), the seat is STRANDED by an Altium that was KILLED instead of closed. Fix: altium_close on every other instance (polite WM_CLOSE releases the lease), then altium_license {action:'use_license'}. Waiting does not clear it, and Refresh is a link the UIA layer cannot invoke.";++pub const SEAT_SWAP_HOWTO: &str = "To move the seat between apps: altium_close the one holding it (answer any Unsaved Changes prompt deliberately — on a shared machine the document may belong to another AI thread), then LAUNCH the other app; it claims the free seat silently at startup. No clicking required.";++/// Attach the seat guidance to any reply where the licence is the thing about to bite.+pub fn seat_hints(mut out: Value, licence_state: Option<&str>, instance_count: usize) -> Value {+ let unlicensed = matches!(+ licence_state.map(|s| s.to_lowercase()).as_deref(),+ Some("no license") | Some("no licence") | Some("viewer")+ );+ if unlicensed {+ out["_licenceHint"] = json!(format!("{SEAT_RULE} {SEAT_STRANDED_FIX}"));+ }+ if instance_count > 1 {+ out["_seatHint"] = json!(format!(+ "{SEAT_RULE} {SEAT_SWAP_HOWTO} NOTE: Altium Designer Develop writes NO licence tail in \+ its title when it HOLDS the seat (only '- Viewer' when it does not), so a blank \+ licenceState on Develop does not mean unlicensed — try the verb."+ ));+ }+ out+}++/// The gate every driving verb passes: running, licensed, and no modal freezing the engine.+/// None = clear to proceed; Some(refusal) = return it verbatim.+pub fn need_licensed() -> Option<Value> {+ let lic = instance::licence_state();+ if lic.get("running").and_then(|b| b.as_bool()) != Some(true) {+ return Some(json!({"success": false, "errorCode": "altium_not_running", "_hint": "altium_launch first."}));+ }+ if lic.get("licensed").and_then(|b| b.as_bool()) == Some(false) {+ let n = instance::instances().len();+ let state = lic.get("state").and_then(|s| s.as_str()).map(str::to_string);+ return Some(seat_hints(+ json!({"success": false, "errorCode": "not_licensed", "licenceState": state,+ "_hint": "Altium refuses scripts without a licence. altium_license {\"action\":\"use_license\"} claims a free seat."}),+ state.as_deref(),+ n,+ ));+ }+ let mut dl = dialogs::scan_scoped(true);+ if !dl.is_empty() {+ shot::attach_dialog_shots(&mut dl);+ return Some(json!({"success": false, "errorCode": "blocked_by_dialog", "dialogs": dl,+ "_hint": "A modal on the main window freezes scripting. Read dialogs[].shot (base64 PNG — the text is painted, not exposed), altium_dismiss_dialogs {hwnd, button}, retry. If it is a script error, altium_agent_recover."}));+ }+ None+}++// ---- openable-document validation --------------------------------------------+// Validated in the BRIDGE so an unsupported path is a clean error instead of Altium's+// "File type not recognised." modal on the user's screen (John: pre-empt, don't clean up).++pub const OPENABLE: &[&str] = &[".pcbdoc", ".schdoc", ".pcblib", ".schlib", ".outjob", ".pcb3dlib",+ ".prjpcb", ".prjscr", ".prjmbd", ".prjhar", ".libpkg", ".txt", ".pas", ".rep"];++/// ab gives a verb 60 s. A cold file open can exceed that; blowing the budget looks like a+/// failure while Altium is still working — wait a little under it and hand back a poll instead.+pub const OPEN_BUDGET_SEC: f64 = 45.0;++pub fn validate_openable(path: &str) -> Option<Value> {+ if path.is_empty() {+ return Some(json!({"success": false, "error": "path is required"}));+ }+ let ext = path.rsplit('.').next().map(|e| format!(".{}", e.to_lowercase())).unwrap_or_default();+ if !path.contains('.') || !OPENABLE.contains(&ext.as_str()) {+ return Some(json!({"success": false, "errorCode": "unsupported_document",+ "error": format!("Altium does not open '{}' — asking it to would raise a 'File type not recognised' modal on the user's screen", if ext.is_empty() { path } else { &ext }),+ "supported": OPENABLE}));+ }+ None+}++// ---- installs ----------------------------------------------------------------++pub const HOST_APP: &str = "Altium Designer";++/// Every Altium install on the box, newest major first.+pub fn detect_installs() -> Vec<Value> {+ let mut out: Vec<(i64, Value)> = Vec::new();+ for pf in ["C:\\Program Files", "C:\\Program Files (x86)"] {+ let altium = std::path::Path::new(pf).join("Altium");+ let Ok(rd) = std::fs::read_dir(&altium) else { continue };+ for e in rd.flatten() {+ let name = e.file_name().to_string_lossy().to_string();+ if !name.to_uppercase().starts_with("AD") {+ continue;+ }+ let exe = e.path().join("X2.EXE");+ if !exe.exists() {+ continue;+ }+ let major: i64 = name.chars().filter(|c| c.is_ascii_digit()).collect::<String>().parse().unwrap_or(0);+ out.push((major, json!({+ "installed": true,+ "base_dir": e.path().to_string_lossy(),+ "exe": exe.to_string_lossy(),+ "system_dir": e.path().join("System").to_string_lossy(),+ "major": major.to_string(),+ })));+ }+ }+ out.sort_by_key(|(m, _)| -m);+ out.into_iter().map(|(_, v)| v).collect()+}++pub fn first_install() -> Option<Value> {+ detect_installs().into_iter().next()+}++pub fn not_installed() -> Value {+ json!({"success": false, "errorCode": "host_app_not_installed", "hostApp": HOST_APP,+ "error": "Altium Designer is not installed on this machine.",+ "_hint": "Altium is a licensed host app (no silent installer). Offer to run the official AltiumDesignerSetup.exe for the user — the altium-install skill has the full drive."})+}++// ---- demo / example constants ------------------------------------------------++pub const EXAMPLES_DIR: &str = r"C:\Users\Public\Documents\Altium\AD26\Examples";++pub fn demo_project() -> String { format!(r"{EXAMPLES_DIR}\Bluetooth Sentinel\Bluetooth_Sentinel.PrjPcb") }+pub fn demo_pcb() -> String { format!(r"{EXAMPLES_DIR}\Bluetooth Sentinel\Bluetooth_Sentinel.PcbDoc") }+pub fn demo_sch() -> String { format!(r"{EXAMPLES_DIR}\Bluetooth Sentinel\Bluetooth_Sentinel.SchDoc") }+pub fn demo_schlib() -> String { format!(r"{EXAMPLES_DIR}\SpiritLevel-SL1\Libraries\SpiritLevel_2E.SchLib") }+pub fn demo_pcblib() -> String { format!(r"{EXAMPLES_DIR}\SpiritLevel-SL1\Libraries\Spirit_Level_Project.PcbLib") }+pub fn demo_outjob_fab() -> String { format!(r"{EXAMPLES_DIR}\Bluetooth Sentinel\Fabrication.OutJob") }+pub fn demo_outjob_asm() -> String { format!(r"{EXAMPLES_DIR}\Bluetooth Sentinel\Assembly.OutJob") }++/// A real 3-pin SOT in the SpiritLevel PcbLib; the first entry ("NT") is a 2-pad test point.+pub const DEMO_FOOTPRINT: &str = "SO-G3";++// ---- agent-call convenience --------------------------------------------------++/// The dominant Python idiom `agent.call("open", {"path": p})` with best-effort semantics.+pub fn agent_open(path: &str, timeout_s: u64) -> Value {+ agent::call("open", &[("path", path.to_string())], None, Duration::from_secs(timeout_s))+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn openable_refuses_unknown_extensions_cleanly() {+ assert!(validate_openable(r"C:\x\board.PcbDoc").is_none());+ assert!(validate_openable(r"C:\x\part.step").is_some());+ let r = validate_openable(r"C:\x\noext").unwrap();+ assert_eq!(r["errorCode"], json!("unsupported_document"));+ assert!(validate_openable("").is_some());+ }++ #[test]+ fn seat_hints_attach_on_the_right_conditions() {+ let out = seat_hints(json!({}), Some("Viewer"), 2);+ assert!(out.get("_licenceHint").is_some());+ assert!(out.get("_seatHint").is_some());+ let out = seat_hints(json!({}), Some("Trial"), 1);+ assert!(out.get("_licenceHint").is_none());+ assert!(out.get("_seatHint").is_none());+ }+}
bridge-rs/src/surfaces.rsadded+334@@ -0,0 +1,334 @@+//! The show_* surfaces: open a document/library member through the agent, run follow-up+//! processes, capture a BACKGROUND proof frame. A surface verb exists to produce PROOF — if the+//! capture failed, saying success:true would hand the caller (and the wiki front-end) a confident+//! nothing, so a missing image is `proof_unavailable`, not success.+use crate::support::{self, s, s_or, timeout_of};+use crate::{agent, dialogs, instance, shot};+use serde_json::{json, Value};+use std::time::Duration;++/// Open a document through the agent, run follow-up processes, return the frame.+pub fn open_then(path: &str, commands: &[(&str, &str)], timeout: Duration, label: &str) -> Value {+ if let Some(g) = support::need_licensed() {+ return g;+ }+ let t0 = support::now();+ let mut steps: Vec<Value> = Vec::new();+ let r = agent::call("open", &[("path", path.to_string())], None, timeout);+ steps.push(json!({"open": path, "result": r.get("data"), "ms": r.get("roundtripMs")}));+ if r.get("success").and_then(|b| b.as_bool()) != Some(true) {+ let mut out = r;+ out["steps"] = json!(steps);+ return out;+ }+ for (cmd, params) in commands {+ let rr = agent::call("run_process", &[("process", cmd.to_string()), ("params", params.to_string())], None, timeout);+ steps.push(json!({"process": cmd, "params": params, "ok": rr.get("success"), "ms": rr.get("roundtripMs")}));+ }+ std::thread::sleep(Duration::from_millis(600));+ let dl = dialogs::scan_scoped(true);+ let frame = shot::shot_of_main();+ let mw = instance::main_window().unwrap_or(json!({}));+ let ok = dl.is_empty();+ json!({"success": ok, "surface": label, "path": path, "steps": steps,+ "title": mw.get("title"), "document": mw.get("document"),+ "dialogsDetected": dl.len(), "dialogs": dl, "shot": frame,+ "windowEtiquette": "background", "elapsedSec": support::elapsed_round(t0),+ "_hint": if ok {+ format!("{label} is on screen (title: {:?}). shot.image is the frame; altium_state for every window.", mw.get("title").and_then(|t| t.as_str()).unwrap_or(""))+ } else {+ "A dialog came up — read dialogs[] before doing anything else.".into()+ }})+}++/// Open a library, select a member, run view processes, capture proof.+pub fn lib_surface(args: &Value, kind: &str, default_path: &str, label: &str, after: &[(&str, &str)]) -> Value {+ if let Some(g) = support::need_licensed() {+ return g;+ }+ let t0 = support::now();+ let path = s(args, "libraryPath").or_else(|| s(args, "filePath")).unwrap_or_else(|| default_path.to_string());+ let name = s(args, "symbolName").or_else(|| s(args, "footprintName")).or_else(|| s(args, "name")).unwrap_or_default();+ let timeout = timeout_of(args, 60);+ let mut steps: Vec<Value> = Vec::new();+ let r = agent::call("open", &[("path", path.clone())], None, timeout);+ steps.push(json!({"open": path, "result": r.get("data"), "ms": r.get("roundtripMs")}));+ if r.get("success").and_then(|b| b.as_bool()) != Some(true) {+ let mut out = r;+ out["steps"] = json!(steps);+ return out;+ }+ let sel = agent::call("lib_select", &[("kind", kind.to_string()), ("name", name.clone())], None, timeout);+ steps.push(json!({"lib_select": if name.is_empty() { "(first)" } else { &name }, "result": sel.get("data"), "ms": sel.get("roundtripMs")}));+ if sel.get("success").and_then(|b| b.as_bool()) != Some(true) {+ let lst = agent::call("lib_list", &[("kind", kind.to_string()), ("limit", "50".into())], None, Duration::from_secs(30));+ let mut out = sel;+ out["steps"] = json!(steps);+ out["available"] = lst.get("data").and_then(|d| d.get("names")).cloned().unwrap_or(Value::Null);+ out["_hint"] = json!("Component not found in that library — pick one of available[] (altium_list_library shows all).");+ return out;+ }+ for (cmd, params) in after {+ let rr = agent::call("run_process", &[("process", cmd.to_string()), ("params", params.to_string())], None, Duration::from_secs(60));+ steps.push(json!({"process": cmd, "params": params, "ok": rr.get("success"), "ms": rr.get("roundtripMs")}));+ }+ std::thread::sleep(Duration::from_millis(600));+ let mut dl = dialogs::scan_scoped(true);+ shot::attach_dialog_shots(&mut dl);+ let frame = shot::shot_of_main();+ let mw = instance::main_window().unwrap_or(json!({}));+ let no_proof = frame.get("proofUnavailable").and_then(|b| b.as_bool()) == Some(true);+ let selected = sel.get("data").and_then(|d| d.get("selected")).cloned().unwrap_or(Value::Null);+ let mut out = json!({"success": dl.is_empty() && !no_proof, "surface": label, "path": path,+ "selected": selected,+ "steps": steps, "title": mw.get("title"), "dialogsDetected": dl.len(), "dialogs": dl, "shot": frame,+ "windowEtiquette": "background", "elapsedSec": support::elapsed_round(t0),+ "_hint": format!("{label}: {:?} in {:?}. shot.image is the frame.",+ out_str(&out_sel(&sel)), mw.get("document").and_then(|d| d.as_str()).unwrap_or(""))});+ if no_proof {+ out["errorCode"] = json!("proof_unavailable");+ out["error"] = out["shot"].get("reason").cloned().unwrap_or(Value::Null);+ out["_hint"] = json!("The surface was opened and selected correctly — only the CAPTURE failed, because the Altium window is minimized. The bridge restores it into the background automatically; if it is still minimized, something re-minimized it mid-call.");+ }+ out+}++fn out_sel(sel: &Value) -> Value {+ sel.get("data").and_then(|d| d.get("selected")).cloned().unwrap_or(Value::Null)+}++fn out_str(v: &Value) -> String {+ v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string())+}++// ---- the eight surfaces ------------------------------------------------------++pub fn show_project(args: &Value) -> Value {+ let p = s(args, "filePath").unwrap_or_else(support::demo_project);+ open_then(&p, &[], timeout_of(args, 60), "project")+}++pub fn show_schematic(args: &Value) -> Value {+ let p = s(args, "filePath").unwrap_or_else(support::demo_sch);+ open_then(&p, &[("Sch:Zoom", "Action=All")], timeout_of(args, 60), "schematic")+}++pub fn show_2d_board(args: &Value) -> Value {+ let p = s(args, "filePath").unwrap_or_else(support::demo_pcb);+ open_then(&p, &[("PCB:SwitchTo2D", ""), ("PCB:Zoom", "Action=All")], timeout_of(args, 120), "2d_board")+}++pub fn show_3d_board(args: &Value) -> Value {+ let p = s(args, "filePath").unwrap_or_else(support::demo_pcb);+ open_then(&p, &[("PCB:SwitchTo3D", ""), ("PCB:Zoom", "Action=All")], timeout_of(args, 120), "3d_board")+}++pub fn show_symbol(args: &Value) -> Value {+ lib_surface(args, "sch", &support::demo_schlib(), "symbol", &[])+}++fn with_demo_footprint(args: &Value) -> Value {+ let mut a = args.clone();+ let custom = args.get("libraryPath").is_some() || args.get("filePath").is_some();+ if a.get("footprintName").is_none() {+ a["footprintName"] = json!(if custom { "" } else { support::DEMO_FOOTPRINT });+ }+ a+}++pub fn show_footprint(args: &Value) -> Value {+ let a = with_demo_footprint(args);+ lib_surface(&a, "pcb", &support::demo_pcblib(), "footprint", &[("PCB:SwitchTo2D", ""), ("PCB:Zoom", "Action=All")])+}++pub fn show_3d_chip(args: &Value) -> Value {+ let a = with_demo_footprint(args);+ lib_surface(&a, "pcb", &support::demo_pcblib(), "3d_chip", &[("PCB:SwitchTo3D", ""), ("PCB:Zoom", "Action=All")])+}++fn lib_kind_and_path(args: &Value) -> (String, String) {+ let path = s(args, "libraryPath").or_else(|| s(args, "filePath")).unwrap_or_else(support::demo_schlib);+ let kind = if path.to_lowercase().ends_with("pcblib") { "pcb" } else { "sch" };+ (kind.into(), path)+}++pub fn show_library(args: &Value) -> Value {+ let (kind, path) = lib_kind_and_path(args);+ let mut a = args.clone();+ a["libraryPath"] = json!(path);+ a["name"] = json!("");+ let mut r = lib_surface(&a, &kind, &path, "library", &[]);+ if r.get("success").and_then(|b| b.as_bool()) == Some(true) {+ let lst = agent::call("lib_list", &[("kind", kind), ("limit", "200".into())], None, Duration::from_secs(30));+ r["components"] = lst.get("data").and_then(|d| d.get("names")).cloned().unwrap_or(Value::Null);+ r["componentCount"] = lst.get("data").and_then(|d| d.get("count")).cloned().unwrap_or(Value::Null);+ }+ r+}++pub fn list_library(args: &Value) -> Value {+ let (kind, path) = lib_kind_and_path(args);+ if let Some(g) = support::need_licensed() {+ return g;+ }+ let r = support::agent_open(&path, 60);+ if r.get("success").and_then(|b| b.as_bool()) != Some(true) {+ return r;+ }+ let limit = s_or(args, "limit", "500");+ let mut lst = agent::call("lib_list", &[("kind", kind.clone()), ("limit", limit)], None, Duration::from_secs(60));+ lst["path"] = json!(path);+ lst["kind"] = json!(kind);+ lst+}++// ---- window / document verbs -------------------------------------------------++pub fn window_info(_args: &Value) -> Value {+ let wins = instance::altium_windows();+ let dl = dialogs::scan();+ let has_modal = dl.iter().any(|d| d.get("ownedByMainInstance").and_then(|b| b.as_bool()) == Some(true));+ let mut out = json!({"success": true, "windows": wins, "dialogs": dl, "hasModalDialogs": has_modal});+ if has_modal {+ out["_hint"] = json!("A dialog is open on Altium's main window: the agent loop and every -R request are frozen behind it. Read dialogs[].body + category, then altium_dismiss_dialogs {hwnd, button} with the work-preserving choice.");+ }+ out+}++/// Make an ALREADY-OPEN document current — the quiet lane. The AGENT is the real quiet lane+/// (a whole-window UIA search takes >100 s on the doc bar): one in-process call, no cursor,+/// no OS foreground; Altium ACTIVATES an already-open document rather than re-reading it.+pub fn activate_document(args: &Value) -> Value {+ if let Some(g) = support::need_licensed() {+ return g;+ }+ let path = match s(args, "path").or_else(|| s(args, "document")).or_else(|| s(args, "name")) {+ Some(p) => p,+ None => return json!({"success": false, "error": "path is required (the document's full path)",+ "_hint": "altium_window_info lists what is open; board verbs need a .PcbDoc current."}),+ };+ if let Some(bad) = support::validate_openable(&path) {+ return bad;+ }+ let wait = timeout_of(args, 60).as_secs_f64().min(support::OPEN_BUDGET_SEC);+ let mut r = agent::call("open", &[("path", path.clone())], None, Duration::from_secs_f64(wait));+ if r.get("errorCode").and_then(|c| c.as_str()) == Some("agent_timeout") {+ return json!({"success": true, "status": "in_progress", "path": path, "lane": "agent",+ "windowEtiquette": "background", "waitedSec": wait,+ "_hint": "Still opening — poll altium_status until the document is current."});+ }+ r["lane"] = json!("agent");+ r["windowEtiquette"] = json!("background");+ if r.get("_hint").is_none() {+ r["_hint"] = json!("The document is now current. An already-open document is activated, not reloaded.");+ }+ r+}++pub fn open_document(args: &Value) -> Value {+ if let Some(g) = support::need_licensed() {+ return g;+ }+ let path = match s(args, "filePath").or_else(|| s(args, "path")) {+ Some(p) => p,+ None => return json!({"success": false, "error": "filePath is required (absolute Windows path)"}),+ };+ if let Some(bad) = support::validate_openable(&path) {+ return bad;+ }+ let wait = timeout_of(args, 60).as_secs_f64().min(support::OPEN_BUDGET_SEC);+ let mut r = agent::call("open", &[("path", path.clone())], None, Duration::from_secs_f64(wait));+ if r.get("errorCode").and_then(|c| c.as_str()) == Some("agent_timeout") {+ // Altium is still opening it — that is not a failure, and ab's 60 s budget must not turn+ // it into one. Report progress and let the caller poll.+ return json!({"success": true, "status": "in_progress", "path": path, "waitedSec": wait,+ "roundtripMs": r.get("roundtripMs"),+ "_hint": "Altium is still opening the document. Poll altium_status (or altium_window_info) until it is current; the agent answers again once the open completes."});+ }+ r["windowEtiquette"] = json!("background");+ r+}++/// Save the focused document (or the one at filePath) — WorkspaceManager:SaveObject.+pub fn save(args: &Value) -> Value {+ if let Some(g) = support::need_licensed() {+ return g;+ }+ if let Some(p) = s(args, "filePath") {+ support::agent_open(&p, 60);+ }+ // pre-empts "a command is currently active" (save-a-copy Warning)+ agent::call("tx_clear", &[], None, Duration::from_secs(30));+ let mut r = agent::call("run_process",+ &[("process", "WorkspaceManager:SaveObject".into()), ("params", "ObjectKind=FocusedDocument".into())],+ None, Duration::from_secs(60));+ r["_hint"] = json!("Saved the focused document. A 'Save As' dialog means the document never had a path — altium_check_dialogs.");+ r+}++pub fn board_shape(args: &Value) -> Value {+ if let Some(g) = support::need_licensed() {+ return g;+ }+ if let Some(p) = s(args, "pcbPath") {+ support::agent_open(&p, 60);+ }+ agent::call("board_shape",+ &[("x0Mm", s_or(args, "x0Mm", "0")), ("y0Mm", s_or(args, "y0Mm", "0")),+ ("wMm", s_or(args, "wMm", "15")), ("hMm", s_or(args, "hMm", "15"))],+ None, timeout_of(args, 120))+}++pub fn pcb_report(args: &Value) -> Value {+ if let Some(g) = support::need_licensed() {+ return g;+ }+ if let Some(p) = s(args, "path") {+ support::agent_open(&p, 60);+ }+ agent::call("pcb_report",+ &[("name", s_or(args, "name", "")), ("want", s_or(args, "want", ""))],+ None, timeout_of(args, 60))+}++// ---- demo tour ---------------------------------------------------------------++const DEMO_BEATS: &[(&str, &str)] = &[+ ("symbol", "A schematic symbol from the SpiritLevel example library, in the Symbol editor."),+ ("footprint", "Its land pattern in the footprint editor: pads, silkscreen, courtyard."),+ ("3d_chip", "The same footprint rendered in 3D."),+ ("schematic", "The Bluetooth Sentinel top-level schematic — six sheets wired by ports."),+ ("2d_board", "The layout in 2D: a two-board rigid-flex, four copper layers."),+ ("3d_board", "The populated board in 3D."),+];++/// Same shape as kicad_demo/fusion_demo: one beat per call, `done` when finished.+pub fn demo(args: &Value) -> Value {+ let stage = args.get("stage").and_then(|v| v.as_u64()).unwrap_or(0) as usize;+ if stage >= DEMO_BEATS.len() {+ return json!({"success": true, "done": true, "stage": stage,+ "narrate": "That is the whole tour: symbol, footprint, 3D part, schematic, 2D and 3D board — all driven through the resident agent while Altium stayed in the background.",+ "_hint": "Tour complete."});+ }+ let (label, narrate) = DEMO_BEATS[stage];+ let beat_args = json!({"timeout": 120});+ let r = match label {+ "symbol" => show_symbol(&beat_args),+ "footprint" => show_footprint(&beat_args),+ "3d_chip" => show_3d_chip(&beat_args),+ "schematic" => show_schematic(&beat_args),+ "2d_board" => show_2d_board(&beat_args),+ _ => show_3d_board(&beat_args),+ };+ let ok = r.get("success").and_then(|b| b.as_bool()) == Some(true);+ let shots = match r.get("shot").and_then(|s| s.get("image")) {+ Some(img) => json!([{"label": label, "image": img}]),+ None => json!([]),+ };+ json!({"success": ok, "done": false, "stage": stage, "label": label, "narrate": narrate,+ "screenshots": shots, "steps": r.get("steps"), "dialogs": r.get("dialogs"),+ "failedSteps": if ok { json!([]) } else { json!([label]) },+ "nextCall": {"verb": "altium_demo", "args": {"stage": stage + 1}},+ "_hint": format!("Beat {}/{} ({label}). Narrate, show the screenshot, then call altium_demo {{stage:{}}}.",+ stage + 1, DEMO_BEATS.len(), stage + 1)})+}
bridge-rs/src/taskbar.rsadded+216@@ -0,0 +1,216 @@+//! "Adom is driving Altium" on the Windows taskbar — badge overlay + progress bar, both painted+//! by ab's generic `desktop_taskbar` verb (never an in-process ITaskbarList3 call: that returns+//! S_OK cross-process and the shell silently ignores it; pup, fusion and kicad each paid for it).+//! No AUMID stamping — a per-window AppUserModelID poisons the shell's icon cache (kicad's lesson).+//!+//! John's rules: progress rides the taskbar icon, never toasts; the overlay auto-clears after+//! 5 minutes without a driving verb (a permanent overlay stops meaning "an AI is driving now").+use crate::{ab, instance};+use serde_json::{json, Value};+use std::collections::{BTreeMap, BTreeSet};+use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};+use std::sync::Mutex;+use std::time::Duration;++static BADGED: Mutex<BTreeSet<i64>> = Mutex::new(BTreeSet::new());+static LAST_DRIVING: AtomicI64 = AtomicI64::new(0);+static IDLE_WATCHER: AtomicBool = AtomicBool::new(false);+static PROGRESS_STOP: Mutex<BTreeMap<String, std::sync::Arc<AtomicBool>>> = Mutex::new(BTreeMap::new());++pub const IDLE_CLEAR_SEC: i64 = 300;++fn now() -> i64 {+ std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0)+}++fn badge_b64() -> String {+ static B64: Mutex<Option<String>> = Mutex::new(None);+ if let Ok(g) = B64.lock() {+ if let Some(s) = g.as_ref() {+ return s.clone();+ }+ }+ let p = std::env::current_exe()+ .ok()+ .and_then(|e| e.parent().map(|d| d.join("resources").join("adom-overlay-32.png")));+ let s = p.and_then(|p| std::fs::read(p).ok()).map(|b| b64encode(&b)).unwrap_or_default();+ if let Ok(mut g) = B64.lock() {+ *g = Some(s.clone());+ }+ s+}++fn taskbar(args: Value) -> bool {+ let r = ab::call("desktop_taskbar", args, &[], 15);+ r.get("success").and_then(|b| b.as_bool()).unwrap_or(false)+ || r.to_string().contains("Taskbar updated")+}++fn main_hwnd() -> Option<i64> {+ instance::main_window().and_then(|w| w.get("hwnd").and_then(|h| h.as_i64()))+}++// ---- badge -------------------------------------------------------------------++pub fn set_driving_badge() -> bool {+ let hwnd = match main_hwnd() {+ Some(h) => h,+ None => return false,+ };+ if BADGED.lock().map(|g| g.contains(&hwnd)).unwrap_or(false) {+ return true;+ }+ let icon = badge_b64();+ if icon.is_empty() {+ return false; // badge asset missing beside the exe — not worth failing a verb over+ }+ let ok = taskbar(json!({"hwnd": hwnd, "overlay": {"icon": icon, "tooltip": "Adom is driving Altium Designer"}}));+ if ok {+ if let Ok(mut g) = BADGED.lock() {+ g.insert(hwnd);+ }+ }+ ok+}++pub fn clear_driving_badge() -> bool {+ let targets: Vec<i64> = BADGED.lock().map(|g| g.iter().cloned().collect()).unwrap_or_default();+ let mut ok = true;+ for h in targets {+ if taskbar(json!({"hwnd": h, "overlay": {"badge": "none"}})) {+ if let Ok(mut g) = BADGED.lock() {+ g.remove(&h);+ }+ } else {+ ok = false;+ }+ }+ ok+}++pub fn badge_active() -> bool {+ BADGED.lock().map(|g| !g.is_empty()).unwrap_or(false)+}++/// Called by dispatch on every driving verb; feeds the idle clock.+pub fn note_driving() {+ LAST_DRIVING.store(now(), Ordering::Relaxed);+}++/// Clear the overlay after IDLE_CLEAR_SEC without a driving verb. Idempotent; the next driving+/// verb re-badges instantly.+pub fn start_idle_watcher() -> bool {+ if IDLE_WATCHER.swap(true, Ordering::SeqCst) {+ return false;+ }+ std::thread::Builder::new()+ .name("altium-badge-idle-clear".into())+ .spawn(|| loop {+ std::thread::sleep(Duration::from_secs(15));+ let last = LAST_DRIVING.load(Ordering::Relaxed);+ if badge_active() && last > 0 && now() - last > IDLE_CLEAR_SEC {+ clear_driving_badge();+ }+ })+ .is_ok()+}++// ---- progress ----------------------------------------------------------------++fn taskbar_progress(state: &str, value: Option<i64>) -> bool {+ let hwnd = match main_hwnd() {+ Some(h) => h,+ None => return false,+ };+ let mut prog = json!({"state": state});+ if let Some(v) = value {+ prog["value"] = json!(v.clamp(0, 100));+ }+ taskbar(json!({"hwnd": hwnd, "progress": prog}))+}++/// Begin a progress bar for `verb`, paced by its measured estimate (marquee when none).+pub fn start_activity_progress(verb: &str, estimate_s: Option<f64>, confidence: &str) {+ let stop = std::sync::Arc::new(AtomicBool::new(false));+ if let Ok(mut g) = PROGRESS_STOP.lock() {+ g.insert(verb.to_string(), stop.clone());+ }+ let est = estimate_s.filter(|e| *e > 0.0 && confidence != "unknown");+ let _ = std::thread::Builder::new().name(format!("altium-progress-{verb}")).spawn(move || {+ let started = std::time::Instant::now();+ match est {+ None => {+ taskbar_progress("indeterminate", None);+ while !stop.load(Ordering::Relaxed) {+ std::thread::sleep(Duration::from_secs(1));+ }+ }+ Some(e) => {+ let mut fails = 0u32;+ while !stop.load(Ordering::Relaxed) {+ std::thread::sleep(Duration::from_millis(700));+ if stop.load(Ordering::Relaxed) {+ break;+ }+ let pct = ((95.0 * started.elapsed().as_secs_f64() / e) as i64).min(95);+ if taskbar_progress("normal", Some(pct)) {+ fails = 0;+ } else {+ fails += 1; // window may not exist yet during a cold launch — keep trying+ if fails > 120 {+ return;+ }+ }+ }+ }+ }+ });+}++pub fn end_activity_progress(verb: &str, failed: bool) {+ let stop = PROGRESS_STOP.lock().ok().and_then(|mut g| g.remove(verb));+ if let Some(s) = stop {+ s.store(true, Ordering::Relaxed);+ } else {+ return; // no bar was started for this verb+ }+ if failed {+ taskbar_progress("error", Some(100));+ std::thread::sleep(Duration::from_millis(1200));+ }+ taskbar_progress("none", None);+ if let Some(h) = main_hwnd() {+ // cancel Windows' own orange attention flash (John: "can u not orange flash the taskbar")+ taskbar(json!({"hwnd": h, "flash": "stop"}));+ }+}++// ---- base64 (no external crate) ----------------------------------------------++fn b64encode(data: &[u8]) -> String {+ const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";+ let mut out = String::with_capacity(data.len().div_ceil(3) * 4);+ for chunk in data.chunks(3) {+ let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];+ let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;+ out.push(T[(n >> 18) as usize & 63] as char);+ out.push(T[(n >> 12) as usize & 63] as char);+ out.push(if chunk.len() > 1 { T[(n >> 6) as usize & 63] as char } else { '=' });+ out.push(if chunk.len() > 2 { T[n as usize & 63] as char } else { '=' });+ }+ out+}++#[cfg(test)]+mod tests {+ use super::b64encode;++ #[test]+ fn base64_reference_vectors() {+ assert_eq!(b64encode(b""), "");+ assert_eq!(b64encode(b"f"), "Zg==");+ assert_eq!(b64encode(b"fo"), "Zm8=");+ assert_eq!(b64encode(b"foo"), "Zm9v");+ assert_eq!(b64encode(b"foobar"), "Zm9vYmFy");+ }+}
bridge-rs/src/verbs.rs+355−159@@ -1,19 +1,53 @@-//! Verb dispatch. v0 = the Phase 1 introspection surface as thin views over the existing agent-//! commands (zero template changes), plus status/readiness. Every verb returns the envelope the-//! Python bridge returns, so the CLI and callers see no difference.-use crate::{agent, board, dialogs, health, lifecycle};+//! Verb dispatch — the FULL surface (110 verbs), at parity with the Python bridge it replaces.+//! Every verb returns the envelope the Python bridge returned, so the CLI and callers see no+//! difference; the engine field says "rust".+use crate::support::{self, s};+use crate::{a365, agent, board, dialogs, library, license, lifecycle, misc, outputs, progress, review, surfaces, taskbar}; use serde_json::{json, Value}; use std::time::Duration; -const KNOWN: &[&str] = &[- "readiness", "status", "agent_status", "eval", "run_command", "open_document",- "list_designators", "component_data", "component_pins", "list_nets", "net_connections",- "layers", "copper_layers", "read_rules", "set_layer_visibility", "footprint_primitives", "schematic_data",- "agent_start", "agent_stop", "agent_recover", "launch", "close", "check_dialogs", "dismiss_dialogs",- "read_board", "board_summary", "rules_by_kind", "layers_in_use",- "describe", "uninstall",+/// Everything this bridge serves. lint_verbs in scripts/release.py checks bridge.json against+/// THIS list — keep it the single source of truth for the dispatch below.+pub const KNOWN: &[&str] = &[+ // health / lifecycle+ "readiness", "status", "diagnostics", "window_info", "state", "check_dialogs",+ "dismiss_dialogs", "launch", "close", "instances", "dialog_log", "uninstall", "describe",+ // agent / REPL+ "agent_start", "agent_stop", "agent_status", "agent_recover",+ "eval", "run_command", "run_script", "list_commands", "open_document", "open_project",+ "activate_document", "progress",+ // board / schematic reads+ "read_board", "board_summary", "rules_by_kind", "layers_in_use", "read_schematic",+ "cross_check", "review_board", "list_designators", "component_data", "component_pins",+ "list_nets", "net_connections", "layers", "copper_layers", "read_rules",+ "set_layer_visibility", "footprint_primitives", "schematic_data",+ // surfaces+ "show_project", "show_schematic", "show_2d_board", "show_3d_board", "show_library",+ "show_symbol", "show_footprint", "show_3d_chip", "list_library", "demo",+ // outputs / project+ "compile", "run_erc", "run_drc", "run_outjob", "export_gerber", "export_bom", "export_step",+ "save", "test_board", "board_shape", "pcb_report", "import_kicad",+ "install_extension", "extension_ping",+ // library authoring+ "create_footprint", "create_symbol", "build_footprint", "build_symbol_modes", "add_body",+ "add_models", "normalize_bodies", "set_params", "set_visibility", "read_symbol",+ "clone_symbol", "rename_symbol", "rename_footprint", "remove_symbol", "remove_footprint",+ "copy_footprint", "xcopy_footprint", "swap_footprint", "symbol_mode", "cleanup_libs",+ "install_library", "install_library_bundle", "compile_intlib", "extract_intlib",+ "place_on_sch", "place_on_pcb", "import_part",+ // licence / account+ "license", "license_status", "ensure_licensed", "setup_trial", "signin", "signin_status",+ "connect_workspace",+ // Altium 365+ "a365_configure", "a365_oauth_begin", "a365_oauth_status", "a365_oauth_cancel",+ "a365_refresh", "a365_logout", "a365_status", "a365_query", "a365_files_upload",+ "a365_library_overview", "a365_library_schema", ]; +pub fn names() -> Vec<String> {+ KNOWN.iter().map(|v| format!("altium_{v}")).collect()+}+ /// One `describe` entry. ab's "Verbs" tab renders these verbatim. fn verb_doc(name: &str, summary: &str, input: Value, output: Value, timeout: u64, hint: &str, related: &[&str], pitfalls: &[&str], example: Value) -> Value { json!({"name": format!("altium_{name}"), "summary": summary, "input": input, "output": output,@@ -25,90 +59,54 @@ fn verb_doc(name: &str, summary: &str, input: Value, output: Value, timeout: u64 fn describe() -> Value { let board = json!({"success": "bool", "data": "object - the agent's payload", "roundtripMs": "int"});- json!({"success": true, "bridge": "altium", "engine": "rust", "verbs": [+ let shot = json!({"success": "bool", "shot": "{image: base64 png, width, height}", "windowEtiquette": "background"});+ json!({"success": true, "bridge": "altium", "engine": "rust",+ "facts": ["one script at a time; a modal freezes everything",+ "never foreground Altium; proofs are captured in the background",+ "one On-Demand seat serves every Altium app on the machine; killing Altium strands it",+ "instance:<pid|key|word> targets one of several running Altiums"],+ "verbs": [ verb_doc("status", "Is Altium running, licensed, and is the resident agent alive?", json!({}), json!({"running": "bool", "licensed": "bool", "licenceState": "string", "agentAlive": "bool"}), 15, "Cheapest health call - answers from ab's window list plus the agent heartbeat; never touches the script engine.",- &["readiness", "agent_status"], &["'running' can be true while 'licensed' is false - a trial lease can be stranded by a crash."], json!({})),- verb_doc("agent_status", "Heartbeat age and workspace of the resident DelphiScript agent.", json!({}),- json!({"alive": "bool", "responsive": "bool", "workspace": "string"}), 15,- "alive=false does NOT mean the script engine is free - a halted script still occupies it.",- &["status"], &["Never fire a script lane just because alive=false; that raises 'Another script executing now' on the user's screen."], json!({})),+ &["readiness", "agent_status", "instances"], &["'running' can be true while 'licensed' is false - a trial lease can be stranded by a crash."], json!({})),+ verb_doc("instances", "Every running Altium (Designer + Designer Develop can run together).", json!({}),+ json!({"count": "int", "instances": "[{pid, key, edition, licensed, agentAlive, ...}]"}), 15,+ "With several running, other verbs REFUSE without instance:<pid|key|word> - guessing means acting inside another AI thread's Altium.",+ &["status"], &["Develop writes NO licence tail when it HOLDS the seat - blank licenceState there means licensed."], json!({})), verb_doc("eval", "Run DelphiScript STATEMENTS inside Altium (Begin ... End;).", json!({"text": "string - statements only, no Var block", "timeout": "int seconds"}), json!({"success": "bool", "note": "string"}), 30, "Single quotes only; no Var block. Capture output by writing a file, not by returning a value.", &["run_command"], &["An undeclared identifier HALTS the engine with a modal that Try/Except cannot catch.", "ShowMessage blocks the loop behind a modal - never use it."], json!({"text": "Begin PCBServer.GetCurrentPCBBoard.ViewManager_FullUpdate; End;"})), verb_doc("run_command", "Run an Altium process (Server:Command) through the agent.", json!({"command": "string - e.g. PCB:Zoom", "params": "string or object"}),- json!({"success": "bool"}), 60, "Params are pipe-joined key=value pairs.", &["eval"], &["Some processes open a modal dialog and block the loop until it is answered."], json!({"command": "PCB:Zoom", "params": {"Action": "All"}})),- verb_doc("open_document", "Open a document and make it current.", json!({"path": "string - absolute .PcbDoc/.SchDoc path"}),- json!({"success": "bool"}), 60, "Board verbs need a .PcbDoc current; schematic iteration needs a LEAF sheet.", &["list_designators", "schematic_data"], &["A hierarchical parent sheet holds only sheet symbols - iterate its children instead."], json!({"path": "C:\\pcb\\Bluetooth_Sentinel.PcbDoc"})),- verb_doc("list_designators", "Every component designator on the current board.", json!({}), board.clone(), 90, "The cheapest board read - use it to confirm the right .PcbDoc is current.", &["component_data", "component_pins"], &["Needs a .PcbDoc current, not a schematic."], json!({})),- verb_doc("component_data", "Designator, comment, footprint, layer and XY for components.", json!({"designator": "string - optional filter", "limit": "int (default 500)"}), board.clone(), 90, "Omit 'designator' for the whole board.", &["list_designators"], &[], json!({"designator": "U1"})),- verb_doc("component_pins", "Pads of one placed component with net names.", json!({"designator": "string - required"}), board.clone(), 90, "Pad-level view; for tracks/arcs/texts use footprint_primitives.", &["footprint_primitives"], &[], json!({"designator": "U1"})),- verb_doc("list_nets", "Every net with its pad count.", json!({}), board.clone(), 90, "Tallied in the agent; sorted by name.", &["net_connections"], &[], json!({})),- verb_doc("net_connections", "Per-net pad list and bounding span.", json!({"net": "string - optional filter", "limit": "int (default 4000)"}), board.clone(), 120, "Span is minx|miny|maxx|maxy in board units.", &["list_nets"], &["A whole-board read on a dense board can exceed the default timeout - filter by net."], json!({"net": "GND"})),- verb_doc("layers", "All layers with visibility state.", json!({}), board.clone(), 90, "Includes non-copper layers; copper_layers is the copper-only view.", &["copper_layers", "set_layer_visibility"], &[], json!({})),- verb_doc("copper_layers", "Copper layers only (top, mid 1-30, planes 1-16, bottom).", json!({}), board.clone(), 90, "Use the returned layerId with set_layer_visibility.", &["layers"], &[], json!({})),- verb_doc("read_rules", "Design rules: kind, priority, scopes, enabled state.", json!({}), board.clone(), 90, "Read-only; rule authoring is not in this surface yet.", &["layers"], &[], json!({})),- verb_doc("set_layer_visibility", "Show or hide one layer and refresh the view.", json!({"layerId": "string - from altium_layers", "visible": "bool"}), board.clone(), 60, "Refreshes the layer tabs and the board view.", &["layers", "copper_layers"], &["Changes what the USER sees - restore visibility when done."], json!({"layerId": "eTopLayer", "visible": true})),- verb_doc("footprint_primitives", "Tracks, arcs, pads and texts of one placed component.", json!({"designator": "string - required"}), board.clone(), 120, "The geometry behind a placed footprint.", &["component_pins"], &[], json!({"designator": "U1"})),- verb_doc("schematic_data", "Components and pins of the current schematic sheet.", json!({"designator": "string - optional", "limit": "int (default 500)", "pins": "bool (default true)"}), board, 120, "Needs a LEAF sheet current.", &["open_document"], &["A hierarchical parent sheet returns sheet symbols, not parts."], json!({})),+ json!({"success": "bool"}), 60, "Params are pipe-joined key=value pairs. altium_list_commands searches the catalog.", &["eval", "list_commands"], &["Some processes open a modal dialog and block the loop until it is answered."], json!({"command": "PCB:Zoom", "params": {"Action": "All"}})),+ verb_doc("read_board", "ONE round trip: components, nets, layers, copper, rules, occupancy, outline.", json!({"sections": "string csv - optional", "limit": "int"}), board.clone(), 120,+ "The primitive every board view derives from; board_summary / rules_by_kind / layers_in_use are shaped from the same read.",+ &["board_summary", "review_board"], &["Needs a .PcbDoc current."], json!({})),+ verb_doc("review_board", "Design-review checks (14) over one board read; schematic checks with includeSchematic.", json!({"board": "path - optional", "sheet": "path - optional", "includeSchematic": "bool", "checks": "[string] - optional filter"}),+ json!({"findings": "[{check, severity, message, ...}]", "counts": "{}", "skipped": "{}", "notImplemented": "{}"}), 120,+ "PARTIAL results by design: skipped says which checks had no data; a clean result means the checks that RAN found nothing.",+ &["read_board", "cross_check"], &["Altium holds ONE current document - the review activates board and sheet in turn."], json!({"includeSchematic": true})),+ verb_doc("import_part", "One call imports a whole part: footprint spec + STEP + symbol modes + models + params + save.", json!({"schLib": "path", "pcbLib": "path", "footprintSpec": "path", "symbolSpec": "path", "step": "path", "params": "{}", "comment": "string", "description": "string"}),+ json!({"symbol": "string", "footprint": "string", "steps": "[]"}), 300,+ "Check the step counts: models:0 fails the call - a symbol with no footprint model places with nothing attached.",+ &["read_symbol", "show_symbol"], &["add_models APPENDS on every call (not idempotent) - repair is remove_symbol + re-import.", "The first part into a NEW library can silently skip models+params if the bridge restarted mid-flight - verify with read_symbol."], json!({})),+ verb_doc("show_symbol", "Open a SchLib member in the Symbol editor and capture a background proof.", json!({"libraryPath": "path", "symbolName": "string"}), shot.clone(), 60,+ "Never foregrounds: a minimized Altium is restored INTO THE BACKGROUND and the user's window handed straight back.",+ &["show_footprint", "show_3d_chip", "show_library"], &["A capture under 400px wide is refused as proof_unavailable - a title-bar sliver is not evidence."], json!({})),+ verb_doc("license", "Drive the License Management page (UIA first - no cursor, no foreground).", json!({"action": "show|refresh|use_license|start_trial|sign_out"}), shot, 60,+ "One On-Demand seat serves EVERY Altium app on this machine. use_license claims a free seat.",+ &["ensure_licensed", "license_status", "close"], &["NEVER kill an Altium holding the seat: the lease strands, the page shows Seats 0/1 while every claim fails. altium_close releases it."], json!({"action": "use_license"})),+ verb_doc("close", "Polite close (releases the licence seat); force needs acceptSeatLoss.", json!({"force": "bool", "acceptSeatLoss": "bool"}), json!({"closedInSec": "float"}), 30,+ "A save prompt on close NAMES the documents - read it; on a shared machine one may belong to another AI thread. Never defaulted.",+ &["license", "launch"], &["force:true without acceptSeatLoss is refused: a kill strands the On-Demand seat."], json!({})), verb_doc("describe", "This list - the bridge's verbs and schemas for ab's Verbs tab.", json!({}), json!({"verbs": "[{name, summary, input, output, ...}]"}), 5, "Static; safe to call any time.", &["status"], &[], json!({})),- verb_doc("uninstall", "Uninstall cascade - ab calls this first during bridge_uninstall.", json!({"cascade": "bool - set by ab"}), json!({"success": "bool", "output": "string"}), 30,- "Stops the resident agent and removes the agent workspace; leaves the user's design files alone.",- &["agent_status"], &["Never deletes anything outside the bridge cache."], json!({"cascade": true})),- ]})-}--/// ab calls this FIRST on bridge_uninstall so the bridge cleans up its own artifacts.-/// Only the agent workspace inside the bridge cache is touched - never a user design file.-fn uninstall() -> Value {- let ws = agent::workspace_dir();- let mut removed: Vec<String> = Vec::new();- let mut note = String::new();- if agent::is_alive() {- // ask the loop to exit on its own; never fire a script lane during uninstall- if std::fs::write(ws.join("stop"), b"1").is_ok() {- note.push_str("stop file dropped for the resident agent; ");- }- std::thread::sleep(Duration::from_millis(1500));- }- if let Ok(entries) = std::fs::read_dir(&ws) {- for e in entries.flatten() {- let name = e.file_name().to_string_lossy().to_string();- let disposable = name.starts_with("response_") || name.starts_with("AdomAgent_")- || matches!(name.as_str(), "hb.txt" | "request.txt" | "request.payload" | "agent.json" | "stop" | "stage.txt");- if disposable && std::fs::remove_file(e.path()).is_ok() {- removed.push(name);- }- }- }- json!({"success": true, "removed": removed.len(), "workspace": ws.to_string_lossy(),- "output": format!("{note}altium-bridge cleaned {} agent workspace files. Altium itself and all design files are untouched.", removed.len())})-}--pub fn names() -> Vec<String> {- KNOWN.iter().map(|v| format!("altium_{v}")).collect()-}--fn s(args: &Value, k: &str) -> Option<String> {- args.get(k).map(|v| match v {- Value::String(x) => x.clone(),- Value::Bool(b) => if *b { "1".into() } else { "0".into() },- other => other.to_string(),- })+ ],+ "allVerbs": names()}) } fn need_licensed() -> Option<Value> {- let lic = health::licence_state();- if !lic.get("running").and_then(|b| b.as_bool()).unwrap_or(false) {- return Some(json!({"success": false, "errorCode": "altium_not_running", "_hint": "altium_launch first."}));- }- if lic.get("licensed").and_then(|b| b.as_bool()) == Some(false) {- return Some(json!({"success": false, "errorCode": "not_licensed", "licenceState": lic.get("state").cloned(),- "_hint": "Altium refuses scripts without a licence. altium_license '{}'."}));- }- None+ support::need_licensed() } fn board_read(cmd: &str, params: Vec<(&str, String)>, timeout: u64) -> Value {@@ -123,29 +121,133 @@ fn board_read(cmd: &str, params: Vec<(&str, String)>, timeout: u64) -> Value { r } +/// The dispatch chokepoint — instance selection, taskbar badge/progress and the run log wrap+/// EVERY verb here (one wrapper, not per-verb calls someone forgets), exactly like the Python+/// bridge's `dispatch_command`.+pub fn handle(command: &str, args: &Value, forwarded: &[(String, String)]) -> Value {+ let cmd = command.strip_prefix("altium_").unwrap_or(command);+ // Verbs that describe the machine rather than drive ONE Altium are exempt from instance+ // resolution — `instances` itself must work when several run, which is exactly when+ // resolution refuses.+ let instance_exempt = matches!(cmd, "instances" | "describe" | "diagnostics" | "progress" | "state" | "uninstall" | "readiness"+ | "a365_configure" | "a365_oauth_begin" | "a365_oauth_status" | "a365_oauth_cancel"+ | "a365_refresh" | "a365_logout" | "a365_status" | "a365_query" | "a365_files_upload"+ | "a365_library_overview" | "a365_library_schema");+ if instance_exempt {+ crate::instance::select(None);+ } else {+ match crate::instance::resolve(args.get("instance")) {+ Ok(inst) => crate::instance::select(Some(inst)),+ Err(refusal) => {+ let code = refusal.get("errorCode").and_then(|c| c.as_str()).unwrap_or("");+ // not-running stays a per-verb concern (many verbs answer usefully without+ // Altium); AMBIGUITY must stop here, or one thread's verb lands in another+ // thread's Altium.+ if matches!(code, "ambiguous_instance" | "instance_not_found") {+ return refusal;+ }+ crate::instance::select(None);+ }+ }+ }+ let driving = !matches!(cmd, "readiness" | "status" | "describe" | "diagnostics" | "window_info"+ | "check_dialogs" | "progress" | "agent_status" | "list_commands" | "state" | "instances" | "dialog_log")+ && !cmd.starts_with("a365_");+ let mut bar_started = false;+ if driving {+ taskbar::set_driving_badge();+ taskbar::note_driving(); // feeds the 5-min idle clock that auto-clears the overlay+ let (est, conf) = progress::estimate(cmd, "warm");+ if est >= 3.0 {+ taskbar::start_activity_progress(cmd, Some(est), &conf);+ bar_started = true;+ }+ }+ let t0 = std::time::Instant::now();+ let out = dispatch(command, args, forwarded);+ let ok = out.get("success").and_then(|b| b.as_bool()).unwrap_or(true);+ progress::log_run(cmd, t0.elapsed().as_secs_f64(), ok);+ if bar_started {+ taskbar::end_activity_progress(cmd, !ok);+ }+ out+}+ pub fn dispatch(command: &str, args: &Value, _forwarded: &[(String, String)]) -> Value { let cmd = command.strip_prefix("altium_").unwrap_or(command); let timeout = args.get("timeout").and_then(|t| t.as_u64()).unwrap_or(60); match cmd {- "status" | "readiness" => {- let lic = health::licence_state();- json!({"success": true, "running": lic.get("running"), "licensed": lic.get("licensed"),- "licenceState": lic.get("state"), "agentAlive": agent::is_alive(), "engine": "rust"})+ // ---- health / lifecycle ----+ "readiness" => misc::readiness(args),+ "status" => misc::status(args),+ "diagnostics" => misc::diagnostics(args),+ "window_info" => surfaces::window_info(args),+ "state" => misc::state(args),+ "instances" => misc::instances_verb(args),+ "dialog_log" => misc::dialog_log(args),+ "launch" => misc::launch(args),+ "close" => misc::close(args),+ "uninstall" => misc::uninstall_verb(args),+ "describe" => describe(),+ "check_dialogs" => {+ let mut d = dialogs::scan();+ if args.get("screenshots").and_then(|b| b.as_bool()).unwrap_or(true) {+ crate::shot::attach_dialog_shots(&mut d);+ }+ json!({"success": true, "dialogsDetected": d.len(), "dialogs": d,+ "_hint": if d.is_empty() { "No Altium dialogs open." } else {+ "ANALYZE before acting: category/safeAction are suggestions; a save prompt is never auto-answered." }})+ }+ "dismiss_dialogs" => {+ let button = s(args, "button").unwrap_or_else(|| "OK".into());+ let titles: Option<Vec<String>> = args.get("titles").and_then(|a| a.as_array())+ .map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect());+ dialogs::dismiss(&button, titles.as_deref())+ }+ // ---- agent / REPL ----+ "agent_start" => {+ if let Some(g) = need_licensed() { return g; }+ let t0 = support::now();+ let r = lifecycle::start(Duration::from_secs(args.get("waitSec").and_then(|v| v.as_u64()).unwrap_or(45)));+ progress::record("agent_start", support::now() - t0, "warm");+ let mut r = r;+ if r.get("success").and_then(|b| b.as_bool()) == Some(true) {+ r["_hint"] = json!("Agent resident. altium_eval / altium_run_command / altium_state now go through it (~ms). Do NOT use altium_run_script while it is up.");+ }+ r+ }+ "agent_stop" => lifecycle::stop(),+ "agent_status" => misc::agent_status(args),+ "agent_recover" => {+ agent::call_no_autostart("tx_clear", &[], Duration::from_secs(30));+ let why = s(args, "reason").unwrap_or_else(|| "requested".into());+ lifecycle::recover(&why) }- "agent_status" => json!({"success": true, "alive": agent::is_alive(), "responsive": agent::is_alive(),- "workspace": agent::workspace_dir().to_string_lossy(), "engine": "rust"}), "eval" => { if let Some(g) = need_licensed() { return g; }- match s(args, "text").or_else(|| s(args, "code")) {- None => json!({"success": false, "error": "text is required (DelphiScript STATEMENTS: Begin ... End;)"}),- Some(t) => agent::call("eval", &[], Some(&t), Duration::from_secs(timeout.max(30))),+ let text = match s(args, "text").or_else(|| s(args, "code")) {+ Some(t) => t,+ None => return json!({"success": false, "error": "text is required (DelphiScript STATEMENTS: Begin ... End;)"}),+ };+ if !agent::is_alive() {+ // not resident → the -R RunScriptText lane is available and simplest+ let mut r = lifecycle::run_script_text_lane(&text, Duration::from_secs(timeout.max(30)));+ r["lane"] = json!("cli_run_script_text");+ if r.get("_hint").is_none() {+ r["_hint"] = json!("Ran via X2 -RScriptingSystem:RunScriptText (statements only). Errors surface as an 'Error'/'X2' modal — altium_check_dialogs.");+ }+ return r; }+ let mut r = agent::call("eval", &[], Some(&text), Duration::from_secs(timeout.max(30)));+ r["lane"] = json!("agent");+ r } "run_command" => { if let Some(g) = need_licensed() { return g; } let c = s(args, "command").or_else(|| s(args, "process")).unwrap_or_default(); if !c.contains(':') {- return json!({"success": false, "error": "command must be Server:Command, e.g. PCB:Zoom"});+ return json!({"success": false, "error": "command must be Server:Command, e.g. PCB:Zoom",+ "_hint": "altium_list_commands {query:'zoom'}"}); } let params = match args.get("params") { Some(Value::Object(m)) => m.iter().map(|(k, v)| format!("{k}={}", v.as_str().map(|x| x.to_string()).unwrap_or(v.to_string()))).collect::<Vec<_>>().join("|"),@@ -154,71 +256,15 @@ pub fn dispatch(command: &str, args: &Value, _forwarded: &[(String, String)]) -> }; agent::call("run_process", &[("process", c), ("params", params)], None, Duration::from_secs(timeout)) }- "open_document" => {- if let Some(g) = need_licensed() { return g; }- match s(args, "path").or_else(|| s(args, "filePath")) {- None => json!({"success": false, "error": "path is required"}),- Some(p) => agent::call("open", &[("path", p)], None, Duration::from_secs(timeout)),- }- }- "list_designators" => board_read("list_designators", vec![], 90),- "component_data" => board_read("component_data", vec![("designator", s(args, "designator").unwrap_or_default()), ("limit", s(args, "limit").unwrap_or("500".into()))], 90),- "component_pins" => match s(args, "designator") {- None => json!({"success": false, "error": "designator is required (e.g. U1)"}),- Some(d) => board_read("component_pins", vec![("designator", d)], 90),- },- "list_nets" => board_read("list_nets", vec![], 90),- "net_connections" => board_read("net_connections", vec![("net", s(args, "net").unwrap_or_default()), ("limit", s(args, "limit").unwrap_or("4000".into()))], 120),- "layers" => board_read("layers", vec![], 90),- "copper_layers" => board_read("copper_layers", vec![], 90),- "read_rules" => board_read("read_rules", vec![], 90),- "set_layer_visibility" => match s(args, "layerId") {- None => json!({"success": false, "error": "layerId is required (from altium_layers)"}),- Some(l) => {- let shown = args.get("displayed").and_then(|b| b.as_bool()).unwrap_or(true);- board_read("set_layer_visibility", vec![("layerId", l), ("displayed", if shown { "1".into() } else { "0".into() })], 60)- }- },- "footprint_primitives" => match s(args, "designator") {- None => json!({"success": false, "error": "designator is required (a placed component, e.g. U1)"}),- Some(d) => board_read("footprint_primitives", vec![("designator", d)], 120),- },- "describe" => describe(),- "uninstall" => uninstall(),- "schematic_data" => {- let pins = if args.get("pins").and_then(|b| b.as_bool()) == Some(false) { "0" } else { "1" };- board_read("schematic_data", vec![("designator", s(args, "designator").unwrap_or_default()), ("limit", s(args, "limit").unwrap_or("500".into())), ("pins", pins.into())], 120)- }- // ---- lifecycle (ported 2026-08-31; every desktop action goes through ab) ----- "agent_start" => {- if let Some(g) = need_licensed() { return g; }- lifecycle::start(Duration::from_secs(timeout.max(45)))- }- "agent_stop" => lifecycle::stop(),- "agent_recover" => {- let why = s(args, "reason").unwrap_or_else(|| "caller asked".into());- lifecycle::recover(&why)- }- "launch" => lifecycle::launch(Duration::from_secs(timeout.max(180))),- "close" => lifecycle::close(- args.get("force").and_then(|b| b.as_bool()).unwrap_or(false),- Duration::from_secs(timeout.max(20)),- ),- "check_dialogs" => {- let d = dialogs::scan();- json!({"success": true, "dialogsDetected": d.len(), "dialogs": d,- "_hint": if d.is_empty() { "clean" } else { "a modal FREEZES the resident loop — clear it before blaming the agent" }})- }- "dismiss_dialogs" => {- let button = s(args, "button").unwrap_or_else(|| "OK".into());- let titles: Option<Vec<String>> = args.get("titles").and_then(|a| a.as_array())- .map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect());- dialogs::dismiss(&button, titles.as_deref())- }+ "run_script" => outputs::run_script(args),+ "list_commands" => misc::list_commands(args),+ "open_document" | "open_project" => surfaces::open_document(args),+ "activate_document" => surfaces::activate_document(args),+ "progress" => misc::progress_verb(args), // ---- board-read primitive: ONE agent round trip, views shaped here ---- "read_board" | "board_summary" | "rules_by_kind" | "layers_in_use" => { if let Some(g) = need_licensed() { return g; }- let sections = s(args, "sections").unwrap_or_else(|| "components,nets,layers,copper,rules,occupancy".into());+ let sections = s(args, "sections").unwrap_or_else(|| "components,nets,layers,copper,rules,occupancy,outline".into()); let r = agent::call("read_board", &[("sections", sections), ("limit", s(args, "limit").unwrap_or("2000".into()))], None, Duration::from_secs(timeout.max(120))); let data = r.get("data").cloned().unwrap_or(Value::Null);@@ -230,8 +276,7 @@ pub fn dispatch(command: &str, args: &Value, _forwarded: &[(String, String)]) -> return json!({"success": true, "raw": data, "roundtripMs": r.get("roundtripMs")}); } let d = board::BoardData::new(data);- // NB: match `cmd` (prefix-stripped), not `command` — "altium_board_summary" would- // otherwise fall through to the raw-read arm.+ // NB: match `cmd` (prefix-stripped), not `command`. match cmd { "board_summary" => json!({"success": true, "summary": d.summary(), "roundtripMs": r.get("roundtripMs"), "_hint": "One agent round trip; every view below is derived in the bridge."}),@@ -246,10 +291,161 @@ pub fn dispatch(command: &str, args: &Value, _forwarded: &[(String, String)]) -> "designators": d.designators(), "nets": d.nets(), "layers": d.layers(), "copperLayers": d.copper_layers(), "rules": d.rules(), "roundtripMs": r.get("roundtripMs"),- "_hint": "The raw read behind every introspection view — record it as a test fixture with `> fixtures/read_board.json`."})+ "_hint": "The raw read behind every introspection view — record it as a test fixture with `raw:true`."}) } }+ "read_schematic" => {+ if let Some(g) = need_licensed() { return g; }+ let params = if args.get("params").and_then(|b| b.as_bool()) == Some(false) { "0" } else { "1" };+ let r = agent::call("read_schematic", &[("sections", s(args, "sections").unwrap_or("components".into())),+ ("params", params.into()),+ ("limit", s(args, "limit").unwrap_or("500".into()))],+ None, Duration::from_secs(timeout.max(90)));+ let data = r.get("data").cloned().unwrap_or(Value::Null);+ if data.get("ok").and_then(|b| b.as_bool()) != Some(true) { return r; }+ let sch = board::SchematicData::new(data);+ json!({"success": true, "document": sch.document(), "count": sch.components().len(),+ "designators": sch.designators(), "components": sch.components(),+ "roundtripMs": r.get("roundtripMs"),+ "_hint": "The CURRENT sheet only — a hierarchical parent holds sheet symbols, not parts."})+ }+ "cross_check" => cross_check(args),+ "review_board" => review::review_board(args),+ // ---- direct board reads ----+ "list_designators" => board_read("list_designators", vec![], 90),+ "component_data" => board_read("component_data", vec![("designator", s(args, "designator").unwrap_or_default()), ("limit", s(args, "limit").unwrap_or("500".into()))], 90),+ "component_pins" => match s(args, "designator") {+ None => json!({"success": false, "error": "designator is required (e.g. U1)"}),+ Some(d) => board_read("component_pins", vec![("designator", d)], 90),+ },+ "list_nets" => board_read("list_nets", vec![], 90),+ "net_connections" => board_read("net_connections", vec![("net", s(args, "net").unwrap_or_default()), ("limit", s(args, "limit").unwrap_or("4000".into()))], 120),+ "layers" => board_read("layers", vec![], 90),+ "copper_layers" => board_read("copper_layers", vec![], 90),+ "read_rules" => board_read("read_rules", vec![], 90),+ "set_layer_visibility" => match s(args, "layerId") {+ None => json!({"success": false, "error": "layerId is required (from altium_layers)"}),+ Some(l) => {+ let shown = args.get("displayed").and_then(|b| b.as_bool()).unwrap_or(true);+ board_read("set_layer_visibility", vec![("layerId", l), ("displayed", if shown { "1".into() } else { "0".into() })], 60)+ }+ },+ "footprint_primitives" => match s(args, "designator") {+ None => json!({"success": false, "error": "designator is required (a placed component, e.g. U1)"}),+ Some(d) => board_read("footprint_primitives", vec![("designator", d)], 120),+ },+ "schematic_data" => {+ let pins = if args.get("pins").and_then(|b| b.as_bool()) == Some(false) { "0" } else { "1" };+ board_read("schematic_data", vec![("designator", s(args, "designator").unwrap_or_default()), ("limit", s(args, "limit").unwrap_or("500".into())), ("pins", pins.into())], 120)+ }+ // ---- surfaces ----+ "show_project" => surfaces::show_project(args),+ "show_schematic" => surfaces::show_schematic(args),+ "show_2d_board" => surfaces::show_2d_board(args),+ "show_3d_board" => surfaces::show_3d_board(args),+ "show_library" => surfaces::show_library(args),+ "show_symbol" => surfaces::show_symbol(args),+ "show_footprint" => surfaces::show_footprint(args),+ "show_3d_chip" => surfaces::show_3d_chip(args),+ "list_library" => surfaces::list_library(args),+ "demo" => surfaces::demo(args),+ "save" => surfaces::save(args),+ "board_shape" => surfaces::board_shape(args),+ "pcb_report" => surfaces::pcb_report(args),+ // ---- outputs / project ----+ "compile" | "run_erc" => outputs::compile(args),+ "run_drc" => outputs::run_drc(args),+ "run_outjob" => outputs::run_outjob(args),+ "export_gerber" => outputs::export_gerber(args),+ "export_bom" => outputs::export_bom(args),+ "export_step" => outputs::export_step(args),+ "test_board" => library::test_board(args),+ "import_kicad" => outputs::import_kicad(args),+ "install_extension" => outputs::install_extension(args),+ "extension_ping" => outputs::extension_ping(args),+ // ---- library authoring ----+ "create_footprint" => library::create_footprint(args),+ "create_symbol" => library::create_symbol(args),+ "build_footprint" => library::build_footprint(args),+ "build_symbol_modes" => library::build_symbol_modes(args),+ "add_body" => library::add_body(args),+ "add_models" => library::add_models(args),+ "normalize_bodies" => library::normalize_bodies(args),+ "set_params" => library::set_params(args),+ "set_visibility" => library::set_visibility(args),+ "read_symbol" => library::read_symbol(args),+ "clone_symbol" => library::clone_symbol(args),+ "rename_symbol" => library::rename_symbol(args),+ "rename_footprint" => library::rename_footprint(args),+ "remove_symbol" => library::remove_symbol(args),+ "remove_footprint" => library::remove_footprint(args),+ "copy_footprint" => library::copy_footprint(args),+ "xcopy_footprint" => library::xcopy_footprint(args),+ "swap_footprint" => library::swap_footprint(args),+ "symbol_mode" => library::symbol_mode(args),+ "cleanup_libs" => library::cleanup_libs(args),+ "install_library" => library::install_library(args),+ "install_library_bundle" => library::install_library_bundle(args),+ "compile_intlib" => library::compile_intlib(args),+ "extract_intlib" => library::extract_intlib(args),+ "place_on_sch" => library::place_on_sch(args),+ "place_on_pcb" => library::place_on_pcb(args),+ "import_part" => library::import_part(args),+ // ---- licence / account ----+ "license" => license::license(args),+ "license_status" => license::license_status(args),+ "ensure_licensed" => license::ensure_licensed(args),+ "setup_trial" => license::setup_trial(args),+ "signin" | "signin_status" => license::signin_status(args),+ "connect_workspace" => license::connect_workspace(args),+ // ---- Altium 365 ----+ "a365_configure" => a365::configure(args),+ "a365_oauth_begin" => a365::oauth_begin(args),+ "a365_oauth_status" => a365::oauth_status(args),+ "a365_oauth_cancel" => a365::oauth_cancel(args),+ "a365_refresh" => a365::refresh(args),+ "a365_logout" => a365::logout(args),+ "a365_status" => a365::status(args),+ "a365_query" => a365::query(args),+ "a365_files_upload" => a365::files_upload(args),+ "a365_library_overview" => a365::library_overview(args),+ "a365_library_schema" => a365::library_schema(args), _ => json!({"success": false, "error": format!("Unknown command: {command}"),- "_hint": format!("Rust bridge v0 serves: {}", names().join(", "))}),+ "_hint": format!("Valid: {}", names().join(", "))}), } }++/// Compare a schematic sheet against a board. Altium CANNOT hold both current at once —+/// `GetCurrentPCBBoard` follows the focused document — so this reads them in TURN: activate the+/// board, read it, activate the sheet, read it.+fn cross_check(args: &Value) -> Value {+ if let Some(g) = need_licensed() { return g; }+ let timeout = Duration::from_secs_f64(support::f_or(args, "timeout", 45.0));+ let read = |kind: &str, path: Option<String>| -> Result<Value, Value> {+ if let Some(p) = &path {+ if let Some(bad) = support::validate_openable(p) {+ return Err(bad);+ }+ let o = agent::call("open", &[("path", p.clone())], None, timeout);+ let d = o.get("data").cloned().unwrap_or(json!({}));+ if d.get("ok").and_then(|b| b.as_bool()) == Some(false) {+ return Err(json!({"success": false, "stage": kind, "error": d.get("error"),+ "_hint": format!("could not open {p}")}));+ }+ }+ let cmd = if kind == "board" { "read_board" } else { "read_schematic" };+ let r = agent::call(cmd, &[("sections", "components".to_string())], None, timeout);+ let d = r.get("data").cloned().unwrap_or(json!({}));+ if d.get("ok").and_then(|b| b.as_bool()) != Some(true) {+ return Err(json!({"success": false, "stage": kind, "error": d.get("error"),+ "_hint": format!("No current {}. Pass {kind}:<path>, or open one first — Altium can only hold ONE document current, so cross_check must switch between them.",+ if kind == "board" { "PCB board" } else { "schematic sheet" })}));+ }+ Ok(d)+ };+ let bd = match read("board", s(args, "board")) { Ok(d) => d, Err(e) => return e };+ let sd = match read("sheet", s(args, "sheet")) { Ok(d) => d, Err(e) => return e };+ let mut out = board::cross_check(&board::SchematicData::new(sd), &board::BoardData::new(bd));+ out["success"] = json!(true);+ out+}
package.json+2−2@@ -1,6 +1,6 @@ { "slug": "altium-bridge",- "version": "0.1.41",+ "version": "0.2.0", "type": "app", "description": "Skills for your container so your AI knows how to drive the Altium Designer bridge (altium_* verbs). The bridge runtime itself is the release zip; Adom Bridge loads that.", "tags": [@@ -115,4 +115,4 @@ "prompt": "generate gerbers and a BOM from my altium project" } ]-}+}
scripts/release.py+65−26@@ -2,62 +2,101 @@ """Build the RUNTIME release zip + manifest for altium-bridge (SDK payload rules: runtime-only, bridge.json at the zip ROOT, no media/docs). +The runtime is the RUST bridge (bridge-rs → altium-bridge.exe, spawn.kind "exe"): the zip carries+the exe, the DelphiScript agent template (the in-process lane — that one stays Pascal by nature),+the taskbar badge asset and the AdomBridge extension payload. No Python ships.+ python3 scripts/release.py # -> dist/adom-bridge-altium-v<ver>.zip + dist/adom-bridge-altium-manifest.json python3 scripts/release.py --publish # + adom-wiki release create/upload + POST manifest to the page -Versions live in ONE place: src/bridge.json (package.json for the skills pkg is bumped in lockstep).+Versions live in ONE place: src/bridge.json (package.json and bridge-rs/src/main.rs BRIDGE_VERSION+are checked against it, so a half-bumped release refuses to build). """-import hashlib, json, os, subprocess, sys, zipfile+import hashlib, json, os, re, subprocess, sys, zipfile from pathlib import Path ROOT = Path(__file__).resolve().parent.parent SRC = ROOT / "src"+RS = ROOT / "bridge-rs"+TARGET = "x86_64-pc-windows-gnu"+EXE = RS / "target" / TARGET / "release" / "altium-bridge.exe" DIST = ROOT / "dist" PAGE = "adom/altium-bridge" PAGE_SLUG = "altium-bridge" -INCLUDE = ["server.py", "bridge.json", "BRIDGE_VERSION", "caller_identity.py",- "handlers/__init__.py", "handlers/a365.py", "handlers/ad_client.py", "handlers/progress.py", "handlers/uia.py",- "handlers/png_stdlib.py", "handlers/altium_detect.py", "handlers/agent.py", "handlers/dialogs.py",- "handlers/rcs_catalog.py", "handlers/taskbar.py", "agent/AdomAgent.pas.tmpl", "agent/AdomAgent.PrjScr",- "resources/adom-overlay-32.png", "extension/Install-AdomBridge.ps1", "extension/REMOVE-AdomBridge.cmd",- "extension/AdomBridge/AdomBridge.dll", "extension/AdomBridge/AdomBridge.ins", "extension/AdomBridge/AdomBridge.rcs",- "extension/AdomBridge/Help/ServerDlls.txt"]-+# (zip path, source path). bridge.json at the ROOT; everything else beside the exe exactly where+# the exe looks for it (agent/, resources/, extension/ relative to current_exe).+INCLUDE = [+ ("bridge.json", SRC / "bridge.json"),+ ("BRIDGE_VERSION", SRC / "BRIDGE_VERSION"),+ ("altium-bridge.exe", EXE),+ ("agent/AdomAgent.pas.tmpl", SRC / "agent" / "AdomAgent.pas.tmpl"),+ ("agent/AdomAgent.PrjScr", SRC / "agent" / "AdomAgent.PrjScr"),+ ("resources/adom-overlay-32.png", SRC / "resources" / "adom-overlay-32.png"),+ ("extension/Install-AdomBridge.ps1", SRC / "extension" / "Install-AdomBridge.ps1"),+ ("extension/REMOVE-AdomBridge.cmd", SRC / "extension" / "REMOVE-AdomBridge.cmd"),+ ("extension/AdomBridge/AdomBridge.dll", SRC / "extension" / "AdomBridge" / "AdomBridge.dll"),+ ("extension/AdomBridge/AdomBridge.ins", SRC / "extension" / "AdomBridge" / "AdomBridge.ins"),+ ("extension/AdomBridge/AdomBridge.rcs", SRC / "extension" / "AdomBridge" / "AdomBridge.rcs"),+ ("extension/AdomBridge/Help/ServerDlls.txt", SRC / "extension" / "AdomBridge" / "Help" / "ServerDlls.txt"),+] -def lint_verbs(src_dir):+def lint_verbs(): """bridge.json's verbs[] is a PROMISE ab routes on. Drift breaks it in both directions: a declared verb with no handler answers "Unknown command" after ab has already routed it, and a working handler that is not declared is unreachable. Both were live on 2026-09-01 (3 phantom- verbs, 3 unreachable ones), so the release now refuses rather than shipping it again."""- import json as _json, re as _re- src = (src_dir / "server.py").read_text(encoding="utf-8", errors="replace")- keys = set(_re.findall(r'COMMAND_HANDLERS\["([a-z0-9_]+)"\]', src))- m = _re.search(r'COMMAND_HANDLERS\s*=\s*\{(.*?)\n\}', src, _re.S)- keys |= set(_re.findall(r'"([a-z0-9_]+)"\s*:\s*h_', m.group(1) if m else ""))- declared = {v[len("altium_"):] for v in _json.loads((src_dir / "bridge.json").read_text())["verbs"]}- phantom, unreachable = sorted(declared - keys), sorted(keys - declared)+ verbs, 3 unreachable ones), so the release refuses rather than shipping it again.+ The dispatch of record is bridge-rs/src/verbs.rs (KNOWN)."""+ rs = (RS / "src" / "verbs.rs").read_text(encoding="utf-8", errors="replace")+ m = re.search(r"pub const KNOWN: &\[&str\] = &\[(.*?)\];", rs, re.S)+ if not m:+ raise SystemExit("cannot find KNOWN in bridge-rs/src/verbs.rs")+ known = set(re.findall(r'"([a-z0-9_]+)"', m.group(1)))+ declared = {v[len("altium_"):] for v in json.loads((SRC / "bridge.json").read_text())["verbs"]}+ phantom, unreachable = sorted(declared - known), sorted(known - declared) if phantom or unreachable: raise SystemExit(- "bridge.json does not match server.py\n"+ "bridge.json does not match bridge-rs/src/verbs.rs\n" f" declared but NOT dispatchable (ab routes them, the bridge 404s): {phantom}\n" f" dispatchable but NOT declared (unreachable through ab): {unreachable}") return len(declared) +def lint_versions(ver):+ """One version, three stamps — refuse a half-bumped release."""+ stamps = {+ "src/BRIDGE_VERSION": (SRC / "BRIDGE_VERSION").read_text().strip(),+ "package.json": json.loads((ROOT / "package.json").read_text())["version"],+ }+ m = re.search(r'BRIDGE_VERSION: &str = "([^"]+)"', (RS / "src" / "main.rs").read_text())+ stamps["bridge-rs/src/main.rs"] = m.group(1) if m else "?"+ bad = {k: v for k, v in stamps.items() if v != ver}+ if bad:+ raise SystemExit(f"version drift against bridge.json {ver}: {bad}")+++def build_exe():+ """Cross-compile the Windows exe + run the test suite first (a red test never ships)."""+ subprocess.run(["cargo", "test", "--quiet"], cwd=RS, check=True)+ subprocess.run(["cargo", "build", "--release", "--target", TARGET], cwd=RS, check=True)+ if not EXE.exists():+ raise SystemExit(f"exe missing after build: {EXE}")++ def main():- n = lint_verbs(SRC)- print(f"verb lint ok: {n} verbs declared and dispatchable") ver = json.loads((SRC / "bridge.json").read_text())["version"]+ lint_versions(ver)+ n = lint_verbs()+ print(f"verb lint ok: {n} verbs declared and dispatchable (rust)")+ build_exe() DIST.mkdir(exist_ok=True) zname = f"adom-bridge-altium-v{ver}.zip" zpath = DIST / zname with zipfile.ZipFile(zpath, "w", zipfile.ZIP_DEFLATED) as z:- for rel in INCLUDE:- p = SRC / rel+ for rel, p in INCLUDE: if not p.exists():- sys.exit(f"missing {rel}")+ sys.exit(f"missing {p}") z.write(p, rel) sha = hashlib.sha256(zpath.read_bytes()).hexdigest() manifest = {@@ -78,7 +117,7 @@ def main(): if "--publish" in sys.argv: run = lambda *a: subprocess.run(a, check=True) run("adom-wiki", "release", "create", PAGE, ver, "--title", f"altium-bridge {ver}", "--changelog", "see CHANGELOG.md")- run("adom-wiki", "release", "upload", PAGE, ver, str(zpath), "--platform", "any")+ run("adom-wiki", "release", "upload", PAGE, ver, str(zpath), "--platform", "windows", "--arch", "x64") run("adom-wiki", "repo", "push", PAGE) # manifest + page files print("published")
src/BRIDGE_VERSION+1−1@@ -1 +1 @@-0.1.41+0.2.0
src/bridge.json+101−83@@ -2,119 +2,137 @@ "manifest_version": 1, "name": "altium", "displayName": "Altium Designer",- "version": "0.1.11",+ "version": "0.2.0", "description": "Drive Altium Designer from Adom: a resident DelphiScript agent (eval + the 1,100-command process launcher), the X2 -R command line, UIA over the VCL shell, dialogs by PID, screenshots of every Altium window, and Altium 365.", "homepage": "https://wiki.adom.inc/adom/altium-bridge", "docs": "https://wiki.adom.inc/adom/altium-bridge", "author": "Adom Inc.", "license": "MIT", "spawn": {- "kind": "python",- "entrypoint": "server.py",+ "kind": "exe",+ "entrypoint": "altium-bridge.exe", "port": 0, "persistent": true, "healthEndpoint": "/health", "stopMethod": "kill",- "killImageName": "python.exe"+ "killImageName": "altium-bridge.exe" }, "verbPrefixes": [ "altium_" ], "verbs": [- "altium_readiness",- "altium_status",+ "altium_a365_configure",+ "altium_a365_files_upload",+ "altium_a365_library_overview",+ "altium_a365_library_schema",+ "altium_a365_logout",+ "altium_a365_oauth_begin",+ "altium_a365_oauth_cancel",+ "altium_a365_oauth_status",+ "altium_a365_query",+ "altium_a365_refresh",+ "altium_a365_status",+ "altium_activate_document",+ "altium_add_body",+ "altium_add_models",+ "altium_agent_recover",+ "altium_agent_start",+ "altium_agent_status",+ "altium_agent_stop",+ "altium_board_shape",+ "altium_board_summary",+ "altium_build_footprint",+ "altium_build_symbol_modes",+ "altium_check_dialogs",+ "altium_cleanup_libs",+ "altium_clone_symbol",+ "altium_close",+ "altium_compile",+ "altium_compile_intlib",+ "altium_component_data",+ "altium_component_pins",+ "altium_connect_workspace",+ "altium_copper_layers",+ "altium_copy_footprint",+ "altium_create_footprint",+ "altium_create_symbol",+ "altium_cross_check",+ "altium_demo", "altium_describe",- "altium_state",- "altium_progress", "altium_diagnostics",- "altium_uninstall",- "altium_launch",- "altium_close",- "altium_window_info",- "altium_screenshot_all",- "altium_check_dialogs",+ "altium_dialog_log", "altium_dismiss_dialogs",- "altium_agent_start",- "altium_agent_stop",- "altium_agent_status",- "altium_agent_recover",+ "altium_ensure_licensed", "altium_eval",- "altium_run_command",- "altium_run_script",- "altium_list_commands",- "altium_open_project",- "altium_open_document",- "altium_close_document",- "altium_save",- "altium_compile",- "altium_show_symbol",- "altium_show_footprint",- "altium_show_3d_chip",- "altium_show_library",- "altium_show_schematic",- "altium_show_2d_board",- "altium_show_3d_board",- "altium_show_project",- "altium_run_outjob",- "altium_run_drc",- "altium_run_erc",+ "altium_export_bom", "altium_export_gerber", "altium_export_step",- "altium_export_pdf",- "altium_export_bom",- "altium_create_symbol",- "altium_create_footprint",- "altium_list_library",- "altium_signin",- "altium_license",- "altium_a365_status",- "altium_a365_query",- "altium_demo",- "altium_install_library",- "altium_compile_intlib",- "altium_extract_intlib",- "altium_set_params",- "altium_add_body",- "altium_install_extension", "altium_extension_ping",+ "altium_extract_intlib",+ "altium_footprint_primitives", "altium_import_kicad",- "altium_a365_configure",- "altium_signin_status",- "altium_connect_workspace",- "altium_build_footprint",- "altium_build_symbol_modes",- "altium_symbol_mode",- "altium_add_models",- "altium_normalize_bodies",- "altium_test_board",- "altium_board_shape",- "altium_swap_footprint",- "altium_pcb_report",- "altium_place_on_sch",- "altium_place_on_pcb",- "altium_remove_symbol",- "altium_clone_symbol",- "altium_rename_symbol",- "altium_read_symbol",- "altium_copy_footprint",- "altium_rename_footprint",- "altium_remove_footprint",- "altium_cleanup_libs",- "altium_set_visibility",- "altium_xcopy_footprint", "altium_import_part",+ "altium_install_extension",+ "altium_install_library", "altium_install_library_bundle",+ "altium_instances",+ "altium_launch",+ "altium_layers",+ "altium_layers_in_use",+ "altium_license",+ "altium_license_status",+ "altium_list_commands", "altium_list_designators",- "altium_component_data",- "altium_component_pins",+ "altium_list_library", "altium_list_nets",- "altium_layers", "altium_net_connections",- "altium_schematic_data",- "altium_copper_layers",+ "altium_normalize_bodies",+ "altium_open_document",+ "altium_open_project",+ "altium_pcb_report",+ "altium_place_on_pcb",+ "altium_place_on_sch",+ "altium_progress",+ "altium_read_board", "altium_read_rules",+ "altium_read_schematic",+ "altium_read_symbol",+ "altium_readiness",+ "altium_remove_footprint",+ "altium_remove_symbol",+ "altium_rename_footprint",+ "altium_rename_symbol",+ "altium_review_board",+ "altium_rules_by_kind",+ "altium_run_command",+ "altium_run_drc",+ "altium_run_erc",+ "altium_run_outjob",+ "altium_run_script",+ "altium_save",+ "altium_schematic_data", "altium_set_layer_visibility",- "altium_footprint_primitives"+ "altium_set_params",+ "altium_set_visibility",+ "altium_setup_trial",+ "altium_show_2d_board",+ "altium_show_3d_board",+ "altium_show_3d_chip",+ "altium_show_footprint",+ "altium_show_library",+ "altium_show_project",+ "altium_show_schematic",+ "altium_show_symbol",+ "altium_signin",+ "altium_signin_status",+ "altium_state",+ "altium_status",+ "altium_swap_footprint",+ "altium_symbol_mode",+ "altium_test_board",+ "altium_uninstall",+ "altium_window_info",+ "altium_xcopy_footprint" ], "statusVerb": "altium_status", "uninstall": "altium_uninstall",