From fb134ee3516538da6cf661e17412ed361b22075b Mon Sep 17 00:00:00 2001
From: iamiks Ourocode
Start with a PM interview, clarify the task, approve execution, and keep proof in the terminal.
install
-+ Windows uses PowerShell 5.1+ or 7+, Erlang/OTP, and `escript.exe`. + Downloaded releases verify the `.sha256` sidecar before install, write to `%LOCALAPPDATA%\Ourocode`, + update the user PATH for new shells, and include `uninstall.ps1` for repeatable reinstall cycles. + The Windows path does not require Bash, WSL, or a permanent execution-policy change. + Release builders also need Git, Elixir/Mix, Rust stable with the MSVC target, and MSVC Build Tools. +
curl -fsSL https://raw.githubusercontent.com/Q00/ourocode/release/bootstrap/install.sh | bash
+ # Windows final-user checks
+$PSVersionTable.PSVersion
+Get-Command escript.exe
+erl -eval "erlang:display(erlang:system_info(otp_release)), halt()." -noshell
+
+# Windows install
+powershell -NoProfile -ExecutionPolicy Bypass -File .\install.ps1 -Version <version>
+ourocode --version
+powershell -NoProfile -ExecutionPolicy Bypass -File .\uninstall.ps1 -AllVersions
+
+# Windows release builder
+.\scripts\package-windows.ps1 -Version <version>
+
+# macOS / Linux
+curl -fsSL https://raw.githubusercontent.com/Q00/ourocode/release/bootstrap/install.sh | bash
ourocode
ourocode --prompt "/agents" --format json
ourocode --verify --format json --project-dir .
diff --git a/install.ps1 b/install.ps1
new file mode 100644
index 0000000..48120c2
--- /dev/null
+++ b/install.ps1
@@ -0,0 +1,412 @@
+[CmdletBinding()]
+param(
+ [string]$Version,
+ [string]$LocalZip,
+ [string]$Sha256,
+ [string]$InstallRoot,
+ [switch]$NoPathUpdate,
+ [string]$Repo = "Q00/ourocode",
+ [string]$ReleaseUrl,
+ [string]$Sha256Url,
+ [switch]$SkipPrerequisiteCheckForTest
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = "Stop"
+
+function Stop-Install {
+ param([string]$Message)
+ [Console]::Error.WriteLine($Message)
+ exit 1
+}
+
+function Resolve-ExistingFile {
+ param(
+ [string]$Path,
+ [string]$Description
+ )
+
+ if ([string]::IsNullOrWhiteSpace($Path)) {
+ Stop-Install "$Description path was empty."
+ }
+
+ $resolved = Resolve-Path -LiteralPath $Path -ErrorAction SilentlyContinue
+ if (-not $resolved) {
+ Stop-Install "$Description not found: $Path"
+ }
+
+ return $resolved.ProviderPath
+}
+
+function Get-DefaultInstallRoot {
+ if ([string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) {
+ Stop-Install "LOCALAPPDATA is not set. Pass -InstallRoot to choose an install directory."
+ }
+
+ return (Join-Path $env:LOCALAPPDATA "Ourocode")
+}
+
+function Get-VersionFromZipName {
+ param([string]$ZipPath)
+
+ $name = [System.IO.Path]::GetFileName($ZipPath)
+ $match = [regex]::Match($name, '^ourocode-v(.+)-windows-x64\.zip$', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
+ if ($match.Success) {
+ return $match.Groups[1].Value
+ }
+
+ return $null
+}
+
+function Assert-EscriptAvailable {
+ param([switch]$SkipForTest)
+
+ if ($SkipForTest) {
+ Write-Host "==> skipping escript.exe prerequisite check for installer test"
+ return
+ }
+
+ $escript = Get-Command escript.exe -ErrorAction SilentlyContinue
+ if (-not $escript) {
+ Stop-Install @"
+Erlang/OTP runtime was not found: escript.exe is not available on PATH.
+
+Ourocode is installed as an Erlang escript and needs Erlang/OTP to run.
+Install Erlang/OTP from https://www.erlang.org/downloads, open a new PowerShell
+session, confirm `Get-Command escript.exe` succeeds, then rerun this installer.
+"@
+ }
+}
+
+function Get-ReleaseAssetUrl {
+ param(
+ [string]$Version,
+ [string]$Repo,
+ [string]$ReleaseUrl
+ )
+
+ $assetName = "ourocode-v$Version-windows-x64.zip"
+ if ([string]::IsNullOrWhiteSpace($ReleaseUrl)) {
+ return "https://github.com/$Repo/releases/download/v$Version/$assetName"
+ }
+
+ return $ReleaseUrl
+}
+
+function Save-UrlToFile {
+ param(
+ [string]$Url,
+ [string]$Destination
+ )
+
+ $client = New-Object System.Net.WebClient
+ try {
+ $client.DownloadFile($Url, $Destination)
+ }
+ finally {
+ $client.Dispose()
+ }
+}
+
+function Read-UrlText {
+ param([string]$Url)
+
+ $client = New-Object System.Net.WebClient
+ try {
+ return $client.DownloadString($Url)
+ }
+ finally {
+ $client.Dispose()
+ }
+}
+
+function Save-ReleaseZip {
+ param(
+ [string]$Version,
+ [string]$Repo,
+ [string]$ReleaseUrl,
+ [string]$DestinationDirectory
+ )
+
+ $assetName = "ourocode-v$Version-windows-x64.zip"
+ $ReleaseUrl = Get-ReleaseAssetUrl -Version $Version -Repo $Repo -ReleaseUrl $ReleaseUrl
+ $destination = Join-Path $DestinationDirectory $assetName
+ Write-Host "==> downloading $ReleaseUrl"
+ Save-UrlToFile -Url $ReleaseUrl -Destination $destination
+ return $destination
+}
+
+function Get-ExpectedReleaseSha256 {
+ param(
+ [string]$Version,
+ [string]$Repo,
+ [string]$ReleaseUrl,
+ [string]$Sha256Url
+ )
+
+ if (-not [string]::IsNullOrWhiteSpace($Sha256Url)) {
+ $checksumUrl = $Sha256Url
+ }
+ else {
+ $checksumUrl = "$(Get-ReleaseAssetUrl -Version $Version -Repo $Repo -ReleaseUrl $ReleaseUrl).sha256"
+ }
+
+ Write-Host "==> downloading checksum $checksumUrl"
+ $content = Read-UrlText -Url $checksumUrl
+ $match = [regex]::Match($content, '(?i)\b[0-9a-f]{64}\b')
+ if (-not $match.Success) {
+ Stop-Install "Checksum response did not contain a SHA256 value: $checksumUrl"
+ }
+
+ return $match.Value
+}
+
+function Get-LocalZipSha256 {
+ param([string]$ZipPath)
+
+ $checksumPath = "$ZipPath.sha256"
+ if (-not (Test-Path -LiteralPath $checksumPath -PathType Leaf)) {
+ Stop-Install "Local zip installs require -Sha256 or a sidecar checksum file at $checksumPath."
+ }
+
+ $content = Get-Content -LiteralPath $checksumPath -Raw
+ $match = [regex]::Match($content, '(?i)\b[0-9a-f]{64}\b')
+ if (-not $match.Success) {
+ Stop-Install "Checksum file did not contain a SHA256 value: $checksumPath"
+ }
+
+ return $match.Value
+}
+
+function Assert-ZipHash {
+ param(
+ [string]$ZipPath,
+ [string]$ExpectedSha256
+ )
+
+ if ([string]::IsNullOrWhiteSpace($ExpectedSha256)) {
+ return
+ }
+
+ $actual = (Get-FileHash -LiteralPath $ZipPath -Algorithm SHA256).Hash.ToLowerInvariant()
+ $expected = $ExpectedSha256.Trim().ToLowerInvariant()
+ if ($actual -ne $expected) {
+ Stop-Install "SHA256 mismatch for $ZipPath. Expected $expected but got $actual."
+ }
+}
+
+function Expand-ReleaseZip {
+ param(
+ [string]$ZipPath,
+ [string]$DestinationDirectory
+ )
+
+ New-Item -ItemType Directory -Force -Path $DestinationDirectory | Out-Null
+ Expand-Archive -LiteralPath $ZipPath -DestinationPath $DestinationDirectory -Force
+
+ $entrypoint = Get-ChildItem -LiteralPath $DestinationDirectory -Recurse -Force -File |
+ Where-Object { $_.Name -ceq "ourocode" } |
+ Sort-Object { $_.FullName.Length } |
+ Select-Object -First 1
+
+ if (-not $entrypoint) {
+ Stop-Install "Release zip did not contain an 'ourocode' escript."
+ }
+
+ return $entrypoint.Directory.FullName
+}
+
+function Copy-ReleaseRoot {
+ param(
+ [string]$SourceRoot,
+ [string]$DestinationRoot
+ )
+
+ New-Item -ItemType Directory -Force -Path $DestinationRoot | Out-Null
+ Get-ChildItem -LiteralPath $SourceRoot -Force | ForEach-Object {
+ Copy-Item -LiteralPath $_.FullName -Destination $DestinationRoot -Recurse -Force
+ }
+
+ $installedOurocode = Join-Path $DestinationRoot "ourocode"
+ if (-not (Test-Path -LiteralPath $installedOurocode -PathType Leaf)) {
+ Stop-Install "Install staging failed: missing $installedOurocode"
+ }
+}
+
+function Move-StagedInstall {
+ param(
+ [string]$StageRoot,
+ [string]$InstallDirectory
+ )
+
+ $parent = Split-Path -Parent $InstallDirectory
+ New-Item -ItemType Directory -Force -Path $parent | Out-Null
+
+ $backup = $null
+ if (Test-Path -LiteralPath $InstallDirectory) {
+ $backup = "$InstallDirectory.previous-$([System.Guid]::NewGuid().ToString('N'))"
+ Move-Item -LiteralPath $InstallDirectory -Destination $backup
+ }
+
+ try {
+ Move-Item -LiteralPath $StageRoot -Destination $InstallDirectory
+ if ($backup -and (Test-Path -LiteralPath $backup)) {
+ Remove-Item -LiteralPath $backup -Recurse -Force
+ }
+ }
+ catch {
+ if ($backup -and (Test-Path -LiteralPath $backup) -and -not (Test-Path -LiteralPath $InstallDirectory)) {
+ Move-Item -LiteralPath $backup -Destination $InstallDirectory
+ }
+ throw
+ }
+}
+
+function Write-CmdLauncher {
+ param(
+ [string]$LauncherPath,
+ [string]$InstallDirectory
+ )
+
+ $installedOurocode = Join-Path $InstallDirectory "ourocode"
+ $installedTty = Join-Path (Join-Path $InstallDirectory "bin") "ourocode_tty.exe"
+ $content = @"
+@echo off
+setlocal
+if exist "$installedTty" set "OUROCODE_TTY=$installedTty"
+escript.exe "$installedOurocode" %*
+exit /b %ERRORLEVEL%
+"@
+
+ $launcherDirectory = Split-Path -Parent $LauncherPath
+ New-Item -ItemType Directory -Force -Path $launcherDirectory | Out-Null
+ Set-Content -LiteralPath $LauncherPath -Value $content -Encoding ASCII
+}
+
+function ConvertTo-PathKey {
+ param([string]$Path)
+
+ if ([string]::IsNullOrWhiteSpace($Path)) {
+ return ""
+ }
+
+ $trimmed = $Path.Trim().Trim('"')
+ try {
+ $full = [System.IO.Path]::GetFullPath($trimmed)
+ }
+ catch {
+ $full = $trimmed
+ }
+
+ return $full.TrimEnd('\').ToLowerInvariant()
+}
+
+function Add-UserPathOnce {
+ param([string]$Directory)
+
+ $current = [Environment]::GetEnvironmentVariable("Path", "User")
+ $entries = @()
+ if (-not [string]::IsNullOrWhiteSpace($current)) {
+ $entries = $current -split ';' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
+ }
+
+ $targetKey = ConvertTo-PathKey $Directory
+ $deduped = New-Object System.Collections.Generic.List[string]
+ $seen = New-Object 'System.Collections.Generic.HashSet[string]'
+
+ foreach ($entry in $entries) {
+ $key = ConvertTo-PathKey $entry
+ if ([string]::IsNullOrWhiteSpace($key)) {
+ continue
+ }
+ if ($key -eq $targetKey) {
+ continue
+ }
+ if ($seen.Add($key)) {
+ $deduped.Add($entry.Trim())
+ }
+ }
+
+ $deduped.Add($Directory)
+ $newPath = [string]::Join(';', $deduped)
+ [Environment]::SetEnvironmentVariable("Path", $newPath, "User")
+
+ if (($env:Path -split ';' | ForEach-Object { ConvertTo-PathKey $_ }) -notcontains $targetKey) {
+ $env:Path = "$Directory;$env:Path"
+ }
+}
+
+Assert-EscriptAvailable -SkipForTest:$SkipPrerequisiteCheckForTest
+
+$tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) "ourocode-install-$([System.Guid]::NewGuid().ToString('N'))"
+New-Item -ItemType Directory -Force -Path $tempRoot | Out-Null
+
+try {
+ $zipPath = $null
+ if (-not [string]::IsNullOrWhiteSpace($LocalZip)) {
+ $zipPath = Resolve-ExistingFile -Path $LocalZip -Description "Local zip"
+ if ([string]::IsNullOrWhiteSpace($Sha256)) {
+ $Sha256 = Get-LocalZipSha256 -ZipPath $zipPath
+ }
+ }
+
+ if ([string]::IsNullOrWhiteSpace($Version)) {
+ if ($zipPath) {
+ $Version = Get-VersionFromZipName -ZipPath $zipPath
+ }
+ if ([string]::IsNullOrWhiteSpace($Version) -and -not [string]::IsNullOrWhiteSpace($env:OUROCODE_VERSION)) {
+ $Version = $env:OUROCODE_VERSION
+ }
+ if ([string]::IsNullOrWhiteSpace($Version)) {
+ $Version = "0.1.13"
+ }
+ }
+
+ if (-not $zipPath) {
+ $zipPath = Save-ReleaseZip -Version $Version -Repo $Repo -ReleaseUrl $ReleaseUrl -DestinationDirectory $tempRoot
+ if ([string]::IsNullOrWhiteSpace($Sha256)) {
+ $Sha256 = Get-ExpectedReleaseSha256 -Version $Version -Repo $Repo -ReleaseUrl $ReleaseUrl -Sha256Url $Sha256Url
+ }
+ }
+
+ $root = $InstallRoot
+ if ([string]::IsNullOrWhiteSpace($root)) {
+ $root = Get-DefaultInstallRoot
+ }
+ $root = [System.IO.Path]::GetFullPath($root)
+
+ $installDirectory = Join-Path $root $Version
+ $launcherDirectory = Join-Path $root "bin"
+ $launcherPath = Join-Path $launcherDirectory "ourocode.cmd"
+ $expanded = Join-Path $tempRoot "expanded"
+ $stage = Join-Path $tempRoot "stage"
+
+ Write-Host "==> ourocode install"
+ Write-Host "==> version: $Version"
+ Write-Host "==> zip: $zipPath"
+ Assert-ZipHash -ZipPath $zipPath -ExpectedSha256 $Sha256
+
+ $releaseRoot = Expand-ReleaseZip -ZipPath $zipPath -DestinationDirectory $expanded
+ Copy-ReleaseRoot -SourceRoot $releaseRoot -DestinationRoot $stage
+ Move-StagedInstall -StageRoot $stage -InstallDirectory $installDirectory
+ Write-CmdLauncher -LauncherPath $launcherPath -InstallDirectory $installDirectory
+
+ if ($NoPathUpdate) {
+ Write-Host "==> PATH update skipped (-NoPathUpdate)"
+ }
+ else {
+ Add-UserPathOnce -Directory $launcherDirectory
+ Write-Host "==> added to user PATH: $launcherDirectory"
+ Write-Host " Open a new PowerShell or Command Prompt session if 'ourocode' is not found."
+ }
+
+ Write-Host ""
+ Write-Host "==> ready"
+ Write-Host " installed: $installDirectory"
+ Write-Host " command: $launcherPath"
+}
+finally {
+ if (Test-Path -LiteralPath $tempRoot) {
+ Remove-Item -LiteralPath $tempRoot -Recurse -Force
+ }
+}
diff --git a/lib/ourocode/terminal/tty_driver.ex b/lib/ourocode/terminal/tty_driver.ex
index ccf3ae4..bf08d6b 100644
--- a/lib/ourocode/terminal/tty_driver.ex
+++ b/lib/ourocode/terminal/tty_driver.ex
@@ -8,10 +8,14 @@ defmodule Ourocode.Terminal.TtyDriver do
@doc "Absolute path of the built tty helper, or nil if it is not present."
@spec helper_path() :: String.t() | nil
def helper_path do
+ cwd = File.cwd!()
+
[
System.get_env("OUROCODE_TTY"),
- Path.join(File.cwd!(), "rust/ourocode_ipc/target/release/ourocode_tty"),
- Path.join(File.cwd!(), "bin/ourocode_tty")
+ Path.join(cwd, "bin/ourocode_tty.exe"),
+ Path.join(cwd, "bin/ourocode_tty"),
+ Path.join(cwd, "rust/ourocode_ipc/target/release/ourocode_tty.exe"),
+ Path.join(cwd, "rust/ourocode_ipc/target/release/ourocode_tty")
]
|> helper_path()
end
diff --git a/rust/ourocode_ipc/Cargo.lock b/rust/ourocode_ipc/Cargo.lock
index 9cae354..09e60eb 100644
--- a/rust/ourocode_ipc/Cargo.lock
+++ b/rust/ourocode_ipc/Cargo.lock
@@ -26,6 +26,7 @@ version = "0.1.0"
dependencies = [
"libc",
"serde_json",
+ "windows-sys",
]
[[package]]
@@ -105,6 +106,79 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+[[package]]
+name = "windows-sys"
+version = "0.59.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
[[package]]
name = "zmij"
version = "1.0.21"
diff --git a/rust/ourocode_ipc/Cargo.toml b/rust/ourocode_ipc/Cargo.toml
index ecf76e8..c5a44ec 100644
--- a/rust/ourocode_ipc/Cargo.toml
+++ b/rust/ourocode_ipc/Cargo.toml
@@ -16,3 +16,9 @@ path = "src/bin/ourocode_tty.rs"
[dependencies]
serde_json = "1"
libc = "0.2"
+windows-sys = { version = "0.59", features = [
+ "Win32_Foundation",
+ "Win32_Storage_FileSystem",
+ "Win32_System_Console",
+ "Win32_System_IO",
+] }
diff --git a/rust/ourocode_ipc/src/bin/ourocode_tty.rs b/rust/ourocode_ipc/src/bin/ourocode_tty.rs
index f287720..2f64b46 100644
--- a/rust/ourocode_ipc/src/bin/ourocode_tty.rs
+++ b/rust/ourocode_ipc/src/bin/ourocode_tty.rs
@@ -20,114 +20,356 @@
//! Protocol: first thing written to fd 4 is " \n"; everything
//! after is the raw key stream. Restores the original termios on exit.
-use std::sync::atomic::{AtomicBool, Ordering};
-use std::{mem, process, ptr, thread};
+#[cfg(unix)]
+mod unix {
+ use std::sync::atomic::{AtomicBool, Ordering};
+ use std::{mem, process, ptr, thread};
-const TTY_IN: libc::c_int = 0;
-const TTY_OUT: libc::c_int = 1;
-const PROTO_IN: libc::c_int = 3;
-const PROTO_OUT: libc::c_int = 4;
+ const TTY_IN: libc::c_int = 0;
+ const TTY_OUT: libc::c_int = 1;
+ const PROTO_IN: libc::c_int = 3;
+ const PROTO_OUT: libc::c_int = 4;
-static mut SAVED: Option = None;
-static RESTORED: AtomicBool = AtomicBool::new(false);
+ static mut SAVED: Option = None;
+ static RESTORED: AtomicBool = AtomicBool::new(false);
-unsafe fn restore() {
- if RESTORED.swap(true, Ordering::SeqCst) {
- return;
+ unsafe fn restore() {
+ if RESTORED.swap(true, Ordering::SeqCst) {
+ return;
+ }
+ if let Some(saved) = SAVED {
+ libc::tcsetattr(TTY_IN, libc::TCSANOW, &saved);
+ }
+ let seq = b"\x1b[?25h\x1b[?1049l";
+ libc::write(TTY_OUT, seq.as_ptr() as *const libc::c_void, seq.len());
}
- if let Some(saved) = SAVED {
- libc::tcsetattr(TTY_IN, libc::TCSANOW, &saved);
+
+ extern "C" fn on_signal(_sig: libc::c_int) {
+ unsafe { restore() };
+ process::exit(0);
}
- let seq = b"\x1b[?25h\x1b[?1049l";
- libc::write(TTY_OUT, seq.as_ptr() as *const libc::c_void, seq.len());
-}
-extern "C" fn on_signal(_sig: libc::c_int) {
- unsafe { restore() };
- process::exit(0);
-}
+ fn install_signal(sig: libc::c_int) {
+ unsafe {
+ let mut sa: libc::sigaction = mem::zeroed();
+ sa.sa_sigaction = on_signal as *const () as usize;
+ libc::sigemptyset(&mut sa.sa_mask);
+ libc::sigaction(sig, &sa, ptr::null_mut());
+ }
+ }
-fn install_signal(sig: libc::c_int) {
- unsafe {
- let mut sa: libc::sigaction = mem::zeroed();
- sa.sa_sigaction = on_signal as *const () as usize;
- libc::sigemptyset(&mut sa.sa_mask);
- libc::sigaction(sig, &sa, ptr::null_mut());
+ fn write_all(fd: libc::c_int, buf: &[u8]) -> bool {
+ let mut off = 0usize;
+ while off < buf.len() {
+ let w = unsafe {
+ libc::write(
+ fd,
+ buf.as_ptr().add(off) as *const libc::c_void,
+ buf.len() - off,
+ )
+ };
+ if w <= 0 {
+ return false;
+ }
+ off += w as usize;
+ }
+ true
}
-}
-fn write_all(fd: libc::c_int, buf: &[u8]) -> bool {
- let mut off = 0usize;
- while off < buf.len() {
- let w = unsafe {
- libc::write(
- fd,
- buf.as_ptr().add(off) as *const libc::c_void,
- buf.len() - off,
- )
+ pub fn run() {
+ // fd 0 must be a terminal. If not (piped / no tty), bail so Elixir falls
+ // back to the plain renderer.
+ if unsafe { libc::isatty(TTY_IN) } != 1 {
+ process::exit(1);
+ }
+
+ unsafe {
+ let mut term: libc::termios = mem::zeroed();
+ if libc::tcgetattr(TTY_IN, &mut term) != 0 {
+ process::exit(1);
+ }
+ SAVED = Some(term);
+
+ let mut raw = term;
+ libc::cfmakeraw(&mut raw);
+ libc::tcsetattr(TTY_IN, libc::TCSANOW, &raw);
+ }
+
+ for sig in [libc::SIGTERM, libc::SIGINT, libc::SIGHUP, libc::SIGPIPE] {
+ install_signal(sig);
+ }
+
+ let (cols, rows) = unsafe {
+ let mut ws: libc::winsize = mem::zeroed();
+ if libc::ioctl(TTY_OUT, libc::TIOCGWINSZ, &mut ws) == 0
+ && ws.ws_col > 0
+ && ws.ws_row > 0
+ {
+ (ws.ws_col, ws.ws_row)
+ } else {
+ (120u16, 40u16)
+ }
};
- if w <= 0 {
- return false;
+ write_all(PROTO_OUT, format!("{} {}\n", cols, rows).as_bytes());
+
+ // terminal input -> Elixir
+ thread::spawn(move || {
+ let mut buf = [0u8; 4096];
+ loop {
+ let n =
+ unsafe { libc::read(TTY_IN, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
+ if n <= 0 || !write_all(PROTO_OUT, &buf[..n as usize]) {
+ process::exit(0);
+ }
+ }
+ });
+
+ // Elixir frames -> terminal. EOF means Elixir is done.
+ let mut buf = [0u8; 16384];
+ loop {
+ let n =
+ unsafe { libc::read(PROTO_IN, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
+ if n <= 0 || !write_all(TTY_OUT, &buf[..n as usize]) {
+ break;
+ }
}
- off += w as usize;
+
+ unsafe { restore() };
+ process::exit(0);
}
- true
}
+#[cfg(unix)]
+fn main() {
+ unix::run();
+}
+
+#[cfg(windows)]
+fn main() {
+ windows::run();
+}
+
+#[cfg(not(any(unix, windows)))]
fn main() {
- // fd 0 must be a terminal. If not (piped / no tty), bail so Elixir falls
- // back to the plain renderer.
- if unsafe { libc::isatty(TTY_IN) } != 1 {
- process::exit(1);
+ eprintln!("ourocode_tty: unsupported platform; falling back");
+ std::process::exit(1);
+}
+
+#[cfg(windows)]
+mod windows {
+ use std::ffi::c_void;
+ use std::process;
+ use std::sync::atomic::{AtomicBool, Ordering};
+ use std::thread;
+
+ use windows_sys::Win32::Foundation::{HANDLE, INVALID_HANDLE_VALUE};
+ use windows_sys::Win32::Storage::FileSystem::{ReadFile, WriteFile};
+ use windows_sys::Win32::System::Console::{
+ GetConsoleMode, GetConsoleScreenBufferInfo, GetStdHandle, SetConsoleMode,
+ CONSOLE_SCREEN_BUFFER_INFO, ENABLE_ECHO_INPUT, ENABLE_LINE_INPUT, ENABLE_PROCESSED_INPUT,
+ ENABLE_VIRTUAL_TERMINAL_INPUT, ENABLE_VIRTUAL_TERMINAL_PROCESSING, STD_INPUT_HANDLE,
+ STD_OUTPUT_HANDLE,
+ };
+
+ const PROTO_IN: libc::c_int = 3;
+ const PROTO_OUT: libc::c_int = 4;
+
+ static RESTORED: AtomicBool = AtomicBool::new(false);
+ static mut INPUT_HANDLE: HANDLE = std::ptr::null_mut();
+ static mut OUTPUT_HANDLE: HANDLE = std::ptr::null_mut();
+ static mut INPUT_MODE: u32 = 0;
+ static mut OUTPUT_MODE: u32 = 0;
+
+ struct ConsoleGuard;
+
+ impl Drop for ConsoleGuard {
+ fn drop(&mut self) {
+ restore();
+ }
}
- unsafe {
- let mut term: libc::termios = mem::zeroed();
- if libc::tcgetattr(TTY_IN, &mut term) != 0 {
- process::exit(1);
+ fn restore() {
+ if RESTORED.swap(true, Ordering::SeqCst) {
+ return;
}
- SAVED = Some(term);
- let mut raw = term;
- libc::cfmakeraw(&mut raw);
- libc::tcsetattr(TTY_IN, libc::TCSANOW, &raw);
+ unsafe {
+ if !INPUT_HANDLE.is_null() && INPUT_HANDLE != INVALID_HANDLE_VALUE {
+ SetConsoleMode(INPUT_HANDLE, INPUT_MODE);
+ }
+ if !OUTPUT_HANDLE.is_null() && OUTPUT_HANDLE != INVALID_HANDLE_VALUE {
+ SetConsoleMode(OUTPUT_HANDLE, OUTPUT_MODE);
+ }
+ }
+
+ let _ = write_console(b"\x1b[?25h\x1b[?1049l");
+ }
+
+ fn write_proto(buf: &[u8]) -> bool {
+ let mut off = 0usize;
+ while off < buf.len() {
+ let remaining = buf.len() - off;
+ let chunk = remaining.min(i32::MAX as usize);
+ let written = unsafe {
+ libc::write(
+ PROTO_OUT,
+ buf.as_ptr().add(off) as *const c_void,
+ chunk as libc::c_uint,
+ )
+ };
+ if written <= 0 {
+ return false;
+ }
+ off += written as usize;
+ }
+ true
}
- for sig in [libc::SIGTERM, libc::SIGINT, libc::SIGHUP, libc::SIGPIPE] {
- install_signal(sig);
+ fn read_proto(buf: &mut [u8]) -> isize {
+ (unsafe {
+ libc::read(
+ PROTO_IN,
+ buf.as_mut_ptr() as *mut c_void,
+ buf.len() as libc::c_uint,
+ )
+ }) as isize
}
- let (cols, rows) = unsafe {
- let mut ws: libc::winsize = mem::zeroed();
- if libc::ioctl(TTY_OUT, libc::TIOCGWINSZ, &mut ws) == 0 && ws.ws_col > 0 && ws.ws_row > 0 {
- (ws.ws_col, ws.ws_row)
+ fn write_console(buf: &[u8]) -> bool {
+ let handle = unsafe { OUTPUT_HANDLE };
+ if handle.is_null() || handle == INVALID_HANDLE_VALUE {
+ return false;
+ }
+
+ let mut off = 0usize;
+ while off < buf.len() {
+ let chunk = (buf.len() - off).min(u32::MAX as usize);
+ let mut written = 0u32;
+ let ok = unsafe {
+ WriteFile(
+ handle,
+ buf.as_ptr().add(off),
+ chunk as u32,
+ &mut written,
+ std::ptr::null_mut(),
+ )
+ };
+ if ok == 0 || written == 0 {
+ return false;
+ }
+ off += written as usize;
+ }
+ true
+ }
+
+ fn read_console(buf: &mut [u8]) -> isize {
+ let handle = unsafe { INPUT_HANDLE };
+ if handle.is_null() || handle == INVALID_HANDLE_VALUE {
+ return -1;
+ }
+
+ let mut read = 0u32;
+ let ok = unsafe {
+ ReadFile(
+ handle,
+ buf.as_mut_ptr(),
+ buf.len().min(u32::MAX as usize) as u32,
+ &mut read,
+ std::ptr::null_mut(),
+ )
+ };
+ if ok == 0 {
+ -1
} else {
- (120u16, 40u16)
+ read as isize
}
- };
- write_all(PROTO_OUT, format!("{} {}\n", cols, rows).as_bytes());
+ }
- // terminal input -> Elixir
- thread::spawn(move || {
- let mut buf = [0u8; 4096];
- loop {
- let n =
- unsafe { libc::read(TTY_IN, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
- if n <= 0 || !write_all(PROTO_OUT, &buf[..n as usize]) {
- process::exit(0);
+ fn terminal_size() -> (i16, i16) {
+ unsafe {
+ let mut info: CONSOLE_SCREEN_BUFFER_INFO = std::mem::zeroed();
+ if GetConsoleScreenBufferInfo(OUTPUT_HANDLE, &mut info) != 0 {
+ let cols = info.srWindow.Right - info.srWindow.Left + 1;
+ let rows = info.srWindow.Bottom - info.srWindow.Top + 1;
+ if cols > 0 && rows > 0 {
+ return (cols, rows);
+ }
}
}
- });
- // Elixir frames -> terminal. EOF means Elixir is done.
- let mut buf = [0u8; 16384];
- loop {
- let n = unsafe { libc::read(PROTO_IN, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
- if n <= 0 || !write_all(TTY_OUT, &buf[..n as usize]) {
- break;
+ (120, 40)
+ }
+
+ fn setup_console() -> Option {
+ unsafe {
+ INPUT_HANDLE = GetStdHandle(STD_INPUT_HANDLE);
+ OUTPUT_HANDLE = GetStdHandle(STD_OUTPUT_HANDLE);
+ if INPUT_HANDLE.is_null()
+ || INPUT_HANDLE == INVALID_HANDLE_VALUE
+ || OUTPUT_HANDLE.is_null()
+ || OUTPUT_HANDLE == INVALID_HANDLE_VALUE
+ {
+ return None;
+ }
+
+ let mut input_mode = 0u32;
+ let mut output_mode = 0u32;
+ if GetConsoleMode(INPUT_HANDLE, &mut input_mode) == 0 {
+ return None;
+ }
+ if GetConsoleMode(OUTPUT_HANDLE, &mut output_mode) == 0 {
+ return None;
+ }
+
+ INPUT_MODE = input_mode;
+ OUTPUT_MODE = output_mode;
+
+ let raw_input = (input_mode
+ & !(ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT | ENABLE_PROCESSED_INPUT))
+ | ENABLE_VIRTUAL_TERMINAL_INPUT;
+ let vt_output = output_mode | ENABLE_VIRTUAL_TERMINAL_PROCESSING;
+
+ if SetConsoleMode(INPUT_HANDLE, raw_input) == 0 {
+ return None;
+ }
+ if SetConsoleMode(OUTPUT_HANDLE, vt_output) == 0 {
+ SetConsoleMode(INPUT_HANDLE, INPUT_MODE);
+ return None;
+ }
}
+
+ Some(ConsoleGuard)
}
- unsafe { restore() };
- process::exit(0);
+ pub fn run() {
+ let _guard = match setup_console() {
+ Some(guard) => guard,
+ None => process::exit(1),
+ };
+
+ let (cols, rows) = terminal_size();
+ if !write_proto(format!("{} {}\n", cols, rows).as_bytes()) {
+ process::exit(1);
+ }
+
+ thread::spawn(move || {
+ let mut buf = [0u8; 4096];
+ loop {
+ let n = read_console(&mut buf);
+ if n <= 0 || !write_proto(&buf[..n as usize]) {
+ process::exit(0);
+ }
+ }
+ });
+
+ let mut buf = [0u8; 16384];
+ loop {
+ let n = read_proto(&mut buf);
+ if n <= 0 || !write_console(&buf[..n as usize]) {
+ break;
+ }
+ }
+
+ process::exit(0);
+ }
}
diff --git a/scripts/package-windows.ps1 b/scripts/package-windows.ps1
new file mode 100644
index 0000000..6bc8b2a
--- /dev/null
+++ b/scripts/package-windows.ps1
@@ -0,0 +1,261 @@
+[CmdletBinding()]
+param(
+ [string]$Version,
+ [string]$OutputDir = "dist",
+ [string]$PrebuiltRoot,
+ [string]$Configuration = "Release"
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = "Stop"
+
+$RepoRoot = Split-Path -Parent (Split-Path -Parent $PSCommandPath)
+$WindowsTarget = "x86_64-pc-windows-msvc"
+
+function Write-Step {
+ param([string]$Message)
+ Write-Host "==> $Message"
+}
+
+function Resolve-RepoPath {
+ param([string]$Path)
+ if ([System.IO.Path]::IsPathRooted($Path)) {
+ return $Path
+ }
+ return (Join-Path $RepoRoot $Path)
+}
+
+function Get-ProjectVersion {
+ $MixFile = Join-Path $RepoRoot "mix.exs"
+ if (-not (Test-Path -LiteralPath $MixFile)) {
+ throw "Version was not provided and mix.exs was not found."
+ }
+
+ $Match = Select-String -Path $MixFile -Pattern 'version:\s*"([^"]+)"' | Select-Object -First 1
+ if ($null -eq $Match) {
+ throw "Version was not provided and no version entry was found in mix.exs."
+ }
+
+ return $Match.Matches[0].Groups[1].Value
+}
+
+function Require-Command {
+ param(
+ [string]$Name,
+ [string]$InstallHint
+ )
+
+ $Command = Get-Command $Name -ErrorAction SilentlyContinue
+ if ($null -eq $Command) {
+ throw "Missing prerequisite '$Name'. $InstallHint"
+ }
+
+ Write-Host (" {0}: {1}" -f $Name, $Command.Source)
+}
+
+function Invoke-Checked {
+ param(
+ [string]$FilePath,
+ [string[]]$Arguments
+ )
+
+ & $FilePath @Arguments
+ if ($LASTEXITCODE -ne 0) {
+ throw "Command failed with exit code ${LASTEXITCODE}: $FilePath $($Arguments -join ' ')"
+ }
+}
+
+function Assert-NormalBuildPrerequisites {
+ Write-Step "checking Windows release build prerequisites"
+ Require-Command "git" "Install Git for Windows and open a new PowerShell session."
+ Require-Command "erl" "Install Erlang/OTP and ensure erl.exe is on PATH."
+ Require-Command "elixir" "Install Elixir and ensure elixir.exe is on PATH."
+ Require-Command "mix" "Install Elixir/Mix and ensure mix.bat is on PATH."
+ Require-Command "cargo" "Install the Rust stable MSVC toolchain and ensure cargo.exe is on PATH."
+ Require-Command "rustc" "Install the Rust stable MSVC toolchain and ensure rustc.exe is on PATH."
+
+ $RustInfo = & rustc -vV
+ if ($LASTEXITCODE -ne 0) {
+ throw "rustc -vV failed with exit code $LASTEXITCODE."
+ }
+
+ $HostLine = $RustInfo | Where-Object { $_ -like "host:*" } | Select-Object -First 1
+ if ($HostLine -notlike "*$WindowsTarget*") {
+ throw "Rust host toolchain must be $WindowsTarget for this package script. Current ${HostLine}. Install with: rustup toolchain install stable-$WindowsTarget"
+ }
+
+ $Rustup = Get-Command "rustup" -ErrorAction SilentlyContinue
+ if ($null -ne $Rustup) {
+ $InstalledTargets = & rustup target list --installed
+ if ($LASTEXITCODE -ne 0) {
+ throw "rustup target list --installed failed with exit code $LASTEXITCODE."
+ }
+ if ($InstalledTargets -notcontains $WindowsTarget) {
+ throw "Missing Rust target $WindowsTarget. Install with: rustup target add $WindowsTarget"
+ }
+ Write-Host " rust target: $WindowsTarget"
+ } else {
+ Write-Host " rust target: rustup not found; using rustc host $WindowsTarget"
+ }
+}
+
+function Assert-RequiredFile {
+ param(
+ [string]$Path,
+ [string]$Description
+ )
+
+ if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
+ throw "Missing package input: $Description at $Path"
+ }
+}
+
+function New-WindowsLaunchers {
+ param([string]$BinDir)
+
+ New-Item -ItemType Directory -Force -Path $BinDir | Out-Null
+
+ $CmdPath = Join-Path $BinDir "ourocode.cmd"
+ $CmdContent = @(
+ "@echo off",
+ "setlocal",
+ 'set "SCRIPT_DIR=%~dp0"',
+ 'set "ROOT_DIR=%SCRIPT_DIR%.."',
+ 'if exist "%ROOT_DIR%\bin\ourocode_tty.exe" set "OUROCODE_TTY=%ROOT_DIR%\bin\ourocode_tty.exe"',
+ 'escript.exe "%ROOT_DIR%\ourocode" %*',
+ "exit /b %ERRORLEVEL%"
+ )
+ Set-Content -Path $CmdPath -Value $CmdContent -Encoding ASCII
+
+ $Ps1Path = Join-Path $BinDir "ourocode.ps1"
+ $Ps1Content = @(
+ '$ErrorActionPreference = "Stop"',
+ '$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path',
+ '$rootDir = Resolve-Path (Join-Path $scriptDir "..")',
+ '$tty = Join-Path $rootDir "bin\ourocode_tty.exe"',
+ 'if (Test-Path -LiteralPath $tty) { $env:OUROCODE_TTY = $tty }',
+ '& escript.exe (Join-Path $rootDir "ourocode") @args',
+ 'exit $LASTEXITCODE'
+ )
+ Set-Content -Path $Ps1Path -Value $Ps1Content -Encoding ASCII
+}
+
+function Copy-PackageInputs {
+ param(
+ [string]$SourceRoot,
+ [string]$PackageRoot,
+ [bool]$IncludeBuiltHelper
+ )
+
+ Assert-RequiredFile (Join-Path $SourceRoot "ourocode") "escript"
+ Assert-RequiredFile (Join-Path $SourceRoot "install.ps1") "PowerShell installer"
+ Assert-RequiredFile (Join-Path $SourceRoot "uninstall.ps1") "PowerShell uninstaller"
+ Assert-RequiredFile (Join-Path $SourceRoot "README.md") "README"
+
+ Copy-Item -LiteralPath (Join-Path $SourceRoot "ourocode") -Destination (Join-Path $PackageRoot "ourocode") -Force
+ Copy-Item -LiteralPath (Join-Path $SourceRoot "install.ps1") -Destination (Join-Path $PackageRoot "install.ps1") -Force
+ Copy-Item -LiteralPath (Join-Path $SourceRoot "uninstall.ps1") -Destination (Join-Path $PackageRoot "uninstall.ps1") -Force
+ Copy-Item -LiteralPath (Join-Path $SourceRoot "README.md") -Destination (Join-Path $PackageRoot "README.md") -Force
+
+ $BinDir = Join-Path $PackageRoot "bin"
+ New-WindowsLaunchers -BinDir $BinDir
+
+ $PrebuiltHelper = Join-Path $SourceRoot "bin\ourocode_tty.exe"
+ if (Test-Path -LiteralPath $PrebuiltHelper -PathType Leaf) {
+ Copy-Item -LiteralPath $PrebuiltHelper -Destination (Join-Path $BinDir "ourocode_tty.exe") -Force
+ Write-Host " helper: included prebuilt bin\ourocode_tty.exe"
+ return
+ }
+
+ if ($IncludeBuiltHelper) {
+ $BuiltHelper = Join-Path $RepoRoot "rust\ourocode_ipc\target\release\ourocode_tty.exe"
+ Assert-RequiredFile $BuiltHelper "built Windows TTY helper"
+ Copy-Item -LiteralPath $BuiltHelper -Destination (Join-Path $BinDir "ourocode_tty.exe") -Force
+ Write-Host " helper: included built bin\ourocode_tty.exe"
+ } else {
+ Write-Host " helper: bin\ourocode_tty.exe not present; package will omit it"
+ }
+}
+
+try {
+ if ([string]::IsNullOrWhiteSpace($Version)) {
+ $Version = Get-ProjectVersion
+ }
+
+ if ($Configuration -ne "Release") {
+ throw "Unsupported build configuration '$Configuration'. Windows release packaging currently supports only Release."
+ }
+
+ if ($Version -match '[\\/:*?"<>|]') {
+ throw "Version contains characters that are invalid for a Windows file name: $Version"
+ }
+
+ $OutputRoot = Resolve-RepoPath $OutputDir
+ $PackageName = "ourocode-v$Version-windows-x64"
+ $StageParent = Join-Path $OutputRoot "_stage"
+ $PackageRoot = Join-Path $StageParent $PackageName
+ $ZipPath = Join-Path $OutputRoot "$PackageName.zip"
+ $ShaPath = "$ZipPath.sha256"
+
+ Write-Step "packaging $PackageName"
+ Write-Host " configuration: $Configuration"
+ New-Item -ItemType Directory -Force -Path $OutputRoot | Out-Null
+ if (Test-Path -LiteralPath $PackageRoot) {
+ Remove-Item -LiteralPath $PackageRoot -Recurse -Force
+ }
+ if (Test-Path -LiteralPath $ZipPath) {
+ Remove-Item -LiteralPath $ZipPath -Force
+ }
+ if (Test-Path -LiteralPath $ShaPath) {
+ Remove-Item -LiteralPath $ShaPath -Force
+ }
+ New-Item -ItemType Directory -Force -Path $PackageRoot | Out-Null
+
+ Push-Location $RepoRoot
+ try {
+ if (-not [string]::IsNullOrWhiteSpace($PrebuiltRoot)) {
+ $ResolvedPrebuiltRoot = Resolve-RepoPath $PrebuiltRoot
+ if (-not (Test-Path -LiteralPath $ResolvedPrebuiltRoot -PathType Container)) {
+ throw "PrebuiltRoot was provided but does not exist: $ResolvedPrebuiltRoot"
+ }
+ Write-Step "using prebuilt fixture root $ResolvedPrebuiltRoot"
+ Copy-PackageInputs -SourceRoot $ResolvedPrebuiltRoot -PackageRoot $PackageRoot -IncludeBuiltHelper $false
+ } else {
+ Assert-NormalBuildPrerequisites
+ Assert-RequiredFile (Join-Path $RepoRoot "install.ps1") "PowerShell installer"
+ Assert-RequiredFile (Join-Path $RepoRoot "uninstall.ps1") "PowerShell uninstaller"
+ Assert-RequiredFile (Join-Path $RepoRoot "README.md") "README"
+
+ Write-Step "building Rust helper"
+ Invoke-Checked "cargo" @("build", "--release", "--manifest-path", ".\rust\ourocode_ipc\Cargo.toml", "--bin", "ourocode_tty")
+
+ Write-Step "building Elixir escript"
+ Invoke-Checked "mix" @("escript.build")
+
+ Copy-PackageInputs -SourceRoot $RepoRoot -PackageRoot $PackageRoot -IncludeBuiltHelper $true
+ }
+ } finally {
+ Pop-Location
+ }
+
+ Write-Step "creating zip"
+ Compress-Archive -Path $PackageRoot -DestinationPath $ZipPath -Force
+
+ Write-Step "writing SHA256"
+ $Hash = Get-FileHash -LiteralPath $ZipPath -Algorithm SHA256
+ "{0} {1}" -f $Hash.Hash.ToLowerInvariant(), (Split-Path -Leaf $ZipPath) | Set-Content -Path $ShaPath -Encoding ASCII
+
+ if (Test-Path -LiteralPath $StageParent) {
+ Remove-Item -LiteralPath $StageParent -Recurse -Force
+ }
+
+ Write-Step "release ready"
+ Write-Host " $ZipPath"
+ Write-Host " $ShaPath"
+} catch {
+ if ((Get-Variable -Name StageParent -ErrorAction SilentlyContinue) -and (Test-Path -LiteralPath $StageParent)) {
+ Remove-Item -LiteralPath $StageParent -Recurse -Force
+ }
+ Write-Error $_.Exception.Message
+ exit 1
+}
diff --git a/test/ourocode/terminal/tty_driver_test.exs b/test/ourocode/terminal/tty_driver_test.exs
index 5f17044..df4b7be 100644
--- a/test/ourocode/terminal/tty_driver_test.exs
+++ b/test/ourocode/terminal/tty_driver_test.exs
@@ -1,5 +1,5 @@
defmodule Ourocode.Terminal.TtyDriverTest do
- use ExUnit.Case, async: true
+ use ExUnit.Case, async: false
alias Ourocode.Terminal.TtyDriver
@@ -14,6 +14,44 @@ defmodule Ourocode.Terminal.TtyDriverTest do
assert TtyDriver.helper_path([nil, missing, existing]) == existing
end
+ test "helper_path resolves packaged Windows exe before other bundled helpers" do
+ # Given
+ root = temp_root!()
+ packaged_exe = touch!(root, "bin/ourocode_tty.exe")
+ touch!(root, "bin/ourocode_tty")
+ touch!(root, "rust/ourocode_ipc/target/release/ourocode_tty.exe")
+
+ # When
+ actual = in_project_root(root, fn -> TtyDriver.helper_path() end)
+
+ # Then
+ assert_same_path(actual, packaged_exe)
+ end
+
+ test "helper_path falls back to source Windows exe before source Unix helper" do
+ # Given
+ root = temp_root!()
+ source_exe = touch!(root, "rust/ourocode_ipc/target/release/ourocode_tty.exe")
+ touch!(root, "rust/ourocode_ipc/target/release/ourocode_tty")
+
+ # When
+ actual = in_project_root(root, fn -> TtyDriver.helper_path() end)
+
+ # Then
+ assert_same_path(actual, source_exe)
+ end
+
+ test "helper_path returns nil when no helper candidate exists" do
+ # Given
+ root = temp_root!()
+
+ # When
+ actual = in_project_root(root, fn -> TtyDriver.helper_path() end)
+
+ # Then
+ assert actual == nil
+ end
+
test "terminal control sequences enable and disable SGR mouse reporting" do
assert TtyDriver.enter_sequence() =~ "?1003h"
assert TtyDriver.enter_sequence() =~ "?1006h"
@@ -33,4 +71,49 @@ defmodule Ourocode.Terminal.TtyDriverTest do
assert TtyDriver.next_chunk(nil, 0) == {:file_cache_ready, ["lib/a.ex"]}
assert TtyDriver.next_chunk(nil, 0) == :tick
end
+
+ defp temp_root! do
+ root = Path.join(System.tmp_dir!(), "ourocode-tty-test-#{System.unique_integer([:positive])}")
+ File.mkdir_p!(root)
+ on_exit(fn -> File.rm_rf(root) end)
+ root
+ end
+
+ defp touch!(root, relative_path) do
+ path = Path.join(root, relative_path)
+ path |> Path.dirname() |> File.mkdir_p!()
+ File.write!(path, "")
+ path
+ end
+
+ defp in_project_root(root, fun) do
+ original_cwd = File.cwd!()
+ original_env = System.get_env("OUROCODE_TTY")
+
+ try do
+ System.delete_env("OUROCODE_TTY")
+ File.cd!(root)
+ fun.()
+ after
+ File.cd!(original_cwd)
+ restore_env(original_env)
+ end
+ end
+
+ defp restore_env(nil), do: System.delete_env("OUROCODE_TTY")
+ defp restore_env(value), do: System.put_env("OUROCODE_TTY", value)
+
+ defp assert_same_path(left, right) do
+ if match?({:win32, _}, :os.type()) do
+ assert windows_path_key(left) == windows_path_key(right)
+ else
+ assert left == right
+ end
+ end
+
+ defp windows_path_key(path) do
+ path
+ |> String.replace("\\", "/")
+ |> String.downcase()
+ end
end
diff --git a/uninstall.ps1 b/uninstall.ps1
new file mode 100644
index 0000000..9ec3e4a
--- /dev/null
+++ b/uninstall.ps1
@@ -0,0 +1,194 @@
+[CmdletBinding()]
+param(
+ [string]$InstallRoot,
+ [string]$Version,
+ [switch]$KeepPath,
+ [switch]$AllVersions
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = "Stop"
+
+function Stop-Uninstall {
+ param([string]$Message)
+ [Console]::Error.WriteLine($Message)
+ exit 1
+}
+
+function Get-DefaultInstallRoot {
+ if ([string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) {
+ Stop-Uninstall "LOCALAPPDATA is not set. Pass -InstallRoot to choose an install directory."
+ }
+
+ return (Join-Path $env:LOCALAPPDATA "Ourocode")
+}
+
+function ConvertTo-PathKey {
+ param([string]$Path)
+
+ if ([string]::IsNullOrWhiteSpace($Path)) {
+ return ""
+ }
+
+ $trimmed = $Path.Trim().Trim('"')
+ try {
+ $full = [System.IO.Path]::GetFullPath($trimmed)
+ }
+ catch {
+ $full = $trimmed
+ }
+
+ return $full.TrimEnd('\').ToLowerInvariant()
+}
+
+function Remove-UserPathEntry {
+ param([string]$Directory)
+
+ $current = [Environment]::GetEnvironmentVariable("Path", "User")
+ if ([string]::IsNullOrWhiteSpace($current)) {
+ return $false
+ }
+
+ $targetKey = ConvertTo-PathKey $Directory
+ $kept = New-Object System.Collections.Generic.List[string]
+ $changed = $false
+
+ foreach ($entry in ($current -split ';')) {
+ if ([string]::IsNullOrWhiteSpace($entry)) {
+ continue
+ }
+
+ $trimmed = $entry.Trim()
+ if ((ConvertTo-PathKey $trimmed) -eq $targetKey) {
+ $changed = $true
+ continue
+ }
+
+ $kept.Add($trimmed)
+ }
+
+ if ($changed) {
+ [Environment]::SetEnvironmentVariable("Path", [string]::Join(';', $kept), "User")
+ $env:Path = [string]::Join(
+ ';',
+ @($env:Path -split ';' | Where-Object {
+ -not [string]::IsNullOrWhiteSpace($_) -and (ConvertTo-PathKey $_) -ne $targetKey
+ })
+ )
+ }
+
+ return $changed
+}
+
+function Remove-IfExists {
+ param([string]$Path)
+
+ if (Test-Path -LiteralPath $Path) {
+ Remove-Item -LiteralPath $Path -Recurse -Force
+ return $true
+ }
+
+ return $false
+}
+
+function Assert-SafeInstallRoot {
+ param([string]$Path)
+
+ $pathKey = ConvertTo-PathKey $Path
+ $driveRootKey = ConvertTo-PathKey ([System.IO.Path]::GetPathRoot($Path))
+ if ($pathKey -eq $driveRootKey) {
+ Stop-Uninstall "Refusing to uninstall from a drive root: $Path"
+ }
+
+ $blockedRoots = @(
+ $env:USERPROFILE,
+ $env:LOCALAPPDATA,
+ $env:APPDATA,
+ $env:ProgramFiles,
+ ${env:ProgramFiles(x86)},
+ $env:SystemRoot,
+ $env:TEMP,
+ $env:TMP
+ )
+
+ foreach ($blockedRoot in $blockedRoots) {
+ if ([string]::IsNullOrWhiteSpace($blockedRoot)) {
+ continue
+ }
+
+ if ($pathKey -eq (ConvertTo-PathKey $blockedRoot)) {
+ Stop-Uninstall "Refusing to uninstall from a broad system or profile directory: $Path"
+ }
+ }
+}
+
+if ($AllVersions -and -not [string]::IsNullOrWhiteSpace($Version)) {
+ Stop-Uninstall "Use either -Version or -AllVersions, not both."
+}
+
+$root = $InstallRoot
+if ([string]::IsNullOrWhiteSpace($root)) {
+ $root = Get-DefaultInstallRoot
+}
+$root = [System.IO.Path]::GetFullPath($root)
+Assert-SafeInstallRoot -Path $root
+
+$launcherDirectory = Join-Path $root "bin"
+$launcherPath = Join-Path $launcherDirectory "ourocode.cmd"
+
+Write-Host "==> ourocode uninstall"
+Write-Host "==> root: $root"
+
+if (-not $KeepPath) {
+ if (Remove-UserPathEntry -Directory $launcherDirectory) {
+ Write-Host "==> removed from user PATH: $launcherDirectory"
+ Write-Host " Open a new PowerShell or Command Prompt session if PATH still shows the old entry."
+ }
+ else {
+ Write-Host "==> user PATH did not contain: $launcherDirectory"
+ }
+}
+else {
+ Write-Host "==> PATH update skipped (-KeepPath)"
+}
+
+if (Remove-IfExists -Path $launcherPath) {
+ Write-Host "==> removed launcher: $launcherPath"
+}
+else {
+ Write-Host "==> launcher not present: $launcherPath"
+}
+
+if ($AllVersions) {
+ $removedRoot = Remove-IfExists -Path $root
+ if ($removedRoot) {
+ Write-Host "==> removed install root: $root"
+ }
+ else {
+ Write-Host "==> install root not present: $root"
+ }
+}
+elseif (-not [string]::IsNullOrWhiteSpace($Version)) {
+ $versionDirectory = Join-Path $root $Version
+ if (Remove-IfExists -Path $versionDirectory) {
+ Write-Host "==> removed version: $versionDirectory"
+ }
+ else {
+ Write-Host "==> version not present: $versionDirectory"
+ }
+
+ if ((Test-Path -LiteralPath $launcherDirectory) -and -not (Get-ChildItem -LiteralPath $launcherDirectory -Force -ErrorAction SilentlyContinue | Select-Object -First 1)) {
+ Remove-Item -LiteralPath $launcherDirectory -Force
+ Write-Host "==> removed empty launcher directory: $launcherDirectory"
+ }
+}
+else {
+ Write-Host "==> version directories left in place; pass -Version or -AllVersions to remove them."
+ if ((Test-Path -LiteralPath $launcherDirectory) -and -not (Get-ChildItem -LiteralPath $launcherDirectory -Force -ErrorAction SilentlyContinue | Select-Object -First 1)) {
+ Remove-Item -LiteralPath $launcherDirectory -Force
+ Write-Host "==> removed empty launcher directory: $launcherDirectory"
+ }
+}
+
+Write-Host ""
+Write-Host "==> removed"
From e561255ce39e2190b6c4ba932d055d09f291a224 Mon Sep 17 00:00:00 2001
From: iamiks
Date: Thu, 2 Jul 2026 02:39:57 +0900
Subject: [PATCH 2/9] chore(gitignore): ignore local generated artifacts
---
.gitignore | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/.gitignore b/.gitignore
index 2682155..a25a8e6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,3 +14,7 @@
# Stray seed artifacts
/seed_*.yaml
+
+.om*/
+*.dump
+docs/rubrics/
From f1e8860db83a1eba51b90757181b0ae5039be0de Mon Sep 17 00:00:00 2001
From: iamiks
Date: Wed, 8 Jul 2026 01:05:53 +0900
Subject: [PATCH 3/9] feat(tui): handle native Windows console events
---
lib/ourocode/model/cli.ex | 33 +-
lib/ourocode/runtime/mcp_daemon/process.ex | 63 +-
lib/ourocode/terminal/tty_driver.ex | 138 +++-
.../terminal/tui_answer_submission.ex | 2 +-
lib/ourocode/terminal/tui_driver_session.ex | 53 +-
lib/ourocode/terminal/tui_input_loop.ex | 21 +-
lib/ourocode/terminal/tui_interaction.ex | 2 +-
lib/ourocode/terminal/tui_login.ex | 48 +-
rust/ourocode_ipc/Cargo.toml | 5 +
rust/ourocode_ipc/src/bin/ourocode.rs | 240 +++++++
rust/ourocode_ipc/src/bin/ourocode_tty.rs | 662 ++++++++++++++++--
scripts/package-windows.ps1 | 21 +
test/ourocode/ipc/helper_port_test.exs | 15 +-
test/ourocode/ipc/rpc_test.exs | 433 ++++++++----
.../runtime/mcp_daemon/process_test.exs | 152 +++-
test/ourocode/runtime/mcp_daemon_test.exs | 19 +-
.../runtime/stream_supervisor_test.exs | 6 +-
test/ourocode/terminal/tty_driver_test.exs | 38 +
.../terminal/tui_driver_session_test.exs | 30 +
.../ourocode/terminal/tui_input_loop_test.exs | 29 +
test/ourocode/terminal/tui_login_test.exs | 20 +
test/test_helper.exs | 186 +++++
22 files changed, 1970 insertions(+), 246 deletions(-)
create mode 100644 rust/ourocode_ipc/src/bin/ourocode.rs
diff --git a/lib/ourocode/model/cli.ex b/lib/ourocode/model/cli.ex
index 58b7bf1..80a6547 100644
--- a/lib/ourocode/model/cli.ex
+++ b/lib/ourocode/model/cli.ex
@@ -37,6 +37,21 @@ defmodule Ourocode.Model.Cli do
end
end
+ @doc false
+ @spec runner_command(
+ String.t(),
+ [String.t()],
+ {:unix | :win32, atom()},
+ (String.t() -> String.t() | nil)
+ ) :: {String.t(), [String.t()]}
+ def runner_command(path, args, os_type \\ :os.type(), which \\ &System.find_executable/1)
+
+ def runner_command(path, args, {:win32, _name}, _which), do: {path, args}
+
+ def runner_command(path, args, _os_type, which) when is_function(which, 1) do
+ {which.("sh") || "/bin/sh", ["-c", ~s(exec "$0" "$@"
delay = Keyword.get(opts, :retry_base_delay_ms, @retry_base_delay_ms)
- run_with_retry(id, path, args(id, prompt, nil), on_chunk, delay, 1)
+ run_with_retry(id, path, args(id, prompt, nil), on_chunk, delay, 1, run)
end
end
- defp run_with_retry(id, path, args, on_chunk, delay, attempt) do
+ defp run_with_retry(id, path, args, on_chunk, delay, attempt, run) do
emitted = :counters.new(1, [])
counted_chunk = fn chunk ->
@@ -67,11 +83,11 @@ defmodule Ourocode.Model.Cli do
on_chunk.(chunk)
end
- case run(id, path, args, counted_chunk) do
+ case run.(id, path, args, counted_chunk) do
{:error, {:exit, _status}} = error ->
if :counters.get(emitted, 1) == 0 and attempt < @max_attempts do
Process.sleep(delay * Integer.pow(2, attempt - 1))
- run_with_retry(id, path, args, on_chunk, delay, attempt + 1)
+ run_with_retry(id, path, args, on_chunk, delay, attempt + 1, run)
else
error
end
@@ -82,18 +98,15 @@ defmodule Ourocode.Model.Cli do
end
defp run(id, path, args, on_chunk) do
- # Spawn through `sh -c 'exec "$0" "$@" \"#{log_path}\" 2>&1", exe] ++ args,
+ shell: command,
+ args: command_args,
log_path: log_path
}
end
@spec stop(map() | nil) :: :ok
def stop(%{mode: :spawned} = handle) do
- case Map.get(handle, :os_pid) do
- pid when is_integer(pid) and pid > 0 ->
- System.cmd("kill", ["-TERM", Integer.to_string(pid)], stderr_to_stdout: true)
-
- _none ->
- :ok
- end
+ handle
+ |> Map.get(:os_pid)
+ |> terminate_os_process()
erl_port = Map.get(handle, :port)
if is_port(erl_port) and Port.info(erl_port) != nil, do: Port.close(erl_port)
@@ -68,4 +64,51 @@ defmodule Ourocode.Runtime.McpDaemon.Process do
end
def stop(_handle), do: :ok
+
+ defp launch_plan(nil, exe, args, log_path) do
+ if windows?() do
+ {exe, args}
+ else
+ shell = default_shell()
+ {shell, shell_args(shell, exe, args, log_path)}
+ end
+ end
+
+ defp launch_plan(shell, exe, args, log_path) do
+ if windows?() and windows_shell?(shell) do
+ {exe, args}
+ else
+ {shell, shell_args(shell, exe, args, log_path)}
+ end
+ end
+
+ defp default_shell do
+ System.find_executable("sh") || "/bin/sh"
+ end
+
+ defp shell_args(_shell, exe, args, log_path) do
+ ["-c", "exec \"$0\" \"$@\" >\"#{log_path}\" 2>&1", exe] ++ args
+ end
+
+ defp terminate_os_process(pid) when is_integer(pid) and pid > 0 do
+ if windows?() do
+ command = System.find_executable("taskkill") || "taskkill"
+ System.cmd(command, ["/PID", Integer.to_string(pid), "/T", "/F"], stderr_to_stdout: true)
+ else
+ System.cmd("kill", ["-TERM", Integer.to_string(pid)], stderr_to_stdout: true)
+ end
+
+ :ok
+ end
+
+ defp terminate_os_process(_pid), do: :ok
+
+ defp windows_shell?(shell) do
+ shell
+ |> Path.basename()
+ |> String.downcase()
+ |> then(&(&1 in ["cmd", "cmd.exe"]))
+ end
+
+ defp windows?, do: match?({:win32, _name}, :os.type())
end
diff --git a/lib/ourocode/terminal/tty_driver.ex b/lib/ourocode/terminal/tty_driver.ex
index bf08d6b..9851c0d 100644
--- a/lib/ourocode/terminal/tty_driver.ex
+++ b/lib/ourocode/terminal/tty_driver.ex
@@ -4,6 +4,11 @@ defmodule Ourocode.Terminal.TtyDriver do
"""
@poll_ms 500
+ @helper_osc_prefix "\e]777;ourocode-"
+ @helper_osc_resize_prefix "\e]777;ourocode-resize="
+ @helper_osc_redraw "\e]777;ourocode-control=redraw\a"
+ @bracketed_paste_start "\e[200~"
+ @bracketed_paste_end "\e[201~"
@doc "Absolute path of the built tty helper, or nil if it is not present."
@spec helper_path() :: String.t() | nil
@@ -34,12 +39,10 @@ defmodule Ourocode.Terminal.TtyDriver do
path ->
port =
- Port.open({:spawn_executable, String.to_charlist(path)}, [
- :binary,
- :exit_status,
- :nouse_stdio,
- :hide
- ])
+ Port.open(
+ {:spawn_executable, String.to_charlist(path)},
+ port_options(:os.type())
+ )
case read_header(port, "") do
{:ok, cols, rows, rest} ->
@@ -72,11 +75,22 @@ defmodule Ourocode.Terminal.TtyDriver do
end
@spec next_chunk(port() | nil, non_neg_integer()) ::
- {:ok, binary()} | {:file_cache_ready, [String.t()]} | :tick | :eof
+ {:ok, binary()}
+ | {:ok, binary(), binary()}
+ | {:resize, {pos_integer(), pos_integer()}}
+ | {:resize, {pos_integer(), pos_integer()}, binary()}
+ | {:control, :redraw}
+ | {:control, :redraw, binary()}
+ | {:ignore, binary()}
+ | {:file_cache_ready, [String.t()]}
+ | :tick
+ | :eof
def next_chunk(port, poll_ms \\ @poll_ms) do
receive do
{^port, {:data, data}} when is_binary(data) ->
- {:ok, data}
+ data
+ |> decode_chunk()
+ |> next_chunk_reply()
{^port, {:exit_status, _status}} ->
:eof
@@ -103,6 +117,11 @@ defmodule Ourocode.Terminal.TtyDriver do
System.get_env("OUROCODE_FORCE_TTY") == "1" or match?({:ok, _}, :io.columns())
end
+ @doc false
+ @spec port_options(:os.type()) :: [:binary | :exit_status | :nouse_stdio | :hide]
+ def port_options({:win32, _}), do: [:binary, :exit_status]
+ def port_options(_os_type), do: [:binary, :exit_status, :nouse_stdio, :hide]
+
@doc false
def enter_sequence, do: "\e[?1049h\e[?1006h\e[?1003h\e[?25l\e[2J\e[H"
@@ -140,6 +159,109 @@ defmodule Ourocode.Terminal.TtyDriver do
end
end
+ @doc false
+ @spec decode_chunk(binary()) ::
+ {:ok, binary(), binary()}
+ | {:resize, {pos_integer(), pos_integer()}, binary()}
+ | {:control, :redraw, binary()}
+ | {:ignore, binary()}
+ def decode_chunk(data) when is_binary(data) do
+ case helper_frame_bounds(data) do
+ nil ->
+ {:ok, data, ""}
+
+ {0, frame_size} ->
+ frame = binary_part(data, 0, frame_size)
+ rest = binary_part(data, frame_size, byte_size(data) - frame_size)
+ decode_helper_frame(frame, rest)
+
+ {start, _frame_size} ->
+ raw = binary_part(data, 0, start)
+ rest = binary_part(data, start, byte_size(data) - start)
+ {:ok, raw, rest}
+ end
+ end
+
+ defp next_chunk_reply({:ok, data, ""}), do: {:ok, data}
+ defp next_chunk_reply({:resize, size, ""}), do: {:resize, size}
+ defp next_chunk_reply({:control, :redraw, ""}), do: {:control, :redraw}
+ defp next_chunk_reply({:ignore, ""}), do: :tick
+ defp next_chunk_reply(other), do: other
+
+ defp decode_helper_frame(@helper_osc_redraw, rest), do: {:control, :redraw, rest}
+
+ defp decode_helper_frame(@helper_osc_resize_prefix <> rest = frame, remaining) do
+ with true <- String.ends_with?(rest, "\a"),
+ value <- binary_part(rest, 0, byte_size(rest) - 1),
+ [cols_text, rows_text] <- String.split(value, "x", parts: 2),
+ {cols, ""} when cols > 0 <- Integer.parse(cols_text),
+ {rows, ""} when rows > 0 <- Integer.parse(rows_text) do
+ {:resize, {cols, rows}, remaining}
+ else
+ _invalid ->
+ if helper_control_frame?(frame), do: {:ignore, remaining}, else: {:ok, frame, remaining}
+ end
+ end
+
+ defp decode_helper_frame(frame, rest) do
+ if helper_control_frame?(frame), do: {:ignore, rest}, else: {:ok, frame, rest}
+ end
+
+ defp helper_control_frame?(data) do
+ String.starts_with?(data, @helper_osc_prefix) and String.ends_with?(data, "\a")
+ end
+
+ defp helper_frame_bounds(data), do: helper_frame_bounds(data, 0)
+
+ defp helper_frame_bounds(data, offset) when offset >= byte_size(data), do: nil
+
+ defp helper_frame_bounds(data, offset) do
+ case next_helper_or_paste(data, offset) do
+ nil ->
+ nil
+
+ {:helper, index} ->
+ case match_from(data, "\a", index) do
+ {bel_index, 1} -> {index, bel_index - index + 1}
+ :nomatch -> nil
+ end
+
+ {:paste, index} ->
+ paste_content_index = index + byte_size(@bracketed_paste_start)
+
+ case match_from(data, @bracketed_paste_end, paste_content_index) do
+ {paste_end_index, paste_end_size} ->
+ helper_frame_bounds(data, paste_end_index + paste_end_size)
+
+ :nomatch ->
+ nil
+ end
+ end
+ end
+
+ defp next_helper_or_paste(data, offset) do
+ match =
+ [
+ helper: match_from(data, @helper_osc_prefix, offset),
+ paste: match_from(data, @bracketed_paste_start, offset)
+ ]
+ |> Enum.reject(fn {_kind, match} -> match == :nomatch end)
+ |> Enum.min_by(fn {_kind, {index, _size}} -> index end, fn -> nil end)
+
+ case match do
+ nil -> nil
+ {kind, {index, _size}} -> {kind, index}
+ end
+ end
+
+ defp match_from(data, pattern, offset) do
+ if offset >= byte_size(data) do
+ :nomatch
+ else
+ :binary.match(data, pattern, scope: {offset, byte_size(data) - offset})
+ end
+ end
+
defp safe_close(port) do
if is_port(port) and Port.info(port) != nil, do: Port.close(port)
:ok
diff --git a/lib/ourocode/terminal/tui_answer_submission.ex b/lib/ourocode/terminal/tui_answer_submission.ex
index 8a4ac52..fa2c68c 100644
--- a/lib/ourocode/terminal/tui_answer_submission.ex
+++ b/lib/ourocode/terminal/tui_answer_submission.ex
@@ -129,5 +129,5 @@ defmodule Ourocode.Terminal.TuiAnswerSubmission do
|> Kernel.in(["cancel", "decline", "/cancel"])
end
- defp log(output, text), do: IO.puts(output, text)
+ defp log(output, text), do: IO.puts(output, String.replace_invalid(text, ""))
end
diff --git a/lib/ourocode/terminal/tui_driver_session.ex b/lib/ourocode/terminal/tui_driver_session.ex
index d7b4979..882f460 100644
--- a/lib/ourocode/terminal/tui_driver_session.ex
+++ b/lib/ourocode/terminal/tui_driver_session.ex
@@ -44,11 +44,39 @@ defmodule Ourocode.Terminal.TuiDriverSession do
end
@spec next_chunk(pid(), non_neg_integer()) ::
- {:ok, binary()} | :tick | :eof
+ {:ok, binary()}
+ | {:resize, {pos_integer(), pos_integer()}}
+ | {:control, :redraw}
+ | :tick
+ | :eof
def next_chunk(state, poll_ms \\ @poll_ms) when is_pid(state) do
case TuiState.take_inbuf(state) do
"" ->
case TtyDriver.next_chunk(TuiState.port(state), poll_ms) do
+ {:ok, raw, rest} ->
+ TuiState.put_inbuf(state, rest)
+ {:ok, raw}
+
+ {:resize, size, rest} ->
+ TuiState.put_inbuf(state, rest)
+ TuiState.put_size(state, size)
+ {:resize, size}
+
+ {:resize, size} ->
+ TuiState.put_size(state, size)
+ {:resize, size}
+
+ {:control, :redraw, rest} ->
+ TuiState.put_inbuf(state, rest)
+ {:control, :redraw}
+
+ {:control, :redraw} ->
+ {:control, :redraw}
+
+ {:ignore, rest} ->
+ TuiState.put_inbuf(state, rest)
+ :tick
+
{:file_cache_ready, files} ->
TuiState.put_file_cache(state, files)
:tick
@@ -58,10 +86,31 @@ defmodule Ourocode.Terminal.TuiDriverSession do
end
buffered ->
- {:ok, buffered}
+ apply_decoded_chunk(state, TtyDriver.decode_chunk(buffered))
end
end
+ defp apply_decoded_chunk(state, {:ok, raw, rest}) do
+ TuiState.put_inbuf(state, rest)
+ {:ok, raw}
+ end
+
+ defp apply_decoded_chunk(state, {:resize, size, rest}) do
+ TuiState.put_inbuf(state, rest)
+ TuiState.put_size(state, size)
+ {:resize, size}
+ end
+
+ defp apply_decoded_chunk(state, {:control, :redraw, rest}) do
+ TuiState.put_inbuf(state, rest)
+ {:control, :redraw}
+ end
+
+ defp apply_decoded_chunk(state, {:ignore, rest}) do
+ TuiState.put_inbuf(state, rest)
+ :tick
+ end
+
@spec refresh_size(pid()) :: {pos_integer(), pos_integer()}
def refresh_size(state) when is_pid(state) do
size = TtyDriver.size(TuiState.size(state))
diff --git a/lib/ourocode/terminal/tui_input_loop.ex b/lib/ourocode/terminal/tui_input_loop.ex
index 1a847eb..7a2b98c 100644
--- a/lib/ourocode/terminal/tui_input_loop.ex
+++ b/lib/ourocode/terminal/tui_input_loop.ex
@@ -64,7 +64,7 @@ defmodule Ourocode.Terminal.TuiInputLoop do
TuiInteraction.capturing?(result, state) and
match?(%{key: k} when k in [:enter, :escape], event) and
- not slash_submit?(event, state) ->
+ not command_submit?(event, state) ->
TuiInteraction.handle_event(event, result, output, state)
draw.()
cont.()
@@ -121,6 +121,15 @@ defmodule Ourocode.Terminal.TuiInputLoop do
:continue -> read_key_loop(result, output, state, callbacks)
end
+ {:resize, {columns, rows}} ->
+ redraw(callbacks, result, output, state, TuiState.buffer(state), columns, rows)
+ read_key_loop(result, output, state, callbacks)
+
+ {:control, :redraw} ->
+ {columns, rows} = TuiState.size(state)
+ redraw(callbacks, result, output, state, TuiState.buffer(state), columns, rows)
+ read_key_loop(result, output, state, callbacks)
+
{:ok, chunk} ->
{columns, rows} = TuiDriverSession.refresh_size(state)
{events, leftover} = KeyReader.decode(TuiState.take_leftover(state) <> chunk)
@@ -149,14 +158,18 @@ defmodule Ourocode.Terminal.TuiInputLoop do
})
end
- defp slash_submit?(%{key: :enter}, state) do
+ defp command_submit?(%{key: :enter}, state) do
state
|> TuiState.buffer()
|> String.trim_leading()
- |> String.starts_with?("/")
+ |> command_like?()
end
- defp slash_submit?(_event, _state), do: false
+ defp command_submit?(_event, _state), do: false
+
+ defp command_like?("/" <> _rest), do: true
+ defp command_like?("ooo" <> rest), do: rest == "" or String.match?(rest, ~r/^\s/)
+ defp command_like?(_line), do: false
defp pending_cancel_prefix?(result, state) do
buffer = TuiState.buffer(state)
diff --git a/lib/ourocode/terminal/tui_interaction.ex b/lib/ourocode/terminal/tui_interaction.ex
index 61be16e..884d330 100644
--- a/lib/ourocode/terminal/tui_interaction.ex
+++ b/lib/ourocode/terminal/tui_interaction.ex
@@ -431,7 +431,7 @@ defmodule Ourocode.Terminal.TuiInteraction do
defp accepted_notification(""), do: "accepted - answer captured"
defp accepted_notification(label), do: "accepted - " <> label
- defp log(output, text), do: IO.puts(output, text)
+ defp log(output, text), do: IO.puts(output, String.replace_invalid(text, ""))
defp clear_captured_activity(output) do
StringIO.flush(output)
diff --git a/lib/ourocode/terminal/tui_login.ex b/lib/ourocode/terminal/tui_login.ex
index 066c757..45a726a 100644
--- a/lib/ourocode/terminal/tui_login.ex
+++ b/lib/ourocode/terminal/tui_login.ex
@@ -257,11 +257,9 @@ defmodule Ourocode.Terminal.TuiLogin do
if TuiEnvironment.test_run?() do
{:error, :test_run}
else
- opener = System.find_executable("open") || System.find_executable("xdg-open")
-
- case opener do
+ case open_url_command(url, :os.type(), &System.find_executable/1) do
nil -> {:error, :not_found}
- command -> system_ok(command, [url])
+ {command, args} -> system_ok(command, args)
end
end
rescue
@@ -272,15 +270,51 @@ defmodule Ourocode.Terminal.TuiLogin do
if TuiEnvironment.test_run?() do
{:error, :test_run}
else
- case clipboard_command() do
- nil -> {:error, :not_found}
- command -> copy_with_stdin(command, text)
+ case windows_clipboard_command(text, :os.type(), &System.find_executable/1) do
+ {command, args, env} ->
+ system_ok(command, args, env: env)
+
+ nil ->
+ copy_to_unix_clipboard(text)
end
end
rescue
exception -> {:error, exception}
end
+ defp copy_to_unix_clipboard(text) do
+ case clipboard_command() do
+ nil -> {:error, :not_found}
+ command -> copy_with_stdin(command, text)
+ end
+ end
+
+ @doc false
+ @spec open_url_command(String.t(), tuple(), (String.t() -> String.t() | nil)) ::
+ {String.t(), [String.t()]} | nil
+ def open_url_command(url, {:win32, _}, _find_executable) do
+ {"rundll32.exe", ["url.dll,FileProtocolHandler", url]}
+ end
+
+ def open_url_command(url, _os_type, find_executable) do
+ case find_executable.("open") || find_executable.("xdg-open") do
+ nil -> nil
+ command -> {command, [url]}
+ end
+ end
+
+ @doc false
+ @spec windows_clipboard_command(String.t(), tuple(), (String.t() -> String.t() | nil)) ::
+ {String.t(), [String.t()], [{String.t(), String.t()}]} | nil
+ def windows_clipboard_command(text, {:win32, _}, find_executable) do
+ command = find_executable.("powershell.exe") || "powershell.exe"
+ args = ["-NoProfile", "-Command", "Set-Clipboard -Value $env:OUROCODE_CLIPBOARD_TEXT"]
+
+ {command, args, [{"OUROCODE_CLIPBOARD_TEXT", text}]}
+ end
+
+ def windows_clipboard_command(_text, _os_type, _find_executable), do: nil
+
defp clipboard_command do
System.find_executable("pbcopy") ||
if(File.exists?("/usr/bin/pbcopy"), do: "/usr/bin/pbcopy")
diff --git a/rust/ourocode_ipc/Cargo.toml b/rust/ourocode_ipc/Cargo.toml
index c5a44ec..bf4f4d3 100644
--- a/rust/ourocode_ipc/Cargo.toml
+++ b/rust/ourocode_ipc/Cargo.toml
@@ -13,11 +13,16 @@ path = "src/lib.rs"
name = "ourocode_tty"
path = "src/bin/ourocode_tty.rs"
+[[bin]]
+name = "ourocode"
+path = "src/bin/ourocode.rs"
+
[dependencies]
serde_json = "1"
libc = "0.2"
windows-sys = { version = "0.59", features = [
"Win32_Foundation",
+ "Win32_Security",
"Win32_Storage_FileSystem",
"Win32_System_Console",
"Win32_System_IO",
diff --git a/rust/ourocode_ipc/src/bin/ourocode.rs b/rust/ourocode_ipc/src/bin/ourocode.rs
new file mode 100644
index 0000000..ada38f6
--- /dev/null
+++ b/rust/ourocode_ipc/src/bin/ourocode.rs
@@ -0,0 +1,240 @@
+use std::env;
+use std::ffi::OsString;
+use std::fs;
+use std::path::{Path, PathBuf};
+use std::process::{Command, ExitCode};
+
+fn main() -> ExitCode {
+ match run() {
+ Ok(code) => ExitCode::from(code),
+ Err(message) => {
+ eprintln!("ourocode.exe: {message}");
+ ExitCode::from(1)
+ }
+ }
+}
+
+fn run() -> Result {
+ let exe =
+ env::current_exe().map_err(|error| format!("could not locate executable: {error}"))?;
+ let exe_dir = exe
+ .parent()
+ .ok_or_else(|| format!("could not resolve executable directory: {}", exe.display()))?;
+ let escript = resolve_escript(exe_dir)
+ .ok_or_else(|| format!("could not find ourocode escript near {}", exe_dir.display()))?;
+ let tty = resolve_tty(exe_dir);
+ let escript_runner = resolve_escript_runner().ok_or_else(|| {
+ "could not find escript.exe; set ESCRIPT or install Erlang/Elixir".to_owned()
+ })?;
+
+ let mut command = Command::new(escript_runner);
+ command.arg(escript).args(env::args_os().skip(1));
+
+ if env::var_os("OUROCODE_TTY").is_none() {
+ if let Some(path) = tty {
+ command.env("OUROCODE_TTY", path);
+ }
+ }
+
+ let status = command
+ .status()
+ .map_err(|error| format!("failed to start escript.exe: {error}"))?;
+ Ok(exit_code(status.code()))
+}
+
+fn exit_code(code: Option) -> u8 {
+ match code {
+ Some(value) if (0..=255).contains(&value) => value as u8,
+ Some(_) => 1,
+ None => 1,
+ }
+}
+
+fn resolve_escript(exe_dir: &Path) -> Option {
+ candidate_escripts(exe_dir)
+ .into_iter()
+ .find(|candidate| candidate.is_file())
+}
+
+fn candidate_escripts(exe_dir: &Path) -> [PathBuf; 2] {
+ [
+ exe_dir.join("ourocode"),
+ exe_dir.parent().map_or_else(
+ || exe_dir.join("ourocode"),
+ |parent| parent.join("ourocode"),
+ ),
+ ]
+}
+
+fn resolve_escript_runner() -> Option {
+ if let Some(path) = env::var_os("ESCRIPT") {
+ return Some(path);
+ }
+
+ candidate_escript_runners()
+ .into_iter()
+ .find(|candidate| candidate.is_file())
+ .map(PathBuf::into_os_string)
+ .or_else(|| Some(OsString::from("escript.exe")))
+}
+
+fn candidate_escript_runners() -> Vec {
+ let mut candidates = path_escript_candidates();
+
+ if let Some(user_profile) = env::var_os("USERPROFILE") {
+ let otp_root = PathBuf::from(user_profile)
+ .join(".elixir-install")
+ .join("installs")
+ .join("otp");
+ candidates.extend(versioned_escript_candidates(&otp_root));
+ }
+
+ candidates.extend(program_files_escript_candidates("ProgramFiles"));
+ candidates.extend(program_files_escript_candidates("ProgramFiles(x86)"));
+ candidates
+}
+
+fn path_escript_candidates() -> Vec {
+ env::var_os("PATH")
+ .map(|path| {
+ env::split_paths(&path)
+ .map(|entry| entry.join("escript.exe"))
+ .collect()
+ })
+ .unwrap_or_default()
+}
+
+fn versioned_escript_candidates(root: &Path) -> Vec {
+ let mut versions = match fs::read_dir(root) {
+ Ok(entries) => entries
+ .filter_map(|entry| entry.ok().map(|entry| entry.path()))
+ .collect::>(),
+ Err(_error) => Vec::new(),
+ };
+
+ versions.sort();
+ versions.reverse();
+
+ versions
+ .into_iter()
+ .flat_map(|version| version_escript_candidates(&version))
+ .collect()
+}
+
+fn version_escript_candidates(version: &Path) -> Vec {
+ let mut candidates = vec![version.join("bin").join("escript.exe")];
+
+ let mut erts_dirs = match fs::read_dir(version) {
+ Ok(entries) => entries
+ .filter_map(|entry| entry.ok().map(|entry| entry.path()))
+ .filter(|path| {
+ path.file_name()
+ .and_then(|name| name.to_str())
+ .is_some_and(|name| name.starts_with("erts-"))
+ })
+ .collect::>(),
+ Err(_error) => Vec::new(),
+ };
+
+ erts_dirs.sort();
+ erts_dirs.reverse();
+ candidates.extend(
+ erts_dirs
+ .into_iter()
+ .map(|path| path.join("bin").join("escript.exe")),
+ );
+ candidates
+}
+
+fn program_files_escript_candidates(var_name: &str) -> Vec {
+ env::var_os(var_name)
+ .map(|program_files| {
+ vec![
+ PathBuf::from(&program_files)
+ .join("Erlang OTP")
+ .join("bin")
+ .join("escript.exe"),
+ PathBuf::from(program_files)
+ .join("Erlang OTP")
+ .join("erts-17.0.2")
+ .join("bin")
+ .join("escript.exe"),
+ ]
+ })
+ .unwrap_or_default()
+}
+
+fn resolve_tty(exe_dir: &Path) -> Option {
+ candidate_ttys(exe_dir)
+ .into_iter()
+ .find(|candidate| candidate.is_file())
+}
+
+fn candidate_ttys(exe_dir: &Path) -> [PathBuf; 2] {
+ [
+ exe_dir.join("ourocode_tty.exe"),
+ exe_dir.join("bin").join("ourocode_tty.exe"),
+ ]
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{candidate_escripts, candidate_ttys, exit_code, versioned_escript_candidates};
+ use std::fs;
+ use std::path::{Path, PathBuf};
+
+ #[test]
+ fn resolves_escript_candidates_for_source_and_installed_layouts() {
+ let candidates = candidate_escripts(Path::new("C:/tools/ourocode/bin"));
+
+ assert_eq!(candidates[0], Path::new("C:/tools/ourocode/bin/ourocode"));
+ assert_eq!(candidates[1], Path::new("C:/tools/ourocode/ourocode"));
+ }
+
+ #[test]
+ fn resolves_tty_candidates_for_source_and_installed_layouts() {
+ let candidates = candidate_ttys(Path::new("C:/tools/ourocode"));
+
+ assert_eq!(
+ candidates[0],
+ Path::new("C:/tools/ourocode/ourocode_tty.exe")
+ );
+ assert_eq!(
+ candidates[1],
+ Path::new("C:/tools/ourocode/bin/ourocode_tty.exe")
+ );
+ }
+
+ #[test]
+ fn normalizes_process_exit_codes() {
+ assert_eq!(exit_code(Some(0)), 0);
+ assert_eq!(exit_code(Some(255)), 255);
+ assert_eq!(exit_code(Some(256)), 1);
+ assert_eq!(exit_code(None), 1);
+ }
+
+ #[test]
+ fn searches_newest_elixir_installed_otp_first() {
+ let root = test_root("ourocode-launcher-otp");
+ let old = root.join("28.0.1");
+ let new = root.join("29.0.2");
+ fs::create_dir_all(old.join("bin")).expect("create old bin");
+ fs::create_dir_all(new.join("bin")).expect("create new bin");
+ fs::create_dir_all(new.join("erts-17.0.2").join("bin")).expect("create erts bin");
+
+ let candidates = versioned_escript_candidates(&root);
+
+ assert_eq!(candidates[0], new.join("bin").join("escript.exe"));
+ assert_eq!(
+ candidates[1],
+ new.join("erts-17.0.2").join("bin").join("escript.exe")
+ );
+ assert_eq!(candidates[2], old.join("bin").join("escript.exe"));
+
+ fs::remove_dir_all(root).expect("remove test root");
+ }
+
+ fn test_root(name: &str) -> PathBuf {
+ std::env::temp_dir().join(format!("{name}-{}", std::process::id()))
+ }
+}
diff --git a/rust/ourocode_ipc/src/bin/ourocode_tty.rs b/rust/ourocode_ipc/src/bin/ourocode_tty.rs
index 2f64b46..210466e 100644
--- a/rust/ourocode_ipc/src/bin/ourocode_tty.rs
+++ b/rust/ourocode_ipc/src/bin/ourocode_tty.rs
@@ -6,17 +6,21 @@
//! a Port-spawned child has no controlling terminal so `/dev/tty` is not an
//! option either.
//!
-//! So we use the terminal fds the BEAM already holds. Erlang's
-//! `:nouse_stdio` port option leaves fds 0/1/2 inherited from the BEAM (the
-//! real terminal) and moves the Erlang<->port protocol to fds 3 and 4.
-//! termios works on any tty fd whether or not it is the ctty, so we set raw
-//! mode directly on fd 0.
+//! On Unix, we use the terminal fds the BEAM already holds. Erlang's
+//! `:nouse_stdio` port option leaves fds 0/1/2 inherited from the BEAM
+//! (the real terminal) and moves the Erlang<->port protocol to fds 3 and 4.
+//! termios works on any tty fd whether or not it is the ctty, so the Unix
+//! helper sets raw mode directly on fd 0.
//!
//! * fd 0 terminal input (keystrokes; raw termios set here)
//! * fd 1 terminal output (frames written verbatim)
//! * fd 3 protocol in (frames from Elixir)
//! * fd 4 protocol out (size header then key byte stream to Elixir)
//!
+//! On Windows, Port stdio remains the protocol pipe on fd 0/1. Console input
+//! and output are acquired separately through CONIN$/CONOUT$ or real console
+//! std handles.
+//!
//! Protocol: first thing written to fd 4 is " \n"; everything
//! after is the raw key stream. Restores the original termios on exit.
@@ -158,27 +162,85 @@ fn main() {
#[cfg(windows)]
mod windows {
use std::ffi::c_void;
+ use std::mem::MaybeUninit;
use std::process;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
- use windows_sys::Win32::Foundation::{HANDLE, INVALID_HANDLE_VALUE};
- use windows_sys::Win32::Storage::FileSystem::{ReadFile, WriteFile};
+ use windows_sys::Win32::Foundation::{
+ CloseHandle, GENERIC_READ, GENERIC_WRITE, HANDLE, INVALID_HANDLE_VALUE,
+ };
+ use windows_sys::Win32::Storage::FileSystem::{
+ CreateFileW, WriteFile, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING,
+ };
use windows_sys::Win32::System::Console::{
- GetConsoleMode, GetConsoleScreenBufferInfo, GetStdHandle, SetConsoleMode,
- CONSOLE_SCREEN_BUFFER_INFO, ENABLE_ECHO_INPUT, ENABLE_LINE_INPUT, ENABLE_PROCESSED_INPUT,
- ENABLE_VIRTUAL_TERMINAL_INPUT, ENABLE_VIRTUAL_TERMINAL_PROCESSING, STD_INPUT_HANDLE,
- STD_OUTPUT_HANDLE,
+ AttachConsole, GetConsoleMode, GetConsoleScreenBufferInfo, GetStdHandle, ReadConsoleInputW,
+ SetConsoleCtrlHandler, SetConsoleMode, ATTACH_PARENT_PROCESS, CONSOLE_SCREEN_BUFFER_INFO,
+ ENABLE_ECHO_INPUT, ENABLE_LINE_INPUT, ENABLE_MOUSE_INPUT, ENABLE_PROCESSED_INPUT,
+ ENABLE_VIRTUAL_TERMINAL_INPUT, ENABLE_VIRTUAL_TERMINAL_PROCESSING, ENABLE_WINDOW_INPUT,
+ FROM_LEFT_1ST_BUTTON_PRESSED, INPUT_RECORD, KEY_EVENT, MOUSE_EVENT, STD_INPUT_HANDLE,
+ STD_OUTPUT_HANDLE, WINDOW_BUFFER_SIZE_EVENT,
};
- const PROTO_IN: libc::c_int = 3;
- const PROTO_OUT: libc::c_int = 4;
+ const PROTO_IN: libc::c_int = 0;
+ const PROTO_OUT: libc::c_int = 1;
+ const CONSOLE_READ_WRITE: u32 = GENERIC_READ | GENERIC_WRITE;
static RESTORED: AtomicBool = AtomicBool::new(false);
static mut INPUT_HANDLE: HANDLE = std::ptr::null_mut();
static mut OUTPUT_HANDLE: HANDLE = std::ptr::null_mut();
static mut INPUT_MODE: u32 = 0;
static mut OUTPUT_MODE: u32 = 0;
+ static mut CLOSE_INPUT_HANDLE: bool = false;
+ static mut CLOSE_OUTPUT_HANDLE: bool = false;
+
+ #[derive(Clone, Copy, Debug, PartialEq, Eq)]
+ enum ConsoleDevice {
+ Input,
+ Output,
+ }
+
+ #[derive(Clone, Copy, Debug, PartialEq, Eq)]
+ enum ConsoleHandleCandidate {
+ NamedConsole { name: &'static str, access: u32 },
+ StdHandle(u32),
+ }
+
+ struct AcquiredConsoleHandle {
+ handle: HANDLE,
+ close_on_restore: bool,
+ }
+
+ enum WindowsConsoleEvent {
+ Key(WindowsKeyEvent),
+ Resize(WindowsResizeEvent),
+ Mouse(WindowsMouseEvent),
+ }
+
+ struct WindowsKeyEvent {
+ key_down: bool,
+ unicode_char: Option,
+ }
+
+ struct WindowsResizeEvent {
+ columns: i16,
+ rows: i16,
+ }
+
+ struct WindowsMouseEvent {
+ column: i16,
+ row: i16,
+ button: MouseButton,
+ state: MouseButtonState,
+ }
+
+ enum MouseButton {
+ Left,
+ }
+
+ enum MouseButtonState {
+ Pressed,
+ }
struct ConsoleGuard;
@@ -188,11 +250,117 @@ mod windows {
}
}
+ fn translate_windows_console_event(event: WindowsConsoleEvent) -> Option> {
+ match event {
+ WindowsConsoleEvent::Key(event) => translate_key_event(event),
+ WindowsConsoleEvent::Resize(event) => translate_resize_event(event),
+ WindowsConsoleEvent::Mouse(event) => translate_mouse_event(event),
+ }
+ }
+
+ fn input_record_to_windows_console_event(record: &INPUT_RECORD) -> Option {
+ match u32::from(record.EventType) {
+ KEY_EVENT => {
+ // SAFETY: Category 8 - FFI boundary union access. Win32 sets
+ // EventType to KEY_EVENT only when Event.KeyEvent is the active
+ // INPUT_RECORD union field; tests construct the same invariant.
+ let event = unsafe { record.Event.KeyEvent };
+ // SAFETY: Category 8 - FFI boundary union access. The `W`
+ // console APIs document UnicodeChar as the active key char
+ // representation for KEY_EVENT_RECORD.
+ let unicode = unsafe { event.uChar.UnicodeChar };
+ let unicode_char = if unicode == 0 {
+ None
+ } else {
+ char::from_u32(u32::from(unicode))
+ };
+
+ Some(WindowsConsoleEvent::Key(WindowsKeyEvent {
+ key_down: event.bKeyDown != 0,
+ unicode_char,
+ }))
+ }
+ WINDOW_BUFFER_SIZE_EVENT => {
+ // SAFETY: Category 8 - FFI boundary union access. Win32 sets
+ // EventType to WINDOW_BUFFER_SIZE_EVENT only when
+ // Event.WindowBufferSizeEvent is the active union field.
+ let event = unsafe { record.Event.WindowBufferSizeEvent };
+
+ Some(WindowsConsoleEvent::Resize(WindowsResizeEvent {
+ columns: event.dwSize.X,
+ rows: event.dwSize.Y,
+ }))
+ }
+ MOUSE_EVENT => {
+ // SAFETY: Category 8 - FFI boundary union access. Win32 sets
+ // EventType to MOUSE_EVENT only when Event.MouseEvent is the
+ // active INPUT_RECORD union field.
+ let event = unsafe { record.Event.MouseEvent };
+ if event.dwButtonState & FROM_LEFT_1ST_BUTTON_PRESSED == 0 {
+ return None;
+ }
+
+ Some(WindowsConsoleEvent::Mouse(WindowsMouseEvent {
+ column: event.dwMousePosition.X,
+ row: event.dwMousePosition.Y,
+ button: MouseButton::Left,
+ state: MouseButtonState::Pressed,
+ }))
+ }
+ _ => None,
+ }
+ }
+
+ fn translate_key_event(event: WindowsKeyEvent) -> Option> {
+ if !event.key_down {
+ return None;
+ }
+
+ event.unicode_char.map(|ch| {
+ let mut encoded = [0u8; 4];
+ ch.encode_utf8(&mut encoded).as_bytes().to_vec()
+ })
+ }
+
+ fn translate_resize_event(event: WindowsResizeEvent) -> Option> {
+ if event.columns <= 0 || event.rows <= 0 {
+ return None;
+ }
+
+ Some(
+ format!(
+ "\x1b]777;ourocode-resize={}x{}\x07",
+ event.columns, event.rows
+ )
+ .into_bytes(),
+ )
+ }
+
+ fn translate_mouse_event(event: WindowsMouseEvent) -> Option> {
+ if event.column < 0 || event.row < 0 {
+ return None;
+ }
+
+ let button_code = match event.button {
+ MouseButton::Left => 0,
+ };
+ let suffix = match event.state {
+ MouseButtonState::Pressed => 'M',
+ };
+ let column = i32::from(event.column) + 1;
+ let row = i32::from(event.row) + 1;
+
+ Some(format!("\x1b[<{button_code};{column};{row}{suffix}").into_bytes())
+ }
+
fn restore() {
if RESTORED.swap(true, Ordering::SeqCst) {
return;
}
+ // SAFETY: Category 8 - FFI boundary. The handles and modes are copied
+ // from successful GetStdHandle/GetConsoleMode calls during setup and
+ // never mutated after the control handler is installed.
unsafe {
if !INPUT_HANDLE.is_null() && INPUT_HANDLE != INVALID_HANDLE_VALUE {
SetConsoleMode(INPUT_HANDLE, INPUT_MODE);
@@ -200,9 +368,32 @@ mod windows {
if !OUTPUT_HANDLE.is_null() && OUTPUT_HANDLE != INVALID_HANDLE_VALUE {
SetConsoleMode(OUTPUT_HANDLE, OUTPUT_MODE);
}
+
+ let _ = write_console(b"\x1b[?25h\x1b[?1049l");
+
+ if CLOSE_INPUT_HANDLE && !INPUT_HANDLE.is_null() && INPUT_HANDLE != INVALID_HANDLE_VALUE
+ {
+ CloseHandle(INPUT_HANDLE);
+ CLOSE_INPUT_HANDLE = false;
+ }
+ if CLOSE_OUTPUT_HANDLE
+ && !OUTPUT_HANDLE.is_null()
+ && OUTPUT_HANDLE != INVALID_HANDLE_VALUE
+ {
+ CloseHandle(OUTPUT_HANDLE);
+ CLOSE_OUTPUT_HANDLE = false;
+ }
}
+ }
- let _ = write_console(b"\x1b[?25h\x1b[?1049l");
+ unsafe extern "system" fn on_console_ctrl(_ctrl_type: u32) -> i32 {
+ restore();
+ 0
+ }
+
+ fn exit_after_restore(code: i32) -> ! {
+ restore();
+ process::exit(code);
}
fn write_proto(buf: &[u8]) -> bool {
@@ -262,26 +453,31 @@ mod windows {
true
}
- fn read_console(buf: &mut [u8]) -> isize {
+ fn read_console_event() -> Result