@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.
- package/LICENSE +21 -21
- package/README.md +516 -466
- package/cordis.patch.yml +19 -19
- package/lib/index.js +1577 -1567
- package/lib/scripts/_dsh-action.ps1 +29 -7
- package/lib/scripts/_dsh-capture.ps1 +266 -240
- package/lib/scripts/_dsh-win32.ps1 +279 -213
- package/package.json +58 -58
|
@@ -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
|
-
$
|
|
312
|
-
|
|
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
|
|
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
|
-
$
|
|
339
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
$
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
$
|
|
82
|
-
$
|
|
83
|
-
$
|
|
84
|
-
|
|
85
|
-
if (-
|
|
86
|
-
$
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
$
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
$
|
|
107
|
-
$
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
$
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
$
|
|
120
|
-
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
$
|
|
126
|
-
$
|
|
127
|
-
$
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
$
|
|
203
|
-
$
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
cursor
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
}
|
|
240
|
-
|
|
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
|
}
|