diff --git a/scripts/test_windows_input.ps1 b/scripts/test_windows_input.ps1 index 28839a07..7396bffa 100644 --- a/scripts/test_windows_input.ps1 +++ b/scripts/test_windows_input.ps1 @@ -11,7 +11,7 @@ param( [ValidateSet('default', 'win32', 'vt')][string] $Profile = 'default', [ValidateSet('native', 'legacy', 'mok2', 'kitty')][string[]] $Modes = @('native', 'legacy', 'mok2', 'kitty'), [ValidateSet('stable', 'preview')][string[]] $Channels = @('stable', 'preview'), - [ValidateSet('direct', 'herdr')][string[]] $Paths = @('direct', 'herdr'), + [ValidateSet('direct', 'herdr', 'herdr-remote')][string[]] $Paths = @('direct', 'herdr'), [string[]] $Cases, [int[]] $Widths = @(80, 119, 120, 121, 132, 160, 240), [int[]] $Heights = @(24, 50), @@ -172,7 +172,7 @@ try { $cursorPosition = $null; $mouseReporting = $false Update-GauntletLease $root try { - if ($path -eq 'herdr') { + if ($path -in @('herdr', 'herdr-remote')) { $server = New-GauntletProcess $exe @('--session', $nonce, 'server') $plan -Capture # Drain pipes asynchronously; server output must never block readiness. $serverOut = $server.StandardOutput.ReadToEndAsync(); $serverErr = $server.StandardError.ReadToEndAsync() @@ -267,11 +267,11 @@ try { $row.outer_sequence = $fresh.sequence $row.negotiation_hex = $ready.negotiation_hex if ($mode -eq 'kitty' -and -not $ready.kitty_acknowledged) { $row.status = 'inconclusive'; $row.reason = 'Kitty disambiguation query not acknowledged; host support is not established'; continue } - if ($case.kind -in @('paste', 'mouse-interleave') -and [HerdrInputGauntlet.Desktop]::CountClipboardFormats() -ne 0) { + if ($case.kind -in @('paste', 'mouse-interleave', 'clipboard-image', 'clipboard-mixed') -and [HerdrInputGauntlet.Desktop]::CountClipboardFormats() -ne 0) { $row.status = 'not_run'; $row.reason = 'Clipboard is not empty; refusing to replace user data'; continue } if ($case.kind -in @('mouse-interleave', 'mouse-focus-refresh')) { $null = Observer-Request $plan 'mouse-on'; $mouseReporting = $true } - $traceLineCount = if ($path -eq 'herdr' -and (Test-Path -LiteralPath $plan.input_trace)) { @(Get-Content -LiteralPath $plan.input_trace).Count } else { 0 } + $traceLineCount = if ($path -in @('herdr', 'herdr-remote') -and (Test-Path -LiteralPath $plan.input_trace)) { @(Get-Content -LiteralPath $plan.input_trace).Count } else { 0 } $begin = Observer-Request $plan 'begin' $row.ready = $true; $row.pane_geometry = $begin.geometry [HerdrInputGauntlet.Desktop]::Guard($window, $nonce, $windowPid) @@ -282,6 +282,10 @@ try { } elseif ($case.kind -eq 'paste') { $clipboardSequence = [HerdrInputGauntlet.Desktop]::SetEmptyClipboard($window, $case.text) $null = [HerdrInputGauntlet.Desktop]::Chord($window, $nonce, $windowPid, [int[]]@(17, 86)) + } elseif ($case.kind -in @('clipboard-image', 'clipboard-mixed')) { + $text = if ($case.kind -eq 'clipboard-mixed') { [string]$case.text } else { $null } + $clipboardSequence = [HerdrInputGauntlet.Desktop]::SetEmptyClipboardImage($window, $text) + $null = [HerdrInputGauntlet.Desktop]::Chord($window, $nonce, $windowPid, [int[]]@(17, 86)) } elseif ($case.kind -eq 'mouse-interleave') { $cursorPosition = [HerdrInputGauntlet.Desktop]::Cursor() $null = [HerdrInputGauntlet.Desktop]::Chord($window, $nonce, $windowPid, [int[]]@(65)) @@ -329,9 +333,18 @@ try { if ($case.kind -eq 'mode-transitions') { $null = Observer-Request $plan 'set-mode' $mode } $row.capture_id = $end.id $row.hex = $end.hex; $row.records = $end.records; $row.error = $end.error; $row.complete = $end.quiet_reached + if ($case.kind -eq 'clipboard-image' -and $path -eq 'herdr-remote') { + $capture = [Convert]::FromHexString([string]$end.hex) + if ($capture.Length -ge 12) { + $stagedPath = [Text.Encoding]::UTF8.GetString($capture, 6, $capture.Length - 12) + if (Test-Path -LiteralPath $stagedPath -PathType Leaf) { + $row.staged_image_sha256 = (Get-FileHash -LiteralPath $stagedPath -Algorithm SHA256).Hash + } + } + } $row.final_outer_geometry = (Outer-State $plan).geometry $row.status = 'observed'; $row.final_pane_geometry = $end.geometry - if ($path -eq 'herdr' -and (Test-Path -LiteralPath $plan.input_trace)) { + if ($path -in @('herdr', 'herdr-remote') -and (Test-Path -LiteralPath $plan.input_trace)) { $traceLines = @(Get-Content -LiteralPath $plan.input_trace) $trace = $traceLines -join "`n" $captureTrace = ($traceLines | Select-Object -Skip $traceLineCount) -join "`n" @@ -339,7 +352,7 @@ try { if ($captureTrace.Contains('transport=win32-serialized')) { $row.input_transport = 'win32-serialized' } } if ($null -ne $clipboardSequence) { - if (-not [HerdrInputGauntlet.Desktop]::ClearOwnedClipboard($clipboardSequence)) { throw 'Could not clear test-owned clipboard' } + if (-not [HerdrInputGauntlet.Desktop]::ClearOwnedClipboard($window, $clipboardSequence)) { throw 'Could not clear test-owned clipboard' } $clipboardSequence = $null } if ($mouseReporting) { $null = Observer-Request $plan 'mouse-off'; $mouseReporting = $false } @@ -361,7 +374,7 @@ try { if ($null -ne $cursorPosition -and [HerdrInputGauntlet.Desktop]::GetForegroundWindow() -eq $window) { [HerdrInputGauntlet.Desktop]::RestoreCursor($cursorPosition); $cursorPosition = $null } - if ($null -ne $clipboardSequence -and -not [HerdrInputGauntlet.Desktop]::ClearOwnedClipboard($clipboardSequence)) { $document.cleanup_errors += 'Could not clear test-owned clipboard' } + if ($null -ne $clipboardSequence -and -not [HerdrInputGauntlet.Desktop]::ClearOwnedClipboard($window, $clipboardSequence)) { $document.cleanup_errors += 'Could not clear test-owned clipboard' } [IO.File]::WriteAllText((Join-Path $work 'probe-stop'), '') if (Test-Path (Join-Path $work 'ready.json')) { try { @@ -370,7 +383,7 @@ try { } catch { $document.cleanup_errors += $_.Exception.Message } } # Normal detach only while still owning foreground focus; never type into another window. - if ($injectionAuthorized -and $path -eq 'herdr' -and [HerdrInputGauntlet.Desktop]::IsOwned($window, $nonce, $windowPid) -and [HerdrInputGauntlet.Desktop]::GetForegroundWindow() -eq $window) { + if ($injectionAuthorized -and $path -in @('herdr', 'herdr-remote') -and [HerdrInputGauntlet.Desktop]::IsOwned($window, $nonce, $windowPid) -and [HerdrInputGauntlet.Desktop]::GetForegroundWindow() -eq $window) { try { $null = [HerdrInputGauntlet.Desktop]::Chord($window, $nonce, $windowPid, [int[]]@(17, 66)) Start-Sleep -Milliseconds 100 @@ -398,9 +411,7 @@ try { $document.cleanup_errors += "$nonce server required forced cleanup" if (-not $server.WaitForExit(5000)) { $document.cleanup_errors += "$nonce server remained active after forced cleanup" } } - if (-not (Test-Path (Join-Path $work 'bootstrap-exit.json'))) { - try { $null = Invoke-GauntletProcess $exe @('session', 'delete', $nonce) $plan } catch { $document.cleanup_errors += $_.Exception.Message } - } + try { $null = Invoke-GauntletProcess $exe @('session', 'delete', $nonce) $plan } catch { $document.cleanup_errors += $_.Exception.Message } $server.Dispose() } if ($null -ne $launcher) { $launcher.Dispose() } diff --git a/scripts/test_windows_input.py b/scripts/test_windows_input.py index f8a70eee..38868843 100644 --- a/scripts/test_windows_input.py +++ b/scripts/test_windows_input.py @@ -89,6 +89,18 @@ class WindowsInputGauntletTests(unittest.TestCase): (b"\x1b[200~\xff\x1b[201~", "fail")]: self.assertEqual(verdict(case, "kitty", {**self.evidence, "hex": data.hex()})[0], expected) + def test_remote_clipboard_image_requires_empty_host_paste_and_staged_png_path(self): + case = self.cases["clipboard-image"] + empty_paste = b"\x1b[200~\x1b[201~" + staged = b"\x1b[200~C:\\Temp\\herdr-clipboard-images-user\\image.png\x1b[201~" + self.assertEqual(verdict(case, "legacy", {**self.evidence, "path": "direct", "hex": empty_paste.hex()})[0], "pass") + remote = {**self.evidence, "path": "herdr-remote", "hex": staged.hex(), + "staged_image_sha256": case["expected"]["legacy"]["sha256"]} + self.assertEqual(verdict(case, "legacy", remote)[0], "pass") + self.assertEqual(verdict(case, "legacy", {**remote, "staged_image_sha256": "0" * 64})[0], "fail") + self.assertEqual(verdict(case, "legacy", {**self.evidence, "path": "herdr-remote", "hex": empty_paste.hex()})[0], "fail") + self.assertEqual(verdict(case, "legacy", {**self.evidence, "path": "herdr", "hex": empty_paste.hex()})[0], "not_run") + def test_mouse_interleave_requires_ordered_motion_and_paste(self): case = self.cases["mouse-interleave"] motion = b"\x1b[<35;10;5M" diff --git a/scripts/windows_input/Bootstrap.ps1 b/scripts/windows_input/Bootstrap.ps1 index 4f6884fb..6008c64f 100644 --- a/scripts/windows_input/Bootstrap.ps1 +++ b/scripts/windows_input/Bootstrap.ps1 @@ -14,7 +14,7 @@ Add-Type -Path "$PSScriptRoot/Native.cs" $before = [HerdrInputGauntlet.ConsoleProbe]::Geometry() $child = $null try { - if ($plan.path -eq 'herdr') { + if ($plan.path -in @('herdr', 'herdr-remote')) { $child = New-GauntletProcess $plan.exe @('--session', $plan.session) $plan } else { $child = New-GauntletProcess $plan.pwsh @('-NoProfile', '-File', "$PSScriptRoot/Probe.ps1", '-PlanPath', $PlanPath) $plan @@ -39,12 +39,6 @@ try { } $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 index 537b66ae..d944e87b 100644 --- a/scripts/windows_input/Common.ps1 +++ b/scripts/windows_input/Common.ps1 @@ -40,7 +40,10 @@ function New-GauntletProcess($Exe, $Arguments, $Plan, [switch] $Capture) { $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 } + if ($Plan.path -in @('herdr', 'herdr-remote')) { $info.Environment['HERDR_WINDOWS_INPUT_TRACE_FILE'] = $Plan.input_trace } + if ($Plan.path -eq 'herdr-remote') { + $info.Environment['HERDR_REMOTE_KEYBINDINGS'] = 'local' + } } $info.RedirectStandardOutput = $Capture.IsPresent $info.RedirectStandardError = $Capture.IsPresent diff --git a/scripts/windows_input/Native.cs b/scripts/windows_input/Native.cs index 5f941f1a..42b387b1 100644 --- a/scripts/windows_input/Native.cs +++ b/scripts/windows_input/Native.cs @@ -115,6 +115,7 @@ namespace HerdrInputGauntlet { [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("user32.dll",CharSet=CharSet.Unicode)] static extern uint RegisterClipboardFormat(string format); [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); @@ -142,6 +143,7 @@ namespace HerdrInputGauntlet { } public static uint SetEmptyClipboard(IntPtr owner,string text) { + if(owner==IntPtr.Zero) throw new Exception("Clipboard owner is required"); if(!OpenClipboard(owner)) throw new Exception("Clipboard busy; refusing replacement"); IntPtr memory=IntPtr.Zero; try { @@ -155,17 +157,64 @@ namespace HerdrInputGauntlet { 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(); } + return AdoptClipboardLease(owner); } - public static bool ClearOwnedClipboard(uint sequence) { + public static uint SetEmptyClipboardImage(IntPtr owner,string text) { + if(owner==IntPtr.Zero) throw new Exception("Clipboard owner is required"); + if(!OpenClipboard(owner)) throw new Exception("Clipboard busy; refusing replacement"); + IntPtr pngMemory=IntPtr.Zero,textMemory=IntPtr.Zero; + bool complete=false; + try { + if(CountClipboardFormats()!=0) throw new Exception("Clipboard changed or contains user data; refusing replacement"); + byte[] png=Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAANSURBVBhXY/jPwPAfAAUAAf+mXJtdAAAAAElFTkSuQmCC"); + pngMemory=GlobalAlloc(2,new UIntPtr((uint)png.Length)); + if(pngMemory==IntPtr.Zero) throw new Exception("Clipboard allocation failed"); + var pointer=GlobalLock(pngMemory); + if(pointer==IntPtr.Zero) throw new Exception("Clipboard allocation failed"); + try { Marshal.Copy(png,0,pointer,png.Length); } finally { GlobalUnlock(pngMemory); } + if(!EmptyClipboard()) throw new Exception("Clipboard clear failed"); + uint pngFormat=RegisterClipboardFormat("PNG"); + if(pngFormat==0 || SetClipboardData(pngFormat,pngMemory)==IntPtr.Zero) throw new Exception("Clipboard image write failed"); + pngMemory=IntPtr.Zero; + if(text!=null) { + byte[] data=Encoding.Unicode.GetBytes(text+"\0"); + textMemory=GlobalAlloc(2,new UIntPtr((uint)data.Length)); + if(textMemory==IntPtr.Zero) throw new Exception("Clipboard allocation failed"); + pointer=GlobalLock(textMemory); + if(pointer==IntPtr.Zero) throw new Exception("Clipboard allocation failed"); + try { Marshal.Copy(data,0,pointer,data.Length); } finally { GlobalUnlock(textMemory); } + if(SetClipboardData(13,textMemory)==IntPtr.Zero) throw new Exception("Clipboard text write failed"); + textMemory=IntPtr.Zero; + } + complete=true; + } finally { + if(!complete) EmptyClipboard(); + if(pngMemory!=IntPtr.Zero) GlobalFree(pngMemory); + if(textMemory!=IntPtr.Zero) GlobalFree(textMemory); + CloseClipboard(); + } + return AdoptClipboardLease(owner); + } + static uint AdoptClipboardLease(IntPtr owner) { + // Closing can synthesize additional formats and advance the sequence. + uint sequence=GetClipboardSequenceNumber(); + try { + if(!OpenClipboard(owner)) throw new Exception("Clipboard busy; cannot establish test ownership"); + try { + if(GetClipboardOwner()!=owner || GetClipboardSequenceNumber()!=sequence) + throw new Exception("Clipboard ownership changed; refusing cleanup lease"); + return sequence; + } finally { CloseClipboard(); } + } catch(Exception error) { + if(!ClearOwnedClipboard(owner,sequence)) + throw new Exception("Could not clean clipboard after ownership verification failed",error); + throw; + } + } + public static bool ClearOwnedClipboard(IntPtr owner,uint sequence) { + if(owner==IntPtr.Zero) return false; if(!OpenClipboard(IntPtr.Zero)) return false; - try { return GetClipboardSequenceNumber()!=sequence || EmptyClipboard(); } + try { return GetClipboardSequenceNumber()!=sequence || GetClipboardOwner()!=owner || EmptyClipboard(); } finally { CloseClipboard(); } } public static string Title(IntPtr hwnd) { var text=new StringBuilder(1024); GetWindowText(hwnd,text,text.Capacity); return text.ToString(); } diff --git a/scripts/windows_input/README.md b/scripts/windows_input/README.md index 36caf3f6..5f8f463f 100644 --- a/scripts/windows_input/README.md +++ b/scripts/windows_input/README.md @@ -87,6 +87,16 @@ arrays normally: -Widths 120 -Heights 30 ``` +`-Paths herdr-remote` runs the current client against its disposable local +server with the remote clipboard bridge active. It needs no SSH host. A focused +clipboard-image qualification is: + +```powershell +.\scripts\test_windows_input.ps1 -AllowInputInjection ` + -Channels stable -Paths direct,herdr-remote -Modes legacy ` + -Cases clipboard-image,clipboard-mixed -Widths 80 -Heights 24 +``` + 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. @@ -120,6 +130,8 @@ Every run needs a **new** output directory. By default it is 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. +- Image-only Ctrl+V through the remote clipboard bridge, plus a mixed image/text + clipboard check that proves Windows Terminal and Herdr preserve the text paste. - 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 @@ -138,7 +150,7 @@ 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 +confirmation-triggering burst paste; non-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 diff --git a/scripts/windows_input/report.py b/scripts/windows_input/report.py index 7c15c149..d6c20803 100644 --- a/scripts/windows_input/report.py +++ b/scripts/windows_input/report.py @@ -78,6 +78,12 @@ def catalogue(): ("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="clipboard-image", kind="clipboard-image", + expected={mode: {"clipboard_image": True, + "sha256": "4BA8D4FD5AB42544FEEFF22D50E84412502433CC95952FD1DF9A5293588DDBEA"} + for mode in MODES[1:]})) + cases.append(dict(id="clipboard-mixed", kind="clipboard-mixed", text="clipboard text wins", + expected={mode: {"paste": "clipboard text wins"} 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", @@ -115,7 +121,6 @@ def catalogue(): ("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) @@ -132,6 +137,8 @@ def verdict(case, mode, evidence): return "inconclusive", "Missing readiness, focus, or complete capture" if evidence.get("error"): return "inconclusive", evidence["error"] + if case["kind"] == "clipboard-image" and evidence.get("path") == "herdr": + return "not_run", "Clipboard image bridging is active only for remote clients" 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): @@ -204,6 +211,25 @@ def verdict(case, mode, evidence): raw = bytes.fromhex(evidence["hex"]) except (KeyError, ValueError, TypeError): return "inconclusive", "Missing or malformed raw bytes" + if expected.get("clipboard_image"): + if evidence.get("path") == "direct": + return (("pass", "Terminal emitted an empty bracketed paste for image-only clipboard") + if raw == b"\x1b[200~\x1b[201~" else + ("fail", "Terminal did not emit an empty bracketed paste for image-only clipboard")) + if evidence.get("path") != "herdr-remote": + return "not_run", "Clipboard image bridge requires the remote-client gauntlet path" + if not raw.startswith(b"\x1b[200~") or not raw.endswith(b"\x1b[201~"): + return "fail", "Remote clipboard image did not reach the pane as one paste" + try: + path = raw[6:-6].decode("utf-8") + except UnicodeDecodeError: + return "fail", "Staged clipboard image path is not UTF-8" + valid_path = re.fullmatch(r"[A-Za-z]:\\.*\\herdr-clipboard-images-[^\\]+\\[^\\]+\.png", path) + if not valid_path: + return "fail", "Pane did not receive a staged clipboard PNG path" + return (("pass", "Exact clipboard PNG was staged and its path reached the pane") + if evidence.get("staged_image_sha256") == expected["sha256"] else + ("fail", "Staged clipboard image contents differ from the fixture")) if expected.get("mouse_interleave"): motion = rb"(?:\x1b\[<35;\d+;\d+M)+" newline = rb"(?:\r\n|\r|\n)" @@ -332,6 +358,7 @@ def qualification_matrix(result): ("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), + ("Remote clipboard image", {"clipboard-image", "clipboard-mixed"}, 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), @@ -378,7 +405,8 @@ def qualification_matrix(result): 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), + herdr_path = "herdr-remote" if name == "Remote clipboard image" else "herdr" + table.append((name, cell(case_ids, width, herdr_path, herdr_modes), cell(case_ids, width, "direct", {"legacy"}), cell(case_ids, width, "direct", {"kitty"}))) return table @@ -386,10 +414,10 @@ def qualification_matrix(result): 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")} + if run.get("path") in {"herdr", "herdr-remote"} 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" + if row.get("path") in {"herdr", "herdr-remote"} 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)*" @@ -418,7 +446,7 @@ def main(): 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"]) + through_failures = sum(row["status"] == "fail" and row.get("path") != "direct" 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; "