dsh-click 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,870 @@
1
+ # dsh-click native helper (Windows PowerShell 5.1).
2
+ #
3
+ # One request = one process: JSON request on stdin, JSON response on stdout
4
+ # (exit code 0 even for handled failures; the error envelope is the response).
5
+ # The harness drives this through ctx.subprocess; nothing here ever writes to
6
+ # any other stream, and nothing here steals foreground focus unless the
7
+ # `focusFallback` flag is explicitly true (config-gated, approval-gated).
8
+ #
9
+ # Operations: windows, apps, shot, tree, snapshot, click, type, scroll, key, launch.
10
+ # The helper is NOT a security boundary by itself — the harness-side
11
+ # freshness check, approval gate, and process-identity check are. This file
12
+ # only refuses to invent global input: actions address one window by handle.
13
+
14
+ $ErrorActionPreference = 'Stop'
15
+
16
+ try {
17
+ [Console]::InputEncoding = [System.Text.Encoding]::UTF8
18
+ [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
19
+ } catch { }
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Win32 interop (one Add-Type block; PS 5.1 safe C#).
23
+ # ---------------------------------------------------------------------------
24
+ Add-Type -TypeDefinition @'
25
+ using System;
26
+ using System.Collections.Generic;
27
+ using System.Runtime.InteropServices;
28
+ using System.Text;
29
+
30
+ public static class DshClickWin32 {
31
+ public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
32
+
33
+ [DllImport("user32.dll")]
34
+ public static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
35
+
36
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)]
37
+ public static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count);
38
+
39
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)]
40
+ public static extern int GetClassName(IntPtr hWnd, StringBuilder text, int count);
41
+
42
+ [DllImport("user32.dll")]
43
+ public static extern bool IsWindowVisible(IntPtr hWnd);
44
+
45
+ [DllImport("user32.dll")]
46
+ public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect);
47
+
48
+ [DllImport("user32.dll")]
49
+ public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
50
+
51
+ [DllImport("user32.dll")]
52
+ public static extern IntPtr GetForegroundWindow();
53
+
54
+ [DllImport("user32.dll")]
55
+ public static extern bool PostMessage(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
56
+
57
+ [DllImport("user32.dll")]
58
+ public static extern bool SetForegroundWindow(IntPtr hWnd);
59
+
60
+ [DllImport("user32.dll")]
61
+ public static extern int GetSystemMetrics(int index);
62
+
63
+ [DllImport("kernel32.dll", SetLastError = true)]
64
+ public static extern IntPtr OpenProcess(uint access, bool inherit, uint processId);
65
+
66
+ [DllImport("kernel32.dll")]
67
+ public static extern bool CloseHandle(IntPtr handle);
68
+
69
+ [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
70
+ public static extern bool QueryFullProcessImageName(IntPtr handle, uint flags, StringBuilder buffer, ref uint size);
71
+
72
+ [StructLayout(LayoutKind.Sequential)]
73
+ public struct RECT {
74
+ public int Left; public int Top; public int Right; public int Bottom;
75
+ }
76
+
77
+ public static IntPtr[] EnumVisibleWindows() {
78
+ var list = new List<IntPtr>();
79
+ EnumWindows(delegate(IntPtr hWnd, IntPtr lParam) {
80
+ if (IsWindowVisible(hWnd)) list.Add(hWnd);
81
+ return true;
82
+ }, IntPtr.Zero);
83
+ return list.ToArray();
84
+ }
85
+
86
+ public static string WindowTitle(IntPtr hWnd) {
87
+ var sb = new StringBuilder(1024);
88
+ GetWindowText(hWnd, sb, sb.Capacity);
89
+ return sb.ToString();
90
+ }
91
+
92
+ public static string WindowClass(IntPtr hWnd) {
93
+ var sb = new StringBuilder(512);
94
+ GetClassName(hWnd, sb, sb.Capacity);
95
+ return sb.ToString();
96
+ }
97
+ }
98
+ '@ | Out-Null
99
+
100
+ # ---------------------------------------------------------------------------
101
+ # Response plumbing.
102
+ # ---------------------------------------------------------------------------
103
+ function Write-DshResponse($payload) {
104
+ $json = $payload | ConvertTo-Json -Depth 24 -Compress
105
+ [Console]::Out.WriteLine($json)
106
+ }
107
+
108
+ function Write-DshError($code, $message) {
109
+ Write-DshResponse @{ ok = $false; error = @{ code = $code; message = $message } }
110
+ }
111
+
112
+ function ConvertTo-DshJsonValue($value) {
113
+ # PS 5.1 lacks -AsHashtable; round-trip through ConvertTo-Json/ConvertFrom-Json
114
+ # is unnecessary here — property bags serialized directly.
115
+ return $value
116
+ }
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # UIA + drawing assemblies.
120
+ # ---------------------------------------------------------------------------
121
+ try {
122
+ Add-Type -AssemblyName UIAutomationClient | Out-Null
123
+ Add-Type -AssemblyName UIAutomationTypes | Out-Null
124
+ Add-Type -AssemblyName System.Drawing | Out-Null
125
+ } catch {
126
+ Write-DshError 'UIA_FAILED' ("cannot load UIAutomation/System.Drawing: " + ($_.Exception.Message))
127
+ exit 0
128
+ }
129
+
130
+ # ---------------------------------------------------------------------------
131
+ # Small helpers.
132
+ # ---------------------------------------------------------------------------
133
+ function Get-WindowRectInfo($hwnd) {
134
+ $rect = New-Object DshClickWin32+RECT
135
+ [void][DshClickWin32]::GetWindowRect($hwnd, [ref]$rect)
136
+ return @{
137
+ x = [int]$rect.Left; y = [int]$rect.Top
138
+ width = [int]($rect.Right - $rect.Left); height = [int]($rect.Bottom - $rect.Top)
139
+ }
140
+ }
141
+
142
+ function Get-ProcessFacts($hwnd) {
143
+ $pidValue = 0
144
+ [void][DshClickWin32]::GetWindowThreadProcessId($hwnd, [ref]$pidValue)
145
+ $pidInt = [int]$pidValue
146
+ $path = $null
147
+ if ($pidInt -gt 0) {
148
+ $handle = [DshClickWin32]::OpenProcess(0x1000, $false, [uint32]$pidInt) # PROCESS_QUERY_LIMITED_INFORMATION
149
+ if ($handle -ne [IntPtr]::Zero) {
150
+ try {
151
+ $size = [uint32]1024
152
+ $buffer = New-Object System.Text.StringBuilder 1024
153
+ if ([DshClickWin32]::QueryFullProcessImageName($handle, 0, $buffer, [ref]$size)) {
154
+ $path = $buffer.ToString()
155
+ }
156
+ } finally {
157
+ [void][DshClickWin32]::CloseHandle($handle)
158
+ }
159
+ }
160
+ }
161
+ return @{ pid = $pidInt; executablePath = $path }
162
+ }
163
+
164
+ function Get-StringHash($text) {
165
+ $sha = [System.Security.Cryptography.SHA256]::Create()
166
+ try {
167
+ $bytes = [System.Text.Encoding]::UTF8.GetBytes($text)
168
+ $hashBytes = $sha.ComputeHash($bytes)
169
+ return ([System.BitConverter]::ToString($hashBytes)).Replace('-', '').ToLowerInvariant()
170
+ } finally {
171
+ $sha.Dispose()
172
+ }
173
+ }
174
+
175
+ function Get-BitmapHash($bitmap) {
176
+ $rect = New-Object System.Drawing.Rectangle(0, 0, $bitmap.Width, $bitmap.Height)
177
+ $data = $bitmap.LockBits($rect, [System.Drawing.Imaging.ImageLockMode]::ReadOnly, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
178
+ try {
179
+ $byteCount = [Math]::Abs($data.Stride) * $bitmap.Height
180
+ $bytes = New-Object byte[] $byteCount
181
+ [System.Runtime.InteropServices.Marshal]::Copy($data.Scan0, $bytes, 0, $byteCount)
182
+ $sha = [System.Security.Cryptography.SHA256]::Create()
183
+ try {
184
+ $hashBytes = $sha.ComputeHash($bytes)
185
+ return ([System.BitConverter]::ToString($hashBytes)).Replace('-', '').ToLowerInvariant()
186
+ } finally {
187
+ $sha.Dispose()
188
+ }
189
+ } finally {
190
+ $bitmap.UnlockBits($data)
191
+ }
192
+ }
193
+
194
+ function Get-UiaElement($hwnd) {
195
+ return [System.Windows.Automation.AutomationElement]::FromHandle([IntPtr]$hwnd)
196
+ }
197
+
198
+ function ConvertTo-Hwnd($value) {
199
+ if ($value -eq $null) { return [IntPtr]::Zero }
200
+ if ($value -is [int]) { return [IntPtr][int64]$value }
201
+ if ($value -is [int64]) { return [IntPtr]$value }
202
+ if ($value -is [double]) { return [IntPtr][int64]$value }
203
+ return [IntPtr]::Zero
204
+ }
205
+
206
+ function Resolve-Window($target) {
207
+ if ($target -eq $null) { $target = @{} }
208
+ $hwnd = ConvertTo-Hwnd $target.windowId
209
+ if ($hwnd -ne [IntPtr]::Zero) {
210
+ $rect = Get-WindowRectInfo $hwnd
211
+ if ($rect.width -le 0 -and $rect.height -le 0) {
212
+ # A handle that no longer resolves to a window.
213
+ throw "window $($target.windowId) not found"
214
+ }
215
+ return $hwnd
216
+ }
217
+ $title = $target.windowTitle
218
+ if ($title -ne $null -and $title -is [string] -and $title.Length -gt 0) {
219
+ foreach ($candidate in [DshClickWin32]::EnumVisibleWindows()) {
220
+ if ([DshClickWin32]::WindowTitle($candidate) -like "*$title*") { return $candidate }
221
+ }
222
+ throw "no visible window matches title '$title'"
223
+ }
224
+ $processId = $target.processId
225
+ if ($processId -ne $null -and $processId -is [int] -and $processId -gt 0) {
226
+ foreach ($candidate in [DshClickWin32]::EnumVisibleWindows()) {
227
+ $pidValue = 0
228
+ [void][DshClickWin32]::GetWindowThreadProcessId($candidate, [ref]$pidValue)
229
+ if ([int]$pidValue -eq [int]$processId) { return $candidate }
230
+ }
231
+ throw "no visible window owned by process $processId"
232
+ }
233
+ $foreground = [DshClickWin32]::GetForegroundWindow()
234
+ if ($foreground -eq [IntPtr]::Zero) { throw 'no foreground window and no target given' }
235
+ return $foreground
236
+ }
237
+
238
+ # ---------------------------------------------------------------------------
239
+ # Accessibility walk (canonical: the same walk builds treeHash everywhere).
240
+ # ---------------------------------------------------------------------------
241
+ $script:TreeNodes = @()
242
+
243
+ function Get-RuntimeIdString($element) {
244
+ try {
245
+ $ids = $element.GetRuntimeId()
246
+ $parts = @()
247
+ foreach ($id in $ids) { $parts += [string][int]$id }
248
+ return ($parts -join '.')
249
+ } catch {
250
+ return ''
251
+ }
252
+ }
253
+
254
+ function Get-ElementPatterns($element) {
255
+ $patterns = @()
256
+ $value = $null
257
+ if ($element.TryGetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern, [ref]$value)) { $patterns += 'value' }
258
+ $invoke = $null
259
+ if ($element.TryGetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern, [ref]$invoke)) { $patterns += 'invoke' }
260
+ $scroll = $null
261
+ if ($element.TryGetCurrentPattern([System.Windows.Automation.ScrollPattern]::Pattern, [ref]$scroll)) { $patterns += 'scroll' }
262
+ return $patterns
263
+ }
264
+
265
+ function Get-ElementRecord($element) {
266
+ $name = ''
267
+ $controlType = ''
268
+ $automationId = ''
269
+ $enabled = $false
270
+ $rect = @{ x = 0; y = 0; width = 0; height = 0 }
271
+ try {
272
+ $name = [string]$element.Current.Name
273
+ $controlType = [string]$element.Current.ControlType.ProgrammaticName
274
+ if ($controlType -like 'ControlType.*') { $controlType = $controlType.Substring(12) }
275
+ $automationId = [string]$element.Current.AutomationId
276
+ $enabled = [bool]$element.Current.IsEnabled
277
+ $bounds = $element.Current.BoundingRectangle
278
+ if (-not $bounds.IsEmpty) {
279
+ $rect = @{ x = [int]$bounds.X; y = [int]$bounds.Y; width = [int]$bounds.Width; height = [int]$bounds.Height }
280
+ }
281
+ } catch { }
282
+ return @{
283
+ elementId = (Get-RuntimeIdString $element)
284
+ controlType = $controlType
285
+ name = $name
286
+ automationId = $automationId
287
+ rect = $rect
288
+ enabled = $enabled
289
+ patterns = @(Get-ElementPatterns $element)
290
+ }
291
+ }
292
+
293
+ function Walk-UiaTree($element, $depth, $maxDepth, $maxElements, $includePixels) {
294
+ if ($script:TreeNodes.Count -ge $maxElements -or $depth -gt $maxDepth -or $element -eq $null) { return }
295
+ $record = Get-ElementRecord $element
296
+ if ($record.elementId -ne '') {
297
+ $script:TreeNodes += $record
298
+ }
299
+ if ($script:TreeNodes.Count -ge $maxElements) { return }
300
+ $walker = [System.Windows.Automation.TreeWalker]::ControlViewWalker
301
+ try {
302
+ $child = $walker.GetFirstChild($element)
303
+ while ($child -ne $null -and $script:TreeNodes.Count -lt $maxElements -and $depth -lt $maxDepth) {
304
+ Walk-UiaTree $child ($depth + 1) $maxDepth $maxElements $includePixels
305
+ if ($script:TreeNodes.Count -ge $maxElements) { break }
306
+ $child = $walker.GetNextSibling($child)
307
+ }
308
+ } catch { }
309
+ }
310
+
311
+ function Get-TreeHashText($nodes) {
312
+ $material = @()
313
+ foreach ($node in $nodes) {
314
+ $r = $node.rect
315
+ $material += "$($node.elementId)|$($node.controlType)|$($node.name)|$($r.x),$($r.y),$($r.width),$($r.height)"
316
+ }
317
+ return ($material -join ';')
318
+ }
319
+
320
+ # ---------------------------------------------------------------------------
321
+ # Screenshot.
322
+ # ---------------------------------------------------------------------------
323
+ function Get-WindowBitmap($hwnd) {
324
+ $rectInfo = Get-WindowRectInfo $hwnd
325
+ if ($rectInfo.width -le 0 -or $rectInfo.height -le 0) { throw "window $hwnd has no drawable rect" }
326
+ $bitmap = New-Object System.Drawing.Bitmap($rectInfo.width, $rectInfo.height)
327
+ $graphics = [System.Drawing.Graphics]::FromImage($bitmap)
328
+ try {
329
+ $graphics.CopyFromScreen($rectInfo.x, $rectInfo.y, 0, 0, (New-Object System.Drawing.Size($rectInfo.width, $rectInfo.height)))
330
+ } finally {
331
+ $graphics.Dispose()
332
+ }
333
+ return $bitmap
334
+ }
335
+
336
+ function Get-PrimaryBitmap() {
337
+ $width = [DshClickWin32]::GetSystemMetrics(0) # SM_CXSCREEN
338
+ $height = [DshClickWin32]::GetSystemMetrics(1) # SM_CYSCREEN
339
+ $bitmap = New-Object System.Drawing.Bitmap($width, $height)
340
+ $graphics = [System.Drawing.Graphics]::FromImage($bitmap)
341
+ try {
342
+ $graphics.CopyFromScreen(0, 0, 0, 0, (New-Object System.Drawing.Size($width, $height)))
343
+ } finally {
344
+ $graphics.Dispose()
345
+ }
346
+ return $bitmap
347
+ }
348
+
349
+ function Resize-BitmapIfNeeded($bitmap, $maxSide) {
350
+ $longest = [Math]::Max($bitmap.Width, $bitmap.Height)
351
+ if ($longest -le $maxSide) { return $bitmap }
352
+ $scale = $maxSide / $longest
353
+ $newWidth = [int][Math]::Max(1, [Math]::Round($bitmap.Width * $scale))
354
+ $newHeight = [int][Math]::Max(1, [Math]::Round($bitmap.Height * $scale))
355
+ $resized = New-Object System.Drawing.Bitmap($newWidth, $newHeight)
356
+ $graphics = [System.Drawing.Graphics]::FromImage($resized)
357
+ try {
358
+ $graphics.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
359
+ $graphics.DrawImage($bitmap, 0, 0, $newWidth, $newHeight)
360
+ } finally {
361
+ $graphics.Dispose()
362
+ }
363
+ $bitmap.Dispose()
364
+ return $resized
365
+ }
366
+
367
+ function Get-BitmapBase64($bitmap) {
368
+ $stream = New-Object System.IO.MemoryStream
369
+ try {
370
+ $bitmap.Save($stream, [System.Drawing.Imaging.ImageFormat]::Png)
371
+ return [Convert]::ToBase64String($stream.ToArray())
372
+ } finally {
373
+ $stream.Dispose()
374
+ }
375
+ }
376
+
377
+ function Get-PixelColorText($bitmap, $x, $y) {
378
+ if ($x -lt 0 -or $y -lt 0 -or $x -ge $bitmap.Width -or $y -ge $bitmap.Height) { return $null }
379
+ $color = $bitmap.GetPixel($x, $y)
380
+ return "rgb($($color.R), $($color.G), $($color.B))"
381
+ }
382
+
383
+ # ---------------------------------------------------------------------------
384
+ # Element lookup.
385
+ # ---------------------------------------------------------------------------
386
+ function Find-ElementByRuntimeId($windowElement, $runtimeIdText) {
387
+ $ids = @()
388
+ foreach ($part in ($runtimeIdText -split '\.')) { $ids += [int]$part }
389
+ if ($ids.Count -eq 0) { return $null }
390
+ $condition = New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::RuntimeIdProperty, $ids)
391
+ try {
392
+ return $windowElement.FindFirst([System.Windows.Automation.TreeScope]::Descendants, $condition)
393
+ } catch {
394
+ return $null
395
+ }
396
+ }
397
+
398
+ function Get-ElementCenter($element) {
399
+ $bounds = $element.Current.BoundingRectangle
400
+ if ($bounds.IsEmpty) { throw 'element has no bounding rectangle' }
401
+ return @{ x = [int]($bounds.X + $bounds.Width / 2); y = [int]($bounds.Y + $bounds.Height / 2) }
402
+ }
403
+
404
+ function Get-ClientPoint($windowRect, $screenX, $screenY) {
405
+ $cx = $screenX - $windowRect.x
406
+ $cy = $screenY - $windowRect.y
407
+ if ($cx -lt 0 -or $cy -lt 0 -or $cx -ge $windowRect.width -or $cy -ge $windowRect.height) {
408
+ throw "point ($screenX, $screenY) lies outside the target window"
409
+ }
410
+ return @{ x = $cx; y = $cy }
411
+ }
412
+
413
+ function Get-LParam($cx, $cy) {
414
+ return [IntPtr](($cy -shl 16) -bor ($cx -band 0xFFFF))
415
+ }
416
+
417
+ function Post-Click($hwnd, $cx, $cy, $button) {
418
+ $down = if ($button -eq 'right') { 0x204 } else { 0x201 }
419
+ $up = if ($button -eq 'right') { 0x205 } else { 0x202 }
420
+ $lparam = Get-LParam $cx $cy
421
+ [void][DshClickWin32]::PostMessage($hwnd, $down, [IntPtr]1, $lparam)
422
+ [void][DshClickWin32]::PostMessage($hwnd, $up, [IntPtr]0, $lparam)
423
+ }
424
+
425
+ function Get-KeyMap() {
426
+ $map = @{}
427
+ $map['ENTER'] = @(0x0D, $true)
428
+ $map['RETURN'] = @(0x0D, $true)
429
+ $map['TAB'] = @(0x09, $true)
430
+ $map['ESC'] = @(0x1B, $true)
431
+ $map['ESCAPE'] = @(0x1B, $true)
432
+ $map['BACKSPACE'] = @(0x08, $true)
433
+ $map['DELETE'] = @(0x2E, $true)
434
+ $map['DEL'] = @(0x2E, $true)
435
+ $map['SPACE'] = @(0x20, $true)
436
+ $map['INSERT'] = @(0x2D, $true)
437
+ $map['HOME'] = @(0x24, $true)
438
+ $map['END'] = @(0x23, $true)
439
+ $map['PAGEUP'] = @(0x21, $true)
440
+ $map['PGUP'] = @(0x21, $true)
441
+ $map['PAGEDOWN'] = @(0x22, $true)
442
+ $map['PGDN'] = @(0x22, $true)
443
+ $map['UP'] = @(0x26, $true)
444
+ $map['DOWN'] = @(0x28, $true)
445
+ $map['LEFT'] = @(0x25, $true)
446
+ $map['RIGHT'] = @(0x27, $true)
447
+ $map['CTRL'] = @(0x11, $false)
448
+ $map['CONTROL'] = @(0x11, $false)
449
+ $map['SHIFT'] = @(0x10, $false)
450
+ $map['ALT'] = @(0x12, $false)
451
+ $map['MENU'] = @(0x12, $false)
452
+ $map['WIN'] = @(0x5B, $false)
453
+ $map['WINDOWS'] = @(0x5B, $false)
454
+ foreach ($n in 1..12) { $map["F$n"] = @(0x6F + $n, $true) }
455
+ foreach ($c in 0..9) { $map["$c"] = @(0x30 + $c, $true) }
456
+ foreach ($c in 'A'..'Z') {
457
+ $code = [int][char]$c
458
+ $map[$c] = @(0x41 + ($code - 65), $true)
459
+ }
460
+ return $map
461
+ }
462
+
463
+ function Post-KeyCombo($hwnd, $keysText) {
464
+ $map = Get-KeyMap
465
+ $tokens = @()
466
+ foreach ($token in ($keysText -split '\+')) {
467
+ $trimmed = $token.Trim()
468
+ if ($trimmed.Length -eq 0) { continue }
469
+ $tokens += $trimmed.ToUpperInvariant()
470
+ }
471
+ if ($tokens.Count -eq 0) { throw 'empty key combination' }
472
+ $modifiers = @()
473
+ $mainKeys = @()
474
+ foreach ($token in $tokens) {
475
+ if ($token -in @('CTRL', 'CONTROL', 'SHIFT', 'ALT', 'MENU', 'WIN', 'WINDOWS')) {
476
+ $modifiers += $token
477
+ } elseif ($map.ContainsKey($token)) {
478
+ $mainKeys += $token
479
+ } elseif ($token.Length -eq 1) {
480
+ $mainKeys += $token
481
+ } else {
482
+ throw "unknown key '$token'"
483
+ }
484
+ }
485
+ foreach ($modifier in $modifiers) {
486
+ $entry = $map[$modifier]
487
+ [void][DshClickWin32]::PostMessage($hwnd, 0x100, [IntPtr]$entry[0], [IntPtr]0)
488
+ }
489
+ foreach ($key in $mainKeys) {
490
+ $isSpecial = $map.ContainsKey($key)
491
+ if ($isSpecial) {
492
+ $entry = $map[$key]
493
+ [void][DshClickWin32]::PostMessage($hwnd, 0x100, [IntPtr]$entry[0], [IntPtr]0)
494
+ [void][DshClickWin32]::PostMessage($hwnd, 0x101, [IntPtr]$entry[0], [IntPtr]0)
495
+ } else {
496
+ $charCode = [int][char]$key
497
+ [void][DshClickWin32]::PostMessage($hwnd, 0x100, [IntPtr]$charCode, [IntPtr]0)
498
+ [void][DshClickWin32]::PostMessage($hwnd, 0x102, [IntPtr]$charCode, [IntPtr]0)
499
+ [void][DshClickWin32]::PostMessage($hwnd, 0x101, [IntPtr]$charCode, [IntPtr]0)
500
+ }
501
+ }
502
+ for ($i = $modifiers.Count - 1; $i -ge 0; $i--) {
503
+ $entry = $map[$modifiers[$i]]
504
+ [void][DshClickWin32]::PostMessage($hwnd, 0x101, [IntPtr]$entry[0], [IntPtr]0)
505
+ }
506
+ }
507
+
508
+ # ---------------------------------------------------------------------------
509
+ # Operations.
510
+ # ---------------------------------------------------------------------------
511
+ function Invoke-OpWindows {
512
+ $windows = @()
513
+ foreach ($hwnd in [DshClickWin32]::EnumVisibleWindows()) {
514
+ $pidValue = 0
515
+ [void][DshClickWin32]::GetWindowThreadProcessId($hwnd, [ref]$pidValue)
516
+ $windows += @{
517
+ windowId = [int64]$hwnd
518
+ processId = if ([int]$pidValue -gt 0) { [int]$pidValue } else { $null }
519
+ title = [DshClickWin32]::WindowTitle($hwnd)
520
+ className = [DshClickWin32]::WindowClass($hwnd)
521
+ rect = (Get-WindowRectInfo $hwnd)
522
+ executablePath = $null
523
+ visible = $true
524
+ }
525
+ }
526
+ # Attach executable paths per window (one handle open per unique pid).
527
+ $pidCache = @{}
528
+ foreach ($window in $windows) {
529
+ if ($window.processId -eq $null) { continue }
530
+ if (-not $pidCache.ContainsKey($window.processId)) {
531
+ $facts = Get-ProcessFacts ([IntPtr][int64]$window.windowId)
532
+ $pidCache[$window.processId] = $facts.executablePath
533
+ }
534
+ $window.executablePath = $pidCache[$window.processId]
535
+ }
536
+ return $windows
537
+ }
538
+
539
+ function Invoke-OpApps {
540
+ $byPid = @{}
541
+ foreach ($hwnd in [DshClickWin32]::EnumVisibleWindows()) {
542
+ $pidValue = 0
543
+ [void][DshClickWin32]::GetWindowThreadProcessId($hwnd, [ref]$pidValue)
544
+ $pidInt = [int]$pidValue
545
+ if ($pidInt -le 0) { continue }
546
+ if (-not $byPid.ContainsKey($pidInt)) { $byPid[$pidInt] = @() }
547
+ $byPid[$pidInt] += @{
548
+ windowId = [int64]$hwnd
549
+ processId = $pidInt
550
+ title = [DshClickWin32]::WindowTitle($hwnd)
551
+ className = [DshClickWin32]::WindowClass($hwnd)
552
+ rect = (Get-WindowRectInfo $hwnd)
553
+ executablePath = $null
554
+ visible = $true
555
+ }
556
+ }
557
+ $apps = @()
558
+ foreach ($pidInt in ($byPid.Keys | Sort-Object)) {
559
+ $windows = $byPid[$pidInt]
560
+ $facts = Get-ProcessFacts ([IntPtr][int64]$windows[0].windowId)
561
+ $name = ''
562
+ try {
563
+ $process = Get-Process -Id $pidInt -ErrorAction Stop
564
+ $name = [string]$process.ProcessName
565
+ } catch { }
566
+ foreach ($window in $windows) { $window.executablePath = $facts.executablePath }
567
+ $apps += @{
568
+ processId = $pidInt
569
+ name = $name
570
+ executablePath = $facts.executablePath
571
+ windows = $windows
572
+ }
573
+ }
574
+ return $apps
575
+ }
576
+
577
+ function Get-SnapshotRecord($hwnd, $maxElements, $maxDepth) {
578
+ $facts = Get-ProcessFacts $hwnd
579
+ $rect = Get-WindowRectInfo $hwnd
580
+ $foreground = ([DshClickWin32]::GetForegroundWindow() -eq $hwnd)
581
+ $script:TreeNodes = @()
582
+ $element = Get-UiaElement $hwnd
583
+ Walk-UiaTree $element 0 $maxDepth $maxElements $false
584
+ $treeHash = Get-StringHash (Get-TreeHashText $script:TreeNodes)
585
+ $bitmap = Get-WindowBitmap $hwnd
586
+ $shotHash = $null
587
+ try {
588
+ $shotHash = Get-BitmapHash $bitmap
589
+ } finally {
590
+ $bitmap.Dispose()
591
+ }
592
+ return @{
593
+ windowId = [int64]$hwnd
594
+ processId = $facts.pid
595
+ executablePath = $facts.executablePath
596
+ title = [DshClickWin32]::WindowTitle($hwnd)
597
+ className = [DshClickWin32]::WindowClass($hwnd)
598
+ rect = $rect
599
+ foreground = $foreground
600
+ treeHash = $treeHash
601
+ shotHash = $shotHash
602
+ elementCount = $script:TreeNodes.Count
603
+ }
604
+ }
605
+
606
+ function Invoke-OpSnapshot($opArgs) {
607
+ $hwnd = Resolve-Window @{ windowId = $opArgs.windowId }
608
+ $maxElements = if ($opArgs.maxElements -ne $null) { [int]$opArgs.maxElements } else { 500 }
609
+ $maxDepth = if ($opArgs.maxDepth -ne $null) { [int]$opArgs.maxDepth } else { 32 }
610
+ return (Get-SnapshotRecord $hwnd $maxElements $maxDepth)
611
+ }
612
+
613
+ function Invoke-OpShot($opArgs) {
614
+ $target = if ($opArgs.target -ne $null) { $opArgs.target } else { @{} }
615
+ $maxSide = if ($opArgs.maxSide -ne $null) { [int]$opArgs.maxSide } else { 2560 }
616
+ $maxElements = if ($opArgs.maxElements -ne $null) { [int]$opArgs.maxElements } else { 500 }
617
+ $maxDepth = if ($opArgs.maxDepth -ne $null) { [int]$opArgs.maxDepth } else { 32 }
618
+ $hasTarget = ($target.windowId -ne $null) -or ($target.windowTitle -ne $null) -or ($target.processId -ne $null)
619
+ if ($hasTarget) {
620
+ $hwnd = Resolve-Window $target
621
+ $bitmap = Get-WindowBitmap $hwnd
622
+ $snapshot = Get-SnapshotRecord $hwnd $maxElements $maxDepth
623
+ } else {
624
+ $hwnd = $null
625
+ $bitmap = Get-PrimaryBitmap
626
+ $snapshot = @{
627
+ windowId = 0; processId = 0; executablePath = $null
628
+ title = 'primary screen'; className = 'Screen'
629
+ rect = @{ x = 0; y = 0; width = $bitmap.Width; height = $bitmap.Height }
630
+ foreground = $true; treeHash = ''; shotHash = ''; elementCount = 0
631
+ }
632
+ }
633
+ try {
634
+ $bitmap = Resize-BitmapIfNeeded $bitmap $maxSide
635
+ $base64 = Get-BitmapBase64 $bitmap
636
+ return @{
637
+ pngBase64 = $base64
638
+ width = $bitmap.Width
639
+ height = $bitmap.Height
640
+ snapshot = $snapshot
641
+ }
642
+ } finally {
643
+ $bitmap.Dispose()
644
+ }
645
+ }
646
+
647
+ function Invoke-OpTree($opArgs) {
648
+ $target = if ($opArgs.target -ne $null) { $opArgs.target } else { @{} }
649
+ $maxElements = if ($opArgs.maxElements -ne $null) { [int]$opArgs.maxElements } else { 500 }
650
+ $maxDepth = if ($opArgs.maxDepth -ne $null) { [int]$opArgs.maxDepth } else { 32 }
651
+ $includePixels = if ($opArgs.includePixels -ne $null) { [bool]$opArgs.includePixels } else { $true }
652
+ $hwnd = Resolve-Window $target
653
+ $snapshot = Get-SnapshotRecord $hwnd $maxElements $maxDepth
654
+ $script:TreeNodes = @()
655
+ $element = Get-UiaElement $hwnd
656
+ Walk-UiaTree $element 0 $maxDepth $maxElements $includePixels
657
+ $pixels = @()
658
+ if ($includePixels) {
659
+ $bitmap = Get-WindowBitmap $hwnd
660
+ try {
661
+ $rectInfo = Get-WindowRectInfo $hwnd
662
+ $count = 0
663
+ foreach ($node in $script:TreeNodes) {
664
+ if ($count -ge 200) { break }
665
+ $r = $node.rect
666
+ if ($r.width -le 0 -or $r.height -le 0) { continue }
667
+ $cx = $r.x + [int]($r.width / 2) - $rectInfo.x
668
+ $cy = $r.y + [int]($r.height / 2) - $rectInfo.y
669
+ $color = Get-PixelColorText $bitmap $cx $cy
670
+ if ($color -ne $null) {
671
+ $label = if ($node.name -ne '') { $node.name } else { "$($node.controlType)[$($node.elementId)]" }
672
+ $pixels += @{ label = $label; x = $r.x + [int]($r.width / 2); y = $r.y + [int]($r.height / 2); color = $color }
673
+ $count += 1
674
+ }
675
+ }
676
+ } finally {
677
+ $bitmap.Dispose()
678
+ }
679
+ }
680
+ return @{
681
+ snapshot = $snapshot
682
+ elements = @($script:TreeNodes)
683
+ pixels = $pixels
684
+ }
685
+ }
686
+
687
+ function Get-ActionOutcome($hwnd, $action, $delivered, $restored, $detail) {
688
+ $before = Get-ProcessFacts $hwnd
689
+ $after = Get-ProcessFacts $hwnd
690
+ $outcome = @{
691
+ windowId = [int64]$hwnd
692
+ action = $action
693
+ delivered = $delivered
694
+ processBefore = $before
695
+ processAfter = $after
696
+ }
697
+ if ($restored -ne $null) { $outcome.restored = [bool]$restored }
698
+ if ($detail -ne $null) { $outcome.detail = [string]$detail }
699
+ return $outcome
700
+ }
701
+
702
+ function Invoke-OpClick($opArgs) {
703
+ $request = $opArgs.request
704
+ $focusFallback = if ($opArgs.focusFallback -ne $null) { [bool]$opArgs.focusFallback } else { $false }
705
+ $hwnd = Resolve-Window @{ windowId = $request.windowId }
706
+ if ($focusFallback) { [void][DshClickWin32]::SetForegroundWindow($hwnd) }
707
+ $windowRect = Get-WindowRectInfo $hwnd
708
+ $delivered = 'posted'
709
+ $windowElement = Get-UiaElement $hwnd
710
+ if ($request.elementId -ne $null -and $request.elementId -is [string] -and $request.elementId.Length -gt 0) {
711
+ $element = Find-ElementByRuntimeId $windowElement ([string]$request.elementId)
712
+ if ($element -eq $null) { throw "element '$($request.elementId)' not found in window $hwnd (re-run screen_read)" }
713
+ $invoke = $null
714
+ if ($element.TryGetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern, [ref]$invoke)) {
715
+ $invoke.Invoke()
716
+ $delivered = 'uia'
717
+ } else {
718
+ $center = Get-ElementCenter $element
719
+ $client = Get-ClientPoint $windowRect $center.x $center.y
720
+ Post-Click $hwnd $client.x $client.y ([string]$request.button)
721
+ $delivered = 'posted'
722
+ }
723
+ } else {
724
+ if ($request.x -eq $null -or $request.y -eq $null) { throw 'click requires elementId or (x, y)' }
725
+ $client = Get-ClientPoint $windowRect ([int]$request.x) ([int]$request.y)
726
+ Post-Click $hwnd $client.x $client.y ([string]$request.button)
727
+ $delivered = 'posted'
728
+ }
729
+ return (Get-ActionOutcome $hwnd 'click' $delivered $null $null)
730
+ }
731
+
732
+ function Invoke-OpType($opArgs) {
733
+ $request = $opArgs.request
734
+ $focusFallback = if ($opArgs.focusFallback -ne $null) { [bool]$opArgs.focusFallback } else { $false }
735
+ $rollback = if ($request.rollback -ne $null) { [bool]$request.rollback } else { $true }
736
+ $hwnd = Resolve-Window @{ windowId = $request.windowId }
737
+ if ($focusFallback) { [void][DshClickWin32]::SetForegroundWindow($hwnd) }
738
+ $windowElement = Get-UiaElement $hwnd
739
+ $element = Find-ElementByRuntimeId $windowElement ([string]$request.elementId)
740
+ if ($element -eq $null) { throw "element '$($request.elementId)' not found in window $hwnd (re-run screen_read)" }
741
+ $value = $null
742
+ if ($element.TryGetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern, [ref]$value)) {
743
+ $original = [string]$value.Current.Value
744
+ try {
745
+ $value.SetValue([string]$request.text)
746
+ return (Get-ActionOutcome $hwnd 'type' 'uia' $false $null)
747
+ } catch {
748
+ $restored = $false
749
+ if ($rollback) {
750
+ try {
751
+ $value.SetValue($original)
752
+ $restored = $true
753
+ } catch { }
754
+ }
755
+ throw "type failed and rollback $(if ($restored) { 'restored the original text' } else { 'could NOT restore the original text' }): $($_.Exception.Message)"
756
+ }
757
+ }
758
+ if ($focusFallback) {
759
+ foreach ($ch in [char[]][string]$request.text) {
760
+ $charCode = [int]$ch
761
+ [void][DshClickWin32]::PostMessage($hwnd, 0x102, [IntPtr]$charCode, [IntPtr]0)
762
+ }
763
+ return (Get-ActionOutcome $hwnd 'type' 'posted' $null 'typed via posted WM_CHAR (no value pattern on the element)')
764
+ }
765
+ throw "element '$($request.elementId)' exposes no value pattern and focusFallback is disabled — refusing to type into it"
766
+ }
767
+
768
+ function Invoke-OpScroll($opArgs) {
769
+ $request = $opArgs.request
770
+ $focusFallback = if ($opArgs.focusFallback -ne $null) { [bool]$opArgs.focusFallback } else { $false }
771
+ $amount = if ($request.amount -ne $null) { [int]$request.amount } else { 3 }
772
+ if ($amount -lt 1) { throw 'scroll amount must be positive' }
773
+ $hwnd = Resolve-Window @{ windowId = $request.windowId }
774
+ $windowElement = Get-UiaElement $hwnd
775
+ $scroll = $null
776
+ $element = $null
777
+ if ($request.elementId -ne $null -and $request.elementId -is [string] -and $request.elementId.Length -gt 0) {
778
+ $element = Find-ElementByRuntimeId $windowElement ([string]$request.elementId)
779
+ if ($element -eq $null) { throw "element '$($request.elementId)' not found in window $hwnd (re-run screen_read)" }
780
+ if (-not $element.TryGetCurrentPattern([System.Windows.Automation.ScrollPattern]::Pattern, [ref]$scroll)) {
781
+ $scroll = $null
782
+ }
783
+ }
784
+ if ($scroll -ne $null) {
785
+ if ($focusFallback) { [void][DshClickWin32]::SetForegroundWindow($hwnd) }
786
+ $vertical = [System.Windows.Automation.ScrollAmount]::NoAmount
787
+ switch ([string]$request.direction) {
788
+ 'up' { $vertical = [System.Windows.Automation.ScrollAmount]::SmallIncrement }
789
+ 'down' { $vertical = [System.Windows.Automation.ScrollAmount]::SmallDecrement }
790
+ 'page-up' { $vertical = [System.Windows.Automation.ScrollAmount]::LargeIncrement }
791
+ 'page-down' { $vertical = [System.Windows.Automation.ScrollAmount]::LargeDecrement }
792
+ default { throw "unknown scroll direction '$($request.direction)'" }
793
+ }
794
+ for ($i = 0; $i -lt $amount; $i++) {
795
+ $scroll.Scroll([System.Windows.Automation.ScrollAmount]::NoAmount, $vertical)
796
+ }
797
+ return (Get-ActionOutcome $hwnd 'scroll' 'uia' $null $null)
798
+ }
799
+ if ($focusFallback) { [void][DshClickWin32]::SetForegroundWindow($hwnd) }
800
+ $direction = [string]$request.direction
801
+ $page = ($direction -eq 'page-up' -or $direction -eq 'page-down')
802
+ $sign = if ($direction -eq 'up' -or $direction -eq 'page-up') { 1 } else { -1 }
803
+ $notches = if ($page) { 3 } else { 1 }
804
+ $delta = [int]($sign * $notches * $amount * 120)
805
+ $wparam = [IntPtr][int64]($delta * 65536)
806
+ $rect = Get-WindowRectInfo $hwnd
807
+ $client = Get-ClientPoint $rect ([int]($rect.x + $rect.width / 2)) ([int]($rect.y + $rect.height / 2))
808
+ [void][DshClickWin32]::PostMessage($hwnd, 0x20A, $wparam, (Get-LParam $client.x $client.y))
809
+ return (Get-ActionOutcome $hwnd 'scroll' 'posted' $null 'posted wheel message to the window')
810
+ }
811
+
812
+ function Invoke-OpKey($opArgs) {
813
+ $request = $opArgs.request
814
+ $focusFallback = if ($opArgs.focusFallback -ne $null) { [bool]$opArgs.focusFallback } else { $false }
815
+ $hwnd = Resolve-Window @{ windowId = $request.windowId }
816
+ if ($focusFallback) { [void][DshClickWin32]::SetForegroundWindow($hwnd) }
817
+ Post-KeyCombo $hwnd ([string]$request.keys)
818
+ return (Get-ActionOutcome $hwnd 'key' 'posted' $null $null)
819
+ }
820
+
821
+ function Invoke-OpLaunch($opArgs) {
822
+ $name = [string]$opArgs.name
823
+ $launchArgs = @()
824
+ if ($opArgs.args -ne $null) {
825
+ foreach ($item in @($opArgs.args)) { $launchArgs += [string]$item }
826
+ }
827
+ $target = $name
828
+ if ($name -notmatch '[\\/]' -and $name -notmatch '\.exe$') {
829
+ $command = Get-Command $name -ErrorAction SilentlyContinue
830
+ if ($command -ne $null) { $target = $command.Source } else { throw "cannot resolve application '$name' on the search path" }
831
+ }
832
+ $process = Start-Process -FilePath $target -ArgumentList $launchArgs -PassThru
833
+ $path = $null
834
+ try {
835
+ $path = $process.Path
836
+ } catch {
837
+ $path = $null
838
+ }
839
+ return @{ processId = [int]$process.Id; executablePath = $path }
840
+ }
841
+
842
+ # ---------------------------------------------------------------------------
843
+ # Entry point.
844
+ # ---------------------------------------------------------------------------
845
+ try {
846
+ $raw = [Console]::In.ReadToEnd()
847
+ $request = $raw | ConvertFrom-Json
848
+ $opArgs = if ($request.args -ne $null) { $request.args } else { @{} }
849
+ $result = $null
850
+ switch ([string]$request.op) {
851
+ 'windows' { $result = Invoke-OpWindows }
852
+ 'apps' { $result = Invoke-OpApps }
853
+ 'snapshot' { $result = Invoke-OpSnapshot $opArgs }
854
+ 'shot' { $result = Invoke-OpShot $opArgs }
855
+ 'tree' { $result = Invoke-OpTree $opArgs }
856
+ 'click' { $result = Invoke-OpClick $opArgs }
857
+ 'type' { $result = Invoke-OpType $opArgs }
858
+ 'scroll' { $result = Invoke-OpScroll $opArgs }
859
+ 'key' { $result = Invoke-OpKey $opArgs }
860
+ 'launch' { $result = Invoke-OpLaunch $opArgs }
861
+ default { throw "unknown op '$($request.op)'" }
862
+ }
863
+ Write-DshResponse @{ ok = $true; result = $result }
864
+ exit 0
865
+ } catch {
866
+ $message = $_.Exception.Message
867
+ if ($message -eq $null -or $message.Length -eq 0) { $message = 'unknown helper failure' }
868
+ Write-DshError 'HELPER_ERROR' $message
869
+ exit 0
870
+ }