@crosshands/platform-windows 0.1.2

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,1426 @@
1
+ $ErrorActionPreference = "Stop"
2
+ $ProgressPreference = "SilentlyContinue"
3
+ $PSModuleAutoLoadingPreference = "None"
4
+ $utf8NoBom = New-Object System.Text.UTF8Encoding $false
5
+ [Console]::InputEncoding = $utf8NoBom
6
+ [Console]::OutputEncoding = $utf8NoBom
7
+ $OutputEncoding = $utf8NoBom
8
+
9
+ # Import only the inbox signing module by its absolute PSHOME path. Autoloading
10
+ # remains disabled so PSModulePath cannot redirect security-sensitive commands.
11
+ $securityModule = Join-Path $PSHOME "Modules\Microsoft.PowerShell.Security\Microsoft.PowerShell.Security.psd1"
12
+ if (-not (Test-Path -LiteralPath $securityModule -PathType Leaf)) {
13
+ throw "provider_unavailable: inbox PowerShell signing module is missing"
14
+ }
15
+ [void](Import-Module -LiteralPath $securityModule -Force -PassThru)
16
+
17
+ Add-Type -AssemblyName UIAutomationClient
18
+ Add-Type -AssemblyName UIAutomationTypes
19
+ Add-Type -AssemblyName System.Drawing
20
+ Add-Type -AssemblyName System.Windows.Forms
21
+
22
+ Add-Type -TypeDefinition @"
23
+ using System;
24
+ using System.Diagnostics;
25
+ using System.Runtime.InteropServices;
26
+
27
+ public static class CrossHandsDesktopWin32 {
28
+ [StructLayout(LayoutKind.Sequential)]
29
+ public struct RECT {
30
+ public int Left;
31
+ public int Top;
32
+ public int Right;
33
+ public int Bottom;
34
+ }
35
+
36
+ [StructLayout(LayoutKind.Sequential)]
37
+ public struct POINT {
38
+ public int X;
39
+ public int Y;
40
+ }
41
+
42
+ [DllImport("user32.dll")]
43
+ public static extern bool GetWindowRect(IntPtr hwnd, out RECT rect);
44
+
45
+ [DllImport("user32.dll", SetLastError = true)]
46
+ public static extern bool PrintWindow(IntPtr hwnd, IntPtr hdcBlt, uint flags);
47
+
48
+ [DllImport("user32.dll")]
49
+ public static extern bool ScreenToClient(IntPtr hwnd, ref POINT point);
50
+
51
+ [DllImport("user32.dll")]
52
+ public static extern bool PostMessage(IntPtr hwnd, uint message, IntPtr wParam, IntPtr lParam);
53
+
54
+ [DllImport("user32.dll")]
55
+ public static extern bool ShowWindow(IntPtr hwnd, int nCmdShow);
56
+
57
+ [DllImport("user32.dll")]
58
+ public static extern bool SetForegroundWindow(IntPtr hwnd);
59
+
60
+ [DllImport("user32.dll")]
61
+ public static extern IntPtr GetForegroundWindow();
62
+
63
+ [DllImport("user32.dll")]
64
+ public static extern bool SetCursorPos(int x, int y);
65
+
66
+ [DllImport("user32.dll")]
67
+ public static extern void mouse_event(uint dwFlags, uint dx, uint dy, int dwData, UIntPtr dwExtraInfo);
68
+
69
+ [DllImport("user32.dll")]
70
+ public static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo);
71
+
72
+ [DllImport("user32.dll", SetLastError = true)]
73
+ public static extern IntPtr OpenInputDesktop(uint flags, bool inherit, uint desiredAccess);
74
+
75
+ [DllImport("user32.dll", SetLastError = true)]
76
+ public static extern bool CloseDesktop(IntPtr desktop);
77
+
78
+ [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
79
+ public static extern bool GetUserObjectInformation(IntPtr handle, int index, System.Text.StringBuilder info, int length, out int needed);
80
+
81
+ [DllImport("user32.dll")]
82
+ public static extern int GetSystemMetrics(int index);
83
+
84
+ [DllImport("user32.dll", SetLastError = true)]
85
+ public static extern bool SetProcessDpiAwarenessContext(IntPtr value);
86
+
87
+ [DllImport("kernel32.dll", SetLastError = true)]
88
+ static extern IntPtr OpenProcess(uint access, bool inherit, int processId);
89
+
90
+ [DllImport("kernel32.dll", SetLastError = true)]
91
+ static extern bool CloseHandle(IntPtr handle);
92
+
93
+ [DllImport("advapi32.dll", SetLastError = true)]
94
+ static extern bool OpenProcessToken(IntPtr process, uint access, out IntPtr token);
95
+
96
+ [DllImport("advapi32.dll", SetLastError = true)]
97
+ static extern bool GetTokenInformation(IntPtr token, int informationClass, IntPtr information, int length, out int returnLength);
98
+
99
+ [DllImport("advapi32.dll")]
100
+ static extern IntPtr GetSidSubAuthority(IntPtr sid, uint index);
101
+
102
+ [DllImport("advapi32.dll")]
103
+ static extern IntPtr GetSidSubAuthorityCount(IntPtr sid);
104
+
105
+ public static int GetProcessIntegrityRid(int processId) {
106
+ IntPtr process = OpenProcess(0x1000, false, processId);
107
+ if (process == IntPtr.Zero) throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error());
108
+ IntPtr token = IntPtr.Zero;
109
+ IntPtr buffer = IntPtr.Zero;
110
+ try {
111
+ if (!OpenProcessToken(process, 0x0008, out token)) throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error());
112
+ int needed;
113
+ GetTokenInformation(token, 25, IntPtr.Zero, 0, out needed);
114
+ buffer = Marshal.AllocHGlobal(needed);
115
+ if (!GetTokenInformation(token, 25, buffer, needed, out needed)) throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error());
116
+ IntPtr sid = Marshal.ReadIntPtr(buffer);
117
+ byte count = Marshal.ReadByte(GetSidSubAuthorityCount(sid));
118
+ return Marshal.ReadInt32(GetSidSubAuthority(sid, (uint)(count - 1)));
119
+ } finally {
120
+ if (buffer != IntPtr.Zero) Marshal.FreeHGlobal(buffer);
121
+ if (token != IntPtr.Zero) CloseHandle(token);
122
+ CloseHandle(process);
123
+ }
124
+ }
125
+ }
126
+ "@
127
+
128
+ # PER_MONITOR_AWARE_V2 keeps UIA screen coordinates, screenshots and Win32
129
+ # input in the same physical coordinate system on mixed-DPI displays.
130
+ try { [void][CrossHandsDesktopWin32]::SetProcessDpiAwarenessContext([IntPtr](-4)) } catch {}
131
+
132
+ $MaxNodes = 1200
133
+ $MaxDepth = 64
134
+ $TextLimit = 500
135
+ $MaxScreenshotPngBytes = 900000
136
+ $MaxScreenshotEdge = 1280
137
+ $MinScreenshotScale = 0.25
138
+ $ScreenshotScaleStep = 0.85
139
+ $BlockedAppFragments = @(
140
+ "1password",
141
+ "bitwarden",
142
+ "dashlane",
143
+ "lastpass",
144
+ "nordpass",
145
+ "proton pass"
146
+ )
147
+
148
+ $WindowsMessages = @{
149
+ Char = 0x0102
150
+ KeyDown = 0x0100
151
+ KeyUp = 0x0101
152
+ MouseMove = 0x0200
153
+ LeftDown = 0x0201
154
+ LeftUp = 0x0202
155
+ RightDown = 0x0204
156
+ RightUp = 0x0205
157
+ MiddleDown = 0x0207
158
+ MiddleUp = 0x0208
159
+ Wheel = 0x020A
160
+ }
161
+
162
+ $MouseEvents = @{
163
+ LeftDown = 0x0002
164
+ LeftUp = 0x0004
165
+ RightDown = 0x0008
166
+ RightUp = 0x0010
167
+ MiddleDown = 0x0020
168
+ MiddleUp = 0x0040
169
+ Wheel = 0x0800
170
+ Hwheel = 0x1000
171
+ }
172
+
173
+ function Write-CrossHandsJson($Payload) {
174
+ $Payload | ConvertTo-Json -Depth 100 -Compress
175
+ }
176
+
177
+ function New-CrossHandsFrame([double]$X, [double]$Y, [double]$Width, [double]$Height) {
178
+ if ($Width -le 0 -or $Height -le 0) { return $null }
179
+ [pscustomobject]@{ x = $X; y = $Y; width = $Width; height = $Height }
180
+ }
181
+
182
+ function ConvertTo-CrossHandsLParam([int]$X, [int]$Y) {
183
+ [IntPtr]((($Y -band 0xffff) -shl 16) -bor ($X -band 0xffff))
184
+ }
185
+
186
+ function ConvertTo-CrossHandsWheelParam([int]$Delta) {
187
+ [IntPtr](($Delta -band 0xffff) -shl 16)
188
+ }
189
+
190
+ function Get-CrossHandsWindowProcesses {
191
+ @(Get-Process | Where-Object { $_.MainWindowHandle -ne 0 } | Sort-Object ProcessName, Id)
192
+ }
193
+
194
+ function Get-CrossHandsInputDesktopName {
195
+ $desktop = [CrossHandsDesktopWin32]::OpenInputDesktop(0, $false, 0x0040)
196
+ if ($desktop -eq [IntPtr]::Zero) { return $null }
197
+ try {
198
+ $needed = 0
199
+ [void][CrossHandsDesktopWin32]::GetUserObjectInformation($desktop, 2, $null, 0, [ref]$needed)
200
+ $name = New-Object System.Text.StringBuilder ([Math]::Max(256, $needed))
201
+ if (-not [CrossHandsDesktopWin32]::GetUserObjectInformation($desktop, 2, $name, $name.Capacity, [ref]$needed)) { return $null }
202
+ $name.ToString()
203
+ } finally {
204
+ [void][CrossHandsDesktopWin32]::CloseDesktop($desktop)
205
+ }
206
+ }
207
+
208
+ function Assert-CrossHandsInteractiveSession {
209
+ if ([CrossHandsDesktopWin32]::GetSystemMetrics(0x1000) -ne 0) {
210
+ throw "session_unavailable: remote desktop sessions are outside the CrossHands v1 trust boundary"
211
+ }
212
+ $desktop = Get-CrossHandsInputDesktopName
213
+ if ([string]::IsNullOrWhiteSpace($desktop)) {
214
+ throw "session_unavailable: the interactive desktop is locked or unavailable"
215
+ }
216
+ if ($desktop -ine "Default") {
217
+ throw "unsupported_capability: secure or non-default desktops are not supported"
218
+ }
219
+ }
220
+
221
+ function Get-CrossHandsSha256([string]$Path) {
222
+ $stream = [System.IO.File]::OpenRead($Path)
223
+ try {
224
+ $sha = [System.Security.Cryptography.SHA256]::Create()
225
+ try { ([BitConverter]::ToString($sha.ComputeHash($stream))).Replace("-", "").ToLowerInvariant() }
226
+ finally { $sha.Dispose() }
227
+ } finally { $stream.Dispose() }
228
+ }
229
+
230
+ function Get-CrossHandsProcessIdentity($Process) {
231
+ $path = [string]$Process.Path
232
+ $publisher = "unavailable"
233
+ try {
234
+ $signature = Get-AuthenticodeSignature -LiteralPath $path
235
+ if ($null -ne $signature.SignerCertificate) { $publisher = [string]$signature.SignerCertificate.Subject }
236
+ elseif ($signature.Status -eq "NotSigned") { $publisher = "unsigned" }
237
+ } catch {}
238
+ [pscustomobject]@{
239
+ pid = [int]$Process.Id
240
+ startedAt = $Process.StartTime.ToUniversalTime().ToString("o")
241
+ sessionId = [int]$Process.SessionId
242
+ desktop = Get-CrossHandsInputDesktopName
243
+ executablePath = [System.IO.Path]::GetFullPath($path)
244
+ integrityRid = [CrossHandsDesktopWin32]::GetProcessIntegrityRid([int]$Process.Id)
245
+ publisher = $publisher
246
+ sha256 = Get-CrossHandsSha256 $path
247
+ }
248
+ }
249
+
250
+ function Assert-CrossHandsProcessIdentity($Process, $Expected) {
251
+ Assert-CrossHandsInteractiveSession
252
+ try { $actual = Get-CrossHandsProcessIdentity $Process }
253
+ catch { throw "unsupported_capability: target identity or token cannot be inspected at equal integrity" }
254
+ $selfIntegrity = [CrossHandsDesktopWin32]::GetProcessIntegrityRid($PID)
255
+ if ($actual.sessionId -ne [Diagnostics.Process]::GetCurrentProcess().SessionId) {
256
+ throw "session_unavailable: target belongs to another logon session"
257
+ }
258
+ if ($actual.integrityRid -ne $selfIntegrity) {
259
+ throw "unsupported_capability: target integrity differs from the CrossHands provider"
260
+ }
261
+ if ($null -ne $Expected) {
262
+ foreach ($property in @("pid", "startedAt", "sessionId", "desktop", "executablePath", "integrityRid", "publisher", "sha256")) {
263
+ if ([string]$actual.$property -cne [string]$Expected.$property) {
264
+ throw "stale_target: target process identity changed ($property)"
265
+ }
266
+ }
267
+ }
268
+ $actual
269
+ }
270
+
271
+ function Find-CrossHandsProcess([string]$Query) {
272
+ $needle = ""
273
+ if ($null -ne $Query) { $needle = $Query.Trim() }
274
+ if ([string]::IsNullOrWhiteSpace($needle)) { throw 'appNotFound("")' }
275
+ if ($needle.StartsWith("pid:", [System.StringComparison]::OrdinalIgnoreCase)) {
276
+ $needle = $needle.Substring(4)
277
+ }
278
+
279
+ $parsedProcessId = 0
280
+ $processes = Get-CrossHandsWindowProcesses
281
+ if ([int]::TryParse($needle, [ref]$parsedProcessId)) {
282
+ $match = $processes | Where-Object { $_.Id -eq $parsedProcessId } | Select-Object -First 1
283
+ if ($null -ne $match) {
284
+ Assert-CrossHandsProcessAllowed $match
285
+ return $match
286
+ }
287
+ }
288
+
289
+ $processNeedle = $needle
290
+ if ($processNeedle.EndsWith(".exe", [System.StringComparison]::OrdinalIgnoreCase)) {
291
+ $processNeedle = $processNeedle.Substring(0, $processNeedle.Length - 4)
292
+ }
293
+
294
+ $match = $processes | Where-Object {
295
+ $_.ProcessName -ieq $processNeedle -or
296
+ "$($_.ProcessName).exe" -ieq $needle -or
297
+ $_.MainWindowTitle -ieq $needle -or
298
+ $_.MainWindowTitle -ilike "*$needle*"
299
+ } | Select-Object -First 1
300
+ if ($null -ne $match) {
301
+ Assert-CrossHandsProcessAllowed $match
302
+ return $match
303
+ }
304
+
305
+ throw "appNotFound(`"$Query`")"
306
+ }
307
+
308
+ function Assert-CrossHandsProcessAllowed($Process) {
309
+ $values = @($Process.ProcessName, $Process.MainWindowTitle) | ForEach-Object { ([string]$_).ToLowerInvariant() }
310
+ foreach ($fragment in $BlockedAppFragments) {
311
+ foreach ($value in $values) {
312
+ if ($value.Contains($fragment)) {
313
+ throw "appBlocked(`"$($Process.ProcessName)`")"
314
+ }
315
+ }
316
+ }
317
+ }
318
+
319
+ function Test-CrossHandsBrowserProcess($Process) {
320
+ $name = ([string]$Process.ProcessName).ToLowerInvariant()
321
+ $browserProcesses = @(
322
+ "arc",
323
+ "brave",
324
+ "chrome",
325
+ "chromium",
326
+ "firefox",
327
+ "librewolf",
328
+ "msedge",
329
+ "opera",
330
+ "vivaldi",
331
+ "zen"
332
+ )
333
+ $browserProcesses -contains $name
334
+ }
335
+
336
+ function Get-CrossHandsRootElement($Process) {
337
+ if ($Process.MainWindowHandle -eq 0) {
338
+ throw "No top-level UI Automation window is available for $($Process.ProcessName)."
339
+ }
340
+ [Windows.Automation.AutomationElement]::FromHandle([IntPtr]$Process.MainWindowHandle)
341
+ }
342
+
343
+ function Get-CrossHandsWindowFrame($Process, $RootElement) {
344
+ $rect = New-Object CrossHandsDesktopWin32+RECT
345
+ if ([CrossHandsDesktopWin32]::GetWindowRect([IntPtr]$Process.MainWindowHandle, [ref]$rect)) {
346
+ return New-CrossHandsFrame $rect.Left $rect.Top ($rect.Right - $rect.Left) ($rect.Bottom - $rect.Top)
347
+ }
348
+
349
+ try {
350
+ $bounds = $RootElement.Current.BoundingRectangle
351
+ if (-not $bounds.IsEmpty) {
352
+ return New-CrossHandsFrame $bounds.X $bounds.Y $bounds.Width $bounds.Height
353
+ }
354
+ } catch {}
355
+ $null
356
+ }
357
+
358
+ function Get-CrossHandsWindowId($Process) {
359
+ [int64]$Process.MainWindowHandle
360
+ }
361
+
362
+ function Get-CrossHandsAppName($Process) {
363
+ if ($Process.ProcessName -eq "ApplicationFrameHost" -and -not [string]::IsNullOrWhiteSpace($Process.MainWindowTitle)) {
364
+ return [string]$Process.MainWindowTitle
365
+ }
366
+ [string]$Process.ProcessName
367
+ }
368
+
369
+ function New-CrossHandsAppRecord($Process) {
370
+ [pscustomobject]@{
371
+ name = Get-CrossHandsAppName $Process
372
+ bundleIdentifier = $Process.ProcessName
373
+ bundleId = $Process.ProcessName
374
+ pid = [int]$Process.Id
375
+ }
376
+ }
377
+
378
+ function Assert-CrossHandsWindowTarget($Process, $WindowId, $WindowIndex) {
379
+ if ($null -ne $WindowIndex -and [int]$WindowIndex -ne 0) {
380
+ throw "windowNotFound(`"$WindowIndex`")"
381
+ }
382
+ if ($null -ne $WindowId -and [int64]$WindowId -ne (Get-CrossHandsWindowId $Process)) {
383
+ throw "windowNotFound(`"$WindowId`")"
384
+ }
385
+ }
386
+
387
+ function Restore-CrossHandsWindow($Process) {
388
+ if ($Process.MainWindowHandle -eq 0) { return }
389
+ [void][CrossHandsDesktopWin32]::ShowWindow([IntPtr]$Process.MainWindowHandle, 9)
390
+ [void][CrossHandsDesktopWin32]::SetForegroundWindow([IntPtr]$Process.MainWindowHandle)
391
+ }
392
+
393
+ function Test-CrossHandsWindowFocused([IntPtr]$WindowHandle) {
394
+ [CrossHandsDesktopWin32]::GetForegroundWindow() -eq $WindowHandle
395
+ }
396
+
397
+ function Wait-CrossHandsWindowFocused([IntPtr]$WindowHandle, [int]$TimeoutMilliseconds) {
398
+ $stopwatch = [Diagnostics.Stopwatch]::StartNew()
399
+ while ($stopwatch.ElapsedMilliseconds -lt $TimeoutMilliseconds) {
400
+ if (Test-CrossHandsWindowFocused $WindowHandle) { return $true }
401
+ Start-Sleep -Milliseconds 50
402
+ }
403
+ Test-CrossHandsWindowFocused $WindowHandle
404
+ }
405
+
406
+ function Assert-CrossHandsKeyboardFocus([IntPtr]$WindowHandle, $Operation) {
407
+ if (Test-CrossHandsWindowFocused $WindowHandle) { return }
408
+ if ([bool]$Operation.restoreWindow) {
409
+ if (Wait-CrossHandsWindowFocused $WindowHandle 500) { return }
410
+ throw "window_not_focused: keyboard input requires the target window to be focused; restoreWindow was requested but the target window is still not focused; bring it forward manually or check desktop permissions"
411
+ }
412
+ throw "window_not_focused: keyboard input requires the target window to be focused; retry with --restore-window"
413
+ }
414
+
415
+ function Get-CrossHandsElementFrame($Element, $WindowFrame) {
416
+ try {
417
+ $bounds = $Element.Current.BoundingRectangle
418
+ if ($bounds.IsEmpty) { return $null }
419
+ if ($null -eq $WindowFrame) {
420
+ return New-CrossHandsFrame $bounds.X $bounds.Y $bounds.Width $bounds.Height
421
+ }
422
+ New-CrossHandsFrame ($bounds.X - $WindowFrame.x) ($bounds.Y - $WindowFrame.y) $bounds.Width $bounds.Height
423
+ } catch {
424
+ $null
425
+ }
426
+ }
427
+
428
+ function Get-CrossHandsProperty($Element, [string]$Name) {
429
+ try { [string]$Element.Current.$Name } catch { "" }
430
+ }
431
+
432
+ function Get-CrossHandsRuntimeId($Element) {
433
+ try { @($Element.GetRuntimeId()) } catch { @() }
434
+ }
435
+
436
+ function Test-CrossHandsSensitiveElement($Element) {
437
+ try {
438
+ if ($Element.Current.IsPassword) { return $true }
439
+ } catch {}
440
+ $controlType = try { [string]$Element.Current.ControlType.ProgrammaticName } catch { "" }
441
+ $parts = @(
442
+ (Get-CrossHandsProperty $Element "LocalizedControlType"),
443
+ $controlType,
444
+ (Get-CrossHandsProperty $Element "Name"),
445
+ (Get-CrossHandsProperty $Element "AutomationId"),
446
+ (Get-CrossHandsProperty $Element "ClassName")
447
+ )
448
+ $haystack = (($parts -join " ") -replace "\s+", " ").ToLowerInvariant()
449
+ foreach ($term in @("password", "passcode", "secret", "one-time code", "verification code")) {
450
+ if ($haystack.Contains($term)) { return $true }
451
+ }
452
+ $haystack -match "(^|[^a-z0-9])pin([^a-z0-9]|$)"
453
+ }
454
+
455
+ function Get-CrossHandsValueText($Element) {
456
+ try {
457
+ if (Test-CrossHandsSensitiveElement $Element) { return "[redacted]" }
458
+ $pattern = $Element.GetCurrentPattern([Windows.Automation.ValuePattern]::Pattern)
459
+ $rawValue = $pattern.Current.Value
460
+ $text = if ($null -eq $rawValue) { "" } else { [string]$rawValue }
461
+ if ($text.Length -gt $TextLimit) { return $text.Substring(0, $TextLimit) + "..." }
462
+ $text
463
+ } catch {
464
+ ""
465
+ }
466
+ }
467
+
468
+ function Get-CrossHandsActions($Element) {
469
+ $actions = New-Object System.Collections.Generic.List[string]
470
+ foreach ($pattern in $Element.GetSupportedPatterns()) {
471
+ $name = [string]$pattern.ProgrammaticName
472
+ if ($name -like "InvokePatternIdentifiers.Pattern") { $actions.Add("Invoke") }
473
+ elseif ($name -like "TogglePatternIdentifiers.Pattern") { $actions.Add("Toggle") }
474
+ elseif ($name -like "SelectionItemPatternIdentifiers.Pattern") { $actions.Add("Select") }
475
+ elseif ($name -like "ScrollPatternIdentifiers.Pattern") { $actions.Add("Scroll") }
476
+ elseif ($name -like "ValuePatternIdentifiers.Pattern") { $actions.Add("SetValue") }
477
+ }
478
+ @($actions | Select-Object -Unique)
479
+ }
480
+
481
+ function Get-CrossHandsMeaningfulActions($Actions) {
482
+ $noisy = @("Invoke", "ScrollToVisible", "ShowMenu")
483
+ @($Actions | Where-Object { $noisy -notcontains $_ })
484
+ }
485
+
486
+ function Format-CrossHandsSnapshotText([string]$Text) {
487
+ if ([string]::IsNullOrWhiteSpace($Text)) { return "" }
488
+ (($Text -replace "\s+", " ").Trim())
489
+ }
490
+
491
+ function Format-CrossHandsValueSegment([string]$RoleKey, [string]$Title, [string]$Value) {
492
+ $clean = Format-CrossHandsSnapshotText $Value
493
+ if ([string]::IsNullOrWhiteSpace($clean) -or $clean -eq $Title) { return "" }
494
+ if ($RoleKey -eq "heading" -and $clean -match "^\d+$") { return "" }
495
+ if ($RoleKey -in @("text", "edit", "document", "scroll bar", "progress bar")) {
496
+ return " $clean"
497
+ }
498
+ ", Value: $clean"
499
+ }
500
+
501
+ function Test-CrossHandsSuppressChildren([string]$RoleKey, [string]$Title, [string]$Value, [string]$Summary) {
502
+ $hasCompactLabel = -not [string]::IsNullOrWhiteSpace($Title) -or -not [string]::IsNullOrWhiteSpace((Format-CrossHandsSnapshotText $Value)) -or -not [string]::IsNullOrWhiteSpace((Format-CrossHandsSnapshotText $Summary))
503
+ $hasCompactLabel -and $RoleKey -in @(
504
+ "button",
505
+ "check box",
506
+ "combo box",
507
+ "heading",
508
+ "hyperlink",
509
+ "link",
510
+ "menu item",
511
+ "radio button",
512
+ "tab item"
513
+ )
514
+ }
515
+
516
+ function Get-CrossHandsTextSnippets($Element, [int]$Limit = 6, [int]$MaxDepth = 3) {
517
+ $values = New-Object System.Collections.Generic.List[string]
518
+ $seen = New-Object System.Collections.Generic.HashSet[string]
519
+
520
+ function Visit-CrossHandsText($Node, [int]$Depth) {
521
+ if ($values.Count -ge $Limit -or $Depth -gt $MaxDepth) { return }
522
+ $role = try { [string]$Node.Current.LocalizedControlType } catch { "" }
523
+ if ($role -match "text|link|label") {
524
+ foreach ($raw in @((Get-CrossHandsProperty $Node "Name"), (Get-CrossHandsValueText $Node))) {
525
+ $value = (($raw -replace "\s+", " ").Trim())
526
+ if (-not [string]::IsNullOrWhiteSpace($value) -and $seen.Add($value)) {
527
+ if ($value.Length -gt 80) { $value = $value.Substring(0, 80) + "..." }
528
+ $values.Add($value)
529
+ if ($values.Count -ge $Limit) { return }
530
+ }
531
+ }
532
+ }
533
+ try {
534
+ $children = $Node.FindAll([Windows.Automation.TreeScope]::Children, [Windows.Automation.Condition]::TrueCondition)
535
+ for ($i = 0; $i -lt $children.Count; $i++) {
536
+ Visit-CrossHandsText $children.Item($i) ($Depth + 1)
537
+ if ($values.Count -ge $Limit) { return }
538
+ }
539
+ } catch {}
540
+ }
541
+
542
+ Visit-CrossHandsText $Element 0
543
+ @($values.ToArray())
544
+ }
545
+
546
+ function Test-CrossHandsPlainTextSubtree($Element, [int]$MaxDepth = 4) {
547
+ $script:sawCrossHandsText = $false
548
+ $allowed = @("pane", "group", "custom", "unknown", "text", "link", "image")
549
+
550
+ function Visit-CrossHandsPlainText($Node, [int]$Depth) {
551
+ if ($Depth -gt $MaxDepth) { return $false }
552
+ $role = try { [string]$Node.Current.LocalizedControlType } catch { "" }
553
+ $roleKey = $role.ToLowerInvariant()
554
+ if ($allowed -notcontains $roleKey) { return $false }
555
+ if ($roleKey -match "text|link") { $script:sawCrossHandsText = $true }
556
+ if (@(Get-CrossHandsMeaningfulActions @(Get-CrossHandsActions $Node)).Count -gt 0) { return $false }
557
+ try {
558
+ $children = $Node.FindAll([Windows.Automation.TreeScope]::Children, [Windows.Automation.Condition]::TrueCondition)
559
+ for ($i = 0; $i -lt $children.Count; $i++) {
560
+ if (-not (Visit-CrossHandsPlainText $children.Item($i) ($Depth + 1))) { return $false }
561
+ }
562
+ } catch {}
563
+ return $true
564
+ }
565
+
566
+ (Visit-CrossHandsPlainText $Element 0) -and $script:sawCrossHandsText
567
+ }
568
+
569
+ function New-CrossHandsElementRecord($Element, [int]$Index, $WindowFrame) {
570
+ $controlType = try { [string]$Element.Current.ControlType.ProgrammaticName } catch { "" }
571
+ $nativeWindowHandle = try { [int64]$Element.Current.NativeWindowHandle } catch { 0 }
572
+ [pscustomobject]@{
573
+ index = $Index
574
+ runtimeId = @(Get-CrossHandsRuntimeId $Element)
575
+ automationId = Get-CrossHandsProperty $Element "AutomationId"
576
+ name = Get-CrossHandsProperty $Element "Name"
577
+ controlType = $controlType
578
+ localizedControlType = Get-CrossHandsProperty $Element "LocalizedControlType"
579
+ className = Get-CrossHandsProperty $Element "ClassName"
580
+ value = Get-CrossHandsValueText $Element
581
+ isSelected = Test-CrossHandsElementSelected $Element
582
+ nativeWindowHandle = $nativeWindowHandle
583
+ frame = Get-CrossHandsElementFrame $Element $WindowFrame
584
+ actions = @(Get-CrossHandsActions $Element)
585
+ }
586
+ }
587
+
588
+ function Test-CrossHandsElementSelected($Element) {
589
+ try {
590
+ $pattern = $Element.GetCurrentPattern([Windows.Automation.SelectionItemPattern]::Pattern)
591
+ return [bool]$pattern.Current.IsSelected
592
+ } catch {
593
+ return $false
594
+ }
595
+ }
596
+
597
+ function Render-CrossHandsTree($RootElement, $WindowFrame, [bool]$CompactBrowserTabs = $false) {
598
+ $records = New-Object System.Collections.Generic.List[object]
599
+ $lines = New-Object System.Collections.Generic.List[string]
600
+ $seen = New-Object System.Collections.Generic.HashSet[string]
601
+ $truncation = [pscustomobject]@{
602
+ truncated = $false
603
+ maxNodes = $MaxNodes
604
+ maxDepth = $MaxDepth
605
+ maxDepthReached = $false
606
+ }
607
+
608
+ function Visit-CrossHandsNode($Node, [int]$Depth) {
609
+ if ($records.Count -ge $MaxNodes -or $Depth -gt $MaxDepth) {
610
+ $truncation.truncated = $true
611
+ if ($Depth -gt $MaxDepth) { $truncation.maxDepthReached = $true }
612
+ return
613
+ }
614
+ $identity = try { (@($Node.GetRuntimeId()) -join ".") } catch { [Guid]::NewGuid().ToString() }
615
+ if (-not $seen.Add($identity)) { return }
616
+
617
+ $record = New-CrossHandsElementRecord $Node $records.Count $WindowFrame
618
+ $children = @()
619
+ try {
620
+ $children = @($Node.FindAll([Windows.Automation.TreeScope]::Children, [Windows.Automation.Condition]::TrueCondition))
621
+ } catch {}
622
+ $meaningfulActions = @(Get-CrossHandsMeaningfulActions $record.actions)
623
+ $title = if ([string]::IsNullOrWhiteSpace($record.name)) { $record.automationId } else { $record.name }
624
+ $role = if ([string]::IsNullOrWhiteSpace($record.localizedControlType)) { $record.controlType } else { $record.localizedControlType }
625
+ $roleKey = $role.ToLowerInvariant()
626
+ $snippets = @(Get-CrossHandsTextSnippets $Node 8 4)
627
+ $genericSummary = $null
628
+ if (($roleKey -in @("pane", "group", "custom", "unknown")) -and [string]::IsNullOrWhiteSpace($title) -and [string]::IsNullOrWhiteSpace($record.value) -and $snippets.Count -ge 2 -and (Test-CrossHandsPlainTextSubtree $Node)) {
629
+ $genericSummary = ($snippets -join " ")
630
+ }
631
+ if (($roleKey -in @("pane", "group", "custom", "unknown")) -and [string]::IsNullOrWhiteSpace($title) -and [string]::IsNullOrWhiteSpace($record.value) -and $meaningfulActions.Count -eq 0 -and $null -eq $genericSummary -and $children.Count -le 1) {
632
+ for ($i = 0; $i -lt $children.Count; $i++) {
633
+ Visit-CrossHandsNode $children.Item($i) $Depth
634
+ }
635
+ return
636
+ }
637
+
638
+ $records.Add($record)
639
+
640
+ $line = "$($record.index) $role $(Format-CrossHandsSnapshotText $title)".TrimEnd()
641
+ $line += Format-CrossHandsValueSegment $roleKey $title $record.value
642
+ if (-not [string]::IsNullOrWhiteSpace($genericSummary) -and $genericSummary -ne $title) {
643
+ $line += ", Text: " + (Format-CrossHandsSnapshotText $genericSummary)
644
+ } elseif ($roleKey -in @("row", "data item", "list item")) {
645
+ $rowSummary = @((Get-CrossHandsTextSnippets $Node 6 3)) -join " "
646
+ if (-not [string]::IsNullOrWhiteSpace($rowSummary) -and $rowSummary -ne $title) {
647
+ $line += ", Text: " + (Format-CrossHandsSnapshotText $rowSummary)
648
+ }
649
+ }
650
+ if ($meaningfulActions.Count -gt 0) {
651
+ $line += ", Secondary Actions: " + ($meaningfulActions -join ", ")
652
+ }
653
+ $lines.Add(("`t" * $Depth) + $line)
654
+
655
+ if (-not [string]::IsNullOrWhiteSpace($genericSummary) -or (Test-CrossHandsSuppressChildren $roleKey $title $record.value $genericSummary)) { return }
656
+ $childLineStart = $lines.Count
657
+ for ($i = 0; $i -lt $children.Count; $i++) {
658
+ Visit-CrossHandsNode $children.Item($i) ($Depth + 1)
659
+ }
660
+ if ($CompactBrowserTabs) {
661
+ Compress-CrossHandsRenderedBrowserTabs $records $lines $childLineStart ($Depth + 1)
662
+ }
663
+ }
664
+
665
+ Visit-CrossHandsNode $RootElement 0
666
+ [pscustomobject]@{ elements = @($records.ToArray()); lines = @($lines.ToArray()); truncation = $truncation }
667
+ }
668
+
669
+ function Compress-CrossHandsRenderedBrowserTabs($Records, $Lines, [int]$StartLine, [int]$Depth) {
670
+ $tabLineIndexes = New-Object System.Collections.Generic.List[int]
671
+ for ($lineIndex = $StartLine; $lineIndex -lt $Lines.Count; $lineIndex++) {
672
+ if (Test-CrossHandsDirectRenderedBrowserTabLine ([string]$Lines[$lineIndex]) $Depth) {
673
+ $tabLineIndexes.Add($lineIndex)
674
+ }
675
+ }
676
+ if ($tabLineIndexes.Count -lt 10) { return }
677
+
678
+ $recordsByIndex = @{}
679
+ foreach ($record in @($Records.ToArray())) {
680
+ $recordsByIndex[[int]$record.index] = $record
681
+ }
682
+ $activeLineIndexes = New-Object System.Collections.Generic.HashSet[int]
683
+ foreach ($lineIndex in $tabLineIndexes) {
684
+ if (Test-CrossHandsActiveRenderedBrowserTabLine ([string]$Lines[$lineIndex]) $Depth $recordsByIndex) {
685
+ [void]$activeLineIndexes.Add($lineIndex)
686
+ }
687
+ }
688
+ if ($activeLineIndexes.Count -eq 0) { return }
689
+
690
+ $omittedRecordIndexes = New-Object System.Collections.Generic.HashSet[int]
691
+ $omittedCount = 0
692
+ $insertionIndex = $tabLineIndexes[0]
693
+ for ($i = $tabLineIndexes.Count - 1; $i -ge 0; $i--) {
694
+ $lineIndex = $tabLineIndexes[$i]
695
+ if ($activeLineIndexes.Contains($lineIndex)) { continue }
696
+ $recordIndex = Get-CrossHandsRenderedElementIndex ([string]$Lines[$lineIndex]) $Depth
697
+ if ($null -ne $recordIndex) {
698
+ [void]$omittedRecordIndexes.Add([int]$recordIndex)
699
+ }
700
+ $Lines.RemoveAt($lineIndex)
701
+ $omittedCount++
702
+ }
703
+ if ($omittedCount -le 0) { return }
704
+ for ($recordIndex = $Records.Count - 1; $recordIndex -ge 0; $recordIndex--) {
705
+ if ($omittedRecordIndexes.Contains([int]$Records[$recordIndex].index)) {
706
+ $Records.RemoveAt($recordIndex)
707
+ }
708
+ }
709
+ $Lines.Insert($insertionIndex, (("`t" * $Depth) + "... $omittedCount inactive browser tabs omitted"))
710
+ }
711
+
712
+ function Test-CrossHandsDirectRenderedBrowserTabLine([string]$Line, [int]$Depth) {
713
+ $indent = "`t" * $Depth
714
+ if (-not $Line.StartsWith($indent)) { return $false }
715
+ $text = $Line.Substring($indent.Length)
716
+ if ($text.StartsWith("`t")) { return $false }
717
+ $text -match "^\d+ (page tab|tab item|tab)($|[ \(,])"
718
+ }
719
+
720
+ function Test-CrossHandsActiveRenderedBrowserTabLine([string]$Line, [int]$Depth, $RecordsByIndex) {
721
+ if ($Line.Contains("(selected")) { return $true }
722
+ $recordIndex = Get-CrossHandsRenderedElementIndex $Line $Depth
723
+ if ($null -eq $recordIndex -or -not $RecordsByIndex.ContainsKey([int]$recordIndex)) { return $false }
724
+ $record = $RecordsByIndex[[int]$recordIndex]
725
+ [bool]$record.isSelected -or (Format-CrossHandsSnapshotText $record.value) -eq "1"
726
+ }
727
+
728
+ function Get-CrossHandsRenderedElementIndex([string]$Line, [int]$Depth) {
729
+ $text = $Line.Substring(("`t" * $Depth).Length)
730
+ if ($text -match "^(\d+)") { return [int]$Matches[1] }
731
+ $null
732
+ }
733
+
734
+ function ConvertTo-CrossHandsPngBytes([System.Drawing.Image]$Image) {
735
+ $stream = $null
736
+ try {
737
+ $stream = New-Object System.IO.MemoryStream
738
+ $Image.Save($stream, [System.Drawing.Imaging.ImageFormat]::Png)
739
+ return ,$stream.ToArray()
740
+ } finally {
741
+ if ($null -ne $stream) { $stream.Dispose() }
742
+ }
743
+ }
744
+
745
+ function New-CrossHandsScreenshotPayload([byte[]]$Bytes, [int]$Width, [int]$Height, [double]$Scale) {
746
+ [pscustomobject]@{
747
+ base64 = [Convert]::ToBase64String($Bytes)
748
+ width = $Width
749
+ height = $Height
750
+ scale = $Scale
751
+ }
752
+ }
753
+
754
+ function Resize-CrossHandsBitmap([System.Drawing.Bitmap]$Source, [int]$Width, [int]$Height) {
755
+ $resized = $null
756
+ $graphics = $null
757
+ try {
758
+ $resized = New-Object System.Drawing.Bitmap $Width, $Height
759
+ $graphics = [System.Drawing.Graphics]::FromImage($resized)
760
+ $graphics.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::Bilinear
761
+ $graphics.DrawImage($Source, 0, 0, $Width, $Height)
762
+ $result = $resized
763
+ $resized = $null
764
+ return $result
765
+ } finally {
766
+ if ($null -ne $graphics) { $graphics.Dispose() }
767
+ if ($null -ne $resized) { $resized.Dispose() }
768
+ }
769
+ }
770
+
771
+ function Get-CrossHandsBoundedScreenshotPayload([System.Drawing.Bitmap]$Bitmap) {
772
+ $originalWidth = [int][Math]::Max(1, $Bitmap.Width)
773
+ $originalHeight = [int][Math]::Max(1, $Bitmap.Height)
774
+ $pngBytes = ConvertTo-CrossHandsPngBytes $Bitmap
775
+ if ($pngBytes.Length -le $MaxScreenshotPngBytes) {
776
+ return New-CrossHandsScreenshotPayload $pngBytes $originalWidth $originalHeight 1.0
777
+ }
778
+
779
+ # Why: screenshots cross process boundaries as PNG base64 in JSON; cap noisy
780
+ # large-window payloads to match the macOS provider's memory bounds.
781
+ $scale = [Math]::Min(1.0, $MaxScreenshotEdge / [double][Math]::Max($originalWidth, $originalHeight))
782
+ while ($scale -ge $MinScreenshotScale) {
783
+ $width = [int][Math]::Max(1, [Math]::Round($originalWidth * $scale))
784
+ $height = [int][Math]::Max(1, [Math]::Round($originalHeight * $scale))
785
+ if ($width -eq $originalWidth -and $height -eq $originalHeight) {
786
+ $scale *= $ScreenshotScaleStep
787
+ continue
788
+ }
789
+
790
+ $resized = $null
791
+ try {
792
+ $resized = Resize-CrossHandsBitmap $Bitmap $width $height
793
+ $candidateBytes = ConvertTo-CrossHandsPngBytes $resized
794
+ if ($candidateBytes.Length -le $MaxScreenshotPngBytes) {
795
+ return New-CrossHandsScreenshotPayload $candidateBytes $width $height ($width / [double]$originalWidth)
796
+ }
797
+ } finally {
798
+ if ($null -ne $resized) { $resized.Dispose() }
799
+ }
800
+
801
+ $scale *= $ScreenshotScaleStep
802
+ }
803
+
804
+ [pscustomobject]@{
805
+ error = [pscustomobject]@{
806
+ code = "screenshot_failed"
807
+ message = "screenshot exceeded the computer-use payload cap after downscaling; retry with --no-screenshot or target a smaller window"
808
+ }
809
+ }
810
+ }
811
+
812
+ function Get-CrossHandsScreenshot([bool]$IncludeScreenshot, [IntPtr]$WindowHandle, $WindowFrame) {
813
+ if (-not $IncludeScreenshot -or $WindowHandle -eq [IntPtr]::Zero -or $null -eq $WindowFrame) { return $null }
814
+ $bitmap = $null
815
+ $graphics = $null
816
+ $hdc = [IntPtr]::Zero
817
+ try {
818
+ $width = [int][Math]::Max(1, [Math]::Round($WindowFrame.width))
819
+ $height = [int][Math]::Max(1, [Math]::Round($WindowFrame.height))
820
+ $bitmap = New-Object System.Drawing.Bitmap $width, $height
821
+ $graphics = [System.Drawing.Graphics]::FromImage($bitmap)
822
+ $hdc = $graphics.GetHdc()
823
+ # PrintWindow captures only the selected HWND. Desktop overlays and
824
+ # unrelated windows must never be sampled into an agent observation.
825
+ if (-not [CrossHandsDesktopWin32]::PrintWindow($WindowHandle, $hdc, 2)) {
826
+ throw "screenshot_failed: target-window capture failed"
827
+ }
828
+ $graphics.ReleaseHdc($hdc)
829
+ $hdc = [IntPtr]::Zero
830
+ Get-CrossHandsBoundedScreenshotPayload $bitmap
831
+ } catch {
832
+ [pscustomobject]@{
833
+ error = [pscustomobject]@{
834
+ code = "screenshot_failed"
835
+ message = "target-window screenshot capture failed; retry with --no-screenshot or verify the target supports PrintWindow"
836
+ }
837
+ }
838
+ } finally {
839
+ if ($hdc -ne [IntPtr]::Zero -and $null -ne $graphics) { $graphics.ReleaseHdc($hdc) }
840
+ if ($null -ne $graphics) { $graphics.Dispose() }
841
+ if ($null -ne $bitmap) { $bitmap.Dispose() }
842
+ }
843
+ }
844
+
845
+ function New-CrossHandsSnapshot([string]$Query, [bool]$IncludeScreenshot, $WindowId = $null, $WindowIndex = $null, [bool]$RestoreWindow = $false) {
846
+ Assert-CrossHandsInteractiveSession
847
+ $process = Find-CrossHandsProcess $Query
848
+ if ($RestoreWindow) { Restore-CrossHandsWindow $process }
849
+ Assert-CrossHandsWindowTarget $process $WindowId $WindowIndex
850
+ $root = Get-CrossHandsRootElement $process
851
+ $windowFrame = Get-CrossHandsWindowFrame $process $root
852
+ $tree = Render-CrossHandsTree $root $windowFrame (Test-CrossHandsBrowserProcess $process)
853
+ $screenshot = Get-CrossHandsScreenshot $IncludeScreenshot $process.MainWindowHandle $windowFrame
854
+
855
+ [pscustomobject]@{
856
+ snapshotId = [guid]::NewGuid().ToString()
857
+ processIdentity = Get-CrossHandsProcessIdentity $process
858
+ app = New-CrossHandsAppRecord $process
859
+ windowTitle = $process.MainWindowTitle
860
+ windowId = Get-CrossHandsWindowId $process
861
+ windowBounds = $windowFrame
862
+ screenshotPngBase64 = if ($null -ne $screenshot) { $screenshot.base64 } else { $null }
863
+ screenshotWidth = if ($null -ne $screenshot) { $screenshot.width } else { $null }
864
+ screenshotHeight = if ($null -ne $screenshot) { $screenshot.height } else { $null }
865
+ screenshotScale = if ($null -ne $screenshot) { $screenshot.scale } else { $null }
866
+ screenshotError = if ($null -ne $screenshot) { $screenshot.error } else { $null }
867
+ coordinateSpace = "window"
868
+ truncation = $tree.truncation
869
+ treeLines = @($tree.lines)
870
+ focusedSummary = $null
871
+ focusedElementId = $null
872
+ selectedText = $null
873
+ elements = @($tree.elements)
874
+ }
875
+ }
876
+
877
+ function Get-CrossHandsAppList {
878
+ Assert-CrossHandsInteractiveSession
879
+ @(Get-CrossHandsWindowProcesses | ForEach-Object {
880
+ New-CrossHandsAppRecord $_
881
+ })
882
+ }
883
+
884
+ function Get-CrossHandsWindowList([string]$Query) {
885
+ Assert-CrossHandsInteractiveSession
886
+ $process = Find-CrossHandsProcess $Query
887
+ $root = Get-CrossHandsRootElement $process
888
+ $windowFrame = Get-CrossHandsWindowFrame $process $root
889
+ $x = $null
890
+ $y = $null
891
+ $width = 0
892
+ $height = 0
893
+ if ($null -ne $windowFrame) {
894
+ $x = [int][Math]::Round($windowFrame.x)
895
+ $y = [int][Math]::Round($windowFrame.y)
896
+ $width = [int][Math]::Max(0, [Math]::Round($windowFrame.width))
897
+ $height = [int][Math]::Max(0, [Math]::Round($windowFrame.height))
898
+ }
899
+ $app = New-CrossHandsAppRecord $process
900
+ [pscustomobject]@{
901
+ app = $app
902
+ windows = @([pscustomobject]@{
903
+ index = 0
904
+ app = $app
905
+ id = Get-CrossHandsWindowId $process
906
+ title = $process.MainWindowTitle
907
+ x = $x
908
+ y = $y
909
+ width = $width
910
+ height = $height
911
+ isMinimized = $false
912
+ isOffscreen = $false
913
+ screenIndex = $null
914
+ platform = [pscustomobject]@{ backend = "uia"; nativeWindowHandle = Get-CrossHandsWindowId $process }
915
+ })
916
+ }
917
+ }
918
+
919
+ function Get-CrossHandsHandshake {
920
+ [pscustomobject]@{
921
+ platform = "win32"
922
+ provider = "crosshands-platform-windows"
923
+ providerVersion = "1.0.0"
924
+ protocolVersion = 1
925
+ graphicalSessionId = "win32:$([Diagnostics.Process]::GetCurrentProcess().SessionId):Default"
926
+ supports = [pscustomobject]@{
927
+ apps = [pscustomobject]@{ list = $true; bundleIds = $false; pids = $true }
928
+ windows = [pscustomobject]@{ list = $true; targetById = $true; targetByIndex = $true; focus = $false; moveResize = $false }
929
+ observation = [pscustomobject]@{ screenshot = $true; annotatedScreenshot = $false; elementFrames = $true; ocr = $false }
930
+ actions = [pscustomobject]@{
931
+ click = $true
932
+ typeText = $true
933
+ pressKey = $true
934
+ hotkey = $true
935
+ pasteText = $true
936
+ scroll = $true
937
+ drag = $true
938
+ setValue = $true
939
+ performAction = $true
940
+ }
941
+ surfaces = [pscustomobject]@{ menus = $false; dialogs = $false; dock = $false; menubar = $false }
942
+ }
943
+ }
944
+ }
945
+
946
+ function Test-CrossHandsSameRuntimeId($Left, $Right) {
947
+ if ($null -eq $Left -or $null -eq $Right -or $Left.Count -ne $Right.Count) { return $false }
948
+ for ($i = 0; $i -lt $Left.Count; $i++) {
949
+ if ([int]$Left[$i] -ne [int]$Right[$i]) { return $false }
950
+ }
951
+ $true
952
+ }
953
+
954
+ function Find-CrossHandsElement($RootElement, $Record) {
955
+ if ($null -eq $Record) { return $null }
956
+ if ($Record.index -eq 0) { return $RootElement }
957
+
958
+ try {
959
+ $descendants = $RootElement.FindAll([Windows.Automation.TreeScope]::Descendants, [Windows.Automation.Condition]::TrueCondition)
960
+ for ($i = 0; $i -lt $descendants.Count; $i++) {
961
+ $candidate = $descendants.Item($i)
962
+ if (Test-CrossHandsSameRuntimeId @($candidate.GetRuntimeId()) @($Record.runtimeId)) {
963
+ return $candidate
964
+ }
965
+ }
966
+ } catch {}
967
+ $null
968
+ }
969
+
970
+ function Invoke-CrossHandsPrimaryAction($Element) {
971
+ foreach ($pattern in @(
972
+ [Windows.Automation.InvokePattern]::Pattern,
973
+ [Windows.Automation.SelectionItemPattern]::Pattern,
974
+ [Windows.Automation.TogglePattern]::Pattern
975
+ )) {
976
+ try {
977
+ $instance = $Element.GetCurrentPattern($pattern)
978
+ if ($pattern -eq [Windows.Automation.InvokePattern]::Pattern) { $instance.Invoke(); return $true }
979
+ if ($pattern -eq [Windows.Automation.SelectionItemPattern]::Pattern) { $instance.Select(); return $true }
980
+ if ($pattern -eq [Windows.Automation.TogglePattern]::Pattern) { $instance.Toggle(); return $true }
981
+ } catch {}
982
+ }
983
+ $false
984
+ }
985
+
986
+ function Invoke-CrossHandsNamedAction($Element, [string]$Action) {
987
+ $wanted = ""
988
+ if ($null -ne $Action) { $wanted = $Action.Trim().ToLowerInvariant() }
989
+ switch ($wanted) {
990
+ "invoke" {
991
+ $pattern = $Element.GetCurrentPattern([Windows.Automation.InvokePattern]::Pattern)
992
+ $pattern.Invoke()
993
+ return $true
994
+ }
995
+ "select" {
996
+ $pattern = $Element.GetCurrentPattern([Windows.Automation.SelectionItemPattern]::Pattern)
997
+ $pattern.Select()
998
+ return $true
999
+ }
1000
+ "toggle" {
1001
+ $pattern = $Element.GetCurrentPattern([Windows.Automation.TogglePattern]::Pattern)
1002
+ $pattern.Toggle()
1003
+ return $true
1004
+ }
1005
+ default {
1006
+ return $false
1007
+ }
1008
+ }
1009
+ }
1010
+
1011
+ function Set-CrossHandsElementValue($Element, [string]$Value) {
1012
+ try {
1013
+ $pattern = $Element.GetCurrentPattern([Windows.Automation.ValuePattern]::Pattern)
1014
+ if (-not $pattern.Current.IsReadOnly) {
1015
+ $pattern.SetValue($Value)
1016
+ return ([string]$pattern.Current.Value -ceq $Value)
1017
+ }
1018
+ } catch {}
1019
+ $false
1020
+ }
1021
+
1022
+ function Get-CrossHandsRequiredNumber($Value, [string]$Name) {
1023
+ if ($null -eq $Value) { throw "$Name is required" }
1024
+ $number = [double]$Value
1025
+ if ([double]::IsNaN($number) -or [double]::IsInfinity($number)) {
1026
+ throw "$Name must be a finite number"
1027
+ }
1028
+ $number
1029
+ }
1030
+
1031
+ function Get-CrossHandsPositiveInteger($Value, [string]$Name) {
1032
+ if ($null -eq $Value) { $Value = 1 }
1033
+ $number = [int]$Value
1034
+ if ($number -le 0) { throw "$Name must be a positive integer" }
1035
+ $number
1036
+ }
1037
+
1038
+ function Get-CrossHandsPositiveNumber($Value, [string]$Name) {
1039
+ if ($null -eq $Value) { $Value = 1 }
1040
+ $number = Get-CrossHandsRequiredNumber $Value $Name
1041
+ if ($number -le 0) { throw "$Name must be a positive number" }
1042
+ $number
1043
+ }
1044
+
1045
+ function Get-CrossHandsRequiredString($Value, [string]$Name) {
1046
+ if ($null -eq $Value) { throw "$Name is required" }
1047
+ $text = [string]$Value
1048
+ if ($text.Length -eq 0) { throw "$Name is required" }
1049
+ $text
1050
+ }
1051
+
1052
+ function Get-CrossHandsScreenPoint($Operation, $WindowFrame) {
1053
+ if ($null -ne $Operation.element) {
1054
+ throw "stale element frame; run get-app-state again and use a fresh element index"
1055
+ }
1056
+ $x = Get-CrossHandsRequiredNumber $Operation.x "x"
1057
+ $y = Get-CrossHandsRequiredNumber $Operation.y "y"
1058
+ @{
1059
+ x = [int][Math]::Round($WindowFrame.x + $x)
1060
+ y = [int][Math]::Round($WindowFrame.y + $y)
1061
+ }
1062
+ }
1063
+
1064
+ function Get-CrossHandsElementScreenPoint($Element) {
1065
+ if ($null -eq $Element) { return $null }
1066
+ try {
1067
+ $rect = $Element.Current.BoundingRectangle
1068
+ if ($rect.Width -gt 0 -and $rect.Height -gt 0) {
1069
+ return @{
1070
+ x = [int][Math]::Round($rect.X + ($rect.Width / 2))
1071
+ y = [int][Math]::Round($rect.Y + ($rect.Height / 2))
1072
+ }
1073
+ }
1074
+ } catch {}
1075
+ $null
1076
+ }
1077
+
1078
+ function Send-CrossHandsMouseClick([IntPtr]$WindowHandle, [int]$ScreenX, [int]$ScreenY, [string]$Button, [int]$Count, $ModifierKeys) {
1079
+ [void][CrossHandsDesktopWin32]::SetForegroundWindow($WindowHandle)
1080
+ if (-not (Wait-CrossHandsWindowFocused $WindowHandle 500)) {
1081
+ throw "window_not_focused: foreground activation could not be verified before mouse input"
1082
+ }
1083
+ [void][CrossHandsDesktopWin32]::SetCursorPos($ScreenX, $ScreenY)
1084
+ $buttonName = if ([string]::IsNullOrWhiteSpace($Button)) { "left" } else { $Button.ToLowerInvariant() }
1085
+ switch ($buttonName) {
1086
+ "left" { $down = $MouseEvents.LeftDown; $up = $MouseEvents.LeftUp }
1087
+ "right" { $down = $MouseEvents.RightDown; $up = $MouseEvents.RightUp }
1088
+ "middle" { $down = $MouseEvents.MiddleDown; $up = $MouseEvents.MiddleUp }
1089
+ default { throw "unsupported mouse button: $Button" }
1090
+ }
1091
+
1092
+ $modifierKeys = @($ModifierKeys)
1093
+ try {
1094
+ foreach ($virtualKey in $modifierKeys) {
1095
+ [CrossHandsDesktopWin32]::keybd_event($virtualKey, 0, 0, [UIntPtr]::Zero)
1096
+ }
1097
+ for ($i = 0; $i -lt (Get-CrossHandsPositiveInteger $Count "click_count"); $i++) {
1098
+ [CrossHandsDesktopWin32]::mouse_event($down, 0, 0, 0, [UIntPtr]::Zero)
1099
+ Start-Sleep -Milliseconds 35
1100
+ [CrossHandsDesktopWin32]::mouse_event($up, 0, 0, 0, [UIntPtr]::Zero)
1101
+ }
1102
+ } finally {
1103
+ for ($i = $modifierKeys.Count - 1; $i -ge 0; $i--) {
1104
+ [CrossHandsDesktopWin32]::keybd_event($modifierKeys[$i], 0, 2, [UIntPtr]::Zero)
1105
+ }
1106
+ }
1107
+ }
1108
+
1109
+ function Send-CrossHandsDrag([IntPtr]$WindowHandle, $From, $To, [double]$DurationMs) {
1110
+ [void][CrossHandsDesktopWin32]::SetForegroundWindow($WindowHandle)
1111
+ if (-not (Wait-CrossHandsWindowFocused $WindowHandle 500)) {
1112
+ throw "window_not_focused: foreground activation could not be verified before drag input"
1113
+ }
1114
+ $startX = [int]$From.x
1115
+ $startY = [int]$From.y
1116
+ $endX = [int]$To.x
1117
+ $endY = [int]$To.y
1118
+ [void][CrossHandsDesktopWin32]::SetCursorPos($startX, $startY)
1119
+ [CrossHandsDesktopWin32]::mouse_event($MouseEvents.LeftDown, 0, 0, 0, [UIntPtr]::Zero)
1120
+ for ($step = 1; $step -le 12; $step++) {
1121
+ $x = [int][Math]::Round($startX + (($endX - $startX) * $step / 12))
1122
+ $y = [int][Math]::Round($startY + (($endY - $startY) * $step / 12))
1123
+ [void][CrossHandsDesktopWin32]::SetCursorPos($x, $y)
1124
+ Start-Sleep -Milliseconds ([Math]::Max(1, [Math]::Round($DurationMs / 12)))
1125
+ }
1126
+ [CrossHandsDesktopWin32]::mouse_event($MouseEvents.LeftUp, 0, 0, 0, [UIntPtr]::Zero)
1127
+ }
1128
+
1129
+ function Send-CrossHandsText([IntPtr]$WindowHandle, [string]$Text) {
1130
+ [void][CrossHandsDesktopWin32]::SetForegroundWindow($WindowHandle)
1131
+ $hasNonAscii = $false
1132
+ foreach ($character in $Text.ToCharArray()) {
1133
+ if ([int][char]$character -gt 0x7F) { $hasNonAscii = $true; break }
1134
+ }
1135
+ if ($hasNonAscii) {
1136
+ foreach ($character in $Text.ToCharArray()) {
1137
+ [void][CrossHandsDesktopWin32]::PostMessage($WindowHandle, $WindowsMessages.Char, [IntPtr][int][char]$character, [IntPtr]::Zero)
1138
+ Start-Sleep -Milliseconds 8
1139
+ }
1140
+ return
1141
+ }
1142
+ [System.Windows.Forms.SendKeys]::SendWait((ConvertTo-CrossHandsSendKeysText $Text))
1143
+ }
1144
+
1145
+ function Get-CrossHandsVirtualKey([string]$Key) {
1146
+ $normalized = $Key.ToLowerInvariant()
1147
+ $map = @{
1148
+ "return" = 0x0D; "enter" = 0x0D; "tab" = 0x09; "escape" = 0x1B; "esc" = 0x1B
1149
+ "backspace" = 0x08; "delete" = 0x2E; "space" = 0x20; "left" = 0x25
1150
+ "up" = 0x26; "right" = 0x27; "down" = 0x28; "home" = 0x24; "end" = 0x23
1151
+ }
1152
+ if ($map.ContainsKey($normalized)) { return $map[$normalized] }
1153
+ if ($normalized.Length -eq 1) { return [int][char]$normalized.ToUpperInvariant()[0] }
1154
+ throw "Unsupported key: $Key"
1155
+ }
1156
+
1157
+ function Send-CrossHandsKey([IntPtr]$WindowHandle, [string]$Key) {
1158
+ [void][CrossHandsDesktopWin32]::SetForegroundWindow($WindowHandle)
1159
+ [System.Windows.Forms.SendKeys]::SendWait((ConvertTo-CrossHandsSendKeysKey $Key))
1160
+ }
1161
+
1162
+ function Get-CrossHandsModifierKind([string]$Modifier) {
1163
+ switch ($Modifier.ToLowerInvariant()) {
1164
+ { $_ -in @("ctrl", "control", "cmdorctrl", "commandorcontrol") } { return "ctrl" }
1165
+ { $_ -in @("shift") } { return "shift" }
1166
+ { $_ -in @("alt", "option") } { return "alt" }
1167
+ { $_ -in @("meta", "super", "win", "cmd", "command") } { return "win" }
1168
+ default { throw "Unsupported modifier: $Modifier" }
1169
+ }
1170
+ }
1171
+
1172
+ function Get-CrossHandsClickModifierKeys($Modifiers) {
1173
+ $keys = @()
1174
+ if ($null -eq $Modifiers) { return $keys }
1175
+ foreach ($modifier in @($Modifiers)) {
1176
+ if ($null -eq $modifier -or [string]::IsNullOrWhiteSpace([string]$modifier)) { continue }
1177
+ $keys += [byte](Get-CrossHandsModifierVirtualKey ([string]$modifier))
1178
+ }
1179
+ return $keys
1180
+ }
1181
+
1182
+ function Get-CrossHandsModifierVirtualKey([string]$Modifier) {
1183
+ switch (Get-CrossHandsModifierKind $Modifier) {
1184
+ "ctrl" { return 0x11 }
1185
+ "shift" { return 0x10 }
1186
+ "alt" { return 0x12 }
1187
+ "win" { return 0x5B }
1188
+ default { throw "Unsupported modifier: $Modifier" }
1189
+ }
1190
+ }
1191
+
1192
+ function Send-CrossHandsHotkey([IntPtr]$WindowHandle, [string]$KeySpec) {
1193
+ $parts = @($KeySpec.Split("+") | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
1194
+ if ($parts.Count -eq 0) { throw "Unsupported key: $KeySpec" }
1195
+ $key = $parts[$parts.Count - 1]
1196
+ $prefix = ""
1197
+ if ($parts.Count -gt 1) {
1198
+ foreach ($modifier in $parts[0..($parts.Count - 2)]) {
1199
+ $prefix += ConvertTo-CrossHandsSendKeysModifier $modifier
1200
+ }
1201
+ }
1202
+ [void][CrossHandsDesktopWin32]::SetForegroundWindow($WindowHandle)
1203
+ [System.Windows.Forms.SendKeys]::SendWait($prefix + (ConvertTo-CrossHandsSendKeysKey $key))
1204
+ }
1205
+
1206
+ function ConvertTo-CrossHandsSendKeysText([string]$Text) {
1207
+ $builder = New-Object System.Text.StringBuilder
1208
+ foreach ($character in $Text.ToCharArray()) {
1209
+ $value = [string]$character
1210
+ if ($value -eq "`r") { continue }
1211
+ if ($value -eq "`n") { [void]$builder.Append("{ENTER}"); continue }
1212
+ if ("+^%~(){}[]".Contains($value)) {
1213
+ [void]$builder.Append("{").Append($value).Append("}")
1214
+ } else {
1215
+ [void]$builder.Append($value)
1216
+ }
1217
+ }
1218
+ $builder.ToString()
1219
+ }
1220
+
1221
+ function ConvertTo-CrossHandsSendKeysKey([string]$Key) {
1222
+ switch ($Key.ToLowerInvariant()) {
1223
+ { $_ -in @("return", "enter") } { return "{ENTER}" }
1224
+ "tab" { return "{TAB}" }
1225
+ { $_ -in @("escape", "esc") } { return "{ESC}" }
1226
+ "backspace" { return "{BACKSPACE}" }
1227
+ "delete" { return "{DELETE}" }
1228
+ "space" { return " " }
1229
+ "left" { return "{LEFT}" }
1230
+ "up" { return "{UP}" }
1231
+ "right" { return "{RIGHT}" }
1232
+ "down" { return "{DOWN}" }
1233
+ "home" { return "{HOME}" }
1234
+ "end" { return "{END}" }
1235
+ { $_ -in @("pageup", "page_up") } { return "{PGUP}" }
1236
+ { $_ -in @("pagedown", "page_down") } { return "{PGDN}" }
1237
+ "insert" { return "{INSERT}" }
1238
+ default {
1239
+ if ($Key.Length -eq 1) { return (ConvertTo-CrossHandsSendKeysText $Key) }
1240
+ throw "Unsupported key: $Key"
1241
+ }
1242
+ }
1243
+ }
1244
+
1245
+ function ConvertTo-CrossHandsSendKeysModifier([string]$Modifier) {
1246
+ switch (Get-CrossHandsModifierKind $Modifier) {
1247
+ "ctrl" { return "^" }
1248
+ "shift" { return "+" }
1249
+ "alt" { return "%" }
1250
+ default { throw "Unsupported modifier: $Modifier" }
1251
+ }
1252
+ }
1253
+
1254
+ function Send-CrossHandsPasteText([IntPtr]$WindowHandle, [string]$Text) {
1255
+ $previous = $null
1256
+ $hadPrevious = $false
1257
+ try { $previous = [System.Windows.Forms.Clipboard]::GetDataObject() } catch {}
1258
+ $hadPrevious = $null -ne $previous
1259
+ try {
1260
+ Set-Clipboard -Value $Text
1261
+ Send-CrossHandsHotkey $WindowHandle "Ctrl+v"
1262
+ } finally {
1263
+ if ($hadPrevious) {
1264
+ try { [System.Windows.Forms.Clipboard]::SetDataObject($previous, $true) } catch {}
1265
+ } else {
1266
+ try { [System.Windows.Forms.Clipboard]::Clear() } catch {}
1267
+ }
1268
+ }
1269
+ }
1270
+
1271
+ function Invoke-CrossHandsOperation($Operation) {
1272
+ Assert-CrossHandsInteractiveSession
1273
+ $includeScreenshot = -not [bool]$Operation.noScreenshot
1274
+ if ($Operation.tool -eq "handshake") {
1275
+ return [pscustomobject]@{ ok = $true; capabilities = Get-CrossHandsHandshake }
1276
+ }
1277
+ if ($Operation.tool -eq "list_apps") {
1278
+ return [pscustomobject]@{ ok = $true; apps = @(Get-CrossHandsAppList) }
1279
+ }
1280
+ if ($Operation.tool -eq "list_windows") {
1281
+ $list = Get-CrossHandsWindowList $Operation.app
1282
+ return [pscustomobject]@{ ok = $true; app = $list.app; windows = @($list.windows) }
1283
+ }
1284
+ if ($Operation.tool -eq "get_app_state") {
1285
+ return [pscustomobject]@{ ok = $true; snapshot = New-CrossHandsSnapshot $Operation.app $includeScreenshot $Operation.windowId $Operation.windowIndex ([bool]$Operation.restoreWindow) }
1286
+ }
1287
+ if ($Operation.tool -eq "inspect_target") {
1288
+ $inspected = Find-CrossHandsProcess $Operation.app
1289
+ $identity = Assert-CrossHandsProcessIdentity $inspected $null
1290
+ return [pscustomobject]@{
1291
+ ok = $true
1292
+ identity = $identity
1293
+ appId = [string]$inspected.ProcessName
1294
+ windowId = Get-CrossHandsWindowId $inspected
1295
+ windowTitle = [string]$inspected.MainWindowTitle
1296
+ }
1297
+ }
1298
+
1299
+ $process = Find-CrossHandsProcess $Operation.app
1300
+ $verifiedIdentity = Assert-CrossHandsProcessIdentity $process $Operation.expectedIdentity
1301
+ if ([bool]$Operation.restoreWindow) { Restore-CrossHandsWindow $process }
1302
+ Assert-CrossHandsWindowTarget $process $Operation.windowId $Operation.windowIndex
1303
+ $root = Get-CrossHandsRootElement $process
1304
+ $windowFrame = if ($null -ne $Operation.windowBounds) { $Operation.windowBounds } else { Get-CrossHandsWindowFrame $process $root }
1305
+ $element = Find-CrossHandsElement $root $Operation.element
1306
+ $fromElement = Find-CrossHandsElement $root $Operation.fromElement
1307
+ $toElement = Find-CrossHandsElement $root $Operation.toElement
1308
+ $handle = [IntPtr]$process.MainWindowHandle
1309
+ if ($Operation.tool -in @("type_text", "press_key", "hotkey", "paste_text")) {
1310
+ Assert-CrossHandsKeyboardFocus $handle $Operation
1311
+ }
1312
+ $action = $null
1313
+
1314
+ switch ($Operation.tool) {
1315
+ "click" {
1316
+ # Why: agents expect a click into a target app to make the next
1317
+ # keyboard action safe, even when UI Automation handles the click.
1318
+ Restore-CrossHandsWindow $process
1319
+ $handledByPattern = $false
1320
+ $clickCount = Get-CrossHandsPositiveInteger $Operation.click_count "click_count"
1321
+ $modifierKeys = @(Get-CrossHandsClickModifierKeys $Operation.modifiers)
1322
+ $hasModifiers = $modifierKeys.Count -gt 0
1323
+ if ($null -ne $element -and -not $hasModifiers -and $Operation.mouse_button -ne "right" -and $Operation.mouse_button -ne "middle" -and $clickCount -le 1) {
1324
+ $handledByPattern = Invoke-CrossHandsPrimaryAction $element
1325
+ }
1326
+ if (-not $handledByPattern) {
1327
+ $point = Get-CrossHandsElementScreenPoint $element
1328
+ if ($null -eq $point) { $point = Get-CrossHandsScreenPoint $Operation $windowFrame }
1329
+ Send-CrossHandsMouseClick $handle $point.x $point.y $Operation.mouse_button $clickCount $modifierKeys
1330
+ $action = [pscustomobject]@{ path = "synthetic"; actionName = $null; fallbackReason = $(if ($hasModifiers) { "modifiersRequireSynthetic" } else { "actionUnsupported" }) }
1331
+ } else {
1332
+ $action = [pscustomobject]@{ path = "accessibility"; actionName = "primaryAction"; fallbackReason = $null }
1333
+ }
1334
+ }
1335
+ "perform_secondary_action" {
1336
+ if ($null -eq $element) { throw "unknown element_index" }
1337
+ if (-not (Invoke-CrossHandsNamedAction $element $Operation.action)) {
1338
+ throw "$($Operation.action) is not a valid secondary action"
1339
+ }
1340
+ $action = [pscustomobject]@{ path = "accessibility"; actionName = $Operation.action; fallbackReason = $null }
1341
+ }
1342
+ "scroll" {
1343
+ $delta = 120 * [int][Math]::Ceiling((Get-CrossHandsPositiveNumber $Operation.pages "pages"))
1344
+ if ($Operation.direction -eq "down" -or $Operation.direction -eq "right") {
1345
+ $delta = -1 * $delta
1346
+ } elseif ($Operation.direction -ne "up" -and $Operation.direction -ne "left") {
1347
+ throw "unsupported scroll direction: $($Operation.direction)"
1348
+ }
1349
+ $point = Get-CrossHandsElementScreenPoint $element
1350
+ if ($null -eq $point) { $point = Get-CrossHandsScreenPoint $Operation $windowFrame }
1351
+ [void][CrossHandsDesktopWin32]::SetForegroundWindow($handle)
1352
+ [void][CrossHandsDesktopWin32]::SetCursorPos([int]$point.x, [int]$point.y)
1353
+ $wheel = if ($Operation.direction -eq "left" -or $Operation.direction -eq "right") { $MouseEvents.Hwheel } else { $MouseEvents.Wheel }
1354
+ [CrossHandsDesktopWin32]::mouse_event($wheel, 0, 0, $delta, [UIntPtr]::Zero)
1355
+ $action = [pscustomobject]@{ path = "synthetic"; actionName = "scroll"; fallbackReason = $null }
1356
+ }
1357
+ "drag" {
1358
+ $from = Get-CrossHandsElementScreenPoint $fromElement
1359
+ if ($null -eq $from -and $null -ne $Operation.fromElement) { throw "stale element frame; run get-app-state again and use a fresh element index" }
1360
+ if ($null -eq $from) {
1361
+ $from = @{
1362
+ x = $windowFrame.x + (Get-CrossHandsRequiredNumber $Operation.from_x "from_x")
1363
+ y = $windowFrame.y + (Get-CrossHandsRequiredNumber $Operation.from_y "from_y")
1364
+ }
1365
+ }
1366
+ $to = Get-CrossHandsElementScreenPoint $toElement
1367
+ if ($null -eq $to -and $null -ne $Operation.toElement) { throw "stale element frame; run get-app-state again and use a fresh element index" }
1368
+ if ($null -eq $to) {
1369
+ $to = @{
1370
+ x = $windowFrame.x + (Get-CrossHandsRequiredNumber $Operation.to_x "to_x")
1371
+ y = $windowFrame.y + (Get-CrossHandsRequiredNumber $Operation.to_y "to_y")
1372
+ }
1373
+ }
1374
+ $durationMs = if ($null -eq $Operation.duration_ms) { 240 } else { [Math]::Min(30000, (Get-CrossHandsPositiveNumber $Operation.duration_ms "duration_ms")) }
1375
+ Send-CrossHandsDrag $handle $from $to $durationMs
1376
+ $action = [pscustomobject]@{ path = "synthetic"; actionName = "drag"; fallbackReason = $null }
1377
+ }
1378
+ "type_text" {
1379
+ Send-CrossHandsText $handle (Get-CrossHandsRequiredString $Operation.text "text")
1380
+ $action = [pscustomobject]@{ path = "synthetic"; actionName = "typeText"; fallbackReason = $null; verification = [pscustomobject]@{ state = "unverified"; reason = "synthetic_input" } }
1381
+ }
1382
+ "press_key" {
1383
+ Send-CrossHandsKey $handle (Get-CrossHandsRequiredString $Operation.key "key")
1384
+ $action = [pscustomobject]@{ path = "synthetic"; actionName = "pressKey"; fallbackReason = $null; verification = [pscustomobject]@{ state = "unverified"; reason = "synthetic_input" } }
1385
+ }
1386
+ "hotkey" {
1387
+ Send-CrossHandsHotkey $handle (Get-CrossHandsRequiredString $Operation.key "key")
1388
+ $action = [pscustomobject]@{ path = "synthetic"; actionName = "hotkey"; fallbackReason = $null; verification = [pscustomobject]@{ state = "unverified"; reason = "synthetic_input" } }
1389
+ }
1390
+ "paste_text" {
1391
+ Send-CrossHandsPasteText $handle (Get-CrossHandsRequiredString $Operation.text "text")
1392
+ $action = [pscustomobject]@{ path = "clipboard"; actionName = "paste"; fallbackReason = $null; verification = [pscustomobject]@{ state = "unverified"; reason = "clipboard_paste" } }
1393
+ }
1394
+ "set_value" {
1395
+ if ($null -eq $element -or -not (Set-CrossHandsElementValue $element ([string]$Operation.value))) {
1396
+ throw "element value is not settable"
1397
+ }
1398
+ $action = [pscustomobject]@{ path = "accessibility"; actionName = "setValue"; fallbackReason = $null; verification = [pscustomobject]@{ state = "verified"; reason = "value_pattern_readback" } }
1399
+ }
1400
+ default {
1401
+ throw "unsupported tool: $($Operation.tool)"
1402
+ }
1403
+ }
1404
+
1405
+ try {
1406
+ $snapshot = New-CrossHandsSnapshot $Operation.app $includeScreenshot $Operation.windowId $Operation.windowIndex
1407
+ } catch {
1408
+ if ($null -eq $Operation.windowId -and $null -eq $Operation.windowIndex) { throw }
1409
+ if ($null -eq $action.verification) {
1410
+ $action | Add-Member -NotePropertyName verification -NotePropertyValue ([pscustomobject]@{ state = "unverified"; reason = "window_changed" })
1411
+ }
1412
+ $snapshot = New-CrossHandsSnapshot $Operation.app $includeScreenshot $null $null
1413
+ }
1414
+ [pscustomobject]@{ ok = $true; action = $action; snapshot = $snapshot; processIdentity = $verifiedIdentity }
1415
+ }
1416
+
1417
+ Write-CrossHandsJson ([pscustomobject]@{ ok = $true; ready = $true; capabilities = Get-CrossHandsHandshake })
1418
+ while ($null -ne ($line = [Console]::In.ReadLine())) {
1419
+ if ([string]::IsNullOrWhiteSpace($line)) { continue }
1420
+ try {
1421
+ $operation = $line | ConvertFrom-Json
1422
+ Write-CrossHandsJson (Invoke-CrossHandsOperation $operation)
1423
+ } catch {
1424
+ Write-CrossHandsJson ([pscustomobject]@{ ok = $false; error = [string]$_.Exception.Message })
1425
+ }
1426
+ }