@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.
- package/README.md +86 -13
- package/lib/index.js +190 -42
- package/lib/scripts/_dsh-action.ps1 +434 -425
- package/lib/scripts/_dsh-capture.ps1 +1 -1
- package/lib/scripts/_dsh-overlay.ps1 +301 -0
- package/lib/scripts/_dsh-uia.ps1 +1 -1
- package/lib/scripts/_dsh-win32.ps1 +1 -1
- package/package.json +1 -1
|
@@ -42,129 +42,138 @@ function Assert-Alive {
|
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
function
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if ([string]::IsNullOrEmpty($
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
} catch {
|
|
56
|
-
|
|
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
|
-
#
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
if (
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
return @($
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function Invoke-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
[
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
544
|
-
$
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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
|
}
|