PRA-only-demo/src/main/capabilities/desktop-uia.ts

357 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { register } from "../engine/registry";
import { RuntimeError, type CapabilityHandler } from "../engine/types";
import { powershell, psString } from "./desktop";
interface UiaSelector {
window?: string;
name?: string;
id?: string;
type?: string;
className?: string;
index: number;
}
export function parseUiaSelector(raw: unknown): UiaSelector {
let text = "";
if (raw && typeof raw === "object") {
const record = raw as Record<string, unknown>;
if (typeof record.value === "string") text = record.value;
else if (typeof record.selector === "string") text = record.selector;
else if (record.commonProps && typeof record.commonProps === "object") {
const common = record.commonProps as Record<string, unknown>;
if (typeof common.uiSelector === "string") text = common.uiSelector;
}
} else if (raw !== undefined && raw !== null) {
text = String(raw);
}
text = text.trim();
if (!text) {
throw new RuntimeError("validation_error", "桌面元素选择器为空", { recommendation: "使用 name=元素名称 或 id=AutomationId;window=窗口名称" });
}
if (text.startsWith("uia:")) text = text.slice(4);
const selector: UiaSelector = { index: 1 };
for (const part of text.split(";")) {
const eq = part.indexOf("=");
if (eq <= 0) continue;
const key = part.slice(0, eq).trim().toLowerCase();
const value = part.slice(eq + 1).trim();
if (!value) continue;
if (key === "window" || key === "win") selector.window = value;
else if (key === "name" || key === "n") selector.name = value;
else if (key === "id" || key === "automationid") selector.id = value;
else if (key === "type" || key === "controltype") selector.type = value.charAt(0).toUpperCase() + value.slice(1).toLowerCase();
else if (key === "class" || key === "classname") selector.className = value;
else if (key === "index") {
const n = Number(value);
if (Number.isFinite(n) && n >= 1) selector.index = Math.floor(n);
}
}
if (!selector.name && !selector.id && !selector.className && !selector.type) {
throw new RuntimeError("validation_error", `桌面元素选择器缺少定位条件:${text}`, { recommendation: "至少提供 name= / id= / type= 之一" });
}
return selector;
}
const UIA_USING = "Add-Type -AssemblyName UIAutomationClient, UIAutomationTypes;";
const FIND_HELPER = `
function Find-RpaWindow([string]$winName) {
$root = [System.Windows.Automation.AutomationElement]::RootElement
if (-not $winName) { return $root }
$cond = New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::NameProperty, $winName)
$win = $root.FindFirst([System.Windows.Automation.TreeScope]::Children, $cond)
if ($win) { return $win }
Add-RpaUser32
$script:hitHwnd = [IntPtr]::Zero
$cb = [WinRpa.RpaUser32Click+EnumProc]{ param($h, $l)
$sb = New-Object System.Text.StringBuilder 256
[WinRpa.RpaUser32Click]::GetWindowTextW($h, $sb, 256) | Out-Null
if ($sb.ToString() -eq $winName) { $script:hitHwnd = $h; return $false }
return $true
}
[WinRpa.RpaUser32Click]::EnumWindows($cb, [IntPtr]::Zero) | Out-Null
if ($script:hitHwnd -ne [IntPtr]::Zero) {
[WinRpa.RpaUser32Click]::ShowWindow($script:hitHwnd, 9) | Out-Null
Start-Sleep -Milliseconds 500
$sw2 = [WinRpa.RpaUser32Click]::GetSystemMetrics(0)
$sh2 = [WinRpa.RpaUser32Click]::GetSystemMetrics(1)
$w2 = [Math]::Min(1400, $sw2 - 120)
$h2 = [Math]::Min(820, $sh2 - 120)
[WinRpa.RpaUser32Click]::SetWindowPos($script:hitHwnd, [IntPtr]::Zero, 60, 60, $w2, $h2, 0x0040) | Out-Null
Start-Sleep -Milliseconds 600
$win = $root.FindFirst([System.Windows.Automation.TreeScope]::Children, $cond)
if ($win) { return $win }
}
throw "window_not_found:$winName"
}
function Find-RpaElement($parent, [string]$name, [string]$id, [string]$type, [string]$className, [int]$index) {
$conds = @()
if ($name) { $conds += New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::NameProperty, $name) }
if ($id) { $conds += New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::AutomationIdProperty, $id) }
if ($className) { $conds += New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::ClassNameProperty, $className) }
if ($type) {
try { $field = [System.Windows.Automation.ControlType].GetField($type); if ($field) { $conds += New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::ControlTypeProperty, $field.GetValue($null)) } } catch {}
if ($ct) { $conds += New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::ControlTypeProperty, $ct) }
}
if ($conds.Count -eq 0) { throw "no_condition" }
$final = if ($conds.Count -eq 1) { $conds[0] } else { New-Object System.Windows.Automation.AndCondition($conds) }
$all = $parent.FindAll([System.Windows.Automation.TreeScope]::Descendants, $final)
if ($all.Count -eq 0) { return $null }
$pick = [Math]::Min([Math]::Max($index, 1), $all.Count)
return $all[$pick - 1]
}
function Add-RpaUser32 {
Add-Type -MemberDefinition '[DllImport("user32.dll")] public static extern bool SetCursorPos(int X, int Y); [DllImport("user32.dll")] public static extern void mouse_event(uint flags, uint dx, uint dy, uint data, UIntPtr extra); [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd); [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); [DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr hWnd, IntPtr after, int X, int Y, int cx, int cy, uint flags); [DllImport("user32.dll")] public static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach); [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId); [DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId(); [DllImport("user32.dll")] public static extern int GetSystemMetrics(int nIndex); [DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr lp); public delegate bool EnumProc(IntPtr hWnd, IntPtr lp); [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetWindowTextW(IntPtr hWnd, System.Text.StringBuilder sb, int max);' -Name RpaUser32Click -Namespace Win32Rpa | Out-Null
}
`;
interface UiaTarget {
selector: UiaSelector;
args: Record<string, unknown>;
node: { id: string; componentId: string };
}
function target(args: Record<string, unknown>, node: { id: string; componentId: string }): UiaTarget {
const raw = args.uiSelector ?? args.target ?? args.selector;
return { selector: parseUiaSelector(raw), args, node };
}
function findScript(t: UiaTarget, timeoutMs: number, gapMs: number): string {
const s = t.selector;
return `${UIA_USING}
${FIND_HELPER}
$deadline = [DateTime]::UtcNow.AddMilliseconds(${Math.max(0, Math.round(timeoutMs))})
$found = $null
while ($null -eq $found) {
try {
$parent = Find-RpaWindow ${psString(s.window ?? "")}
$found = Find-RpaElement $parent ${psString(s.name ?? "")} ${psString(s.id ?? "")} ${psString(s.type ?? "")} ${psString(s.className ?? "")} ${s.index}
} catch {
$err = $_.Exception.Message
if ($err -like "window_not_found:*") { $found = $null }
}
if ($null -eq $found -and [DateTime]::UtcNow -ge $deadline) { break }
if ($null -eq $found) { Start-Sleep -Milliseconds ${Math.max(50, Math.round(gapMs))} }
}
if ($null -eq $found) { Write-Output "JSON_NOT_FOUND" } else {
$rect = $found.Current.BoundingRectangle
$info = @{ found = $true; name = $found.Current.Name; id = $found.Current.AutomationId; type = $found.Current.ControlType.ProgrammaticName; enabled = $found.Current.IsEnabled; x = [int]$rect.X; y = [int]$rect.Y; w = [int]$rect.Width; h = [int]$rect.Height }
Write-Output ($info | ConvertTo-Json -Compress)
}`;
}
async function findElement(t: UiaTarget, timeoutMs = 5000, gapMs = 200): Promise<Record<string, unknown> | null> {
const out = await powershell(findScript(t, timeoutMs, gapMs), Math.max(30000, timeoutMs + 30000));
const line = out.split(/\r?\n/).filter(Boolean).pop() || "";
if (line.includes("JSON_NOT_FOUND")) return null;
try {
return JSON.parse(line);
} catch {
throw new RuntimeError("desktop_command_failed", `桌面元素查找输出异常:${out.slice(0, 200)}`, { nodeId: t.node.id, componentId: t.node.componentId });
}
}
function clickScript(t: UiaTarget): string {
const s = t.selector;
const clickway = String(t.args.clickway ?? "0");
const doubleClick = clickway === "1" || /double/i.test(clickway);
return `${UIA_USING}
${FIND_HELPER}
$el = $null
try {
$parent = Find-RpaWindow ${psString(s.window ?? "")}
$el = Find-RpaElement $parent ${psString(s.name ?? "")} ${psString(s.id ?? "")} ${psString(s.type ?? "")} ${psString(s.className ?? "")} ${s.index}
} catch { $el = $null }
if ($null -eq $el) { Write-Output "JSON_NOT_FOUND"; exit 0 }
$rect = $el.Current.BoundingRectangle
$offscreen = $el.Current.IsOffscreen -or $rect.Width -le 0 -or $rect.Height -le 0
$method = "coordinate_click"
$ok = $false
if (-not $offscreen) {
Add-RpaUser32
$walker = [System.Windows.Automation.TreeWalker]::ControlViewWalker
$ancestor = $el
$hwnd = [IntPtr]::Zero
while ($null -ne $ancestor) {
$h = $ancestor.Current.NativeWindowHandle
if ($h -ne 0) { $hwnd = [IntPtr]$h; break }
$ancestor = $walker.GetParent($ancestor)
}
if ($hwnd -ne [IntPtr]::Zero) {
$fgHwnd = [WinRpa.RpaUser32Click]::GetForegroundWindow()
if ($fgHwnd -ne $hwnd) {
$fgPid = [uint32]0
$fgThread = [WinRpa.RpaUser32Click]::GetWindowThreadProcessId($fgHwnd, [ref]$fgPid)
$thisThread = [WinRpa.RpaUser32Click]::GetCurrentThreadId()
[WinRpa.RpaUser32Click]::AttachThreadInput($thisThread, $fgThread, $true) | Out-Null
[WinRpa.RpaUser32Click]::SetForegroundWindow($hwnd) | Out-Null
[WinRpa.RpaUser32Click]::AttachThreadInput($thisThread, $fgThread, $false) | Out-Null
}
Start-Sleep -Milliseconds 250
}
$x = [int]($rect.X + $rect.Width / 2)
$y = [int]($rect.Y + $rect.Height / 2)
[WinRpa.RpaUser32Click]::SetCursorPos($x, $y) | Out-Null
Start-Sleep -Milliseconds 120
[WinRpa.RpaUser32Click]::mouse_event(0x0002,0,0,0,[UIntPtr]::Zero)
Start-Sleep -Milliseconds 40
[WinRpa.RpaUser32Click]::mouse_event(0x0004,0,0,0,[UIntPtr]::Zero)
if (${doubleClick}) {
Start-Sleep -Milliseconds 60
[WinRpa.RpaUser32Click]::mouse_event(0x0002,0,0,0,[UIntPtr]::Zero)
Start-Sleep -Milliseconds 40
[WinRpa.RpaUser32Click]::mouse_event(0x0004,0,0,0,[UIntPtr]::Zero)
}
$ok = $true
} else {
$method = "invoke_pattern"
foreach ($entry in @(@("Invoke", [System.Windows.Automation.InvokePattern]::Pattern), @("Toggle", [System.Windows.Automation.TogglePattern]::Pattern), @("Expand", [System.Windows.Automation.ExpandCollapsePattern]::Pattern), @("Select", [System.Windows.Automation.SelectionItemPattern]::Pattern))) {
try {
$p = $el.GetCurrentPattern($entry[1])
switch ($entry[0]) {
"Invoke" { $p.Invoke(); $ok = $true }
"Toggle" { $p.Toggle(); $ok = $true }
"Expand" { $p.Expand(); $ok = $true }
"Select" { $p.Select(); $ok = $true }
}
if ($ok) { break }
} catch {}
}
}
$rect2 = $el.Current.BoundingRectangle
Write-Output (@{ clicked = $ok; method = $method; name = $el.Current.Name; x = [int]$rect2.X; y = [int]$rect2.Y; w = [int]$rect2.Width; h = [int]$rect2.Height } | ConvertTo-Json -Compress)`;
}
function setValueScript(t: UiaTarget, value: string): string {
const s = t.selector;
return `${UIA_USING}
${FIND_HELPER}
try {
$parent = Find-RpaWindow ${psString(s.window ?? "")}
$el = Find-RpaElement $parent ${psString(s.name ?? "")} ${psString(s.id ?? "")} ${psString(s.type ?? "")} ${psString(s.className ?? "")} ${s.index}
if ($null -eq $el) { Write-Output "JSON_NOT_FOUND"; exit 0 }
$value = ${psString(value)}
$method = "value_pattern"
$ok = $false
try {
$vp = $el.GetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern)
$vp.SetValue($value)
$ok = $true
} catch {
$method = "clipboard_paste"
Add-Type -AssemblyName System.Windows.Forms
$saved = $null
try { $saved = [System.Windows.Forms.Clipboard]::GetText() } catch {}
try { $el.SetFocus() } catch {}
Start-Sleep -Milliseconds 150
try { [System.Windows.Forms.Clipboard]::SetText($value) } catch {}
Start-Sleep -Milliseconds 80
[System.Windows.Forms.SendKeys]::SendWait("^a")
Start-Sleep -Milliseconds 60
[System.Windows.Forms.SendKeys]::SendWait("^v")
Start-Sleep -Milliseconds 300
$ok = $true
try {
if ($saved) { [System.Windows.Forms.Clipboard]::SetText($saved) }
elseif ($saved -ne $null) { [System.Windows.Forms.Clipboard]::Clear() }
} catch {}
}
Write-Output (@{ set = $ok; method = $method; name = $el.Current.Name } | ConvertTo-Json -Compress)`;
}
function getValueScript(t: UiaTarget): string {
const s = t.selector;
return `${UIA_USING}
${FIND_HELPER}
try {
$parent = Find-RpaWindow ${psString(s.window ?? "")}
$el = Find-RpaElement $parent ${psString(s.name ?? "")} ${psString(s.id ?? "")} ${psString(s.type ?? "")} ${psString(s.className ?? "")} ${s.index}
if ($null -eq $el) { Write-Output "JSON_NOT_FOUND"; exit 0 }
$value = $el.Current.Name
try { $vp = $el.GetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern); $value = $vp.Value } catch {}
Write-Output (@{ value = $value; name = $el.Current.Name } | ConvertTo-Json -Compress)`;
}
async function runScript(script: string, t: UiaTarget, timeoutMs = 60000): Promise<Record<string, unknown> | null> {
const out = await powershell(script, timeoutMs);
const line = out.split(/\r?\n/).filter(Boolean).pop() || "";
if (line.includes("JSON_NOT_FOUND")) return null;
try {
return JSON.parse(line);
} catch {
throw new RuntimeError("desktop_command_failed", `桌面元素操作输出异常:${out.slice(0, 200)}`, { nodeId: t.node.id, componentId: t.node.componentId });
}
}
function sleepMs(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function runWithImplicitWait(
t: UiaTarget,
action: string,
script: string,
exec: (script: string, t: UiaTarget, timeoutMs?: number) => Promise<Record<string, unknown> | null>,
args: Record<string, unknown>
): Promise<Record<string, unknown>> {
const waitMs = Number(args.waitMs ?? args.wait_ms ?? 4000) || 4000;
const deadline = Date.now() + Math.max(0, waitMs);
for (;;) {
try {
return requireFound(await exec(script, t), t, action);
} catch (error) {
const code = (error as { code?: string }).code;
if (code !== "desktop_element_not_found" || Date.now() >= deadline) throw error;
await sleepMs(400);
}
}
}
function requireFound(result: Record<string, unknown> | null, t: UiaTarget, action: string): Record<string, unknown> {
if (!result) {
const s = t.selector;
throw new RuntimeError("desktop_element_not_found", `桌面元素未找到(${action}):${JSON.stringify({ window: s.window, name: s.name, id: s.id, type: s.type, className: s.className, index: s.index })}。若指定了窗口,请确认应用已启动且主窗口未最小化到托盘`, { nodeId: t.node.id, componentId: t.node.componentId });
}
return result;
}
const clickGround: CapabilityHandler = async (_ctx, node, args) => {
const t = target(args, node);
return runWithImplicitWait(t, "click", clickScript(t), runScript, args);
};
const setValueGround: CapabilityHandler = async (_ctx, node, args) => {
const t = target(args, node);
const value = String(args.value ?? args.text ?? "");
return runWithImplicitWait(t, "setValue", setValueScript(t, value), runScript, args);
};
const existsGround: CapabilityHandler = async (_ctx, node, args) => {
const t = target(args, node);
const found = await findElement(t, 1000, 150);
return found !== null;
};
const getValueGround: CapabilityHandler = async (_ctx, node, args) => {
const t = target(args, node);
const result = await runWithImplicitWait(t, "getValue", getValueScript(t), runScript, args);
return result.value ?? "";
};
const waitElementGround: CapabilityHandler = async (_ctx, node, args) => {
const t = target(args, node);
const timeout = Number(args.timeout ?? args.timeoutMs ?? 5000) || 5000;
const gap = Number(args.time_gap ?? args.gapMs ?? 200) || 200;
const found = await findElement(t, timeout, gap);
return found !== null;
};
register("Element_ClickGround", clickGround);
register("Element_SetValueGround", setValueGround);
register("Element_ExistsGround", existsGround);
register("Element_GetValueGround", getValueGround);
register("Element_WaitElementGround", waitElementGround);