@momasiku/dsh-pilot 0.0.0-stage → 0.5.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +466 -3
- package/cordis.patch.yml +19 -0
- package/lib/index.js +1567 -0
- package/lib/scripts/_dsh-action.ps1 +417 -0
- package/lib/scripts/_dsh-capture.ps1 +241 -0
- package/lib/scripts/_dsh-win32.ps1 +214 -0
- package/lib/scripts/desktop-action.ps1 +40 -0
- package/lib/scripts/desktop-probe.ps1 +43 -0
- package/lib/scripts/desktop-worker.ps1 +63 -0
- package/package.json +58 -6
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
# pilot / _dsh-action.ps1 - one action, executed in whatever process called it.
|
|
2
|
+
#
|
|
3
|
+
# Extracted from desktop-action.ps1 so the same code serves two callers:
|
|
4
|
+
# * desktop-action.ps1 - one action per process (the fallback path);
|
|
5
|
+
# * desktop-worker.ps1 - many actions in one warm process (the fast path).
|
|
6
|
+
#
|
|
7
|
+
# Differences from the one-shot script it came from: failures throw
|
|
8
|
+
# (Write-ActionError / Assert-Alive used to print JSON and exit, which inside a
|
|
9
|
+
# long-lived worker would kill every later request), and the result is returned as
|
|
10
|
+
# a hashtable instead of being printed.
|
|
11
|
+
$ErrorActionPreference = 'Stop'
|
|
12
|
+
|
|
13
|
+
# Set by Write-ActionError so a caller can forward the structured payload a thrown
|
|
14
|
+
# failure carries (cancellation is not just an error message).
|
|
15
|
+
$script:ActionStopPayload = $null
|
|
16
|
+
|
|
17
|
+
function Write-ActionError([string] $message) {
|
|
18
|
+
$script:ActionStopPayload = [ordered]@{ ok = $false; error = $message }
|
|
19
|
+
throw [System.Exception]::new($message)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function Assert-Alive {
|
|
23
|
+
if (Test-Cancelled) {
|
|
24
|
+
$script:ActionStopPayload = [ordered]@{ ok = $false; cancelled = $true; error = 'the action loop was cancelled before this step' }
|
|
25
|
+
throw [System.Exception]::new('the action loop was cancelled before this step')
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function Get-ProcessNameSafe([uint32] $pidValue) {
|
|
30
|
+
try { return (Get-Process -Id $pidValue -ErrorAction Stop).ProcessName } catch { return $null }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function Test-Cancelled {
|
|
34
|
+
if ([string]::IsNullOrEmpty($script:CancelFile)) { return $false }
|
|
35
|
+
if (-not (Test-Path -LiteralPath $script:CancelFile)) { return $false }
|
|
36
|
+
try {
|
|
37
|
+
$content = [System.IO.File]::ReadAllText($script:CancelFile, [System.Text.Encoding]::UTF8)
|
|
38
|
+
return $content.Trim() -eq 'stop'
|
|
39
|
+
} catch {
|
|
40
|
+
return $false
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# Re-assert the target window as foreground immediately before injecting input.
|
|
46
|
+
# Focus can be taken by another process between two steps, and a click or
|
|
47
|
+
# keystroke delivered to the wrong window is a real, destructive side effect, so
|
|
48
|
+
# this fails loud rather than acting on whatever happens to be in front.
|
|
49
|
+
function Assert-TargetFocus {
|
|
50
|
+
if ($script:TargetHwnd -eq [IntPtr]::Zero) { return }
|
|
51
|
+
if (-not [DshInput]::IsAlive($script:TargetHwnd)) {
|
|
52
|
+
Write-ActionError "the target window (hwnd $($script:TargetHwndText)) no longer exists; re-read the screen and target a live window"
|
|
53
|
+
}
|
|
54
|
+
if ([DshInput]::IsForeground($script:TargetHwnd)) { return }
|
|
55
|
+
if ([DshInput]::ForceForeground($script:TargetHwnd)) { return }
|
|
56
|
+
$fg = [DshInput]::ForegroundTitle()
|
|
57
|
+
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"
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function Get-Point([object] $x, [object] $y, [string] $what) {
|
|
61
|
+
if ($null -eq $x -or $null -eq $y) { Write-ActionError "$what requires both x and y" }
|
|
62
|
+
$px = 0; $py = 0
|
|
63
|
+
if (-not [int]::TryParse(([string] $x), [ref] $px)) { Write-ActionError "$what x must be an integer (got '$x')" }
|
|
64
|
+
if (-not [int]::TryParse(([string] $y), [ref] $py)) { Write-ActionError "$what y must be an integer (got '$y')" }
|
|
65
|
+
$vx = [DshInput]::GetSystemMetrics(76); $vy = [DshInput]::GetSystemMetrics(77)
|
|
66
|
+
$vw = [DshInput]::GetSystemMetrics(78); $vh = [DshInput]::GetSystemMetrics(79)
|
|
67
|
+
if ($px -lt $vx -or $py -lt $vy -or $px -ge ($vx + $vw) -or $py -ge ($vy + $vh)) {
|
|
68
|
+
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"
|
|
69
|
+
}
|
|
70
|
+
return @($px, $py)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function Invoke-Drag([int] $fromX, [int] $fromY, [int] $toX, [int] $toY, [string] $button) {
|
|
74
|
+
$null = [DshInput]::SetCursorPos($fromX, $fromY)
|
|
75
|
+
Start-Sleep -Milliseconds 90
|
|
76
|
+
Assert-Alive
|
|
77
|
+
[DshInput]::Button($button, $true)
|
|
78
|
+
try {
|
|
79
|
+
$steps = 24
|
|
80
|
+
for ($i = 1; $i -le $steps; $i++) {
|
|
81
|
+
$x = [int] [math]::Round($fromX + (($toX - $fromX) * $i / $steps))
|
|
82
|
+
$y = [int] [math]::Round($fromY + (($toY - $fromY) * $i / $steps))
|
|
83
|
+
$null = [DshInput]::SetCursorPos($x, $y)
|
|
84
|
+
Start-Sleep -Milliseconds 18
|
|
85
|
+
if ($i % 4 -eq 0) { Assert-Alive }
|
|
86
|
+
}
|
|
87
|
+
} finally {
|
|
88
|
+
# Never leave the left button latched down, even on abort.
|
|
89
|
+
[DshInput]::Button($button, $false)
|
|
90
|
+
}
|
|
91
|
+
Start-Sleep -Milliseconds 90
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function Get-KeySequence([string] $spec) {
|
|
95
|
+
$raw = $spec.Trim()
|
|
96
|
+
if ([string]::IsNullOrEmpty($raw)) { Write-ActionError 'key requires a non-empty value' }
|
|
97
|
+
$parts = @(($raw -split '\+') | Where-Object { $_ -ne '' } | ForEach-Object { $_.Trim() })
|
|
98
|
+
if ($parts.Count -eq 0) { Write-ActionError "key '$spec' has no usable key name" }
|
|
99
|
+
$modifiers = ''
|
|
100
|
+
$base = ''
|
|
101
|
+
foreach ($part in $parts) {
|
|
102
|
+
$lower = $part.ToLowerInvariant()
|
|
103
|
+
if ($lower -eq 'ctrl' -or $lower -eq 'control') { $modifiers += '^'; continue }
|
|
104
|
+
if ($lower -eq 'alt') { $modifiers += '%'; continue }
|
|
105
|
+
if ($lower -eq 'shift') { $modifiers += '+'; continue }
|
|
106
|
+
if (@('win', 'windows', 'cmd', 'meta') -contains $lower) { $modifiers += 'WIN'; continue }
|
|
107
|
+
if (-not [string]::IsNullOrEmpty($base)) { Write-ActionError "key combination '$spec' names both '$base' and '$part'; only one non-modifier key is supported" }
|
|
108
|
+
$base = $part
|
|
109
|
+
}
|
|
110
|
+
if ([string]::IsNullOrEmpty($base) -and [string]::IsNullOrEmpty($modifiers)) { Write-ActionError "key '$spec' has no usable key name" }
|
|
111
|
+
|
|
112
|
+
# Named keys become SendKeys tokens; a single literal character passes through.
|
|
113
|
+
$named = @{
|
|
114
|
+
'enter' = 'ENTER'; 'return' = 'ENTER'; 'tab' = 'TAB'; 'esc' = 'ESC'; 'escape' = 'ESC'
|
|
115
|
+
'space' = ' '; 'backspace' = 'BACKSPACE'; 'back' = 'BACKSPACE'; 'delete' = 'DELETE'; 'del' = 'DELETE'
|
|
116
|
+
'home' = 'HOME'; 'end' = 'END'; 'pageup' = 'PGUP'; 'pgup' = 'PGUP'; 'pagedown' = 'PGDN'; 'pgdn' = 'PGDN'
|
|
117
|
+
'up' = 'UP'; 'down' = 'DOWN'; 'left' = 'LEFT'; 'right' = 'RIGHT'; 'insert' = 'INS'; 'ins' = 'INS'
|
|
118
|
+
}
|
|
119
|
+
if (-not [string]::IsNullOrEmpty($base)) {
|
|
120
|
+
$lowerBase = $base.ToLowerInvariant()
|
|
121
|
+
if ($named.ContainsKey($lowerBase)) {
|
|
122
|
+
$base = $named[$lowerBase]
|
|
123
|
+
} elseif ($lowerBase -match '^f([1-9]|1[0-6])$') {
|
|
124
|
+
$base = 'F' + $Matches[1]
|
|
125
|
+
} elseif ($lowerBase -match '^f([0-9]{1,2})$') {
|
|
126
|
+
Write-ActionError "key '$spec' is above F16, which the Windows SendKeys bridge does not implement; use F1..F16"
|
|
127
|
+
} elseif ($base.Length -ne 1) {
|
|
128
|
+
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"
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
$encoded = if ($base.Length -eq 0) { '' } elseif ($base.Length -eq 1 -and -not ($base -cmatch '[+^%~()\[\]{}]')) { $base } else { "{$base}" }
|
|
132
|
+
if ($modifiers.Contains('WIN')) {
|
|
133
|
+
$plain = $modifiers.Replace('WIN', '')
|
|
134
|
+
if ($plain.Length -gt 0) { Write-ActionError "key '$spec' mixes the win modifier with other modifiers, which this build does not support" }
|
|
135
|
+
return @{ win = $true; sequence = $encoded }
|
|
136
|
+
}
|
|
137
|
+
return @{ win = $false; sequence = ($modifiers + $encoded) }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function Invoke-TextPaste([string] $text) {
|
|
141
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
142
|
+
$saved = $null
|
|
143
|
+
try { if ([System.Windows.Forms.Clipboard]::ContainsText()) { $saved = [System.Windows.Forms.Clipboard]::GetText() } } catch { $saved = $null }
|
|
144
|
+
try { [System.Windows.Forms.Clipboard]::SetText($text) } catch { Write-ActionError "could not place the text on the clipboard: $($_.Exception.Message)" }
|
|
145
|
+
Start-Sleep -Milliseconds 60
|
|
146
|
+
Assert-Alive
|
|
147
|
+
try { [DshInput]::SendKeysText('^v') } catch { Write-ActionError "the paste keystroke could not be delivered: $($_.Exception.Message)" }
|
|
148
|
+
Start-Sleep -Milliseconds 120
|
|
149
|
+
if ($null -ne $saved) {
|
|
150
|
+
try { [System.Windows.Forms.Clipboard]::SetText($saved) } catch { }
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
<#
|
|
155
|
+
Execute one action. $Config is the request object the callers already build
|
|
156
|
+
(action, x, y, toX, toY, amount, text, key, title, button, hwnd, cancelFile,
|
|
157
|
+
settleMs). Returns the same shape the one-shot script used to print.
|
|
158
|
+
#>
|
|
159
|
+
function Invoke-DshAction {
|
|
160
|
+
param([object] $Config)
|
|
161
|
+
|
|
162
|
+
function Get-Param([string] $key) {
|
|
163
|
+
if ($null -ne $Config -and $Config.PSObject.Properties.Name -contains $key) { return $Config.$key }
|
|
164
|
+
return $null
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
$script:CancelFile = [string] (Get-Param 'cancelFile')
|
|
168
|
+
|
|
169
|
+
# Optional target window. When present, every input action re-asserts it as the
|
|
170
|
+
# foreground window immediately before injecting, so a click or keystroke can
|
|
171
|
+
# never land in whatever window happens to be in front instead.
|
|
172
|
+
$script:TargetHwnd = [IntPtr]::Zero
|
|
173
|
+
$script:TargetHwndText = ''
|
|
174
|
+
$hwndRaw = [string] (Get-Param 'hwnd')
|
|
175
|
+
if (-not [string]::IsNullOrWhiteSpace($hwndRaw)) {
|
|
176
|
+
$parsed = [long] 0
|
|
177
|
+
$text = $hwndRaw.Trim()
|
|
178
|
+
$ok = $false
|
|
179
|
+
if ($text -match '^0[xX]([0-9a-fA-F]+)$') { $ok = [long]::TryParse($Matches[1], [System.Globalization.NumberStyles]::HexNumber, [System.Globalization.CultureInfo]::InvariantCulture, [ref] $parsed) }
|
|
180
|
+
else { $ok = [long]::TryParse($text, [ref] $parsed) }
|
|
181
|
+
if (-not $ok -or $parsed -eq 0) { Write-ActionError "hwnd must be a nonzero window handle such as 0x1094C (got '$hwndRaw')" }
|
|
182
|
+
$script:TargetHwnd = [IntPtr] $parsed
|
|
183
|
+
$script:TargetHwndText = $text
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
$action = [string] (Get-Param 'action')
|
|
187
|
+
if ([string]::IsNullOrWhiteSpace($action)) { Write-ActionError 'action is required' }
|
|
188
|
+
$action = $action.Trim()
|
|
189
|
+
|
|
190
|
+
$buttonRaw = [string] (Get-Param 'button')
|
|
191
|
+
$button = if ([string]::IsNullOrWhiteSpace($buttonRaw)) { 'left' } else { $buttonRaw.Trim().ToLowerInvariant() }
|
|
192
|
+
if (@('left', 'right', 'middle') -notcontains $button) { Write-ActionError "button must be left, right, or middle (got '$button')" }
|
|
193
|
+
|
|
194
|
+
$windowTitle = [string] (Get-Param 'title')
|
|
195
|
+
if ($null -eq $windowTitle) { $windowTitle = '' }
|
|
196
|
+
|
|
197
|
+
$notes = @()
|
|
198
|
+
Assert-Alive
|
|
199
|
+
|
|
200
|
+
switch ($action) {
|
|
201
|
+
'click' {
|
|
202
|
+
$point = Get-Point (Get-Param 'x') (Get-Param 'y') 'click'
|
|
203
|
+
$null = [DshInput]::SetCursorPos($point[0], $point[1])
|
|
204
|
+
Start-Sleep -Milliseconds 80
|
|
205
|
+
Assert-Alive
|
|
206
|
+
Assert-TargetFocus
|
|
207
|
+
[DshInput]::Button($button, $true)
|
|
208
|
+
Start-Sleep -Milliseconds 45
|
|
209
|
+
[DshInput]::Button($button, $false)
|
|
210
|
+
}
|
|
211
|
+
'doubleClick' {
|
|
212
|
+
$point = Get-Point (Get-Param 'x') (Get-Param 'y') 'doubleClick'
|
|
213
|
+
$null = [DshInput]::SetCursorPos($point[0], $point[1])
|
|
214
|
+
Start-Sleep -Milliseconds 80
|
|
215
|
+
Assert-Alive
|
|
216
|
+
Assert-TargetFocus
|
|
217
|
+
[DshInput]::Button($button, $true); Start-Sleep -Milliseconds 40; [DshInput]::Button($button, $false)
|
|
218
|
+
Start-Sleep -Milliseconds 70
|
|
219
|
+
Assert-Alive
|
|
220
|
+
Assert-TargetFocus
|
|
221
|
+
[DshInput]::Button($button, $true); Start-Sleep -Milliseconds 40; [DshInput]::Button($button, $false)
|
|
222
|
+
}
|
|
223
|
+
'rightClick' {
|
|
224
|
+
$point = Get-Point (Get-Param 'x') (Get-Param 'y') 'rightClick'
|
|
225
|
+
$null = [DshInput]::SetCursorPos($point[0], $point[1])
|
|
226
|
+
Start-Sleep -Milliseconds 80
|
|
227
|
+
Assert-Alive
|
|
228
|
+
Assert-TargetFocus
|
|
229
|
+
[DshInput]::Button('right', $true)
|
|
230
|
+
Start-Sleep -Milliseconds 45
|
|
231
|
+
[DshInput]::Button('right', $false)
|
|
232
|
+
}
|
|
233
|
+
'middleClick' {
|
|
234
|
+
$point = Get-Point (Get-Param 'x') (Get-Param 'y') 'middleClick'
|
|
235
|
+
$null = [DshInput]::SetCursorPos($point[0], $point[1])
|
|
236
|
+
Start-Sleep -Milliseconds 80
|
|
237
|
+
Assert-Alive
|
|
238
|
+
Assert-TargetFocus
|
|
239
|
+
[DshInput]::Button('middle', $true)
|
|
240
|
+
Start-Sleep -Milliseconds 45
|
|
241
|
+
[DshInput]::Button('middle', $false)
|
|
242
|
+
}
|
|
243
|
+
'move' {
|
|
244
|
+
$point = Get-Point (Get-Param 'x') (Get-Param 'y') 'move'
|
|
245
|
+
$null = [DshInput]::SetCursorPos($point[0], $point[1])
|
|
246
|
+
# The move is queued, so an immediate read can still return the previous
|
|
247
|
+
# position; wait (bounded) until the pointer is where the caller asked.
|
|
248
|
+
$moveDeadline = [DateTime]::UtcNow.AddMilliseconds(200)
|
|
249
|
+
while ([DateTime]::UtcNow -lt $moveDeadline) {
|
|
250
|
+
$reached = [DshInput]::Cursor()
|
|
251
|
+
if ($reached.X -eq $point[0] -and $reached.Y -eq $point[1]) { break }
|
|
252
|
+
Start-Sleep -Milliseconds 10
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
'drag' {
|
|
256
|
+
$from = Get-Point (Get-Param 'x') (Get-Param 'y') 'drag'
|
|
257
|
+
$to = Get-Point (Get-Param 'toX') (Get-Param 'toY') 'drag'
|
|
258
|
+
Assert-TargetFocus
|
|
259
|
+
Invoke-Drag $from[0] $from[1] $to[0] $to[1] $button
|
|
260
|
+
}
|
|
261
|
+
'scroll' {
|
|
262
|
+
$amountRaw = Get-Param 'amount'
|
|
263
|
+
$amount = 3
|
|
264
|
+
if ($null -ne $amountRaw) {
|
|
265
|
+
if (-not [int]::TryParse(([string] $amountRaw), [ref] $amount)) { Write-ActionError "amount must be an integer (got '$amountRaw')" }
|
|
266
|
+
}
|
|
267
|
+
if ($amount -eq 0) { Write-ActionError 'amount must not be zero' }
|
|
268
|
+
$hasPoint = ($null -ne (Get-Param 'x')) -and ($null -ne (Get-Param 'y'))
|
|
269
|
+
if ($hasPoint) {
|
|
270
|
+
$point = Get-Point (Get-Param 'x') (Get-Param 'y') 'scroll'
|
|
271
|
+
$null = [DshInput]::SetCursorPos($point[0], $point[1])
|
|
272
|
+
Start-Sleep -Milliseconds 60
|
|
273
|
+
}
|
|
274
|
+
Assert-Alive
|
|
275
|
+
Assert-TargetFocus
|
|
276
|
+
$horizontal = ($null -ne (Get-Param 'toX')) -or ($null -ne (Get-Param 'toY'))
|
|
277
|
+
if ($horizontal) {
|
|
278
|
+
[DshInput]::HWheel($amount * 120)
|
|
279
|
+
} else {
|
|
280
|
+
[DshInput]::Wheel($amount * 120)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
'type' {
|
|
284
|
+
$text = [string] (Get-Param 'text')
|
|
285
|
+
if ([string]::IsNullOrEmpty($text)) { Write-ActionError 'type requires non-empty text' }
|
|
286
|
+
Assert-TargetFocus
|
|
287
|
+
Invoke-TextPaste $text
|
|
288
|
+
$notes += 'The text was delivered as one paste, so the target saw no individual keystrokes.'
|
|
289
|
+
}
|
|
290
|
+
'key' {
|
|
291
|
+
$keySpec = [string] (Get-Param 'key')
|
|
292
|
+
if ([string]::IsNullOrWhiteSpace($keySpec)) { Write-ActionError 'key requires a value such as enter, tab, esc, ctrl+s, alt+tab' }
|
|
293
|
+
$sequence = Get-KeySequence $keySpec
|
|
294
|
+
Assert-TargetFocus
|
|
295
|
+
if ($sequence.win) {
|
|
296
|
+
[DshInput]::WinKey($true)
|
|
297
|
+
Start-Sleep -Milliseconds 60
|
|
298
|
+
try { [DshInput]::SendKeysText($sequence.sequence) }
|
|
299
|
+
catch { Write-ActionError "the keystroke '$keySpec' could not be delivered: $($_.Exception.Message)" }
|
|
300
|
+
finally { [DshInput]::WinKey($false) }
|
|
301
|
+
} else {
|
|
302
|
+
try { [DshInput]::SendKeysText($sequence.sequence) }
|
|
303
|
+
catch { Write-ActionError "the keystroke '$keySpec' could not be delivered: $($_.Exception.Message)" }
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
'focus' {
|
|
307
|
+
if ($script:TargetHwnd -ne [IntPtr]::Zero) {
|
|
308
|
+
# An explicit handle is authoritative and works for a window hidden
|
|
309
|
+
# to the tray, which a title search cannot reach.
|
|
310
|
+
if (-not [DshInput]::IsAlive($script:TargetHwnd)) { Write-ActionError "the target window (hwnd $($script:TargetHwndText)) no longer exists" }
|
|
311
|
+
$raised = [DshInput]::ForceForeground($script:TargetHwnd)
|
|
312
|
+
if (-not $raised) { $notes += "Windows would not hand focus to hwnd $($script:TargetHwndText); '$([DshInput]::ForegroundTitle())' kept it." }
|
|
313
|
+
Start-Sleep -Milliseconds 220
|
|
314
|
+
} else {
|
|
315
|
+
if ([string]::IsNullOrWhiteSpace($windowTitle)) { Write-ActionError 'focus requires a title, or an hwnd' }
|
|
316
|
+
$needle = $windowTitle.ToLowerInvariant()
|
|
317
|
+
# EnumWindows, not Process.MainWindowHandle: an Electron, UWP or tray
|
|
318
|
+
# window frequently reports no main window handle at all, so a
|
|
319
|
+
# MainWindowHandle scan cannot find a window that `windows` (which
|
|
320
|
+
# already enumerates) happily lists.
|
|
321
|
+
$target = [IntPtr]::Zero
|
|
322
|
+
$fallback = [IntPtr]::Zero
|
|
323
|
+
foreach ($handle in [DshInput]::TopLevelWindows()) {
|
|
324
|
+
if (-not [DshInput]::IsWindowVisible($handle)) { continue }
|
|
325
|
+
$title = [DshInput]::WindowTitle($handle)
|
|
326
|
+
if ($title.Length -eq 0) { continue }
|
|
327
|
+
$owner = [uint32] 0
|
|
328
|
+
$null = [DshInput]::GetWindowThreadProcessId($handle, [ref] $owner)
|
|
329
|
+
$processName = Get-ProcessNameSafe $owner
|
|
330
|
+
$hit = $title.ToLowerInvariant().Contains($needle)
|
|
331
|
+
if (-not $hit -and $null -ne $processName) { $hit = $processName.ToLowerInvariant().Contains($needle) }
|
|
332
|
+
if (-not $hit) { continue }
|
|
333
|
+
if ([DshInput]::IsForeground($handle)) { $target = $handle; break }
|
|
334
|
+
if ($fallback -eq [IntPtr]::Zero) { $fallback = $handle }
|
|
335
|
+
}
|
|
336
|
+
if ($target -eq [IntPtr]::Zero) { $target = $fallback }
|
|
337
|
+
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" }
|
|
338
|
+
$raised = [DshInput]::Activate($target)
|
|
339
|
+
if (-not $raised) { $notes += "Windows refused the foreground request for '$([DshInput]::WindowTitle($target))'; the taskbar entry may still need a click." }
|
|
340
|
+
Start-Sleep -Milliseconds 220
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
'windows' {
|
|
344
|
+
$query = $windowTitle.ToLowerInvariant()
|
|
345
|
+
$list = @()
|
|
346
|
+
foreach ($handle in [DshInput]::TopLevelWindows()) {
|
|
347
|
+
$title = [DshInput]::WindowTitle($handle)
|
|
348
|
+
if ($title.Length -eq 0) { continue }
|
|
349
|
+
$owner = [uint32] 0
|
|
350
|
+
$null = [DshInput]::GetWindowThreadProcessId($handle, [ref] $owner)
|
|
351
|
+
$processName = Get-ProcessNameSafe $owner
|
|
352
|
+
if ([string]::IsNullOrEmpty($query) -eq $false) {
|
|
353
|
+
$inTitle = $title.ToLowerInvariant().Contains($query)
|
|
354
|
+
$inProcess = ($null -ne $processName) -and $processName.ToLowerInvariant().Contains($query)
|
|
355
|
+
if (-not ($inTitle -or $inProcess)) { continue }
|
|
356
|
+
}
|
|
357
|
+
$rect = [DshInput]::WindowRect($handle)
|
|
358
|
+
$list += [ordered]@{
|
|
359
|
+
handle = ('0x{0:X}' -f $handle.ToInt64())
|
|
360
|
+
title = $title
|
|
361
|
+
process = $processName
|
|
362
|
+
pid = [int] $owner
|
|
363
|
+
bounds = ('{0},{1},{2},{3}' -f $rect.Left, $rect.Top, ($rect.Right - $rect.Left), ($rect.Bottom - $rect.Top))
|
|
364
|
+
minimized = [bool] [DshInput]::IsIconic($handle)
|
|
365
|
+
visible = [bool] [DshInput]::IsWindowVisible($handle)
|
|
366
|
+
foreground = [bool] [DshInput]::IsForeground($handle)
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
$foreground = $list | Where-Object { $_.foreground } | Select-Object -First 1
|
|
370
|
+
$result = [ordered]@{
|
|
371
|
+
ok = $true
|
|
372
|
+
action = 'windows'
|
|
373
|
+
windows = $list
|
|
374
|
+
windowCount = $list.Count
|
|
375
|
+
foregroundWindow = $foreground
|
|
376
|
+
foregroundTitle = [DshInput]::ForegroundTitle()
|
|
377
|
+
cursor = [ordered]@{ x = ([DshInput]::Cursor()).X; y = ([DshInput]::Cursor()).Y }
|
|
378
|
+
notes = @()
|
|
379
|
+
actedAt = [DateTimeOffset]::Now.ToString('o')
|
|
380
|
+
}
|
|
381
|
+
return $result
|
|
382
|
+
}
|
|
383
|
+
default {
|
|
384
|
+
Write-ActionError "unknown action '$action' (use click, doubleClick, rightClick, middleClick, move, drag, scroll, type, key, focus, or windows)"
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
# ------------------------------------------------------------------ settle + report
|
|
389
|
+
$settleRaw = Get-Param 'settleMs'
|
|
390
|
+
$settle = 750
|
|
391
|
+
if ($null -ne $settleRaw) {
|
|
392
|
+
if (-not [int]::TryParse(([string] $settleRaw), [ref] $settle)) { Write-ActionError "settleMs must be an integer (got '$settleRaw')" }
|
|
393
|
+
}
|
|
394
|
+
if ($settle -lt 0) { $settle = 0 }
|
|
395
|
+
if ($settle -gt 0) {
|
|
396
|
+
$remaining = $settle
|
|
397
|
+
while ($remaining -gt 0) {
|
|
398
|
+
Assert-Alive
|
|
399
|
+
$slice = [math]::Min(120, $remaining)
|
|
400
|
+
Start-Sleep -Milliseconds $slice
|
|
401
|
+
$remaining -= $slice
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
$cursor = [DshInput]::Cursor()
|
|
406
|
+
$result = [ordered]@{
|
|
407
|
+
ok = $true
|
|
408
|
+
action = $action
|
|
409
|
+
cursor = [ordered]@{ x = $cursor.X; y = $cursor.Y }
|
|
410
|
+
foregroundTitle = [DshInput]::ForegroundTitle()
|
|
411
|
+
foregroundClass = [DshInput]::ForegroundClass()
|
|
412
|
+
foregroundProcess = Get-ProcessNameSafe ([uint32] [DshInput]::ForegroundPid())
|
|
413
|
+
notes = $notes
|
|
414
|
+
actedAt = [DateTimeOffset]::Now.ToString('o')
|
|
415
|
+
}
|
|
416
|
+
return $result
|
|
417
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# pilot / _dsh-capture.ps1 - one capture, executed in whatever process called it.
|
|
2
|
+
#
|
|
3
|
+
# Extracted from desktop-probe.ps1 so the same code serves two callers:
|
|
4
|
+
# * desktop-probe.ps1 - one frame per process (the fallback path);
|
|
5
|
+
# * desktop-worker.ps1 - frames in the warm process (the fast path).
|
|
6
|
+
#
|
|
7
|
+
# Differences from the one-shot script it came from: failures throw instead of
|
|
8
|
+
# printing JSON and exiting, and the result is returned instead of being printed.
|
|
9
|
+
$ErrorActionPreference = 'Stop'
|
|
10
|
+
|
|
11
|
+
$script:CaptureStopPayload = $null
|
|
12
|
+
|
|
13
|
+
function Write-ProbeError([string] $message) {
|
|
14
|
+
$script:CaptureStopPayload = [ordered]@{ ok = $false; error = $message }
|
|
15
|
+
throw [System.Exception]::new($message)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function Get-ProcessNameSafe([uint32] $pidValue) {
|
|
19
|
+
try { return (Get-Process -Id $pidValue -ErrorAction Stop).ProcessName } catch { return $null }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function ConvertTo-WindowInfo([IntPtr] $handle, [bool] $withProcess) {
|
|
23
|
+
if ($handle -eq [IntPtr]::Zero) { return $null }
|
|
24
|
+
$title = [DshProbe]::WindowTitle($handle)
|
|
25
|
+
$class = [DshProbe]::WindowClass($handle)
|
|
26
|
+
$rect = [DshProbe]::WindowRect($handle)
|
|
27
|
+
$pidValue = [uint32] 0
|
|
28
|
+
$null = [DshProbe]::GetWindowThreadProcessId($handle, [ref] $pidValue)
|
|
29
|
+
return [ordered]@{
|
|
30
|
+
handle = ('0x{0:X}' -f $handle.ToInt64())
|
|
31
|
+
title = $title
|
|
32
|
+
class = $class
|
|
33
|
+
process = if ($withProcess) { Get-ProcessNameSafe $pidValue } else { $null }
|
|
34
|
+
pid = [int] $pidValue
|
|
35
|
+
bounds = ('{0},{1},{2},{3}' -f $rect.Left, $rect.Top, ($rect.Right - $rect.Left), ($rect.Bottom - $rect.Top))
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
<#
|
|
41
|
+
Capture the desktop, a monitor, a region or one window. $Params carries out,
|
|
42
|
+
screen, window, region and includeCursor.
|
|
43
|
+
#>
|
|
44
|
+
function Invoke-DshCapture {
|
|
45
|
+
param([object] $Params)
|
|
46
|
+
|
|
47
|
+
$params = if ($null -eq $Params) { [pscustomobject]@{} } else { $Params }
|
|
48
|
+
|
|
49
|
+
$outPath = if ($params.PSObject.Properties.Name -contains 'out') { [string] $params.out } else { '' }
|
|
50
|
+
if ([string]::IsNullOrWhiteSpace($outPath)) { Write-ProbeError 'params.out (the PNG destination) is required' }
|
|
51
|
+
|
|
52
|
+
$screenMode = if ($params.PSObject.Properties.Name -contains 'screen' -and $null -ne $params.screen) { [string] $params.screen } else { 'primary' }
|
|
53
|
+
$windowQuery = if ($params.PSObject.Properties.Name -contains 'window' -and $null -ne $params.window) { [string] $params.window } else { '' }
|
|
54
|
+
$regionSpec = if ($params.PSObject.Properties.Name -contains 'region' -and $null -ne $params.region) { [string] $params.region } else { '' }
|
|
55
|
+
$includeCursor = [bool] ($params.PSObject.Properties.Name -contains 'includeCursor' -and $params.includeCursor)
|
|
56
|
+
$rulers = $true
|
|
57
|
+
if ($params.PSObject.Properties.Name -contains 'rulers') { $rulers = [bool] $params.rulers }
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ------------------------------------------------------------ screen inventory
|
|
61
|
+
$screens = @()
|
|
62
|
+
try {
|
|
63
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
64
|
+
$all = [System.Windows.Forms.Screen]::AllScreens
|
|
65
|
+
foreach ($s in $all) {
|
|
66
|
+
$screens += [ordered]@{
|
|
67
|
+
device = $s.DeviceName
|
|
68
|
+
primary = [bool] $s.Primary
|
|
69
|
+
bounds = ('{0},{1},{2},{3}' -f $s.Bounds.X, $s.Bounds.Y, $s.Bounds.Width, $s.Bounds.Height)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
} catch {
|
|
73
|
+
Write-ProbeError "the desktop is not available to this session (screen enumeration failed: $($_.Exception.Message))"
|
|
74
|
+
}
|
|
75
|
+
if ($screens.Count -eq 0) { Write-ProbeError 'the desktop is not available to this session (no interactive display)' }
|
|
76
|
+
|
|
77
|
+
$primaryDip = $all | Where-Object { $_.Primary } | Select-Object -First 1
|
|
78
|
+
if ($null -eq $primaryDip) { $primaryDip = $all[0] }
|
|
79
|
+
|
|
80
|
+
# Resolution order: an explicit window, then a region, then the screen mode.
|
|
81
|
+
$kind = $null
|
|
82
|
+
$captureDip = $null
|
|
83
|
+
$scale = $null
|
|
84
|
+
|
|
85
|
+
if (-not [string]::IsNullOrWhiteSpace($windowQuery)) {
|
|
86
|
+
$needle = $windowQuery.ToLowerInvariant()
|
|
87
|
+
$candidate = $null
|
|
88
|
+
foreach ($process in (Get-Process | Where-Object { $_.MainWindowHandle -ne 0 })) {
|
|
89
|
+
$handle = $process.MainWindowHandle
|
|
90
|
+
if (-not [DshProbe]::IsWindowVisible($handle)) { continue }
|
|
91
|
+
$title = [DshProbe]::WindowTitle($handle)
|
|
92
|
+
if ($title.ToLowerInvariant().Contains($needle) -or $process.ProcessName.ToLowerInvariant().Contains($needle)) {
|
|
93
|
+
if ($null -eq $candidate) { $candidate = $process }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if ($null -eq $candidate) { Write-ProbeError "no visible top-level window matches '$windowQuery'" }
|
|
97
|
+
$handle = $candidate.MainWindowHandle
|
|
98
|
+
$rect = [DshProbe]::WindowRect($handle)
|
|
99
|
+
$captureDip = [pscustomobject]@{ X = $rect.Left; Y = $rect.Top; Width = ($rect.Right - $rect.Left); Height = ($rect.Bottom - $rect.Top) }
|
|
100
|
+
$kind = 'window'
|
|
101
|
+
} elseif (-not [string]::IsNullOrWhiteSpace($regionSpec)) {
|
|
102
|
+
$parts = @($regionSpec -split '[,\s]+' | Where-Object { $_ -ne '' })
|
|
103
|
+
if ($parts.Count -ne 4) { Write-ProbeError "region must be 'x,y,width,height' (got '$regionSpec')" }
|
|
104
|
+
try { $r = @($parts | ForEach-Object { [int] $_ }) } catch { Write-ProbeError "region must contain four integers (got '$regionSpec')" }
|
|
105
|
+
if ($r[2] -le 0 -or $r[3] -le 0) { Write-ProbeError 'region width and height must be positive' }
|
|
106
|
+
$captureDip = [pscustomobject]@{ X = $r[0]; Y = $r[1]; Width = $r[2]; Height = $r[3] }
|
|
107
|
+
$kind = 'region'
|
|
108
|
+
} elseif ($screenMode -eq 'all') {
|
|
109
|
+
$bounds = [System.Windows.Forms.SystemInformation]::VirtualScreen
|
|
110
|
+
$captureDip = [pscustomobject]@{ X = $bounds.X; Y = $bounds.Y; Width = $bounds.Width; Height = $bounds.Height }
|
|
111
|
+
$kind = 'all'
|
|
112
|
+
} elseif ($screenMode -eq 'primary') {
|
|
113
|
+
$captureDip = $primaryDip.Bounds
|
|
114
|
+
$kind = 'primary'
|
|
115
|
+
} else {
|
|
116
|
+
$index = 0
|
|
117
|
+
if (-not [int]::TryParse($screenMode, [ref] $index)) { Write-ProbeError "screen must be 'primary', 'all', or a monitor index (got '$screenMode')" }
|
|
118
|
+
if ($index -lt 0 -or $index -ge $screens.Count) { Write-ProbeError "monitor index $index is out of range (0..$($screens.Count - 1))" }
|
|
119
|
+
$captureDip = $all[$index].Bounds
|
|
120
|
+
$kind = "screen:$index"
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
# --------------------------------------------------------------------- capture
|
|
124
|
+
Add-Type -AssemblyName System.Drawing
|
|
125
|
+
$bitmap = New-Object System.Drawing.Bitmap($captureDip.Width, $captureDip.Height)
|
|
126
|
+
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
|
|
127
|
+
$imageWidth = 0
|
|
128
|
+
$imageHeight = 0
|
|
129
|
+
try {
|
|
130
|
+
$graphics.CopyFromScreen($captureDip.X, $captureDip.Y, 0, 0, $bitmap.Size)
|
|
131
|
+
if ($includeCursor) {
|
|
132
|
+
try {
|
|
133
|
+
$cursor = [DshProbe]::Cursor()
|
|
134
|
+
$cx = $cursor.X - $captureDip.X
|
|
135
|
+
$cy = $cursor.Y - $captureDip.Y
|
|
136
|
+
if ($cx -ge 0 -and $cy -ge 0 -and $cx -lt $bitmap.Width -and $cy -lt $bitmap.Height) {
|
|
137
|
+
$pen = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(255, 255, 0, 0)), 2
|
|
138
|
+
$graphics.DrawLine($pen, ($cx - 14), $cy, ($cx + 14), $cy)
|
|
139
|
+
$graphics.DrawLine($pen, $cx, ($cy - 14), $cx, ($cy + 14))
|
|
140
|
+
$pen.Dispose()
|
|
141
|
+
}
|
|
142
|
+
} catch {
|
|
143
|
+
# A cursor overlay is cosmetic: never fail a capture over it.
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if ($rulers) {
|
|
147
|
+
# Coordinate rulers: ticks every $rulerStep pixels along the top and left
|
|
148
|
+
# edge, each labelled with the SCREEN coordinate it sits at (capture origin
|
|
149
|
+
# plus the local offset). The model can then read a position straight off
|
|
150
|
+
# the image instead of counting pixels, which also survives any later
|
|
151
|
+
# downscaling by the attachment store - the ratio in the envelope says what
|
|
152
|
+
# the labels have to be multiplied by.
|
|
153
|
+
try {
|
|
154
|
+
$rulerStep = if ($bitmap.Width -ge 800) { 200 } else { 100 }
|
|
155
|
+
$font = New-Object System.Drawing.Font('Segoe UI', 11, [System.Drawing.FontStyle]::Bold)
|
|
156
|
+
$ink = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(255, 255, 64, 64))
|
|
157
|
+
$shadow = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(200, 0, 0, 0))
|
|
158
|
+
$tick = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(170, 255, 64, 64)), 1
|
|
159
|
+
try {
|
|
160
|
+
for ($x = $rulerStep; $x -lt $bitmap.Width; $x += $rulerStep) {
|
|
161
|
+
$graphics.DrawLine($tick, $x, 0, $x, 10)
|
|
162
|
+
$label = [string] ([int] $captureDip.X + $x)
|
|
163
|
+
$graphics.DrawString($label, $font, $shadow, ($x + 2), 13)
|
|
164
|
+
$graphics.DrawString($label, $font, $ink, ($x + 1), 12)
|
|
165
|
+
}
|
|
166
|
+
for ($y = $rulerStep; $y -lt $bitmap.Height; $y += $rulerStep) {
|
|
167
|
+
$graphics.DrawLine($tick, 0, $y, 10, $y)
|
|
168
|
+
$label = [string] ([int] $captureDip.Y + $y)
|
|
169
|
+
$graphics.DrawString($label, $font, $shadow, 13, ($y + 2))
|
|
170
|
+
$graphics.DrawString($label, $font, $ink, 12, ($y + 1))
|
|
171
|
+
}
|
|
172
|
+
$originLabel = ('origin {0},{1} step {2}' -f [int] $captureDip.X, [int] $captureDip.Y, $rulerStep)
|
|
173
|
+
$graphics.DrawString($originLabel, $font, $shadow, 15, 30)
|
|
174
|
+
$graphics.DrawString($originLabel, $font, $ink, 14, 29)
|
|
175
|
+
} finally {
|
|
176
|
+
$tick.Dispose()
|
|
177
|
+
$font.Dispose()
|
|
178
|
+
$ink.Dispose()
|
|
179
|
+
$shadow.Dispose()
|
|
180
|
+
}
|
|
181
|
+
} catch {
|
|
182
|
+
# Rulers are an aid: never fail a capture over them.
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
$directory = Split-Path -Parent $outPath
|
|
186
|
+
if (-not [string]::IsNullOrEmpty($directory) -and -not (Test-Path -LiteralPath $directory)) {
|
|
187
|
+
$null = New-Item -ItemType Directory -Path $directory -Force
|
|
188
|
+
}
|
|
189
|
+
$bitmap.Save($outPath, [System.Drawing.Imaging.ImageFormat]::Png)
|
|
190
|
+
$imageWidth = $bitmap.Width
|
|
191
|
+
$imageHeight = $bitmap.Height
|
|
192
|
+
} catch {
|
|
193
|
+
Write-ProbeError "screen capture failed: $($_.Exception.Message)"
|
|
194
|
+
} finally {
|
|
195
|
+
$graphics.Dispose()
|
|
196
|
+
$bitmap.Dispose()
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
# DIP -> physical. Both the capture rect and the DIP screen rect come from the
|
|
200
|
+
# same monitor, so the ratio is the monitor's real scale factor.
|
|
201
|
+
if ($captureDip.Width -gt 0) { $scale = [math]::Round(($imageWidth / $captureDip.Width), 4) } else { $scale = 1 }
|
|
202
|
+
$originX = [int] [math]::Round($captureDip.X * $scale)
|
|
203
|
+
$originY = [int] [math]::Round($captureDip.Y * $scale)
|
|
204
|
+
|
|
205
|
+
# ------------------------------------------------------------------- foreground
|
|
206
|
+
$foreground = ConvertTo-WindowInfo ([DshProbe]::GetForegroundWindow()) $true
|
|
207
|
+
$cursorInfo = $null
|
|
208
|
+
try {
|
|
209
|
+
$cursor = [DshProbe]::Cursor()
|
|
210
|
+
$screen = $screens | Where-Object {
|
|
211
|
+
$b = ($_.bounds -split ',') | ForEach-Object { [int] $_ }
|
|
212
|
+
$cursor.X -ge $b[0] -and $cursor.X -lt ($b[0] + $b[2]) -and $cursor.Y -ge $b[1] -and $cursor.Y -lt ($b[1] + $b[3])
|
|
213
|
+
} | Select-Object -First 1
|
|
214
|
+
$cursorInfo = [ordered]@{
|
|
215
|
+
x = $cursor.X
|
|
216
|
+
y = $cursor.Y
|
|
217
|
+
screen = if ($null -ne $screen) { $screen.device } else { $null }
|
|
218
|
+
}
|
|
219
|
+
} catch {
|
|
220
|
+
$cursorInfo = $null
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
$result = [ordered]@{
|
|
224
|
+
ok = $true
|
|
225
|
+
path = (Resolve-Path -LiteralPath $outPath).Path
|
|
226
|
+
kind = $kind
|
|
227
|
+
imageWidth = $imageWidth
|
|
228
|
+
imageHeight = $imageHeight
|
|
229
|
+
originX = $originX
|
|
230
|
+
originY = $originY
|
|
231
|
+
scale = $scale
|
|
232
|
+
dpiAware = $true
|
|
233
|
+
dipBounds = ('{0},{1},{2},{3}' -f $captureDip.X, $captureDip.Y, $captureDip.Width, $captureDip.Height)
|
|
234
|
+
screens = $screens
|
|
235
|
+
cursor = $cursorInfo
|
|
236
|
+
foregroundWindow = $foreground
|
|
237
|
+
rulers = [bool] $rulers
|
|
238
|
+
capturedAt = [DateTimeOffset]::Now.ToString('o')
|
|
239
|
+
}
|
|
240
|
+
return $result
|
|
241
|
+
}
|