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.
- package/LICENSE +21 -0
- package/README.md +187 -2
- package/assets/icon-180.png +0 -0
- package/assets/screenshot-bilibili.png +0 -0
- package/assets/screenshot-calc.png +0 -0
- package/assets/screenshot-sheet.png +0 -0
- package/cordis.patch.yml +4 -0
- package/icon.svg +8 -0
- package/lib/index.js +346 -0
- package/lib/scripts/DshGui.cs +267 -0
- package/lib/scripts/DshUia.cs +212 -0
- package/lib/scripts/gui-anchors.ps1 +146 -0
- package/lib/scripts/gui-daemon.ps1 +89 -0
- package/lib/scripts/gui-lib.ps1 +68 -0
- package/lib/scripts/gui-ocr.ps1 +71 -0
- package/lib/scripts/gui-ops.ps1 +915 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +56 -3
- package/screenshots.json +5 -0
|
@@ -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
|
+
}
|