diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-07-25 02:40:14 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-07-25 02:40:14 +0300 |
| commit | 8afd52ae57f9237f4f46138ad224dfc621d7ba59 (patch) | |
| tree | 349bbbd86c57863539ebc4c2eacf34550e076fa2 /scripts/windows_conpty_enhanced_input_probe.ps1 | |
| parent | 5f1957c27bd23f73b339265d3acd32f4a21878a3 (diff) | |
fix: bundle modern ConPTY on Windows (#1828)
refs #1533
refs #1644
Diffstat (limited to 'scripts/windows_conpty_enhanced_input_probe.ps1')
| -rw-r--r-- | scripts/windows_conpty_enhanced_input_probe.ps1 | 418 |
1 files changed, 418 insertions, 0 deletions
diff --git a/scripts/windows_conpty_enhanced_input_probe.ps1 b/scripts/windows_conpty_enhanced_input_probe.ps1 new file mode 100644 index 00000000..bbd2b40b --- /dev/null +++ b/scripts/windows_conpty_enhanced_input_probe.ps1 @@ -0,0 +1,418 @@ +param( + [Parameter(Mandatory = $true)] + [string] $ExePath, + + [string] $Session = "conpty-input-$([guid]::NewGuid().ToString('N'))", + + [string] $ExpectedConsoleHostPath = "" +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Invoke-Checked { + param([string] $Command, [string[]] $Arguments) + & $Command @Arguments + if ($LASTEXITCODE -ne 0) { + throw "command failed with exit code $LASTEXITCODE`: $Command $($Arguments -join ' ')" + } +} + +function Read-Pane { + param([string] $PaneId) + $output = & $script:Exe pane read $PaneId --source recent-unwrapped --lines 200 --format text 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "pane read failed with exit code $LASTEXITCODE`: $($output -join "`n")" + } + return $output -join "`n" +} + +function Wait-PaneText { + param( + [string] $PaneId, + [string] $Needle, + [int] $TimeoutSeconds = 10 + ) + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + do { + $text = Read-Pane -PaneId $PaneId + if ($text.Contains($Needle)) { + return $text + } + Start-Sleep -Milliseconds 200 + } while ((Get-Date) -lt $deadline) + return $text +} + +function New-ProbePane { + param([string] $Mode) + $created = & $script:Exe workspace create --cwd $PWD.Path 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "workspace create failed with exit code $LASTEXITCODE`: $($created -join "`n")" + } + $paneId = (($created -join "`n") | ConvertFrom-Json).result.root_pane.pane_id + if ([string]::IsNullOrWhiteSpace($paneId)) { + throw "workspace create did not return a root pane id: $($created -join "`n")" + } + + $command = "& '$script:ProbeExe' $Mode" + Invoke-Checked $script:Exe @("pane", "run", $paneId, $command) + $ready = "PROBE_READY_$($Mode.ToUpperInvariant())" + $text = Wait-PaneText -PaneId $paneId -Needle $ready + if (-not $text.Contains($ready)) { + throw "$Mode probe did not become ready: $text" + } + return $paneId +} + +function Get-LatestProbeHex { + param([string] $PaneText) + $hexMatches = [regex]::Matches($PaneText, "PROBE_ALL:([0-9a-f]+)") + if ($hexMatches.Count -eq 0) { + return "" + } + return $hexMatches[$hexMatches.Count - 1].Groups[1].Value +} + +function Wait-PaneHexAppend { + param( + [string] $PaneId, + [string] $PreviousHex, + [string] $ExpectedHex, + [int] $TimeoutSeconds = 4 + ) + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + do { + $paneText = Read-Pane -PaneId $PaneId + $latestHex = Get-LatestProbeHex -PaneText $paneText + if ($latestHex.StartsWith($PreviousHex)) { + $appendedHex = $latestHex.Substring($PreviousHex.Length) + if ($appendedHex -eq $ExpectedHex) { + return [ordered]@{ delivered = $true; pane = $paneText } + } + } + Start-Sleep -Milliseconds 200 + } while ((Get-Date) -lt $deadline) + return [ordered]@{ delivered = $false; pane = $paneText } +} + +function Send-KeyAndObserve { + param( + [string] $PaneId, + [string] $Key, + [string] $ExpectedHex + ) + $before = Get-LatestProbeHex -PaneText (Read-Pane -PaneId $PaneId) + Invoke-Checked $script:Exe @("pane", "send-keys", $PaneId, $Key) + $observed = Wait-PaneHexAppend -PaneId $PaneId -PreviousHex $before -ExpectedHex $ExpectedHex + return [ordered]@{ + key = $Key + expected_hex = $ExpectedHex + delivered = $observed.delivered + pane = $observed.pane + } +} + +function Send-RawAndObserve { + param( + [string] $PaneId, + [string] $Text, + [string] $ExpectedHex + ) + $before = Get-LatestProbeHex -PaneText (Read-Pane -PaneId $PaneId) + & $script:Exe pane send-text $PaneId $Text | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "pane send-text failed with exit code $LASTEXITCODE" + } + $observed = Wait-PaneHexAppend -PaneId $PaneId -PreviousHex $before -ExpectedHex $ExpectedHex + return [ordered]@{ + expected_hex = $ExpectedHex + delivered = $observed.delivered + pane = $observed.pane + } +} + +$script:Exe = (Resolve-Path $ExePath).Path +$workDir = Join-Path ([System.IO.Path]::GetTempPath()) "herdr-conpty-input-$([guid]::NewGuid().ToString('N'))" +$probeSource = Join-Path $workDir "probe.rs" +$script:ProbeExe = Join-Path $workDir "probe.exe" +$oldSession = $env:HERDR_SESSION +$oldSocket = $env:HERDR_SOCKET_PATH +$oldClientSocket = $env:HERDR_CLIENT_SOCKET_PATH +$server = $null +$report = [ordered]@{} +$failed = $false +$initialConsoleHostIds = @(Get-Process -Name conhost, OpenConsole -ErrorAction SilentlyContinue | ForEach-Object { $_.Id }) +$expectedConsoleHost = if ([string]::IsNullOrWhiteSpace($ExpectedConsoleHostPath)) { + $null +} else { + (Resolve-Path $ExpectedConsoleHostPath).Path +} + +try { + New-Item -ItemType Directory -Force -Path $workDir | Out-Null +@' +use std::ffi::c_void; + +const STD_INPUT_HANDLE: u32 = -10i32 as u32; +const STD_OUTPUT_HANDLE: u32 = -11i32 as u32; +const ENABLE_PROCESSED_INPUT: u32 = 0x0001; +const ENABLE_LINE_INPUT: u32 = 0x0002; +const ENABLE_ECHO_INPUT: u32 = 0x0004; +const ENABLE_VIRTUAL_TERMINAL_INPUT: u32 = 0x0200; + +type Handle = *mut c_void; + +#[link(name = "Kernel32")] +extern "system" { + fn GetStdHandle(kind: u32) -> Handle; + fn GetConsoleMode(handle: Handle, mode: *mut u32) -> i32; + fn SetConsoleMode(handle: Handle, mode: u32) -> i32; + fn ReadFile( + handle: Handle, + buffer: *mut c_void, + bytes_to_read: u32, + bytes_read: *mut u32, + overlapped: *mut c_void, + ) -> i32; + fn WriteFile( + handle: Handle, + buffer: *const c_void, + bytes_to_write: u32, + bytes_written: *mut u32, + overlapped: *mut c_void, + ) -> i32; +} + +fn write_all(handle: Handle, mut bytes: &[u8]) { + while !bytes.is_empty() { + let mut written = 0; + let ok = unsafe { + WriteFile( + handle, + bytes.as_ptr().cast(), + bytes.len() as u32, + &mut written, + std::ptr::null_mut(), + ) + }; + if ok == 0 || written == 0 { + std::process::exit(3); + } + bytes = &bytes[written as usize..]; + } +} + +fn main() { + let mode = std::env::args().nth(1).unwrap_or_else(|| "legacy".to_string()); + let input = unsafe { GetStdHandle(STD_INPUT_HANDLE) }; + let output = unsafe { GetStdHandle(STD_OUTPUT_HANDLE) }; + let mut console_mode = 0; + if unsafe { GetConsoleMode(input, &mut console_mode) } == 0 { + write_all(output, b"PROBE_ERROR_GET_MODE\r\n"); + std::process::exit(1); + } + let raw_vt_mode = (console_mode | ENABLE_VIRTUAL_TERMINAL_INPUT) + & !(ENABLE_PROCESSED_INPUT | ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT); + if unsafe { SetConsoleMode(input, raw_vt_mode) } == 0 { + write_all(output, b"PROBE_ERROR_SET_MODE\r\n"); + std::process::exit(2); + } + + if mode == "kitty" { + write_all(output, b"\x1b[>7u\x1b[?u\x1b[cPROBE_READY_KITTY\r\n"); + } else { + write_all(output, b"PROBE_READY_LEGACY\r\n"); + } + + let mut all = Vec::new(); + let mut buffer = [0u8; 256]; + loop { + let mut read = 0; + let ok = unsafe { + ReadFile( + input, + buffer.as_mut_ptr().cast(), + buffer.len() as u32, + &mut read, + std::ptr::null_mut(), + ) + }; + if ok == 0 || read == 0 { + break; + } + all.extend_from_slice(&buffer[..read as usize]); + let mut line = String::from("PROBE_ALL:"); + for byte in &all { + use std::fmt::Write as _; + let _ = write!(&mut line, "{byte:02x}"); + } + line.push_str("\r\n"); + write_all(output, line.as_bytes()); + } +} +'@ | Set-Content -NoNewline -Encoding utf8 $probeSource + + Invoke-Checked rustc @("--edition", "2021", $probeSource, "-o", $script:ProbeExe) + + $env:HERDR_SESSION = $Session + Remove-Item Env:HERDR_SOCKET_PATH -ErrorAction SilentlyContinue + Remove-Item Env:HERDR_CLIENT_SOCKET_PATH -ErrorAction SilentlyContinue + + $os = Get-CimInstance Win32_OperatingSystem + $report.os = [ordered]@{ + caption = $os.Caption + version = $os.Version + build = $os.BuildNumber + } + + Invoke-Checked $script:Exe @("--version") + & $script:Exe --default-config | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "command failed with exit code $LASTEXITCODE`: $script:Exe --default-config" + } + + $server = Start-Process -FilePath $script:Exe -ArgumentList "server" -PassThru -WindowStyle Hidden + $deadline = (Get-Date).AddSeconds(10) + $serverReady = $false + do { + Start-Sleep -Milliseconds 250 + $status = & $script:Exe status server 2>&1 + if ($LASTEXITCODE -eq 0 -and (($status -join "`n") -match "status: running")) { + $serverReady = $true + break + } + } while ((Get-Date) -lt $deadline) + if (-not $serverReady) { + throw "server did not become ready: $($status -join "`n")" + } + + $legacyPane = New-ProbePane -Mode "legacy" + $report.legacy_alt_v = Send-KeyAndObserve -PaneId $legacyPane -Key "alt+v" -ExpectedHex "1b76" + + $kittyPane = New-ProbePane -Mode "kitty" + $report.kitty_initial = Wait-PaneText -PaneId $kittyPane -Needle "1b5b3f3775" + $kittyInitialHex = Get-LatestProbeHex -PaneText $report.kitty_initial + $report.device_attributes_response = $kittyInitialHex -match "1b5b3f(?:3[0-9]|3b)+63" + $report.kitty_query_response = $kittyInitialHex.Contains("1b5b3f3775") + $report.kitty_alt_v = Send-KeyAndObserve -PaneId $kittyPane -Key "alt+v" -ExpectedHex "1b5b3131383b333a3175" + $report.kitty_ctrl_u = Send-KeyAndObserve -PaneId $kittyPane -Key "ctrl+u" -ExpectedHex "1b5b3131373b353a3175" + $report.kitty_ctrl_v = Send-KeyAndObserve -PaneId $kittyPane -Key "ctrl+v" -ExpectedHex "1b5b3131383b353a3175" + $report.kitty_shift_enter = Send-KeyAndObserve -PaneId $kittyPane -Key "shift+enter" -ExpectedHex "1b5b31333b3275" + $report.kitty_ctrl_backspace = Send-KeyAndObserve -PaneId $kittyPane -Key "ctrl+backspace" -ExpectedHex "1b5b3132373b3575" + $report.kitty_up = Send-KeyAndObserve -PaneId $kittyPane -Key "up" -ExpectedHex "1b5b313b313a3141" + $report.kitty_escape = Send-KeyAndObserve -PaneId $kittyPane -Key "esc" -ExpectedHex "1b5b323775" + $report.raw_kitty_alt_v = Send-RawAndObserve -PaneId $kittyPane -Text ([char]27 + "[118;3:1u") -ExpectedHex "1b5b3131383b333a3175" + $report.raw_kitty_ctrl_u = Send-RawAndObserve -PaneId $kittyPane -Text ([char]27 + "[117;5:1u") -ExpectedHex "1b5b3131373b353a3175" + $report.raw_kitty_ctrl_v = Send-RawAndObserve -PaneId $kittyPane -Text ([char]27 + "[118;5:1u") -ExpectedHex "1b5b3131383b353a3175" + $report.raw_kitty_shift_enter = Send-RawAndObserve -PaneId $kittyPane -Text ([char]27 + "[13;2u") -ExpectedHex "1b5b31333b3275" + $report.raw_kitty_ctrl_backspace = Send-RawAndObserve -PaneId $kittyPane -Text ([char]27 + "[127;5u") -ExpectedHex "1b5b3132373b3575" + $report.raw_kitty_ctrl_delete = Send-RawAndObserve -PaneId $kittyPane -Text ([char]27 + "[57426;5u") -ExpectedHex "1b5b35373432363b3575" + $report.raw_alt_v = Send-RawAndObserve -PaneId $kittyPane -Text ([char]27 + "v") -ExpectedHex "1b76" + $report.raw_ctrl_u = Send-RawAndObserve -PaneId $kittyPane -Text ([string][char]0x15) -ExpectedHex "15" + + $consoleHosts = @(Get-Process -Name conhost, OpenConsole -ErrorAction SilentlyContinue | + Where-Object { $initialConsoleHostIds -notcontains $_.Id } | + ForEach-Object { + $path = "" + $version = "" + try { $path = $_.Path } catch {} + try { $version = $_.MainModule.FileVersionInfo.FileVersion } catch {} + [ordered]@{ + id = $_.Id + name = $_.ProcessName + path = $path + version = $version + } + }) + $report.new_console_hosts = $consoleHosts + $appLocalHostRequired = $null -ne $expectedConsoleHost + if ($appLocalHostRequired) { + $report.expected_console_host = $expectedConsoleHost + $report.app_local_console_host = @($consoleHosts | Where-Object { + $_.name -ieq "OpenConsole" -and $_.path -ieq $expectedConsoleHost + }).Count -gt 0 + } else { + $report.app_local_console_host = $null + } + + $failed = -not $report.legacy_alt_v.delivered ` + -or -not $report.device_attributes_response ` + -or -not $report.kitty_query_response ` + -or -not $report.kitty_alt_v.delivered ` + -or -not $report.kitty_ctrl_u.delivered ` + -or -not $report.kitty_ctrl_v.delivered ` + -or -not $report.kitty_shift_enter.delivered ` + -or -not $report.kitty_ctrl_backspace.delivered ` + -or -not $report.kitty_up.delivered ` + -or -not $report.kitty_escape.delivered ` + -or -not $report.raw_kitty_alt_v.delivered ` + -or -not $report.raw_kitty_ctrl_u.delivered ` + -or -not $report.raw_kitty_ctrl_v.delivered ` + -or -not $report.raw_kitty_shift_enter.delivered ` + -or -not $report.raw_kitty_ctrl_backspace.delivered ` + -or -not $report.raw_kitty_ctrl_delete.delivered ` + -or -not $report.raw_alt_v.delivered ` + -or -not $report.raw_ctrl_u.delivered ` + -or ($appLocalHostRequired -and -not $report.app_local_console_host) +} finally { + if ($null -ne $server) { + try { + $stopOutput = & $script:Exe server stop 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Host "server stop during cleanup failed: $($stopOutput -join "`n")" + } + } catch { + Write-Host "server stop during cleanup failed: $($_.Exception.Message)" + } + Wait-Process -Id $server.Id -Timeout 10 -ErrorAction SilentlyContinue + $server.Refresh() + if (-not $server.HasExited) { + & taskkill.exe /PID $server.Id /T /F 2>&1 | Out-Null + } + } + if ($null -ne $expectedConsoleHost) { + $hostExitDeadline = (Get-Date).AddSeconds(5) + do { + $remainingHosts = @(Get-Process -Name OpenConsole -ErrorAction SilentlyContinue | + Where-Object { $initialConsoleHostIds -notcontains $_.Id } | + Where-Object { + $path = "" + try { $path = $_.Path } catch {} + $path -ieq $expectedConsoleHost + }) + if ($remainingHosts.Count -eq 0) { + break + } + Start-Sleep -Milliseconds 100 + } while ((Get-Date) -lt $hostExitDeadline) + $report.app_local_console_host_exited = $remainingHosts.Count -eq 0 + if (-not $report.app_local_console_host_exited) { + $failed = $true + } + } + $global:LASTEXITCODE = 0 + if ($null -eq $oldSession) { + Remove-Item Env:HERDR_SESSION -ErrorAction SilentlyContinue + } else { + $env:HERDR_SESSION = $oldSession + } + if ($null -eq $oldSocket) { + Remove-Item Env:HERDR_SOCKET_PATH -ErrorAction SilentlyContinue + } else { + $env:HERDR_SOCKET_PATH = $oldSocket + } + if ($null -eq $oldClientSocket) { + Remove-Item Env:HERDR_CLIENT_SOCKET_PATH -ErrorAction SilentlyContinue + } else { + $env:HERDR_CLIENT_SOCKET_PATH = $oldClientSocket + } + + $json = $report | ConvertTo-Json -Depth 8 + Write-Host $json + Remove-Item -Recurse -Force $workDir -ErrorAction SilentlyContinue +} + +if ($failed) { + throw "enhanced Windows ConPTY input probe failed; see the JSON report above" +} |
