diff options
| author | akbash <akbash@herdr.dev> | 2026-09-18 02:13:13 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-18 01:13:13 +0200 |
| commit | b88e8116e9e1f7e2f6e8b211536311cd17feca9a (patch) | |
| tree | 699535f31233ae1aee63a110bdbf2e989ba0878b /scripts/windows_input | |
| parent | 14351f121f6fc8c84b7c6c6eda78ad210e93cfcb (diff) | |
test: add local windows input gauntlet (#4298)
* test: add local windows input gauntlet
refs #4251
* test: add local windows input gauntlet
Harden elevation and channel identity checks, distinguish known host limitations, and surface incomplete cleanup.
refs #4251
* test(windows): harden input gauntlet execution
refs #4251
* test(windows): make input gauntlet self-contained
refs #4251
* test(windows): bound input gauntlet failure paths
refs #4251
* test(windows): extend input qualification gauntlet
refs #4251
* test(windows): complete input qualification matrix
refs #4251
---------
Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com>
Co-authored-by: Jonathan Liebig <jonathan.liebig@gmail.com>
Diffstat (limited to 'scripts/windows_input')
| -rw-r--r-- | scripts/windows_input/Bootstrap.ps1 | 51 | ||||
| -rw-r--r-- | scripts/windows_input/Common.ps1 | 88 | ||||
| -rw-r--r-- | scripts/windows_input/Native.cs | 439 | ||||
| -rw-r--r-- | scripts/windows_input/Probe.ps1 | 70 | ||||
| -rw-r--r-- | scripts/windows_input/README.md | 213 | ||||
| -rw-r--r-- | scripts/windows_input/report.py | 446 |
6 files changed, 1307 insertions, 0 deletions
diff --git a/scripts/windows_input/Bootstrap.ps1 b/scripts/windows_input/Bootstrap.ps1 new file mode 100644 index 00000000..4f6884fb --- /dev/null +++ b/scripts/windows_input/Bootstrap.ps1 @@ -0,0 +1,51 @@ +#Requires -Version 7.0 +param([Parameter(Mandatory)][string] $PlanPath) +. "$PSScriptRoot/Common.ps1" +$plan = Read-GauntletJson $PlanPath +# Terminal activation can inherit another process's environment. Normalize here too. +foreach ($item in @(Get-ChildItem Env:)) { + if ($item.Name -like 'HERDR_*' -or $item.Name -in @('SSH_CONNECTION', 'SSH_TTY')) { Remove-Item "Env:$($item.Name)" } +} +$env:HERDR_CONFIG_PATH = $plan.config +$env:XDG_CONFIG_HOME = $plan.config_home +$env:HERDR_SESSION = $plan.session +if ($plan.profile -ne 'default') { $env:HERDR_WINDOWS_INPUT_PROBE = $plan.profile } +Add-Type -Path "$PSScriptRoot/Native.cs" +$before = [HerdrInputGauntlet.ConsoleProbe]::Geometry() +$child = $null +try { + if ($plan.path -eq 'herdr') { + $child = New-GauntletProcess $plan.exe @('--session', $plan.session) $plan + } else { + $child = New-GauntletProcess $plan.pwsh @('-NoProfile', '-File', "$PSScriptRoot/Probe.ps1", '-PlanPath', $PlanPath) $plan + } + $sequence = 0 + while (-not $child.HasExited -and (Test-GauntletLease $plan.root) -and -not (Test-Path (Join-Path $plan.work 'stop'))) { + $sequence++ + Write-GauntletJson (Join-Path $plan.work 'outer.json') @{ + nonce = $plan.nonce; pid = $PID; child_pid = $child.Id; sequence = $sequence + geometry = [HerdrInputGauntlet.ConsoleProbe]::Geometry(); before = $before + } + Start-Sleep -Milliseconds 100 + } +} finally { + $cleanup = @() + if ($null -ne $child) { + # Allow normal detach / observer disposal before forcing this owned child down. + if (-not $child.WaitForExit(5000)) { + $cleanup += 'Child required forced termination; normal console restoration was not qualified' + $child.Kill($true) + if (-not $child.WaitForExit(5000)) { $cleanup += 'Child remained active after forced termination' } + } + $child.Dispose() + } + if ($plan.path -eq 'herdr') { + foreach ($verb in @('stop', 'delete')) { + try { $null = Invoke-GauntletProcess $plan.exe @('session', $verb, $plan.session) $plan } + catch { $cleanup += $_.ToString() } + } + } + Write-GauntletJson (Join-Path $plan.work 'bootstrap-exit.json') @{ + nonce = $plan.nonce; before = $before; after = [HerdrInputGauntlet.ConsoleProbe]::Geometry(); cleanup_errors = $cleanup + } +} diff --git a/scripts/windows_input/Common.ps1 b/scripts/windows_input/Common.ps1 new file mode 100644 index 00000000..537b66ae --- /dev/null +++ b/scripts/windows_input/Common.ps1 @@ -0,0 +1,88 @@ +# Shared test helpers. Dot-sourcing does not touch the desktop or console. +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Write-GauntletJson($Path, $Value) { + $temporary = "$Path.$([guid]::NewGuid().ToString('N')).tmp" + [IO.File]::WriteAllText($temporary, ($Value | ConvertTo-Json -Depth 30), [Text.UTF8Encoding]::new($false)) + for ($attempt = 0; ; $attempt++) { + try { [IO.File]::Move($temporary, $Path, $true); return } + catch [IO.IOException], [UnauthorizedAccessException] { + if ($attempt -ge 99) { throw } + Start-Sleep -Milliseconds 10 + } + } +} + +function Read-GauntletJson($Path) { + if (Test-Path -LiteralPath $Path) { + $stream = [IO.File]::Open($Path, [IO.FileMode]::Open, [IO.FileAccess]::Read, ([IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete)) + try { + $reader = [IO.StreamReader]::new($stream) + try { return $reader.ReadToEnd() | ConvertFrom-Json -AsHashtable } + finally { $reader.Dispose() } + } finally { $stream.Dispose() } + } + return $null +} + +function Quote-GauntletPS([string] $Text) { return "'" + $Text.Replace("'", "''") + "'" } + +function New-GauntletProcess($Exe, $Arguments, $Plan, [switch] $Capture) { + $info = [Diagnostics.ProcessStartInfo]::new($Exe) + $info.UseShellExecute = $false + foreach ($argument in $Arguments) { $info.ArgumentList.Add([string]$argument) } + foreach ($name in @($info.Environment.Keys)) { + if ($name -like 'HERDR_*' -or $name -in @('SSH_CONNECTION', 'SSH_TTY')) { $null = $info.Environment.Remove($name) } + } + if ($null -ne $Plan) { + $info.Environment['HERDR_CONFIG_PATH'] = $Plan.config + $info.Environment['XDG_CONFIG_HOME'] = $Plan.config_home + $info.Environment['HERDR_SESSION'] = $Plan.session + if ($Plan.profile -ne 'default') { $info.Environment['HERDR_WINDOWS_INPUT_PROBE'] = $Plan.profile } + if ($Plan.path -eq 'herdr') { $info.Environment['HERDR_WINDOWS_INPUT_TRACE_FILE'] = $Plan.input_trace } + } + $info.RedirectStandardOutput = $Capture.IsPresent + $info.RedirectStandardError = $Capture.IsPresent + $info.CreateNoWindow = $Capture.IsPresent + return [Diagnostics.Process]::Start($info) +} + +function Invoke-GauntletProcess($Exe, $Arguments, $Plan = $null, [int] $Timeout = 30) { + $process = New-GauntletProcess $Exe $Arguments $Plan -Capture + try { + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + $timer = [Diagnostics.Stopwatch]::StartNew() + if (-not $process.WaitForExit($Timeout * 1000)) { + $process.Kill($true) + throw "Command timed out: $Exe $($Arguments -join ' ')" + } + $remaining = [Math]::Max(0, $Timeout * 1000 - [int]$timer.ElapsedMilliseconds) + if (-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout, $stderr), $remaining)) { + throw "Output streams stayed open after exit: $Exe $($Arguments -join ' ')" + } + if ($process.ExitCode -ne 0) { throw "Command failed ($($process.ExitCode)): $Exe`n$($stderr.GetAwaiter().GetResult())" } + return $stdout.GetAwaiter().GetResult() + } finally { $process.Dispose() } +} + +function Update-GauntletLease($Root) { + [IO.File]::WriteAllText((Join-Path $Root 'lease'), [DateTime]::UtcNow.ToString('O')) +} + +function Test-GauntletLease($Root) { + $lease = Get-Item -LiteralPath (Join-Path $Root 'lease') -ErrorAction SilentlyContinue + return $null -ne $lease -and ([DateTime]::UtcNow - $lease.LastWriteTimeUtc).TotalSeconds -lt 90 +} + +function Wait-GauntletJson($Path, $Root, $Predicate, [int] $Timeout = 20) { + $deadline = [DateTime]::UtcNow.AddSeconds($Timeout) + do { + Update-GauntletLease $Root + $value = Read-GauntletJson $Path + if ($null -ne $value -and (& $Predicate $value)) { return $value } + Start-Sleep -Milliseconds 100 + } while ([DateTime]::UtcNow -lt $deadline) + throw "Timed out waiting for fresh acknowledgement: $Path" +} diff --git a/scripts/windows_input/Native.cs b/scripts/windows_input/Native.cs new file mode 100644 index 00000000..5f941f1a --- /dev/null +++ b/scripts/windows_input/Native.cs @@ -0,0 +1,439 @@ +// Test-only Windows desktop/console adapter. No calls occur when this file is compiled. +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; + +namespace HerdrInputGauntlet { + [ComImport, Guid("45BA127D-10A8-46EA-8AB7-56EA9078943C")] + class ApplicationActivationManager { } + + [ComImport, Guid("2E941141-7F97-4756-BA1D-9DECDE894A3D"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + interface IApplicationActivationManager { + [PreserveSig] + int ActivateApplication([MarshalAs(UnmanagedType.LPWStr)] string appUserModelId, + [MarshalAs(UnmanagedType.LPWStr)] string arguments, uint options, out uint processId); + } + + public static class Desktop { + [StructLayout(LayoutKind.Sequential)] public struct Point { public int X, Y; } + [StructLayout(LayoutKind.Sequential)] public struct Rect { public int Left, Top, Right, Bottom; } + [StructLayout(LayoutKind.Sequential)] struct Mouse { public int X, Y; public uint Data, Flags, Time; public UIntPtr Extra; } + [StructLayout(LayoutKind.Sequential)] struct Key { public ushort Vk, Scan; public uint Flags, Time; public UIntPtr Extra; } + [StructLayout(LayoutKind.Explicit)] struct Union { [FieldOffset(0)] public Mouse Mouse; [FieldOffset(0)] public Key Key; } + [StructLayout(LayoutKind.Sequential)] struct Input { public uint Type; public Union Data; } + delegate bool EnumProc(IntPtr hwnd, IntPtr arg); + delegate IntPtr HookProc(int code,IntPtr message,IntPtr data); + [StructLayout(LayoutKind.Sequential)] struct Message { public IntPtr Window; public uint Id; public UIntPtr WParam; public IntPtr LParam; public uint Time; public Point Point; public uint Private; } + [DllImport("user32.dll",SetLastError=true)] static extern IntPtr SetWindowsHookEx(int kind,HookProc callback,IntPtr module,uint thread); + [DllImport("user32.dll")] static extern bool UnhookWindowsHookEx(IntPtr hook); + [DllImport("user32.dll")] static extern IntPtr CallNextHookEx(IntPtr hook,int code,IntPtr message,IntPtr data); + [DllImport("user32.dll")] static extern int GetMessage(out Message message,IntPtr window,uint min,uint max); + [DllImport("user32.dll")] static extern bool PostThreadMessage(uint thread,uint message,UIntPtr wparam,IntPtr lparam); + [DllImport("kernel32.dll")] static extern uint GetCurrentThreadId(); + [DllImport("kernel32.dll",CharSet=CharSet.Unicode)] static extern IntPtr GetModuleHandle(string name); + static volatile bool stopped; + static Thread stopThread; + static uint stopThreadId; + static HookProc stopHook; + static readonly ManualResetEventSlim stopReady=new ManualResetEventSlim(); + static string stopError; + public static void StartEmergencyStop() { + stopThread=new Thread(()=> { + IntPtr hook=IntPtr.Zero; + try { + stopThreadId=GetCurrentThreadId(); + stopHook=(code,message,data)=> { + if(code>=0 && (message.ToInt64()==0x100 || message.ToInt64()==0x104) && Marshal.ReadInt32(data)==0x7B) stopped=true; + return CallNextHookEx(IntPtr.Zero,code,message,data); + }; + hook=SetWindowsHookEx(13,stopHook,GetModuleHandle(null),0); + if(hook==IntPtr.Zero) throw new Win32Exception(); + stopReady.Set(); + Message message; + while(GetMessage(out message,IntPtr.Zero,0,0)>0) {} + } catch(Exception e) { stopError=e.Message; stopped=true; stopReady.Set(); } + finally { if(hook!=IntPtr.Zero) UnhookWindowsHookEx(hook); } + }) { IsBackground=true }; + stopThread.Start(); + if(!stopReady.Wait(3000) || stopError!=null) throw new Exception("Cannot install emergency-stop latch: "+stopError); + } + public static void StopEmergencyStop() { + if(stopThread!=null) { PostThreadMessage(stopThreadId,0x12,UIntPtr.Zero,IntPtr.Zero); stopThread.Join(1000); } + } + [DllImport("kernel32.dll",SetLastError=true)] static extern IntPtr OpenProcess(uint access,bool inherit,int pid); + [DllImport("advapi32.dll",SetLastError=true)] static extern bool OpenProcessToken(IntPtr process,uint access,out IntPtr token); + [DllImport("advapi32.dll",SetLastError=true)] static extern bool GetTokenInformation(IntPtr token,int kind,out uint value,int size,out int returned); + [DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr handle); + [StructLayout(LayoutKind.Sequential)] struct FileInformation { + public uint Attributes,CreationLow,CreationHigh,AccessLow,AccessHigh,WriteLow,WriteHigh; + public uint Volume,SizeHigh,SizeLow,Links,IndexHigh,IndexLow; + } + [DllImport("kernel32.dll",CharSet=CharSet.Unicode,SetLastError=true)] static extern Microsoft.Win32.SafeHandles.SafeFileHandle CreateFile(string path,uint access,uint share,IntPtr security,uint creation,uint flags,IntPtr template); + [DllImport("kernel32.dll",SetLastError=true)] static extern bool GetFileInformationByHandle(Microsoft.Win32.SafeHandles.SafeFileHandle file,out FileInformation info); + public static string FileIdentity(string path) { + using(var file=CreateFile(path,0,7,IntPtr.Zero,3,0x02000000,IntPtr.Zero)) { + FileInformation info; + if(file.IsInvalid || !GetFileInformationByHandle(file,out info)) throw new Win32Exception(Marshal.GetLastWin32Error(),"Cannot establish Terminal file/installation identity"); + return info.Volume.ToString("x8")+":"+info.IndexHigh.ToString("x8")+info.IndexLow.ToString("x8"); + } + } + public static void AssertNotElevated(int pid) { + var process=OpenProcess(0x1000,false,pid); + if(process==IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error(),"Cannot verify process elevation; refusing desktop input"); + IntPtr token=IntPtr.Zero; + try { + uint elevated; int returned; + if(!OpenProcessToken(process,8,out token) || !GetTokenInformation(token,20,out elevated,4,out returned) || returned!=4) + throw new Win32Exception(Marshal.GetLastWin32Error(),"Cannot verify process elevation; refusing desktop input"); + if(elevated!=0) throw new Exception("Elevated controller/Terminal is not allowed. Start PowerShell and Terminal without Run as administrator."); + } finally { if(token!=IntPtr.Zero) CloseHandle(token); CloseHandle(process); } + } + [DllImport("user32.dll")] static extern bool EnumWindows(EnumProc callback, IntPtr arg); + [DllImport("user32.dll", CharSet=CharSet.Unicode)] static extern int GetWindowText(IntPtr hwnd, StringBuilder value, int count); + [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr hwnd, out uint pid); + [DllImport("user32.dll")] static extern IntPtr GetKeyboardLayout(uint thread); + [DllImport("user32.dll")] static extern bool SetForegroundWindow(IntPtr hwnd); + [DllImport("user32.dll")] static extern bool GetCursorPos(out Point point); + [DllImport("user32.dll")] static extern bool SetCursorPos(int x,int y); + [DllImport("user32.dll")] static extern int GetSystemMetrics(int index); + [DllImport("user32.dll")] static extern short GetAsyncKeyState(int vk); + [DllImport("user32.dll")] static extern uint MapVirtualKeyEx(uint key, uint kind, IntPtr layout); + [DllImport("user32.dll", CharSet=CharSet.Unicode)] static extern int ToUnicodeEx(uint key,uint scan,byte[] state,StringBuilder text,int count,uint flags,IntPtr layout); + [DllImport("user32.dll", SetLastError=true)] static extern uint SendInput(uint count, Input[] events, int size); + [DllImport("user32.dll")] static extern bool GetWindowRect(IntPtr hwnd, out Rect rect); + [DllImport("user32.dll")] static extern bool SetWindowPos(IntPtr hwnd, IntPtr after, int x, int y, int width, int height, uint flags); + [DllImport("user32.dll")] public static extern int CountClipboardFormats(); + [DllImport("user32.dll")] public static extern uint GetClipboardSequenceNumber(); + [DllImport("user32.dll")] static extern IntPtr GetClipboardOwner(); + [DllImport("user32.dll")] static extern bool OpenClipboard(IntPtr owner); + [DllImport("user32.dll")] static extern bool CloseClipboard(); + [DllImport("user32.dll")] static extern bool EmptyClipboard(); + [DllImport("user32.dll")] static extern IntPtr SetClipboardData(uint format,IntPtr value); + [DllImport("kernel32.dll")] static extern IntPtr GlobalAlloc(uint flags,UIntPtr size); + [DllImport("kernel32.dll")] static extern IntPtr GlobalLock(IntPtr memory); + [DllImport("kernel32.dll")] static extern bool GlobalUnlock(IntPtr memory); + [DllImport("kernel32.dll")] static extern IntPtr GlobalFree(IntPtr memory); + + static string QuoteArgument(string value) { + if(value.Length>0 && value.IndexOfAny(new[]{' ', '\t', '\n', '\v', '"'})<0) return value; + var result=new StringBuilder("\""); + int slashes=0; + foreach(char c in value) { + if(c=='\\') { slashes++; continue; } + if(c=='"') result.Append('\\',slashes*2+1); + else result.Append('\\',slashes); + result.Append(c); slashes=0; + } + result.Append('\\',slashes*2).Append('"'); + return result.ToString(); + } + public static int ActivateApplication(string appUserModelId,string[] arguments) { + var manager=(IApplicationActivationManager)new ApplicationActivationManager(); + uint pid; + int result=manager.ActivateApplication(appUserModelId,string.Join(" ",Array.ConvertAll(arguments,QuoteArgument)),0,out pid); + if(result<0) Marshal.ThrowExceptionForHR(result); + return checked((int)pid); + } + + public static uint SetEmptyClipboard(IntPtr owner,string text) { + if(!OpenClipboard(owner)) throw new Exception("Clipboard busy; refusing replacement"); + IntPtr memory=IntPtr.Zero; + try { + if(CountClipboardFormats()!=0) throw new Exception("Clipboard changed or contains user data; refusing replacement"); + byte[] data=Encoding.Unicode.GetBytes(text+"\0"); + memory=GlobalAlloc(2,new UIntPtr((uint)data.Length)); + if(memory==IntPtr.Zero) throw new Exception("Clipboard allocation failed"); + var pointer=GlobalLock(memory); + if(pointer==IntPtr.Zero) throw new Exception("Clipboard lock failed"); + try { Marshal.Copy(data,0,pointer,data.Length); } finally { GlobalUnlock(memory); } + if(!EmptyClipboard() || SetClipboardData(13,memory)==IntPtr.Zero) throw new Exception("Clipboard write failed"); + memory=IntPtr.Zero; // ownership transferred to Windows + } finally { if(memory!=IntPtr.Zero) GlobalFree(memory); CloseClipboard(); } + // Closing can synthesize additional formats and advance the sequence. + // Reopen before adopting it so another writer cannot become our lease. + if(!OpenClipboard(owner)) throw new Exception("Clipboard busy; cannot establish test ownership"); + try { + if(owner==IntPtr.Zero || GetClipboardOwner()!=owner) throw new Exception("Clipboard ownership changed; refusing cleanup lease"); + return GetClipboardSequenceNumber(); + } finally { CloseClipboard(); } + } + public static bool ClearOwnedClipboard(uint sequence) { + if(!OpenClipboard(IntPtr.Zero)) return false; + try { return GetClipboardSequenceNumber()!=sequence || EmptyClipboard(); } + finally { CloseClipboard(); } + } + public static string Title(IntPtr hwnd) { var text=new StringBuilder(1024); GetWindowText(hwnd,text,text.Capacity); return text.ToString(); } + public static IntPtr Find(string nonce) { + var matches=new List<IntPtr>(); + EnumWindows((h,a)=> { if (Title(h).Contains(nonce)) matches.Add(h); return true; },IntPtr.Zero); + if(matches.Count>1) throw new Exception("Ambiguous test window identity"); + return matches.Count==1 ? matches[0] : IntPtr.Zero; + } + public static int Pid(IntPtr hwnd) { uint pid; GetWindowThreadProcessId(hwnd,out pid); return checked((int)pid); } + public static string Layout(IntPtr hwnd) { uint pid; return GetKeyboardLayout(GetWindowThreadProcessId(hwnd,out pid)).ToInt64().ToString("x"); } + public static bool IsOwned(IntPtr hwnd, string nonce, int pid) { return hwnd!=IntPtr.Zero && Pid(hwnd)==pid && Title(hwnd).Contains(nonce); } + public static void Guard(IntPtr hwnd, string nonce, int pid) { + if(!IsOwned(hwnd,nonce,pid) || GetForegroundWindow()!=hwnd) throw new Exception("Lost owned test-window focus; injection aborted"); + AssertNotElevated(pid); + if(stopped || (GetAsyncKeyState(0x7B)&0x8000)!=0) throw new Exception("F12 emergency stop"); + } + public static void Focus(IntPtr hwnd, string nonce, int pid) { + if(!IsOwned(hwnd,nonce,pid)) throw new Exception("Lost window ownership"); + AssertNotElevated(pid); + var deadline=DateTime.UtcNow.AddSeconds(3); + do { + SetForegroundWindow(hwnd); + if(GetForegroundWindow()==hwnd) return; + Thread.Sleep(100); + } while(DateTime.UtcNow<deadline); + throw new Exception("Cannot focus test window; no input sent"); + } + public static void FocusAwayAndBack(IntPtr other,IntPtr hwnd,string nonce,int pid) { + if(other==IntPtr.Zero || other==hwnd) throw new Exception("No separate controller window for focus-cycle qualification"); + AssertNotElevated(Pid(other)); + var deadline=DateTime.UtcNow.AddSeconds(3); + do { + SetForegroundWindow(other); + if(GetForegroundWindow()==other) break; + Thread.Sleep(100); + } while(DateTime.UtcNow<deadline); + if(GetForegroundWindow()!=other) throw new Exception("Cannot focus controller window for focus-cycle qualification"); + Thread.Sleep(250); + Focus(hwnd,nonce,pid); + Thread.Sleep(250); + } + public static void Neutral() { + foreach(int key in new[]{0x10,0x11,0x12,0x5B,0x5C,1,2,4}) + if((GetAsyncKeyState(key)&0x8000)!=0) throw new Exception("Release physical modifiers and mouse buttons before running"); + } + static Input Event(ushort vk, bool release, IntPtr layout) { + uint mapped=MapVirtualKeyEx(vk,4,layout); + if(mapped==0) throw new Exception("No scan code for virtual key "+vk); + return new Input { Type=1, Data=new Union { Key=new Key { Scan=(ushort)(mapped&255), Flags=8u|(release?2u:0u)|((mapped&0xff00)!=0?1u:0u) } } }; + } + public static int[] Scans(IntPtr hwnd,int[] keys) { + uint ignored; var layout=GetKeyboardLayout(GetWindowThreadProcessId(hwnd,out ignored)); + var scans=new List<int>(); + foreach(int key in keys) scans.Add(Event((ushort)key,false,layout).Data.Key.Scan); + return scans.ToArray(); + } + static int[] LayoutChord(int vk,int modifiers) { + var keys=new List<int>(); + if((modifiers&1)!=0) keys.Add(0x10); + if((modifiers&2)!=0) keys.Add(0x11); + if((modifiers&4)!=0) keys.Add(0x12); + keys.Add(vk); + return keys.ToArray(); + } + public static int[] DeadKeyChord(IntPtr hwnd,char accent) { + uint ignored; var layout=GetKeyboardLayout(GetWindowThreadProcessId(hwnd,out ignored)); + for(int modifiers=0;modifiers<8;modifiers++) for(uint vk=1;vk<255;vk++) { + uint scan=MapVirtualKeyEx(vk,4,layout); + if(scan==0) continue; + var state=new byte[256]; + if((modifiers&1)!=0) state[0x10]=0x80; + if((modifiers&2)!=0) state[0x11]=0x80; + if((modifiers&4)!=0) state[0x12]=0x80; + var text=new StringBuilder(4); + // Flag 4 discovers dead keys without changing keyboard state. + if(ToUnicodeEx(vk,scan,state,text,text.Capacity,4,layout)<0 && text.Length>0 && text[0]==accent) + return LayoutChord((int)vk,modifiers); + } + throw new Exception("Active layout has no "+accent+" dead key"); + } + // One balanced batch; no modifier is intentionally held between calls. + public static int Chord(IntPtr hwnd, string nonce, int pid, int[] keys) { + Guard(hwnd,nonce,pid); Neutral(); + uint ignored; var layout=GetKeyboardLayout(GetWindowThreadProcessId(hwnd,out ignored)); + var events=new List<Input>(); + foreach(int key in keys) events.Add(Event(checked((ushort)key),false,layout)); + for(int i=keys.Length-1;i>=0;i--) events.Add(Event(checked((ushort)keys[i]),true,layout)); + Guard(hwnd,nonce,pid); + uint sent=SendInput((uint)events.Count,events.ToArray(),Marshal.SizeOf<Input>()); + if(sent!=events.Count) { + // Release only keys whose down event was actually submitted and not yet released. + var held=new List<Input>(); + for(int i=0;i<keys.Length && i<sent;i++) + if(sent <= 2*keys.Length-1-i) held.Add(Event((ushort)keys[i],true,layout)); + if(held.Count>0) { + uint released=SendInput((uint)held.Count,held.ToArray(),Marshal.SizeOf<Input>()); + if(released!=held.Count) throw new Exception("SendInput cleanup incomplete: "+released+"/"+held.Count+" key-up events; release test modifiers manually before continuing"); + } + throw new Exception("SendInput incomplete (possible UIPI restriction): "+sent+"/"+events.Count); + } + return (int)sent; + } + public static long Cursor() { + Point point; + if(!GetCursorPos(out point)) throw new Win32Exception(); + return ((long)(uint)point.X<<32)|(uint)point.Y; + } + public static void RestoreCursor(long packed) { + if(!SetCursorPos(unchecked((int)(packed>>32)),unchecked((int)packed))) throw new Win32Exception(); + } + public static void MouseMoveInside(IntPtr hwnd,string nonce,int pid,int offset) { + Guard(hwnd,nonce,pid); Neutral(); Rect rect; + if(!GetWindowRect(hwnd,out rect)) throw new Win32Exception(); + int x=(rect.Left+rect.Right)/2+offset,y=(rect.Top+rect.Bottom)/2; + if(x<=rect.Left+20 || x>=rect.Right-20 || y<=rect.Top+20 || y>=rect.Bottom-20) throw new Exception("Mouse target is outside the safe window interior"); + int left=GetSystemMetrics(76),top=GetSystemMetrics(77),width=GetSystemMetrics(78),height=GetSystemMetrics(79); + if(width<2 || height<2) throw new Exception("Virtual desktop geometry unavailable"); + int nx=(int)Math.Round((x-left)*65535.0/(width-1)),ny=(int)Math.Round((y-top)*65535.0/(height-1)); + var input=new Input { Type=0, Data=new Union { Mouse=new Mouse { X=nx,Y=ny,Flags=0xC001 } } }; + if(SendInput(1,new[]{input},Marshal.SizeOf<Input>())!=1) throw new Win32Exception(Marshal.GetLastWin32Error(),"Mouse injection failed"); + } + public static void MouseClickWheelInside(IntPtr hwnd,string nonce,int pid,int offset) { + Guard(hwnd,nonce,pid); Neutral(); Rect rect; + if(!GetWindowRect(hwnd,out rect)) throw new Win32Exception(); + int x=(rect.Left+rect.Right)/2+offset,y=(rect.Top+rect.Bottom)/2; + if(x<=rect.Left+20 || x>=rect.Right-20 || y<=rect.Top+20 || y>=rect.Bottom-20) throw new Exception("Mouse target is outside the safe window interior"); + int left=GetSystemMetrics(76),top=GetSystemMetrics(77),width=GetSystemMetrics(78),height=GetSystemMetrics(79); + if(width<2 || height<2) throw new Exception("Virtual desktop geometry unavailable"); + int nx=(int)Math.Round((x-left)*65535.0/(width-1)),ny=(int)Math.Round((y-top)*65535.0/(height-1)); + var inputs=new[]{ + new Input { Type=0, Data=new Union { Mouse=new Mouse { X=nx,Y=ny,Flags=0xC001 } } }, + new Input { Type=0, Data=new Union { Mouse=new Mouse { Flags=0x0002 } } }, + new Input { Type=0, Data=new Union { Mouse=new Mouse { Flags=0x0004 } } }, + new Input { Type=0, Data=new Union { Mouse=new Mouse { Data=120,Flags=0x0800 } } } + }; + uint sent=SendInput((uint)inputs.Length,inputs,Marshal.SizeOf<Input>()); + if(sent!=inputs.Length) { + int error=Marshal.GetLastWin32Error(); + if(sent==2 && SendInput(1,new[]{inputs[2]},Marshal.SizeOf<Input>())!=1) + throw new Win32Exception(Marshal.GetLastWin32Error(),"Mouse injection failed after button-down; release the left mouse button manually"); + throw new Win32Exception(error,"Mouse click/wheel injection failed"); + } + } + public static void Resize(IntPtr hwnd,string nonce,int pid,int dx,int dy) { + Guard(hwnd,nonce,pid); Rect r; + if(!GetWindowRect(hwnd,out r)) throw new Win32Exception(); + int w=r.Right-r.Left+dx,h=r.Bottom-r.Top+dy; + if(w<300 || h<200 || w>10000 || h>10000) throw new Exception("Unsafe resize request"); + if(!SetWindowPos(hwnd,IntPtr.Zero,0,0,w,h,0x16)) throw new Win32Exception(); + } + } + + public sealed class ConsoleProbe : IDisposable { + [StructLayout(LayoutKind.Sequential)] struct Coord { public short X,Y; } + [StructLayout(LayoutKind.Sequential)] struct SmallRect { public short Left,Top,Right,Bottom; } + [StructLayout(LayoutKind.Sequential)] struct Info { public Coord Size,Cursor; public ushort Attributes; public SmallRect Window; public Coord Maximum; } + [StructLayout(LayoutKind.Explicit,Size=20)] struct Record { + [FieldOffset(0)] public ushort Type; + [FieldOffset(4)] public int Down; + [FieldOffset(8)] public ushort Repeat; + [FieldOffset(10)] public ushort Vk; + [FieldOffset(12)] public ushort Scan; + [FieldOffset(14)] public ushort Unicode; + [FieldOffset(16)] public uint Control; + } + [DllImport("kernel32.dll")] static extern IntPtr GetStdHandle(int kind); + [DllImport("kernel32.dll",SetLastError=true)] static extern bool GetConsoleMode(IntPtr handle,out uint mode); + [DllImport("kernel32.dll",SetLastError=true)] static extern bool SetConsoleMode(IntPtr handle,uint mode); + [DllImport("kernel32.dll")] static extern bool GetConsoleScreenBufferInfo(IntPtr handle,out Info info); + [DllImport("kernel32.dll")] static extern uint GetConsoleCP(); + [DllImport("kernel32.dll")] static extern bool SetConsoleCP(uint cp); + [DllImport("kernel32.dll")] static extern bool ReadFile(IntPtr handle,byte[] buffer,uint size,out uint count,IntPtr overlap); + [DllImport("kernel32.dll")] static extern bool WriteFile(IntPtr handle,byte[] buffer,uint size,out uint count,IntPtr overlap); + [DllImport("kernel32.dll")] static extern bool ReadConsoleInputW(IntPtr handle,Record[] records,uint size,out uint count); + [DllImport("kernel32.dll")] static extern uint GetCurrentThreadId(); + [DllImport("kernel32.dll")] static extern IntPtr OpenThread(uint access,bool inherit,uint id); + [DllImport("kernel32.dll")] static extern bool CancelSynchronousIo(IntPtr thread); + [DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr handle); + readonly object gate=new object(); + readonly List<byte> bytes=new List<byte>(); + readonly List<long[]> records=new List<long[]>(); + readonly IntPtr input=GetStdHandle(-10), output=GetStdHandle(-11); + readonly uint original, outputMode, cp; + readonly bool native; + volatile bool running=true; + Thread reader; + IntPtr threadHandle; + string error; + public string Error { + get { return Volatile.Read(ref error); } + private set { Volatile.Write(ref error,value); } + } + public ConsoleProbe(string mode) { + if(!GetConsoleMode(input,out original) || !GetConsoleMode(output,out outputMode)) throw new Exception("Probe needs a real console"); + cp=GetConsoleCP(); native=mode=="native"; + try { + if(!SetConsoleCP(65001) || !SetConsoleMode(output,outputMode|4) || !SetConsoleMode(input,native ? (original & ~0x247u)|0x98u : (original & ~7u)|0x200u)) throw new Win32Exception(); + Print("\x1b[?2004h"+(mode=="kitty"?"\x1b[>1u\x1b[?u":mode=="mok2"?"\x1b[>4;2m":"")); + reader=new Thread(Read) { IsBackground=true }; reader.Start(); + } catch { SetConsoleMode(input,original); SetConsoleMode(output,outputMode); SetConsoleCP(cp); throw; } + } + public static long[] Geometry() { + Info info; uint mode; + if(!GetConsoleScreenBufferInfo(GetStdHandle(-11),out info) || !GetConsoleMode(GetStdHandle(-10),out mode)) throw new Exception("Console geometry unavailable"); + return new long[]{ info.Window.Right-info.Window.Left+1,info.Window.Bottom-info.Window.Top+1,mode }; + } + public static void Print(string text) { + byte[] data=Encoding.UTF8.GetBytes(text); uint count; + if(!WriteFile(GetStdHandle(-11),data,(uint)data.Length,out count,IntPtr.Zero) || count!=data.Length) throw new Exception("Console output failed"); + } + public void SetKeyboardMode(string mode) { + string sequence="\x1b[<u\x1b[>4;0m"; + if(mode=="mok2") sequence+="\x1b[>4;2m"; + else if(mode=="kitty") sequence+="\x1b[>1u"; + else if(mode!="legacy") throw new Exception("Unknown keyboard mode transition"); + Print(sequence); + } + void Read() { + threadHandle=OpenThread(1,false,GetCurrentThreadId()); + if(threadHandle==IntPtr.Zero) { Error="Cannot acquire reader cancellation handle"; return; } + try { + while(running) { + if(Count()>1048576) { Error="Probe input limit exceeded"; break; } + uint count; + if(native) { + var batch=new Record[64]; + if(!ReadConsoleInputW(input,batch,64,out count)) { if(running) Error="ReadConsoleInputW failed"; break; } + lock(gate) for(int i=0;i<count;i++) { + var r=batch[i]; + // Preserve every native record, including non-key event type and payload. + records.Add(new long[]{r.Type,r.Down,r.Repeat,r.Vk,r.Scan,r.Unicode,r.Control}); + } + } else { + var batch=new byte[4096]; + if(!ReadFile(input,batch,4096,out count,IntPtr.Zero)) { if(running) Error="ReadFile failed"; break; } + lock(gate) for(int i=0;i<count;i++) bytes.Add(batch[i]); + } + } + } catch(Exception e) { Error=e.Message; } + } + public void Clear() { lock(gate) { bytes.Clear(); records.Clear(); } } + public bool ClearIfCount(int expected) { + lock(gate) { + if(bytes.Count+records.Count!=expected) return false; + bytes.Clear(); records.Clear(); return true; + } + } + public string Hex() { lock(gate) return BitConverter.ToString(bytes.ToArray()).Replace("-","").ToLowerInvariant(); } + public long[][] Records() { lock(gate) return records.ToArray(); } + public int Count() { lock(gate) return bytes.Count+records.Count; } + public string[] CleanupErrors { get; private set; } = new string[0]; + public void Dispose() { + var errors=new List<string>(); + running=false; + if(threadHandle!=IntPtr.Zero) CancelSynchronousIo(threadHandle); + if(reader!=null && !reader.Join(1000)) errors.Add("Reader did not stop"); + if(threadHandle!=IntPtr.Zero) CloseHandle(threadHandle); + try { Print("\x1b[<u\x1b[>4;0m\x1b[?2004l\x1b[?1000l\x1b[?1006l"); } + catch(Exception e) { errors.Add(e.Message); } + if(!SetConsoleMode(input,original)) errors.Add("Input-mode restoration failed"); + if(!SetConsoleMode(output,outputMode)) errors.Add("Output-mode restoration failed"); + if(!SetConsoleCP(cp)) errors.Add("Code-page restoration failed"); + uint actual; + if(!GetConsoleMode(input,out actual) || actual!=original) errors.Add("Input mode differs after restoration"); + if(!GetConsoleMode(output,out actual) || actual!=outputMode) errors.Add("Output mode differs after restoration"); + if(GetConsoleCP()!=cp) errors.Add("Code page differs after restoration"); + CleanupErrors=errors.ToArray(); + } + } +} diff --git a/scripts/windows_input/Probe.ps1 b/scripts/windows_input/Probe.ps1 new file mode 100644 index 00000000..ad3a9c2b --- /dev/null +++ b/scripts/windows_input/Probe.ps1 @@ -0,0 +1,70 @@ +#Requires -Version 7.0 +param([Parameter(Mandatory)][string] $PlanPath) +. "$PSScriptRoot/Common.ps1" +$plan = Read-GauntletJson $PlanPath +Add-Type -Path "$PSScriptRoot/Native.cs" +$probe = $null +try { + $probe = [HerdrInputGauntlet.ConsoleProbe]::new($plan.mode) + [HerdrInputGauntlet.ConsoleProbe]::Print("`r`nHerdr input gauntlet observer $($plan.nonce)`r`n") + if ($plan.mode -eq 'kitty') { + $deadline = [DateTime]::UtcNow.AddSeconds(2) + while (-not $probe.Hex().Contains('1b5b3f3175') -and [DateTime]::UtcNow -lt $deadline) { Start-Sleep -Milliseconds 50 } + } + Write-GauntletJson (Join-Path $plan.work 'ready.json') @{ + nonce = $plan.nonce; pid = $PID; mode = $plan.mode; geometry = [HerdrInputGauntlet.ConsoleProbe]::Geometry() + negotiation_hex = $probe.Hex(); kitty_acknowledged = $probe.Hex().Contains('1b5b3f3175') + } + # Negotiation replies are setup evidence, not input for the first case. + $probe.Clear() + $closedCount = 0 + $last = '' + while ((Test-GauntletLease $plan.root) -and -not (Test-Path (Join-Path $plan.work 'probe-stop'))) { + $request = Read-GauntletJson (Join-Path $plan.work 'request.json') + if ($null -ne $request -and $request.id -ne $last) { + if ($request.nonce -ne $plan.nonce) { throw 'Unexpected observer nonce' } + $last = $request.id + $quietReached = $false + if ($request.action -eq 'mouse-on') { + [HerdrInputGauntlet.ConsoleProbe]::Print("`e[?1003h`e[?1006h") + Start-Sleep -Milliseconds 200 + } + if ($request.action -eq 'mouse-off') { + [HerdrInputGauntlet.ConsoleProbe]::Print("`e[?1003l`e[?1006l") + Start-Sleep -Milliseconds 100 + } + if ($request.action -eq 'set-mode') { + $probe.SetKeyboardMode([string]$request.value) + Start-Sleep -Milliseconds 200 + } + if ($request.action -eq 'begin' -and -not $probe.ClearIfCount($closedCount)) { throw 'Unexpected input arrived between captures' } + if ($request.action -eq 'end') { + # Capture trailing duplicates/releases as well as the first expected bytes. + $deadline = [DateTime]::UtcNow.AddSeconds(3) + $quiet = [DateTime]::UtcNow + $count = $probe.Count() + do { + Start-Sleep -Milliseconds 50 + $newCount = $probe.Count() + if ($newCount -ne $count) { $quiet = [DateTime]::UtcNow; $count = $newCount } + } while (([DateTime]::UtcNow - $quiet).TotalMilliseconds -lt 350 -and [DateTime]::UtcNow -lt $deadline) + $quietReached = ([DateTime]::UtcNow - $quiet).TotalMilliseconds -ge 350 + $closedCount = $probe.Count() + } + Write-GauntletJson (Join-Path $plan.work 'reply.json') @{ + id = $request.id; nonce = $plan.nonce; action = $request.action + hex = $probe.Hex(); records = @($probe.Records()); error = $probe.Error; quiet_reached = $quietReached + geometry = [HerdrInputGauntlet.ConsoleProbe]::Geometry() + } + } + Start-Sleep -Milliseconds 25 + } +} catch { + Write-GauntletJson (Join-Path $plan.work 'probe-error.json') @{ error = $_.ToString(); nonce = $plan.nonce } + throw +} finally { + if ($null -ne $probe) { + $probe.Dispose() + Write-GauntletJson (Join-Path $plan.work 'probe-exit.json') @{ nonce = $plan.nonce; cleanup_errors = @($probe.CleanupErrors) } + } +} diff --git a/scripts/windows_input/README.md b/scripts/windows_input/README.md new file mode 100644 index 00000000..340ecb0e --- /dev/null +++ b/scripts/windows_input/README.md @@ -0,0 +1,213 @@ +# Local Windows input gauntlet (experimental) + +This is local Windows qualification infrastructure, not a claim that all Windows +input works. It sends real scan-code gestures through a new Windows Terminal +window, both directly to an observer and through an attached Herdr client. It +does not substitute `pane send-keys` for host input. + +## Safety and prerequisites + +Use an **unlocked, isolated interactive desktop** where you will not do other +work during automated input. The runner changes foreground focus and window +size. `SendInput` has an unavoidable check-to-use focus race; a foreground check +is not an OS security boundary. The runner rejects an elevated controller and +checks the target Terminal process token before focus, resize, and injection. +An elevation-query failure is also a refusal, not permission to proceed. Start +PowerShell and Terminal **without Run as administrator**, on a dedicated desktop. There is no unattended CI job or runner permission +change in this implementation. + +Required: Windows, PowerShell **7** (`pwsh`), Python 3, Rust/Cargo, and at least +one Windows Terminal channel. Stable and Preview are discovered independently +through their installed packages. Explicit paths are available when discovery +does not work. No software is installed or updated. The actual Terminal process path/version is recorded, +not inferred from the requested channel or bundled OpenConsole version. Stable +and Preview must resolve to distinct installations: the runner compares Windows +file/directory identities before launch, then checks actual image, installation, +and PID/start-time identities after activation. Aliases cannot count one install +twice. The portable report validator rejects duplicate identities too. + +**F12** aborts before the next automatic gesture. Moving focus away also aborts +further automatic input. Each injected chord contains its own releases; there +are no intentionally held modifiers between calls. Do not hold physical modifiers +or mouse buttons while starting a run. Normal controller cancellation runs cleanup; +bootstrap/probe leases also expire if the controller disappears. Lease expiry is +not a replacement for a secure isolated desktop. + +Clipboard tests require an **empty clipboard**. Clear it yourself only after +saving anything you need. The runner will not replace existing text, images, +rich formats, or files. It writes its synthetic text while holding the clipboard +lock and clears it afterward only if its sequence number is unchanged. A later +user clipboard update is left alone. No original clipboard contents are logged. + +## Run + +From the repository in PowerShell 7: + +```powershell +just test-windows-input +``` + +The full catalogue normally exits `2` because operator-assisted and explicitly +unimplemented qualification cases remain. That is incomplete coverage, not an +automated test failure; inspect the printed matrix and retained `report.json`. + +The recipe itself is the explicit opt-in to foreground input injection. It builds +the current checkout in release mode, stages that exact binary with the pinned +ConPTY runtime, and prints its path and hash. Use `-ExePath` only to compare a +specific old/new packaged binary. + +Or invoke the script directly: + +```powershell +pwsh -NoProfile -File scripts/test_windows_input.ps1 ` + -ExePath 'C:\test-app\herdr.exe' -AllowInputInjection ` + -StablePath 'C:\TerminalStable\WindowsTerminal.exe' ` + -PreviewPath 'C:\TerminalPreview\WindowsTerminal.exe' +``` + +The default tests current Herdr's **default** input policy. Diagnostic runs may +use `-Profile win32` or `-Profile vt`; they do not replace the default run. +`-Modes native,legacy,mok2,kitty`, `-Channels`, `-Paths`, `-Cases`, `-Widths`, and +`-Heights` select a focused campaign. In a direct PowerShell invocation, supply +arrays normally: + +```powershell +.\scripts\test_windows_input.ps1 -ExePath 'C:\test-app\herdr.exe' ` + -AllowInputInjection -Modes legacy,kitty -Cases mouse-interleave,mode-transitions ` + -Widths 120 -Heights 30 +``` + +Dead-key acute composition is automatic when the target Terminal thread's +active layout exposes that physical mapping. The runner discovers and injects +the real scan-code chord; it never substitutes pasted or Unicode-packet text. +`-Manual` enables AltGr, IME, and the remaining guided composition cases. The operator must activate the +indicated layout/IME, perform the gesture in the test window, then return to the +controller and press Enter. Finish each prompt within the 90-second lease. +Record the layout you actually selected in your qualification notes; the report +also includes the observed foreground thread layout handle. Do not paste text +for a composition case. The runner does not install/change global layouts. + +You can inspect the full catalogue without Windows or desktop interaction: + +```powershell +pwsh -NoProfile -File scripts/test_windows_input.ps1 -ExePath unused -MatrixOnly +``` + +Every run needs a **new** output directory. By default it is +`.local/windows-input/<unique-id>/`. Do not reuse an old report directory. + +## What the implementation measures + +- Native console records, including down/up, repeat, virtual/scan codes, Unicode, + modifier state, and raw payloads for other native record types. +- Legacy VT, modifyOtherKeys level 2, and Kitty disambiguation observer modes. + These are **pane consumer modes**, not names for Herdr's outer reader. +- Physical-style Enter/Shift+Enter and other modifier chords, navigation and word + movement chords, control keys, and editing keys. The oracle checks bytes or + records; it does not claim that a particular editor implements word selection + correctly merely because Ctrl+Shift+Right reached it. +- Actual Windows clipboard paste via Ctrl+V, including LF/CRLF/CR, whitespace, + BMP Unicode/combining characters, and escape-looking text. + A host binding or multiline-paste confirmation dialog can intercept the gesture; + the runner does not dismiss unexpected dialogs or rebind Terminal shortcuts. +- A full case pass at an observed 120×30 host size; keyboard/paste sentinels at + **80, 119, 120, 121, 132, 160, 240 columns**, at 24 and 50 rows; then return to + 80 columns. This exercises narrow→wide→narrow resizing of the actual outer + window. Both outer and pane dimensions are captured. Herdr chrome means pane + width is not the same as outer width. An unreachable size is not a pass. +- Automatic active-layout dead-key acute input, plus guided AltGr, extended + accent, and IME commits, with exact expected committed text. +- Ordered typing, mouse-motion reports, and bracketed paste in one capture, plus + click/wheel reporting before and after a real focus cycle, the same mouse + sentinel after resize, and legacy→modifyOtherKeys→Kitty→modifyOtherKeys→legacy + transitions without restart. + +The focus/resize mouse check runs in Windows Terminal. It guards Herdr's recovery +sequence but does not certify the Tabby/Alacritty host-specific report in #4284. +The catalogue also lists explicit **qualification gaps**: mouse drag and +right-edge coordinate mapping; visual reflow/wrapping; native held-key repeat; +lock/keypad combinations; dead-key cancellation; IME cancellation; capture/config +reload and attach cycles; injected setup/recovery faults; supplementary-plane and +confirmation-triggering burst paste; image/file clipboard integrations; and +positive host-scrollback evidence for native PageUp/PageDown. These are recorded +`not_run` or `inconclusive`, not fabricated successes. They need +separate fixtures/oracles before becoming automated assertions. The catalogue is +broad; the automated run does **not cover every row**. + +Injected PageUp/PageDown remains `inconclusive` through Herdr. On Windows +Terminal 1.24, both `SendInput` and `keybd_event` produced zeroed non-key records +even in the direct-host baseline, so absence of pane input cannot prove that +Herdr consumed the key. Conclusive qualification requires literal physical input +plus a positive pane scroll-offset change; that guided check is not automated yet. + +## Evidence and verdicts + +The retained directory contains: + +- `matrix.json`: all cases, profiles, and declared byte/record expectations; +- `observations.json`: raw captures, per-case identity, measured dimensions, + readiness/focus flags, errors, and cleanup results; +- `report.json`: interpreted verdicts and counts; +- per-window plans, nonce-bound observer acknowledgements, initial/final console + modes, and bootstrap/probe error records. + +The console ends with a capability matrix derived only from that run's captured +observations. Filtered, unavailable, or operator-assisted cases remain +`NOT TESTED` or `MANUAL`; the full evidence and reasons remain in `report.json`. +The Herdr column is labelled Win32 only after the current binary's selected +reader decodes a real nonce-owned Win32 serialized record. Configuration defaults +alone do not select the label; missing runtime evidence prints `UNKNOWN`. + +Direct-host and through-Herdr observations are labelled separately. A direct-host +failure is **not automatically a Herdr bug**. Each row labels its failure scope as +`direct_host` or `through_herdr_not_yet_attributed`. For example, a terminal may +intercept Alt+Enter or use a different control-key encoding. + +One explicit known-gap rule labels nonce-bound, complete **WT 1.24/1.25 +direct-host mOK captures** of Shift/Ctrl/Ctrl+Shift+Enter or Shift+Tab as +`unsupported` only when they contain the exact legacy bytes. It does not exempt +other tests or versions, an empty/malformed capture, Kitty, or any through-Herdr +failure. Through-Herdr still must preserve the requested gesture. +Unsupported remains incomplete coverage, not green. Review captures before +adding other capability exceptions; never bless lost Shift information in Herdr +just to make a report green. + +Comparisons consume the **complete captured sequence**, including a quiet interval +to catch trailing duplicates/releases. Paste must have one intact bracketed +wrapper and the complete payload. CR/LF variants are compared as logical newlines; +this is not proof of byte-exact line-ending policy. Native keys require their +non-modifier down/up pair, modifier state, and repeat count. + +Exit codes: + +- `0`: all observed assertions passed and no recorded coverage gaps/errors; +- `1`: an assertion, harness, or cleanup failed; +- `2`: coverage is incomplete (including deliberately unimplemented catalogue + rows, missing hosts, or unavailable geometry). + +An empty or missing report never means success. None of these statuses certifies +an entire Windows host. + +## Cleanup and native handoff + +Every window has a fresh nonce, named session, isolated config root, known +PowerShell pane shell, and observer acknowledgement. Inherited Herdr socket, +backend, session, and SSH variables are cleared in the actual launched process, +not just in the controller. Setup APIs only create/focus the observer pane. + +Cleanup targets the named session, retained child-process handles, and the exact +nonce-bearing Terminal window. There is no process-name-wide kill or sweep of +newly appeared OpenConsole processes. Artifacts are retained. Forced cleanup and +console-mode mismatches are reported, not hidden behind a passing key test. + +For release-risk input changes, qualify Stable and Preview, real focus-loss/F12 +interruption, partial startup, clipboard changes, cleanup, and measured width +boundaries. Use old and new Herdr binaries in **separate runs** to establish that +a known regression is caught. Do not overwrite installations or reuse an earlier +server. Portable unit tests validate the catalogue and verdict logic; they do not +replace native Windows evidence. + +The existing `windows_conpty_enhanced_input_probe.ps1` remains the downstream API +control. Existing Rust keyboard/Windows-translator tests remain the deterministic +parser controls. This runner complements them rather than cross-producting their +fixtures or pretending they establish host behavior. diff --git a/scripts/windows_input/report.py b/scripts/windows_input/report.py new file mode 100644 index 00000000..7c15c149 --- /dev/null +++ b/scripts/windows_input/report.py @@ -0,0 +1,446 @@ +"""Portable case catalogue and strict evidence verdicts for the local Windows gauntlet. + +This is not a host simulator. A report cannot turn absent Windows evidence into a pass. +""" +import argparse +import json +import re +from pathlib import Path + +WIDTHS = [80, 119, 120, 121, 132, 160, 240] +HEIGHTS = [24, 50] +MODES = ["native", "legacy", "mok2", "kitty"] + + +def hex_of(text): + return text.encode("utf-8").hex() + + +def catalogue(): + cases = [] + + def key(name, vk, text, modifiers=(), enhanced=None, legacy=None): + mask = (1 if 16 in modifiers else 0) | (2 if 18 in modifiers else 0) | (4 if 17 in modifiers else 0) + unicode = (vk + 32 if 65 <= vk <= 90 else vk if vk in (8, 9, 13, 27) else 0) + if 65 <= vk <= 90 and 16 in modifiers: + unicode = vk + if 17 in modifiers: + unicode = vk - 64 if 65 <= vk <= 90 else 10 if vk == 13 else 127 if vk == 8 else 0 + expected = {"native": {"vk": vk, "modifiers": mask, "unicode": unicode, "modifier_keys": list(modifiers)}} + if text is not None: + expected.update({mode: {"hex": [hex_of(text)]} for mode in MODES[1:]}) + if legacy is not None: + expected["legacy"] = {"hex": [hex_of(legacy)]} + if enhanced is not None: + expected["mok2"] = {"hex": [hex_of(f"\x1b[27;{mask + 1};{enhanced}~")]} + expected["kitty"] = {"hex": [hex_of(f"\x1b[{enhanced};{mask + 1}u")]} + cases.append(dict(id=name, kind="key", chords=[[*modifiers, vk]], expected=expected)) + + key("letter-a", 65, "a") + key("shift-letter", 65, "A", (16,)) + key("enter", 13, "\r") + key("shift-enter", 13, None, (16,), 13) + cases[-1]["expected"]["legacy"] = {"loss": "enter", "hex": ["0d"]} + key("ctrl-enter", 13, None, (17,), 13) + cases[-1]["expected"]["legacy"] = {"loss": "modifier", "hex": ["0a"]} + key("ctrl-shift-enter", 13, None, (17, 16), 13) + cases[-1]["expected"]["legacy"] = {"loss": "modifier", "hex": ["0a"]} + key("tab", 9, "\t") + key("shift-tab", 9, "\x1b[Z", (16,)) + cases[-1]["expected"]["mok2"] = {"hex": [hex_of("\x1b[27;2;9~")]} + cases[-1]["expected"]["kitty"]["hex"].append(hex_of("\x1b[9;2u")) + key("backspace", 8, "\x7f") + key("ctrl-backspace", 8, None, (17,), 127, "\x08") + key("alt-backspace", 8, None, (18,), 127, "\x1b\x7f") + key("escape", 27, "\x1b") + # Kitty's disambiguation flag encodes Escape distinctly. + cases[-1]["expected"]["kitty"] = {"hex": [hex_of("\x1b[27u")]} + for name, vk, seq, kitty in [("up", 38, "A", 57419), ("down", 40, "B", 57420), ("right", 39, "C", 57418), + ("left", 37, "D", 57417), ("home", 36, "H", 57423), ("end", 35, "F", 57424)]: + for prefix, modifiers, modifier in [("", (), 1), ("shift-", (16,), 2), ("ctrl-", (17,), 5), ("ctrl-shift-", (17, 16), 6)]: + if prefix == "ctrl-shift-" and name in ("up", "down", "home", "end"): + continue + key(prefix + name, vk, "\x1b[" + ("" if modifier == 1 else f"1;{modifier}") + seq, modifiers) + cases[-1]["expected"]["kitty"]["hex"].append(hex_of(f"\x1b[{kitty}{'' if modifier == 1 else f';{modifier}'}u")) + for name, vk, code, kitty in [("insert", 45, 2, 57425), ("delete", 46, 3, 57426), + ("page-up", 33, 5, 57421), ("page-down", 34, 6, 57422)]: + key(name, vk, f"\x1b[{code}~") + cases[-1]["expected"]["kitty"]["hex"].append(hex_of(f"\x1b[{kitty}u")) + for letter in "acdj lmqsu z".replace(" ", ""): + key("ctrl-" + letter, ord(letter.upper()), None, (17,), ord(letter), chr(ord(letter) - 96)) + key("alt-v", 86, None, (18,), ord("v"), "\x1bv") + for name, text in [ + ("paste-lf", "line 1\nline 2"), + ("paste-crlf", "line 1\r\nline 2\r\n"), + ("paste-cr", "line 1\rline 2"), + ("paste-whitespace", " one\t\n\n two \n"), + ("paste-unicode", "é e\u0301 日本語\nnext"), + ("paste-escape-looking", "literal [200~ and \\x1b[31m\nend"), + ]: + cases.append(dict(id=name, kind="paste", text=text, expected={mode: {"paste": text} for mode in MODES[1:]})) + cases.append(dict(id="mouse-interleave", kind="mouse-interleave", text="mouse\npaste", + expected={mode: {"mouse_interleave": True} for mode in MODES[1:]})) + cases.append(dict(id="mouse-focus-refresh", kind="mouse-focus-refresh", + expected={mode: {"mouse_focus_refresh": True} for mode in MODES[1:]})) + transitions = "a\r" + "b\x1b[27;2;13~" + "c\x1b[13;2u" + "d\x1b[27;2;13~" + "e\r" + "f" + cases.append(dict(id="mode-transitions", kind="mode-transitions", + expected={mode: {"hex": [hex_of(transitions)]} for mode in MODES[1:]})) + cases.append(dict(id="dead-acute", kind="layout-key", dead="´", base_vk=69, + expected={mode: {"hex": [hex_of("é")]} for mode in MODES[1:]})) + for name, prompt, text in [ + ("dead-grave", "With US-International active, type grave then e, once; no Enter.", "è"), + ("dead-circumflex", "With US-International active, type circumflex then e, once; no Enter.", "ê"), + ("dead-tilde", "With US-International active, type tilde then n, once; no Enter.", "ñ"), + ("dead-diaeresis", "With US-International active, type diaeresis then u, once; no Enter.", "ü"), + ("dead-space", "With US-International active, type acute then Space, once; no Enter.", "'"), + ("altgr-euro", "With your declared euro-producing AltGr layout, type € using its AltGr chord, once; no Enter.", "€"), + ("ime-commit", "Using your declared IME, compose and commit 日本語 (not paste); do not submit the prompt.", "日本語"), + ]: + cases.append(dict(id=name, kind="manual", prompt=prompt, expected={mode: {"hex": [hex_of(text)]} for mode in MODES[1:]})) + # Explicit qualification tasks, never auto-passed by key/byte checks. + for name, prompt in [ + ("dead-cancel-repeat", "Qualify dead-key cancellation, repeated accent and non-composing next character against the direct-host baseline."), + ("ime-cancel", "Qualify IME cancel and partial composition without committed/duplicate text."), + ("native-repeat-release", "Qualify held-key repeat, release identity, and modifier interleaving using native records."), + ("locks-keypad", "Qualify CapsLock/NumLock, keypad Enter/operators/decimal and restore lock states."), + ("mouse-right-edge", "Click/drag/wheel at a marked pane column above 120; compare actual report coordinates and visual target."), + ("wrap-rendering", "Verify the displayed ruler and wrapped multiline text at both window heights and every observed width."), + ("capture-refresh", "Toggle mouse capture/config reload, refocus, detach/reattach; repeat paste and Shift+Enter."), + ("setup-recovery", "Inject a recoverable setup failure and late VT activation; verify mode restoration and the same input sentinels."), + ("ctrl-v", "Qualify raw Ctrl+V only with the Windows Terminal paste binding explicitly removed."), + ("alt-enter", "Qualify Alt+Enter with the Windows Terminal fullscreen binding explicitly controlled."), + ("ctrl-shift-up", "Qualify Ctrl+Shift+Up with the Windows Terminal scroll binding explicitly controlled."), + ("ctrl-shift-down", "Qualify Ctrl+Shift+Down with the Windows Terminal scroll binding explicitly controlled."), + ("ctrl-shift-home", "Qualify Ctrl+Shift+Home with the Windows Terminal scroll binding explicitly controlled."), + ("ctrl-shift-end", "Qualify Ctrl+Shift+End with the Windows Terminal scroll binding explicitly controlled."), + ("paste-supplementary", "Qualify supplementary-plane clipboard text, including emoji, against the direct-host baseline."), + ("paste-burst", "Qualify a 200-line clipboard burst with the host multiline-paste warning configured or handled explicitly."), + ("clipboard-nontext", "Qualify supported image/file clipboard integrations separately; do not infer from text paste."), + ]: + cases.append(dict(id=name, kind="qualification", prompt=prompt, expected={})) + return dict(schema=1, widths=WIDTHS, heights=HEIGHTS, modes=MODES, cases=cases) + + +def verdict(case, mode, evidence): + """The full capture, not a matching prefix, is the primary assertion.""" + if evidence.get("status") in {"not_run", "unsupported", "inconclusive"}: + return evidence["status"], evidence.get("reason", "No qualifying observation") + expected = case["expected"].get(mode) + if expected is None: + return "not_run", "No automatic oracle for this case/profile; requires qualification" + if not evidence.get("ready") or not evidence.get("focus_verified") or not evidence.get("complete"): + return "inconclusive", "Missing readiness, focus, or complete capture" + if evidence.get("error"): + return "inconclusive", evidence["error"] + if evidence.get("path") == "herdr" and case["id"] in ("page-up", "page-down") and "vk" not in expected: + expected = {"hex": [""]} # Plain page keys intentionally control Herdr's host scrollback. + def geometry(name): + value = evidence.get(name) + return value[:2] if isinstance(value, list) and len(value) >= 2 and all(type(v) is int for v in value[:2]) else None + outer, pane = geometry("outer_geometry"), geometry("pane_geometry") + final_pane, final_outer = geometry("final_pane_geometry"), geometry("final_outer_geometry") + if outer != [evidence.get("width"), evidence.get("height")]: + return "inconclusive", "Requested geometry was not observed" + if pane is None or min(pane) <= 0: + return "inconclusive", "Missing actual pane dimensions" + if final_pane != pane: + return "inconclusive", "Pane geometry changed during capture" + if final_outer != outer: + return "inconclusive", "Outer geometry changed during capture" + if evidence.get("path") == "herdr" and case["id"] in ("page-up", "page-down") and "vk" in expected: + records = evidence.get("records") + if not isinstance(records, list) or any(not isinstance(record, list) or len(record) != 7 + or not all(type(field) is int for field in record) for record in records): + return "inconclusive", "Malformed native record" + return (("inconclusive", "No positive host-scrollback evidence for the consumed page key") + if all(record[0] in (4, 16) for record in records) + else ("fail", "Plain page key unexpectedly reached the pane")) + if (evidence.get("path") == "herdr" and case["id"] in ("page-up", "page-down") + and evidence.get("hex") == ""): + return "inconclusive", "No positive host-scrollback evidence for the consumed page key" + if "vk" in expected: + records = evidence.get("records") + scans = evidence.get("scans", []) + chord = case["chords"][0] + if not isinstance(records, list): + return "inconclusive", "Malformed native record" + if not isinstance(scans, list) or len(scans) != len(chord) or not all(type(scan) is int for scan in scans): + return "inconclusive", "Missing injected scan-code evidence" + expected_scans = dict(zip(chord, scans)) + aliases = {160: 16, 161: 16, 162: 17, 163: 17, 164: 18, 165: 18} + held = set() + keys = [] + for record in records: + if not isinstance(record, list) or len(record) != 7 or not all(type(field) is int for field in record): + return "inconclusive", "Malformed native record" + if record[0] in (4, 16): # Resize/focus notifications carry no typed text. + continue + if record[0] != 1: + return "fail", "Unexpected non-key native input" + vk = aliases.get(record[3], record[3]) + if vk not in (16, 17, 18): + keys.append(record) + continue + if vk not in expected["modifier_keys"] or record[5] != 0 or record[2] != 1 or record[4] != expected_scans[vk]: + return "fail", "Unexpected/corrupted modifier record" + if record[1] == 1 and vk not in held: + held.add(vk) + elif record[1] == 0 and vk in held: + held.remove(vk) + else: + return "fail", "Unbalanced modifier sequence" + if held: + return "fail", "Modifier release missing" + if len(keys) != 2 or [r[1] for r in keys] != [1, 0]: + return "fail", "Expected exactly one non-modifier down/up pair" + for record in keys: + control = record[6] + modifiers = bool(control & 16) + 2 * bool(control & 3) + 4 * bool(control & 12) + if (record[3] != expected["vk"] or modifiers != expected["modifiers"] or record[2] != 1 + or record[5] != expected["unicode"] or record[4] != expected_scans[expected["vk"]]): + return "fail", "Native identity/modifiers/repeat differ" + return "pass", "Native down/up pair matches" + try: + raw = bytes.fromhex(evidence["hex"]) + except (KeyError, ValueError, TypeError): + return "inconclusive", "Missing or malformed raw bytes" + if expected.get("mouse_interleave"): + motion = rb"(?:\x1b\[<35;\d+;\d+M)+" + newline = rb"(?:\r\n|\r|\n)" + pattern = b"a" + motion + rb"\x1b\[200~mouse" + newline + rb"paste\x1b\[201~" + motion + b"b" + return ("pass", "Typing, mouse motion and paste remained ordered") if re.fullmatch(pattern, raw) else ("fail", "Mouse interleave order or payload differs") + if expected.get("mouse_focus_refresh"): + coords = rb"\d+;\d+" + gesture = (rb"(?:\x1b\[<35;" + coords + rb"M)*" + + rb"\x1b\[<0;" + coords + rb"M" + + rb"\x1b\[<0;" + coords + rb"m" + + rb"\x1b\[<64;" + coords + rb"M") + pattern = b"a" + gesture + b"b" + b"c" + gesture + b"d" + return ("pass", "Click/wheel reporting survived focus loss and regain") if re.fullmatch(pattern, raw) else ("fail", "Mouse reporting failed before or after focus regain") + if case["id"] == "mode-transitions" and evidence.get("path") == "direct": + legacy_only = hex_of("a\rb\rc\rd\re\rf") + kitty_only = hex_of("a\rb\rc\x1b[13;2u" + "d\re\rf") + if raw.hex() in {legacy_only, kitty_only}: + return "unsupported", "Direct Windows Terminal ignored modifyOtherKeys during the transition chain" + return "fail", "Unexpected direct-host runtime transition bytes" + if "loss" in expected: + return ("unsupported", "Plain VT cannot preserve modified Enter") if raw.hex() in expected["hex"] else ("fail", "Unexpected plain-VT modified Enter result") + if "hex" in expected: + return ("pass", "Exact bytes match") if raw.hex() in expected["hex"] else ("fail", "Bytes differ (including any duplicates/trailing input)") + if not raw.startswith(b"\x1b[200~") or not raw.endswith(b"\x1b[201~"): + return "fail", "Missing bracketed-paste envelope; newlines may be key events" + try: + actual = raw[6:-6].decode("utf-8") + except UnicodeDecodeError: + return "fail", "Paste is not intact UTF-8" + # Line-ending conversion is expected on Windows; all other payload bytes matter. + normalize = lambda text: text.replace("\r\n", "\n").replace("\r", "\n") + return ("pass", "One complete paste matches") if normalize(actual) == normalize(expected["paste"]) else ("fail", "Paste payload differs") + + +def known_host_gap(observation, case, terminal_version): + """A narrowly observed host limitation, never a blanket version exemption.""" + legacy = case["expected"].get("legacy", {}).get("hex", []) + mok2 = case["expected"].get("mok2", {}).get("hex", []) + return (observation.get("path") == "direct" + and observation.get("mode") == "mok2" + and case["id"] in {"shift-enter", "ctrl-enter", "ctrl-shift-enter", "shift-tab"} + and re.match(r"^1\.(?:24|25)\.", str(terminal_version)) and set(legacy) != set(mok2) + and observation.get("hex") in legacy) + + +def channel_identity_errors(hosts): + """Compare both launcher identities and the processes actually activated.""" + identities = {} + for host in hosts: + values = identities.setdefault(host.get("channel"), set()) + for field, kind in (("launcher_identity", "file"), ("installation_identity", "installation")): + if host.get(field): + values.add((kind, host[field])) + for run in host.get("runs", []): + for field, kind in (("image_identity", "file"), ("installation_identity", "installation"), ("process_identity", "process")): + if run.get(field): + values.add((kind, run[field])) + return ["Stable and Preview share a Terminal executable, installation, or process identity"] if identities.get("stable", set()) & identities.get("preview", set()) else [] + + +def summarize(document): + matrix = catalogue() + cases = {case["id"]: case for case in matrix["cases"]} + rows = [] + seen = set() + captures = set() + for observation in document.get("observations", []): + identity = tuple(observation.get(k) for k in ("host", "path", "mode", "phase", "width", "height", "case")) + if identity in seen: + raise ValueError(f"Duplicate observation identity: {identity}") + seen.add(identity) + case = cases[observation["case"]] + status, reason = verdict(case, observation["mode"], observation) + scope = "direct_host" if observation.get("path") == "direct" else "through_herdr_not_yet_attributed" + if status in ("pass", "fail"): + bound = [run for host in document.get("hosts", []) if host.get("channel") == observation.get("host") + for run in host.get("runs", []) + if run.get("nonce") == observation.get("nonce") and run.get("path") == observation.get("path") + and run.get("mode") == observation.get("mode") and run.get("pid", 0) > 0 and run.get("hwnd", 0) != 0 + and run.get("elevated") is False and run.get("image_identity") and run.get("installation_identity")] + capture = observation.get("capture_id") + if len(bound) != 1 or document.get("controller_elevated") is not False or not capture or capture in captures: + status, reason = "inconclusive", "Missing non-elevated owned-run binding or fresh capture identity" + else: + captures.add(capture) + if status == "fail" and known_host_gap(observation, case, bound[0].get("terminal_version")): + status, reason = "unsupported", "Direct host ignored mOK and emitted the case's legacy bytes" + rows.append({**observation, "status": status, "reason": reason, "failure_scope": scope}) + counts = {status: sum(r["status"] == status for r in rows) for status in ("pass", "fail", "not_run", "unsupported", "inconclusive")} + # No run may claim all-green just because it produced zero/missing observations. + planned = set() + geometries = [(120, 30, True)] + [(w, h, False) for h in document.get("heights", HEIGHTS) for w in document.get("widths", WIDTHS)] + [(80, 30, False)] + selected_cases = document.get("cases") or list(cases) + for host in document.get("channels") or ("stable", "preview"): + for path in document.get("paths") or ("direct", "herdr"): + for mode in document.get("modes", MODES): + for phase, (width, height, full) in enumerate(geometries, 1): + for case_id in selected_cases if full else (case_id for case_id in ("letter-a", "shift-enter", "paste-lf", "mouse-focus-refresh") if case_id in selected_cases): + planned.add((host, path, mode, phase, width, height, case_id)) + missing = planned - seen + if seen - planned: + raise ValueError("Observations outside the declared run matrix") + hosts = document.get("hosts", []) + errors = list(document.get("errors", [])) + channel_identity_errors(hosts) + expected_hosts = set(document.get("channels") or ("stable", "preview")) + complete = (bool(rows) and not missing and {h.get("channel") for h in hosts} == expected_hosts + and all(h.get("runs") for h in hosts) + and not errors and not document.get("cleanup_errors") + and all(r["status"] == "pass" for r in rows)) + return {**document, "errors": errors, "observations": rows, "counts": counts, "coverage_missing": len(missing), "observed_checks_passed": complete, + "native_qualification": "Required; this report is not a full Windows support certificate"} + + +def qualification_matrix(result): + """Collapse real observations into the user-facing capability summary.""" + rows = result.get("observations", []) + required_channels = set(result.get("channels") or ("stable", "preview")) + manual_cases = {case["id"] for case in catalogue()["cases"] if case["kind"] == "manual"} + groups = [ + ("Printable keys", {"letter-a", "shift-letter"}, None), + ("Shift+Enter", {"shift-enter"}, None), + ("Ctrl+Enter", {"ctrl-enter"}, None), + ("Ctrl+Shift+Enter", {"ctrl-shift-enter"}, None), + ("Navigation/editing", {"up", "down", "left", "right", "home", "end", "insert", "delete", "tab", "shift-tab", "page-up", "page-down"}, None), + ("Multiline paste", {"paste-lf"}, None), + ("CR/LF/CRLF paste", {"paste-lf", "paste-crlf", "paste-cr"}, None), + ("Unicode/whitespace paste", {"paste-unicode", "paste-whitespace"}, None), + ("Paste framing/ordering", {case["id"] for case in catalogue()["cases"] if case["kind"] == "paste"}, None), + ("Resize 120 -> 80", {"letter-a", "shift-enter", "paste-lf"}, 80), + ("Mouse while typing/pasting", {"mouse-interleave"}, None), + ("Mouse after focus regain", {"mouse-focus-refresh"}, None), + ("Mouse after resize", {"mouse-focus-refresh"}, 80), + ("Dead-key composition", {"dead-acute"}, None), + ("AltGr", {"altgr-euro"}, None), + ("IME composition", {"ime-commit"}, None), + ("Runtime mode transitions", {"mode-transitions"}, None), + ] + + def cell(case_ids, width, path, modes): + matched = [row for row in rows if row.get("case") in case_ids and row.get("path") == path + and row.get("mode") in modes and (width is None or row.get("width") == width)] + statuses = {row.get("status") for row in matched} + if "fail" in statuses: + return "FAIL" + if case_ids <= manual_cases and not any(status == "pass" for status in statuses): + return "MANUAL" + if {row.get("case") for row in matched} != case_ids: + return "PARTIAL" if "pass" in statuses else "INCONCLUSIVE" if "inconclusive" in statuses else "NOT TESTED" + if required_channels and {(row.get("host"), row.get("case")) for row in matched} != { + (channel, case_id) for channel in required_channels for case_id in case_ids}: + return "PARTIAL" + if case_ids == {"mode-transitions"} and path == "direct" and statuses <= {"unsupported", "inconclusive"}: + return "X - mOK ignored" + if path == "direct" and modes == {"legacy"} and statuses == {"unsupported"}: + return "X - becomes Enter" if case_ids == {"shift-enter"} else "X - loses modifier" + def case_passed(case_id): + statuses_for_case = {row.get("status") for row in matched if row.get("case") == case_id} + return "pass" in statuses_for_case or (width == 80 and path == "direct" and modes == {"legacy"} + and case_id == "shift-enter" and statuses_for_case == {"unsupported"}) + per_case_passed = all(case_passed(case_id) for case_id in case_ids) + allowed = {"pass", "inconclusive"} + if width == 80 and path == "direct" and modes == {"legacy"}: + allowed.add("unsupported") + if per_case_passed and statuses <= allowed: + return "PASS**" if "inconclusive" in statuses else "PASS" + if "inconclusive" in statuses: + return "INCONCLUSIVE" + if "unsupported" in statuses: + return "UNSUPPORTED" + return "NOT TESTED" + + table = [] + for name, case_ids, width in groups: + herdr_modes = {"legacy"} if name in {"Mouse after resize", "Runtime mode transitions"} else {"mok2"} if "Enter" in name or name in {"Resize 120 -> 80", "Dead-key composition", "AltGr", "IME composition"} else {"legacy"} + table.append((name, cell(case_ids, width, "herdr", herdr_modes), + cell(case_ids, width, "direct", {"legacy"}), cell(case_ids, width, "direct", {"kitty"}))) + return table + + +def herdr_protocol_label(result): + runs = {(host.get("channel"), run.get("path"), run.get("mode"), run.get("nonce")) + for host in result.get("hosts", []) for run in host.get("runs", []) + if run.get("path") == "herdr" and run.get("nonce")} + proven = {(row.get("host"), row.get("path"), row.get("mode"), row.get("nonce")) + for row in result.get("observations", []) + if row.get("path") == "herdr" and row.get("input_reader") == "windows-console" + and row.get("input_transport") == "win32-serialized" and row.get("nonce")} + return "Win32 (Herdr)*" if runs and runs <= proven else "Herdr default (UNKNOWN)*" + + +def print_qualification_matrix(result): + table = [("Thing", herdr_protocol_label(result), "Plain VT", "Kitty"), *qualification_matrix(result)] + widths = [max(len(str(row[column])) for row in table) for column in range(4)] + line = lambda row: " | ".join(str(value).ljust(widths[index]) for index, value in enumerate(row)) + print("\nWindows input qualification results") + print(line(table[0])) + print("-+-".join("-" * width for width in widths)) + for row in table[1:]: + print(line(row)) + print("* Herdr protocol label comes from runtime evidence; UNKNOWN is never treated as Win32.") + print("** At least one capable host passed; host capability gaps remain visible in report.json.") + print("MANUAL requires an operator-assisted -Manual run; no automated result is claimed.") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command", choices=["matrix", "report"]) + parser.add_argument("--input", type=Path) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + result = catalogue() if args.command == "matrix" else summarize(json.loads(args.input.read_text(encoding="utf-8-sig"))) + args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + if args.command == "report": + counts = result["counts"] + through_failures = sum(row["status"] == "fail" and row.get("path") == "herdr" for row in result["observations"]) + direct_failures = sum(row["status"] == "fail" and row.get("path") == "direct" for row in result["observations"]) + print(f"Observed: {counts['pass']} pass, {counts['fail']} fail, {counts['unsupported']} unsupported, " + f"{counts['inconclusive']} inconclusive, {counts['not_run']} not run; " + f"{result['coverage_missing']} planned rows missing") + if through_failures: + print(f"Assessment: {through_failures} through-Herdr failures need attribution; inspect report.json before treating them as product regressions") + elif result.get("errors") or result.get("cleanup_errors"): + print("Assessment: harness or cleanup failed; this run does not qualify input behavior") + elif direct_failures: + print(f"Assessment: {direct_failures} direct-host differences observed; these are not automatically Herdr bugs") + elif counts["inconclusive"] or counts["not_run"] or counts["unsupported"] or result["coverage_missing"]: + print("Assessment: observed assertions passed, but host limitations or qualification gaps remain") + else: + print("Assessment: all planned observed assertions passed") + if result.get("errors"): + print("Run errors: " + " | ".join(result["errors"])) + if result.get("cleanup_errors"): + print("Cleanup errors: " + " | ".join(result["cleanup_errors"])) + print_qualification_matrix(result) + return 1 if result["counts"]["fail"] or result.get("errors") or result.get("cleanup_errors") else 2 if not result["observed_checks_passed"] else 0 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) |
