aboutsummaryrefslogtreecommitdiff
path: root/scripts/windows_input
diff options
context:
space:
mode:
Diffstat (limited to 'scripts/windows_input')
-rw-r--r--scripts/windows_input/Bootstrap.ps18
-rw-r--r--scripts/windows_input/Common.ps15
-rw-r--r--scripts/windows_input/Native.cs63
-rw-r--r--scripts/windows_input/README.md14
-rw-r--r--scripts/windows_input/report.py38
5 files changed, 107 insertions, 21 deletions
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(); }
+ return AdoptClipboardLease(owner);
+ }
+ 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.
- // Reopen before adopting it so another writer cannot become our lease.
- if(!OpenClipboard(owner)) throw new Exception("Clipboard busy; cannot establish test ownership");
+ uint sequence=GetClipboardSequenceNumber();
try {
- if(owner==IntPtr.Zero || GetClipboardOwner()!=owner) throw new Exception("Clipboard ownership changed; refusing cleanup lease");
- return GetClipboardSequenceNumber();
- } finally { CloseClipboard(); }
+ 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(uint sequence) {
+ 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; "