@momasiku/dsh-pilot 0.5.7 → 0.5.9

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.
@@ -304,21 +304,31 @@ switch ($action) {
304
304
  }
305
305
  }
306
306
  'focus' {
307
+ # A refused hand-off used to be a note: the step reported ok and the batch
308
+ # carried on, so every later click and keystroke went to whatever window was
309
+ # actually in front. That is the one failure this plugin must not report as
310
+ # success, so it is an error now; tolerant keeps the old behaviour for a
311
+ # caller that knows the coordinates are right either way.
312
+ $tolerant = $false
313
+ $tolerantRaw = Get-Param 'tolerant'
314
+ if ($null -ne $tolerantRaw) { $tolerant = [bool] $tolerantRaw }
315
+ $label = ''
316
+ $target = [IntPtr]::Zero
307
317
  if ($script:TargetHwnd -ne [IntPtr]::Zero) {
308
318
  # An explicit handle is authoritative and works for a window hidden
309
319
  # to the tray, which a title search cannot reach.
310
320
  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." }
321
+ $target = $script:TargetHwnd
322
+ $label = "hwnd $($script:TargetHwndText)"
323
+ $null = [DshInput]::ForceForeground($target)
313
324
  Start-Sleep -Milliseconds 220
314
325
  } else {
315
326
  if ([string]::IsNullOrWhiteSpace($windowTitle)) { Write-ActionError 'focus requires a title, or an hwnd' }
316
327
  $needle = $windowTitle.ToLowerInvariant()
317
328
  # EnumWindows, not Process.MainWindowHandle: an Electron, UWP or tray
318
329
  # 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
330
+ # MainWindowHandle scan cannot find a window that the windows action
331
+ # (which already enumerates) happily lists.
322
332
  $fallback = [IntPtr]::Zero
323
333
  foreach ($handle in [DshInput]::TopLevelWindows()) {
324
334
  if (-not [DshInput]::IsWindowVisible($handle)) { continue }
@@ -335,10 +345,22 @@ switch ($action) {
335
345
  }
336
346
  if ($target -eq [IntPtr]::Zero) { $target = $fallback }
337
347
  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." }
348
+ $label = "'" + [DshInput]::WindowTitle($target) + "'"
349
+ $null = [DshInput]::Activate($target)
340
350
  Start-Sleep -Milliseconds 220
341
351
  }
352
+
353
+ # Ask the desktop, not the API. SetForegroundWindow is documented to be
354
+ # unreliable about its return value, and the state that decides where the
355
+ # next click lands is the foreground window, not what the call returned.
356
+ if (-not [DshInput]::IsForeground($target)) {
357
+ $holder = [DshInput]::ForegroundTitle()
358
+ if ($tolerant) {
359
+ $notes += "Windows would not hand focus to $label; '$holder' kept it (tolerant: continuing)."
360
+ } else {
361
+ 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."
362
+ }
363
+ }
342
364
  }
343
365
  'windows' {
344
366
  $query = $windowTitle.ToLowerInvariant()
@@ -1,241 +1,267 @@
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
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
+ # Re-enumerated on every capture, deliberately.
62
+ #
63
+ # System.Windows.Forms.Screen.AllScreens is a static cache that only refreshes
64
+ # when the process pumps WM_DISPLAYCHANGE. The warm worker is a -NonInteractive
65
+ # read loop with no message pump, so that cache is never invalidated: after a
66
+ # resolution change the worker keeps reporting the geometry it saw at startup.
67
+ # Measured here - a worker alive 13 hours reported
68
+ # <screens>primary WinDisc 0,0,1600,900</screens> while the same result carried
69
+ # <image_size>1024x768</image_size>. Restarting the worker produced the correct
70
+ # 1024x768. Two answers to one question, inside the very result the coordinate
71
+ # contract is read from. EnumDisplayMonitors re-queries per call, so there is no
72
+ # cache to go stale.
73
+ $screens = @()
74
+ $screenBounds = @()
75
+ $rows = @()
76
+ try {
77
+ $rows = @([DshProbe]::Monitors())
78
+ } catch {
79
+ Write-ProbeError "the desktop is not available to this session (screen enumeration failed: $($_.Exception.Message))"
80
+ }
81
+ foreach ($row in $rows) {
82
+ $parts = $row -split '\|'
83
+ if ($parts.Count -lt 6) { continue }
84
+ $left = [int] $parts[2]; $top = [int] $parts[3]; $right = [int] $parts[4]; $bottom = [int] $parts[5]
85
+ if (($right - $left) -le 0 -or ($bottom - $top) -le 0) { continue }
86
+ $screens += [ordered]@{
87
+ device = $parts[0]
88
+ primary = ($parts[1] -eq '1')
89
+ bounds = ('{0},{1},{2},{3}' -f $left, $top, ($right - $left), ($bottom - $top))
90
+ }
91
+ $screenBounds += [pscustomobject]@{ X = $left; Y = $top; Width = ($right - $left); Height = ($bottom - $top) }
92
+ }
93
+ if ($screens.Count -eq 0) { Write-ProbeError 'the desktop is not available to this session (no interactive display)' }
94
+
95
+ $primaryIndex = 0
96
+ for ($i = 0; $i -lt $screens.Count; $i++) { if ($screens[$i].primary) { $primaryIndex = $i; break } }
97
+ $primaryDip = $screenBounds[$primaryIndex]
98
+
99
+ # Resolution order: an explicit window, then a region, then the screen mode.
100
+ $kind = $null
101
+ $captureDip = $null
102
+ $scale = $null
103
+
104
+ if (-not [string]::IsNullOrWhiteSpace($windowQuery)) {
105
+ $needle = $windowQuery.ToLowerInvariant()
106
+ $candidate = $null
107
+ foreach ($process in (Get-Process | Where-Object { $_.MainWindowHandle -ne 0 })) {
108
+ $handle = $process.MainWindowHandle
109
+ if (-not [DshProbe]::IsWindowVisible($handle)) { continue }
110
+ $title = [DshProbe]::WindowTitle($handle)
111
+ if ($title.ToLowerInvariant().Contains($needle) -or $process.ProcessName.ToLowerInvariant().Contains($needle)) {
112
+ if ($null -eq $candidate) { $candidate = $process }
113
+ }
114
+ }
115
+ if ($null -eq $candidate) { Write-ProbeError "no visible top-level window matches '$windowQuery'" }
116
+ $handle = $candidate.MainWindowHandle
117
+ $rect = [DshProbe]::WindowRect($handle)
118
+ $captureDip = [pscustomobject]@{ X = $rect.Left; Y = $rect.Top; Width = ($rect.Right - $rect.Left); Height = ($rect.Bottom - $rect.Top) }
119
+ $kind = 'window'
120
+ } elseif (-not [string]::IsNullOrWhiteSpace($regionSpec)) {
121
+ $parts = @($regionSpec -split '[,\s]+' | Where-Object { $_ -ne '' })
122
+ if ($parts.Count -ne 4) { Write-ProbeError "region must be 'x,y,width,height' (got '$regionSpec')" }
123
+ try { $r = @($parts | ForEach-Object { [int] $_ }) } catch { Write-ProbeError "region must contain four integers (got '$regionSpec')" }
124
+ if ($r[2] -le 0 -or $r[3] -le 0) { Write-ProbeError 'region width and height must be positive' }
125
+ $captureDip = [pscustomobject]@{ X = $r[0]; Y = $r[1]; Width = $r[2]; Height = $r[3] }
126
+ $kind = 'region'
127
+ } elseif ($screenMode -eq 'all') {
128
+ # SM_XVIRTUALSCREEN(76), SM_YVIRTUALSCREEN(77), SM_CXVIRTUALSCREEN(78), SM_CYVIRTUALSCREEN(79).
129
+ # GetSystemMetrics is a live call; SystemInformation.VirtualScreen comes from the
130
+ # same WinForms cache as Screen.AllScreens and goes stale for the same reason.
131
+ $vx = [DshProbe]::GetSystemMetrics(76)
132
+ $vy = [DshProbe]::GetSystemMetrics(77)
133
+ $vw = [DshProbe]::GetSystemMetrics(78)
134
+ $vh = [DshProbe]::GetSystemMetrics(79)
135
+ if ($vw -le 0 -or $vh -le 0) { $vx = $primaryDip.X; $vy = $primaryDip.Y; $vw = $primaryDip.Width; $vh = $primaryDip.Height }
136
+ $captureDip = [pscustomobject]@{ X = $vx; Y = $vy; Width = $vw; Height = $vh }
137
+ $kind = 'all'
138
+ } elseif ($screenMode -eq 'primary') {
139
+ $captureDip = $primaryDip
140
+ $kind = 'primary'
141
+ } else {
142
+ $index = 0
143
+ if (-not [int]::TryParse($screenMode, [ref] $index)) { Write-ProbeError "screen must be 'primary', 'all', or a monitor index (got '$screenMode')" }
144
+ if ($index -lt 0 -or $index -ge $screens.Count) { Write-ProbeError "monitor index $index is out of range (0..$($screens.Count - 1))" }
145
+ $captureDip = $screenBounds[$index]
146
+ $kind = "screen:$index"
147
+ }
148
+
149
+ # --------------------------------------------------------------------- capture
150
+ Add-Type -AssemblyName System.Drawing
151
+ $bitmap = New-Object System.Drawing.Bitmap($captureDip.Width, $captureDip.Height)
152
+ $graphics = [System.Drawing.Graphics]::FromImage($bitmap)
153
+ $imageWidth = 0
154
+ $imageHeight = 0
155
+ try {
156
+ $graphics.CopyFromScreen($captureDip.X, $captureDip.Y, 0, 0, $bitmap.Size)
157
+ if ($includeCursor) {
158
+ try {
159
+ $cursor = [DshProbe]::Cursor()
160
+ $cx = $cursor.X - $captureDip.X
161
+ $cy = $cursor.Y - $captureDip.Y
162
+ if ($cx -ge 0 -and $cy -ge 0 -and $cx -lt $bitmap.Width -and $cy -lt $bitmap.Height) {
163
+ $pen = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(255, 255, 0, 0)), 2
164
+ $graphics.DrawLine($pen, ($cx - 14), $cy, ($cx + 14), $cy)
165
+ $graphics.DrawLine($pen, $cx, ($cy - 14), $cx, ($cy + 14))
166
+ $pen.Dispose()
167
+ }
168
+ } catch {
169
+ # A cursor overlay is cosmetic: never fail a capture over it.
170
+ }
171
+ }
172
+ if ($rulers) {
173
+ # Coordinate rulers: ticks every $rulerStep pixels along the top and left
174
+ # edge, each labelled with the SCREEN coordinate it sits at (capture origin
175
+ # plus the local offset). The model can then read a position straight off
176
+ # the image instead of counting pixels, which also survives any later
177
+ # downscaling by the attachment store - the ratio in the envelope says what
178
+ # the labels have to be multiplied by.
179
+ try {
180
+ $rulerStep = if ($bitmap.Width -ge 800) { 200 } else { 100 }
181
+ $font = New-Object System.Drawing.Font('Segoe UI', 11, [System.Drawing.FontStyle]::Bold)
182
+ $ink = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(255, 255, 64, 64))
183
+ $shadow = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(200, 0, 0, 0))
184
+ $tick = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(170, 255, 64, 64)), 1
185
+ try {
186
+ for ($x = $rulerStep; $x -lt $bitmap.Width; $x += $rulerStep) {
187
+ $graphics.DrawLine($tick, $x, 0, $x, 10)
188
+ $label = [string] ([int] $captureDip.X + $x)
189
+ $graphics.DrawString($label, $font, $shadow, ($x + 2), 13)
190
+ $graphics.DrawString($label, $font, $ink, ($x + 1), 12)
191
+ }
192
+ for ($y = $rulerStep; $y -lt $bitmap.Height; $y += $rulerStep) {
193
+ $graphics.DrawLine($tick, 0, $y, 10, $y)
194
+ $label = [string] ([int] $captureDip.Y + $y)
195
+ $graphics.DrawString($label, $font, $shadow, 13, ($y + 2))
196
+ $graphics.DrawString($label, $font, $ink, 12, ($y + 1))
197
+ }
198
+ $originLabel = ('origin {0},{1} step {2}' -f [int] $captureDip.X, [int] $captureDip.Y, $rulerStep)
199
+ $graphics.DrawString($originLabel, $font, $shadow, 15, 30)
200
+ $graphics.DrawString($originLabel, $font, $ink, 14, 29)
201
+ } finally {
202
+ $tick.Dispose()
203
+ $font.Dispose()
204
+ $ink.Dispose()
205
+ $shadow.Dispose()
206
+ }
207
+ } catch {
208
+ # Rulers are an aid: never fail a capture over them.
209
+ }
210
+ }
211
+ $directory = Split-Path -Parent $outPath
212
+ if (-not [string]::IsNullOrEmpty($directory) -and -not (Test-Path -LiteralPath $directory)) {
213
+ $null = New-Item -ItemType Directory -Path $directory -Force
214
+ }
215
+ $bitmap.Save($outPath, [System.Drawing.Imaging.ImageFormat]::Png)
216
+ $imageWidth = $bitmap.Width
217
+ $imageHeight = $bitmap.Height
218
+ } catch {
219
+ Write-ProbeError "screen capture failed: $($_.Exception.Message)"
220
+ } finally {
221
+ $graphics.Dispose()
222
+ $bitmap.Dispose()
223
+ }
224
+
225
+ # DIP -> physical. Both the capture rect and the DIP screen rect come from the
226
+ # same monitor, so the ratio is the monitor's real scale factor.
227
+ if ($captureDip.Width -gt 0) { $scale = [math]::Round(($imageWidth / $captureDip.Width), 4) } else { $scale = 1 }
228
+ $originX = [int] [math]::Round($captureDip.X * $scale)
229
+ $originY = [int] [math]::Round($captureDip.Y * $scale)
230
+
231
+ # ------------------------------------------------------------------- foreground
232
+ $foreground = ConvertTo-WindowInfo ([DshProbe]::GetForegroundWindow()) $true
233
+ $cursorInfo = $null
234
+ try {
235
+ $cursor = [DshProbe]::Cursor()
236
+ $screen = $screens | Where-Object {
237
+ $b = ($_.bounds -split ',') | ForEach-Object { [int] $_ }
238
+ $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])
239
+ } | Select-Object -First 1
240
+ $cursorInfo = [ordered]@{
241
+ x = $cursor.X
242
+ y = $cursor.Y
243
+ screen = if ($null -ne $screen) { $screen.device } else { $null }
244
+ }
245
+ } catch {
246
+ $cursorInfo = $null
247
+ }
248
+
249
+ $result = [ordered]@{
250
+ ok = $true
251
+ path = (Resolve-Path -LiteralPath $outPath).Path
252
+ kind = $kind
253
+ imageWidth = $imageWidth
254
+ imageHeight = $imageHeight
255
+ originX = $originX
256
+ originY = $originY
257
+ scale = $scale
258
+ dpiAware = $true
259
+ dipBounds = ('{0},{1},{2},{3}' -f $captureDip.X, $captureDip.Y, $captureDip.Width, $captureDip.Height)
260
+ screens = $screens
261
+ cursor = $cursorInfo
262
+ foregroundWindow = $foreground
263
+ rulers = [bool] $rulers
264
+ capturedAt = [DateTimeOffset]::Now.ToString('o')
265
+ }
266
+ return $result
241
267
  }