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
v0.1.13: UIA-first clicking (no cursor movement), altium_activate_document, altium-uia skill
9 files changed
+306−242
CHANGELOG.md+6@@ -1,5 +1,11 @@ # Changelog +## 0.1.13 — 2026-09-01+- **UIA first, cursor last.** John, watching the bridge drive his box: *"you keep turning on the cursor, but you could maybe use UIA to do those clicks"*. He was right — `altium_license` had been clicking hardcoded pixel coordinates since 0.1.x. Altium DOES expose a UIA tree, including the WebView2 License Management page (`automationId: UseLicenseButton`, invokable). The licence lane now invokes programmatically (no foreground, no cursor movement) and falls back to the image-space click only on a genuine UIA miss; the reply says which lane ran (`lane`, `windowEtiquette`).+- **New verb `altium_activate_document {name}`** — every open document is an invokable button in Altium's document bar (`GUIBinderView.TGUIDocBarButton`, accessible name = the tab caption). Making a .PcbDoc current no longer needs `open_document` (which raises Altium's window) or a pixel click on the tab. 95 verbs.+- New DEVELOPER skill **altium-uia**: the measured catalog of what AD26.9.1 exposes to UI Automation (dialogs, main menus, title bar, document bar, licence page) plus what it does NOT (the canvas — that is the agent's scripting API), the two costs (UIA searches over Altium's tree are slow; nothing reaches an app while a higher-integrity window such as a screensaver owns the foreground), and the honest list of what still legitimately foregrounds.+- Diagnosed the "Could not start Vault Explorer" warning users see on every launch: the extension is correctly registered (`ExtensionsRegistry.xml`) with a complete payload, and Altium's private .NET 8.0.15 runtime loads fine, so it is neither a broken registry nor a runtime mismatch. Investigation continues.+ ## 0.1.12 — 2026-08-31 - **14× faster agent round trip: 450–600 ms → 31–47 ms.** Root cause: `Break` inside a `Try..Except` in this script engine is implemented as an exception, so the `Except` arm swallowed it — every file write ran all 20 retry iterations (`Sleep(10)` each, ~220–310 ms) even though the FIRST write had already succeeded. Both write primitives now use a `Done` flag with a `While` guard instead. Verified across the whole template: no other `Break`-inside-`Try` site exists. - `hb.txt` keeps tmp+rename (readers hold it open, and a direct rewrite raced them into `EFCreateError`); `response_<id>.json` writes direct — one file op instead of save+delete+rename.
adom-bridge-altium-manifest.json+4−4@@ -1,9 +1,9 @@ { "name": "altium",- "version": "0.1.12",- "url": "https://wiki.adom.inc/download/adom/altium-bridge/0.1.12/adom-bridge-altium-v0.1.12.zip",- "sha256": "5f61c9096177d798b34f1bac8503577483be61c5ba216a8acdcaa1953dd29cc6",- "size": 104395,+ "version": "0.1.13",+ "url": "https://wiki.adom.inc/download/adom/altium-bridge/0.1.13/adom-bridge-altium-v0.1.13.zip",+ "sha256": "9a17fd1747a1986f15e4525f5235f302a05dcc7a2087d903c84995a4f60de10a",+ "size": 104721, "verbPrefixes": [ "altium_" ],
adom-bridge-altium-v0.1.13.zipadded⋯ 1 unchanged line ⋯
bridge.json+81−80@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "altium", "displayName": "Altium Designer",- "version": "0.1.12",+ "version": "0.1.13", "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",@@ -21,100 +21,101 @@ "altium_" ], "verbs": [- "altium_readiness",- "altium_status",- "altium_describe",- "altium_state",- "altium_progress",- "altium_diagnostics",- "altium_uninstall",- "altium_launch",- "altium_close",- "altium_window_info",- "altium_screenshot_all",- "altium_check_dialogs",- "altium_dismiss_dialogs",+ "altium_a365_configure",+ "altium_a365_query",+ "altium_a365_status",+ "altium_activate_document",+ "altium_add_body",+ "altium_add_models",+ "altium_agent_recover", "altium_agent_start",- "altium_agent_stop", "altium_agent_status",- "altium_agent_recover",- "altium_eval",- "altium_run_command",- "altium_run_script",- "altium_list_commands",- "altium_open_project",- "altium_open_document",+ "altium_agent_stop",+ "altium_board_shape",+ "altium_build_footprint",+ "altium_build_symbol_modes",+ "altium_check_dialogs",+ "altium_cleanup_libs",+ "altium_clone_symbol",+ "altium_close", "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_gerber",- "altium_export_step",- "altium_export_pdf",- "altium_export_bom",- "altium_create_symbol",+ "altium_compile_intlib",+ "altium_component_data",+ "altium_component_pins",+ "altium_connect_workspace",+ "altium_copper_layers",+ "altium_copy_footprint", "altium_create_footprint",- "altium_list_library",- "altium_signin",- "altium_license",- "altium_a365_status",- "altium_a365_query",+ "altium_create_symbol", "altium_demo",- "altium_install_library",- "altium_compile_intlib",- "altium_extract_intlib",- "altium_set_params",- "altium_add_body",- "altium_install_extension",+ "altium_describe",+ "altium_diagnostics",+ "altium_dismiss_dialogs",+ "altium_eval",+ "altium_export_bom",+ "altium_export_gerber",+ "altium_export_pdf",+ "altium_export_step", "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_launch",+ "altium_layers",+ "altium_license",+ "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_rules",+ "altium_read_symbol",+ "altium_readiness",+ "altium_remove_footprint",+ "altium_remove_symbol",+ "altium_rename_footprint",+ "altium_rename_symbol",+ "altium_run_command",+ "altium_run_drc",+ "altium_run_erc",+ "altium_run_outjob",+ "altium_run_script",+ "altium_save",+ "altium_schematic_data",+ "altium_screenshot_all", "altium_set_layer_visibility",- "altium_footprint_primitives"+ "altium_set_params",+ "altium_set_visibility",+ "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",
package.json+14−13@@ -1,6 +1,6 @@ { "slug": "altium-bridge",- "version": "0.1.12",+ "version": "0.1.13", "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": [@@ -23,24 +23,25 @@ "files": [ "README.md", "SKILL.md",- "package.json",- "install.sh",- "uninstall.sh",- "skills/altium-bridge-plan/SKILL.md",- "dockbar.json", "dashboard/altium_dashboard.py",- "dashboard/ui/index.html",- "dashboard/ui/newbie-panel.js", "dashboard/ui/altium-logo.png", "dashboard/ui/dock-altium.svg",- "skills/altium-part-import/SKILL.md",- "skills/altium-kicad-import/SKILL.md",- "skills/altium-install/SKILL.md",+ "dashboard/ui/index.html",+ "dashboard/ui/newbie-panel.js",+ "dockbar.json",+ "install.sh",+ "package.json",+ "skills/altium-boards/SKILL.md", "skills/altium-bridge-dev/SKILL.md",+ "skills/altium-bridge-plan/SKILL.md", "skills/altium-bridge-publish/SKILL.md",+ "skills/altium-install/SKILL.md",+ "skills/altium-kicad-import/SKILL.md", "skills/altium-libraries/SKILL.md",- "skills/altium-boards/SKILL.md",- "skills/altium-outputs/SKILL.md"+ "skills/altium-outputs/SKILL.md",+ "skills/altium-part-import/SKILL.md",+ "skills/altium-uia/SKILL.md",+ "uninstall.sh" ], "discovery_triggers": [ "open my design in altium",
skills/altium-uia/SKILL.mdadded+64@@ -0,0 +1,64 @@+---+name: altium-uia+user-invocable: false+description: >-+ DEVELOPER-only. Driving Altium Designer through Windows UI Automation (ab's desktop_ui_* verbs)+ instead of synthetic clicks, and the measured catalog of what AD26 actually exposes: dialogs,+ main menus, the document bar (every open document is an invokable button), and the WebView2+ License Management page. Read BEFORE adding any verb that touches an Altium window, and before+ reaching for desktop_click. Trigger words — altium uia, ui automation, desktop_ui_click,+ automation id, foreground, focus steal, cursor moved, click altium button, switch document tab,+ license management click, altium control catalog.+---++# altium-uia++Parent skill: [altium-bridge-dev](../altium-bridge-dev/SKILL.md).++**The rule: UIA first. `desktop_click` is the fallback, not the default.**++`desktop_click` is SendInput — it FOREGROUNDS the target and MOVES the user's pointer. `desktop_ui_click`+is a UIA Invoke: programmatic, no foreground, no focus steal, no cursor movement. John, 2026-09-01,+watching this bridge drive his box: *"you keep turning on the cursor, but you could maybe use UIA to+do those clicks"*. He was right — the licence lane had been clicking hardcoded pixels since 2026-08-29.++## What AD26.9.1 actually exposes (measured 2026-09-01, hwnd-scoped `desktop_ui_tree`)++| Surface | How to reach it | Notes |+|---|---|---|+| **Dialogs** — Error / Warning / Confirm / "Connect to Workspace" | `desktop_ui_click {hwnd, name: "OK"\|"Choose a license"\|"Continue", role: "button"}` | Plain VCL forms (`TXPBitBtn`). Reliable and fast. This is how the busy-notice reaper and `dismiss_dialogs` work. |+| **Main menus** | `MenuItem` by name: `File`, `View`, `Project`, `Window`, `Help` | All `invokable=true`. |+| **Title bar** | `Preferences...`, `Share`, `Buy Now`, `Place a Comment`, `Open Home page`, and the workspace item (named after the workspace, e.g. `Adom`) | MenuItems, invokable. |+| **Document bar — every open document** | automationId `GUIBinderView.TGUIDocBarButton`, accessible name = the tab caption (`Bluetooth_Sentinel.PcbDoc`, `(2) License Management`) | **Invoking one makes that document CURRENT.** This is the quiet way to satisfy "board verbs need a .PcbDoc current" — see `altium_activate_document`. Captions carry Altium's `(n) ` prefixes, so match on a substring first. |+| **License Management (WebView2)** | `automationId: UseLicenseButton`, `invokable: true`; others by name (`Refresh`, `Roam`, `Release`, `Get License`, `Sign Out`) | Chromium exposes its a11y tree, so the page DOM IS reachable. Verified live: a UIA invoke reclaimed a stranded On-Demand seat with zero cursor movement. |+| **Panels button** (bottom-right) | `GUIDocForm.TPanelsHolderXPButton`, name `Panels` | Invokable. |+| Canvas / PCB + schematic editing area | NOT exposed | Custom-drawn. Geometry work goes through the resident agent's scripting API, never through input. |++## Two costs, both paid for in this session++1. **UIA searches over Altium's tree are SLOW.** A scoped `desktop_find_control` on the main window+ took seconds, and a batch of eight speculative name lookups timed out at 4 minutes. Always scope+ with `hwnd`, cache automationIds in code (see `handlers/uia.py: IDS`), and never loop over names+ you are guessing at. Prefer a known automationId; fall back to ONE name search.+2. **Nothing reaches an app while a higher-integrity window owns the foreground.** A screensaver+ counts: with `adom-screensaver.scr` in front, both `desktop_click` and `desktop_ui_*` were+ refused with a UIPI error that explicitly says retrying will not help. Detect it+ (`desktop_get_foreground` → an unexpected pid), tell the user, and stop. Do NOT elevate ab.++## The pattern to copy++`handlers/uia.py::click_by_name_then_pixels(hwnd, name, pixel_click, automation_id=None)` — UIA+first, legacy image-space click only on a genuine miss, and the reply carries `lane: "uia" |+"image_click"` plus `windowEtiquette: "background" | "foreground"` so the caller can tell the user+whether their screen was touched. Every new verb that presses something should use it.++## What still legitimately foregrounds++Be honest about these in the verb's reply (`windowEtiquette: "foreground"`) rather than pretending:++* `altium_launch` — Altium's own splash raises itself. Unavoidable, and expected by the user.+* `altium_agent_start` — the `-R` bootstrap opens the `.PrjScr` in the Script editor, which+ ACTIVATES Altium. The bridge captures the user's foreground window first and hands it back, but+ the flash still happens. Restarting the agent repeatedly is therefore a visible nuisance: don't+ cycle it casually.+* Anything the canvas needs (there is no UIA there).
src/BRIDGE_VERSION+1−1@@ -1 +1 @@-0.1.12+0.1.13
src/handlers/uia.py+98−141@@ -1,144 +1,101 @@-"""uia.py — BACKGROUND UI Automation for Altium Designer, via .NET UIAutomation (PowerShell).--Why: opening the Footprint Editor / 3D viewer used to fall back to clicking a-toolbar icon (moves the user's MOUSE) or an Alt+3 keystroke (needs the window-FOREGROUNDED). Both hijack the desktop. UIA's Invoke pattern triggers a control-PROGRAMMATICALLY — no foreground, no focus steal, no cursor movement. Altium (VCL/DevExpress) is-wxWidgets but exposes an invokable UIA tree, so its toolbar buttons / menu items-are reachable by accessible name.--Self-contained: shells out to `powershell` (present on every Windows box) which-loads System.Windows.Automation. NOT Bridge's gated `run_script` — this is the bridge's-OWN subprocess (same as how it runs kicad-cli), so no shell-approval is involved.-UIA is a mature, stable API (unlike the thin/undocumented Win32 menu-ID path).+"""UI Automation helpers — the QUIET way to drive Altium.++Measured 2026-09-01 on AD26.9.1 (John's box), after John asked "you keep turning on the cursor,+but you could maybe use UIA to do those clicks":++Altium exposes a real UIA tree, and far more of it than this bridge was using:++* **Dialogs** (Error / Warning / Confirm / "Connect to Workspace") are plain VCL forms — their+ buttons are reachable by accessible NAME (`OK`, `Choose a license`, `Continue`).+* **The main menus and title bar** are MenuItems: `File`, `View`, `Project`, `Window`, `Help`,+ `Preferences...`, `Share`, `Buy Now`, `Open Home page`, and the workspace item (`Adom`).+* **Every open document is a button** in the document bar — automationId+ `GUIBinderView.TGUIDocBarButton`, accessible name = the tab caption+ ("Bluetooth_Sentinel.PcbDoc", "(2) License Management"). Invoking one makes that document+ CURRENT — no cursor, no foreground. This is the quiet way to satisfy the board verbs'+ "needs a .PcbDoc current" rule.+* **The WebView2 pages** (License Management) expose their DOM through the a11y tree: the+ "Use License" button is `automationId=UseLicenseButton`, `invokable=true`. The bridge used to+ click it at hardcoded pixel coordinates measured on a maximised window.++Why it matters (John's rule): `desktop_click` is SendInput — it needs the target FOREGROUNDED and+it moves the user's pointer. UIA Invoke is programmatic: no foreground, no focus steal, no cursor+movement. So: **UIA first, image-space click only for what UIA genuinely cannot reach.**++Two costs to know:+* A UIA search over Altium's tree is SLOW (seconds, occasionally >60 s under load). Scope every+ call with `hwnd`, keep timeouts generous, and never loop over speculative names.+* Synthetic input (and UIA) is refused outright while a higher-integrity window owns the+ foreground — a screensaver counts. The error names UIPI and says retrying will not help. """+from __future__ import annotations -import json-import subprocess-import sys--IS_WINDOWS = sys.platform == "win32"--# PowerShell that: attaches to a window by hwnd, finds a control by exact name or-# name-substring (optionally filtered by control type), and Invokes it (or, for-# SetValue, sets its text). Emits a one-line JSON result. All background.-_PS_TEMPLATE = r'''-$ErrorActionPreference = 'Stop'-try {- Add-Type -AssemblyName UIAutomationClient, UIAutomationTypes- $AE = [System.Windows.Automation.AutomationElement]- $TS = [System.Windows.Automation.TreeScope]- $root = $AE::FromHandle([IntPtr]__HWND__)- if ($root -eq $null) { '{"ok":false,"reason":"window_not_found"}'; exit }-- $name = __NAME__- $contains = __CONTAINS__- $action = '__ACTION__'- $text = __TEXT__-- $target = $null- if ($name) {- $cond = New-Object System.Windows.Automation.PropertyCondition($AE::NameProperty, $name)- $target = $root.FindFirst($TS::Descendants, $cond)- }- if ($target -eq $null -and $contains) {- # bounded FindAll, filter by name substring. For an invoke we must pick a control- # that ACTUALLY supports InvokePattern — KiCad exposes the label "Footprint Editor"- # AND the toolbar button with the same name; grabbing the label yields- # not_invokable. So when action=invoke, prefer an invokable match (fall back to- # first substring match only if none is invokable).- $all = $root.FindAll($TS::Descendants, [System.Windows.Automation.Condition]::TrueCondition)- $firstMatch = $null- foreach ($el in $all) {- $n = $el.Current.Name- if ($n -and $n.ToLower().Contains($contains.ToLower())) {- if ($firstMatch -eq $null) { $firstMatch = $el }- if ($action -eq 'invoke') {- $tmp = $null- if ($el.TryGetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern, [ref]$tmp)) { $target = $el; break }- } elseif ($action -eq 'setvalue') {- $tmp = $null- if ($el.TryGetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern, [ref]$tmp)) { $target = $el; break }- } else { $target = $el; break }- }- }- if ($target -eq $null) { $target = $firstMatch }- }- if ($target -eq $null) { '{"ok":false,"reason":"control_not_found"}'; exit }-- $foundName = $target.Current.Name- if ($action -eq 'invoke') {- $p = $null- if ($target.TryGetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern, [ref]$p)) {- $p.Invoke()- '{"ok":true,"action":"invoke","name":' + ($foundName | ConvertTo-Json) + '}'- } else {- # some KiCad menu items expose ExpandCollapse or are only Selectable- $sp = $null- if ($target.TryGetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern, [ref]$sp)) {- $sp.Select(); '{"ok":true,"action":"select","name":' + ($foundName | ConvertTo-Json) + '}'- } else { '{"ok":false,"reason":"not_invokable","name":' + ($foundName | ConvertTo-Json) + '}' }- }- } elseif ($action -eq 'setvalue') {- $vp = $null- if ($target.TryGetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern, [ref]$vp)) {- $vp.SetValue($text)- '{"ok":true,"action":"setvalue","name":' + ($foundName | ConvertTo-Json) + '}'- } else { '{"ok":false,"reason":"not_settable","name":' + ($foundName | ConvertTo-Json) + '}' }- } else {- '{"ok":true,"action":"find","name":' + ($foundName | ConvertTo-Json) + ',"found":true}'- }-} catch {- '{"ok":false,"reason":"exception","error":' + ($_.Exception.Message | ConvertTo-Json) + '}'+import caller_identity++# Known automationIds, so the common paths skip a name search entirely.+IDS = {+ "use_license": "UseLicenseButton", }-'''---def _ps_json(value) -> str:- """Render a Python value as a PowerShell literal (string or $null)."""- if value is None:- return "$null"- return json.dumps(str(value)) # JSON string == a valid PS single/double-quoted string---def _run(hwnd: int, action: str, name=None, contains=None, text=None, timeout: float = 8.0) -> dict:- if not IS_WINDOWS:- return {"ok": False, "reason": "not_windows"}- script = (_PS_TEMPLATE- .replace("__HWND__", str(int(hwnd)))- .replace("__NAME__", _ps_json(name))- .replace("__CONTAINS__", _ps_json(contains))- .replace("__ACTION__", action)- .replace("__TEXT__", _ps_json(text)))- try:- proc = subprocess.run(- ["powershell", "-NoProfile", "-NonInteractive", "-Command", script],- capture_output=True, text=True, timeout=timeout,- )- out = (proc.stdout or "").strip().splitlines()- for line in reversed(out): # last non-empty line is our JSON- line = line.strip()- if line.startswith("{"):- return json.loads(line)- return {"ok": False, "reason": "no_output", "stderr": (proc.stderr or "")[:200]}- except subprocess.TimeoutExpired:- return {"ok": False, "reason": "timeout"}- except Exception as e: # pylint: disable=broad-except- return {"ok": False, "reason": "run_failed", "error": str(e)[:200]}---def uia_invoke(hwnd: int, name: str = None, contains: str = None, timeout: float = 8.0) -> dict:- """Background-Invoke a control (toolbar button / menu item) in the given window,- matched by exact accessible name OR name substring. No foreground, no cursor.- Returns {ok, action, name} / {ok:False, reason}."""- return _run(hwnd, "invoke", name=name, contains=contains, timeout=timeout)---def uia_set_value(hwnd: int, text: str, name: str = None, contains: str = None, timeout: float = 8.0) -> dict:- """Background-SetValue on a text control (e.g. a search box) — no focus/typing."""- return _run(hwnd, "setvalue", name=name, contains=contains, text=text, timeout=timeout)---def uia_find(hwnd: int, name: str = None, contains: str = None, timeout: float = 6.0) -> dict:- """Read-only: is the control present in the UIA tree? {ok, found, name}."""- return _run(hwnd, "find", name=name, contains=contains, timeout=timeout)++DOC_TAB_ID = "GUIBinderView.TGUIDocBarButton"+++def click(hwnd: int, name: str | None = None, automation_id: str | None = None,+ role: str | None = None, timeout: int = 30) -> dict:+ """Invoke a control programmatically. Returns ab's reply plus `quiet: True` when it worked+ (quiet = the user's cursor never moved and their foreground window was not stolen)."""+ args: dict = {"hwnd": hwnd}+ if automation_id:+ args["automationId"] = automation_id+ if name:+ args["name"] = name+ if role:+ args["role"] = role+ r = caller_identity.ad_callback("desktop_ui_click", args, timeout=timeout) or {}+ if r.get("success"):+ r["quiet"] = True+ return r+++def find(hwnd: int, contains: str, role: str | None = None, timeout: int = 60) -> dict:+ args: dict = {"hwnd": hwnd, "contains": contains}+ if role:+ args["role"] = role+ return caller_identity.ad_callback("desktop_find_control", args, timeout=timeout) or {}+++def click_by_name_then_pixels(hwnd: int, name: str, pixel_click, automation_id: str | None = None,+ role: str = "button") -> dict:+ """UIA first (quiet), image-space click only if UIA cannot reach the control.++ `pixel_click` is a zero-arg callable performing the legacy click; it is invoked ONLY on a UIA+ miss, and the result is marked `windowEtiquette: "foreground"` so callers can tell the user+ their screen was touched.+ """+ r = click(hwnd, name=name, automation_id=automation_id or IDS.get(name.lower().replace(" ", "_")), role=role)+ if r.get("success"):+ r["windowEtiquette"] = "background"+ r["lane"] = "uia"+ return r+ out = pixel_click() or {}+ if isinstance(out, dict):+ out["lane"] = "image_click"+ out["windowEtiquette"] = "foreground"+ out["_uiaError"] = r.get("error") or r.get("_hint")+ return out+++def activate_document(hwnd: int, caption: str) -> dict:+ """Make an OPEN document current by invoking its tab in the document bar — no cursor, no+ foreground. `caption` is matched against the tab's accessible name; the exact caption carries+ Altium's "(2) " prefixes, so a substring search runs first."""+ hit = find(hwnd, caption)+ best = hit.get("best") or {}+ exact = best.get("name")+ r = click(hwnd, name=exact or caption, role="button")+ if r.get("success"):+ return {"success": True, "activated": exact or caption, "lane": "uia",+ "windowEtiquette": "background",+ "_hint": "The document bar tab was invoked programmatically — the user's cursor never moved."}+ return {"success": False, "error": r.get("error") or "no document tab matched",+ "candidate": exact, "_hint": f"altium_window_info lists open documents; the tab caption may carry a '(n) ' prefix."}
src/server.py+38−3@@ -24,6 +24,7 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import caller_identity # noqa: E402+from handlers import uia from handlers import ad_client, agent, altium_detect, dialogs, progress, rcs_catalog # noqa: E402 BRIDGE_VERSION = "0.1.0"@@ -1005,6 +1006,13 @@ def h_connect_workspace(args): LICENSE_PAGE_COORDS = { # image-space on a 1400x840 safe capture of a maximised main window (measured 2026-08-29) "refresh": (1288, 178), "use_license": (1252, 388), "start_trial": (788, 607), "sign_out": (386, 179)} +# The SAME actions by accessible name / automationId — the quiet lane (measured 2026-09-01).+# UIA needs no coordinates, so it survives a resized or non-maximised window, which the pixel+# table does not.+LICENSE_PAGE_NAMES = {"refresh": "Refresh", "use_license": "Use License",+ "start_trial": "Start Trial", "sign_out": "Sign Out"}+LICENSE_PAGE_IDS = {"use_license": "UseLicenseButton"}+ def h_license(args): """Drive the License Management page. It is a WebView2 — not in UIA — so actions are@@ -1026,9 +1034,16 @@ def h_license(args): out = {"success": True, "licenceState": altium_detect.licence_state().get("state"), "shot": shot, "windowEtiquette": "background"} if action in LICENSE_PAGE_COORDS and shot.get("shotId"): x, y = LICENSE_PAGE_COORDS[action]- r = caller_identity.ad_callback("desktop_click", {"space": "image", "shotId": shot["shotId"], "x": x, "y": y, "hwnd": lic["hwnd"]})- out["clicked"] = {"action": action, "x": x, "y": y, "result": r}- out["windowEtiquette"] = "foreground"+ # UIA FIRST (John, 2026-09-01: "you keep turning on the cursor"). The licence page is a+ # WebView2, but its DOM IS in the a11y tree — "Use License" is automationId+ # UseLicenseButton, invokable. A UIA invoke moves no cursor and steals no foreground;+ # the pixel click below is the fallback for anything UIA cannot reach.+ r = uia.click_by_name_then_pixels(+ lic["hwnd"], LICENSE_PAGE_NAMES.get(action, action),+ lambda: caller_identity.ad_callback("desktop_click", {"space": "image", "shotId": shot["shotId"], "x": x, "y": y, "hwnd": lic["hwnd"]}),+ automation_id=LICENSE_PAGE_IDS.get(action))+ out["clicked"] = {"action": action, "lane": r.get("lane"), "result": r}+ out["windowEtiquette"] = r.get("windowEtiquette", "foreground") time.sleep(2.0) out["licenceStateAfter"] = altium_detect.licence_state().get("state") out["shotAfter"] = caller_identity.ad_callback("desktop_screenshot_window", {"hwnd": lic["hwnd"]})@@ -1039,6 +1054,24 @@ def h_license(args): ++def h_activate_document(args):+ """Make an ALREADY-OPEN document current, quietly.++ The board verbs need a .PcbDoc current and the schematic verbs need a leaf sheet; until now the+ only lanes were altium_open_document (re-opens, and Altium raises its window) or a pixel click+ on the tab. Every open document is a UIA button in the document bar, so invoking it is+ programmatic: no cursor movement, no foreground steal (John, 2026-09-01)."""+ lic = altium_detect.licence_state()+ if not lic.get("running"):+ return {"success": False, "errorCode": "altium_not_running", "_hint": "altium_launch first."}+ name = args.get("name") or args.get("document") or args.get("caption")+ if not name:+ return {"success": False, "error": "name is required (a tab caption, e.g. 'Bluetooth_Sentinel.PcbDoc')",+ "_hint": "altium_window_info lists what is open."}+ return uia.activate_document(lic["hwnd"], name)++ # ---- licence lifecycle: status, ensure-licensed, trial setup (John 2026-08-31) ------------ def h_license_status(args):@@ -1604,6 +1637,7 @@ _VERB_CATALOG = { "dismiss_dialogs": "Press a dialog button in the background {hwnd, button}; safeOnly:true limits to catalogued safe defaults.", "launch": "Start X2.exe and wait for the main window (~60s cold).", "close": "WM_CLOSE the main window; reports a save prompt instead of answering it; force:true taskkills.",+ "activate_document": "Make an ALREADY-OPEN document current by invoking its document-bar tab through UIA — no cursor, no foreground.", "agent_start": "Install + bootstrap the resident DelphiScript agent via X2 -R RunScript; waits for heartbeat.", "agent_stop": "Drop the stop file; loop exits.", "agent_status": "Heartbeat age, request count, workspace.",@@ -1658,6 +1692,7 @@ COMMAND_HANDLERS = { "readiness": h_readiness, "status": h_status, "diagnostics": h_diagnostics, "window_info": h_window_info, "state": h_state, "check_dialogs": h_check_dialogs, "dismiss_dialogs": h_dismiss_dialogs, "launch": h_launch, "close": h_close,+ "activate_document": h_activate_document, "agent_start": h_agent_start, "agent_stop": h_agent_stop, "agent_status": h_agent_status, "agent_recover": h_agent_recover, "eval": h_eval, "run_command": h_run_command, "run_script": h_run_script,