dsh-gui-control 0.0.0-stage → 0.1.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.
@@ -0,0 +1,915 @@
1
+ #requires -Version 5.1
2
+ # Shared op implementations for both GuiControl.ps1 (one-shot) and gui-daemon.ps1.
3
+ # ASCII only: Windows PowerShell 5.1 reads BOM-less scripts as ANSI/GBK.
4
+
5
+ function O([object]$obj, [string]$name, $default) {
6
+ if ($null -eq $obj) { return $default }
7
+ # In-process callers pass hashtables, the wire passes PSCustomObjects. Support both,
8
+ # otherwise every internal call silently degrades to defaults.
9
+ if ($obj -is [System.Collections.IDictionary]) {
10
+ if (-not $obj.Contains($name)) { return $default }
11
+ $v = $obj[$name]
12
+ if ($null -eq $v) { return $default }
13
+ return $v
14
+ }
15
+ $p = $obj.PSObject.Properties[$name]
16
+ if ($null -eq $p) { return $default }
17
+ if ($null -eq $p.Value) { return $default }
18
+ return $p.Value
19
+ }
20
+
21
+ function Has-Key([object]$obj, [string]$name) {
22
+ if ($null -eq $obj) { return $false }
23
+ if ($obj -is [System.Collections.IDictionary]) { return $obj.Contains($name) }
24
+ return ($null -ne $obj.PSObject.Properties[$name])
25
+ }
26
+
27
+ # OCR lives in its own file so the one-shot CLI only pays for it when used.
28
+ . (Join-Path $PSScriptRoot 'gui-ocr.ps1')
29
+ # Anchor layer: named targets resolved by uia -> ocr -> derive -> point.
30
+ . (Join-Path $PSScriptRoot 'gui-anchors.ps1')
31
+
32
+ function Import-DshGui([string]$root) {
33
+ $cs = Join-Path $root 'DshGui.cs'
34
+ Add-Type -Path $cs -ReferencedAssemblies System.Drawing -ErrorAction Stop
35
+ $size = [DshGui]::Init()
36
+ if ($size -ne 40) { throw 'Need 64-bit PowerShell (INPUT struct must be 40 bytes).' }
37
+ }
38
+
39
+ function Import-DshUia([string]$root) {
40
+ Add-Type -AssemblyName UIAutomationClient -ErrorAction Stop
41
+ Add-Type -AssemblyName UIAutomationTypes -ErrorAction Stop
42
+ # BoundingRectangle returns System.Windows.Rect, which lives in WindowsBase.
43
+ Add-Type -AssemblyName WindowsBase -ErrorAction Stop
44
+ $refs = @(
45
+ [System.Windows.Automation.AutomationElement].Assembly.Location,
46
+ [System.Windows.Automation.AutomationIdentifier].Assembly.Location,
47
+ [System.Windows.Rect].Assembly.Location
48
+ )
49
+ Add-Type -Path (Join-Path $root 'DshUia.cs') -ReferencedAssemblies $refs -ErrorAction Stop
50
+ }
51
+
52
+ function Get-Vk([string]$name) {
53
+ $n = $name.Trim().ToLower()
54
+ $map = @{
55
+ 'ctrl' = 0x11; 'control' = 0x11; 'shift' = 0x10; 'alt' = 0x12; 'win' = 0x5B
56
+ 'enter' = 0x0D; 'return' = 0x0D; 'tab' = 0x09; 'esc' = 0x1B; 'escape' = 0x1B; 'space' = 0x20
57
+ 'backspace' = 0x08; 'bs' = 0x08; 'delete' = 0x2E; 'del' = 0x2E; 'insert' = 0x2D
58
+ 'home' = 0x24; 'end' = 0x23; 'pageup' = 0x21; 'pgup' = 0x21; 'pagedown' = 0x22; 'pgdn' = 0x22
59
+ 'up' = 0x26; 'down' = 0x28; 'left' = 0x25; 'right' = 0x27
60
+ 'capslock' = 0x14; 'printscreen' = 0x2C; 'apps' = 0x5D
61
+ 'minus' = 0xBD; 'equal' = 0xBB; 'comma' = 0xBC; 'period' = 0xBE; 'slash' = 0xBF
62
+ 'semicolon' = 0xBA; 'quote' = 0xDE; 'backtick' = 0xC0; 'lbracket' = 0xDB; 'rbracket' = 0xDD
63
+ 'backslash' = 0xDC
64
+ }
65
+ if ($map.ContainsKey($n)) { return [uint16]$map[$n] }
66
+ if ($n -match '^f([1-9]|1[0-9]|2[0-4])$') { return [uint16](0x6F + [int]$Matches[1]) }
67
+ if ($n.Length -eq 1) {
68
+ $c = $n.ToUpper()[0]
69
+ if ([char]::IsLetterOrDigit($c)) { return [uint16][int][char]$c }
70
+ }
71
+ throw "Unknown key: $name"
72
+ }
73
+
74
+ function Get-WinRows {
75
+ $out = @()
76
+ foreach ($h in [DshGui]::TopWindows()) {
77
+ if (-not [DshGui]::IsWindowVisible($h)) { continue }
78
+ $t = [DshGui]::Title($h)
79
+ if ([string]::IsNullOrWhiteSpace($t)) { continue }
80
+ $procId = [uint32]0
81
+ [void][DshGui]::GetWindowThreadProcessId($h, [ref]$procId)
82
+ $r = New-Object 'DshGui+RECT'
83
+ [void][DshGui]::GetWindowRect($h, [ref]$r)
84
+ $pn = ''
85
+ try { $pn = (Get-Process -Id $procId -ErrorAction Stop).ProcessName } catch { }
86
+ $out += [pscustomobject]@{
87
+ Hex = ('0x{0:X}' -f $h.ToInt64()); Pid = $procId; Process = $pn; Title = $t
88
+ Left = $r.Left; Top = $r.Top; Right = $r.Right; Bottom = $r.Bottom
89
+ }
90
+ }
91
+ return $out
92
+ }
93
+
94
+ function Resolve-Win([string]$m) {
95
+ if ([string]::IsNullOrEmpty($m)) { return $null }
96
+ $rows = @(Get-WinRows)
97
+ if ($m -match '^\d+$') {
98
+ $byPid = @($rows | Where-Object { $_.Pid -eq [uint32]$m })
99
+ if ($byPid.Count -ge 1) { return $byPid[0] }
100
+ }
101
+ $hit = @($rows | Where-Object { $_.Title -like "*$m*" -or $_.Process -like "*$m*" })
102
+ if ($hit.Count -eq 0) { throw "No window matching '$m'." }
103
+ return $hit[0]
104
+ }
105
+
106
+ function Get-OpPoint($op) {
107
+ # Absolute x/y, or window-relative when "win" is supplied.
108
+ $x = O $op 'x' $null
109
+ $y = O $op 'y' $null
110
+ if ($null -eq $x -or $null -eq $y) { return $null }
111
+ if (Has-Key $op 'win') {
112
+ $w = Resolve-Win (O $op 'win' '')
113
+ if ($null -ne $w) {
114
+ $h = [IntPtr][Convert]::ToInt64($w.Hex, 16)
115
+ $origin = [DshGui]::ClientOrigin($h)
116
+ return @{ x = [int]$origin.X + [int]$x; y = [int]$origin.Y + [int]$y }
117
+ }
118
+ }
119
+ return @{ x = [int]$x; y = [int]$y }
120
+ }
121
+
122
+ function Get-WinRect($w) {
123
+ return @{ x = [int]$w.Left; y = [int]$w.Top; w = [int]($w.Right - $w.Left); h = [int]($w.Bottom - $w.Top) }
124
+ }
125
+
126
+ function Get-UiaWindow([string]$name, [int]$timeoutMs = 2500) {
127
+ # A window can exist in the Win32 sense before it registers in the UIA tree, so wait.
128
+ $deadline = (Get-Date).AddMilliseconds($timeoutMs)
129
+ do {
130
+ $w = [DshUia]::FindWindow($name)
131
+ if ($null -ne $w) { return $w }
132
+ Start-Sleep -Milliseconds 150
133
+ } while ((Get-Date) -lt $deadline)
134
+ return $null
135
+ }
136
+
137
+ function Find-Uia($scope, [string]$text, [string]$type, [int]$max, [int]$timeoutMs = 0) {
138
+ # A window exists before its children register in the UIA tree; poll briefly on empty.
139
+ $deadline = (Get-Date).AddMilliseconds([Math]::Max(0, $timeoutMs))
140
+ do {
141
+ $els = [DshUia]::Find($scope, $text, $type, $max)
142
+ if ($els.Count -gt 0) { return $els }
143
+ if ($timeoutMs -le 0) { return $els }
144
+ Start-Sleep -Milliseconds 150
145
+ } while ((Get-Date) -lt $deadline)
146
+ return $els
147
+ }
148
+
149
+ function Get-CaptureRect($op) {
150
+ # Explicit x/y/w/h wins over "win": "a region inside this window" is the common case.
151
+ if ((Has-Key $op 'x') -and (Has-Key $op 'w')) {
152
+ return @{ x = [int](O $op 'x' 0); y = [int](O $op 'y' 0); w = [int](O $op 'w' 0); h = [int](O $op 'h' 0) }
153
+ }
154
+ if (Has-Key $op 'win') {
155
+ $w = Resolve-Win (O $op 'win' '')
156
+ return Get-WinRect $w
157
+ }
158
+ $vs = [DshGui]::VirtualScreen()
159
+ return @{ x = $vs.Left; y = $vs.Top; w = ($vs.Right - $vs.Left); h = ($vs.Bottom - $vs.Top) }
160
+ }
161
+
162
+ function Invoke-Capture($op) {
163
+ $r = Get-CaptureRect $op
164
+ if ($r.w -le 0 -or $r.h -le 0) { throw 'invalid capture region' }
165
+ $path = Join-Path $env:TEMP ('cap-{0}-{1}.png' -f $PID, ([guid]::NewGuid().ToString('N').Substring(0, 8)))
166
+ [DshGui]::Shot($r.x, $r.y, $r.w, $r.h, $path)
167
+ return @{ path = $path; x = $r.x; y = $r.y; w = $r.w; h = $r.h }
168
+ }
169
+
170
+ # Windows OCR splits CJK into per-glyph words, so line text arrives as "插 件 市 场".
171
+ # Match on both the raw string and a whitespace-stripped form.
172
+ function Test-TextHit([string]$hay, [string]$needle) {
173
+ if ([string]::IsNullOrEmpty($hay) -or [string]::IsNullOrEmpty($needle)) { return $false }
174
+ if ($hay.IndexOf($needle, [StringComparison]::OrdinalIgnoreCase) -ge 0) { return $true }
175
+ $h = ($hay -replace '\s+', '')
176
+ $n = ($needle -replace '\s+', '')
177
+ if ($n.Length -eq 0) { return $false }
178
+ return ($h.IndexOf($n, [StringComparison]::OrdinalIgnoreCase) -ge 0)
179
+ }
180
+
181
+ function Invoke-Btn([string]$btn, [int]$times, [int]$gap) {
182
+ $down = 2; $up = 4
183
+ switch ($btn) {
184
+ 'right' { $down = 8; $up = 16 }
185
+ 'middle' { $down = 32; $up = 64 }
186
+ 'left' { $down = 2; $up = 4 }
187
+ default { throw "Unknown button: $btn" }
188
+ }
189
+ for ($i = 0; $i -lt $times; $i++) {
190
+ [DshGui]::Mouse($down, 0); Start-Sleep -Milliseconds $gap
191
+ [DshGui]::Mouse($up, 0); Start-Sleep -Milliseconds $gap
192
+ }
193
+ }
194
+
195
+ function Send-Txt([string]$s, [string]$mode) {
196
+ if ($mode -eq 'paste') {
197
+ Set-Clipboard -Value $s
198
+ Start-Sleep -Milliseconds 50
199
+ [DshGui]::Key(0x11, $false); [DshGui]::Key(0x56, $false)
200
+ Start-Sleep -Milliseconds 25
201
+ [DshGui]::Key(0x56, $true); [DshGui]::Key(0x11, $true)
202
+ return "pasted $($s.Length)"
203
+ }
204
+ foreach ($ch in $s.ToCharArray()) {
205
+ if ($ch -eq [char]10) { [DshGui]::Key(0x0D, $false); [DshGui]::Key(0x0D, $true) }
206
+ elseif ($ch -eq [char]13) { }
207
+ else { [DshGui]::Char([uint16][int]$ch) }
208
+ }
209
+ return "typed $($s.Length)"
210
+ }
211
+
212
+ # Executes one op; returns a hashtable describing the result.
213
+ function Invoke-GuiOp($op) {
214
+ $name = (O $op 'op' '').ToString().ToLower()
215
+ switch ($name) {
216
+
217
+ 'ping' { return @{ ok = $true; result = 'pong' } }
218
+
219
+ 'screen' {
220
+ $r = [DshGui]::VirtualScreen()
221
+ return @{ ok = $true; result = @{ x = $r.Left; y = $r.Top; w = ($r.Right - $r.Left); h = ($r.Bottom - $r.Top) } }
222
+ }
223
+
224
+ 'pos' {
225
+ $p = [DshGui]::Cursor()
226
+ return @{ ok = $true; result = @{ x = $p.X; y = $p.Y } }
227
+ }
228
+
229
+ 'move' {
230
+ $pt = Get-OpPoint $op
231
+ if ($null -eq $pt) { throw 'move needs x/y' }
232
+ [DshGui]::MoveAbs($pt.x, $pt.y)
233
+ return @{ ok = $true; result = @{ x = $pt.x; y = $pt.y } }
234
+ }
235
+
236
+ 'click' {
237
+ $pt = Get-OpPoint $op
238
+ if ($null -ne $pt) { [DshGui]::MoveAbs($pt.x, $pt.y); Start-Sleep -Milliseconds (O $op 'settle' 30) }
239
+ Invoke-Btn (O $op 'button' 'left') ([int](O $op 'count' 1)) ([int](O $op 'gap' 25))
240
+ return @{ ok = $true; result = 'clicked' }
241
+ }
242
+
243
+ 'drag' {
244
+ $a = Get-OpPoint $op
245
+ $b = @{ x = [int](O $op 'x2' 0); y = [int](O $op 'y2' 0) }
246
+ if (Has-Key $op 'win') {
247
+ $w = Resolve-Win (O $op 'win' '')
248
+ $h = [IntPtr][Convert]::ToInt64($w.Hex, 16)
249
+ $o2 = [DshGui]::ClientOrigin($h)
250
+ $b = @{ x = [int]$o2.X + $b.x; y = [int]$o2.Y + $b.y }
251
+ }
252
+ [DshGui]::MoveAbs($a.x, $a.y); Start-Sleep -Milliseconds 60
253
+ [DshGui]::Mouse(2, 0); Start-Sleep -Milliseconds 70
254
+ [DshGui]::MoveAbs($b.x, $b.y); Start-Sleep -Milliseconds 90
255
+ [DshGui]::Mouse(4, 0)
256
+ return @{ ok = $true; result = 'dragged' }
257
+ }
258
+
259
+ 'scroll' {
260
+ $pt = Get-OpPoint $op
261
+ if ($null -ne $pt) { [DshGui]::MoveAbs($pt.x, $pt.y); Start-Sleep -Milliseconds 25 }
262
+ [DshGui]::ScrollWheel([int](O $op 'amount' 120))
263
+ return @{ ok = $true; result = 'scrolled' }
264
+ }
265
+
266
+ 'type' {
267
+ $t = (O $op 'text' '').ToString()
268
+ if ([string]::IsNullOrEmpty($t)) { throw 'type needs text' }
269
+ return @{ ok = $true; result = (Send-Txt $t (O $op 'method' 'auto')) }
270
+ }
271
+
272
+ 'key' {
273
+ $vk = Get-Vk (O $op 'key' '')
274
+ [DshGui]::Key($vk, $false); Start-Sleep -Milliseconds 20; [DshGui]::Key($vk, $true)
275
+ return @{ ok = $true; result = 'key' }
276
+ }
277
+
278
+ 'hotkey' {
279
+ $parts = (O $op 'key' '') -split '\+'
280
+ $vks = @()
281
+ foreach ($p in $parts) { $vks += (Get-Vk $p) }
282
+ foreach ($v in $vks) { [DshGui]::Key($v, $false); Start-Sleep -Milliseconds 12 }
283
+ Start-Sleep -Milliseconds 18
284
+ for ($i = $vks.Count - 1; $i -ge 0; $i--) { [DshGui]::Key($vks[$i], $true); Start-Sleep -Milliseconds 12 }
285
+ return @{ ok = $true; result = 'hotkey' }
286
+ }
287
+
288
+ 'sleep' { Start-Sleep -Milliseconds ([int](O $op 'ms' 200)); return @{ ok = $true; result = 'slept' } }
289
+
290
+ 'windows' {
291
+ $rows = @(Get-WinRows | ForEach-Object {
292
+ @{ hex = $_.Hex; pid = $_.Pid; process = $_.Process; title = $_.Title
293
+ rect = "$($_.Left),$($_.Top) $($_.Right - $_.Left)x$($_.Bottom - $_.Top)" }
294
+ })
295
+ return @{ ok = $true; result = $rows }
296
+ }
297
+
298
+ 'foreground' {
299
+ $h = [DshGui]::GetForegroundWindow()
300
+ $procId = [uint32]0
301
+ [void][DshGui]::GetWindowThreadProcessId($h, [ref]$procId)
302
+ $pn = ''
303
+ try { $pn = (Get-Process -Id $procId).ProcessName } catch { }
304
+ return @{ ok = $true; result = @{ title = [DshGui]::Title($h); process = $pn; pid = $procId; hex = ('0x{0:X}' -f $h.ToInt64()) } }
305
+ }
306
+
307
+ 'activate' {
308
+ $w = Resolve-Win (O $op 'win' '')
309
+ $h = [IntPtr][Convert]::ToInt64($w.Hex, 16)
310
+ if ([DshGui]::IsIconic($h)) { [void][DshGui]::ShowWindow($h, 9); Start-Sleep -Milliseconds 120 }
311
+ [void][DshGui]::SetForegroundWindow($h)
312
+ $t0 = [DateTime]::Now
313
+ while ((([DateTime]::Now) - $t0).TotalMilliseconds -lt 700) {
314
+ if ([DshGui]::GetForegroundWindow() -eq $h) { break }
315
+ [DshGui]::SwitchToThisWindow($h, $true)
316
+ Start-Sleep -Milliseconds 80
317
+ }
318
+ return @{ ok = ([DshGui]::GetForegroundWindow() -eq $h); result = @{ title = $w.Title; rect = (Get-WinRect $w) } }
319
+ }
320
+
321
+ 'winrect' {
322
+ $w = Resolve-Win (O $op 'win' '')
323
+ $r = Get-WinRect $w
324
+ $h = [IntPtr][Convert]::ToInt64($w.Hex, 16)
325
+ $o2 = [DshGui]::ClientOrigin($h)
326
+ return @{ ok = $true; result = @{ title = $w.Title; x = $r.x; y = $r.y; w = $r.w; h = $r.h; clientX = $o2.X; clientY = $o2.Y } }
327
+ }
328
+
329
+ 'winmove' {
330
+ $w = Resolve-Win (O $op 'win' '')
331
+ $h = [IntPtr][Convert]::ToInt64($w.Hex, 16)
332
+ $ok = [DshGui]::MoveWindow($h, [int](O $op 'x' 0), [int](O $op 'y' 0), [int](O $op 'w' 800), [int](O $op 'h' 600), $true)
333
+ return @{ ok = $ok; result = 'moved' }
334
+ }
335
+
336
+ 'winmax' {
337
+ $w = Resolve-Win (O $op 'win' '')
338
+ [void][DshGui]::ShowWindow([IntPtr][Convert]::ToInt64($w.Hex, 16), 3)
339
+ return @{ ok = $true; result = 'maximized' }
340
+ }
341
+
342
+ 'waitwin' {
343
+ $m = O $op 'win' ''
344
+ $timeout = [int](O $op 'timeout' 10)
345
+ $deadline = [DateTime]::Now.AddSeconds($timeout)
346
+ while ([DateTime]::Now -lt $deadline) {
347
+ $hit = @(Get-WinRows | Where-Object { $_.Title -like "*$m*" -or $_.Process -like "*$m*" })
348
+ if ($hit.Count -ge 1) { return @{ ok = $true; result = @{ title = $hit[0].Title; hex = $hit[0].Hex } } }
349
+ Start-Sleep -Milliseconds 120
350
+ }
351
+ return @{ ok = $false; error = "waitwin timeout: $m" }
352
+ }
353
+
354
+ 'waitgone' {
355
+ $m = O $op 'win' ''
356
+ $timeout = [int](O $op 'timeout' 10)
357
+ $deadline = [DateTime]::Now.AddSeconds($timeout)
358
+ while ([DateTime]::Now -lt $deadline) {
359
+ $hit = @(Get-WinRows | Where-Object { $_.Title -like "*$m*" -or $_.Process -like "*$m*" })
360
+ if ($hit.Count -eq 0) { return @{ ok = $true; result = 'gone' } }
361
+ Start-Sleep -Milliseconds 120
362
+ }
363
+ return @{ ok = $false; error = "waitgone timeout: $m" }
364
+ }
365
+
366
+ 'waittitle' {
367
+ # Wait until the matched window's title contains a given substring (page changed).
368
+ $m = O $op 'win' ''
369
+ $want = O $op 'text' ''
370
+ $timeout = [int](O $op 'timeout' 10)
371
+ $deadline = [DateTime]::Now.AddSeconds($timeout)
372
+ while ([DateTime]::Now -lt $deadline) {
373
+ $hit = @(Get-WinRows | Where-Object { $_.Title -like "*$want*" })
374
+ if ($hit.Count -ge 1) { return @{ ok = $true; result = @{ title = $hit[0].Title; hex = $hit[0].Hex } } }
375
+ Start-Sleep -Milliseconds 150
376
+ }
377
+ return @{ ok = $false; error = "waittitle timeout: $want" }
378
+ }
379
+
380
+ 'launch' {
381
+ # Some desktop apps refuse CreateProcess from an automation host and exit 0
382
+ # immediately (the bilibili client does). via=explorer goes through ShellExecute
383
+ # like a desktop double-click, which those apps accept.
384
+ $t = (O $op 'text' '').ToString()
385
+ if ([string]::IsNullOrEmpty($t)) { throw 'launch needs text' }
386
+ $via = (O $op 'via' '').ToString().ToLower()
387
+ $cwd = (O $op 'cwd' '').ToString()
388
+ if ($via -eq 'explorer') {
389
+ Start-Process -FilePath 'explorer.exe' -ArgumentList $t
390
+ return @{ ok = $true; result = 'launched via explorer' }
391
+ }
392
+ if ([string]::IsNullOrEmpty($cwd)) { Start-Process -FilePath $t }
393
+ else { Start-Process -FilePath $t -WorkingDirectory $cwd }
394
+ return @{ ok = $true; result = 'launched' }
395
+ }
396
+
397
+ 'clipset' { Set-Clipboard -Value (O $op 'text' ''); return @{ ok = $true; result = 'set' } }
398
+ 'clipget' { return @{ ok = $true; result = (Get-Clipboard -Raw) } }
399
+
400
+ 'pixel' {
401
+ $pt = Get-OpPoint $op
402
+ $c = [DshGui]::Pixel($pt.x, $pt.y)
403
+ return @{ ok = $true; result = @{ x = $pt.x; y = $pt.y; rgb = ('#{0:X6}' -f $c) } }
404
+ }
405
+
406
+ 'waitpixel' {
407
+ # Wait until pixel at x,y matches rgb within tolerance.
408
+ $pt = Get-OpPoint $op
409
+ $want = (O $op 'rgb' '#000000').TrimStart('#')
410
+ $tol = [int](O $op 'tol' 12)
411
+ $timeout = [int](O $op 'timeout' 5)
412
+ $wr = [Convert]::ToInt32($want, 16)
413
+ $deadline = [DateTime]::Now.AddSeconds($timeout)
414
+ while ([DateTime]::Now -lt $deadline) {
415
+ $c = [DshGui]::Pixel($pt.x, $pt.y)
416
+ $dr = [Math]::Abs((($c -shr 16) -band 255) - (($wr -shr 16) -band 255))
417
+ $dg = [Math]::Abs((($c -shr 8) -band 255) - (($wr -shr 8) -band 255))
418
+ $db = [Math]::Abs(($c -band 255) - ($wr -band 255))
419
+ if ($dr -le $tol -and $dg -le $tol -and $db -le $tol) { return @{ ok = $true; result = @{ rgb = ('#{0:X6}' -f $c) } } }
420
+ Start-Sleep -Milliseconds 60
421
+ }
422
+ return @{ ok = $false; error = 'waitpixel timeout' }
423
+ }
424
+
425
+ 'shot' {
426
+ # Options that make a screenshot readable by the model without arithmetic:
427
+ # rulers=1 -> screen-coordinate tick labels; scale<1 -> smaller image;
428
+ # unchanged=true when the frame is byte-identical to the previous one.
429
+ $path = (O $op 'path' '').ToString()
430
+ if ([string]::IsNullOrEmpty($path)) {
431
+ $path = Join-Path $env:TEMP ('shot-{0:HHmmssfff}.png' -f (Get-Date))
432
+ }
433
+ $r = Get-CaptureRect $op
434
+ $scale = [double](O $op 'scale' 1)
435
+ if ($scale -le 0 -or $scale -gt 1) { $scale = 1 }
436
+ $dW = [int]($r.w * $scale); $dH = [int]($r.h * $scale)
437
+ $step = [int](O $op 'step' 200)
438
+
439
+ if ((O $op 'rulers' $false) -eq $true) {
440
+ $note = ('origin {0},{1} capture {2}x{3} delivered {4}x{5} scale {6} step {7}' -f $r.x, $r.y, $r.w, $r.h, $dW, $dH, $scale, $step)
441
+ [DshGui]::ShotRuled($r.x, $r.y, $r.w, $r.h, $scale, $step, $path, $note)
442
+ }
443
+ elseif ($scale -lt 1) {
444
+ [DshGui]::ShotScaled($r.x, $r.y, $r.w, $r.h, $scale, $path)
445
+ }
446
+ else {
447
+ [DshGui]::Shot($r.x, $r.y, $r.w, $r.h, $path)
448
+ }
449
+
450
+ $bytes = [System.IO.File]::ReadAllBytes($path)
451
+ $md5 = [System.Security.Cryptography.MD5]::Create()
452
+ $hash = [BitConverter]::ToString($md5.ComputeHash($bytes)).Replace('-', '')
453
+
454
+ if ((O $op 'force' $false) -ne $true -and $hash -eq $script:LastShotHash -and (Test-Path $script:LastShotPath)) {
455
+ Remove-Item $path -Force -ErrorAction SilentlyContinue
456
+ return @{ ok = $true; result = @{ unchanged = $true; path = $script:LastShotPath; x = $r.x; y = $r.y
457
+ w = $dW; h = $dH; scale = $scale; hash = $hash } }
458
+ }
459
+ $script:LastShotHash = $hash
460
+ $script:LastShotPath = $path
461
+ return @{ ok = $true; result = @{ unchanged = $false; path = $path; x = $r.x; y = $r.y
462
+ w = $dW; h = $dH; scale = $scale; hash = $hash; bytes = $bytes.Length } }
463
+ }
464
+
465
+ 'ocr' {
466
+ # OCR a window or region; returns text lines with absolute boxes.
467
+ $cap = Invoke-Capture $op
468
+ try {
469
+ $lines = @(Get-OcrLines $cap.path $cap.x $cap.y)
470
+ }
471
+ finally {
472
+ Remove-Item $cap.path -Force -ErrorAction SilentlyContinue
473
+ }
474
+ return @{ ok = $true; result = $lines }
475
+ }
476
+
477
+ 'findtext' {
478
+ # Locate a control by its visible text. Semantic targeting without UIA.
479
+ # grow=1 retries with an expanded crop: Windows OCR is sensitive to where the
480
+ # crop cuts, so the same label can vanish when the region is 10 px shorter.
481
+ $needle = (O $op 'text' '').ToString()
482
+ if ([string]::IsNullOrEmpty($needle)) { throw 'findtext needs text' }
483
+ $max = [int](O $op 'max' 10)
484
+ $grow = [int](O $op 'grow' 0)
485
+ $regions = @(Get-CaptureRect $op)
486
+ if ($grow -gt 0) {
487
+ $base = $regions[0]
488
+ foreach ($f in @(0.12, 0.25)) {
489
+ $dx = [int]($base.w * $f); $dy = [int]($base.h * $f)
490
+ $regions += @{ x = $base.x - $dx; y = $base.y - $dy; w = $base.w + 2 * $dx; h = $base.h + 2 * $dy }
491
+ }
492
+ $vs = [DshGui]::VirtualScreen()
493
+ $regions += @{ x = $vs.Left; y = $vs.Top; w = ($vs.Right - $vs.Left); h = ($vs.Bottom - $vs.Top) }
494
+ }
495
+
496
+ $lines = @()
497
+ foreach ($reg in $regions) {
498
+ $cap = @{ }
499
+ if ((Has-Key $reg 'x')) { $cap = $reg } else { $cap = Get-CaptureRect $op }
500
+ $path = Join-Path $env:TEMP ('cap-{0}-{1}.png' -f $PID, ([guid]::NewGuid().ToString('N').Substring(0, 8)))
501
+ [DshGui]::Shot($cap.x, $cap.y, $cap.w, $cap.h, $path)
502
+ try { $lines = @(Get-OcrLines $path $cap.x $cap.y) }
503
+ finally { Remove-Item $path -Force -ErrorAction SilentlyContinue }
504
+
505
+ $hits = @()
506
+ $i = 0
507
+ foreach ($l in $lines) {
508
+ if (Test-TextHit $l.text $needle) {
509
+ $hits += @{ index = $i; text = $l.text; x = $l.x; y = $l.y; w = $l.w; h = $l.h; cx = $l.cx; cy = $l.cy }
510
+ $i++
511
+ if ($hits.Count -ge $max) { break }
512
+ }
513
+ }
514
+ if ($hits.Count -ge 1) {
515
+ if ((O $op 'list' $false) -eq $true) {
516
+ return @{ ok = $true; result = @{ matches = $hits; all = $lines } }
517
+ }
518
+ return @{ ok = $true; result = $hits }
519
+ }
520
+ }
521
+ return @{ ok = $false; error = "text not found: $needle" }
522
+ }
523
+
524
+ 'clicktext' {
525
+ # find it, then click its center. The accuracy workhorse for CEF apps.
526
+ $needle = (O $op 'text' '').ToString()
527
+ if ([string]::IsNullOrEmpty($needle)) { throw 'clicktext needs text' }
528
+ $cap = Invoke-Capture $op
529
+ try {
530
+ $lines = @(Get-OcrLines $cap.path $cap.x $cap.y)
531
+ }
532
+ finally {
533
+ Remove-Item $cap.path -Force -ErrorAction SilentlyContinue
534
+ }
535
+ $hits = @($lines | Where-Object { Test-TextHit $_.text $needle })
536
+ if ($hits.Count -eq 0) { return @{ ok = $false; error = "text not found: $needle" } }
537
+ $pick = [int](O $op 'index' 0)
538
+ if ($pick -ge $hits.Count) { $pick = $hits.Count - 1 }
539
+ $t = $hits[$pick]
540
+ [DshGui]::MoveAbs($t.cx, $t.cy)
541
+ Start-Sleep -Milliseconds ([int](O $op 'settle' 40))
542
+ Invoke-Btn 'left' 1 25
543
+ return @{ ok = $true; result = @{ text = $t.text; x = $t.cx; y = $t.cy; index = $pick } }
544
+ }
545
+
546
+ 'waittext' {
547
+ $needle = (O $op 'text' '').ToString()
548
+ $timeout = [int](O $op 'timeout' 10)
549
+ $deadline = [DateTime]::Now.AddSeconds($timeout)
550
+ while ([DateTime]::Now -lt $deadline) {
551
+ $cap = Invoke-Capture $op
552
+ try { $lines = @(Get-OcrLines $cap.path $cap.x $cap.y) }
553
+ finally { Remove-Item $cap.path -Force -ErrorAction SilentlyContinue }
554
+ $hit = @($lines | Where-Object { Test-TextHit $_.text $needle })
555
+ if ($hit.Count -ge 1) { return @{ ok = $true; result = @{ text = $hit[0].text; cx = $hit[0].cx; cy = $hit[0].cy } } }
556
+ Start-Sleep -Milliseconds 200
557
+ }
558
+ return @{ ok = $false; error = "waittext timeout: $needle" }
559
+ }
560
+
561
+ 'uia-find' {
562
+ # Exact control lookup through accessibility. Falls back to nothing: if the app
563
+ # exposes no UIA tree, use findtext/clicktext (OCR) instead.
564
+ $winName = (O $op 'win' '').ToString()
565
+ $scope = Get-UiaWindow $winName
566
+ if ($null -eq $scope) { return @{ ok = $false; error = "uia window not found: $winName" } }
567
+ $els = [DshUia]::Find($scope, (O $op 'text' ''), (O $op 'type' ''), [int](O $op 'max' 20))
568
+ $out = @()
569
+ foreach ($e in $els) {
570
+ $r = [DshUia]::Rect($e)
571
+ $out += @{
572
+ name = [DshUia]::Name($e); auto = [DshUia]::AutoId($e); type = [DshUia]::TypeOf($e)
573
+ x = [int]$r[0]; y = [int]$r[1]; w = [int]$r[2]; h = [int]$r[3]
574
+ cx = [int]($r[0] + $r[2] / 2); cy = [int]($r[1] + $r[3] / 2)
575
+ }
576
+ }
577
+ if ($out.Count -eq 0) { return @{ ok = $false; error = "uia no match: $($op.text)" } }
578
+ return @{ ok = $true; result = $out }
579
+ }
580
+
581
+ 'uia-click' {
582
+ $winName = (O $op 'win' '').ToString()
583
+ $scope = Get-UiaWindow $winName
584
+ if ($null -eq $scope) { return @{ ok = $false; error = "uia window not found: $winName" } }
585
+ $els = Find-Uia $scope (O $op 'text' '') (O $op 'type' '') 50 ([int](O $op 'waitMs' 2500))
586
+ if ($els.Count -eq 0) { return @{ ok = $false; error = "uia no match: $($op.text)" } }
587
+ $pick = [int](O $op 'index' 0)
588
+ if ($pick -ge $els.Count) { $pick = $els.Count - 1 }
589
+ $e = $els[$pick]
590
+ $how = [DshUia]::Activate($e)
591
+ if ($how -eq 'none') {
592
+ $r = [DshUia]::Rect($e)
593
+ [DshGui]::MoveAbs([int]($r[0] + $r[2] / 2), [int]($r[1] + $r[3] / 2))
594
+ Start-Sleep -Milliseconds 40
595
+ Invoke-Btn 'left' 1 25
596
+ $how = 'mouse'
597
+ }
598
+ $r2 = [DshUia]::Rect($e)
599
+ return @{ ok = $true; result = @{ name = [DshUia]::Name($e); how = $how; x = [int]$r2[0]; y = [int]$r2[1] } }
600
+ }
601
+
602
+ 'uia-set' {
603
+ $winName = (O $op 'win' '').ToString()
604
+ $scope = Get-UiaWindow $winName
605
+ if ($null -eq $scope) { return @{ ok = $false; error = "uia window not found: $winName" } }
606
+ $els = Find-Uia $scope (O $op 'text' '') (O $op 'type' '') 20 ([int](O $op 'waitMs' 2500))
607
+ if ($els.Count -eq 0) { return @{ ok = $false; error = "uia no match: $($op.text)" } }
608
+ $how = [DshUia]::SetText($els[0], (O $op 'value' '').ToString())
609
+ return @{ ok = ($how -ne 'none'); result = @{ how = $how } }
610
+ }
611
+
612
+ 'uia-get' {
613
+ # Read a control's text/value: the cheapest possible verification.
614
+ $winName = (O $op 'win' '').ToString()
615
+ $scope = Get-UiaWindow $winName
616
+ if ($null -eq $scope) { return @{ ok = $false; error = "uia window not found: $winName" } }
617
+ $els = Find-Uia $scope (O $op 'text' '') (O $op 'type' '') 20 ([int](O $op 'waitMs' 2500))
618
+ if ($els.Count -eq 0) { return @{ ok = $false; error = "uia no match: $($op.text)" } }
619
+ return @{ ok = $true; result = @{ text = [DshUia]::GetText($els[0]); name = [DshUia]::Name($els[0]) } }
620
+ }
621
+
622
+ 'anchor' {
623
+ # Resolve a named target from apps\<app>.json. No pixels in the caller.
624
+ $r = Resolve-Anchor (O $op 'app' '') (O $op 'name' '') (O $op 'win' '')
625
+ return @{ ok = $true; result = $r }
626
+ }
627
+
628
+ 'anchor-click' {
629
+ $r = Resolve-Anchor (O $op 'app' '') (O $op 'name' '') (O $op 'win' '')
630
+ [DshGui]::MoveAbs($r.cx, $r.cy)
631
+ Start-Sleep -Milliseconds ([int](O $op 'settle' 40))
632
+ Invoke-Btn 'left' 1 25
633
+ return @{ ok = $true; result = $r }
634
+ }
635
+
636
+ 'anchor-dump' {
637
+ # Authoring aid: every OCR line of the window with fractional coordinates.
638
+ $r = Get-AnchorDump (O $op 'app' '')
639
+ return @{ ok = $true; result = $r }
640
+ }
641
+
642
+ 'uia-dump' {
643
+ # Read a web page (or any app) as a structured list instead of pixels.
644
+ # Chromium exposes the DOM through UIA, so this is exact and position independent.
645
+ $winName = (O $op 'win' '').ToString()
646
+ $scope = Get-UiaWindow $winName
647
+ if ($null -eq $scope) { return @{ ok = $false; error = "uia window not found: $winName" } }
648
+ if ((O $op 'document' $true) -eq $true) { $scope = [DshUia]::DocumentOf($scope) }
649
+ $max = [int](O $op 'max' 300)
650
+ $els = [DshUia]::Descendants($scope, $max)
651
+ $out = @()
652
+ foreach ($e in $els) {
653
+ $nm = [DshUia]::Name($e)
654
+ $tp = [DshUia]::TypeOf($e)
655
+ if ($nm.Length -eq 0 -and $tp -notin @('Edit', 'Document', 'Button')) { continue }
656
+ $r = [DshUia]::Rect($e)
657
+ $out += @{
658
+ name = $nm; type = $tp; auto = [DshUia]::AutoId($e)
659
+ x = [int]$r[0]; y = [int]$r[1]; w = [int]$r[2]; h = [int]$r[3]
660
+ cx = [int]($r[0] + $r[2] / 2); cy = [int]($r[1] + $r[3] / 2)
661
+ }
662
+ if ($out.Count -ge $max) { break }
663
+ }
664
+ return @{ ok = $true; result = $out }
665
+ }
666
+
667
+ 'uia-pick' {
668
+ # Pick the Nth element of a control type, ordered top-to-bottom.
669
+ # Radios and checkboxes on a web page carry no accessible name, so name-based
670
+ # lookup cannot address them; index + SELECT pattern does, even off-screen.
671
+ $winName = (O $op 'win' '').ToString()
672
+ $scope = Get-UiaWindow $winName
673
+ if ($null -eq $scope) { return @{ ok = $false; error = "uia window not found: $winName" } }
674
+ if ((O $op 'document' $true) -eq $true) { $scope = [DshUia]::DocumentOf($scope) }
675
+ $type = (O $op 'type' 'RadioButton').ToString()
676
+ $els = [DshUia]::Find($scope, '', $type, 800)
677
+ $ex = (O $op 'exclude' '').ToString()
678
+ if ($ex.Length -gt 0) { $els = @($els | Where-Object { [DshUia]::Name($_) -notmatch $ex }) }
679
+ $sorted = @($els)
680
+ $idx = [int](O $op 'index' 0)
681
+ if ($idx -lt 0 -or $idx -ge $sorted.Count) { return @{ ok = $false; error = "index $idx out of range ($($sorted.Count) $type found)" } }
682
+ $e = $sorted[$idx]
683
+ # Identity check: the list may have re-rendered since it was read.
684
+ # Identity predicate: name AND automation id, checked before any dispatch.
685
+ $expect = (O $op 'expect' '').ToString()
686
+ $expectId = (O $op 'expectId' '').ToString()
687
+ $actName = [DshUia]::Name($e)
688
+ $actId = [DshUia]::AutoId($e)
689
+ $actType = [DshUia]::TypeOf($e)
690
+ if ($expect.Length -gt 0 -and $actName.IndexOf($expect, [System.StringComparison]::OrdinalIgnoreCase) -lt 0) {
691
+ return @{ ok = $false; error = ('identity mismatch at index ' + $idx + ': expected name "' + $expect + '", found "' + $actName + '"'); delivery = 'not_delivered' }
692
+ }
693
+ if ($expectId.Length -gt 0 -and $actId -ne $expectId) {
694
+ return @{ ok = $false; error = ('identity mismatch at index ' + $idx + ': expected id "' + $expectId + '", found "' + $actId + '"'); delivery = 'not_delivered' }
695
+ }
696
+ $how = [DshUia]::Activate($e)
697
+ $r = [DshUia]::Rect($e)
698
+ $sel = [DshUia]::IsSelected($e)
699
+ return @{ ok = $true; result = @{ index = $idx; count = $sorted.Count; how = $how; selected = $sel; identity = @{ type = $actType; name = $actName; autoId = $actId }
700
+ x = [int]$r[0]; y = [int]$r[1]; cx = [int]($r[0] + $r[2] / 2); cy = [int]($r[1] + $r[3] / 2) } }
701
+ }
702
+
703
+ 'uia-state' {
704
+ # Which radios/checkboxes are currently on - the cheapest way to verify a form.
705
+ $winName = (O $op 'win' '').ToString()
706
+ $scope = Get-UiaWindow $winName
707
+ if ($null -eq $scope) { return @{ ok = $false; error = "uia window not found: $winName" } }
708
+ if ((O $op 'document' $true) -eq $true) { $scope = [DshUia]::DocumentOf($scope) }
709
+ $type = (O $op 'type' 'RadioButton').ToString()
710
+ $els = [DshUia]::Find($scope, '', $type, 800)
711
+ $ex = (O $op 'exclude' '').ToString()
712
+ if ($ex.Length -gt 0) { $els = @($els | Where-Object { [DshUia]::Name($_) -notmatch $ex }) }
713
+ $sorted = @($els)
714
+ $out = @()
715
+ for ($i = 0; $i -lt $sorted.Count; $i++) {
716
+ $r = [DshUia]::Rect($sorted[$i])
717
+ $out += @{ index = $i; selected = [DshUia]::IsSelected($sorted[$i]); y = [int]$r[1] }
718
+ }
719
+ return @{ ok = $true; result = $out }
720
+ }
721
+
722
+ 'batch' {
723
+ # Run many ops inside one round-trip. Stops on first failure unless "keepGoing".
724
+ $ops = @(O $op 'ops' @())
725
+ $results = @()
726
+ foreach ($sub in $ops) {
727
+ $r = Invoke-GuiOp $sub
728
+ $results += $r
729
+ if (-not $r.ok -and -not (O $op 'keepGoing' $false)) { break }
730
+ }
731
+ $allOk = $true
732
+ foreach ($r in $results) { if (-not $r.ok) { $allOk = $false } }
733
+ return @{ ok = $allOk; result = $results }
734
+ }
735
+
736
+ 'uia-mini' {
737
+ # Token-cheap page read: dedupe by name, no rects, truncate long text.
738
+ # uia-dump returns objects with rects (~7k chars per page); this returns lines.
739
+ $winName = (O $op 'win' '').ToString()
740
+ $scope = Get-UiaWindow $winName
741
+ if ($null -eq $scope) { return @{ ok = $false; error = "uia window not found: $winName" } }
742
+ if ((O $op 'document' $true) -eq $true) { $scope = [DshUia]::DocumentOf($scope) }
743
+ $cap = [int](O $op 'chars' 60)
744
+ $maxOut = [int](O $op 'lines' 60)
745
+ $region = $null
746
+ if ((Has-Key $op 'x') -and (Has-Key $op 'w')) {
747
+ $region = @{ x = [int](O $op 'x' 0); y = [int](O $op 'y' 0); w = [int](O $op 'w' 0); h = [int](O $op 'h' 0) }
748
+ }
749
+ $keep = (O $op 'types' '').ToString().ToLower()
750
+ $els = [DshUia]::Descendants($scope, 2000)
751
+ $seen = @{}
752
+ $out = @()
753
+ foreach ($e in $els) {
754
+ $nm = [DshUia]::Name($e)
755
+ $tp = [DshUia]::TypeOf($e)
756
+ if ([string]::IsNullOrWhiteSpace($nm)) {
757
+ # An unnamed editable field still matters; show its value, redacted when secret.
758
+ if ($tp -notin @('Edit', 'Text', 'Document')) { continue }
759
+ if ([DshUia]::IsPassword($e)) { $nm = '<redacted>' }
760
+ else {
761
+ $nm = [DshUia]::Value($e)
762
+ if ([string]::IsNullOrWhiteSpace($nm)) { $nm = '(empty ' + $tp + ')' }
763
+ }
764
+ }
765
+ if ($keep.Length -gt 0 -and $keep.IndexOf($tp.ToLower()) -lt 0) { continue }
766
+ if ($null -ne $region) {
767
+ $r = [DshUia]::Rect($e)
768
+ if ($r[0] -lt $region.x -or $r[1] -lt $region.y -or
769
+ ($r[0] + $r[2]) -gt ($region.x + $region.w) -or ($r[1] + $r[3]) -gt ($region.y + $region.h)) { continue }
770
+ }
771
+ $t = $nm -replace '\s+', ' '
772
+ $key = $tp + '|' + $t
773
+ if ($seen.ContainsKey($key)) { continue }
774
+ $seen[$key] = 1
775
+ if ($t.Length -gt $cap) { $t = $t.Substring(0, $cap) + '~' }
776
+ $short = $tp
777
+ if ($short.Length -gt 5) { $short = $short.Substring(0, 5) }
778
+ $out += ('{0} {1}' -f $short, $t)
779
+ if ($out.Count -ge $maxOut) { break }
780
+ }
781
+ return @{ ok = $true; result = $out }
782
+ }
783
+
784
+ 'task' {
785
+ # Declarative task runner: a list of steps executed inside the daemon with no
786
+ # agent in the loop. One call = one task, verified step by step.
787
+ # Step fields: do (op name), plus that op's params,
788
+ # until (op polled until ok), untilTimeout (s), retry, soft.
789
+ $name = (O $op 'name' 'task').ToString()
790
+ $steps = @()
791
+ if (Has-Key $op 'file') {
792
+ $p = (O $op 'file' '').ToString()
793
+ if (-not (Test-Path $p)) { return @{ ok = $false; error = "task file not found: $p" } }
794
+ $def = Get-Content -Raw -Encoding UTF8 $p | ConvertFrom-Json
795
+ if ($def.name) { $name = $def.name }
796
+ $steps = @($def.steps)
797
+ }
798
+ else { $steps = @(O $op 'steps' @()) }
799
+ if ($steps.Count -eq 0) { return @{ ok = $false; error = 'task has no steps' } }
800
+
801
+ # Risk gate: a medium/high step must never run before the caller confirms.
802
+ $confirm = (O $op 'confirm' $false) -eq $true
803
+ $dryRun = (O $op 'dryRun' $false) -eq $true
804
+ $risky = @()
805
+ $plan = @()
806
+ $n = 0
807
+ foreach ($s in $steps) {
808
+ $n++
809
+ $risk = (O $s 'risk' 'low').ToString().ToLower()
810
+ $plan += @{ n = $n; do = (O $s 'do' '').ToString(); risk = $risk }
811
+ if ($risk -ne 'low') { $risky += @{ n = $n; do = (O $s 'do' '').ToString(); risk = $risk } }
812
+ }
813
+ if ($dryRun) { return @{ ok = $true; result = @{ name = $name; dryRun = $true; steps = $plan } } }
814
+ if ($risky.Count -gt 0 -and -not $confirm) {
815
+ return @{ ok = $false; error = ('confirm required for ' + $risky.Count + ' risky step(s)')
816
+ result = @{ name = $name; risky = $risky; hint = 'resend with confirm=true, or dryRun=true to inspect' } }
817
+ }
818
+
819
+ $T = [Diagnostics.Stopwatch]::StartNew()
820
+ $log = @()
821
+ $i = 0
822
+ foreach ($s in $steps) {
823
+ $i++
824
+ $sw = [Diagnostics.Stopwatch]::StartNew()
825
+ $o = @{}
826
+ foreach ($p in $s.PSObject.Properties) { $o[$p.Name] = $p.Value }
827
+ $doName = (O $s 'do' '').ToString()
828
+ if ([string]::IsNullOrEmpty($doName)) { $doName = (O $s 'op' '').ToString() }
829
+ $o.Remove('do')
830
+ $null = $o.Remove('op')
831
+ $o['op'] = $doName
832
+
833
+ $until = $null
834
+ if (Has-Key $s 'until') { $until = $s.until }
835
+ $untilTimeout = 8; if (Has-Key $s 'untilTimeout') { $untilTimeout = [int]$s.untilTimeout }
836
+ $retry = 1; if (Has-Key $s 'retry') { $retry = [int]$s.retry }
837
+ foreach ($k in @('until', 'untilTimeout', 'retry', 'risk')) { $null = $o.Remove($k) }
838
+
839
+ # Delivery semantics: what a failure says about whether input actually landed.
840
+ # not_delivered = nothing was sent, safe to retry; delivered_unverified = it may
841
+ # have landed, so retrying risks a duplicate; delivered_verified = re-read agreed.
842
+ $inputOps = @('click', 'dclick', 'move', 'drag', 'scroll', 'type', 'key', 'hotkey',
843
+ 'uia-click', 'uia-pick', 'uia-set', 'activate', 'winmove', 'launch', 'closeapp')
844
+ $isInput = $inputOps -contains $doName
845
+
846
+ $stepOk = $false
847
+ $note = ''
848
+ $delivery = 'not_delivered'
849
+ for ($try = 1; $try -le [Math]::Max(1, $retry); $try++) {
850
+ $r = $null
851
+ try { $r = Invoke-GuiOp $o } catch { $r = @{ ok = $false; error = $_.Exception.Message } }
852
+ if (-not $r.ok) {
853
+ $note = [string]$r.error
854
+ # A refusal before dispatch sent nothing, so retrying it is always safe.
855
+ $preDispatch = ($note -match 'not found|no match|no window|unknown op|out of range|invalid|needs |not exist')
856
+ if ($isInput -and -not $preDispatch) { $delivery = 'delivered_unverified' }
857
+ else { $delivery = 'not_delivered' }
858
+ if ($delivery -ne 'not_delivered') { break }
859
+ continue
860
+ }
861
+ if ($null -eq $until) {
862
+ $stepOk = $true
863
+ if ($isInput) { $delivery = 'delivered_unverified' } else { $delivery = 'not_delivered' }
864
+ break
865
+ }
866
+
867
+ $u = @{}
868
+ $u = @{}
869
+ foreach ($p in $until.PSObject.Properties) { $u[$p.Name] = $p.Value }
870
+ if (-not $u.ContainsKey('waitMs')) { $u['waitMs'] = 0 }
871
+ $deadline = (Get-Date).AddSeconds($untilTimeout)
872
+ while ((Get-Date) -lt $deadline) {
873
+ $ur = $null
874
+ try { $ur = Invoke-GuiOp $u } catch { $ur = @{ ok = $false } }
875
+ if ($ur.ok) { $stepOk = $true; break }
876
+ Start-Sleep -Milliseconds 150
877
+ }
878
+ if ($stepOk) { $delivery = 'delivered_verified'; break }
879
+ # The step dispatched but its effect never showed up: do NOT replay it.
880
+ $delivery = $(if ($isInput) { 'delivered_unverified' } else { 'not_delivered' })
881
+ $note = 'until-timeout'
882
+ break
883
+ }
884
+ # Carry a compact value out of read-only steps (uia-get / clipget / ...).
885
+ $outText = ''
886
+ if ($stepOk -and $null -ne $r -and $null -ne $r.result) {
887
+ if ($r.result -is [string]) { $outText = $r.result }
888
+ elseif ($r.result -is [hashtable] -and $r.result.ContainsKey('text')) { $outText = [string]$r.result.text }
889
+ }
890
+ $sw.Stop()
891
+ $log += @{ n = $i; do = $doName; ok = $stepOk; ms = $sw.ElapsedMilliseconds; note = $note; out = $outText; delivery = $delivery; retrySafe = ($delivery -eq 'not_delivered') }
892
+ if (-not $stepOk) {
893
+ return @{ ok = $false; error = ('step ' + $i + ' (' + $doName + ') failed: ' + $note); delivery = $delivery; retry = $(if ($delivery -eq 'not_delivered') { 'safe' } else { 'unsafe' }); result = @{ name = $name; steps = $log; total = $T.ElapsedMilliseconds } }
894
+ }
895
+ }
896
+ return @{ ok = $true; result = @{ name = $name; steps = $log; total = $T.ElapsedMilliseconds } }
897
+ }
898
+
899
+ 'pathexists' {
900
+ # Filesystem as a step condition: lets a task wait for the real outcome instead
901
+ # of sleeping a guessed interval.
902
+ $p = (O $op 'text' '').ToString()
903
+ if ([string]::IsNullOrEmpty($p)) { throw 'pathexists needs text' }
904
+ if (Test-Path -LiteralPath $p) {
905
+ $item = Get-Item -LiteralPath $p -ErrorAction SilentlyContinue
906
+ $size = 0
907
+ if ($item -and -not $item.PSIsContainer) { $size = $item.Length }
908
+ return @{ ok = $true; result = @{ path = $p; exists = $true; bytes = $size } }
909
+ }
910
+ return @{ ok = $false; error = ('missing: ' + $p) }
911
+ }
912
+
913
+ default { return @{ ok = $false; error = "unknown op: $name" } }
914
+ }
915
+ }