@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.
@@ -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
+ }