aboutsummaryrefslogtreecommitdiff
path: root/scripts/windows_input
diff options
context:
space:
mode:
Diffstat (limited to 'scripts/windows_input')
-rw-r--r--scripts/windows_input/Bootstrap.ps151
-rw-r--r--scripts/windows_input/Common.ps188
-rw-r--r--scripts/windows_input/Native.cs439
-rw-r--r--scripts/windows_input/Probe.ps170
-rw-r--r--scripts/windows_input/README.md213
-rw-r--r--scripts/windows_input/report.py446
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())