@momasiku/dsh-pilot 0.5.17 → 0.6.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.
@@ -42,129 +42,138 @@ function Assert-Alive {
42
42
  }
43
43
  }
44
44
 
45
- function Get-ProcessNameSafe([uint32] $pidValue) {
46
- try { return (Get-Process -Id $pidValue -ErrorAction Stop).ProcessName } catch { return $null }
47
- }
48
-
49
- function Test-Cancelled {
50
- if ([string]::IsNullOrEmpty($script:CancelFile)) { return $false }
51
- if (-not (Test-Path -LiteralPath $script:CancelFile)) { return $false }
52
- try {
53
- $content = [System.IO.File]::ReadAllText($script:CancelFile, [System.Text.Encoding]::UTF8)
54
- return $content.Trim() -eq 'stop'
55
- } catch {
56
- return $false
57
- }
58
- }
45
+ function Remove-ZeroWidth([string] $text) {
46
+ # Chrome and Edge put U+200B inside their own window titles - the title reads
47
+ # "Microsoft Edge" but the bytes are "Microsoft​ Ed"; a substring search
48
+ # for what a person sees then finds nothing. Strip the invisible ones on both
49
+ # sides of the comparison instead of asking the caller to guess.
50
+ if ([string]::IsNullOrEmpty($text)) { return '' }
51
+ return ($text -replace '[​‌‍⁠]', '')
52
+ }
53
+
54
+ function Get-ProcessNameSafe([uint32] $pidValue) {
55
+ try { return (Get-Process -Id $pidValue -ErrorAction Stop).ProcessName } catch { return $null }
56
+ }
57
+
58
+ function Test-Cancelled {
59
+ if ([string]::IsNullOrEmpty($script:CancelFile)) { return $false }
60
+ if (-not (Test-Path -LiteralPath $script:CancelFile)) { return $false }
61
+ try {
62
+ $content = [System.IO.File]::ReadAllText($script:CancelFile, [System.Text.Encoding]::UTF8)
63
+ return $content.Trim() -eq 'stop'
64
+ } catch {
65
+ return $false
66
+ }
67
+ }
68
+
69
+
70
+ # Re-assert the target window as foreground immediately before injecting input.
71
+ # Focus can be taken by another process between two steps, and a click or
72
+ # keystroke delivered to the wrong window is a real, destructive side effect, so
73
+ # this fails loud rather than acting on whatever happens to be in front.
74
+ function Assert-TargetFocus {
75
+ if ($script:TargetHwnd -eq [IntPtr]::Zero) { return }
76
+ if (-not [DshInput]::IsAlive($script:TargetHwnd)) {
77
+ Write-ActionError "the target window (hwnd $($script:TargetHwndText)) no longer exists; re-read the screen and target a live window"
78
+ }
79
+ if ([DshInput]::IsForeground($script:TargetHwnd)) { return }
80
+ if ([DshInput]::ForceForeground($script:TargetHwnd)) { return }
81
+ $fg = [DshInput]::ForegroundTitle()
82
+ Write-ActionError "could not bring the target window (hwnd $($script:TargetHwndText)) to the foreground; '$fg' is holding focus, so this action was refused instead of being sent to the wrong window"
83
+ }
84
+
85
+ function Get-Point([object] $x, [object] $y, [string] $what) {
86
+ if ($null -eq $x -or $null -eq $y) { Write-ActionError "$what requires both x and y" }
87
+ $px = 0; $py = 0
88
+ if (-not [int]::TryParse(([string] $x), [ref] $px)) { Write-ActionError "$what x must be an integer (got '$x')" }
89
+ if (-not [int]::TryParse(([string] $y), [ref] $py)) { Write-ActionError "$what y must be an integer (got '$y')" }
90
+ $vx = [DshInput]::GetSystemMetrics(76); $vy = [DshInput]::GetSystemMetrics(77)
91
+ $vw = [DshInput]::GetSystemMetrics(78); $vh = [DshInput]::GetSystemMetrics(79)
92
+ if ($px -lt $vx -or $py -lt $vy -or $px -ge ($vx + $vw) -or $py -ge ($vy + $vh)) {
93
+ Write-ActionError "$what point ($px,$py) is outside the virtual desktop ($vx,$vy,$vw,$vh); screenshots are physical pixels, so re-read the image before acting"
94
+ }
95
+ return @($px, $py)
96
+ }
97
+
98
+ function Invoke-Drag([int] $fromX, [int] $fromY, [int] $toX, [int] $toY, [string] $button) {
99
+ $null = [DshInput]::SetCursorPos($fromX, $fromY)
100
+ Start-Sleep -Milliseconds 90
101
+ Assert-Alive
102
+ [DshInput]::Button($button, $true)
103
+ try {
104
+ $steps = 24
105
+ for ($i = 1; $i -le $steps; $i++) {
106
+ $x = [int] [math]::Round($fromX + (($toX - $fromX) * $i / $steps))
107
+ $y = [int] [math]::Round($fromY + (($toY - $fromY) * $i / $steps))
108
+ $null = [DshInput]::SetCursorPos($x, $y)
109
+ Start-Sleep -Milliseconds 18
110
+ if ($i % 4 -eq 0) { Assert-Alive }
111
+ }
112
+ } finally {
113
+ # Never leave the left button latched down, even on abort.
114
+ [DshInput]::Button($button, $false)
115
+ }
116
+ Start-Sleep -Milliseconds 90
117
+ }
59
118
 
