dsh-click 0.3.27 → 0.3.29
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/CHANGELOG.md +21 -2
- package/README.en.md +450 -0
- package/README.es.md +450 -0
- package/README.hi.md +450 -0
- package/README.md +268 -191
- package/README.pt.md +450 -0
- package/lib/index.js +68 -20
- package/lib/types/platform/runner.d.ts +2 -2
- package/lib/types/platform/runner.d.ts.map +1 -1
- package/lib/types/platform/types.d.ts +38 -3
- package/lib/types/platform/types.d.ts.map +1 -1
- package/lib/types/tools.d.ts.map +1 -1
- package/lib/types/version.d.ts +1 -1
- package/native/win32/dsh-click-helper.ps1 +420 -79
- package/package.json +1 -1
- package/README-es.md +0 -377
- package/README-hi.md +0 -377
- package/README-pt.md +0 -377
- package/README-zh.md +0 -377
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
# dsh-click native helper (Windows PowerShell 5.1).
|
|
2
2
|
#
|
|
3
3
|
# One request = one process: JSON request on stdin, JSON response on stdout
|
|
4
4
|
# (exit code 0 even for handled failures; the error envelope is the response).
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
# any other stream, and nothing here steals foreground focus unless the
|
|
7
7
|
# `focusFallback` flag is explicitly true (config-gated, approval-gated).
|
|
8
8
|
#
|
|
9
|
-
# Operations: windows, apps, shot, tree, snapshot, click, type, scroll,
|
|
9
|
+
# Operations: windows, apps, metrics, shot, tree, snapshot, click, type, scroll,
|
|
10
|
+
# key, launch.
|
|
10
11
|
# The helper is NOT a security boundary by itself - the harness-side
|
|
11
12
|
# freshness check, approval gate, and process-identity check are. This file
|
|
12
13
|
# only refuses to invent global input: actions address one window by handle.
|
|
@@ -150,6 +151,34 @@ public static class DshClickWin32 {
|
|
|
150
151
|
[DllImport("user32.dll")]
|
|
151
152
|
public static extern int GetSystemMetrics(int index);
|
|
152
153
|
|
|
154
|
+
// PrintWindow: ask a window to paint itself into a DC. Flag 2 is
|
|
155
|
+
// PW_RENDERFULLCONTENT (Windows 8.1+), which makes DWM/DirectComposition
|
|
156
|
+
// windows render their real content instead of a blank or chrome-only
|
|
157
|
+
// frame; flag 0 is the legacy path for windows that ignore the newer flag.
|
|
158
|
+
[DllImport("user32.dll", SetLastError = true)]
|
|
159
|
+
public static extern bool PrintWindow(IntPtr hWnd, IntPtr hdcBlt, uint nFlags);
|
|
160
|
+
|
|
161
|
+
// DPI awareness. The helper is hosted by powershell.exe, whose manifest
|
|
162
|
+
// declares no DPI awareness, so GetWindowRect/GetSystemMetrics/GetClientRect
|
|
163
|
+
// return logical pixels while UIA BoundingRectangle and the screen DC are
|
|
164
|
+
// physical. On a 200% display that is a clean 2x mismatch (and a 1:1 BitBlt
|
|
165
|
+
// of a logical-sized rect is a crop of the physical surface, not a scaled
|
|
166
|
+
// image). Per-Monitor V2 is set before any window or metric API runs.
|
|
167
|
+
[DllImport("user32.dll", SetLastError = true)]
|
|
168
|
+
public static extern bool SetProcessDpiAwarenessContext(IntPtr context);
|
|
169
|
+
|
|
170
|
+
[DllImport("user32.dll")]
|
|
171
|
+
public static extern IntPtr GetThreadDpiAwarenessContext();
|
|
172
|
+
|
|
173
|
+
[DllImport("user32.dll")]
|
|
174
|
+
public static extern int GetAwarenessFromDpiAwarenessContext(IntPtr context);
|
|
175
|
+
|
|
176
|
+
[DllImport("shcore.dll")]
|
|
177
|
+
public static extern int SetProcessDpiAwareness(int awareness);
|
|
178
|
+
|
|
179
|
+
[DllImport("user32.dll")]
|
|
180
|
+
public static extern bool SetProcessDPIAware();
|
|
181
|
+
|
|
153
182
|
[DllImport("kernel32.dll", SetLastError = true)]
|
|
154
183
|
public static extern IntPtr OpenProcess(uint access, bool inherit, uint processId);
|
|
155
184
|
|
|
@@ -187,6 +216,33 @@ public static class DshClickWin32 {
|
|
|
187
216
|
}
|
|
188
217
|
'@ | Out-Null
|
|
189
218
|
|
|
219
|
+
# ---------------------------------------------------------------------------
|
|
220
|
+
# DPI awareness (must run before any HWND/metric API).
|
|
221
|
+
# ---------------------------------------------------------------------------
|
|
222
|
+
# DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = -4. Each helper process serves
|
|
223
|
+
# exactly one request, so setting it once here is enough. Fall back to the
|
|
224
|
+
# 8.1+ shcore call and then to the Vista-era system-aware call; if every call
|
|
225
|
+
# fails, keep the old behaviour instead of breaking the request.
|
|
226
|
+
$script:DpiAwareness = 0
|
|
227
|
+
try {
|
|
228
|
+
if ([DshClickWin32]::SetProcessDpiAwarenessContext([IntPtr](-4))) { $script:DpiAwareness = 2 }
|
|
229
|
+
} catch { }
|
|
230
|
+
if ($script:DpiAwareness -eq 0) {
|
|
231
|
+
try {
|
|
232
|
+
if ([DshClickWin32]::SetProcessDpiAwareness(2) -eq 0) { $script:DpiAwareness = 2 }
|
|
233
|
+
} catch { }
|
|
234
|
+
}
|
|
235
|
+
if ($script:DpiAwareness -eq 0) {
|
|
236
|
+
try {
|
|
237
|
+
if ([DshClickWin32]::SetProcessDPIAware()) { $script:DpiAwareness = 1 }
|
|
238
|
+
} catch { }
|
|
239
|
+
}
|
|
240
|
+
if ($script:DpiAwareness -eq 0) {
|
|
241
|
+
try {
|
|
242
|
+
$script:DpiAwareness = [DshClickWin32]::GetAwarenessFromDpiAwarenessContext([DshClickWin32]::GetThreadDpiAwarenessContext())
|
|
243
|
+
} catch { }
|
|
244
|
+
}
|
|
245
|
+
|
|
190
246
|
# ---------------------------------------------------------------------------
|
|
191
247
|
# Response plumbing.
|
|
192
248
|
# ---------------------------------------------------------------------------
|
|
@@ -298,8 +354,9 @@ function Resolve-Window($target) {
|
|
|
298
354
|
$hwnd = ConvertTo-Hwnd $target.windowId
|
|
299
355
|
if ($hwnd -ne [IntPtr]::Zero) {
|
|
300
356
|
$rect = Get-WindowRectInfo $hwnd
|
|
301
|
-
if ($rect.width -le 0 -
|
|
357
|
+
if ($rect.width -le 0 -or $rect.height -le 0) {
|
|
302
358
|
# A handle that no longer resolves to a window.
|
|
359
|
+
$script:ErrorCode = 'WINDOW_NOT_FOUND'
|
|
303
360
|
throw "window $($target.windowId) not found"
|
|
304
361
|
}
|
|
305
362
|
return $hwnd
|
|
@@ -309,6 +366,7 @@ function Resolve-Window($target) {
|
|
|
309
366
|
foreach ($candidate in [DshClickWin32]::EnumVisibleWindows()) {
|
|
310
367
|
if ([DshClickWin32]::WindowTitle($candidate) -like "*$title*") { return $candidate }
|
|
311
368
|
}
|
|
369
|
+
$script:ErrorCode = 'WINDOW_NOT_FOUND'
|
|
312
370
|
throw "no visible window matches title '$title'"
|
|
313
371
|
}
|
|
314
372
|
$processId = $target.processId
|
|
@@ -318,13 +376,41 @@ function Resolve-Window($target) {
|
|
|
318
376
|
[void][DshClickWin32]::GetWindowThreadProcessId($candidate, [ref]$pidValue)
|
|
319
377
|
if ([int]$pidValue -eq [int]$processId) { return $candidate }
|
|
320
378
|
}
|
|
379
|
+
$script:ErrorCode = 'WINDOW_NOT_FOUND'
|
|
321
380
|
throw "no visible window owned by process $processId"
|
|
322
381
|
}
|
|
323
382
|
$foreground = [DshClickWin32]::GetForegroundWindow()
|
|
324
|
-
if ($foreground -eq [IntPtr]::Zero) {
|
|
383
|
+
if ($foreground -eq [IntPtr]::Zero) {
|
|
384
|
+
$script:ErrorCode = 'WINDOW_NOT_FOUND'
|
|
385
|
+
throw 'no foreground window and no target given'
|
|
386
|
+
}
|
|
325
387
|
return $foreground
|
|
326
388
|
}
|
|
327
389
|
|
|
390
|
+
# One target resolver for the observer ops. `tree`/`shot` always read
|
|
391
|
+
# `args.target`; `snapshot` historically read `args.windowId`. Both spellings
|
|
392
|
+
# now go through here, so `{"op":"snapshot","args":{"target":{"windowId":N}}}`
|
|
393
|
+
# addresses window N instead of silently falling back to the foreground window.
|
|
394
|
+
# `args.target` wins when both are present; the legacy top-level `windowId` is
|
|
395
|
+
# only a fallback, so old callers keep working.
|
|
396
|
+
function Resolve-OpTarget($opArgs) {
|
|
397
|
+
$target = @{}
|
|
398
|
+
if ($opArgs.target -ne $null) {
|
|
399
|
+
$candidate = $opArgs.target
|
|
400
|
+
if ($candidate.windowId -ne $null) { $target.windowId = $candidate.windowId }
|
|
401
|
+
if ($candidate.windowTitle -ne $null) { $target.windowTitle = $candidate.windowTitle }
|
|
402
|
+
if ($candidate.processId -ne $null) { $target.processId = $candidate.processId }
|
|
403
|
+
}
|
|
404
|
+
if ($target.windowId -eq $null -and $target.windowTitle -eq $null -and $target.processId -eq $null) {
|
|
405
|
+
if ($opArgs.windowId -ne $null) { $target.windowId = $opArgs.windowId }
|
|
406
|
+
}
|
|
407
|
+
return $target
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function Test-TargetPresent($target) {
|
|
411
|
+
return ($target.windowId -ne $null) -or ($target.windowTitle -ne $null) -or ($target.processId -ne $null)
|
|
412
|
+
}
|
|
413
|
+
|
|
328
414
|
# ---------------------------------------------------------------------------
|
|
329
415
|
# Accessibility walk (canonical: the same walk builds treeHash everywhere).
|
|
330
416
|
# ---------------------------------------------------------------------------
|
|
@@ -410,30 +496,214 @@ function Get-TreeHashText($nodes) {
|
|
|
410
496
|
# ---------------------------------------------------------------------------
|
|
411
497
|
# Screenshot.
|
|
412
498
|
# ---------------------------------------------------------------------------
|
|
413
|
-
|
|
499
|
+
# A window is captured in three steps, best first:
|
|
500
|
+
# 1. PrintWindow(hwnd, hdc, 2) PW_RENDERFULLCONTENT - the window paints its
|
|
501
|
+
# own content, so identity and pixels do not depend on occlusion/Z-order.
|
|
502
|
+
# 2. PrintWindow(hwnd, hdc, 0) legacy windows that ignore the newer flag.
|
|
503
|
+
# 3. CopyFromScreen last resort; correct only while unobscured.
|
|
504
|
+
# PrintWindow reports success for frames that carry no content at all (a blank
|
|
505
|
+
# Electron frame, or a Chromium window that paints only its title-bar buttons),
|
|
506
|
+
# so every candidate goes through Test-BitmapLooksBlank before it is accepted.
|
|
507
|
+
# Measured on Windows 11: PW(2) carries content for WinUI3/Electron/Chromium;
|
|
508
|
+
# PW(0/1) is blank or chrome-only for the latter two. The thresholds below are
|
|
509
|
+
# fitted to that matrix: a pure-color check alone is fooled by a gray frame with
|
|
510
|
+
# three title-bar buttons, so the color-count floor is 6 quantized colors.
|
|
511
|
+
$script:CaptureInset = 40 # px trimmed from each edge before sampling
|
|
512
|
+
$script:CaptureGrid = 16 # interior sample grid (16x16)
|
|
513
|
+
$script:CaptureDarkGrid = 32 # full-frame sample grid for the dark-ratio test
|
|
514
|
+
|
|
515
|
+
function Get-BitmapLuminance($color) {
|
|
516
|
+
return (0.299 * [double]$color.R) + (0.587 * [double]$color.G) + (0.114 * [double]$color.B)
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
# True when a captured frame carries no usable content: a flat interior, an
|
|
520
|
+
# almost entirely black frame, or (on the no-flag PrintWindow path only) a few
|
|
521
|
+
# flat colors with chrome painted in a corner.
|
|
522
|
+
#
|
|
523
|
+
# The color-count rule is `strict` because a legitimate all-white page can also
|
|
524
|
+
# sample as 3 quantized colors: measured on a 2582x1550 Chrome new-tab window,
|
|
525
|
+
# PrintWindow(2) gives 3 colors but a luminance sd of 9.6, while the chrome-only
|
|
526
|
+
# frame the no-flag path paints on Chromium gives 4-5 colors with sd 13-14. The
|
|
527
|
+
# two are indistinguishable by color count alone, so the count rule is applied
|
|
528
|
+
# only where chrome-only frames are known to come from (PrintWindow flag 0).
|
|
529
|
+
# A blank all-black frame is caught on every path by the sd/dark-ratio rules.
|
|
530
|
+
function Test-BitmapLooksBlank($bitmap, $strict) {
|
|
531
|
+
if ($null -eq $bitmap -or $bitmap.Width -le 0 -or $bitmap.Height -le 0) { return $true }
|
|
532
|
+
$width = $bitmap.Width
|
|
533
|
+
$height = $bitmap.Height
|
|
534
|
+
|
|
535
|
+
# Interior region: drop the outer band (border, shadow, title-bar buttons)
|
|
536
|
+
# where a failed PrintWindow can still paint a plausible-looking frame.
|
|
537
|
+
$insetX = [Math]::Min($script:CaptureInset, [int][Math]::Floor(($width - 1) / 2))
|
|
538
|
+
$insetY = [Math]::Min($script:CaptureInset, [int][Math]::Floor(($height - 1) / 2))
|
|
539
|
+
$left = $insetX
|
|
540
|
+
$top = $insetY
|
|
541
|
+
$right = $width - 1 - $insetX
|
|
542
|
+
$bottom = $height - 1 - $insetY
|
|
543
|
+
if (($right - $left) -lt 8 -or ($bottom - $top) -lt 8) {
|
|
544
|
+
# Tiny window: the inset leaves nothing to sample, so use the full frame.
|
|
545
|
+
$left = 0; $top = 0; $right = $width - 1; $bottom = $height - 1
|
|
546
|
+
}
|
|
547
|
+
$spanX = [Math]::Max(1, $right - $left)
|
|
548
|
+
$spanY = [Math]::Max(1, $bottom - $top)
|
|
549
|
+
$stepX = [Math]::Max(1, [int][Math]::Ceiling($spanX / $script:CaptureGrid))
|
|
550
|
+
$stepY = [Math]::Max(1, [int][Math]::Ceiling($spanY / $script:CaptureGrid))
|
|
551
|
+
|
|
552
|
+
$colors = @{}
|
|
553
|
+
$sum = 0.0
|
|
554
|
+
$sumSquares = 0.0
|
|
555
|
+
$count = 0
|
|
556
|
+
for ($x = $left; $x -le $right; $x += $stepX) {
|
|
557
|
+
for ($y = $top; $y -le $bottom; $y += $stepY) {
|
|
558
|
+
$color = $bitmap.GetPixel($x, $y)
|
|
559
|
+
# 5-bit quantization: UI colors cluster, and an exact ARGB count is both
|
|
560
|
+
# noisy and slower than needed for a blank/non-blank verdict.
|
|
561
|
+
$key = ([int]($color.R -band 0xF8) -shl 16) -bor ([int]($color.G -band 0xF8) -shl 8) -bor [int]($color.B -band 0xF8)
|
|
562
|
+
$colors[$key] = $true
|
|
563
|
+
$luminance = Get-BitmapLuminance $color
|
|
564
|
+
$sum += $luminance
|
|
565
|
+
$sumSquares += $luminance * $luminance
|
|
566
|
+
$count += 1
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
if ($count -eq 0) { return $true }
|
|
570
|
+
$mean = $sum / $count
|
|
571
|
+
$variance = [Math]::Max(0.0, ($sumSquares / $count) - ($mean * $mean))
|
|
572
|
+
$deviation = [Math]::Sqrt($variance)
|
|
573
|
+
if ($deviation -lt 2) { return $true }
|
|
574
|
+
if ($strict -and $colors.Count -le 6) { return $true }
|
|
575
|
+
|
|
576
|
+
# Full-frame dark ratio: a PrintWindow frame that painted nothing at all.
|
|
577
|
+
$dark = 0
|
|
578
|
+
$samples = 0
|
|
579
|
+
$darkStepX = [Math]::Max(1, [int][Math]::Ceiling($width / $script:CaptureDarkGrid))
|
|
580
|
+
$darkStepY = [Math]::Max(1, [int][Math]::Ceiling($height / $script:CaptureDarkGrid))
|
|
581
|
+
for ($x = 0; $x -lt $width; $x += $darkStepX) {
|
|
582
|
+
for ($y = 0; $y -lt $height; $y += $darkStepY) {
|
|
583
|
+
if ((Get-BitmapLuminance $bitmap.GetPixel($x, $y)) -lt 8) { $dark += 1 }
|
|
584
|
+
$samples += 1
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
if ($samples -gt 0 -and (($dark * 100) -ge (99 * $samples))) { return $true }
|
|
588
|
+
return $false
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function New-CaptureBitmap($width, $height) {
|
|
592
|
+
if ($width -le 0 -or $height -le 0) { throw "cannot allocate a ${width}x${height} capture bitmap" }
|
|
593
|
+
return New-Object System.Drawing.Bitmap($width, $height)
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
# One PrintWindow attempt; returns $null when the API refuses, otherwise the
|
|
597
|
+
# bitmap (which may still be blank - the caller decides).
|
|
598
|
+
function Get-PrintWindowFrame($hwnd, $rectInfo, $flags) {
|
|
599
|
+
$bitmap = New-CaptureBitmap $rectInfo.width $rectInfo.height
|
|
600
|
+
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
|
|
601
|
+
$hdc = [IntPtr]::Zero
|
|
602
|
+
$painted = $false
|
|
603
|
+
try {
|
|
604
|
+
$hdc = $graphics.GetHdc()
|
|
605
|
+
$painted = [DshClickWin32]::PrintWindow($hwnd, $hdc, [uint32]$flags)
|
|
606
|
+
} catch {
|
|
607
|
+
$painted = $false
|
|
608
|
+
} finally {
|
|
609
|
+
if ($hdc -ne [IntPtr]::Zero) {
|
|
610
|
+
try { $graphics.ReleaseHdc($hdc) } catch { }
|
|
611
|
+
}
|
|
612
|
+
$graphics.Dispose()
|
|
613
|
+
}
|
|
614
|
+
if (-not $painted) {
|
|
615
|
+
$bitmap.Dispose()
|
|
616
|
+
return $null
|
|
617
|
+
}
|
|
618
|
+
return $bitmap
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
# Capture one window: returns @{ bitmap; method; rect }. The caller owns the
|
|
622
|
+
# bitmap and must dispose it. Minimized windows are refused up front: IsIconic
|
|
623
|
+
# windows report a placeholder rect, and PrintWindow paints only the small
|
|
624
|
+
# window-icon band even when it answers true, so there is no honest image.
|
|
625
|
+
function Get-WindowCapture($hwnd) {
|
|
414
626
|
$rectInfo = Get-WindowRectInfo $hwnd
|
|
415
|
-
if ($rectInfo.width -le 0 -or $rectInfo.height -le 0) {
|
|
416
|
-
|
|
627
|
+
if ($rectInfo.width -le 0 -or $rectInfo.height -le 0) {
|
|
628
|
+
$script:ErrorCode = 'WINDOW_NOT_FOUND'
|
|
629
|
+
throw "window $hwnd has no drawable rect"
|
|
630
|
+
}
|
|
631
|
+
if ([DshClickWin32]::IsIconic($hwnd)) {
|
|
632
|
+
$script:ErrorCode = 'WINDOW_MINIMIZED'
|
|
633
|
+
throw "window $hwnd is minimized; a minimized window has no capturable pixels - restore it, then re-observe"
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
$candidate = Get-PrintWindowFrame $hwnd $rectInfo 2 # PW_RENDERFULLCONTENT
|
|
637
|
+
if ($candidate -ne $null) {
|
|
638
|
+
if (-not (Test-BitmapLooksBlank $candidate $false)) {
|
|
639
|
+
return @{ bitmap = $candidate; method = 'printwindow-fullcontent'; rect = $rectInfo }
|
|
640
|
+
}
|
|
641
|
+
$candidate.Dispose()
|
|
642
|
+
}
|
|
643
|
+
$candidate = Get-PrintWindowFrame $hwnd $rectInfo 0 # legacy PrintWindow
|
|
644
|
+
if ($candidate -ne $null) {
|
|
645
|
+
if (-not (Test-BitmapLooksBlank $candidate $true)) {
|
|
646
|
+
return @{ bitmap = $candidate; method = 'printwindow'; rect = $rectInfo }
|
|
647
|
+
}
|
|
648
|
+
$candidate.Dispose()
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
# Screen-region copy: the old path, kept for windows that refuse PrintWindow.
|
|
652
|
+
$bitmap = New-CaptureBitmap $rectInfo.width $rectInfo.height
|
|
417
653
|
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
|
|
418
654
|
try {
|
|
419
655
|
$graphics.CopyFromScreen($rectInfo.x, $rectInfo.y, 0, 0, (New-Object System.Drawing.Size($rectInfo.width, $rectInfo.height)))
|
|
656
|
+
} catch {
|
|
657
|
+
$bitmap.Dispose()
|
|
658
|
+
throw
|
|
420
659
|
} finally {
|
|
421
660
|
$graphics.Dispose()
|
|
422
661
|
}
|
|
423
|
-
return $bitmap
|
|
662
|
+
return @{ bitmap = $bitmap; method = 'copyfromscreen'; rect = $rectInfo }
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
# Resolve the requested screen rectangle. `virtual` is the whole desktop
|
|
666
|
+
# (negative origins on multi-monitor setups) and stays experimental: only the
|
|
667
|
+
# single-monitor case is covered by the verification runs, and BitBlt across
|
|
668
|
+
# negative virtual-screen coordinates is untested here. If the virtual metrics
|
|
669
|
+
# are unusable, fall back to the primary screen rather than failing the call.
|
|
670
|
+
function Get-ScreenMetrics($scope) {
|
|
671
|
+
if ($scope -eq 'virtual') {
|
|
672
|
+
$virtualX = [int][DshClickWin32]::GetSystemMetrics(76) # SM_XVIRTUALSCREEN
|
|
673
|
+
$virtualY = [int][DshClickWin32]::GetSystemMetrics(77) # SM_YVIRTUALSCREEN
|
|
674
|
+
$virtualWidth = [int][DshClickWin32]::GetSystemMetrics(78) # SM_CXVIRTUALSCREEN
|
|
675
|
+
$virtualHeight = [int][DshClickWin32]::GetSystemMetrics(79) # SM_CYVIRTUALSCREEN
|
|
676
|
+
if ($virtualWidth -gt 0 -and $virtualHeight -gt 0) {
|
|
677
|
+
return @{ scope = 'virtual'; x = $virtualX; y = $virtualY; width = $virtualWidth; height = $virtualHeight }
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
return @{
|
|
681
|
+
scope = 'primary'; x = 0; y = 0
|
|
682
|
+
width = [int][DshClickWin32]::GetSystemMetrics(0) # SM_CXSCREEN
|
|
683
|
+
height = [int][DshClickWin32]::GetSystemMetrics(1) # SM_CYSCREEN
|
|
684
|
+
}
|
|
424
685
|
}
|
|
425
686
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
$
|
|
429
|
-
$
|
|
687
|
+
# Capture a screen region: returns @{ bitmap; method; scope; rect }.
|
|
688
|
+
function Get-ScreenCapture($scope) {
|
|
689
|
+
$metrics = Get-ScreenMetrics $scope
|
|
690
|
+
if ($metrics.width -le 0 -or $metrics.height -le 0) { throw 'the screen reports no drawable area' }
|
|
691
|
+
$bitmap = New-CaptureBitmap $metrics.width $metrics.height
|
|
430
692
|
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
|
|
431
693
|
try {
|
|
432
|
-
$graphics.CopyFromScreen(
|
|
694
|
+
$graphics.CopyFromScreen($metrics.x, $metrics.y, 0, 0, (New-Object System.Drawing.Size($metrics.width, $metrics.height)))
|
|
695
|
+
} catch {
|
|
696
|
+
$bitmap.Dispose()
|
|
697
|
+
throw
|
|
433
698
|
} finally {
|
|
434
699
|
$graphics.Dispose()
|
|
435
700
|
}
|
|
436
|
-
return
|
|
701
|
+
return @{
|
|
702
|
+
bitmap = $bitmap
|
|
703
|
+
method = 'copyfromscreen'
|
|
704
|
+
scope = $metrics.scope
|
|
705
|
+
rect = @{ x = $metrics.x; y = $metrics.y; width = $metrics.width; height = $metrics.height }
|
|
706
|
+
}
|
|
437
707
|
}
|
|
438
708
|
|
|
439
709
|
function Resize-BitmapIfNeeded($bitmap, $maxSide) {
|
|
@@ -603,12 +873,17 @@ function Invoke-OpWindows {
|
|
|
603
873
|
foreach ($hwnd in [DshClickWin32]::EnumVisibleWindows()) {
|
|
604
874
|
$pidValue = 0
|
|
605
875
|
[void][DshClickWin32]::GetWindowThreadProcessId($hwnd, [ref]$pidValue)
|
|
876
|
+
# A minimized window still enumerates, but GetWindowRect then returns the
|
|
877
|
+
# CW_USEDEFAULT placeholder (-32000,-32000 314x50). Report the state and
|
|
878
|
+
# suppress the placeholder rect instead of handing it to coordinate math.
|
|
879
|
+
$minimized = [DshClickWin32]::IsIconic($hwnd)
|
|
606
880
|
$windows += @{
|
|
607
881
|
windowId = [int64]$hwnd
|
|
608
882
|
processId = if ([int]$pidValue -gt 0) { [int]$pidValue } else { $null }
|
|
609
883
|
title = [DshClickWin32]::WindowTitle($hwnd)
|
|
610
884
|
className = [DshClickWin32]::WindowClass($hwnd)
|
|
611
|
-
rect = (Get-WindowRectInfo $hwnd
|
|
885
|
+
rect = if ($minimized) { $null } else { Get-WindowRectInfo $hwnd }
|
|
886
|
+
minimized = $minimized
|
|
612
887
|
executablePath = $null
|
|
613
888
|
visible = $true
|
|
614
889
|
}
|
|
@@ -634,12 +909,14 @@ function Invoke-OpApps {
|
|
|
634
909
|
$pidInt = [int]$pidValue
|
|
635
910
|
if ($pidInt -le 0) { continue }
|
|
636
911
|
if (-not $byPid.ContainsKey($pidInt)) { $byPid[$pidInt] = @() }
|
|
912
|
+
$minimized = [DshClickWin32]::IsIconic($hwnd)
|
|
637
913
|
$byPid[$pidInt] += @{
|
|
638
914
|
windowId = [int64]$hwnd
|
|
639
915
|
processId = $pidInt
|
|
640
916
|
title = [DshClickWin32]::WindowTitle($hwnd)
|
|
641
917
|
className = [DshClickWin32]::WindowClass($hwnd)
|
|
642
|
-
rect = (Get-WindowRectInfo $hwnd
|
|
918
|
+
rect = if ($minimized) { $null } else { Get-WindowRectInfo $hwnd }
|
|
919
|
+
minimized = $minimized
|
|
643
920
|
executablePath = $null
|
|
644
921
|
visible = $true
|
|
645
922
|
}
|
|
@@ -664,91 +941,129 @@ function Invoke-OpApps {
|
|
|
664
941
|
return $apps
|
|
665
942
|
}
|
|
666
943
|
|
|
667
|
-
function Get-SnapshotRecord($hwnd, $maxElements, $maxDepth) {
|
|
668
|
-
$
|
|
669
|
-
$
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
Walk-UiaTree $element 0 $maxDepth $maxElements $false
|
|
674
|
-
$treeHash = Get-StringHash (Get-TreeHashText $script:TreeNodes)
|
|
675
|
-
$bitmap = Get-WindowBitmap $hwnd
|
|
676
|
-
$shotHash = $null
|
|
944
|
+
function Get-SnapshotRecord($hwnd, $maxElements, $maxDepth, $capture) {
|
|
945
|
+
$ownsCapture = $false
|
|
946
|
+
if ($null -eq $capture) {
|
|
947
|
+
$capture = Get-WindowCapture $hwnd
|
|
948
|
+
$ownsCapture = $true
|
|
949
|
+
}
|
|
677
950
|
try {
|
|
678
|
-
$
|
|
951
|
+
$facts = Get-ProcessFacts $hwnd
|
|
952
|
+
$rect = Get-WindowRectInfo $hwnd
|
|
953
|
+
$foreground = ([DshClickWin32]::GetForegroundWindow() -eq $hwnd)
|
|
954
|
+
$script:TreeNodes = @()
|
|
955
|
+
$element = Get-UiaElement $hwnd
|
|
956
|
+
Walk-UiaTree $element 0 $maxDepth $maxElements $false
|
|
957
|
+
$treeHash = Get-StringHash (Get-TreeHashText $script:TreeNodes)
|
|
958
|
+
# The hash is taken from the same frame the caller will ship as the
|
|
959
|
+
# screenshot (or compare later), not from a second capture: two captures
|
|
960
|
+
# can disagree on a busy window and on which capture path succeeded.
|
|
961
|
+
$shotHash = Get-BitmapHash $capture.bitmap
|
|
962
|
+
return @{
|
|
963
|
+
windowId = [int64]$hwnd
|
|
964
|
+
processId = $facts.pid
|
|
965
|
+
executablePath = $facts.executablePath
|
|
966
|
+
title = [DshClickWin32]::WindowTitle($hwnd)
|
|
967
|
+
className = [DshClickWin32]::WindowClass($hwnd)
|
|
968
|
+
rect = $rect
|
|
969
|
+
foreground = $foreground
|
|
970
|
+
treeHash = $treeHash
|
|
971
|
+
shotHash = $shotHash
|
|
972
|
+
elementCount = $script:TreeNodes.Count
|
|
973
|
+
}
|
|
679
974
|
} finally {
|
|
680
|
-
$bitmap.Dispose()
|
|
681
|
-
}
|
|
682
|
-
return @{
|
|
683
|
-
windowId = [int64]$hwnd
|
|
684
|
-
processId = $facts.pid
|
|
685
|
-
executablePath = $facts.executablePath
|
|
686
|
-
title = [DshClickWin32]::WindowTitle($hwnd)
|
|
687
|
-
className = [DshClickWin32]::WindowClass($hwnd)
|
|
688
|
-
rect = $rect
|
|
689
|
-
foreground = $foreground
|
|
690
|
-
treeHash = $treeHash
|
|
691
|
-
shotHash = $shotHash
|
|
692
|
-
elementCount = $script:TreeNodes.Count
|
|
975
|
+
if ($ownsCapture) { $capture.bitmap.Dispose() }
|
|
693
976
|
}
|
|
694
977
|
}
|
|
695
978
|
|
|
696
979
|
function Invoke-OpSnapshot($opArgs) {
|
|
697
|
-
$
|
|
980
|
+
$target = Resolve-OpTarget $opArgs
|
|
981
|
+
if (-not (Test-TargetPresent $target)) {
|
|
982
|
+
# This used to fall back to the foreground window without saying so, which
|
|
983
|
+
# reported a different window than the caller asked for. Refuse instead.
|
|
984
|
+
$script:ErrorCode = 'NO_TARGET'
|
|
985
|
+
throw 'snapshot requires windowId or target; it never guesses the foreground window'
|
|
986
|
+
}
|
|
987
|
+
$hwnd = Resolve-Window $target
|
|
698
988
|
$maxElements = if ($opArgs.maxElements -ne $null) { [int]$opArgs.maxElements } else { 500 }
|
|
699
989
|
$maxDepth = if ($opArgs.maxDepth -ne $null) { [int]$opArgs.maxDepth } else { 32 }
|
|
700
|
-
|
|
990
|
+
$capture = Get-WindowCapture $hwnd
|
|
991
|
+
try {
|
|
992
|
+
return (Get-SnapshotRecord $hwnd $maxElements $maxDepth $capture)
|
|
993
|
+
} finally {
|
|
994
|
+
$capture.bitmap.Dispose()
|
|
995
|
+
}
|
|
701
996
|
}
|
|
702
997
|
|
|
703
998
|
function Invoke-OpShot($opArgs) {
|
|
704
|
-
$target =
|
|
999
|
+
$target = Resolve-OpTarget $opArgs
|
|
1000
|
+
$scope = if ($opArgs.scope -ne $null) { [string]$opArgs.scope } else { 'primary' }
|
|
1001
|
+
if ($scope -ne 'primary' -and $scope -ne 'virtual') {
|
|
1002
|
+
$script:ErrorCode = 'INVALID_SCOPE'
|
|
1003
|
+
throw "unknown scope '$scope' (expected 'primary' or 'virtual')"
|
|
1004
|
+
}
|
|
705
1005
|
$maxSide = if ($opArgs.maxSide -ne $null) { [int]$opArgs.maxSide } else { 2560 }
|
|
706
1006
|
$maxElements = if ($opArgs.maxElements -ne $null) { [int]$opArgs.maxElements } else { 500 }
|
|
707
1007
|
$maxDepth = if ($opArgs.maxDepth -ne $null) { [int]$opArgs.maxDepth } else { 32 }
|
|
708
|
-
$
|
|
709
|
-
|
|
710
|
-
$hwnd = Resolve-Window $target
|
|
711
|
-
$bitmap = Get-WindowBitmap $hwnd
|
|
712
|
-
$snapshot = Get-SnapshotRecord $hwnd $maxElements $maxDepth
|
|
713
|
-
} else {
|
|
714
|
-
$hwnd = $null
|
|
715
|
-
$bitmap = Get-PrimaryBitmap
|
|
716
|
-
$snapshot = @{
|
|
717
|
-
windowId = 0; processId = 0; executablePath = $null
|
|
718
|
-
title = 'primary screen'; className = 'Screen'
|
|
719
|
-
rect = @{ x = 0; y = 0; width = $bitmap.Width; height = $bitmap.Height }
|
|
720
|
-
foreground = $true; treeHash = ''; shotHash = ''; elementCount = 0
|
|
721
|
-
}
|
|
722
|
-
}
|
|
1008
|
+
$capture = $null
|
|
1009
|
+
$bitmap = $null
|
|
723
1010
|
try {
|
|
724
|
-
|
|
1011
|
+
if (Test-TargetPresent $target) {
|
|
1012
|
+
$hwnd = Resolve-Window $target
|
|
1013
|
+
$capture = Get-WindowCapture $hwnd
|
|
1014
|
+
$captureScope = 'window'
|
|
1015
|
+
$snapshot = Get-SnapshotRecord $hwnd $maxElements $maxDepth $capture
|
|
1016
|
+
} else {
|
|
1017
|
+
# No target means the screen, not the foreground window: the result says
|
|
1018
|
+
# `scope: primary` and its snapshot is titled "primary screen" (windowId 0).
|
|
1019
|
+
$capture = Get-ScreenCapture $scope
|
|
1020
|
+
$captureScope = $capture.scope
|
|
1021
|
+
$snapshot = @{
|
|
1022
|
+
windowId = 0; processId = 0; executablePath = $null
|
|
1023
|
+
title = if ($capture.scope -eq 'virtual') { 'virtual screen' } else { 'primary screen' }
|
|
1024
|
+
className = 'Screen'
|
|
1025
|
+
rect = $capture.rect
|
|
1026
|
+
foreground = $true; treeHash = ''; shotHash = ''; elementCount = 0
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
$bitmap = $capture.bitmap
|
|
1030
|
+
if ([Math]::Max($bitmap.Width, $bitmap.Height) -gt $maxSide) {
|
|
1031
|
+
# Resize-BitmapIfNeeded disposes the original; the finally below disposes
|
|
1032
|
+
# whichever bitmap is current, and Dispose is idempotent.
|
|
1033
|
+
$bitmap = Resize-BitmapIfNeeded $capture.bitmap $maxSide
|
|
1034
|
+
}
|
|
725
1035
|
$base64 = Get-BitmapBase64 $bitmap
|
|
726
1036
|
return @{
|
|
727
1037
|
pngBase64 = $base64
|
|
728
1038
|
width = $bitmap.Width
|
|
729
1039
|
height = $bitmap.Height
|
|
1040
|
+
captureMethod = $capture.method
|
|
1041
|
+
scope = $captureScope
|
|
730
1042
|
snapshot = $snapshot
|
|
731
1043
|
}
|
|
732
1044
|
} finally {
|
|
733
|
-
$bitmap.Dispose()
|
|
1045
|
+
if ($null -ne $bitmap) { $bitmap.Dispose() }
|
|
1046
|
+
elseif ($null -ne $capture) { $capture.bitmap.Dispose() }
|
|
734
1047
|
}
|
|
735
1048
|
}
|
|
736
1049
|
|
|
737
1050
|
function Invoke-OpTree($opArgs) {
|
|
738
|
-
$target =
|
|
1051
|
+
$target = Resolve-OpTarget $opArgs
|
|
739
1052
|
$maxElements = if ($opArgs.maxElements -ne $null) { [int]$opArgs.maxElements } else { 500 }
|
|
740
1053
|
$maxDepth = if ($opArgs.maxDepth -ne $null) { [int]$opArgs.maxDepth } else { 32 }
|
|
741
1054
|
$includePixels = if ($opArgs.includePixels -ne $null) { [bool]$opArgs.includePixels } else { $true }
|
|
1055
|
+
# An empty target still means the foreground window here (documented for
|
|
1056
|
+
# screen_read); only `snapshot` refuses the guess.
|
|
742
1057
|
$hwnd = Resolve-Window $target
|
|
743
|
-
$
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
$
|
|
750
|
-
|
|
751
|
-
$rectInfo =
|
|
1058
|
+
$capture = Get-WindowCapture $hwnd
|
|
1059
|
+
try {
|
|
1060
|
+
$snapshot = Get-SnapshotRecord $hwnd $maxElements $maxDepth $capture
|
|
1061
|
+
$script:TreeNodes = @()
|
|
1062
|
+
$element = Get-UiaElement $hwnd
|
|
1063
|
+
Walk-UiaTree $element 0 $maxDepth $maxElements $includePixels
|
|
1064
|
+
$pixels = @()
|
|
1065
|
+
if ($includePixels) {
|
|
1066
|
+
$rectInfo = $capture.rect
|
|
752
1067
|
$count = 0
|
|
753
1068
|
foreach ($node in $script:TreeNodes) {
|
|
754
1069
|
if ($count -ge 200) { break }
|
|
@@ -756,21 +1071,21 @@ function Invoke-OpTree($opArgs) {
|
|
|
756
1071
|
if ($r.width -le 0 -or $r.height -le 0) { continue }
|
|
757
1072
|
$cx = $r.x + [int]($r.width / 2) - $rectInfo.x
|
|
758
1073
|
$cy = $r.y + [int]($r.height / 2) - $rectInfo.y
|
|
759
|
-
$color = Get-PixelColorText $bitmap $cx $cy
|
|
1074
|
+
$color = Get-PixelColorText $capture.bitmap $cx $cy
|
|
760
1075
|
if ($color -ne $null) {
|
|
761
1076
|
$label = if ($node.name -ne '') { $node.name } else { "$($node.controlType)[$($node.elementId)]" }
|
|
762
1077
|
$pixels += @{ label = $label; x = $r.x + [int]($r.width / 2); y = $r.y + [int]($r.height / 2); color = $color }
|
|
763
1078
|
$count += 1
|
|
764
1079
|
}
|
|
765
1080
|
}
|
|
766
|
-
} finally {
|
|
767
|
-
$bitmap.Dispose()
|
|
768
1081
|
}
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
1082
|
+
return @{
|
|
1083
|
+
snapshot = $snapshot
|
|
1084
|
+
elements = @($script:TreeNodes)
|
|
1085
|
+
pixels = $pixels
|
|
1086
|
+
}
|
|
1087
|
+
} finally {
|
|
1088
|
+
$capture.bitmap.Dispose()
|
|
774
1089
|
}
|
|
775
1090
|
}
|
|
776
1091
|
|
|
@@ -965,6 +1280,27 @@ function Invoke-OpLaunch($opArgs) {
|
|
|
965
1280
|
return @{ processId = [int]$process.Id; executablePath = $path }
|
|
966
1281
|
}
|
|
967
1282
|
|
|
1283
|
+
# ---------------------------------------------------------------------------
|
|
1284
|
+
# Metrics: DPU/DPI diagnostics without touching a window.
|
|
1285
|
+
# ---------------------------------------------------------------------------
|
|
1286
|
+
function Invoke-OpMetrics {
|
|
1287
|
+
# 0 = unaware (legacy bug), 1 = system-aware, 2 = per-monitor.
|
|
1288
|
+
return @{
|
|
1289
|
+
awareness = [int]$script:DpiAwareness
|
|
1290
|
+
screen = @{
|
|
1291
|
+
width = [int][DshClickWin32]::GetSystemMetrics(0) # SM_CXSCREEN
|
|
1292
|
+
height = [int][DshClickWin32]::GetSystemMetrics(1) # SM_CYSCREEN
|
|
1293
|
+
}
|
|
1294
|
+
virtualScreen = @{
|
|
1295
|
+
x = [int][DshClickWin32]::GetSystemMetrics(76) # SM_XVIRTUALSCREEN
|
|
1296
|
+
y = [int][DshClickWin32]::GetSystemMetrics(77) # SM_YVIRTUALSCREEN
|
|
1297
|
+
width = [int][DshClickWin32]::GetSystemMetrics(78) # SM_CXVIRTUALSCREEN
|
|
1298
|
+
height = [int][DshClickWin32]::GetSystemMetrics(79) # SM_CYVIRTUALSCREEN
|
|
1299
|
+
}
|
|
1300
|
+
monitors = [int][DshClickWin32]::GetSystemMetrics(80) # SM_CMONITORS
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
|
|
968
1304
|
# ---------------------------------------------------------------------------
|
|
969
1305
|
# Entry point.
|
|
970
1306
|
# ---------------------------------------------------------------------------
|
|
@@ -976,8 +1312,12 @@ try {
|
|
|
976
1312
|
# Per-request focus-fallback verdict (set by Set-DshFocusFallback, consumed by
|
|
977
1313
|
# Get-ActionOutcome); one helper process serves exactly one request.
|
|
978
1314
|
$script:focusFallbackDetail = $null
|
|
1315
|
+
# Per-request failure code; a plain `throw` still defaults to HELPER_ERROR,
|
|
1316
|
+
# while the capture/scope guards set a precise code for the caller.
|
|
1317
|
+
$script:ErrorCode = $null
|
|
979
1318
|
switch ([string]$request.op) {
|
|
980
1319
|
'windows' { $result = Invoke-OpWindows }
|
|
1320
|
+
'metrics' { $result = Invoke-OpMetrics }
|
|
981
1321
|
'apps' { $result = Invoke-OpApps }
|
|
982
1322
|
'snapshot' { $result = Invoke-OpSnapshot $opArgs }
|
|
983
1323
|
'shot' { $result = Invoke-OpShot $opArgs }
|
|
@@ -994,6 +1334,7 @@ try {
|
|
|
994
1334
|
} catch {
|
|
995
1335
|
$message = $_.Exception.Message
|
|
996
1336
|
if ($message -eq $null -or $message.Length -eq 0) { $message = 'unknown helper failure' }
|
|
997
|
-
|
|
1337
|
+
$code = if ($script:ErrorCode -ne $null -and [string]$script:ErrorCode -ne '') { [string]$script:ErrorCode } else { 'HELPER_ERROR' }
|
|
1338
|
+
Write-DshError $code $message
|
|
998
1339
|
exit 0
|
|
999
1340
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-click",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.29",
|
|
4
4
|
"description": "Cross-platform native desktop control tools for DeepSeek Harness (Windows first): screen_shot, screen_read (accessibility tree + pixel description for text-only models), click/type/scroll/key, and app_list/app_launch — every mutating action gated by approval, never stealing foreground focus, with stale-observation rejection and process-identity checks",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|