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.
@@ -1,4 +1,4 @@
1
- # dsh-click native helper (Windows PowerShell 5.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, key, launch.
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 -and $rect.height -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) { throw 'no foreground window and no target given' }
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
- function Get-WindowBitmap($hwnd) {
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) { throw "window $hwnd has no drawable rect" }
416
- $bitmap = New-Object System.Drawing.Bitmap($rectInfo.width, $rectInfo.height)
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
- function Get-PrimaryBitmap() {
427
- $width = [DshClickWin32]::GetSystemMetrics(0) # SM_CXSCREEN
428
- $height = [DshClickWin32]::GetSystemMetrics(1) # SM_CYSCREEN
429
- $bitmap = New-Object System.Drawing.Bitmap($width, $height)
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(0, 0, 0, 0, (New-Object System.Drawing.Size($width, $height)))
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 $bitmap
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
- $facts = Get-ProcessFacts $hwnd
669
- $rect = Get-WindowRectInfo $hwnd
670
- $foreground = ([DshClickWin32]::GetForegroundWindow() -eq $hwnd)
671
- $script:TreeNodes = @()
672
- $element = Get-UiaElement $hwnd
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
- $shotHash = Get-BitmapHash $bitmap
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
- $hwnd = Resolve-Window @{ windowId = $opArgs.windowId }
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
- return (Get-SnapshotRecord $hwnd $maxElements $maxDepth)
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 = if ($opArgs.target -ne $null) { $opArgs.target } else { @{} }
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
- $hasTarget = ($target.windowId -ne $null) -or ($target.windowTitle -ne $null) -or ($target.processId -ne $null)
709
- if ($hasTarget) {
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
- $bitmap = Resize-BitmapIfNeeded $bitmap $maxSide
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 = if ($opArgs.target -ne $null) { $opArgs.target } else { @{} }
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
- $snapshot = Get-SnapshotRecord $hwnd $maxElements $maxDepth
744
- $script:TreeNodes = @()
745
- $element = Get-UiaElement $hwnd
746
- Walk-UiaTree $element 0 $maxDepth $maxElements $includePixels
747
- $pixels = @()
748
- if ($includePixels) {
749
- $bitmap = Get-WindowBitmap $hwnd
750
- try {
751
- $rectInfo = Get-WindowRectInfo $hwnd
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
- return @{
771
- snapshot = $snapshot
772
- elements = @($script:TreeNodes)
773
- pixels = $pixels
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
- Write-DshError 'HELPER_ERROR' $message
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.27",
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",