1070 lines
53 KiB
TypeScript
1070 lines
53 KiB
TypeScript
import { register } from "../engine/registry";
|
||
import fs from "node:fs";
|
||
import path from "node:path";
|
||
import { RuntimeError, type CapabilityHandler } from "../engine/types";
|
||
import { powershell, psString } from "../driver/windows/powershell";
|
||
import type { UiaSelector } from "../driver/types";
|
||
import { setElementDriverImplementation } from "../driver/windows/windows-driver";
|
||
|
||
// UiaSelector 类型已上移 driver/types.ts(任务 3.2 element 域)
|
||
|
||
/** ControlType 显式映射:type= 值 → .NET ControlType 字段名;复合名称不得用首字母大写推导 */
|
||
const UIA_CONTROL_TYPE_MAP: Record<string, string> = {
|
||
button: "Button",
|
||
calendar: "Calendar",
|
||
checkbox: "CheckBox",
|
||
combo: "ComboBox",
|
||
combobox: "ComboBox",
|
||
custom: "Custom",
|
||
datagrid: "DataGrid",
|
||
dataitem: "DataItem",
|
||
document: "Document",
|
||
edit: "Edit",
|
||
group: "Group",
|
||
header: "Header",
|
||
headeritem: "HeaderItem",
|
||
hyperlink: "Hyperlink",
|
||
image: "Image",
|
||
list: "List",
|
||
listbox: "List",
|
||
listitem: "ListItem",
|
||
menu: "Menu",
|
||
menubar: "MenuBar",
|
||
menuitem: "MenuItem",
|
||
pane: "Pane",
|
||
progressbar: "ProgressBar",
|
||
radio: "RadioButton",
|
||
radiobutton: "RadioButton",
|
||
scrollbar: "ScrollBar",
|
||
separator: "Separator",
|
||
slider: "Slider",
|
||
spinner: "Spinner",
|
||
splitbutton: "SplitButton",
|
||
statusbar: "StatusBar",
|
||
tab: "Tab",
|
||
tabitem: "TabItem",
|
||
table: "Table",
|
||
text: "Text",
|
||
thumb: "Thumb",
|
||
titlebar: "TitleBar",
|
||
toolbar: "ToolBar",
|
||
tooltip: "ToolTip",
|
||
tree: "Tree",
|
||
treeitem: "TreeItem",
|
||
window: "Window"
|
||
};
|
||
|
||
function normalizeUiaControlType(raw: string): string {
|
||
const key = raw.trim().toLowerCase().replace(/[\s_-]+/g, "");
|
||
return UIA_CONTROL_TYPE_MAP[key] ?? raw.charAt(0).toUpperCase() + raw.slice(1).toLowerCase();
|
||
}
|
||
|
||
/** 解析 path= 段:Pane>Edit[name=用户名;index=2],段间用 > 分隔,段内 [prop=value;...] */
|
||
function parseUiaSelectorPath(value: string): UiaSelector["path"] {
|
||
// 按括号外的 > 分段,避免 path=Edit[name=a>b] 这类属性值被错误拆开
|
||
const rawSegments: string[] = [];
|
||
let buffer = "";
|
||
let bracketDepth = 0;
|
||
for (const char of value) {
|
||
if (char === "[") bracketDepth += 1;
|
||
else if (char === "]") bracketDepth = Math.max(0, bracketDepth - 1);
|
||
if (char === ">" && bracketDepth === 0) {
|
||
rawSegments.push(buffer);
|
||
buffer = "";
|
||
} else {
|
||
buffer += char;
|
||
}
|
||
}
|
||
rawSegments.push(buffer);
|
||
const segments = rawSegments.map(item => item.trim()).filter(Boolean);
|
||
if (!segments.length) return undefined;
|
||
const path: NonNullable<UiaSelector["path"]> = [];
|
||
for (const segment of segments) {
|
||
const match = segment.match(/^([A-Za-z][A-Za-z0-9_]*)?\s*(?:\[(.*)\])?$/);
|
||
if (!match) {
|
||
throw new RuntimeError("validation_error", `UIA 路径段无效:${segment}`, { recommendation: "使用 Type[name=xx;id=yy;class=zz;index=1] > 下一级" });
|
||
}
|
||
const entry: NonNullable<UiaSelector["path"]>[number] = {};
|
||
if (match[1]) entry.type = normalizeUiaControlType(match[1]);
|
||
const props = match[2];
|
||
if (props) {
|
||
for (const pair of props.split(/[;,,]/).map(item => item.trim()).filter(Boolean)) {
|
||
const eq = pair.indexOf("=");
|
||
if (eq <= 0) {
|
||
throw new RuntimeError("validation_error", `UIA 路径属性无效:${pair}`, { recommendation: "使用 name= / id= / class= / index=" });
|
||
}
|
||
const key = pair.slice(0, eq).trim().toLowerCase();
|
||
const val = pair.slice(eq + 1).trim();
|
||
if (key === "name" || key === "n") entry.name = val;
|
||
else if (key === "id" || key === "automationid") entry.id = val;
|
||
else if (key === "class" || key === "classname") entry.className = val;
|
||
else if (key === "type" || key === "controltype") entry.type = normalizeUiaControlType(val);
|
||
else if (key === "index") {
|
||
const n = Number(val);
|
||
if (!Number.isFinite(n) || n < 1) throw new RuntimeError("validation_error", `UIA 路径 index 无效:${val}`);
|
||
entry.index = Math.floor(n);
|
||
}
|
||
else throw new RuntimeError("validation_error", `UIA 路径属性不支持:${key}`);
|
||
}
|
||
}
|
||
if (!entry.type && !entry.name && !entry.id && !entry.className) {
|
||
throw new RuntimeError("validation_error", `UIA 路径段缺少定位条件:${segment}`, { recommendation: "每段至少提供 type/name/id/class 之一" });
|
||
}
|
||
path.push(entry);
|
||
}
|
||
return path;
|
||
}
|
||
|
||
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 };
|
||
// path 段内的 [name=a;b] 不应被顶层 ; 拆开
|
||
const selectorParts: string[] = [];
|
||
let partBuffer = "";
|
||
let selectorBracketDepth = 0;
|
||
for (const char of text) {
|
||
if (char === "[") selectorBracketDepth += 1;
|
||
else if (char === "]") selectorBracketDepth = Math.max(0, selectorBracketDepth - 1);
|
||
if (char === ";" && selectorBracketDepth === 0) {
|
||
selectorParts.push(partBuffer);
|
||
partBuffer = "";
|
||
} else {
|
||
partBuffer += char;
|
||
}
|
||
}
|
||
selectorParts.push(partBuffer);
|
||
for (const part of selectorParts) {
|
||
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.typeRaw = value;
|
||
selector.type = normalizeUiaControlType(value);
|
||
}
|
||
else if (key === "class" || key === "classname") selector.className = value;
|
||
else if (key === "screen") {
|
||
const nums = value.split(/[,,]/).map(item => Number(item.trim()));
|
||
if (nums.length !== 4 || nums.some(n => !Number.isFinite(n))) {
|
||
throw new RuntimeError("validation_error", "screen 坐标格式无效:" + value, { recommendation: "使用 screen=x,y,width,height" });
|
||
}
|
||
selector.screen = { x: nums[0], y: nums[1], w: nums[2], h: nums[3] };
|
||
}
|
||
else if (key === "rel") {
|
||
const nums = value.split(/[,،]/).map(item => Number(item.trim()));
|
||
if (nums.length !== 4 || nums.some(n => !Number.isFinite(n))) {
|
||
throw new RuntimeError("validation_error", "rel coordinate invalid: " + value, { recommendation: "rel=x,y,width,height" });
|
||
}
|
||
selector.rel = { x: nums[0], y: nums[1], w: nums[2], h: nums[3] };
|
||
}
|
||
else if (key === "process") selector.process = value;
|
||
else if (key === "winclass") selector.winClass = value;
|
||
else if (key === "enriched") selector.enriched = value === "1" || value.toLowerCase() === "true";
|
||
else if (key === "aitype") selector.aiType = value;
|
||
else if (key === "aitext") selector.aiText = value;
|
||
else if (key === "ai") selector.ai = value === "1" || value.toLowerCase() === "true";
|
||
else if (key === "index") {
|
||
const n = Number(value);
|
||
if (Number.isFinite(n) && n >= 1) selector.index = Math.floor(n);
|
||
}
|
||
else if (key === "path" || key === "ancestors" || key === "ancestorpath") {
|
||
selector.path = parseUiaSelectorPath(value);
|
||
}
|
||
}
|
||
const hasWindowAnchor = Boolean(selector.window || selector.winClass || selector.process);
|
||
if (!selector.name && !selector.id && !selector.className && !selector.type && !selector.screen && !selector.path && !(selector.rel && hasWindowAnchor)) {
|
||
throw new RuntimeError("validation_error", `桌面元素选择器缺少定位条件:${text}`, { recommendation: "至少提供 name= / id= / type= 之一" });
|
||
}
|
||
return selector;
|
||
}
|
||
|
||
const UIA_USING = [
|
||
"Add-Type -AssemblyName UIAutomationClient, UIAutomationTypes;",
|
||
"try { Add-Type -MemberDefinition '[DllImport(\"user32.dll\")] public static extern bool SetProcessDpiAwarenessContext(IntPtr value);' -Name RpaDpi -Namespace WinRpaDpi | Out-Null; [WinRpaDpi.RpaDpi]::SetProcessDpiAwarenessContext([IntPtr](-4)) | Out-Null } catch {}"
|
||
].join("\n");
|
||
|
||
const FIND_HELPER = `
|
||
function Find-RpaWindow([string]$winName, [string]$winClass, [string]$processName) {
|
||
# 只读查找:绝不移动/还原/缩放窗口;还原、激活、移动是独立显式动作(Restore/Activate/Move-RpaWindow)
|
||
$root = [System.Windows.Automation.AutomationElement]::RootElement
|
||
if (-not $winName -and -not $winClass -and -not $processName) { return $root }
|
||
if ($winName) {
|
||
$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 = [Win32Rpa.RpaUser32Click+EnumProc]{ param($h, $l)
|
||
if (-not [Win32Rpa.RpaUser32Click]::IsWindowVisible($h)) { return $true }
|
||
$titleSb = New-Object System.Text.StringBuilder 512
|
||
[void][Win32Rpa.RpaUser32Click]::GetWindowTextW($h, $titleSb, 512)
|
||
$title = $titleSb.ToString()
|
||
if ($winName -and $title -notlike "*$winName*") { return $true }
|
||
$classSb = New-Object System.Text.StringBuilder 256
|
||
[void][Win32Rpa.RpaUser32Click]::GetClassNameW($h, $classSb, 256)
|
||
$className = $classSb.ToString()
|
||
if ($winClass -and $className -ne $winClass) { return $true }
|
||
if ($processName) {
|
||
$windowPid = [uint32]0
|
||
[void][Win32Rpa.RpaUser32Click]::GetWindowThreadProcessId($h, [ref]$windowPid)
|
||
try { $procName = (Get-Process -Id $windowPid -ErrorAction Stop).ProcessName } catch { return $true }
|
||
if (-not $procName.Equals($processName, [StringComparison]::OrdinalIgnoreCase)) { return $true }
|
||
}
|
||
$script:hitHwnd = $h
|
||
return $false
|
||
}
|
||
[void][Win32Rpa.RpaUser32Click]::EnumWindows($cb, [IntPtr]::Zero)
|
||
if ($script:hitHwnd -ne [IntPtr]::Zero) {
|
||
try { $win = [System.Windows.Automation.AutomationElement]::FromHandle($script:hitHwnd); if ($win) { return $win } } catch {}
|
||
}
|
||
$missing = (@($winName, $winClass, $processName) | Where-Object { $_ }) -join ","
|
||
throw "window_not_found:$missing"
|
||
}
|
||
function Restore-RpaWindow($win) {
|
||
# 显式动作:还原窗口(最小化/最大化状态恢复),不用于查询
|
||
Add-RpaUser32
|
||
$hwnd = [IntPtr]$win.Current.NativeWindowHandle
|
||
if ($hwnd -eq [IntPtr]::Zero) { throw "window_no_hwnd" }
|
||
[void][WinRpa.RpaUser32Click]::ShowWindow($hwnd, 9)
|
||
Start-Sleep -Milliseconds 300
|
||
return $hwnd
|
||
}
|
||
function Activate-RpaWindow($win) {
|
||
# 显式动作:把窗口带到前台
|
||
Add-RpaUser32
|
||
$hwnd = [IntPtr]$win.Current.NativeWindowHandle
|
||
if ($hwnd -eq [IntPtr]::Zero) { throw "window_no_hwnd" }
|
||
$fgHwnd = [WinRpa.RpaUser32Click]::GetForegroundWindow()
|
||
if ($fgHwnd -ne $hwnd) {
|
||
$fgPid = [uint32]0
|
||
$fgThread = [WinRpa.RpaUser32Click]::GetWindowThreadProcessId($fgHwnd, [ref]$fgPid)
|
||
$thisThread = [WinRpa.RpaUser32Click]::GetCurrentThreadId()
|
||
[void][WinRpa.RpaUser32Click]::AttachThreadInput($thisThread, $fgThread, $true)
|
||
[void][WinRpa.RpaUser32Click]::SetForegroundWindow($hwnd)
|
||
[void][WinRpa.RpaUser32Click]::AttachThreadInput($thisThread, $fgThread, $false)
|
||
}
|
||
Start-Sleep -Milliseconds 200
|
||
return $hwnd
|
||
}
|
||
function Move-RpaWindow($win, [int]$x, [int]$y, [int]$w, [int]$h) {
|
||
# 显式动作:移动/缩放窗口;w/h<=0 时使用安全默认尺寸
|
||
Add-RpaUser32
|
||
$hwnd = [IntPtr]$win.Current.NativeWindowHandle
|
||
if ($hwnd -eq [IntPtr]::Zero) { throw "window_no_hwnd" }
|
||
$sw = [WinRpa.RpaUser32Click]::GetSystemMetrics(0)
|
||
$sh = [WinRpa.RpaUser32Click]::GetSystemMetrics(1)
|
||
if ($w -le 0) { $w = [Math]::Min(1400, $sw - 120) }
|
||
if ($h -le 0) { $h = [Math]::Min(820, $sh - 120) }
|
||
[void][WinRpa.RpaUser32Click]::SetWindowPos($hwnd, [IntPtr]::Zero, $x, $y, $w, $h, 0x0040)
|
||
Start-Sleep -Milliseconds 300
|
||
return $hwnd
|
||
}
|
||
function Find-RpaElement($parent, [string]$name, [string]$id, [string]$type, [string]$className, [int]$index, [switch]$directChild) {
|
||
$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) {
|
||
# 不区分大小写匹配 .NET ControlType 字段,兼容 ComboBox/ListItem/DataGrid 等复合名称
|
||
try {
|
||
$ctField = [System.Windows.Automation.ControlType].GetFields([System.Reflection.BindingFlags]"Public,Static") | Where-Object { $_.Name -ieq $type } | Select-Object -First 1
|
||
if ($ctField) { $conds += New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::ControlTypeProperty, $ctField.GetValue($null)) }
|
||
} catch {}
|
||
}
|
||
if ($conds.Count -eq 0) { throw "no_condition" }
|
||
$final = if ($conds.Count -eq 1) { $conds[0] } else { New-Object System.Windows.Automation.AndCondition($conds) }
|
||
$scope = if ($directChild) { [System.Windows.Automation.TreeScope]::Children } else { [System.Windows.Automation.TreeScope]::Descendants }
|
||
$all = $parent.FindAll($scope, $final)
|
||
if ($all.Count -eq 0) { return $null }
|
||
$pick = [Math]::Min([Math]::Max($index, 1), $all.Count)
|
||
return $all[$pick - 1]
|
||
}
|
||
function Find-RpaElementByPath($parent, [string]$pathJsonBase64) {
|
||
# 逐段走直接子级,用稳定属性(id/type/class 优先)解析窗口→目标祖先路径
|
||
$segments = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($pathJsonBase64)) | ConvertFrom-Json
|
||
$cur = $parent
|
||
foreach ($seg in @($segments)) {
|
||
$ix = 1
|
||
try { if ($seg.index) { $ix = [int]$seg.index } } catch {}
|
||
$next = Find-RpaElement $cur $seg.name $seg.id $seg.type $seg.className $ix -directChild
|
||
if ($null -eq $next) { return $null }
|
||
$cur = $next
|
||
}
|
||
return $cur
|
||
}
|
||
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); [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetClassNameW(IntPtr hWnd, System.Text.StringBuilder sb, int max); [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd);' -Name RpaUser32Click -Namespace Win32Rpa | Out-Null
|
||
}
|
||
`;
|
||
|
||
const RESOLVE_HELPER = `
|
||
function Resolve-RpaScreenRect($s) {
|
||
$hasStable = [bool]($s.name -or $s.id -or $s.type -or $s.className)
|
||
$hasWindowAnchor = [bool]($s.window -or $s.winClass -or $s.process)
|
||
if ($s.enriched -and $hasStable) {
|
||
try {
|
||
$parent = Find-RpaWindow ([string]$s.window) ([string]$s.winClass) ([string]$s.process)
|
||
$el = Find-RpaElement $parent ([string]$s.name) ([string]$s.id) ([string]$s.type) ([string]$s.className) ([int]1)
|
||
if ($el) {
|
||
$r = $el.Current.BoundingRectangle
|
||
if ($r.Width -gt 0 -and $r.Height -gt 0) { return @{ x=[int]$r.X; y=[int]$r.Y; w=[int]$r.Width; h=[int]$r.Height; method='uia' } }
|
||
}
|
||
} catch {}
|
||
}
|
||
if ($s.rel -and $hasWindowAnchor) {
|
||
try {
|
||
$parent = Find-RpaWindow ([string]$s.window) ([string]$s.winClass) ([string]$s.process)
|
||
$r = $parent.Current.BoundingRectangle
|
||
return @{ x=[int]($r.X + $s.rel.x); y=[int]($r.Y + $s.rel.y); w=[int]$s.rel.w; h=[int]$s.rel.h; method='window_rel' }
|
||
} catch {}
|
||
}
|
||
if ($s.screen) { return @{ x=[int]$s.screen.x; y=[int]$s.screen.y; w=[int]$s.screen.w; h=[int]$s.screen.h; method='screen_fallback' } }
|
||
throw "selector_rect_not_found"
|
||
}
|
||
`;
|
||
|
||
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 };
|
||
}
|
||
|
||
/** 生成“解析窗口 + 解析元素(路径优先,其次平铺属性)”的 PowerShell 语句,结果写入 $el */
|
||
function elementResolveLines(s: UiaSelector): string {
|
||
const parentLine = "$parent = Find-RpaWindow " + psString(s.window ?? "") + " " + psString(s.winClass ?? "") + " " + psString(s.process ?? "");
|
||
if (s.path?.length) {
|
||
const pathB64 = Buffer.from(JSON.stringify(s.path), "utf8").toString("base64");
|
||
return parentLine + "\n$el = Find-RpaElementByPath $parent '" + pathB64 + "'";
|
||
}
|
||
return parentLine
|
||
+ "\n$el = Find-RpaElement $parent " + psString(s.name ?? "")
|
||
+ " " + psString(s.id ?? "")
|
||
+ " " + psString(s.type ?? "")
|
||
+ " " + psString(s.className ?? "")
|
||
+ " " + s.index;
|
||
}
|
||
|
||
const SCREEN_USER32 = "Add-Type -MemberDefinition '[DllImport(\"user32.dll\")] public static extern bool SetProcessDpiAwarenessContext(IntPtr value); [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);' -Name RpaScreenInput -Namespace RpaScreen | Out-Null; try { [RpaScreen.RpaScreenInput]::SetProcessDpiAwarenessContext([IntPtr](-4)) | Out-Null } catch {}";
|
||
|
||
function resolveScreenPreamble(t: UiaTarget): string {
|
||
const s = t.selector;
|
||
const screen = s.screen ? `@{x=${s.screen.x};y=${s.screen.y};w=${s.screen.w};h=${s.screen.h}}` : "$null";
|
||
const rel = s.rel ? `@{x=${s.rel.x};y=${s.rel.y};w=${s.rel.w};h=${s.rel.h}}` : "$null";
|
||
return `${UIA_USING}
|
||
${FIND_HELPER}
|
||
${RESOLVE_HELPER}
|
||
$s = @{ enriched=${s.enriched ? "$true" : "$false"}; window=${JSON.stringify(s.window ?? "")}; winClass=${JSON.stringify(s.winClass ?? "")}; process=${JSON.stringify(s.process ?? "")}; name=${JSON.stringify(s.name ?? "")}; id=${JSON.stringify(s.id ?? "")}; type=${JSON.stringify(s.type ?? "")}; className=${JSON.stringify(s.className ?? "")}; screen=${screen}; rel=${rel} }
|
||
$rect = Resolve-RpaScreenRect $s
|
||
$x = [int]($rect.x + $rect.w / 2)
|
||
$y = [int]($rect.y + $rect.h / 2)`;
|
||
}
|
||
|
||
function clickScreenScript(t: UiaTarget): string {
|
||
const clickway = String(t.args.clickway ?? "0");
|
||
const doubleClick = clickway === "1" || /double/i.test(clickway);
|
||
return `${resolveScreenPreamble(t)}
|
||
${SCREEN_USER32}
|
||
if (-not [RpaScreen.RpaScreenInput]::SetCursorPos($x, $y)) { throw "cursor_move_failed" }
|
||
Start-Sleep -Milliseconds 100
|
||
[RpaScreen.RpaScreenInput]::mouse_event(0x0002,0,0,0,[UIntPtr]::Zero)
|
||
Start-Sleep -Milliseconds 40
|
||
[RpaScreen.RpaScreenInput]::mouse_event(0x0004,0,0,0,[UIntPtr]::Zero)
|
||
if (${doubleClick}) {
|
||
Start-Sleep -Milliseconds 70
|
||
[RpaScreen.RpaScreenInput]::mouse_event(0x0002,0,0,0,[UIntPtr]::Zero)
|
||
Start-Sleep -Milliseconds 40
|
||
[RpaScreen.RpaScreenInput]::mouse_event(0x0004,0,0,0,[UIntPtr]::Zero)
|
||
}
|
||
Write-Output (@{ clicked = $true; method = $rect.method; resolvedMethod = $rect.method; x = $x; y = $y } | ConvertTo-Json -Compress)`;
|
||
}
|
||
|
||
function setValueScreenScript(t: UiaTarget, value: string): string {
|
||
return `${resolveScreenPreamble(t)}
|
||
${SCREEN_USER32}
|
||
Add-Type -AssemblyName System.Windows.Forms
|
||
[RpaScreen.RpaScreenInput]::SetCursorPos($x, $y) | Out-Null
|
||
Start-Sleep -Milliseconds 120
|
||
[RpaScreen.RpaScreenInput]::mouse_event(0x0002,0,0,0,[UIntPtr]::Zero)
|
||
Start-Sleep -Milliseconds 40
|
||
[RpaScreen.RpaScreenInput]::mouse_event(0x0004,0,0,0,[UIntPtr]::Zero)
|
||
Start-Sleep -Milliseconds 220
|
||
$saved = $null
|
||
try { $saved = [System.Windows.Forms.Clipboard]::GetText() } catch {}
|
||
try { [System.Windows.Forms.Clipboard]::SetText(${psString(value)}) } catch { throw "clipboard_failed" }
|
||
Start-Sleep -Milliseconds 80
|
||
[System.Windows.Forms.SendKeys]::SendWait('^a')
|
||
Start-Sleep -Milliseconds 60
|
||
[System.Windows.Forms.SendKeys]::SendWait('^v')
|
||
Start-Sleep -Milliseconds 280
|
||
try {
|
||
if ($saved) { [System.Windows.Forms.Clipboard]::SetText($saved) } else { [System.Windows.Forms.Clipboard]::Clear() }
|
||
} catch {}
|
||
Write-Output (@{ set = $true; method = $rect.method; resolvedMethod = $rect.method; x = $x; y = $y } | ConvertTo-Json -Compress)`;
|
||
}
|
||
|
||
function getValueScreenScript(t: UiaTarget): string {
|
||
return `${resolveScreenPreamble(t)}
|
||
Add-Type -AssemblyName WindowsBase
|
||
$pt = New-Object System.Windows.Point($x, $y)
|
||
$el = [System.Windows.Automation.AutomationElement]::FromPoint($pt)
|
||
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; method = $rect.method } | ConvertTo-Json -Compress)`;
|
||
}
|
||
|
||
function screenExistsScript(t: UiaTarget): string {
|
||
return `${resolveScreenPreamble(t)}
|
||
Add-Type -AssemblyName WindowsBase
|
||
$found = $true
|
||
if ($rect.method -ne 'uia') {
|
||
try {
|
||
$pt = New-Object System.Windows.Point($x, $y)
|
||
$hit = [System.Windows.Automation.AutomationElement]::FromPoint($pt)
|
||
if ($null -eq $hit) { $found = $false }
|
||
else {
|
||
$hr = $hit.Current.BoundingRectangle
|
||
$inside = ($hr.Right -ge $rect.x) -and ($hr.Left -le ($rect.x + $rect.w)) -and ($hr.Bottom -ge $rect.y) -and ($hr.Top -le ($rect.y + $rect.h))
|
||
if (-not $inside) { $found = $false }
|
||
}
|
||
} catch { $found = $false }
|
||
}
|
||
Write-Output (@{ found = $found; method = $rect.method; x = $rect.x; y = $rect.y; w = $rect.w; h = $rect.h } | ConvertTo-Json -Compress)`;
|
||
}
|
||
|
||
async function screenElementExists(t: UiaTarget): Promise<boolean> {
|
||
try {
|
||
const result = await runScript(screenExistsScript(t), t);
|
||
return result?.found === true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
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 {
|
||
${elementResolveLines(s)}
|
||
$found = $el
|
||
} 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 {
|
||
${elementResolveLines(s)}
|
||
} catch { $el = $null }
|
||
if ($null -eq $el) { Write-Output "JSON_NOT_FOUND"; exit 0 }
|
||
$rect = $el.Current.BoundingRectangle
|
||
Add-RpaUser32
|
||
$vsX = [WinRpa.RpaUser32Click]::GetSystemMetrics(76)
|
||
$vsY = [WinRpa.RpaUser32Click]::GetSystemMetrics(77)
|
||
$vsW = [WinRpa.RpaUser32Click]::GetSystemMetrics(78)
|
||
$vsH = [WinRpa.RpaUser32Click]::GetSystemMetrics(79)
|
||
$inVs = ($rect.X -ge $vsX) -and ($rect.Y -ge $vsY) -and (($rect.X + $rect.Width) -le ($vsX + $vsW)) -and (($rect.Y + $rect.Height) -le ($vsY + $vsH))
|
||
$offscreen = $el.Current.IsOffscreen -or $rect.Width -le 0 -or $rect.Height -le 0 -or (-not $inVs)
|
||
if ($offscreen) {
|
||
# 窗口可能被最小化或移出屏幕:还原并移回可视区后重取坐标
|
||
$walker0 = [System.Windows.Automation.TreeWalker]::ControlViewWalker
|
||
$anc0 = $el
|
||
$topHwnd = [IntPtr]::Zero
|
||
while ($null -ne $anc0) {
|
||
$h0 = $anc0.Current.NativeWindowHandle
|
||
if ($h0 -ne 0) { $topHwnd = [IntPtr]$h0; break }
|
||
$anc0 = $walker0.GetParent($anc0)
|
||
}
|
||
if ($topHwnd -ne [IntPtr]::Zero) {
|
||
[WinRpa.RpaUser32Click]::ShowWindow($topHwnd, 9) | Out-Null
|
||
Start-Sleep -Milliseconds 600
|
||
$sw3 = [WinRpa.RpaUser32Click]::GetSystemMetrics(0)
|
||
$sh3 = [WinRpa.RpaUser32Click]::GetSystemMetrics(1)
|
||
$w3 = [Math]::Min(1400, $sw3 - 120)
|
||
$h3 = [Math]::Min(820, $sh3 - 120)
|
||
[WinRpa.RpaUser32Click]::SetWindowPos($topHwnd, [IntPtr]::Zero, 60, 60, $w3, $h3, 0x0040) | Out-Null
|
||
Start-Sleep -Milliseconds 600
|
||
$rect = $el.Current.BoundingRectangle
|
||
$inVs = ($rect.X -ge $vsX) -and ($rect.Y -ge $vsY) -and (($rect.X + $rect.Width) -le ($vsX + $vsW)) -and (($rect.Y + $rect.Height) -le ($vsY + $vsH))
|
||
$offscreen = $el.Current.IsOffscreen -or $rect.Width -le 0 -or $rect.Height -le 0 -or (-not $inVs)
|
||
}
|
||
}
|
||
$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)`;
|
||
}
|
||
export function setValueScript(t: UiaTarget, value: string): string {
|
||
const s = t.selector;
|
||
return `${UIA_USING}
|
||
${FIND_HELPER}
|
||
${elementResolveLines(s)}
|
||
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}
|
||
${elementResolveLines(s)}
|
||
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);
|
||
const result = t.selector.screen
|
||
? await runScript(clickScreenScript(t), t)
|
||
: await runWithImplicitWait(t, "click", clickScript(t), runScript, args);
|
||
// 元素定位到了但实际没点成(如拾取到全屏透明覆盖层:IsOffscreen=true 且无 Invoke/Toggle/Expand/Select 模式)。
|
||
// 这种"假成功"会掩盖问题,必须按错误契约抛出,让运行记录如实标红。
|
||
if (result && result.clicked === false) {
|
||
const s = t.selector;
|
||
throw new RuntimeError(
|
||
"desktop_error",
|
||
`元素点击未生效:已定位到目标(window=${s.window ?? ""}, name=${s.name ?? ""}, type=${s.type ?? ""}),但该元素不可见点击且不支持任何可调用模式`,
|
||
{
|
||
recommendation: "重新用「桌面拾取」选择真实可见的目标控件;若拾取结果落在全屏透明覆盖层上(如 NVIDIA 游戏内覆盖层、录屏悬浮条),先关闭覆盖层再拾取",
|
||
nodeId: node.id,
|
||
componentId: node.componentId,
|
||
detail: JSON.stringify(result)
|
||
}
|
||
);
|
||
}
|
||
return result;
|
||
};
|
||
|
||
const setValueGround: CapabilityHandler = async (_ctx, node, args) => {
|
||
const t = target(args, node);
|
||
const value = String(args.value ?? args.text ?? "");
|
||
if (t.selector.screen) return runScript(setValueScreenScript(t, value), t);
|
||
return runWithImplicitWait(t, "setValue", setValueScript(t, value), runScript, args);
|
||
};
|
||
|
||
const existsGround: CapabilityHandler = async (_ctx, node, args) => {
|
||
const t = target(args, node);
|
||
if (t.selector.screen) return await screenElementExists(t);
|
||
const found = await findElement(t, 1000, 150);
|
||
return found !== null;
|
||
};
|
||
|
||
const getValueGround: CapabilityHandler = async (_ctx, node, args) => {
|
||
const t = target(args, node);
|
||
if (t.selector.screen) {
|
||
const result = await runScript(getValueScreenScript(t), t);
|
||
return requireFound(result, t, "getValue").value ?? "";
|
||
}
|
||
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);
|
||
if (t.selector.screen) {
|
||
const timeout = Number(args.timeout ?? args.timeoutMs ?? 5000) || 5000;
|
||
const gap = Number(args.time_gap ?? args.gapMs ?? 200) || 200;
|
||
const deadline = Date.now() + timeout;
|
||
do {
|
||
if (await screenElementExists(t)) return true;
|
||
if (Date.now() >= deadline) break;
|
||
await new Promise(resolve => setTimeout(resolve, gap));
|
||
} while (Date.now() < deadline);
|
||
return false;
|
||
}
|
||
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);
|
||
const restoreWindowGround: CapabilityHandler = async (_ctx, node, args) => {
|
||
const t = target(args, node);
|
||
const lines = ["$hwnd = Restore-RpaWindow $el"];
|
||
if ([args.x, args.y, args.width, args.height].some((value) => value !== undefined && value !== null && String(value).trim() !== "")) {
|
||
const x = Math.round(Number(args.x ?? 60)) || 0;
|
||
const y = Math.round(Number(args.y ?? 60)) || 0;
|
||
const width = Math.round(Number(args.width ?? 0)) || 0;
|
||
const height = Math.round(Number(args.height ?? 0)) || 0;
|
||
lines.push("$hwnd = Move-RpaWindow $el " + x + " " + y + " " + width + " " + height);
|
||
}
|
||
return runWithImplicitWait(t, "restoreWindow", propertyValue([...lines, "Write-Output (@{ hwnd = [string]$hwnd; name = $el.Current.Name } | ConvertTo-Json -Compress)"], t), runScript, args);
|
||
};
|
||
|
||
const activateWindowGround: CapabilityHandler = async (_ctx, node, args) => {
|
||
const t = target(args, node);
|
||
return runWithImplicitWait(t, "activateWindow", propertyValue([
|
||
"$hwnd = Activate-RpaWindow $el",
|
||
"Write-Output (@{ hwnd = [string]$hwnd; name = $el.Current.Name } | ConvertTo-Json -Compress)"
|
||
], t), runScript, args);
|
||
};
|
||
|
||
const moveWindowGround: CapabilityHandler = async (_ctx, node, args) => {
|
||
const t = target(args, node);
|
||
const x = Math.round(Number(args.x ?? 60)) || 0;
|
||
const y = Math.round(Number(args.y ?? 60)) || 0;
|
||
const width = Math.round(Number(args.width ?? 0)) || 0;
|
||
const height = Math.round(Number(args.height ?? 0)) || 0;
|
||
return runWithImplicitWait(t, "moveWindow", propertyValue([
|
||
"$hwnd = Move-RpaWindow $el " + x + " " + y + " " + width + " " + height,
|
||
"Write-Output (@{ hwnd = [string]$hwnd; name = $el.Current.Name } | ConvertTo-Json -Compress)"
|
||
], t), runScript, args);
|
||
};
|
||
|
||
register("Element_WaitElementGround", waitElementGround);
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
function outputPath(args: Record<string, unknown>, extension = "png"): string {
|
||
const raw = args.filePath ?? args.folder ?? args.path;
|
||
const folder = path.resolve(String(raw && String(raw).trim() ? raw : process.cwd()));
|
||
const rawName = String(args.filename ?? args.fileName ?? args.name ?? `element-${Date.now()}.${extension}`)
|
||
.replace(/\$\{randomString\}/g, Math.random().toString(36).slice(2, 10));
|
||
const name = /\.[A-Za-z0-9]{2,8}$/.test(rawName) ? rawName : `${rawName}.${extension}`;
|
||
return path.join(folder, name);
|
||
}
|
||
|
||
function propertyValue(scriptLines: string[], t: UiaTarget): string {
|
||
const s = t.selector;
|
||
return `${UIA_USING}
|
||
${FIND_HELPER}
|
||
$el = $null
|
||
try {
|
||
${elementResolveLines(s)}
|
||
} catch { $el = $null }
|
||
if ($null -eq $el) { Write-Output "JSON_NOT_FOUND"; exit 0 }
|
||
${scriptLines.join("\n")}`;
|
||
}
|
||
|
||
const getCheckGround: CapabilityHandler = async (_ctx, node, args) => {
|
||
const t = target(args, node);
|
||
const result = await runWithImplicitWait(t, "getCheck", propertyValue([
|
||
"$checked = $false",
|
||
"try { $checked = $el.GetCurrentPattern([System.Windows.Automation.TogglePattern]::Pattern).Current.ToggleState -eq [System.Windows.Automation.ToggleState]::On } catch {}",
|
||
"Write-Output (@{ value = [bool]$checked; name = $el.Current.Name; enabled = [bool]$el.Current.IsEnabled } | ConvertTo-Json -Compress)"
|
||
], t), runScript, args);
|
||
return result.value === true;
|
||
};
|
||
|
||
const setCheckGround: CapabilityHandler = async (_ctx, node, args) => {
|
||
const t = target(args, node);
|
||
const desired = ["1", "true", "yes", "on"].includes(String(args.isChecked ?? "1").toLowerCase());
|
||
return runWithImplicitWait(t, "setCheck", propertyValue([
|
||
`$desired = ${desired ? "$true" : "$false"}`,
|
||
"$current = $false; $hasToggle = $false",
|
||
"try { $pattern = $el.GetCurrentPattern([System.Windows.Automation.TogglePattern]::Pattern); $current = $pattern.Current.ToggleState -eq [System.Windows.Automation.ToggleState]::On; $hasToggle = $true } catch {}",
|
||
"$method = if ($hasToggle) { 'toggle_pattern' } else { 'coordinate_click' }",
|
||
"if (-not $hasToggle -or $current -ne $desired) {",
|
||
" if ($hasToggle) { $pattern.Toggle() | Out-Null }",
|
||
" else {",
|
||
" $rect = $el.Current.BoundingRectangle; Add-RpaUser32",
|
||
" [Win32Rpa.RpaUser32Click]::SetCursorPos([int]($rect.X + $rect.Width / 2), [int]($rect.Y + $rect.Height / 2)) | Out-Null",
|
||
" Start-Sleep -Milliseconds 100; [Win32Rpa.RpaUser32Click]::mouse_event(0x0002,0,0,0,[UIntPtr]::Zero)",
|
||
" Start-Sleep -Milliseconds 50; [Win32Rpa.RpaUser32Click]::mouse_event(0x0004,0,0,0,[UIntPtr]::Zero)",
|
||
" }",
|
||
"}",
|
||
"$after = $false; try { $after = $el.GetCurrentPattern([System.Windows.Automation.TogglePattern]::Pattern).Current.ToggleState -eq [System.Windows.Automation.ToggleState]::On } catch {}",
|
||
"Write-Output (@{ set = ($after -eq $desired); checked = [bool]$after; method = $method; name = $el.Current.Name } | ConvertTo-Json -Compress)"
|
||
], t), runScript, args);
|
||
};
|
||
|
||
const regionGround: CapabilityHandler = async (_ctx, node, args) => {
|
||
const t = target(args, node);
|
||
const relative = String(args.position ?? "3") === "2";
|
||
const result = await runWithImplicitWait(t, "getRegion", propertyValue([
|
||
"$rect = $el.Current.BoundingRectangle; $ox = 0; $oy = 0",
|
||
relative ? [
|
||
"try {",
|
||
" Add-RpaUser32",
|
||
' Add-Type -MemberDefinition \'[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect); public struct RECT { public int Left; public int Top; public int Right; public int Bottom; }\' -Name RpaWindowRect -Namespace Win32RpaRect | Out-Null',
|
||
" $walker = [System.Windows.Automation.TreeWalker]::ControlViewWalker; $ancestor = $el",
|
||
" while ($null -ne $ancestor) {",
|
||
" $hwnd = $ancestor.Current.NativeWindowHandle",
|
||
" if ($hwnd -ne 0) { $wr = New-Object Win32RpaRect.RpaWindowRect+RECT; [void][Win32RpaRect.RpaWindowRect]::GetWindowRect([IntPtr]$hwnd, [ref]$wr); $ox = $wr.Left; $oy = $wr.Top; break }",
|
||
" $ancestor = $walker.GetParent($ancestor)",
|
||
" }",
|
||
"} catch {}",
|
||
].join("\n") : "",
|
||
"Write-Output (@{ x = [int]($rect.X - $ox); y = [int]($rect.Y - $oy); width = [int]$rect.Width; height = [int]$rect.Height } | ConvertTo-Json -Compress)"
|
||
], t), runScript, args);
|
||
return result;
|
||
};
|
||
|
||
function getSelectScript(t: UiaTarget): string {
|
||
const byText = String(t.args.choose ?? "1") !== "2";
|
||
return propertyValue([
|
||
"$result = @(); $method = 'selection_pattern'",
|
||
"try { $sel = $el.GetCurrentPattern([System.Windows.Automation.SelectionPattern]::Pattern); foreach ($item in $sel.Current.GetSelection()) { $value = $item.Current.Name; if (-not " + (byText ? "$true" : "$false") + ") { $value = $item.Current.AutomationId }; $result += @{ text = $item.Current.Name; value = $item.Current.AutomationId; selected = $true } } } catch {}",
|
||
"if ($result.Count -eq 0) { try { $value = $el.GetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern).Value } catch { $value = $el.Current.Name }; $result += @{ text = [string]$value; value = [string]$value; selected = $true }; $method = 'value_pattern' }",
|
||
"Write-Output (@{ result = $result; method = $method; name = $el.Current.Name } | ConvertTo-Json -Compress -Depth 3)"
|
||
], t);
|
||
}
|
||
|
||
const getSelectGround: CapabilityHandler = async (_ctx, node, args) => {
|
||
const t = target(args, node);
|
||
const result = await runWithImplicitWait(t, "getSelect", getSelectScript(t), runScript, args);
|
||
const items = Array.isArray(result.result) ? result.result : [];
|
||
return items.length === 1 ? items[0] : items;
|
||
};
|
||
|
||
function setSelectScript(t: UiaTarget, values: string[]): string {
|
||
const encoded = Buffer.from(JSON.stringify(values), "utf16le").toString("base64");
|
||
const choose = String(t.args.choose ?? "1");
|
||
return propertyValue([
|
||
`$desired = [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String('${encoded}')) | ConvertFrom-Json`,
|
||
"$items = @($el.FindAll([System.Windows.Automation.TreeScope]::Descendants, [System.Windows.Automation.Condition]::TrueCondition) | Where-Object { $_.Current.ControlType.ProgrammaticName -match 'ListItem|ComboBoxItem' })",
|
||
"if ($items.Count -eq 0) { try { $el.Expand() } catch {}; Start-Sleep -Milliseconds 250; $items = @($el.FindAll([System.Windows.Automation.TreeScope]::Descendants, [System.Windows.Automation.Condition]::TrueCondition) | Where-Object { $_.Current.ControlType.ProgrammaticName -match 'ListItem|ComboBoxItem' }) }",
|
||
"$targets = @(); $method = 'selection_item_pattern'",
|
||
"foreach ($wanted in @($desired)) {",
|
||
` if ('${choose}' -eq '0') { $ix = [int]$wanted - 1; if ($ix -ge 0 -and $ix -lt $items.Count) { $targets += $items[$ix] } }`,
|
||
` elseif ('${choose}' -eq '2') { $targets += @($items | Where-Object { $_.Current.AutomationId -eq [string]$wanted }) }`,
|
||
" else { $targets += @($items | Where-Object { $_.Current.Name -eq [string]$wanted }) }",
|
||
"}",
|
||
"$ok = $targets.Count -gt 0",
|
||
"foreach ($item in $targets) { try { $item.GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern).Select() } catch { try { $item.Invoke() } catch { $ok = $false } } }",
|
||
"try { $el.Collapse() } catch {}",
|
||
"Write-Output (@{ set = [bool]$ok; matched = $targets.Count; available = $items.Count; method = $method; name = $el.Current.Name } | ConvertTo-Json -Compress)"
|
||
], t);
|
||
}
|
||
|
||
const setSelectGround: CapabilityHandler = async (_ctx, node, args) => {
|
||
const t = target(args, node);
|
||
const raw = args.itemsToSelect ?? args.value ?? args.text;
|
||
let values: string[] = [];
|
||
if (Array.isArray(raw)) values = raw.map((value) => String(value));
|
||
else if (raw !== undefined && raw !== null && String(raw).trim() !== "") {
|
||
try { const parsed = JSON.parse(String(raw)); values = Array.isArray(parsed) ? parsed.map((value) => String(value)) : [String(parsed)]; }
|
||
catch { values = [String(raw)]; }
|
||
}
|
||
if (!values.length) throw new RuntimeError("validation_error", "下拉框选择项为空", { nodeId: node.id, componentId: node.componentId });
|
||
return runWithImplicitWait(t, "setSelect", setSelectScript(t, values), runScript, args);
|
||
};
|
||
|
||
function attrScript(t: UiaTarget, attrName: string, attrValue = ""): string {
|
||
const name = attrName.toLowerCase();
|
||
if (name === "value" || name === "text") {
|
||
return propertyValue([
|
||
"try { $value = $el.GetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern).Value } catch { $value = $el.Current.Name }",
|
||
"Write-Output (@{ value = [string]$value; name = $el.Current.Name } | ConvertTo-Json -Compress)"
|
||
], t);
|
||
}
|
||
if (!attrValue) {
|
||
return propertyValue([
|
||
"$p = @{ name = $el.Current.Name; id = $el.Current.AutomationId; class = $el.Current.ClassName; type = $el.Current.ControlType.ProgrammaticName; enabled = $el.Current.IsEnabled; help = $el.Current.HelpText; value = $el.Current.Name }",
|
||
`$key = ${psString(name || "name")}`,
|
||
"$result = if ($p.Contains($key)) { $p[$key] } else { $p.name }",
|
||
"Write-Output (@{ value = [string]$result; properties = $p; name = $el.Current.Name } | ConvertTo-Json -Compress)"
|
||
], t);
|
||
}
|
||
if (name !== "value" && name !== "text") throw new RuntimeError("validation_error", `桌面 UIA 不支持设置属性:${attrName};可设置 value/text`, { nodeId: t.node.id, componentId: t.node.componentId });
|
||
return propertyValue([
|
||
"$ok = $false; $method = 'value_pattern'",
|
||
`try { $el.GetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern).SetValue(${psString(attrValue)}); $ok = $true } catch {}`,
|
||
"if (-not $ok) { $method = 'keyboard'; Add-Type -AssemblyName System.Windows.Forms; $el.SetFocus() | Out-Null; Start-Sleep -Milliseconds 100; [System.Windows.Forms.SendKeys]::SendWait('^a'); [System.Windows.Forms.SendKeys]::SendWait('^v'); Start-Sleep -Milliseconds 100; $ok = $true }",
|
||
"Write-Output (@{ set = $ok; method = $method; name = $el.Current.Name } | ConvertTo-Json -Compress)"
|
||
], t);
|
||
}
|
||
|
||
const getAttributeGround: CapabilityHandler = async (_ctx, node, args) => {
|
||
const t = target(args, node);
|
||
const attrName = String(args.attrName ?? args.attribute ?? "name");
|
||
const result = await runWithImplicitWait(t, "getAttribute", attrScript(t, attrName), runScript, args);
|
||
return result.value ?? "";
|
||
};
|
||
|
||
const setAttributeGround: CapabilityHandler = async (_ctx, node, args) => {
|
||
const t = target(args, node);
|
||
const attrName = String(args.attrName ?? args.attribute ?? "value");
|
||
const attrValue = String(args.attrValue ?? args.value ?? "");
|
||
return runWithImplicitWait(t, "setAttribute", attrScript(t, attrName, attrValue), runScript, args);
|
||
};
|
||
|
||
const getButtonEnableGround: CapabilityHandler = async (_ctx, node, args) => {
|
||
const t = target(args, node);
|
||
const result = await runWithImplicitWait(t, "getButtonEnable", propertyValue(["Write-Output (@{ value = [bool]$el.Current.IsEnabled; name = $el.Current.Name } | ConvertTo-Json -Compress)"], t), runScript, args);
|
||
return result.value === true;
|
||
};
|
||
|
||
const shotGround: CapabilityHandler = async (_ctx, node, args) => {
|
||
const t = target(args, node);
|
||
const output = outputPath(args);
|
||
fs.mkdirSync(path.dirname(output), { recursive: true });
|
||
const result = await runWithImplicitWait(t, "shot", propertyValue([
|
||
"$rect = $el.Current.BoundingRectangle; if ($rect.Width -le 0 -or $rect.Height -le 0) { throw 'element_not_visible' }",
|
||
"Add-Type -AssemblyName System.Drawing | Out-Null",
|
||
"$bitmap = New-Object System.Drawing.Bitmap([int]$rect.Width, [int]$rect.Height)",
|
||
"$graphics = [System.Drawing.Graphics]::FromImage($bitmap)",
|
||
"$graphics.CopyFromScreen([int]$rect.X, [int]$rect.Y, 0, 0, $bitmap.Size); $graphics.Dispose()",
|
||
`$bitmap.Save(${psString(output)}, [System.Drawing.Imaging.ImageFormat]::Png); $bitmap.Dispose()`,
|
||
`Write-Output (@{ value = ${psString(output)}; x = [int]$rect.X; y = [int]$rect.Y; width = [int]$rect.Width; height = [int]$rect.Height } | ConvertTo-Json -Compress)`
|
||
], t), runScript, args);
|
||
return result.value ?? output;
|
||
};
|
||
|
||
const mouseToGround: CapabilityHandler = async (_ctx, node, args) => {
|
||
const t = target(args, node);
|
||
return runWithImplicitWait(t, "mouseTo", propertyValue([
|
||
"$rect = $el.Current.BoundingRectangle; Add-RpaUser32",
|
||
"$x = [int]($rect.X + $rect.Width / 2); $y = [int]($rect.Y + $rect.Height / 2)",
|
||
"[Win32Rpa.RpaUser32Click]::SetCursorPos($x, $y) | Out-Null",
|
||
"Write-Output (@{ x = $x; y = $y; name = $el.Current.Name } | ConvertTo-Json -Compress)"
|
||
], t), runScript, args);
|
||
};
|
||
|
||
const scrollIntoViewGround: CapabilityHandler = async (_ctx, node, args) => {
|
||
const t = target(args, node);
|
||
return runWithImplicitWait(t, "scrollIntoView", propertyValue([
|
||
"$ok = $false; $method = 'scroll_item_pattern'",
|
||
"try { $el.GetCurrentPattern([System.Windows.Automation.ScrollItemPattern]::Pattern).ScrollIntoView(); $ok = $true } catch {}",
|
||
"if (-not $ok) { $method = 'focus'; try { $el.SetFocus() | Out-Null; $ok = $true } catch {} }",
|
||
"Write-Output (@{ scrolled = $ok; method = $method; name = $el.Current.Name } | ConvertTo-Json -Compress)"
|
||
], t), runScript, args);
|
||
};
|
||
|
||
function childrenGroundHandler(single: boolean): CapabilityHandler {
|
||
return async (_ctx, node, args) => {
|
||
const t = target(args, node);
|
||
const num = Math.max(0, Math.floor(Number(args.num ?? 0) || 0));
|
||
const result = await runWithImplicitWait(t, "getChildren", propertyValue([
|
||
"$walker = [System.Windows.Automation.TreeWalker]::ControlViewWalker; $child = $walker.GetFirstChild($el); $children = @()",
|
||
"while ($null -ne $child) {",
|
||
" $r = $child.Current.BoundingRectangle",
|
||
" $children += @{ name = $child.Current.Name; id = $child.Current.AutomationId; type = $child.Current.ControlType.ProgrammaticName; className = $child.Current.ClassName; enabled = [bool]$child.Current.IsEnabled; x = [int]$r.X; y = [int]$r.Y; width = [int]$r.Width; height = [int]$r.Height }",
|
||
" $child = $walker.GetNextSibling($child)",
|
||
"}",
|
||
single ? `$n = ${num}; if ($n -ge 1 -and $n -le $children.Count) { $children = @($children[$n - 1]) }` : "",
|
||
"Write-Output (@{ result = $children; count = $children.Count } | ConvertTo-Json -Compress -Depth 4)"
|
||
], t), runScript, args);
|
||
return result.result ?? [];
|
||
};
|
||
}
|
||
|
||
register("Element_GetCheckGround", getCheckGround);
|
||
register("Element_SetCheckGround", setCheckGround);
|
||
register("Element_RegionGround", regionGround);
|
||
register("Element_GetSelectGround", getSelectGround);
|
||
register("Element_SetSelectGround", setSelectGround);
|
||
register("Element_GetAttributeGround", getAttributeGround);
|
||
register("Element_SetAttributeGround", setAttributeGround);
|
||
register("Element_GetButtonEnableGround", getButtonEnableGround);
|
||
register("Element_ShotGround", shotGround);
|
||
register("Element_MouseToGround", mouseToGround);
|
||
register("Element_ScrollIntoViewGround", scrollIntoViewGround);
|
||
register("Element_ChildrenGround", childrenGroundHandler(false));
|
||
register("Element_DesktopChildren", childrenGroundHandler(true));
|
||
register("Element_RestoreWindowGround", restoreWindowGround);
|
||
register("Element_ActivateWindowGround", activateWindowGround);
|
||
register("Element_MoveWindowGround", moveWindowGround);
|
||
|
||
/** ElementDriver 的 PowerShell 实现(任务 3.3,决策点 6):ground 原语委托本模块内部函数。
|
||
* handler 层面向 driver 接口的收口放到阶段 5/6 按需进行,避免大范围重写引入行为回归。 */
|
||
setElementDriverImplementation({
|
||
parseSelector: (raw) => parseUiaSelector(raw),
|
||
async find(selector, options) {
|
||
return findElement({ selector, args: {}, node: { id: "driver", componentId: "driver" } }, options?.timeoutMs ?? 5000, options?.gapMs ?? 200);
|
||
},
|
||
async exists(selector) {
|
||
return (await findElement({ selector, args: {}, node: { id: "driver", componentId: "driver" } }, 2000, 200)) !== null;
|
||
},
|
||
async click(selector, options) {
|
||
const t = { selector, args: {}, node: { id: "driver", componentId: "driver" } };
|
||
await runScript(clickScript(t), t, options?.timeoutMs ?? 60000);
|
||
},
|
||
async setValue(selector, value) {
|
||
const t = { selector, args: {}, node: { id: "driver", componentId: "driver" } };
|
||
await runScript(setValueScript(t, value), t);
|
||
},
|
||
async getValue(selector) {
|
||
const t = { selector, args: {}, node: { id: "driver", componentId: "driver" } };
|
||
return runScript(getValueScript(t), t);
|
||
}
|
||
});
|