repositories / dotfiles
dotfiles
bugabingas dorkfiles
owned by admin
pi/agent/skillz/win32-drive/scripts/win32-drive.ps1
Raw$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
}