119
+ function Get-KeySequence([string] $spec) {
120
+ $raw = $spec.Trim()
121
+ if ([string]::IsNullOrEmpty($raw)) { Write-ActionError 'key requires a non-empty value' }
122
+ $parts = @(($raw -split '\+') | Where-Object { $_ -ne '' } | ForEach-Object { $_.Trim() })
123
+ if ($parts.Count -eq 0) { Write-ActionError "key '$spec' has no usable key name" }
124
+ $modifiers = ''
125
+ $base = ''
126
+ foreach ($part in $parts) {
127
+ $lower = $part.ToLowerInvariant()
128
+ if ($lower -eq 'ctrl' -or $lower -eq 'control') { $modifiers += '^'; continue }
129
+ if ($lower -eq 'alt') { $modifiers += '%'; continue }
130
+ if ($lower -eq 'shift') { $modifiers += '+'; continue }
131
+ if (@('win', 'windows', 'cmd', 'meta') -contains $lower) { $modifiers += 'WIN'; continue }
132
+ if (-not [string]::IsNullOrEmpty($base)) { Write-ActionError "key combination '$spec' names both '$base' and '$part'; only one non-modifier key is supported" }
133
+ $base = $part
134
+ }
135
+ if ([string]::IsNullOrEmpty($base) -and [string]::IsNullOrEmpty($modifiers)) { Write-ActionError "key '$spec' has no usable key name" }
60
136
 
61
- # Re-assert the target window as foreground immediately before injecting input.
62
- # Focus can be taken by another process between two steps, and a click or
63
- # keystroke delivered to the wrong window is a real, destructive side effect, so
64
- # this fails loud rather than acting on whatever happens to be in front.
65
- function Assert-TargetFocus {
66
- if ($script:TargetHwnd -eq [IntPtr]::Zero) { return }
67
- if (-not [DshInput]::IsAlive($script:TargetHwnd)) {
68
- Write-ActionError "the target window (hwnd $($script:TargetHwndText)) no longer exists; re-read the screen and target a live window"
69
- }
70
- if ([DshInput]::IsForeground($script:TargetHwnd)) { return }
71
- if ([DshInput]::ForceForeground($script:TargetHwnd)) { return }
72
- $fg = [DshInput]::ForegroundTitle()
73
- Write-ActionError "could not bring the target window (hwnd $($script:TargetHwndText)) to the foreground; '$fg' is holding focus, so this action was refused instead of being sent to the wrong window"
74
- }
75
-
76
- function Get-Point([object] $x, [object] $y, [string] $what) {
77
- if ($null -eq $x -or $null -eq $y) { Write-ActionError "$what requires both x and y" }
78
- $px = 0; $py = 0
79
- if (-not [int]::TryParse(([string] $x), [ref] $px)) { Write-ActionError "$what x must be an integer (got '$x')" }
80
- if (-not [int]::TryParse(([string] $y), [ref] $py)) { Write-ActionError "$what y must be an integer (got '$y')" }
81
- $vx = [DshInput]::GetSystemMetrics(76); $vy = [DshInput]::GetSystemMetrics(77)
82
- $vw = [DshInput]::GetSystemMetrics(78); $vh = [DshInput]::GetSystemMetrics(79)
83
- if ($px -lt $vx -or $py -lt $vy -or $px -ge ($vx + $vw) -or $py -ge ($vy + $vh)) {
84
- Write-ActionError "$what point ($px,$py) is outside the virtual desktop ($vx,$vy,$vw,$vh); screenshots are physical pixels, so re-read the image before acting"
85
- }
86
- return @($px, $py)
87
- }
88
-
89
- function Invoke-Drag([int] $fromX, [int] $fromY, [int] $toX, [int] $toY, [string] $button) {
90
- $null = [DshInput]::SetCursorPos($fromX, $fromY)
91
- Start-Sleep -Milliseconds 90
92
- Assert-Alive
93
- [DshInput]::Button($button, $true)
94
- try {
95
- $steps = 24
96
- for ($i = 1; $i -le $steps; $i++) {
97
- $x = [int] [math]::Round($fromX + (($toX - $fromX) * $i / $steps))
98
- $y = [int] [math]::Round($fromY + (($toY - $fromY) * $i / $steps))
99
- $null = [DshInput]::SetCursorPos($x, $y)
100
- Start-Sleep -Milliseconds 18
101
- if ($i % 4 -eq 0) { Assert-Alive }
102
- }
103
- } finally {
104
- # Never leave the left button latched down, even on abort.
105
- [DshInput]::Button($button, $false)
106
- }
107
- Start-Sleep -Milliseconds 90
108
- }
109
-
110
- function Get-KeySequence([string] $spec) {
111
- $raw = $spec.Trim()
112
- if ([string]::IsNullOrEmpty($raw)) { Write-ActionError 'key requires a non-empty value' }
113
- $parts = @(($raw -split '\+') | Where-Object { $_ -ne '' } | ForEach-Object { $_.Trim() })
114
- if ($parts.Count -eq 0) { Write-ActionError "key '$spec' has no usable key name" }
115
- $modifiers = ''
116
- $base = ''
117
- foreach ($part in $parts) {
118
- $lower = $part.ToLowerInvariant()
119
- if ($lower -eq 'ctrl' -or $lower -eq 'control') { $modifiers += '^'; continue }
120
- if ($lower -eq 'alt') { $modifiers += '%'; continue }
121
- if ($lower -eq 'shift') { $modifiers += '+'; continue }
122
- if (@('win', 'windows', 'cmd', 'meta') -contains $lower) { $modifiers += 'WIN'; continue }
123
- if (-not [string]::IsNullOrEmpty($base)) { Write-ActionError "key combination '$spec' names both '$base' and '$part'; only one non-modifier key is supported" }
124
- $base = $part
125
- }
126
- if ([string]::IsNullOrEmpty($base) -and [string]::IsNullOrEmpty($modifiers)) { Write-ActionError "key '$spec' has no usable key name" }
127
-
128
- # Named keys become SendKeys tokens; a single literal character passes through.
129
- $named = @{
130
- 'enter' = 'ENTER'; 'return' = 'ENTER'; 'tab' = 'TAB'; 'esc' = 'ESC'; 'escape' = 'ESC'
131
- 'space' = ' '; 'backspace' = 'BACKSPACE'; 'back' = 'BACKSPACE'; 'delete' = 'DELETE'; 'del' = 'DELETE'
132
- 'home' = 'HOME'; 'end' = 'END'; 'pageup' = 'PGUP'; 'pgup' = 'PGUP'; 'pagedown' = 'PGDN'; 'pgdn' = 'PGDN'
133
- 'up' = 'UP'; 'down' = 'DOWN'; 'left' = 'LEFT'; 'right' = 'RIGHT'; 'insert' = 'INS'; 'ins' = 'INS'
134
- }
135
- if (-not [string]::IsNullOrEmpty($base)) {
136
- $lowerBase = $base.ToLowerInvariant()
137
- if ($named.ContainsKey($lowerBase)) {
138
- $base = $named[$lowerBase]
139
- } elseif ($lowerBase -match '^f([1-9]|1[0-6])$') {
140
- $base = 'F' + $Matches[1]
141
- } elseif ($lowerBase -match '^f([0-9]{1,2})$') {
142
- Write-ActionError "key '$spec' is above F16, which the Windows SendKeys bridge does not implement; use F1..F16"
143
- } elseif ($base.Length -ne 1) {
144
- Write-ActionError "key '$spec' names an unknown key '$base'; use a single character or a name such as enter/tab/esc/backspace/delete/home/end/up/down/left/right/pgup/pgdn/f1..f16"
145
- }
146
- }
147
- $encoded = if ($base.Length -eq 0) { '' } elseif ($base.Length -eq 1 -and -not ($base -cmatch '[+^%~()\[\]{}]')) { $base } else { "{$base}" }
148
- if ($modifiers.Contains('WIN')) {
149
- $plain = $modifiers.Replace('WIN', '')
150
- if ($plain.Length -gt 0) { Write-ActionError "key '$spec' mixes the win modifier with other modifiers, which this build does not support" }
151
- return @{ win = $true; sequence = $encoded }
152
- }
153
- return @{ win = $false; sequence = ($modifiers + $encoded) }
154
- }
155
-
156
- function Invoke-TextPaste([string] $text) {
157
- Add-Type -AssemblyName System.Windows.Forms
158
- $saved = $null
159
- try { if ([System.Windows.Forms.Clipboard]::ContainsText()) { $saved = [System.Windows.Forms.Clipboard]::GetText() } } catch { $saved = $null }
160
- try { [System.Windows.Forms.Clipboard]::SetText($text) } catch { Write-ActionError "could not place the text on the clipboard: $($_.Exception.Message)" }
161
- Start-Sleep -Milliseconds 60
162
- Assert-Alive
163
- try { [DshInput]::SendKeysText('^v') } catch { Write-ActionError "the paste keystroke could not be delivered: $($_.Exception.Message)" }
164
- Start-Sleep -Milliseconds 120
165
- if ($null -ne $saved) {
166
- try { [System.Windows.Forms.Clipboard]::SetText($saved) } catch { }
167
- }
137
+ # Named keys become SendKeys tokens; a single literal character passes through.
138
+ $named = @{
139
+ 'enter' = 'ENTER'; 'return' = 'ENTER'; 'tab' = 'TAB'; 'esc' = 'ESC'; 'escape' = 'ESC'
140
+ 'space' = ' '; 'backspace' = 'BACKSPACE'; 'back' = 'BACKSPACE'; 'delete' = 'DELETE'; 'del' = 'DELETE'
141
+ 'home' = 'HOME'; 'end' = 'END'; 'pageup' = 'PGUP'; 'pgup' = 'PGUP'; 'pagedown' = 'PGDN'; 'pgdn' = 'PGDN'
142
+ 'up' = 'UP'; 'down' = 'DOWN'; 'left' = 'LEFT'; 'right' = 'RIGHT'; 'insert' = 'INS'; 'ins' = 'INS'
143
+ }
144
+ if (-not [string]::IsNullOrEmpty($base)) {
145
+ $lowerBase = $base.ToLowerInvariant()
146
+ if ($named.ContainsKey($lowerBase)) {
147
+ $base = $named[$lowerBase]
148
+ } elseif ($lowerBase -match '^f([1-9]|1[0-6])$') {
149
+ $base = 'F' + $Matches[1]
150
+ } elseif ($lowerBase -match '^f([0-9]{1,2})$') {
151
+ Write-ActionError "key '$spec' is above F16, which the Windows SendKeys bridge does not implement; use F1..F16"
152
+ } elseif ($base.Length -ne 1) {
153
+ Write-ActionError "key '$spec' names an unknown key '$base'; use a single character or a name such as enter/tab/esc/backspace/delete/home/end/up/down/left/right/pgup/pgdn/f1..f16"
154
+ }
155
+ }
156
+ $encoded = if ($base.Length -eq 0) { '' } elseif ($base.Length -eq 1 -and -not ($base -cmatch '[+^%~()\[\]{}]')) { $base } else { "{$base}" }
157
+ if ($modifiers.Contains('WIN')) {
158
+ $plain = $modifiers.Replace('WIN', '')
159
+ if ($plain.Length -gt 0) { Write-ActionError "key '$spec' mixes the win modifier with other modifiers, which this build does not support" }
160
+ return @{ win = $true; sequence = $encoded }
161
+ }
162
+ return @{ win = $false; sequence = ($modifiers + $encoded) }
163
+ }
164
+
165
+ function Invoke-TextPaste([string] $text) {
166
+ Add-Type -AssemblyName System.Windows.Forms
167
+ $saved = $null
168
+ try { if ([System.Windows.Forms.Clipboard]::ContainsText()) { $saved = [System.Windows.Forms.Clipboard]::GetText() } } catch { $saved = $null }
169
+ try { [System.Windows.Forms.Clipboard]::SetText($text) } catch { Write-ActionError "could not place the text on the clipboard: $($_.Exception.Message)" }
170
+ Start-Sleep -Milliseconds 60
171
+ Assert-Alive
172
+ try { [DshInput]::SendKeysText('^v') } catch { Write-ActionError "the paste keystroke could not be delivered: $($_.Exception.Message)" }
173
+ Start-Sleep -Milliseconds 120
174
+ if ($null -ne $saved) {
175
+ try { [System.Windows.Forms.Clipboard]::SetText($saved) } catch { }
176
+ }
168
177
  }
169
178
 
170
179
  <#
@@ -175,7 +184,7 @@ settleMs). Returns the same shape the one-shot script used to print.
175
184
  function Find-DshWindowHandle([string] $needle) {
176
185
  # Foreground wins, otherwise the first visible match: the same rule focus uses,
177
186
  # so a title means the same window to every action that takes one.
178
- $lower = $needle.ToLowerInvariant()
187
+ $lower = (Remove-ZeroWidth $needle).ToLowerInvariant()
179
188
  $fallback = [IntPtr]::Zero
180
189
  foreach ($handle in [DshInput]::TopLevelWindows()) {
181
190
  if (-not [DshInput]::IsWindowVisible($handle)) { continue }
@@ -184,7 +193,7 @@ function Find-DshWindowHandle([string] $needle) {
184
193
  $owner = [uint32] 0
185
194
  $null = [DshInput]::GetWindowThreadProcessId($handle, [ref] $owner)
186
195
  $processName = Get-ProcessNameSafe $owner
187
- $hit = $title.ToLowerInvariant().Contains($lower)
196
+ $hit = (Remove-ZeroWidth $title).ToLowerInvariant().Contains($lower)
188
197
  if (-not $hit -and $null -ne $processName) { $hit = $processName.ToLowerInvariant().Contains($lower) }
189
198
  if (-not $hit) { continue }
190
199
  if ([DshInput]::IsForeground($handle)) { return $handle }
@@ -207,19 +216,19 @@ function Get-DshTargetPoint([string] $indexText, [IntPtr] $hwnd, [string] $title
207
216
  }
208
217
  $element = Resolve-DshUiaIndex -Handle $handle -Index $index
209
218
  if ($null -eq $element) { Write-ActionError ('the tree changed: index {0} is past the end of the listing for ''{1}''. Read controls again.' -f $index, [DshInput]::WindowTitle($handle)) }
210
- if ($null -ne $script:UiListing -and $script:UiListing.handle -eq [long] $handle -and $script:UiListing.items.ContainsKey($index)) {
211
- # Compare identity, not just existence: a reorder keeps the index valid.
212
- $gotRole = ''
213
- $gotName = ''
214
- try {
215
- $gotRole = $element.Current.ControlType.ProgrammaticName -replace 'ControlType\.', ''
216
- $gotName = [string] $element.Current.Name
217
- } catch { }
218
- $want = $script:UiListing.items[$index]
219
- if ($gotRole -ne $want.role -or $gotName -ne $want.name) {
220
- Write-ActionError ('the control at index {0} is not the one that was listed: expected [{1}] ''{2}'', found [{3}] ''{4}''. Read controls again.' -f $index, $want.role, $want.name, $gotRole, $gotName)
221
- }
222
- }
219
+ if ($null -ne $script:UiListing -and $script:UiListing.handle -eq [long] $handle -and $script:UiListing.items.ContainsKey($index)) {
220
+ # Compare identity, not just existence: a reorder keeps the index valid.
221
+ $gotRole = ''
222
+ $gotName = ''
223
+ try {
224
+ $gotRole = $element.Current.ControlType.ProgrammaticName -replace 'ControlType\.', ''
225
+ $gotName = [string] $element.Current.Name
226
+ } catch { }
227
+ $want = $script:UiListing.items[$index]
228
+ if ($gotRole -ne $want.role -or $gotName -ne $want.name) {
229
+ Write-ActionError ('the control at index {0} is not the one that was listed: expected [{1}] ''{2}'', found [{3}] ''{4}''. Read controls again.' -f $index, $want.role, $want.name, $gotRole, $gotName)
230
+ }
231
+ }
223
232
  $centre = Get-DshUiaCentre -Element $element
224
233
  if ($null -eq $centre) { Write-ActionError ('the control at index {0} has no on-screen rectangle - it may be scrolled out of view or collapsed' -f $index) }
225
234
  return @($centre.x, $centre.y)
@@ -276,297 +285,297 @@ function Invoke-DshAction {
276
285
  $notes = @()
277
286
  Assert-Alive
278
287
 
279
- switch ($action) {
280
- 'click' {
281
- $point = Get-AnchoredPoint 'click'
282
- $null = [DshInput]::SetCursorPos($point[0], $point[1])
283
- Start-Sleep -Milliseconds 80
284
- Assert-Alive
285
- Assert-TargetFocus
286
- [DshInput]::Button($button, $true)
287
- Start-Sleep -Milliseconds 45
288
- [DshInput]::Button($button, $false)
289
- }
290
- 'doubleClick' {
291
- $point = Get-AnchoredPoint 'doubleClick'
292
- $null = [DshInput]::SetCursorPos($point[0], $point[1])
293
- Start-Sleep -Milliseconds 80
294
- Assert-Alive
295
- Assert-TargetFocus
296
- [DshInput]::Button($button, $true); Start-Sleep -Milliseconds 40; [DshInput]::Button($button, $false)
297
- Start-Sleep -Milliseconds 70
298
- Assert-Alive
299
- Assert-TargetFocus
300
- [DshInput]::Button($button, $true); Start-Sleep -Milliseconds 40; [DshInput]::Button($button, $false)
301
- }
302
- 'rightClick' {
303
- $point = Get-AnchoredPoint 'rightClick'
304
- $null = [DshInput]::SetCursorPos($point[0], $point[1])
305
- Start-Sleep -Milliseconds 80
306
- Assert-Alive
307
- Assert-TargetFocus
308
- [DshInput]::Button('right', $true)
309
- Start-Sleep -Milliseconds 45
310
- [DshInput]::Button('right', $false)
311
- }
312
- 'middleClick' {
313
- $point = Get-AnchoredPoint 'middleClick'
314
- $null = [DshInput]::SetCursorPos($point[0], $point[1])
315
- Start-Sleep -Milliseconds 80
316
- Assert-Alive
317
- Assert-TargetFocus
318
- [DshInput]::Button('middle', $true)
319
- Start-Sleep -Milliseconds 45
320
- [DshInput]::Button('middle', $false)
321
- }
322
- 'move' {
323
- $point = Get-AnchoredPoint 'move'
324
- $null = [DshInput]::SetCursorPos($point[0], $point[1])
325
- # The move is queued, so an immediate read can still return the previous
326
- # position; wait (bounded) until the pointer is where the caller asked.
327
- $moveDeadline = [DateTime]::UtcNow.AddMilliseconds(200)
328
- while ([DateTime]::UtcNow -lt $moveDeadline) {
329
- $reached = [DshInput]::Cursor()
330
- if ($reached.X -eq $point[0] -and $reached.Y -eq $point[1]) { break }
331
- Start-Sleep -Milliseconds 10
332
- }
333
- }
334
- 'drag' {
335
- $from = Get-Point (Get-Param 'x') (Get-Param 'y') 'drag'
336
- $to = Get-Point (Get-Param 'toX') (Get-Param 'toY') 'drag'
337
- Assert-TargetFocus
338
- Invoke-Drag $from[0] $from[1] $to[0] $to[1] $button
339
- }
340
- 'scroll' {
341
- $amountRaw = Get-Param 'amount'
342
- $amount = 3
343
- if ($null -ne $amountRaw) {
344
- if (-not [int]::TryParse(([string] $amountRaw), [ref] $amount)) { Write-ActionError "amount must be an integer (got '$amountRaw')" }
345
- }
346
- if ($amount -eq 0) { Write-ActionError 'amount must not be zero' }
347
- $hasPoint = ($null -ne (Get-Param 'x')) -and ($null -ne (Get-Param 'y'))
348
- if ($hasPoint) {
349
- $point = Get-Point (Get-Param 'x') (Get-Param 'y') 'scroll'
350
- $null = [DshInput]::SetCursorPos($point[0], $point[1])
351
- Start-Sleep -Milliseconds 60
352
- }
353
- Assert-Alive
354
- Assert-TargetFocus
355
- $horizontal = ($null -ne (Get-Param 'toX')) -or ($null -ne (Get-Param 'toY'))
356
- if ($horizontal) {
357
- [DshInput]::HWheel($amount * 120)
358
- } else {
359
- [DshInput]::Wheel($amount * 120)
360
- }
361
- }
362
- 'type' {
363
- $text = [string] (Get-Param 'text')
364
- if ([string]::IsNullOrEmpty($text)) { Write-ActionError 'type requires non-empty text' }
365
- Assert-TargetFocus
366
- Invoke-TextPaste $text
367
- $notes += 'The text was delivered as one paste, so the target saw no individual keystrokes.'
368
- }
369
- 'key' {
370
- $keySpec = [string] (Get-Param 'key')
371
- if ([string]::IsNullOrWhiteSpace($keySpec)) { Write-ActionError 'key requires a value such as enter, tab, esc, ctrl+s, alt+tab' }
372
- $sequence = Get-KeySequence $keySpec
373
- Assert-TargetFocus
374
- if ($sequence.win) {
375
- [DshInput]::WinKey($true)
376
- Start-Sleep -Milliseconds 60
377
- try { [DshInput]::SendKeysText($sequence.sequence) }
378
- catch { Write-ActionError "the keystroke '$keySpec' could not be delivered: $($_.Exception.Message)" }
379
- finally { [DshInput]::WinKey($false) }
380
- } else {
381
- try { [DshInput]::SendKeysText($sequence.sequence) }
382
- catch { Write-ActionError "the keystroke '$keySpec' could not be delivered: $($_.Exception.Message)" }
383
- }
384
- }
385
- 'controls' {
386
- # Read the window's UI Automation tree and return it as text. This is the
387
- # cheap half of seeing: a control's name and rectangle, no frame, no pixels.
388
- # It returns its own result and returns early so it never pays the settle
389
- # or the capture - the whole point is that no screenshot is needed.
390
- $treeHandle = $script:TargetHwnd
391
- if ($treeHandle -eq [IntPtr]::Zero) {
392
- if ([string]::IsNullOrWhiteSpace($windowTitle)) { Write-ActionError 'controls requires a title, or an hwnd' }
393
- $treeHandle = Find-DshWindowHandle $windowTitle
394
- if ($treeHandle -eq [IntPtr]::Zero) { Write-ActionError ('no visible top-level window matches ''{0}''; pass an hwnd if the window is hidden to the tray' -f $windowTitle) }
395
- }
396
- $includeValues = $false
397
- $ivRaw = Get-Param 'includeValues'
398
- if ($null -ne $ivRaw) { $includeValues = [bool] $ivRaw }
399
- $meaningfulOnly = $true
400
- $moRaw = Get-Param 'meaningfulOnly'
401
- if ($null -ne $moRaw) { $meaningfulOnly = [bool] $moRaw }
402
- $tree = Get-DshUiaNodes -Handle $treeHandle -IncludeValues $includeValues -MeaningfulOnly $meaningfulOnly
403
- if (-not $script:UiWarmed -and $tree.total -lt 8) {
404
- # A window that really has fewer than eight elements pays one extra read
405
- # once per worker; a cold Electron window is the case this exists for.
406
- Start-Sleep -Milliseconds 300
407
- $tree = Get-DshUiaNodes -Handle $treeHandle -IncludeValues $includeValues -MeaningfulOnly $meaningfulOnly
408
- }
409
- if ($tree.total -ge 8) { $script:UiWarmed = $true }
410
- if (-not $tree.ok) { Write-ActionError $tree.error }
411
- $items = @{}
412
- foreach ($n in $tree.nodes) { $items[[int] $n.index] = @{ role = [string] $n.role; name = [string] $n.name } }
413
- $script:UiListing = @{ handle = [long] $treeHandle; items = $items }
414
- $lines = Format-DshUiaNodes -Nodes $tree.nodes -Truncated $tree.truncated
415
- if ($meaningfulOnly -and $tree.nodes.Count -eq 0) {
416
- # An empty list would read as 'this window has no tree', which is the one
417
- # wrong conclusion to leave a caller with: say what is actually there.
418
- $full = Get-DshUiaNodes -Handle $treeHandle -MeaningfulOnly $false
419
- if ($full.nodes.Count -gt 0) {
420
- $lines = @(('(no operable controls here; the window has {0} elements - pass meaningfulOnly:false to see them)' -f $full.nodes.Count))
421
- }
422
- }
423
- $treeCursor = [DshInput]::Cursor()
424
- $result = [ordered]@{
425
- ok = $true
426
- action = 'controls'
427
- cursor = [ordered]@{ x = $treeCursor.X; y = $treeCursor.Y }
428
- foregroundTitle = [DshInput]::ForegroundTitle()
429
- foregroundClass = [DshInput]::ForegroundClass()
430
- foregroundProcess = Get-ProcessNameSafe ([uint32] [DshInput]::ForegroundPid())
431
- controls = $lines
432
- controlCount = $tree.nodes.Count
433
- controlsTruncated = [bool] $tree.truncated
434
- controlsWindow = [DshInput]::WindowTitle($treeHandle)
435
- notes = $notes
436
- actedAt = [DateTimeOffset]::Now.ToString('o')
437
- }
438
- return $result
439
- }
440
- 'focus' { # A refused hand-off used to be a note: the step reported ok and the batch
441
- # carried on, so every later click and keystroke went to whatever window was
442
- # actually in front. That is the one failure this plugin must not report as
443
- # success, so it is an error now; tolerant keeps the old behaviour for a
444
- # caller that knows the coordinates are right either way.
445
- $tolerant = $false
446
- $tolerantRaw = Get-Param 'tolerant'
447
- if ($null -ne $tolerantRaw) { $tolerant = [bool] $tolerantRaw }
448
- $label = ''
449
- $target = [IntPtr]::Zero
450
- if ($script:TargetHwnd -ne [IntPtr]::Zero) {
451
- # An explicit handle is authoritative and works for a window hidden
452
- # to the tray, which a title search cannot reach.
453
- if (-not [DshInput]::IsAlive($script:TargetHwnd)) { Write-ActionError "the target window (hwnd $($script:TargetHwndText)) no longer exists" }
454
- $target = $script:TargetHwnd
455
- $label = "hwnd $($script:TargetHwndText)"
456
- $null = [DshInput]::ForceForeground($target)
457
- Start-Sleep -Milliseconds 220
458
- } else {
459
- if ([string]::IsNullOrWhiteSpace($windowTitle)) { Write-ActionError 'focus requires a title, or an hwnd' }
460
- $needle = $windowTitle.ToLowerInvariant()
461
- # EnumWindows, not Process.MainWindowHandle: an Electron, UWP or tray
462
- # window frequently reports no main window handle at all, so a
463
- # MainWindowHandle scan cannot find a window that the windows action
464
- # (which already enumerates) happily lists.
465
- $fallback = [IntPtr]::Zero
466
- foreach ($handle in [DshInput]::TopLevelWindows()) {
467
- if (-not [DshInput]::IsWindowVisible($handle)) { continue }
468
- $title = [DshInput]::WindowTitle($handle)
469
- if ($title.Length -eq 0) { continue }
470
- $owner = [uint32] 0
471
- $null = [DshInput]::GetWindowThreadProcessId($handle, [ref] $owner)
472
- $processName = Get-ProcessNameSafe $owner
473
- $hit = $title.ToLowerInvariant().Contains($needle)
474
- if (-not $hit -and $null -ne $processName) { $hit = $processName.ToLowerInvariant().Contains($needle) }
475
- if (-not $hit) { continue }
476
- if ([DshInput]::IsForeground($handle)) { $target = $handle; break }
477
- if ($fallback -eq [IntPtr]::Zero) { $fallback = $handle }
478
- }
479
- if ($target -eq [IntPtr]::Zero) { $target = $fallback }
480
- if ($target -eq [IntPtr]::Zero) { Write-ActionError "no visible top-level window matches '$windowTitle'; pass an hwnd if the window is hidden to the tray" }
481
- $label = "'" + [DshInput]::WindowTitle($target) + "'"
482
- $null = [DshInput]::Activate($target)
483
- Start-Sleep -Milliseconds 220
484
- }
485
-
486
- # Ask the desktop, not the API. SetForegroundWindow is documented to be
487
- # unreliable about its return value, and the state that decides where the
488
- # next click lands is the foreground window, not what the call returned.
489
- if (-not [DshInput]::IsForeground($target)) {
490
- $holder = [DshInput]::ForegroundTitle()
491
- if ($tolerant) {
492
- $notes += "Windows would not hand focus to $label; '$holder' kept it (tolerant: continuing)."
493
- } else {
494
- Write-ActionError "could not bring $label to the foreground; '$holder' still holds it, so the rest of this batch would land there. Pass tolerant: true on this step if the coordinates are right either way."
495
- }
496
- }
497
- }
498
- 'windows' {
499
- $query = $windowTitle.ToLowerInvariant()
500
- $list = @()
501
- foreach ($handle in [DshInput]::TopLevelWindows()) {
502
- $title = [DshInput]::WindowTitle($handle)
503
- if ($title.Length -eq 0) { continue }
504
- $owner = [uint32] 0
505
- $null = [DshInput]::GetWindowThreadProcessId($handle, [ref] $owner)
506
- $processName = Get-ProcessNameSafe $owner
507
- if ([string]::IsNullOrEmpty($query) -eq $false) {
508
- $inTitle = $title.ToLowerInvariant().Contains($query)
509
- $inProcess = ($null -ne $processName) -and $processName.ToLowerInvariant().Contains($query)
510
- if (-not ($inTitle -or $inProcess)) { continue }
511
- }
512
- $rect = [DshInput]::WindowRect($handle)
513
- $list += [ordered]@{
514
- handle = ('0x{0:X}' -f $handle.ToInt64())
515
- title = $title
516
- process = $processName
517
- pid = [int] $owner
518
- bounds = ('{0},{1},{2},{3}' -f $rect.Left, $rect.Top, ($rect.Right - $rect.Left), ($rect.Bottom - $rect.Top))
519
- minimized = [bool] [DshInput]::IsIconic($handle)
520
- visible = [bool] [DshInput]::IsWindowVisible($handle)
521
- foreground = [bool] [DshInput]::IsForeground($handle)
522
- }
523
- }
524
- $foreground = $list | Where-Object { $_.foreground } | Select-Object -First 1
525
- $result = [ordered]@{
526
- ok = $true
527
- action = 'windows'
528
- windows = $list
529
- windowCount = $list.Count
530
- foregroundWindow = $foreground
531
- foregroundTitle = [DshInput]::ForegroundTitle()
532
- cursor = [ordered]@{ x = ([DshInput]::Cursor()).X; y = ([DshInput]::Cursor()).Y }
533
- notes = @()
534
- actedAt = [DateTimeOffset]::Now.ToString('o')
535
- }
536
- return $result
537
- }
538
- default {
539
- Write-ActionError "unknown action '$action' (use click, doubleClick, rightClick, middleClick, move, drag, scroll, type, key, focus, or windows)"
540
- }
288
+ switch ($action) {
289
+ 'click' {
290
+ $point = Get-AnchoredPoint 'click'
291
+ $null = [DshInput]::SetCursorPos($point[0], $point[1])
292
+ Start-Sleep -Milliseconds 80
293
+ Assert-Alive
294
+ Assert-TargetFocus
295
+ [DshInput]::Button($button, $true)
296
+ Start-Sleep -Milliseconds 45
297
+ [DshInput]::Button($button, $false)
298
+ }
299
+ 'doubleClick' {
300
+ $point = Get-AnchoredPoint 'doubleClick'
301
+ $null = [DshInput]::SetCursorPos($point[0], $point[1])
302
+ Start-Sleep -Milliseconds 80
303
+ Assert-Alive
304
+ Assert-TargetFocus
305
+ [DshInput]::Button($button, $true); Start-Sleep -Milliseconds 40; [DshInput]::Button($button, $false)
306
+ Start-Sleep -Milliseconds 70
307
+ Assert-Alive
308
+ Assert-TargetFocus
309
+ [DshInput]::Button($button, $true); Start-Sleep -Milliseconds 40; [DshInput]::Button($button, $false)
310
+ }
311
+ 'rightClick' {
312
+ $point = Get-AnchoredPoint 'rightClick'
313
+ $null = [DshInput]::SetCursorPos($point[0], $point[1])
314
+ Start-Sleep -Milliseconds 80
315
+ Assert-Alive
316
+ Assert-TargetFocus
317
+ [DshInput]::Button('right', $true)
318
+ Start-Sleep -Milliseconds 45
319
+ [DshInput]::Button('right', $false)
320
+ }
321
+ 'middleClick' {
322
+ $point = Get-AnchoredPoint 'middleClick'
323
+ $null = [DshInput]::SetCursorPos($point[0], $point[1])
324
+ Start-Sleep -Milliseconds 80
325
+ Assert-Alive
326
+ Assert-TargetFocus
327
+ [DshInput]::Button('middle', $true)
328
+ Start-Sleep -Milliseconds 45
329
+ [DshInput]::Button('middle', $false)
330
+ }
331
+ 'move' {
332
+ $point = Get-AnchoredPoint 'move'
333
+ $null = [DshInput]::SetCursorPos($point[0], $point[1])
334
+ # The move is queued, so an immediate read can still return the previous
335
+ # position; wait (bounded) until the pointer is where the caller asked.
336
+ $moveDeadline = [DateTime]::UtcNow.AddMilliseconds(200)
337
+ while ([DateTime]::UtcNow -lt $moveDeadline) {
338
+ $reached = [DshInput]::Cursor()
339
+ if ($reached.X -eq $point[0] -and $reached.Y -eq $point[1]) { break }
340
+ Start-Sleep -Milliseconds 10
341
+ }
342
+ }
343
+ 'drag' {
344
+ $from = Get-Point (Get-Param 'x') (Get-Param 'y') 'drag'
345
+ $to = Get-Point (Get-Param 'toX') (Get-Param 'toY') 'drag'
346
+ Assert-TargetFocus
347
+ Invoke-Drag $from[0] $from[1] $to[0] $to[1] $button
348
+ }
349
+ 'scroll' {
350
+ $amountRaw = Get-Param 'amount'
351
+ $amount = 3
352
+ if ($null -ne $amountRaw) {
353
+ if (-not [int]::TryParse(([string] $amountRaw), [ref] $amount)) { Write-ActionError "amount must be an integer (got '$amountRaw')" }
354
+ }
355
+ if ($amount -eq 0) { Write-ActionError 'amount must not be zero' }
356
+ $hasPoint = ($null -ne (Get-Param 'x')) -and ($null -ne (Get-Param 'y'))
357
+ if ($hasPoint) {
358
+ $point = Get-Point (Get-Param 'x') (Get-Param 'y') 'scroll'
359
+ $null = [DshInput]::SetCursorPos($point[0], $point[1])
360
+ Start-Sleep -Milliseconds 60
361
+ }
362
+ Assert-Alive
363
+ Assert-TargetFocus
364
+ $horizontal = ($null -ne (Get-Param 'toX')) -or ($null -ne (Get-Param 'toY'))
365
+ if ($horizontal) {
366
+ [DshInput]::HWheel($amount * 120)
367
+ } else {
368
+ [DshInput]::Wheel($amount * 120)
369
+ }
370
+ }
371
+ 'type' {
372
+ $text = [string] (Get-Param 'text')
373
+ if ([string]::IsNullOrEmpty($text)) { Write-ActionError 'type requires non-empty text' }
374
+ Assert-TargetFocus
375
+ Invoke-TextPaste $text
376
+ $notes += 'The text was delivered as one paste, so the target saw no individual keystrokes.'
377
+ }
378
+ 'key' {
379
+ $keySpec = [string] (Get-Param 'key')
380
+ if ([string]::IsNullOrWhiteSpace($keySpec)) { Write-ActionError 'key requires a value such as enter, tab, esc, ctrl+s, alt+tab' }
381
+ $sequence = Get-KeySequence $keySpec
382
+ Assert-TargetFocus
383
+ if ($sequence.win) {
384
+ [DshInput]::WinKey($true)
385
+ Start-Sleep -Milliseconds 60
386
+ try { [DshInput]::SendKeysText($sequence.sequence) }
387
+ catch { Write-ActionError "the keystroke '$keySpec' could not be delivered: $($_.Exception.Message)" }
388
+ finally { [DshInput]::WinKey($false) }
389
+ } else {
390
+ try { [DshInput]::SendKeysText($sequence.sequence) }
391
+ catch { Write-ActionError "the keystroke '$keySpec' could not be delivered: $($_.Exception.Message)" }
392
+ }
393
+ }
394
+ 'controls' {
395
+ # Read the window's UI Automation tree and return it as text. This is the
396
+ # cheap half of seeing: a control's name and rectangle, no frame, no pixels.
397
+ # It returns its own result and returns early so it never pays the settle
398
+ # or the capture - the whole point is that no screenshot is needed.
399
+ $treeHandle = $script:TargetHwnd
400
+ if ($treeHandle -eq [IntPtr]::Zero) {
401
+ if ([string]::IsNullOrWhiteSpace($windowTitle)) { Write-ActionError 'controls requires a title, or an hwnd' }
402
+ $treeHandle = Find-DshWindowHandle $windowTitle
403
+ if ($treeHandle -eq [IntPtr]::Zero) { Write-ActionError ('no visible top-level window matches ''{0}''; pass an hwnd if the window is hidden to the tray' -f $windowTitle) }
404
+ }
405
+ $includeValues = $false
406
+ $ivRaw = Get-Param 'includeValues'
407
+ if ($null -ne $ivRaw) { $includeValues = [bool] $ivRaw }
408
+ $meaningfulOnly = $true
409
+ $moRaw = Get-Param 'meaningfulOnly'
410
+ if ($null -ne $moRaw) { $meaningfulOnly = [bool] $moRaw }
411
+ $tree = Get-DshUiaNodes -Handle $treeHandle -IncludeValues $includeValues -MeaningfulOnly $meaningfulOnly
412
+ if (-not $script:UiWarmed -and $tree.total -lt 8) {
413
+ # A window that really has fewer than eight elements pays one extra read
414
+ # once per worker; a cold Electron window is the case this exists for.
415
+ Start-Sleep -Milliseconds 300
416
+ $tree = Get-DshUiaNodes -Handle $treeHandle -IncludeValues $includeValues -MeaningfulOnly $meaningfulOnly
417
+ }
418
+ if ($tree.total -ge 8) { $script:UiWarmed = $true }
419
+ if (-not $tree.ok) { Write-ActionError $tree.error }
420
+ $items = @{}
421
+ foreach ($n in $tree.nodes) { $items[[int] $n.index] = @{ role = [string] $n.role; name = [string] $n.name } }
422
+ $script:UiListing = @{ handle = [long] $treeHandle; items = $items }
423
+ $lines = Format-DshUiaNodes -Nodes $tree.nodes -Truncated $tree.truncated
424
+ if ($meaningfulOnly -and $tree.nodes.Count -eq 0) {
425
+ # An empty list would read as 'this window has no tree', which is the one
426
+ # wrong conclusion to leave a caller with: say what is actually there.
427
+ $full = Get-DshUiaNodes -Handle $treeHandle -MeaningfulOnly $false
428
+ if ($full.nodes.Count -gt 0) {
429
+ $lines = @(('(no operable controls here; the window has {0} elements - pass meaningfulOnly:false to see them)' -f $full.nodes.Count))
430
+ }
431
+ }
432
+ $treeCursor = [DshInput]::Cursor()
433
+ $result = [ordered]@{
434
+ ok = $true
435
+ action = 'controls'
436
+ cursor = [ordered]@{ x = $treeCursor.X; y = $treeCursor.Y }
437
+ foregroundTitle = [DshInput]::ForegroundTitle()
438
+ foregroundClass = [DshInput]::ForegroundClass()
439
+ foregroundProcess = Get-ProcessNameSafe ([uint32] [DshInput]::ForegroundPid())
440
+ controls = $lines
441
+ controlCount = $tree.nodes.Count
442
+ controlsTruncated = [bool] $tree.truncated
443
+ controlsWindow = [DshInput]::WindowTitle($treeHandle)
444
+ notes = $notes
445
+ actedAt = [DateTimeOffset]::Now.ToString('o')
446
+ }
447
+ return $result
448
+ }
449
+ 'focus' { # A refused hand-off used to be a note: the step reported ok and the batch
450
+ # carried on, so every later click and keystroke went to whatever window was
451
+ # actually in front. That is the one failure this plugin must not report as
452
+ # success, so it is an error now; tolerant keeps the old behaviour for a
453
+ # caller that knows the coordinates are right either way.
454
+ $tolerant = $false
455
+ $tolerantRaw = Get-Param 'tolerant'
456
+ if ($null -ne $tolerantRaw) { $tolerant = [bool] $tolerantRaw }
457
+ $label = ''
458
+ $target = [IntPtr]::Zero
459
+ if ($script:TargetHwnd -ne [IntPtr]::Zero) {
460
+ # An explicit handle is authoritative and works for a window hidden
461
+ # to the tray, which a title search cannot reach.
462
+ if (-not [DshInput]::IsAlive($script:TargetHwnd)) { Write-ActionError "the target window (hwnd $($script:TargetHwndText)) no longer exists" }
463
+ $target = $script:TargetHwnd
464
+ $label = "hwnd $($script:TargetHwndText)"
465
+ $null = [DshInput]::ForceForeground($target)
466
+ Start-Sleep -Milliseconds 220
467
+ } else {
468
+ if ([string]::IsNullOrWhiteSpace($windowTitle)) { Write-ActionError 'focus requires a title, or an hwnd' }
469
+ $needle = (Remove-ZeroWidth $windowTitle).ToLowerInvariant()
470
+ # EnumWindows, not Process.MainWindowHandle: an Electron, UWP or tray
471
+ # window frequently reports no main window handle at all, so a
472
+ # MainWindowHandle scan cannot find a window that the windows action
473
+ # (which already enumerates) happily lists.
474
+ $fallback = [IntPtr]::Zero
475
+ foreach ($handle in [DshInput]::TopLevelWindows()) {
476
+ if (-not [DshInput]::IsWindowVisible($handle)) { continue }
477
+ $title = [DshInput]::WindowTitle($handle)
478
+ if ($title.Length -eq 0) { continue }
479
+ $owner = [uint32] 0
480
+ $null = [DshInput]::GetWindowThreadProcessId($handle, [ref] $owner)
481
+ $processName = Get-ProcessNameSafe $owner
482
+ $hit = (Remove-ZeroWidth $title).ToLowerInvariant().Contains($needle)
483
+ if (-not $hit -and $null -ne $processName) { $hit = $processName.ToLowerInvariant().Contains($needle) }
484
+ if (-not $hit) { continue }
485
+ if ([DshInput]::IsForeground($handle)) { $target = $handle; break }
486
+ if ($fallback -eq [IntPtr]::Zero) { $fallback = $handle }
487
+ }
488
+ if ($target -eq [IntPtr]::Zero) { $target = $fallback }
489
+ if ($target -eq [IntPtr]::Zero) { Write-ActionError "no visible top-level window matches '$windowTitle'; pass an hwnd if the window is hidden to the tray" }
490
+ $label = "'" + [DshInput]::WindowTitle($target) + "'"
491
+ $null = [DshInput]::Activate($target)
492
+ Start-Sleep -Milliseconds 220
493
+ }
494
+
495
+ # Ask the desktop, not the API. SetForegroundWindow is documented to be
496
+ # unreliable about its return value, and the state that decides where the
497
+ # next click lands is the foreground window, not what the call returned.
498
+ if (-not [DshInput]::IsForeground($target)) {
499
+ $holder = [DshInput]::ForegroundTitle()
500
+ if ($tolerant) {
501
+ $notes += "Windows would not hand focus to $label; '$holder' kept it (tolerant: continuing)."
502
+ } else {
503
+ Write-ActionError "could not bring $label to the foreground; '$holder' still holds it, so the rest of this batch would land there. Pass tolerant: true on this step if the coordinates are right either way."
504
+ }
505
+ }
506
+ }
507
+ 'windows' {
508
+ $query = $windowTitle.ToLowerInvariant()
509
+ $list = @()
510
+ foreach ($handle in [DshInput]::TopLevelWindows()) {
511
+ $title = [DshInput]::WindowTitle($handle)
512
+ if ($title.Length -eq 0) { continue }
513
+ $owner = [uint32] 0
514
+ $null = [DshInput]::GetWindowThreadProcessId($handle, [ref] $owner)
515
+ $processName = Get-ProcessNameSafe $owner
516
+ if ([string]::IsNullOrEmpty($query) -eq $false) {
517
+ $inTitle = $title.ToLowerInvariant().Contains($query)
518
+ $inProcess = ($null -ne $processName) -and $processName.ToLowerInvariant().Contains($query)
519
+ if (-not ($inTitle -or $inProcess)) { continue }
520
+ }
521
+ $rect = [DshInput]::WindowRect($handle)
522
+ $list += [ordered]@{
523
+ handle = ('0x{0:X}' -f $handle.ToInt64())
524
+ title = $title
525
+ process = $processName
526
+ pid = [int] $owner
527
+ bounds = ('{0},{1},{2},{3}' -f $rect.Left, $rect.Top, ($rect.Right - $rect.Left), ($rect.Bottom - $rect.Top))
528
+ minimized = [bool] [DshInput]::IsIconic($handle)
529
+ visible = [bool] [DshInput]::IsWindowVisible($handle)
530
+ foreground = [bool] [DshInput]::IsForeground($handle)
531
+ }
532
+ }
533
+ $foreground = $list | Where-Object { $_.foreground } | Select-Object -First 1
534
+ $result = [ordered]@{
535
+ ok = $true
536
+ action = 'windows'
537
+ windows = $list
538
+ windowCount = $list.Count
539
+ foregroundWindow = $foreground
540
+ foregroundTitle = [DshInput]::ForegroundTitle()
541
+ cursor = [ordered]@{ x = ([DshInput]::Cursor()).X; y = ([DshInput]::Cursor()).Y }
542
+ notes = @()
543
+ actedAt = [DateTimeOffset]::Now.ToString('o')
544
+ }
545
+ return $result
546
+ }
547
+ default {
548
+ Write-ActionError "unknown action '$action' (use click, doubleClick, rightClick, middleClick, move, drag, scroll, type, key, focus, or windows)"
549
+ }
550
+ }
551
+
552
+ # ------------------------------------------------------------------ settle + report
553
+ $settleRaw = Get-Param 'settleMs'
554
+ $settle = 750
555
+ if ($null -ne $settleRaw) {
556
+ if (-not [int]::TryParse(([string] $settleRaw), [ref] $settle)) { Write-ActionError "settleMs must be an integer (got '$settleRaw')" }
557
+ }
558
+ if ($settle -lt 0) { $settle = 0 }
559
+ if ($settle -gt 0) {
560
+ $remaining = $settle
561
+ while ($remaining -gt 0) {
562
+ Assert-Alive
563
+ $slice = [math]::Min(120, $remaining)
564
+ Start-Sleep -Milliseconds $slice
565
+ $remaining -= $slice
566
+ }
541
567
  }
542
568
 
543
- # ------------------------------------------------------------------ settle + report
544
- $settleRaw = Get-Param 'settleMs'
545
- $settle = 750
546
- if ($null -ne $settleRaw) {
547
- if (-not [int]::TryParse(([string] $settleRaw), [ref] $settle)) { Write-ActionError "settleMs must be an integer (got '$settleRaw')" }
548
- }
549
- if ($settle -lt 0) { $settle = 0 }
550
- if ($settle -gt 0) {
551
- $remaining = $settle
552
- while ($remaining -gt 0) {
553
- Assert-Alive
554
- $slice = [math]::Min(120, $remaining)
555
- Start-Sleep -Milliseconds $slice
556
- $remaining -= $slice
557
- }
558
- }
559
-
560
- $cursor = [DshInput]::Cursor()
561
- $result = [ordered]@{
562
- ok = $true
563
- action = $action
564
- cursor = [ordered]@{ x = $cursor.X; y = $cursor.Y }
565
- foregroundTitle = [DshInput]::ForegroundTitle()
566
- foregroundClass = [DshInput]::ForegroundClass()
567
- foregroundProcess = Get-ProcessNameSafe ([uint32] [DshInput]::ForegroundPid())
568
- notes = $notes
569
- actedAt = [DateTimeOffset]::Now.ToString('o')
569
+ $cursor = [DshInput]::Cursor()
570
+ $result = [ordered]@{
571
+ ok = $true
572
+ action = $action
573
+ cursor = [ordered]@{ x = $cursor.X; y = $cursor.Y }
574
+ foregroundTitle = [DshInput]::ForegroundTitle()
575
+ foregroundClass = [DshInput]::ForegroundClass()
576
+ foregroundProcess = Get-ProcessNameSafe ([uint32] [DshInput]::ForegroundPid())
577
+ notes = $notes
578
+ actedAt = [DateTimeOffset]::Now.ToString('o')
570
579
  }
571
580
  return $result
572
581
  }