$raw = [Environment]::GetCommandLineArgs() $scriptIndex = -1 $scriptName = [IO.Path]::GetFileName($PSCommandPath) for ($i = 0; $i -lt $raw.Count; $i++) { try { if ($scriptIndex -lt 0 -and [IO.Path]::GetFileName([string]$raw[$i]) -eq $scriptName) { $scriptIndex = $i } } catch {} } $argv = @() if ($scriptIndex -ge 0) { for ($i = $scriptIndex + 1; $i -lt $raw.Count; $i++) { $argv += [string] $raw[$i] } } $Command = 'help' $Rest = @() if ($argv.Count -gt 0) { $Command = [string] $argv[0] if ($argv.Count -gt 1) { $Rest = @($argv[1..($argv.Count - 1)]) } } $ErrorActionPreference = 'Stop' $script:win32DriveStart = [Diagnostics.Stopwatch]::StartNew() # Assemblies are loaded on demand: list/focus/window/uia never pay for WinForms. function Import-Drawing { if (-not $script:win32DriveDrawing) { Add-Type -AssemblyName System.Drawing; $script:win32DriveDrawing = $true } } function Import-Forms { if (-not $script:win32DriveForms) { Import-Drawing; Add-Type -AssemblyName System.Windows.Forms; $script:win32DriveForms = $true } } $script:win32DriveNativeSource = @" using System; using System.Text; using System.Runtime.InteropServices; public static class Win32DriveNative { public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam); [StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left; public int Top; public int Right; public int Bottom; } [StructLayout(LayoutKind.Sequential)] public struct POINT { public int X; public int Y; } [StructLayout(LayoutKind.Sequential)] public struct LASTINPUTINFO { public uint cbSize; public uint dwTime; } [DllImport("user32.dll")] public static extern bool EnumWindows(EnumWindowsProc cb, IntPtr lParam); [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd); [DllImport("user32.dll")] public static extern bool IsWindow(IntPtr hWnd); [DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count); [DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetClassName(IntPtr hWnd, StringBuilder text, int count); [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint pid); [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect); [DllImport("dwmapi.dll")] public static extern int DwmGetWindowAttribute(IntPtr hwnd, int attr, out RECT rect, int size); [DllImport("user32.dll")] public static extern bool PrintWindow(IntPtr hwnd, IntPtr hdcBlt, uint flags); [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd); [DllImport("user32.dll")] public static extern bool MoveWindow(IntPtr hWnd, int x, int y, int w, int h, bool repaint); [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int cmd); [DllImport("user32.dll")] public static extern bool PostMessage(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam); [DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y); [DllImport("user32.dll")] public static extern void mouse_event(uint flags, uint dx, uint dy, uint data, UIntPtr extraInfo); [DllImport("user32.dll")] public static extern bool GetCursorPos(out POINT point); [DllImport("user32.dll")] public static extern bool GetLastInputInfo(ref LASTINPUTINFO info); [DllImport("user32.dll", CharSet=CharSet.Unicode, SetLastError=true)] public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint msg, UIntPtr wParam, string lParam, uint flags, uint timeout, out UIntPtr result); [DllImport("user32.dll", CharSet=CharSet.Unicode, SetLastError=true, EntryPoint="SendMessageTimeoutW")] public static extern IntPtr SendMessageTimeoutPtr(IntPtr hWnd, uint msg, UIntPtr wParam, IntPtr lParam, uint flags, uint timeout, out UIntPtr result); [DllImport("user32.dll", CharSet=CharSet.Unicode, SetLastError=true, EntryPoint="SendMessageTimeoutW")] public static extern IntPtr SendMessageTimeoutText(IntPtr hWnd, uint msg, UIntPtr wParam, StringBuilder lParam, uint flags, uint timeout, out UIntPtr result); [DllImport("kernel32.dll")] public static extern uint GetTickCount(); [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr hWnd); [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); [DllImport("user32.dll", SetLastError=true)] public static extern bool SetProcessDpiAwarenessContext(IntPtr value); [DllImport("shcore.dll")] public static extern int SetProcessDpiAwareness(int value); [DllImport("user32.dll")] public static extern bool SetProcessDPIAware(); [DllImport("user32.dll")] public static extern bool BringWindowToTop(IntPtr hWnd); [DllImport("oleacc.dll")] public static extern int AccessibleObjectFromWindow(IntPtr hWnd, uint objectId, ref Guid iid, [MarshalAs(UnmanagedType.IUnknown)] out object accessible); [DllImport("user32.dll")] public static extern bool IsHungAppWindow(IntPtr hWnd); [DllImport("user32.dll", EntryPoint="SystemParametersInfoW")] static extern bool SystemParametersInfoGet(uint action, uint param, ref uint value, uint winIni); [DllImport("user32.dll", EntryPoint="SystemParametersInfoW")] static extern bool SystemParametersInfoSet(uint action, uint param, IntPtr value, uint winIni); // Activation is asynchronous across processes, so every attempt has to be // confirmed by polling instead of by an immediate GetForegroundWindow. public static bool WaitForeground(IntPtr hWnd, int waitMs) { for (int waited = 0; ; waited += 40) { if (GetForegroundWindow() == hWnd) { return true; } if (waited >= waitMs) { return false; } System.Threading.Thread.Sleep(40); } } public static bool Foreground(IntPtr hWnd, int waitMs) { if (GetForegroundWindow() == hWnd) { return true; } BringWindowToTop(hWnd); SetForegroundWindow(hWnd); return WaitForeground(hWnd, waitMs); } // Windows refuses SetForegroundWindow from a background process while the // foreground lock timeout is running. Relaxing that timeout around a single // attempt is the safe workaround: AttachThreadInput would couple our input // queue to the foreground application and can hang it. The parameter is // restored only after activation settled, and without SPIF_SENDCHANGE so no // WM_SETTINGCHANGE storm hits every window on the desktop. public static uint GetForegroundLockTimeout() { uint value = 0; SystemParametersInfoGet(0x2000, 0, ref value, 0); return value; } public static bool ForegroundUnlocked(IntPtr hWnd, int waitMs) { const uint SPI_GETFOREGROUNDLOCKTIMEOUT = 0x2000; const uint SPI_SETFOREGROUNDLOCKTIMEOUT = 0x2001; uint previous = 0; if (!SystemParametersInfoGet(SPI_GETFOREGROUNDLOCKTIMEOUT, 0, ref previous, 0) || previous == 0) { return false; } SystemParametersInfoSet(SPI_SETFOREGROUNDLOCKTIMEOUT, 0, IntPtr.Zero, 0); try { return Foreground(hWnd, waitMs); } finally { SystemParametersInfoSet(SPI_SETFOREGROUNDLOCKTIMEOUT, 0, new IntPtr(previous), 0); } } [DllImport("user32.dll", EntryPoint="SetWindowLongPtr")] static extern IntPtr SetWindowLongPtr64(IntPtr hWnd, int index, IntPtr value); [DllImport("user32.dll", EntryPoint="GetWindowLongPtr")] static extern IntPtr GetWindowLongPtr64(IntPtr hWnd, int index); [DllImport("user32.dll", EntryPoint="SetWindowLong")] static extern int SetWindowLong32(IntPtr hWnd, int index, int value); [DllImport("user32.dll", EntryPoint="GetWindowLong")] static extern int GetWindowLong32(IntPtr hWnd, int index); // Keeps the guard overlay out of the activation chain: without this the // overlay steals foreground from the window the command is about to drive. public static void AddExStyle(IntPtr hWnd, int bits) { const int GWL_EXSTYLE = -20; if (IntPtr.Size == 8) { long current = GetWindowLongPtr64(hWnd, GWL_EXSTYLE).ToInt64(); SetWindowLongPtr64(hWnd, GWL_EXSTYLE, new IntPtr(current | (long) (uint) bits)); } else { int current = GetWindowLong32(hWnd, GWL_EXSTYLE); SetWindowLong32(hWnd, GWL_EXSTYLE, current | bits); } } [StructLayout(LayoutKind.Sequential)] public struct KEYBDINPUT64 { public ushort wVk; public ushort wScan; public uint dwFlags; public uint time; public ulong dwExtraInfo; } [StructLayout(LayoutKind.Sequential, Size=40)] public struct INPUT64 { public uint type; public uint pad; public KEYBDINPUT64 ki; } [StructLayout(LayoutKind.Sequential)] public struct KEYBDINPUT32 { public ushort wVk; public ushort wScan; public uint dwFlags; public uint time; public uint dwExtraInfo; } [StructLayout(LayoutKind.Sequential, Size=28)] public struct INPUT32 { public uint type; public KEYBDINPUT32 ki; } [DllImport("user32.dll", SetLastError=true)] static extern uint SendInput(uint count, INPUT64[] inputs, int size); [DllImport("user32.dll", SetLastError=true, EntryPoint="SendInput")] static extern uint SendInput32(uint count, INPUT32[] inputs, int size); public static int TypeUnicode(string text) { const uint KEYEVENTF_KEYUP = 0x0002; const uint KEYEVENTF_UNICODE = 0x0004; const ushort VK_RETURN = 0x0D; int sent = 0; foreach (char ch in text) { if (ch == '\r') { continue; } bool enter = ch == '\n'; ushort vk = enter ? VK_RETURN : (ushort) 0; ushort scan = enter ? (ushort) 0 : (ushort) ch; uint flags = enter ? 0u : KEYEVENTF_UNICODE; if (IntPtr.Size == 8) { INPUT64[] events = new INPUT64[2]; events[0].type = 1; events[0].ki.wVk = vk; events[0].ki.wScan = scan; events[0].ki.dwFlags = flags; events[1] = events[0]; events[1].ki.dwFlags = flags | KEYEVENTF_KEYUP; sent += (int) SendInput(2, events, Marshal.SizeOf(typeof(INPUT64))); } else { INPUT32[] events = new INPUT32[2]; events[0].type = 1; events[0].ki.wVk = vk; events[0].ki.wScan = scan; events[0].ki.dwFlags = flags; events[1] = events[0]; events[1].ki.dwFlags = flags | KEYEVENTF_KEYUP; sent += (int) SendInput32(2, events, Marshal.SizeOf(typeof(INPUT32))); } } return sent; } } "@ # Compiling this P/Invoke surface costs about a second and spawns csc.exe, which # flashes a console window. Cache the assembly per source hash and reuse it. function Import-Native([string] $Source) { if ('Win32DriveNative' -as [type]) { return 'loaded' } $sha = [Security.Cryptography.SHA256]::Create() try { $hash = ([BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($Source))) -replace '-', '').Substring(0, 16) } finally { $sha.Dispose() } $dir = Join-Path $env:LOCALAPPDATA 'win32-drive' $dll = Join-Path $dir "Win32DriveNative-$hash.dll" if (Test-Path $dll) { try { Add-Type -Path $dll return 'cache' } catch {} } if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null } $staging = Join-Path $dir ('build-' + [guid]::NewGuid().ToString('N') + '.dll') Add-Type -TypeDefinition $Source -OutputAssembly $staging -OutputType Library try { Move-Item -LiteralPath $staging -Destination $dll -Force } catch { Remove-Item -LiteralPath $staging -Force -ErrorAction SilentlyContinue } if (Test-Path $dll) { Add-Type -Path $dll } else { Add-Type -TypeDefinition $Source } 'compiled' } $script:win32DriveNativeLoad = Import-Native $script:win32DriveNativeSource function Set-DpiAwareness { try { if ([Win32DriveNative]::SetProcessDpiAwarenessContext([IntPtr] (-4))) { return 'per-monitor-v2' } } catch {} try { if ([Win32DriveNative]::SetProcessDpiAwareness(2) -eq 0) { return 'per-monitor' } } catch {} try { [void] [Win32DriveNative]::SetProcessDPIAware(); return 'system' } catch {} 'unaware' } $script:win32DriveDpi = Set-DpiAwareness function Write-Json($Value) { $Value | ConvertTo-Json -Depth 24 -Compress } # Errors carry a stable code, a hint, and whatever context saves the agent a # follow-up query (candidate windows, foreground state, idle time). function Fail([string] $Code, [string] $Message, [string] $Hint, $Extra) { $payload = [ordered] @{ ok = $false; error = $Message; code = $Code } if ($Hint) { $payload['hint'] = $Hint } if ($Extra) { foreach ($key in $Extra.Keys) { $payload[$key] = $Extra[$key] } } $script:win32DriveFailure = $payload throw $Message } function Get-FailurePayload([string] $Name, $ErrorRecord) { $message = $ErrorRecord.Exception.Message $payload = if ($script:win32DriveFailure -and $script:win32DriveFailure['error'] -eq $message) { $script:win32DriveFailure } else { [ordered] @{ ok = $false; error = $message; code = 'unexpected' } } $script:win32DriveFailure = $null $payload['cmd'] = $Name [pscustomobject] $payload } $script:win32DriveValueOptions = @('Hwnd','Process','TitleContains','X','Y','Width','Height','Out','Text','Keys','Action','Button','Count','DurationMs','IdleMs','TimeoutMs','CountdownMs','Set','Depth','Mode','Scope','Focus','Cursor','Return','Refresh','Method','Name','AutomationId','ClassName','ControlType','Index','Value','Json','File') $script:win32DriveFlagOptions = @('Pointer','ContinueOnError','NoCountdown') function Parse-Options([string[]] $ArgList) { $opts = @{} $pos = @() for ($i = 0; $i -lt $ArgList.Count; $i++) { $arg = $ArgList[$i] if ($arg.StartsWith('-')) { $key = $arg.TrimStart('-') if ($script:win32DriveFlagOptions -contains $key) { $opts[$key] = $true; continue } if ($script:win32DriveValueOptions -notcontains $key) { Fail 'unknown-option' "Unknown option: -$key" 'run help for the option catalog' @{ knownOptions = @($script:win32DriveValueOptions + $script:win32DriveFlagOptions) } } if ($i + 1 -ge $ArgList.Count) { Fail 'missing-value' "Missing value for -$key" "pass a value after -$key" } $i++ $opts[$key] = $ArgList[$i] } else { $pos += $arg } } if ($pos.Count -gt 0) { Fail 'positional-args' ('Unexpected positional args: ' + ($pos -join ' ')) 'every input is a named option, for example -Text hello' } $opts } function Int-Opt($Opts, [string] $Name, $Default = $null) { if (-not $Opts.ContainsKey($Name)) { return $Default } [int] $Opts[$Name] } function Long-Opt($Opts, [string] $Name, $Default = $null) { if (-not $Opts.ContainsKey($Name)) { return $Default } [int64] $Opts[$Name] } function Str-Opt($Opts, [string] $Name, $Default = $null) { if (-not $Opts.ContainsKey($Name)) { return $Default } [string] $Opts[$Name] } function Get-ProcessNames { if ($null -ne $script:win32DriveProcessNames) { return $script:win32DriveProcessNames } $map = @{} foreach ($proc in (Get-Process -ErrorAction SilentlyContinue)) { $map[[int64] $proc.Id] = $proc.ProcessName } $script:win32DriveProcessNames = $map $map } function Get-Windows { $script:win32DriveWindows = @() $script:win32DriveForeground = [Win32DriveNative]::GetForegroundWindow() $names = Get-ProcessNames [Win32DriveNative]::EnumWindows({ param($h, $l) if ([Win32DriveNative]::IsWindowVisible($h)) { $sb = [Text.StringBuilder]::new(1024) [void] [Win32DriveNative]::GetWindowText($h, $sb, $sb.Capacity) $title = $sb.ToString() if ($title.Trim().Length -gt 0) { [uint32] $procId = 0 [void] [Win32DriveNative]::GetWindowThreadProcessId($h, [ref] $procId) $rect = Get-Rect $h $script:win32DriveWindows += [pscustomobject] [ordered] @{ hwnd = $h.ToInt64() pid = [int64] $procId process = $names[[int64] $procId] title = $title x = $rect.Left y = $rect.Top width = $rect.Right - $rect.Left height = $rect.Bottom - $rect.Top z = $script:win32DriveWindows.Count minimized = [bool] [Win32DriveNative]::IsIconic($h) focused = $h -eq $script:win32DriveForeground } } } $true }, [IntPtr]::Zero) | Out-Null $script:win32DriveWindows } function Get-Rect([IntPtr] $Hwnd) { $rect = New-Object Win32DriveNative+RECT $size = [Runtime.InteropServices.Marshal]::SizeOf([type] [Win32DriveNative+RECT]) $hr = [Win32DriveNative]::DwmGetWindowAttribute($Hwnd, 9, [ref] $rect, $size) if ($hr -ne 0) { [void] [Win32DriveNative]::GetWindowRect($Hwnd, [ref] $rect) } $rect } function Get-RawRect([IntPtr] $Hwnd) { $rect = New-Object Win32DriveNative+RECT [void] [Win32DriveNative]::GetWindowRect($Hwnd, [ref] $rect) $rect } function Has-Selector($Opts) { $Opts.ContainsKey('Hwnd') -or $Opts.ContainsKey('Process') -or $Opts.ContainsKey('TitleContains') } function Get-FocusMode($Opts) { $focus = (Str-Opt $Opts 'Focus' 'require').ToLowerInvariant() if (@('auto','require','skip') -notcontains $focus) { Fail 'invalid-value' 'Focus must be auto, require, or skip' 'require uses activation APIs only, auto adds a caption click, skip does not focus' } if ($focus -eq 'auto' -and -not $Opts.ContainsKey('Pointer')) { Fail 'pointer-consent' 'Focus auto falls back to clicking the target caption' 'ask the user first, then pass -Pointer' } $focus } function Get-ForegroundInfo { $h = [Win32DriveNative]::GetForegroundWindow() $title = '' [uint32] $procId = 0 if ($h -ne [IntPtr]::Zero) { $sb = [Text.StringBuilder]::new(1024) [void] [Win32DriveNative]::GetWindowText($h, $sb, $sb.Capacity) $title = $sb.ToString() [void] [Win32DriveNative]::GetWindowThreadProcessId($h, [ref] $procId) } $proc = (Get-ProcessNames)[[int64] $procId] $hung = $false try { $hung = [bool] [Win32DriveNative]::IsHungAppWindow($h) } catch {} [pscustomobject] [ordered] @{ hwnd = $h.ToInt64() pid = [int64] $procId process = $proc title = $title hung = $hung } } function Invoke-PointerClick([int] $X, [int] $Y, [string] $Button = 'left', [int] $Count = 1, [bool] $RestoreCursor = $true) { $origin = New-Object Win32DriveNative+POINT [void] [Win32DriveNative]::GetCursorPos([ref] $origin) [void] [Win32DriveNative]::SetCursorPos($X, $Y) $down = if ($Button -eq 'right') { 0x0008 } else { 0x0002 } $up = if ($Button -eq 'right') { 0x0010 } else { 0x0004 } for ($i = 0; $i -lt $Count; $i++) { [Win32DriveNative]::mouse_event($down, 0, 0, 0, [UIntPtr]::Zero) Start-Sleep -Milliseconds 45 [Win32DriveNative]::mouse_event($up, 0, 0, 0, [UIntPtr]::Zero) Start-Sleep -Milliseconds 80 } if ($RestoreCursor) { [void] [Win32DriveNative]::SetCursorPos($origin.X, $origin.Y) } } # Activation APIs are denied to background processes on locked-down foreground # settings, but real pointer input always activates the window it lands on. function Invoke-CaptionClick([IntPtr] $Hwnd) { $r = Get-Rect $Hwnd $width = $r.Right - $r.Left if ($width -le 0) { return $false } Invoke-PointerClick ([int] ($r.Left + $width * 0.35)) ([int] ($r.Top + 6)) 'left' 1 $true [Win32DriveNative]::WaitForeground($Hwnd, 800) } function Set-Foreground([IntPtr] $Hwnd, [string] $Mode = 'auto', [int] $TimeoutMs = 2500) { if ([Win32DriveNative]::IsIconic($Hwnd)) { Fail 'window-minimized' 'Target window is minimized' ('restore it first: window -Hwnd ' + $Hwnd.ToInt64() + ' -Action restore') @{ hwnd = $Hwnd.ToInt64() } } $deadline = (Get-Date).AddMilliseconds($TimeoutMs) do { # Windows denies activation to background processes while the user is # actually typing or moving the mouse, so only try during input gaps. if ((Get-IdleMs) -ge 250) { if ([Win32DriveNative]::Foreground($Hwnd, 300)) { return } if ([Win32DriveNative]::ForegroundUnlocked($Hwnd, 400)) { return } } Start-Sleep -Milliseconds 80 } while ((Get-Date) -lt $deadline) if ($Mode -eq 'auto' -and (Invoke-CaptionClick $Hwnd)) { return } $fg = Get-ForegroundInfo $reason = if ($fg.hung) { 'the current foreground window is not responding' } elseif ((Get-IdleMs) -lt 500) { 'the user is still providing input' } else { 'foreground lock' } $hint = if ($fg.hung) { 'nothing can be driven until that window recovers' } elseif ($Mode -eq 'require') { 'ask the user, then retry with -Focus auto -Pointer, which clicks the target caption' } else { 'retry when the user is idle, or drive the app with uia-invoke/uia-set instead' } Fail 'focus-denied' ('Could not focus target window ' + $Hwnd.ToInt64() + ': ' + $reason) $hint @{ hwnd = $Hwnd.ToInt64() foreground = $fg lockTimeoutMs = [int64] [Win32DriveNative]::GetForegroundLockTimeout() idleMs = Get-IdleMs focusMode = $Mode } } function Select-Windows($Opts) { $wins = @(Get-Windows) $proc = Str-Opt $Opts 'Process' $title = Str-Opt $Opts 'TitleContains' if ($Opts.ContainsKey('Hwnd')) { $wanted = Long-Opt $Opts 'Hwnd' $wins = @($wins | Where-Object { $_.hwnd -eq $wanted }) } if ($proc) { $wins = @($wins | Where-Object { $_.process -eq $proc }) } if ($title) { $wins = @($wins | Where-Object { $_.title.IndexOf($title, [StringComparison]::OrdinalIgnoreCase) -ge 0 }) } $wins } function Get-WindowInfo([IntPtr] $Hwnd) { @(Get-Windows) | Where-Object { $_.hwnd -eq $Hwnd.ToInt64() } | Select-Object -First 1 } function Resolve-Window($Opts) { if ($Opts.ContainsKey('Hwnd')) { $hwnd = [IntPtr] (Long-Opt $Opts 'Hwnd') if (-not [Win32DriveNative]::IsWindow($hwnd)) { Fail 'stale-window' 'Invalid window handle' 'the window closed or the handle is wrong: run list again' @{ hwnd = $hwnd.ToInt64() } } return $hwnd } $wins = @(Select-Windows $Opts) if ($wins.Count -eq 0) { $proc = Str-Opt $Opts 'Process' $candidates = if ($proc) { @(Get-Windows | Where-Object { $_.process -like "*$proc*" } | Select-Object -First 8) } else { @(Get-Windows | Select-Object -First 8) } Fail 'no-window' 'No matching window' 'relax the selector; candidates lists what is open right now' @{ candidates = $candidates } } if ($wins.Count -gt 1) { Fail 'ambiguous-window' ('Ambiguous window: ' + $wins.Count + ' matches') 'pick one -Hwnd from candidates' @{ candidates = @($wins | Select-Object -First 8) } } [IntPtr] $wins[0].hwnd } function Get-IdleMs { $info = New-Object Win32DriveNative+LASTINPUTINFO $info.cbSize = [Runtime.InteropServices.Marshal]::SizeOf($info) [void] [Win32DriveNative]::GetLastInputInfo([ref] $info) [int64] ([Win32DriveNative]::GetTickCount() - $info.dwTime) } function Wait-UserIdle([int] $IdleMs, [int] $TimeoutMs) { $start = Get-Date while ((Get-IdleMs) -lt $IdleMs) { if (((Get-Date) - $start).TotalMilliseconds -gt $TimeoutMs) { Fail 'user-active' "User not idle for $IdleMs ms" 'the human is using the machine: retry later, raise -TimeoutMs, or use a background command' @{ idleMs = Get-IdleMs; requiredIdleMs = $IdleMs } } Start-Sleep -Milliseconds 100 } } # One overlay renderer: quick flash for background commands, banner plus # countdown for foreground takeovers, which the human can veto by touching # keyboard or mouse. function Show-Overlay($X = $null, $Y = $null, [string] $Banner = $null, [int] $DurationMs = 180, [bool] $AbortOnInput = $false) { Import-Forms $state = [pscustomobject] @{ Banner = $Banner; Remaining = $DurationMs } $bannerScreen = if ($null -ne $X -and $null -ne $Y) { [Windows.Forms.Screen]::FromPoint([Drawing.Point]::new([int] $X, [int] $Y)) } else { [Windows.Forms.Screen]::PrimaryScreen } $forms = @() foreach ($screen in [Windows.Forms.Screen]::AllScreens) { $b = $screen.Bounds $targetX = $X $targetY = $Y $showBanner = $Banner -and $screen.DeviceName -eq $bannerScreen.DeviceName $form = New-Object Windows.Forms.Form $form.FormBorderStyle = 'None' $form.StartPosition = 'Manual' $form.Bounds = $b $form.TopMost = $true $form.BackColor = [Drawing.Color]::Magenta $form.TransparencyKey = [Drawing.Color]::Magenta $form.ShowInTaskbar = $false $form.Opacity = 0.0 $form.Add_Paint({ param($sender, $event) $event.Graphics.SmoothingMode = [Drawing.Drawing2D.SmoothingMode]::AntiAlias $pen = New-Object Drawing.Pen ([Drawing.Color]::Red), 16 try { $event.Graphics.DrawRectangle($pen, 8, 8, $sender.Width - 17, $sender.Height - 17) if ($targetX -ne $null -and $targetY -ne $null -and $b.Contains([Drawing.Point]::new([int]$targetX, [int]$targetY))) { $localX = [int]$targetX - $b.Left $localY = [int]$targetY - $b.Top $event.Graphics.DrawEllipse($pen, $localX - 24, $localY - 24, 48, 48) } if ($showBanner) { $text = $state.Banner if ($state.Remaining -gt 0) { $text = $text + "`nacting in " + [Math]::Max(0, [Math]::Round($state.Remaining / 1000.0, 1)) + 's - touch keyboard or mouse to cancel' } $font = New-Object Drawing.Font 'Segoe UI', 20, ([Drawing.FontStyle]::Bold) $back = New-Object Drawing.SolidBrush ([Drawing.Color]::FromArgb(235, 20, 0, 0)) $fore = New-Object Drawing.SolidBrush ([Drawing.Color]::White) try { $size = $event.Graphics.MeasureString($text, $font) $boxWidth = $size.Width + 48 $boxHeight = $size.Height + 28 $boxX = ($sender.Width - $boxWidth) / 2 $boxY = 48 $event.Graphics.FillRectangle($back, $boxX, $boxY, $boxWidth, $boxHeight) $event.Graphics.DrawRectangle($pen, $boxX, $boxY, $boxWidth, $boxHeight) $event.Graphics.DrawString($text, $font, $fore, $boxX + 24, $boxY + 14) } finally { $font.Dispose(); $back.Dispose(); $fore.Dispose() } } } finally { $pen.Dispose() } }.GetNewClosure()) # WS_EX_NOACTIVATE | WS_EX_TRANSPARENT | WS_EX_TOOLWINDOW: never take focus, # never swallow input, never appear in the alt-tab list. [Win32DriveNative]::AddExStyle($form.Handle, (0x08000000 -bor 0x00000020 -bor 0x00000080)) [void] [Win32DriveNative]::ShowWindow($form.Handle, 4) $forms += $form } foreach ($opacity in 0.2,0.45,0.7,0.9) { foreach ($form in $forms) { $form.Opacity = $opacity; $form.Invalidate() } [Windows.Forms.Application]::DoEvents() Start-Sleep -Milliseconds 20 } $completed = $true if ($DurationMs -gt 0) { $deadline = (Get-Date).AddMilliseconds($DurationMs) while ($true) { $remaining = ($deadline - (Get-Date)).TotalMilliseconds if ($remaining -le 0) { break } $state.Remaining = [int] $remaining if ($Banner) { foreach ($form in $forms) { $form.Invalidate() } } [Windows.Forms.Application]::DoEvents() Start-Sleep -Milliseconds 50 if ($AbortOnInput -and (Get-IdleMs) -lt 150) { $completed = $false; break } } } foreach ($opacity in 0.7,0.45,0.2,0.0) { foreach ($form in $forms) { $form.Opacity = $opacity } [Windows.Forms.Application]::DoEvents() Start-Sleep -Milliseconds 20 } foreach ($form in $forms) { $form.Close(); $form.Dispose() } [Windows.Forms.Application]::DoEvents() $completed } function Draw-Indicator($X = $null, $Y = $null, [int] $DurationMs = 180) { [void] (Show-Overlay $X $Y $null $DurationMs $false) } # Background commands: wait for an input gap, flash once, act. function Invoke-RacingGuard($Opts, $X = $null, $Y = $null) { $idle = Int-Opt $Opts 'IdleMs' 800 $timeout = Int-Opt $Opts 'TimeoutMs' 10000 Wait-UserIdle $idle $timeout Draw-Indicator $X $Y 120 } # Foreground/pointer commands: same wait, then a named banner with a countdown # the human can veto. function Invoke-Takeover($Opts, [string] $Label, $X = $null, $Y = $null) { $idle = Int-Opt $Opts 'IdleMs' 800 $timeout = Int-Opt $Opts 'TimeoutMs' 10000 Wait-UserIdle $idle $timeout $countdown = if ($Opts.ContainsKey('NoCountdown')) { 0 } else { Int-Opt $Opts 'CountdownMs' 1200 } if (-not (Show-Overlay $X $Y $Label $countdown $true)) { Fail 'takeover-aborted' 'Foreground takeover cancelled by user input' 'the human took the machine back mid-countdown: ask before retrying' @{ label = $Label } } } function Theme-Name($Value) { if ([int]$Value -eq 0) { 'dark' } else { 'light' } } function Theme-Value([string] $Mode) { switch ($Mode.ToLowerInvariant()) { 'dark' { 0 } 'light' { 1 } default { throw 'Mode must be dark or light' } } } function Get-Theme { $path = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize' $item = Get-ItemProperty -Path $path -ErrorAction SilentlyContinue $apps = if ($item -and $null -ne $item.AppsUseLightTheme) { $item.AppsUseLightTheme } else { 1 } $system = if ($item -and $null -ne $item.SystemUsesLightTheme) { $item.SystemUsesLightTheme } else { 1 } [pscustomobject] [ordered] @{ ok = $true; apps = Theme-Name $apps; system = Theme-Name $system } } function Broadcast-ThemeChange([string] $Refresh) { $result = [UIntPtr]::Zero foreach ($name in @('ImmersiveColorSet','WindowsThemeElement','Software\Microsoft\Windows\CurrentVersion\Themes\Personalize')) { [void] [Win32DriveNative]::SendMessageTimeout([IntPtr]0xffff, 0x001A, [UIntPtr]::Zero, $name, 2, 5000, [ref] $result) } [void] [Win32DriveNative]::SendMessageTimeout([IntPtr]0xffff, 0x031A, [UIntPtr]::Zero, $null, 2, 5000, [ref] $result) if ($Refresh -eq 'full') { Start-Process -WindowStyle Hidden -FilePath rundll32.exe -ArgumentList 'user32.dll,UpdatePerUserSystemParameters 1, True' -Wait } } function Set-Theme([string] $Mode, [string] $Scope, [string] $Refresh) { $value = Theme-Value $Mode $scopeName = if ($Scope) { $Scope.ToLowerInvariant() } else { 'both' } if (@('apps','system','both') -notcontains $scopeName) { throw 'Scope must be apps, system, or both' } $refreshName = if ($Refresh) { $Refresh.ToLowerInvariant() } else { 'broadcast' } if (@('broadcast','full') -notcontains $refreshName) { throw 'Refresh must be broadcast or full' } $previous = Get-Theme $path = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize' New-Item -Path $path -Force | Out-Null if ($scopeName -eq 'apps' -or $scopeName -eq 'both') { Set-ItemProperty -Path $path -Name AppsUseLightTheme -Type DWord -Value $value } if ($scopeName -eq 'system' -or $scopeName -eq 'both') { Set-ItemProperty -Path $path -Name SystemUsesLightTheme -Type DWord -Value $value } Broadcast-ThemeChange $refreshName $current = Get-Theme [pscustomobject] [ordered] @{ ok = $true apps = $current.apps system = $current.system scope = $scopeName refresh = $refreshName previous = [ordered] @{ apps = $previous.apps; system = $previous.system } } } function Save-Bitmap($Bitmap, [string] $Out) { if (-not $Out) { $Out = Join-Path $env:TEMP ("win32-drive-shot-" + [guid]::NewGuid().ToString('N') + '.png') } $full = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Out) $parent = Split-Path -Parent $full if ($parent -and -not (Test-Path $parent)) { New-Item -ItemType Directory -Force -Path $parent | Out-Null } $Bitmap.Save($full, [Drawing.Imaging.ImageFormat]::Png) $full } function Get-PixelColor([int] $X, [int] $Y) { Import-Drawing $bmp = [Drawing.Bitmap]::new(1, 1) $g = [Drawing.Graphics]::FromImage($bmp) try { $g.CopyFromScreen($X, $Y, 0, 0, [Drawing.Size]::new(1, 1)) $c = $bmp.GetPixel(0, 0) [pscustomobject] [ordered] @{ ok = $true; x = $X; y = $Y; r = $c.R; g = $c.G; b = $c.B; hex = ('#{0:X2}{1:X2}{2:X2}' -f $c.R, $c.G, $c.B) } } finally { $g.Dispose(); $bmp.Dispose() } } function Capture-Region([int] $X, [int] $Y, [int] $Width, [int] $Height, [string] $Out) { Import-Drawing $bmp = [Drawing.Bitmap]::new($Width, $Height) $g = [Drawing.Graphics]::FromImage($bmp) try { $g.CopyFromScreen($X, $Y, 0, 0, $bmp.Size) $path = Save-Bitmap $bmp $Out [pscustomobject] [ordered] @{ ok = $true; path = $path; x = $X; y = $Y; width = $Width; height = $Height; mode = 'region' } } finally { $g.Dispose(); $bmp.Dispose() } } function Test-BlankBitmap($Bitmap) { $first = $null for ($ix = 0; $ix -lt 5; $ix++) { for ($iy = 0; $iy -lt 5; $iy++) { $px = [int] (($Bitmap.Width - 1) * $ix / 4) $py = [int] (($Bitmap.Height - 1) * $iy / 4) $argb = $Bitmap.GetPixel($px, $py).ToArgb() if ($null -eq $first) { $first = $argb } elseif ($argb -ne $first) { return $false } } } $true } function Capture-Window([IntPtr] $Hwnd, [string] $Out) { Import-Drawing $frame = Get-Rect $Hwnd $raw = Get-RawRect $Hwnd $w = $frame.Right - $frame.Left $h = $frame.Bottom - $frame.Top $rawW = $raw.Right - $raw.Left $rawH = $raw.Bottom - $raw.Top if ($w -le 0 -or $h -le 0 -or $rawW -le 0 -or $rawH -le 0) { throw 'Window has empty bounds' } $used = 'printwindow' $image = $null $bmp = [Drawing.Bitmap]::new($rawW, $rawH) $g = [Drawing.Graphics]::FromImage($bmp) try { $hdc = $g.GetHdc() $ok = [Win32DriveNative]::PrintWindow($Hwnd, $hdc, 2) $g.ReleaseHdc($hdc) if ($ok -and (Test-BlankBitmap $bmp)) { $ok = $false } if ($ok) { # PrintWindow renders from the raw window rect; crop back to the DWM frame # bounds so capture pixels share the coordinate space of list/click/uia. $cropX = [Math]::Max(0, $frame.Left - $raw.Left) $cropY = [Math]::Max(0, $frame.Top - $raw.Top) $cropW = [Math]::Min($w, $rawW - $cropX) $cropH = [Math]::Min($h, $rawH - $cropY) $image = $bmp.Clone([Drawing.Rectangle]::new($cropX, $cropY, $cropW, $cropH), $bmp.PixelFormat) } else { $used = 'screen-crop' $image = [Drawing.Bitmap]::new($w, $h) $shotGraphics = [Drawing.Graphics]::FromImage($image) try { $shotGraphics.CopyFromScreen($frame.Left, $frame.Top, 0, 0, $image.Size) } finally { $shotGraphics.Dispose() } } $path = Save-Bitmap $image $Out [pscustomobject] [ordered] @{ ok = $true; path = $path; hwnd = $Hwnd.ToInt64(); x = $frame.Left; y = $frame.Top; width = $image.Width; height = $image.Height; mode = $used } } finally { $g.Dispose(); $bmp.Dispose() if ($image) { $image.Dispose() } } } function Rect-Int($Value) { $d = [double] $Value if ([double]::IsNaN($d) -or [double]::IsInfinity($d)) { return $null } [int] [Math]::Round($d) } function Dump-Uia($Element, [int] $Depth, [int] $MaxDepth) { if ($null -eq $Element) { return $null } $item = [ordered] @{ name = $null automationId = $null className = $null controlType = $null x = $null y = $null width = $null height = $null truncated = $false error = $null children = @() } try { $current = $Element.Current $rect = $current.BoundingRectangle $item.name = $current.Name $item.automationId = $current.AutomationId $item.className = $current.ClassName $item.controlType = $current.ControlType.ProgrammaticName $item.x = Rect-Int $rect.X $item.y = Rect-Int $rect.Y $item.width = Rect-Int $rect.Width $item.height = Rect-Int $rect.Height } catch { $item.error = $_.Exception.Message return [pscustomobject] $item } if ($Depth -lt $MaxDepth) { try { $children = $Element.FindAll([Windows.Automation.TreeScope]::Children, [Windows.Automation.Condition]::TrueCondition) foreach ($child in $children) { $item.children += Dump-Uia $child ($Depth + 1) $MaxDepth } } catch { $item.error = $_.Exception.Message } } else { try { if ($null -ne $Element.FindFirst([Windows.Automation.TreeScope]::Children, [Windows.Automation.Condition]::TrueCondition)) { $item.truncated = $true } } catch {} } [pscustomobject] $item } function Import-Uia { Add-Type -AssemblyName UIAutomationClient,UIAutomationTypes } function Get-UiaControlType([string] $Name) { $field = [Windows.Automation.ControlType].GetField($Name, 'Public,Static,IgnoreCase') if ($null -eq $field) { throw "Unknown control type: $Name" } $field.GetValue($null) } function Describe-UiaElement($Element) { $rect = $Element.Current.BoundingRectangle [pscustomobject] [ordered] @{ name = $Element.Current.Name automationId = $Element.Current.AutomationId className = $Element.Current.ClassName controlType = $Element.Current.ControlType.ProgrammaticName x = Rect-Int $rect.X y = Rect-Int $rect.Y width = Rect-Int $rect.Width height = Rect-Int $rect.Height } } # Element addressing for the focus-free action commands: any combination of # -Name, -AutomationId, -ClassName, -ControlType, disambiguated by -Index. function Resolve-UiaElement($Opts) { Import-Uia $root = if (Has-Selector $Opts) { [Windows.Automation.AutomationElement]::FromHandle((Resolve-Window $Opts)) } else { Fail 'missing-selector' 'element commands need a window selector' 'pass -Hwnd, -Process, or -TitleContains; list shows all windows' } if (-not ($Opts.ContainsKey('Name') -or $Opts.ContainsKey('AutomationId') -or $Opts.ContainsKey('ClassName') -or $Opts.ContainsKey('ControlType'))) { Fail 'missing-filter' 'element commands need -Name, -AutomationId, -ClassName, or -ControlType' 'read the tree with uia first, then address the element; filters accept wildcards' } # ControlType narrows the tree walk; the string filters are wildcards, because # bridged providers generate volatile class names and automation ids. $condition = if ($Opts.ContainsKey('ControlType')) { New-Object Windows.Automation.PropertyCondition ([Windows.Automation.AutomationElement]::ControlTypeProperty), (Get-UiaControlType (Str-Opt $Opts 'ControlType')) } else { [Windows.Automation.Condition]::TrueCondition } $found = @($root.FindAll([Windows.Automation.TreeScope]::Descendants, $condition)) foreach ($filter in @(@('Name','Name'), @('AutomationId','AutomationId'), @('ClassName','ClassName'))) { if (-not $Opts.ContainsKey($filter[0])) { continue } $pattern = Str-Opt $Opts $filter[0] $property = $filter[1] $found = @($found | Where-Object { [string] $_.Current.$property -like $pattern }) } if ($found.Count -eq 0) { $sample = @($root.FindAll([Windows.Automation.TreeScope]::Children, [Windows.Automation.Condition]::TrueCondition) | Select-Object -First 8 | ForEach-Object { Describe-UiaElement $_ }) Fail 'no-element' 'No matching element' 'filters are wildcards against the uia tree; candidates lists the direct children' @{ candidates = $sample } } if ($Opts.ContainsKey('Index')) { $index = Int-Opt $Opts 'Index' if ($index -lt 0 -or $index -ge $found.Count) { Fail 'index-out-of-range' ('Index out of range: ' + $found.Count + ' matching elements') 'use an index between 0 and matches-1' @{ matches = $found.Count } } return $found[$index] } if ($found.Count -gt 1) { Fail 'ambiguous-element' ('Ambiguous element: ' + $found.Count + ' matches') 'narrow the filters or pass -Index' @{ candidates = @($found | Select-Object -First 8 | ForEach-Object { Describe-UiaElement $_ }) } } $found[0] } # Acts through accessibility patterns: no activation, no pointer, no keyboard, # so the user's foreground window and cursor stay untouched. function Invoke-UiaElement($Element) { $pattern = $null if ($Element.TryGetCurrentPattern([Windows.Automation.InvokePattern]::Pattern, [ref] $pattern)) { $pattern.Invoke(); return 'Invoke' } if ($Element.TryGetCurrentPattern([Windows.Automation.TogglePattern]::Pattern, [ref] $pattern)) { $pattern.Toggle(); return 'Toggle' } if ($Element.TryGetCurrentPattern([Windows.Automation.SelectionItemPattern]::Pattern, [ref] $pattern)) { $pattern.Select(); return 'SelectionItem' } if ($Element.TryGetCurrentPattern([Windows.Automation.ExpandCollapsePattern]::Pattern, [ref] $pattern)) { if ($pattern.Current.ExpandCollapseState -eq [Windows.Automation.ExpandCollapseState]::Expanded) { $pattern.Collapse(); return 'Collapse' } $pattern.Expand(); return 'Expand' } # Apps that only ship MSAA (WinForms, old Win32) surface as pattern-less panes # backed by a real HWND; both fallbacks below still avoid focus and pointer. $native = Get-UiaNativeHandle $Element if ($native -ne [IntPtr]::Zero) { if (Invoke-MsaaDefaultAction $native) { return 'MsaaDoDefaultAction' } if ((Get-WindowClassName $native) -match 'BUTTON') { [void] [Win32DriveNative]::PostMessage($native, 0x00F5, [IntPtr]::Zero, [IntPtr]::Zero) return 'BM_CLICK' } } Fail 'no-pattern' 'Element supports no invokable pattern' 'this app exposes no usable accessibility action: ask the user, then use click -Pointer' @{ element = (Describe-UiaElement $Element) } } function Set-UiaElementValue($Element, [string] $Value) { $pattern = $null if ($Element.TryGetCurrentPattern([Windows.Automation.ValuePattern]::Pattern, [ref] $pattern)) { if ($pattern.Current.IsReadOnly) { throw 'Element value is read-only' } $pattern.SetValue($Value) return 'Value' } $native = Get-UiaNativeHandle $Element if ($native -ne [IntPtr]::Zero -and (Send-WindowText $native $Value)) { return 'WM_CHAR' } Fail 'no-pattern' 'Element exposes no settable value' 'ask the user, then focus it and use type -Pointer' @{ element = (Describe-UiaElement $Element) } } # Posted characters reach a native edit control without focus and still raise # the app's change notifications, which WM_SETTEXT alone suppresses. function Send-WindowText([IntPtr] $Hwnd, [string] $Value) { $result = [UIntPtr]::Zero if ([Win32DriveNative]::SendMessageTimeoutPtr($Hwnd, 0x00B1, [UIntPtr]::Zero, [IntPtr] (-1), 2, 2000, [ref] $result) -eq [IntPtr]::Zero) { return $false } if ($Value.Length -eq 0) { [void] [Win32DriveNative]::PostMessage($Hwnd, 0x0102, [IntPtr] 8, [IntPtr] 1) } foreach ($ch in $Value.ToCharArray()) { if ($ch -eq "`r") { continue } $code = if ($ch -eq "`n") { 13 } else { [int] $ch } [void] [Win32DriveNative]::PostMessage($Hwnd, 0x0102, [IntPtr] $code, [IntPtr] 1) } # Posted messages are processed by the target's own loop, so confirm by polling. $expected = $Value.Replace("`r", '') $deadline = (Get-Date).AddMilliseconds(1200) do { Start-Sleep -Milliseconds 50 if ((Get-RemoteWindowText $Hwnd).Replace("`r", '') -eq $expected) { return $true } } while ((Get-Date) -lt $deadline) $false } # GetWindowText does not cross process boundaries for control text; WM_GETTEXT does. function Get-RemoteWindowText([IntPtr] $Hwnd) { $buffer = [Text.StringBuilder]::new(8192) $result = [UIntPtr]::Zero [void] [Win32DriveNative]::SendMessageTimeoutText($Hwnd, 0x000D, [UIntPtr]::new([uint32] $buffer.Capacity), $buffer, 2, 2000, [ref] $result) $buffer.ToString() } function Get-UiaNativeHandle($Element) { try { [IntPtr] $Element.Current.NativeWindowHandle } catch { [IntPtr]::Zero } } function Get-WindowClassName([IntPtr] $Hwnd) { $sb = [Text.StringBuilder]::new(256) [void] [Win32DriveNative]::GetClassName($Hwnd, $sb, $sb.Capacity) $sb.ToString() } function Invoke-MsaaDefaultAction([IntPtr] $Hwnd) { $iid = [guid] '618736e0-3c3d-11cf-810c-00aa00389b71' $accessible = $null try { if ([Win32DriveNative]::AccessibleObjectFromWindow($Hwnd, 0xFFFFFFFC, [ref] $iid, [ref] $accessible) -ne 0) { return $false } if ($null -eq $accessible) { return $false } [void] $accessible.GetType().InvokeMember('accDoDefaultAction', 'InvokeMethod', $null, $accessible, @([int] 0)) $true } catch { $false } } function Escape-SendKeysText([string] $Text) { $special = '+^%~()[]' $out = New-Object Text.StringBuilder foreach ($ch in $Text.ToCharArray()) { switch ($ch) { "`r" { break } "`n" { [void] $out.Append('{ENTER}'); break } "`t" { [void] $out.Append('{TAB}'); break } '{' { [void] $out.Append('{{}'); break } '}' { [void] $out.Append('{}}'); break } default { if ($special.IndexOf($ch) -ge 0) { [void] $out.Append('{').Append($ch).Append('}') } else { [void] $out.Append($ch) } } } } $out.ToString() } function Get-ReturnMode($Opts) { $mode = (Str-Opt $Opts 'Return' 'restore').ToLowerInvariant() if (@('restore','keep') -notcontains $mode) { Fail 'invalid-value' 'Return must be restore or keep' 'restore puts the human back in their window when the action is done' } $mode } function Format-TakeoverLabel([string] $Action, $Target, $X = $null, $Y = $null) { $where = if ($Target) { $title = [string] $Target.title if ($title.Length -gt 60) { $title = $title.Substring(0, 57) + '...' } $Target.process + ': ' + $title } elseif ($null -ne $X) { "screen $X,$Y" } else { 'the focused window' } 'win32-drive ' + $Action + ' -> ' + $where } function Restore-Foreground($Previous, [string] $Mode) { if ($Mode -ne 'restore' -or $null -eq $Previous -or $Previous.hwnd -eq 0) { return $null } $handle = [IntPtr] $Previous.hwnd if ([Win32DriveNative]::GetForegroundWindow() -eq $handle) { return $Previous.hwnd } if (-not [Win32DriveNative]::IsWindow($handle)) { return $null } if ([Win32DriveNative]::Foreground($handle, 400) -or [Win32DriveNative]::ForegroundUnlocked($handle, 400)) { return $Previous.hwnd } $null } # One process start costs more than every action in it, so a whole interaction # can be sent as an ordered step list with pauses between steps. function Invoke-Batch($Opts) { $json = if ($Opts.ContainsKey('Json')) { Str-Opt $Opts 'Json' } elseif ($Opts.ContainsKey('File')) { Get-Content -Raw -LiteralPath (Str-Opt $Opts 'File') } else { [Console]::In.ReadToEnd() } if (-not $json -or $json.Trim().Length -eq 0) { Fail 'batch-empty' 'batch needs steps' 'pass -Json, -File, or pipe JSON on stdin: [{"cmd":"list"},{"cmd":"wait","ms":300}]' } # Assigning first matters: @(ConvertFrom-Json ...) wraps the whole array in one element. try { $parsed = ConvertFrom-Json $json } catch { Fail 'batch-invalid-json' ('Batch steps are not valid JSON: ' + $_.Exception.Message) 'shells mangle embedded quotes: prefer -File steps.json or pipe the JSON on stdin' @{ received = $json.Substring(0, [Math]::Min(200, $json.Length)) } } $steps = @($parsed) $continueOnError = $Opts.ContainsKey('ContinueOnError') $results = @() $failedAt = $null for ($index = 0; $index -lt $steps.Count; $index++) { $step = $steps[$index] $name = [string] $step.cmd $stepOpts = @{} foreach ($property in $step.PSObject.Properties) { if ($property.Name -in @('cmd','ms')) { continue } if ($property.Value -is [bool]) { if ($property.Value) { $stepOpts[$property.Name] = $true } continue } if ($script:win32DriveValueOptions -notcontains $property.Name -and $script:win32DriveFlagOptions -notcontains $property.Name) { Fail 'unknown-option' ("Unknown option in step " + $index + ": " + $property.Name) 'steps use the same option names as the command line' } $stepOpts[$property.Name] = [string] $property.Value } $stepWatch = [Diagnostics.Stopwatch]::StartNew() try { $payload = if ($name -eq 'wait') { $ms = if ($null -ne $step.ms) { [int] $step.ms } else { 250 } Start-Sleep -Milliseconds $ms [pscustomobject] [ordered] @{ ok = $true; waitedMs = $ms } } else { Invoke-DriveCommand $name $stepOpts } $results += (Add-Envelope $name $payload $index $stepWatch) } catch { $results += (Add-Envelope $name (Get-FailurePayload $name $_) $index $stepWatch) $failedAt = $index if (-not $continueOnError) { break } } } [pscustomobject] [ordered] @{ ok = ($null -eq $failedAt) steps = $steps.Count ran = $results.Count failedAt = $failedAt results = $results } } function Add-Envelope([string] $Name, $Payload, $Step = $null, $Watch = $null) { $elapsed = if ($Watch) { [int] $Watch.ElapsedMilliseconds } else { [int] $script:win32DriveStart.ElapsedMilliseconds } $result = $Payload | Add-Member -PassThru -NotePropertyName cmd -NotePropertyValue $Name -Force $result = $result | Add-Member -PassThru -NotePropertyName elapsedMs -NotePropertyValue $elapsed -Force if ($null -ne $Step) { $result = $result | Add-Member -PassThru -NotePropertyName step -NotePropertyValue $Step -Force } $result } function Show-Help { [pscustomobject] [ordered] @{ ok = $true runtime = 'Windows PowerShell 5.1+' dpiAwareness = $script:win32DriveDpi nativeLoad = $script:win32DriveNativeLoad speed = 'process start dominates: send one batch of steps instead of many invocations; every result carries cmd and elapsedMs' errors = 'failures are JSON with code, hint, and context: candidates for no-window/ambiguous-window/no-element, foreground plus lockTimeoutMs plus idleMs for focus-denied' takeover = 'pointer and focus commands show a named banner with a countdown; user input during the countdown aborts with code takeover-aborted' coordinates = 'screen pixels; with a window selector, click -X/-Y are relative to the DWM frame bounds reported by list and shot' racingGuard = 'racing commands fade a 16px red border/ring, wait for user idle, then act' focus = 'uia-invoke and uia-set act without focus, pointer, or keyboard; click/type/key focus the target after the racing guard and verify it, where require uses activation APIs only, auto adds a caption click and needs -Pointer, and skip does not focus' pointerConsent = 'synthetic mouse input requires an explicit -Pointer flag' globalOptions = '-IdleMs n (default 800) and -TimeoutMs n (default 10000) tune the racing guard' commands = [ordered] @{ help = [ordered] @{ racing = $false; usage = 'help' } batch = [ordered] @{ racing = $null; usage = 'batch -File steps.json | batch (JSON on stdin) | batch -Json ''[{"cmd":"list"},{"cmd":"wait","ms":300}]'' [-ContinueOnError]' } list = [ordered] @{ racing = $false; usage = 'list [-Hwnd n|-Process p|-TitleContains t]' } shot = [ordered] @{ racing = $false; usage = 'shot [-Hwnd n|-Process p -TitleContains t|-X x -Y y -Width w -Height h] [-Out file.png]' } pixel = [ordered] @{ racing = $false; usage = 'pixel -X x -Y y' } uia = [ordered] @{ racing = $false; usage = 'uia [-Hwnd n|-Process p|-TitleContains t] [-Depth n]' } 'uia-invoke' = [ordered] @{ racing = $true; usage = 'uia-invoke -Hwnd n|-Process p|-TitleContains t [-Name n] [-AutomationId a] [-ClassName c] [-ControlType t] [-Index i]' } 'uia-set' = [ordered] @{ racing = $true; usage = 'uia-set -Hwnd n|-Process p|-TitleContains t -Value text [-Name n] [-AutomationId a] [-ClassName c] [-ControlType t] [-Index i]' } cursor = [ordered] @{ racing = $false; usage = 'cursor' } focus = [ordered] @{ racing = $false; usage = 'focus' } idle = [ordered] @{ racing = $false; usage = 'idle' } clipboard = [ordered] @{ racing = $null; usage = 'clipboard | clipboard -Set text' } theme = [ordered] @{ racing = $true; usage = 'theme | theme -Mode dark|light [-Scope apps|system|both] [-Refresh broadcast|full]' } overlay = [ordered] @{ racing = $false; usage = 'overlay [-X x -Y y] [-Text banner] [-DurationMs ms]' } click = [ordered] @{ racing = $true; takeover = $true; usage = 'click -Pointer [-Hwnd n|-Process p|-TitleContains t] [-X x -Y y] [-Button left|right] [-Count n] [-Focus auto|require|skip] [-Cursor restore|keep] [-Return restore|keep] [-CountdownMs n|-NoCountdown]' } type = [ordered] @{ racing = $true; takeover = $true; usage = 'type [-Hwnd n|-Process p|-TitleContains t] -Text text [-Focus auto|require|skip] [-Pointer] [-Method unicode|sendkeys] [-Return restore|keep] [-CountdownMs n|-NoCountdown]' } key = [ordered] @{ racing = $true; takeover = $true; usage = 'key [-Hwnd n|-Process p|-TitleContains t] -Keys "^s" [-Focus auto|require|skip] [-Pointer] [-Return restore|keep] [-CountdownMs n|-NoCountdown]' } window = [ordered] @{ racing = $true; usage = 'window -Action move|min|max|restore|close [-Hwnd n|-Process p|-TitleContains t] [-X x -Y y -Width w -Height h]' } } } } function Invoke-DriveCommand([string] $Name, $Opts) { $opts = $Opts switch ($Name.ToLowerInvariant()) { 'help' { return Show-Help } 'list' { $windows = if (Has-Selector $opts) { @(Select-Windows $opts) } else { @(Get-Windows) } return [pscustomobject] [ordered] @{ ok = $true count = $windows.Count foreground = (Get-ForegroundInfo) idleMs = Get-IdleMs windows = $windows } } 'idle' { return [pscustomobject] [ordered] @{ ok = $true; idleMs = Get-IdleMs } } 'focus' { return (Get-ForegroundInfo) | Add-Member -PassThru -NotePropertyName ok -NotePropertyValue $true } 'cursor' { $p = New-Object Win32DriveNative+POINT [void] [Win32DriveNative]::GetCursorPos([ref] $p) return [pscustomobject] [ordered] @{ ok = $true; x = $p.X; y = $p.Y } } 'overlay' { $banner = Str-Opt $opts 'Text' $duration = Int-Opt $opts 'DurationMs' 650 $completed = Show-Overlay (Int-Opt $opts 'X') (Int-Opt $opts 'Y') $banner $duration ($opts.ContainsKey('Pointer') -eq $false -and $null -ne $banner) return [pscustomobject] [ordered] @{ ok = $true; banner = $banner; durationMs = $duration; completed = $completed } } 'pixel' { $x = Int-Opt $opts 'X' $y = Int-Opt $opts 'Y' if ($x -eq $null -or $y -eq $null) { throw 'pixel needs -X and -Y' } return Get-PixelColor $x $y } 'shot' { $out = Str-Opt $opts 'Out' if (Has-Selector $opts) { return Capture-Window (Resolve-Window $opts) $out } Import-Forms $screen = [System.Windows.Forms.SystemInformation]::VirtualScreen $x = Int-Opt $opts 'X' $screen.Left $y = Int-Opt $opts 'Y' $screen.Top $w = Int-Opt $opts 'Width' $screen.Width $h = Int-Opt $opts 'Height' $screen.Height return Capture-Region $x $y $w $h $out } 'uia-invoke' { $element = Resolve-UiaElement $opts $describe = Describe-UiaElement $element Invoke-RacingGuard $opts $describe.x $describe.y $pattern = Invoke-UiaElement $element return [pscustomobject] [ordered] @{ ok = $true; pattern = $pattern; element = $describe; foreground = (Get-ForegroundInfo) } } 'uia-set' { if (-not $opts.ContainsKey('Value')) { throw 'uia-set needs -Value' } $element = Resolve-UiaElement $opts $describe = Describe-UiaElement $element Invoke-RacingGuard $opts $describe.x $describe.y $pattern = Set-UiaElementValue $element (Str-Opt $opts 'Value') return [pscustomobject] [ordered] @{ ok = $true; pattern = $pattern; element = $describe; foreground = (Get-ForegroundInfo) } } 'uia' { Import-Uia $depth = Int-Opt $opts 'Depth' 2 if (Has-Selector $opts) { $el = [Windows.Automation.AutomationElement]::FromHandle((Resolve-Window $opts)) } else { $el = [Windows.Automation.AutomationElement]::FocusedElement } return [pscustomobject] [ordered] @{ ok = $true; tree = Dump-Uia $el 0 $depth } } 'clipboard' { Import-Forms if ($opts.ContainsKey('Set')) { Invoke-RacingGuard $opts [Windows.Forms.Clipboard]::SetText((Str-Opt $opts 'Set')) return [pscustomobject] [ordered] @{ ok = $true; action = 'set' } } return [pscustomobject] [ordered] @{ ok = $true; text = [Windows.Forms.Clipboard]::GetText() } } 'theme' { if (-not $opts.ContainsKey('Mode')) { return Get-Theme } Invoke-RacingGuard $opts return Set-Theme (Str-Opt $opts 'Mode') (Str-Opt $opts 'Scope' 'both') (Str-Opt $opts 'Refresh' 'broadcast') } 'click' { if (-not $opts.ContainsKey('Pointer')) { Fail 'pointer-consent' 'click injects pointer input' 'ask the user first, then pass -Pointer; uia-invoke acts without pointer or focus' } $button = (Str-Opt $opts 'Button' 'left').ToLowerInvariant() $count = Int-Opt $opts 'Count' 1 $focus = Get-FocusMode $opts $returnMode = Get-ReturnMode $opts $cursorMode = (Str-Opt $opts 'Cursor' 'restore').ToLowerInvariant() if (@('restore','keep') -notcontains $cursorMode) { Fail 'invalid-value' 'Cursor must be restore or keep' 'restore puts the pointer back where the human left it' } $x = Int-Opt $opts 'X' $y = Int-Opt $opts 'Y' $hwnd = $null $target = $null if (Has-Selector $opts) { $hwnd = Resolve-Window $opts $target = Get-WindowInfo $hwnd $r = Get-Rect $hwnd if ($x -eq $null) { $x = [int] (($r.Left + $r.Right) / 2) } else { $x = $r.Left + $x } if ($y -eq $null) { $y = [int] (($r.Top + $r.Bottom) / 2) } else { $y = $r.Top + $y } } if ($x -eq $null -or $y -eq $null) { Fail 'missing-option' 'click needs -X and -Y unless targeting a window center' 'pass screen coordinates, or a window selector for a center click' } $origin = New-Object Win32DriveNative+POINT [void] [Win32DriveNative]::GetCursorPos([ref] $origin) $previous = Get-ForegroundInfo # Focus after the guard: activation is only granted during input gaps. Invoke-Takeover $opts (Format-TakeoverLabel "$button click" $target $x $y) $x $y if ($hwnd -ne $null -and $focus -ne 'skip') { Set-Foreground $hwnd $focus } Invoke-PointerClick $x $y $button $count $false if ($cursorMode -eq 'restore') { [void] [Win32DriveNative]::SetCursorPos($origin.X, $origin.Y) } $returnedTo = Restore-Foreground $previous $returnMode return [pscustomobject] [ordered] @{ ok = $true x = $x y = $y button = $button count = $count focus = $focus cursor = $cursorMode return = $returnMode returnedTo = $returnedTo target = $target foreground = (Get-ForegroundInfo) } } 'type' { $text = Str-Opt $opts 'Text' if ($null -eq $text) { Fail 'missing-option' 'type needs -Text' 'pass the literal text; newlines become Enter' } $focus = Get-FocusMode $opts $returnMode = Get-ReturnMode $opts $handle = if (Has-Selector $opts) { Resolve-Window $opts } else { $null } $target = if ($handle) { Get-WindowInfo $handle } else { $null } $defaultMethod = if ([IntPtr]::Size -eq 8) { 'unicode' } else { 'sendkeys' } $method = (Str-Opt $opts 'Method' $defaultMethod).ToLowerInvariant() if (@('unicode','sendkeys') -notcontains $method) { Fail 'invalid-value' 'Method must be unicode or sendkeys' 'unicode sends the text verbatim; sendkeys uses the legacy escaped syntax' } $previous = Get-ForegroundInfo Invoke-Takeover $opts (Format-TakeoverLabel 'type' $target) if ($handle -ne $null -and $focus -ne 'skip') { Set-Foreground $handle $focus } if ($method -eq 'unicode') { [void] [Win32DriveNative]::TypeUnicode($text) } else { Import-Forms; [Windows.Forms.SendKeys]::SendWait((Escape-SendKeysText $text)) } $returnedTo = Restore-Foreground $previous $returnMode return [pscustomobject] [ordered] @{ ok = $true chars = $text.Length method = $method focus = $focus return = $returnMode returnedTo = $returnedTo target = $target foreground = (Get-ForegroundInfo) } } 'key' { $keySpec = Str-Opt $opts 'Keys' if (-not $keySpec) { Fail 'missing-option' 'key needs -Keys' 'SendKeys syntax, for example ^s or %{F4}' } $focus = Get-FocusMode $opts $returnMode = Get-ReturnMode $opts $handle = if (Has-Selector $opts) { Resolve-Window $opts } else { $null } $target = if ($handle) { Get-WindowInfo $handle } else { $null } $previous = Get-ForegroundInfo Invoke-Takeover $opts (Format-TakeoverLabel "key $keySpec" $target) if ($handle -ne $null -and $focus -ne 'skip') { Set-Foreground $handle $focus } Import-Forms [Windows.Forms.SendKeys]::SendWait($keySpec) $returnedTo = Restore-Foreground $previous $returnMode return [pscustomobject] [ordered] @{ ok = $true keys = $keySpec focus = $focus return = $returnMode returnedTo = $returnedTo target = $target foreground = (Get-ForegroundInfo) } } 'window' { $action = (Str-Opt $opts 'Action').ToLowerInvariant() if (-not $action) { Fail 'missing-option' 'window needs -Action' 'move, min, max, restore, or close' } $hwnd = Resolve-Window $opts Invoke-RacingGuard $opts switch ($action) { 'move' { $r = Get-Rect $hwnd $x = Int-Opt $opts 'X' $r.Left $y = Int-Opt $opts 'Y' $r.Top $w = Int-Opt $opts 'Width' ($r.Right - $r.Left) $h = Int-Opt $opts 'Height' ($r.Bottom - $r.Top) [void] [Win32DriveNative]::MoveWindow($hwnd, $x, $y, $w, $h, $true) } 'min' { [void] [Win32DriveNative]::ShowWindow($hwnd, 6) } 'max' { [void] [Win32DriveNative]::ShowWindow($hwnd, 3) } 'restore' { [void] [Win32DriveNative]::ShowWindow($hwnd, 9) } 'close' { [void] [Win32DriveNative]::PostMessage($hwnd, 0x0010, [IntPtr]::Zero, [IntPtr]::Zero) } default { Fail 'invalid-value' "Unknown window action: $action" 'move, min, max, restore, or close' } } Start-Sleep -Milliseconds 120 return [pscustomobject] [ordered] @{ ok = $true hwnd = $hwnd.ToInt64() action = $action window = (Get-WindowInfo $hwnd) foreground = (Get-ForegroundInfo) } } default { Fail 'unknown-command' "Unknown command: $Name" 'run help for the command catalog' @{ commands = @((Show-Help).commands.Keys) } } } } try { $options = Parse-Options $Rest $payload = if ($Command.ToLowerInvariant() -eq 'batch') { Invoke-Batch $options } else { Invoke-DriveCommand $Command $options } Write-Json (Add-Envelope $Command $payload) exit 0 } catch { Write-Json (Add-Envelope $Command (Get-FailurePayload $Command $_)) exit 1 }