← Commit history

v0.1.13: UIA-first clicking (no cursor movement), altium_activate_document, altium-uia skill

John Lauer ·7535402df0 ·1mo ago ·parent 1b1301e
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,