@ai-agent-forge/plugin-desktop-windows-observe 0.85.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -0
- package/agent-forge.json +15 -0
- package/dist/capability.d.ts +13 -0
- package/dist/capability.d.ts.map +1 -0
- package/dist/capability.js +97 -0
- package/dist/capability.js.map +1 -0
- package/dist/entry.d.ts +14 -0
- package/dist/entry.d.ts.map +1 -0
- package/dist/entry.js +15 -0
- package/dist/entry.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/projection.d.ts +113 -0
- package/dist/projection.d.ts.map +1 -0
- package/dist/projection.js +226 -0
- package/dist/projection.js.map +1 -0
- package/dist/shared.d.ts +15 -0
- package/dist/shared.d.ts.map +1 -0
- package/dist/shared.js +30 -0
- package/dist/shared.js.map +1 -0
- package/dist/types.d.ts +202 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +21 -0
- package/dist/types.js.map +1 -0
- package/dist/windows-uia-adapter.d.ts +68 -0
- package/dist/windows-uia-adapter.d.ts.map +1 -0
- package/dist/windows-uia-adapter.js +711 -0
- package/dist/windows-uia-adapter.js.map +1 -0
- package/package.json +57 -0
- package/plugin.json +12 -0
|
@@ -0,0 +1,711 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inlined from agent-forge `src/plugins/desktop/windows-uia-adapter.ts`
|
|
3
|
+
* (D-075 S3 extraction) so this package keeps its dependency closure limited
|
|
4
|
+
* to the plugin SDK and typebox. Semantics are unchanged: the full driver
|
|
5
|
+
* (query + action PowerShell scripts) is carried completely even though this
|
|
6
|
+
* observe package only exposes the read-only capability surface. This file is
|
|
7
|
+
* a deliberate copy shared (not imported) with
|
|
8
|
+
* `plugins/desktop-windows-input/src/windows-uia-adapter.ts`; keep both
|
|
9
|
+
* copies in sync.
|
|
10
|
+
*/
|
|
11
|
+
import { spawnSync as nodeSpawnSync } from "node:child_process";
|
|
12
|
+
import { detachedFreeze } from "@agent-forge/plugin-sdk";
|
|
13
|
+
import { findDesktopReplaySessionMismatch, requiresDesktopUserConfirmation } from "./types.js";
|
|
14
|
+
const READONLY_CAPABILITIES = ["desktop-session", "desktop-uia"];
|
|
15
|
+
const SUPPORTED_ACTIONS = [
|
|
16
|
+
"focus",
|
|
17
|
+
"click",
|
|
18
|
+
"type",
|
|
19
|
+
"key",
|
|
20
|
+
"scroll",
|
|
21
|
+
];
|
|
22
|
+
const error = (code, message, causeCode) => ({
|
|
23
|
+
version: 1,
|
|
24
|
+
code,
|
|
25
|
+
message,
|
|
26
|
+
...(causeCode === undefined ? {} : { causeCode }),
|
|
27
|
+
});
|
|
28
|
+
const unsupported = (operationId, reason, causeCode = "platform-unavailable") => ({
|
|
29
|
+
version: 1,
|
|
30
|
+
operationId,
|
|
31
|
+
status: "unsupported",
|
|
32
|
+
summary: reason,
|
|
33
|
+
error: error(causeCode === "action-unsupported" ? "action_unsupported" : "platform_unavailable", reason, causeCode),
|
|
34
|
+
});
|
|
35
|
+
const rectangle = (raw) => ({
|
|
36
|
+
x: Number.isFinite(raw.x) ? Number(raw.x) : 0,
|
|
37
|
+
y: Number.isFinite(raw.y) ? Number(raw.y) : 0,
|
|
38
|
+
width: Number.isFinite(raw.width) ? Number(raw.width) : 0,
|
|
39
|
+
height: Number.isFinite(raw.height) ? Number(raw.height) : 0,
|
|
40
|
+
});
|
|
41
|
+
const QUERY_SCRIPT = `
|
|
42
|
+
try {
|
|
43
|
+
Add-Type -AssemblyName UIAutomationClient -ErrorAction Stop
|
|
44
|
+
Add-Type -AssemblyName UIAutomationTypes -ErrorAction Stop
|
|
45
|
+
function SafeInt($value) {
|
|
46
|
+
$number = [double]$value
|
|
47
|
+
if ([double]::IsNaN($number) -or [double]::IsInfinity($number)) { return 0 }
|
|
48
|
+
return [int][math]::Round($number)
|
|
49
|
+
}
|
|
50
|
+
function Get-ControlId($node, [int]$index) {
|
|
51
|
+
$automationId = [string]$node.Current.AutomationId
|
|
52
|
+
$name = [string]$node.Current.Name
|
|
53
|
+
$role = [string]$node.Current.ControlType.ProgrammaticName
|
|
54
|
+
if ($automationId.Length -gt 0) { return $automationId }
|
|
55
|
+
if ($name.Length -gt 0) { return $role + ':' + $name }
|
|
56
|
+
return $role + ':' + $index
|
|
57
|
+
}
|
|
58
|
+
$root = [System.Windows.Automation.AutomationElement]::RootElement
|
|
59
|
+
$windowWalker = [System.Windows.Automation.TreeWalker]::ControlViewWalker
|
|
60
|
+
$windows = [System.Collections.Generic.List[object]]::new()
|
|
61
|
+
$controls = [System.Collections.Generic.List[object]]::new()
|
|
62
|
+
$focusedMatches = [System.Collections.Generic.List[object]]::new()
|
|
63
|
+
$focused = $null
|
|
64
|
+
try { $focused = [System.Windows.Automation.AutomationElement]::FocusedElement } catch {}
|
|
65
|
+
$maxWindows = 256
|
|
66
|
+
$maxControlsPerWindow = 1000
|
|
67
|
+
$maxControlsTotal = 10000
|
|
68
|
+
$maxDepth = 32
|
|
69
|
+
function Add-Descendants($node, [int64]$windowHandle, [int]$depth, [ref]$count) {
|
|
70
|
+
if ($null -eq $node -or $depth -gt $maxDepth -or $count.Value -ge $maxControlsPerWindow -or $controls.Count -ge $maxControlsTotal) { return }
|
|
71
|
+
$index = $count.Value
|
|
72
|
+
$count.Value = $count.Value + 1
|
|
73
|
+
$childRect = $node.Current.BoundingRectangle
|
|
74
|
+
$controlId = Get-ControlId $node $index
|
|
75
|
+
$value = $null
|
|
76
|
+
try {
|
|
77
|
+
$pattern = $null
|
|
78
|
+
if ($node.TryGetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern, [ref]$pattern)) { $value = [string]$pattern.Current.Value }
|
|
79
|
+
} catch {}
|
|
80
|
+
$name = [string]$node.Current.Name
|
|
81
|
+
$nativeWindowHandle = [int64]$node.Current.NativeWindowHandle
|
|
82
|
+
if ($null -eq $value -and $name.Length -gt 0) { $value = $name }
|
|
83
|
+
$controls.Add([pscustomobject]@{ windowHandle=$windowHandle; nativeWindowHandle=$nativeWindowHandle; controlId=$controlId; name=$name; role=[string]$node.Current.ControlType.ProgrammaticName; enabled=[bool]$node.Current.IsEnabled; value=$value; x=(SafeInt $childRect.X); y=(SafeInt $childRect.Y); width=(SafeInt $childRect.Width); height=(SafeInt $childRect.Height) })
|
|
84
|
+
try {
|
|
85
|
+
if ($null -ne $focused -and $node.Equals($focused)) { $focusedMatches.Add([pscustomobject]@{ windowHandle=$windowHandle; controlId=$controlId }) }
|
|
86
|
+
} catch {}
|
|
87
|
+
$child = $windowWalker.GetFirstChild($node)
|
|
88
|
+
while ($null -ne $child -and $count.Value -lt $maxControlsPerWindow) {
|
|
89
|
+
Add-Descendants $child $windowHandle ($depth + 1) $count
|
|
90
|
+
$child = $windowWalker.GetNextSibling($child)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
$window = $windowWalker.GetFirstChild($root)
|
|
94
|
+
$windowCount = 0
|
|
95
|
+
while ($null -ne $window -and $windowCount -lt $maxWindows) {
|
|
96
|
+
$handle = [int64]$window.Current.NativeWindowHandle
|
|
97
|
+
if ($handle -ne 0) {
|
|
98
|
+
$rect = $window.Current.BoundingRectangle
|
|
99
|
+
$processId = [int]$window.Current.ProcessId
|
|
100
|
+
$windows.Add([pscustomobject]@{ handle=$handle; processId=$processId; name=[string]$window.Current.Name; x=(SafeInt $rect.X); y=(SafeInt $rect.Y); width=(SafeInt $rect.Width); height=(SafeInt $rect.Height) })
|
|
101
|
+
$windowCount++
|
|
102
|
+
$child = $windowWalker.GetFirstChild($window)
|
|
103
|
+
$count = 0
|
|
104
|
+
while ($null -ne $child -and $count -lt $maxControlsPerWindow -and $controls.Count -lt $maxControlsTotal) {
|
|
105
|
+
Add-Descendants $child $handle 1 ([ref]$count)
|
|
106
|
+
$child = $windowWalker.GetNextSibling($child)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
$window = $windowWalker.GetNextSibling($window)
|
|
110
|
+
}
|
|
111
|
+
$focusedControl = if ($focusedMatches.Count -gt 0) { $focusedMatches[0] } else { $null }
|
|
112
|
+
[pscustomobject]@{ windows=@($windows); controls=@($controls); focusedControl=$focusedControl; available=$true } | ConvertTo-Json -Compress -Depth 8
|
|
113
|
+
} catch {
|
|
114
|
+
[pscustomobject]@{ available=$false; error=[string]$_.Exception.Message } | ConvertTo-Json -Compress -Depth 5
|
|
115
|
+
}`;
|
|
116
|
+
const ACTION_SCRIPT = `
|
|
117
|
+
try {
|
|
118
|
+
Add-Type -AssemblyName UIAutomationClient -ErrorAction Stop
|
|
119
|
+
Add-Type -AssemblyName UIAutomationTypes -ErrorAction Stop
|
|
120
|
+
$payload = $env:AGENT_FORGE_UIA_INPUT | ConvertFrom-Json
|
|
121
|
+
$handle = [int64]$payload.target.windowHandle
|
|
122
|
+
$window = [System.Windows.Automation.AutomationElement]::FromHandle([IntPtr]$handle)
|
|
123
|
+
if ($null -eq $window) { throw 'Window handle is no longer available' }
|
|
124
|
+
$walker = [System.Windows.Automation.TreeWalker]::ControlViewWalker
|
|
125
|
+
$target = $window
|
|
126
|
+
$script:AgentForgeTarget = $null
|
|
127
|
+
if ($null -ne $payload.target.controlId) {
|
|
128
|
+
$controlHandle = [int64]$payload.target.controlWindowHandle
|
|
129
|
+
if ($controlHandle -gt 0) {
|
|
130
|
+
try {
|
|
131
|
+
$candidate = [System.Windows.Automation.AutomationElement]::FromHandle([IntPtr]$controlHandle)
|
|
132
|
+
if ($null -ne $candidate) {
|
|
133
|
+
$candidateAutomationId = [string]$candidate.Current.AutomationId
|
|
134
|
+
$candidateName = [string]$candidate.Current.Name
|
|
135
|
+
$candidateRole = [string]$candidate.Current.ControlType.ProgrammaticName
|
|
136
|
+
$candidateId = if ($candidateAutomationId.Length -gt 0) { $candidateAutomationId } elseif ($candidateName.Length -gt 0) { $candidateRole + ':' + $candidateName } else { $null }
|
|
137
|
+
if ($candidateId -eq [string]$payload.target.controlId) { $script:AgentForgeTarget = $candidate }
|
|
138
|
+
}
|
|
139
|
+
} catch {}
|
|
140
|
+
}
|
|
141
|
+
$maxDepth = 32
|
|
142
|
+
$maxVisited = 1000
|
|
143
|
+
function Find-Descendant($node, [int]$depth, [ref]$visited) {
|
|
144
|
+
if ($null -eq $node -or $depth -gt $maxDepth -or $visited.Value -ge $maxVisited -or $null -ne $script:AgentForgeTarget) { return }
|
|
145
|
+
$visited.Value = $visited.Value + 1
|
|
146
|
+
$automationId = [string]$node.Current.AutomationId
|
|
147
|
+
$name = [string]$node.Current.Name
|
|
148
|
+
$role = [string]$node.Current.ControlType.ProgrammaticName
|
|
149
|
+
$id = if ($automationId.Length -gt 0) { $automationId } elseif ($name.Length -gt 0) { $role + ':' + $name } else { $role + ':' + ($visited.Value - 1) }
|
|
150
|
+
if ($id -eq [string]$payload.target.controlId) { $script:AgentForgeTarget = $node; return }
|
|
151
|
+
$child = $walker.GetFirstChild($node)
|
|
152
|
+
while ($null -ne $child -and $visited.Value -lt $maxVisited -and $null -eq $script:AgentForgeTarget) {
|
|
153
|
+
Find-Descendant $child ($depth + 1) $visited
|
|
154
|
+
$child = $walker.GetNextSibling($child)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
$visited = 0
|
|
158
|
+
if ($null -eq $script:AgentForgeTarget) {
|
|
159
|
+
$node = $walker.GetFirstChild($window)
|
|
160
|
+
while ($null -ne $node -and $visited -lt $maxVisited -and $null -eq $script:AgentForgeTarget) {
|
|
161
|
+
Find-Descendant $node 1 ([ref]$visited)
|
|
162
|
+
$node = $walker.GetNextSibling($node)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if ($null -eq $script:AgentForgeTarget) { throw ('Control was not found: ' + [string]$payload.target.controlId) }
|
|
166
|
+
$target = $script:AgentForgeTarget
|
|
167
|
+
}
|
|
168
|
+
if ($payload.action.kind -eq 'focus') { $target.SetFocus() }
|
|
169
|
+
elseif ($payload.action.kind -eq 'click') {
|
|
170
|
+
$pattern = $null
|
|
171
|
+
$invoked = $false
|
|
172
|
+
try {
|
|
173
|
+
if ($target.TryGetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern, [ref]$pattern)) {
|
|
174
|
+
$pattern.Invoke()
|
|
175
|
+
$invoked = $true
|
|
176
|
+
}
|
|
177
|
+
} catch {}
|
|
178
|
+
if (-not $invoked) {
|
|
179
|
+
if ($controlHandle -le 0) { throw 'Control does not support InvokePattern' }
|
|
180
|
+
try {
|
|
181
|
+
Add-Type @'
|
|
182
|
+
using System;
|
|
183
|
+
using System.Runtime.InteropServices;
|
|
184
|
+
public static class AgentForgeNativeClick {
|
|
185
|
+
[DllImport("user32.dll", CharSet=CharSet.Unicode, SetLastError=true)]
|
|
186
|
+
public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam, uint flags, uint timeout, out IntPtr result);
|
|
187
|
+
}
|
|
188
|
+
'@
|
|
189
|
+
$sendResult = [IntPtr]::Zero
|
|
190
|
+
$callResult = [AgentForgeNativeClick]::SendMessageTimeout([IntPtr]$controlHandle, 0x00F5, [IntPtr]::Zero, [IntPtr]::Zero, 0x0002, 3000, [ref]$sendResult)
|
|
191
|
+
if ($callResult -eq [IntPtr]::Zero) { throw 'Target click failed' }
|
|
192
|
+
} catch { throw 'Target click failed' }
|
|
193
|
+
}
|
|
194
|
+
} elseif ($payload.action.kind -eq 'type') {
|
|
195
|
+
$pattern = $null
|
|
196
|
+
$set = $false
|
|
197
|
+
try {
|
|
198
|
+
if ($target.TryGetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern, [ref]$pattern)) {
|
|
199
|
+
$pattern.SetValue([string]$payload.action.text)
|
|
200
|
+
$set = $true
|
|
201
|
+
}
|
|
202
|
+
} catch {}
|
|
203
|
+
if (-not $set) {
|
|
204
|
+
$legacy = $null
|
|
205
|
+
try {
|
|
206
|
+
if ($target.TryGetCurrentPattern([System.Windows.Automation.LegacyIAccessiblePattern]::Pattern, [ref]$legacy)) {
|
|
207
|
+
$legacy.SetValue([string]$payload.action.text)
|
|
208
|
+
$set = $true
|
|
209
|
+
}
|
|
210
|
+
} catch {}
|
|
211
|
+
}
|
|
212
|
+
if (-not $set) {
|
|
213
|
+
if ($controlHandle -le 0) { throw 'Control does not support ValuePattern or LegacyIAccessiblePattern' }
|
|
214
|
+
try {
|
|
215
|
+
Add-Type @'
|
|
216
|
+
using System;
|
|
217
|
+
using System.Runtime.InteropServices;
|
|
218
|
+
public static class AgentForgeNativeText {
|
|
219
|
+
[DllImport("user32.dll", CharSet=CharSet.Unicode, SetLastError=true)]
|
|
220
|
+
public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg, IntPtr wParam, string lParam, uint flags, uint timeout, out IntPtr result);
|
|
221
|
+
}
|
|
222
|
+
'@
|
|
223
|
+
$sendResult = [IntPtr]::Zero
|
|
224
|
+
$callResult = [AgentForgeNativeText]::SendMessageTimeout([IntPtr]$controlHandle, 0x000C, [IntPtr]::Zero, [string]$payload.action.text, 0x0002, 3000, [ref]$sendResult)
|
|
225
|
+
if ($callResult -eq [IntPtr]::Zero -or $sendResult -eq [IntPtr]::Zero) { throw 'Target text update failed' }
|
|
226
|
+
} catch { throw 'Target text update failed' }
|
|
227
|
+
}
|
|
228
|
+
} elseif ($payload.action.kind -eq 'key') {
|
|
229
|
+
$keyText = [string]$payload.action.key
|
|
230
|
+
$hasSendKeysSyntax = $keyText -match '[+%^{}]'
|
|
231
|
+
if (-not $hasSendKeysSyntax -and $controlHandle -gt 0) {
|
|
232
|
+
# Plain text with a real control handle: deliver synchronously through
|
|
233
|
+
# SendMessage WM_CHAR. Queued input (SendInput / WScript SendKeys) never
|
|
234
|
+
# reaches GUI windows in non-interactive or remote desktop sessions, while
|
|
235
|
+
# synchronous SendMessage does — the same path that type (WM_SETTEXT) and
|
|
236
|
+
# click (BM_CLICK) already use. Keyboard focus is not required.
|
|
237
|
+
Add-Type @'
|
|
238
|
+
using System;
|
|
239
|
+
using System.Runtime.InteropServices;
|
|
240
|
+
public static class AgentForgeNativeKey {
|
|
241
|
+
[DllImport("user32.dll", SetLastError=true)] public static extern IntPtr SendMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
|
|
242
|
+
}
|
|
243
|
+
'@
|
|
244
|
+
foreach ($ch in $keyText.ToCharArray()) {
|
|
245
|
+
[AgentForgeNativeKey]::SendMessage([IntPtr]$controlHandle, 0x0102, [IntPtr][char]$ch, [IntPtr]::Zero) | Out-Null
|
|
246
|
+
}
|
|
247
|
+
} else {
|
|
248
|
+
if ($payload.target.controlId) { $target.SetFocus() }
|
|
249
|
+
$wshell = New-Object -ComObject WScript.Shell
|
|
250
|
+
$wshell.SendKeys($keyText)
|
|
251
|
+
}
|
|
252
|
+
} elseif ($payload.action.kind -eq 'scroll') {
|
|
253
|
+
Add-Type @'
|
|
254
|
+
using System;
|
|
255
|
+
using System.Runtime.InteropServices;
|
|
256
|
+
public static class AgentForgeNativeScroll {
|
|
257
|
+
[DllImport("user32.dll", SetLastError=true)]
|
|
258
|
+
public static extern bool SetCursorPos(int X, int Y);
|
|
259
|
+
[DllImport("user32.dll")]
|
|
260
|
+
public static extern void mouse_event(uint dwFlags, int dx, int dy, int dwData, UIntPtr dwExtraInfo);
|
|
261
|
+
}
|
|
262
|
+
'@
|
|
263
|
+
$direction = [string]$payload.action.text
|
|
264
|
+
$directionMatch = [regex]::Match($direction, '^(up|down)(?::(\\d+))?$')
|
|
265
|
+
if (-not $directionMatch.Success) { throw 'Scroll direction must be up or down with optional :lines' }
|
|
266
|
+
$lines = if ($directionMatch.Groups[2].Value) { [int]$directionMatch.Groups[2].Value } else { 3 }
|
|
267
|
+
if ($lines -lt 1 -or $lines -gt 100) { throw 'Scroll lines must be between 1 and 100' }
|
|
268
|
+
try {
|
|
269
|
+
$rect = $target.Current.BoundingRectangle
|
|
270
|
+
$centerX = [int]($rect.X + $rect.Width / 2)
|
|
271
|
+
$centerY = [int]($rect.Y + $rect.Height / 2)
|
|
272
|
+
[AgentForgeNativeScroll]::SetCursorPos($centerX, $centerY) | Out-Null
|
|
273
|
+
} catch {}
|
|
274
|
+
$wheelDelta = 120 * $lines
|
|
275
|
+
if ($direction.StartsWith('down')) { $wheelDelta = -$wheelDelta }
|
|
276
|
+
[AgentForgeNativeScroll]::mouse_event(0x0800, 0, 0, $wheelDelta, [UIntPtr]::Zero)
|
|
277
|
+
} else { throw ('Action kind is not supported by UI Automation: ' + [string]$payload.action.kind) }
|
|
278
|
+
[pscustomobject]@{ ok=$true } | ConvertTo-Json -Compress
|
|
279
|
+
} catch {
|
|
280
|
+
$message = [string]$_.Exception.Message
|
|
281
|
+
$code = if ($message -match 'Window handle is no longer available|Control was not found') { 'stale_target' } else { 'action_failed' }
|
|
282
|
+
$reason = if ($message -match 'ValuePattern|LegacyIAccessiblePattern') { 'value_pattern_unavailable' } elseif ($message -match 'InvokePattern') { 'invoke_pattern_unavailable' } elseif ($message -match 'SetFocus') { 'focus_failed' } else { 'uia_action_exception' }
|
|
283
|
+
[pscustomobject]@{ ok=$false; code=$code; reason=$reason } | ConvertTo-Json -Compress
|
|
284
|
+
}`;
|
|
285
|
+
const parseJson = (stdout) => {
|
|
286
|
+
try {
|
|
287
|
+
const parsed = JSON.parse(stdout.trim());
|
|
288
|
+
return typeof parsed === "object" && parsed !== null ? parsed : undefined;
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
return undefined;
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
export class WindowsUiAutomationDriver {
|
|
295
|
+
driverId = "windows-uia";
|
|
296
|
+
driverVersion = "1.0.0";
|
|
297
|
+
command;
|
|
298
|
+
timeoutMs;
|
|
299
|
+
platform;
|
|
300
|
+
spawn;
|
|
301
|
+
sessions = new Map();
|
|
302
|
+
targets = new Map();
|
|
303
|
+
sessionTargetKeys = new Map();
|
|
304
|
+
latestObservationIds = new Map();
|
|
305
|
+
observedSessions = new Set();
|
|
306
|
+
nextSessionId = 1;
|
|
307
|
+
nextObservationId = 1;
|
|
308
|
+
constructor(options = {}) {
|
|
309
|
+
this.command = options.command ?? "powershell.exe";
|
|
310
|
+
this.timeoutMs = options.timeoutMs ?? 15_000;
|
|
311
|
+
this.platform = options.platform ?? process.platform;
|
|
312
|
+
this.spawn =
|
|
313
|
+
options.spawnSync ??
|
|
314
|
+
((command, args, spawnOptions) => nodeSpawnSync(command, args, spawnOptions));
|
|
315
|
+
}
|
|
316
|
+
probe() {
|
|
317
|
+
if (this.platform !== "win32") {
|
|
318
|
+
return {
|
|
319
|
+
available: false,
|
|
320
|
+
platform: this.platform,
|
|
321
|
+
driver: "windows-uia",
|
|
322
|
+
reason: "Windows UI Automation requires win32",
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
const result = this.run(QUERY_SCRIPT);
|
|
326
|
+
if (result === undefined)
|
|
327
|
+
return {
|
|
328
|
+
available: false,
|
|
329
|
+
platform: this.platform,
|
|
330
|
+
driver: "windows-uia",
|
|
331
|
+
reason: "PowerShell or UIAutomationClient is unavailable",
|
|
332
|
+
};
|
|
333
|
+
return result.available === true
|
|
334
|
+
? {
|
|
335
|
+
available: true,
|
|
336
|
+
platform: this.platform,
|
|
337
|
+
driver: "windows-uia",
|
|
338
|
+
capabilities: READONLY_CAPABILITIES,
|
|
339
|
+
}
|
|
340
|
+
: {
|
|
341
|
+
available: false,
|
|
342
|
+
platform: this.platform,
|
|
343
|
+
driver: "windows-uia",
|
|
344
|
+
reason: "UI Automation probe failed",
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
openSession(request) {
|
|
348
|
+
const probe = this.probe();
|
|
349
|
+
if (!probe.available)
|
|
350
|
+
return {
|
|
351
|
+
status: "unsupported",
|
|
352
|
+
error: error("platform_unavailable", probe.reason ?? "Windows UI Automation is unavailable", "platform-unavailable"),
|
|
353
|
+
};
|
|
354
|
+
const session = detachedFreeze({
|
|
355
|
+
version: 1,
|
|
356
|
+
sessionId: `desktop-windows-session-${this.nextSessionId++}`,
|
|
357
|
+
isolation: request.isolation,
|
|
358
|
+
status: "ready",
|
|
359
|
+
...(request.applicationId === undefined ? {} : { applicationId: request.applicationId }),
|
|
360
|
+
});
|
|
361
|
+
this.sessions.set(session.sessionId, session);
|
|
362
|
+
return { status: "succeeded", session };
|
|
363
|
+
}
|
|
364
|
+
observe(sessionId) {
|
|
365
|
+
const session = this.sessions.get(sessionId);
|
|
366
|
+
if (session === undefined)
|
|
367
|
+
return this.failed("observe", "session_not_found", "Session does not exist");
|
|
368
|
+
if (session.status === "stopped")
|
|
369
|
+
return this.cancelled("observe", "Session has been stopped");
|
|
370
|
+
const raw = this.run(QUERY_SCRIPT);
|
|
371
|
+
if (raw === undefined || raw.available !== true) {
|
|
372
|
+
this.invalidateObservation(sessionId);
|
|
373
|
+
return unsupported("observe", "Windows UI Automation observation failed");
|
|
374
|
+
}
|
|
375
|
+
return this.toObservation(session, raw);
|
|
376
|
+
}
|
|
377
|
+
execute(request) {
|
|
378
|
+
const session = this.sessions.get(request.sessionId);
|
|
379
|
+
if (session === undefined)
|
|
380
|
+
return this.failed(request.operationId, "session_not_found", "Session does not exist");
|
|
381
|
+
if (session.status === "stopped")
|
|
382
|
+
return this.cancelled(request.operationId, "Session has been stopped");
|
|
383
|
+
if (requiresDesktopUserConfirmation(request.action.kind))
|
|
384
|
+
return {
|
|
385
|
+
version: 1,
|
|
386
|
+
operationId: request.operationId,
|
|
387
|
+
status: "needs_user",
|
|
388
|
+
summary: `User confirmation required for ${request.action.kind}`,
|
|
389
|
+
error: error("needs_user", `The ${request.action.kind} action is paused for the user`, request.action.kind),
|
|
390
|
+
};
|
|
391
|
+
if (!SUPPORTED_ACTIONS.includes(request.action.kind))
|
|
392
|
+
return unsupported(request.operationId, "The selected Windows UI Automation adapter does not support this action", "action-unsupported");
|
|
393
|
+
if (request.action.target === undefined)
|
|
394
|
+
return this.failed(request.operationId, "target_required", "This desktop action requires an observed target");
|
|
395
|
+
if (request.action.kind === "type" && typeof request.action.text !== "string")
|
|
396
|
+
return this.failed(request.operationId, "text_required", "Type action requires text");
|
|
397
|
+
if (request.action.kind === "key" && typeof request.action.key !== "string")
|
|
398
|
+
return this.failed(request.operationId, "key_required", "Key action requires a key sequence");
|
|
399
|
+
if (request.action.kind === "scroll" && typeof request.action.text !== "string")
|
|
400
|
+
return this.failed(request.operationId, "direction_required", "Scroll action requires an up/down direction");
|
|
401
|
+
const postconditions = request.postconditions ?? [];
|
|
402
|
+
if (postconditions.length > 0 && request.reobserveAfterAction === false)
|
|
403
|
+
return this.failed(request.operationId, "reobserve_required", "Postconditions require an action re-observation");
|
|
404
|
+
const target = this.resolveTarget(request.sessionId, request.action.target);
|
|
405
|
+
if (target === undefined)
|
|
406
|
+
return this.failed(request.operationId, this.observedSessions.has(request.sessionId) ? "stale_target" : "target_not_found", this.observedSessions.has(request.sessionId)
|
|
407
|
+
? "Target identity is stale; reobserve the desktop before retrying"
|
|
408
|
+
: "Target identity was not observed by UI Automation");
|
|
409
|
+
const raw = this.run(ACTION_SCRIPT, { action: request.action, target: target ?? {} });
|
|
410
|
+
if (raw === undefined)
|
|
411
|
+
return unsupported(request.operationId, "Windows UI Automation action failed");
|
|
412
|
+
if (raw.ok !== true) {
|
|
413
|
+
if (raw.code === "stale_target") {
|
|
414
|
+
this.invalidateObservation(request.sessionId);
|
|
415
|
+
return this.failed(request.operationId, "stale_target", "Target identity is stale; reobserve the desktop before retrying");
|
|
416
|
+
}
|
|
417
|
+
const causeCode = typeof raw.reason === "string" &&
|
|
418
|
+
[
|
|
419
|
+
"value_pattern_unavailable",
|
|
420
|
+
"invoke_pattern_unavailable",
|
|
421
|
+
"focus_failed",
|
|
422
|
+
"uia_action_exception",
|
|
423
|
+
].includes(raw.reason)
|
|
424
|
+
? raw.reason
|
|
425
|
+
: "uia_action_exception";
|
|
426
|
+
return this.failed(request.operationId, "action_failed", "UI Automation action failed", causeCode);
|
|
427
|
+
}
|
|
428
|
+
const observation = request.reobserveAfterAction === false ? undefined : this.observe(request.sessionId);
|
|
429
|
+
const verification = this.verifyPostconditions(postconditions, observation);
|
|
430
|
+
if (postconditions.length > 0 && verification.status === "unknown")
|
|
431
|
+
return {
|
|
432
|
+
version: 1,
|
|
433
|
+
operationId: request.operationId,
|
|
434
|
+
status: "failed",
|
|
435
|
+
summary: "Desktop action completed but could not be re-observed",
|
|
436
|
+
verification,
|
|
437
|
+
error: error("verification_unavailable", "The requested desktop state could not be re-observed", "reobserve-failed"),
|
|
438
|
+
};
|
|
439
|
+
if (verification.status === "failed")
|
|
440
|
+
return {
|
|
441
|
+
version: 1,
|
|
442
|
+
operationId: request.operationId,
|
|
443
|
+
status: "failed",
|
|
444
|
+
summary: "Desktop action completed but a postcondition failed",
|
|
445
|
+
...(observation !== undefined && "observationId" in observation ? { observation } : {}),
|
|
446
|
+
verification,
|
|
447
|
+
error: error("postcondition_failed", "The requested desktop state was not observed", "verification-failed"),
|
|
448
|
+
};
|
|
449
|
+
return {
|
|
450
|
+
version: 1,
|
|
451
|
+
operationId: request.operationId,
|
|
452
|
+
status: "succeeded",
|
|
453
|
+
summary: "Desktop action completed through Windows UI Automation",
|
|
454
|
+
...(observation !== undefined && "observationId" in observation ? { observation } : {}),
|
|
455
|
+
verification,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
stop(sessionId) {
|
|
459
|
+
const session = this.sessions.get(sessionId);
|
|
460
|
+
if (session === undefined)
|
|
461
|
+
return this.failed("stop", "session_not_found", "Session does not exist");
|
|
462
|
+
const stopped = detachedFreeze({ ...session, status: "stopped" });
|
|
463
|
+
this.sessions.set(sessionId, stopped);
|
|
464
|
+
this.invalidateObservation(sessionId);
|
|
465
|
+
return stopped;
|
|
466
|
+
}
|
|
467
|
+
run(script, payload) {
|
|
468
|
+
if (this.platform !== "win32")
|
|
469
|
+
return undefined;
|
|
470
|
+
const env = {};
|
|
471
|
+
for (const key of ["SystemRoot", "WINDIR", "PATH", "PATHEXT", "ComSpec", "TEMP", "TMP"]) {
|
|
472
|
+
const value = process.env[key];
|
|
473
|
+
if (value !== undefined)
|
|
474
|
+
env[key] = value;
|
|
475
|
+
}
|
|
476
|
+
if (payload !== undefined)
|
|
477
|
+
env.AGENT_FORGE_UIA_INPUT = JSON.stringify(payload);
|
|
478
|
+
const result = this.spawn(this.command, ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], { encoding: "utf8", timeout: this.timeoutMs, windowsHide: true, env });
|
|
479
|
+
if (result.error !== undefined || result.status !== 0)
|
|
480
|
+
return undefined;
|
|
481
|
+
return parseJson(result.stdout);
|
|
482
|
+
}
|
|
483
|
+
toObservation(session, raw) {
|
|
484
|
+
this.invalidateObservation(session.sessionId);
|
|
485
|
+
const observationId = `desktop-windows-observation-${this.nextObservationId++}`;
|
|
486
|
+
const targetKeys = new Set();
|
|
487
|
+
this.sessionTargetKeys.set(session.sessionId, targetKeys);
|
|
488
|
+
const windows = [];
|
|
489
|
+
for (const item of raw.windows ?? []) {
|
|
490
|
+
if (!Number.isFinite(item.handle))
|
|
491
|
+
continue;
|
|
492
|
+
const handle = Number(item.handle);
|
|
493
|
+
const windowId = `window-${handle}`;
|
|
494
|
+
windows.push({
|
|
495
|
+
version: 1,
|
|
496
|
+
windowId,
|
|
497
|
+
appId: `process:${Number(item.processId ?? 0)}`,
|
|
498
|
+
title: item.name ?? "",
|
|
499
|
+
bounds: rectangle(item),
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
const visibleWindows = session.applicationId === undefined
|
|
503
|
+
? windows
|
|
504
|
+
: windows.filter((window) => window.appId === session.applicationId);
|
|
505
|
+
for (const window of visibleWindows) {
|
|
506
|
+
const handle = Number(/^window-(\d+)$/.exec(window.windowId)?.[1]);
|
|
507
|
+
if (!Number.isFinite(handle))
|
|
508
|
+
continue;
|
|
509
|
+
const key = this.targetKey(session.sessionId, "window", window.windowId);
|
|
510
|
+
this.targets.set(key, { sessionId: session.sessionId, observationId, windowHandle: handle });
|
|
511
|
+
targetKeys.add(key);
|
|
512
|
+
}
|
|
513
|
+
const controls = [];
|
|
514
|
+
for (const item of raw.controls ?? []) {
|
|
515
|
+
if (!Number.isFinite(item.windowHandle))
|
|
516
|
+
continue;
|
|
517
|
+
const windowId = `window-${Number(item.windowHandle)}`;
|
|
518
|
+
if (!visibleWindows.some((window) => window.windowId === windowId))
|
|
519
|
+
continue;
|
|
520
|
+
const controlId = `${windowId}:${item.controlId ?? "control"}`;
|
|
521
|
+
const key = this.targetKey(session.sessionId, "control", controlId);
|
|
522
|
+
this.targets.set(key, {
|
|
523
|
+
sessionId: session.sessionId,
|
|
524
|
+
observationId,
|
|
525
|
+
windowHandle: Number(item.windowHandle),
|
|
526
|
+
...(Number.isFinite(item.nativeWindowHandle) && Number(item.nativeWindowHandle) > 0
|
|
527
|
+
? { controlWindowHandle: Number(item.nativeWindowHandle) }
|
|
528
|
+
: {}),
|
|
529
|
+
controlId: item.controlId,
|
|
530
|
+
});
|
|
531
|
+
targetKeys.add(key);
|
|
532
|
+
controls.push({
|
|
533
|
+
version: 1,
|
|
534
|
+
controlId,
|
|
535
|
+
windowId,
|
|
536
|
+
role: item.role ?? "",
|
|
537
|
+
name: item.name ?? "",
|
|
538
|
+
bounds: rectangle(item),
|
|
539
|
+
enabled: item.enabled !== false,
|
|
540
|
+
...(typeof item.value === "string" && item.value.length > 0 ? { value: item.value } : {}),
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
const focusedControl = raw.focusedControl;
|
|
544
|
+
const focusedWindowId = Number.isFinite(focusedControl?.windowHandle)
|
|
545
|
+
? `window-${Number(focusedControl?.windowHandle)}`
|
|
546
|
+
: undefined;
|
|
547
|
+
const focusedControlId = focusedWindowId === undefined || typeof focusedControl?.controlId !== "string"
|
|
548
|
+
? undefined
|
|
549
|
+
: `${focusedWindowId}:${focusedControl.controlId}`;
|
|
550
|
+
const observation = detachedFreeze({
|
|
551
|
+
version: 1,
|
|
552
|
+
sessionId: session.sessionId,
|
|
553
|
+
observationId,
|
|
554
|
+
source: "windows-uia",
|
|
555
|
+
windows: visibleWindows,
|
|
556
|
+
controls,
|
|
557
|
+
...(focusedControlId !== undefined && controls.some((control) => control.controlId === focusedControlId)
|
|
558
|
+
? { focusedControlId }
|
|
559
|
+
: {}),
|
|
560
|
+
});
|
|
561
|
+
this.latestObservationIds.set(session.sessionId, observationId);
|
|
562
|
+
this.observedSessions.add(session.sessionId);
|
|
563
|
+
return observation;
|
|
564
|
+
}
|
|
565
|
+
resolveTarget(sessionId, target) {
|
|
566
|
+
const identity = target.kind === "control" ? target.controlId : target.windowId;
|
|
567
|
+
const record = this.targets.get(this.targetKey(sessionId, target.kind, identity));
|
|
568
|
+
if (record === undefined ||
|
|
569
|
+
record.sessionId !== sessionId ||
|
|
570
|
+
record.observationId !== this.latestObservationIds.get(sessionId))
|
|
571
|
+
return undefined;
|
|
572
|
+
return {
|
|
573
|
+
windowHandle: record.windowHandle,
|
|
574
|
+
...(record.controlWindowHandle === undefined ? {} : { controlWindowHandle: record.controlWindowHandle }),
|
|
575
|
+
...(record.controlId === undefined ? {} : { controlId: record.controlId }),
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
targetKey(sessionId, kind, identity) {
|
|
579
|
+
return `${sessionId}:${kind}:${identity}`;
|
|
580
|
+
}
|
|
581
|
+
invalidateObservation(sessionId) {
|
|
582
|
+
for (const key of this.sessionTargetKeys.get(sessionId) ?? [])
|
|
583
|
+
this.targets.delete(key);
|
|
584
|
+
this.sessionTargetKeys.delete(sessionId);
|
|
585
|
+
this.latestObservationIds.delete(sessionId);
|
|
586
|
+
}
|
|
587
|
+
verifyPostconditions(postconditions, observation) {
|
|
588
|
+
if (postconditions.length === 0)
|
|
589
|
+
return { status: "passed", checks: [] };
|
|
590
|
+
if (observation === undefined || !("observationId" in observation))
|
|
591
|
+
return { status: "unknown", checks: ["observation-unavailable"] };
|
|
592
|
+
const checks = [];
|
|
593
|
+
for (const condition of postconditions) {
|
|
594
|
+
const passed = this.checkPostcondition(condition, observation);
|
|
595
|
+
checks.push(`${condition.kind}:${passed ? "passed" : "failed"}`);
|
|
596
|
+
if (!passed)
|
|
597
|
+
return { status: "failed", checks };
|
|
598
|
+
}
|
|
599
|
+
return { status: "passed", checks };
|
|
600
|
+
}
|
|
601
|
+
checkPostcondition(condition, observation) {
|
|
602
|
+
if (condition.kind === "window-exists")
|
|
603
|
+
return observation.windows.some((window) => window.windowId === condition.windowId);
|
|
604
|
+
if (condition.kind === "control-exists")
|
|
605
|
+
return observation.controls.some((control) => control.controlId === condition.controlId);
|
|
606
|
+
if (condition.kind === "focused")
|
|
607
|
+
return observation.focusedControlId === condition.controlId;
|
|
608
|
+
const target = condition.target;
|
|
609
|
+
if (target.kind === "window") {
|
|
610
|
+
const window = observation.windows.find((item) => item.windowId === target.windowId);
|
|
611
|
+
if (window === undefined || condition.property !== "name")
|
|
612
|
+
return false;
|
|
613
|
+
return window.title === condition.expected;
|
|
614
|
+
}
|
|
615
|
+
const control = observation.controls.find((item) => {
|
|
616
|
+
if (item.controlId !== target.controlId)
|
|
617
|
+
return false;
|
|
618
|
+
return target.windowId === undefined || item.windowId === target.windowId;
|
|
619
|
+
});
|
|
620
|
+
if (control === undefined)
|
|
621
|
+
return false;
|
|
622
|
+
if (condition.property === "enabled")
|
|
623
|
+
return control.enabled === condition.expected;
|
|
624
|
+
if (condition.property === "name")
|
|
625
|
+
return control.name === condition.expected;
|
|
626
|
+
return control.value === condition.expected;
|
|
627
|
+
}
|
|
628
|
+
failed(operationId, code, message, causeCode) {
|
|
629
|
+
return { version: 1, operationId, status: "failed", summary: message, error: error(code, message, causeCode) };
|
|
630
|
+
}
|
|
631
|
+
cancelled(operationId, message) {
|
|
632
|
+
return {
|
|
633
|
+
version: 1,
|
|
634
|
+
operationId,
|
|
635
|
+
status: "cancelled",
|
|
636
|
+
summary: message,
|
|
637
|
+
error: error("cancelled", message, "stopped"),
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
/** Adapter facade that adds replay and the needs_user gate around a Windows driver. */
|
|
642
|
+
export class DesktopWindowsAdapter {
|
|
643
|
+
adapterId = "desktop-windows-uia";
|
|
644
|
+
adapterVersion = "1.0.0";
|
|
645
|
+
capabilities = READONLY_CAPABILITIES;
|
|
646
|
+
driver;
|
|
647
|
+
constructor(driver = new WindowsUiAutomationDriver()) {
|
|
648
|
+
this.driver = driver;
|
|
649
|
+
}
|
|
650
|
+
probe() {
|
|
651
|
+
return this.driver.probe();
|
|
652
|
+
}
|
|
653
|
+
openSession(request) {
|
|
654
|
+
return this.driver.openSession(request);
|
|
655
|
+
}
|
|
656
|
+
observe(sessionId) {
|
|
657
|
+
return this.driver.observe(sessionId);
|
|
658
|
+
}
|
|
659
|
+
reobserve(sessionId) {
|
|
660
|
+
return this.driver.observe(sessionId);
|
|
661
|
+
}
|
|
662
|
+
execute(request) {
|
|
663
|
+
if (requiresDesktopUserConfirmation(request.action.kind)) {
|
|
664
|
+
return {
|
|
665
|
+
version: 1,
|
|
666
|
+
operationId: request.operationId,
|
|
667
|
+
status: "needs_user",
|
|
668
|
+
summary: `User confirmation required for ${request.action.kind}`,
|
|
669
|
+
error: error("needs_user", `The ${request.action.kind} action is paused for the user`, request.action.kind),
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
return this.driver.execute(request);
|
|
673
|
+
}
|
|
674
|
+
stop(sessionId) {
|
|
675
|
+
return this.driver.stop(sessionId);
|
|
676
|
+
}
|
|
677
|
+
replay(request) {
|
|
678
|
+
const mismatchedOperationId = findDesktopReplaySessionMismatch(request);
|
|
679
|
+
if (mismatchedOperationId !== undefined)
|
|
680
|
+
return detachedFreeze({
|
|
681
|
+
version: 1,
|
|
682
|
+
status: "failed",
|
|
683
|
+
results: [
|
|
684
|
+
{
|
|
685
|
+
version: 1,
|
|
686
|
+
operationId: mismatchedOperationId,
|
|
687
|
+
status: "failed",
|
|
688
|
+
summary: "Replay operation session does not match the replay session",
|
|
689
|
+
error: error("session_mismatch", "Replay operation session does not match the replay session", "replay-session"),
|
|
690
|
+
},
|
|
691
|
+
],
|
|
692
|
+
});
|
|
693
|
+
const results = [];
|
|
694
|
+
for (const operation of request.operations) {
|
|
695
|
+
const result = this.execute(operation);
|
|
696
|
+
results.push(result);
|
|
697
|
+
if (request.stopOnFailure && result.status !== "succeeded")
|
|
698
|
+
return detachedFreeze({
|
|
699
|
+
version: 1,
|
|
700
|
+
status: result.status === "cancelled"
|
|
701
|
+
? "cancelled"
|
|
702
|
+
: result.status === "needs_user"
|
|
703
|
+
? "needs_user"
|
|
704
|
+
: "failed",
|
|
705
|
+
results,
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
return detachedFreeze({ version: 1, status: "succeeded", results });
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
//# sourceMappingURL=windows-uia-adapter.js.map
|