feat(conpty): integrate bundled ConPTY support and verification (#488)

- Added scripts to prepare and verify the Microsoft ConPTY package for Windows.
- Updated build workflows to include checks for bundled ConPTY in installers.
- Enhanced application support info to display ConPTY status and version.
- Modified the portable updater to handle ConPTY files during updates.
- Updated documentation to reflect changes in ConPTY usage and requirements.
- Introduced tests for ConPTY backend functionality to ensure reliability.
This commit is contained in:
Kang committed 2026-09-26 08:53:48 +08:00
1 parent 75dcfc1e8d
commit 31d1c51edb
43 files changed
+4638 -101

No files matched your search

+85
View File
@@ -0,0 +1,85 @@
[CmdletBinding()]
param(
[string]$InstallerPath,
[string]$PortableZipPath
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
if ([string]::IsNullOrWhiteSpace($InstallerPath) -eq [string]::IsNullOrWhiteSpace($PortableZipPath)) {
throw "Specify exactly one of InstallerPath or PortableZipPath."
}
$required = @(
'conpty[\\/]x64[\\/]conpty\.dll',
'conpty[\\/]x64[\\/]x64[\\/]OpenConsole\.exe',
'conpty[\\/]x64[\\/]arm64[\\/]OpenConsole\.exe',
'conpty[\\/]arm64[\\/]conpty\.dll',
'conpty[\\/]arm64[\\/]arm64[\\/]OpenConsole\.exe',
'conpty[\\/]LICENSE\.txt'
)
if ($InstallerPath) {
$path = (Resolve-Path -LiteralPath $InstallerPath).Path
if ([IO.Path]::GetExtension($path) -ieq '.msi') {
$tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath())
$extractDir = Join-Path $tempRoot "nyaterm-msi-inspect-$([Guid]::NewGuid().ToString('N'))"
try {
$process = Start-Process msiexec.exe -ArgumentList @(
'/a', "`"$path`"", '/qn', "TARGETDIR=`"$extractDir`""
) -Wait -PassThru -WindowStyle Hidden
if ($process.ExitCode -ne 0) {
throw "MSI administrative extraction failed with code $($process.ExitCode): $path"
}
$listing = (Get-ChildItem -LiteralPath $extractDir -Recurse -File |
ForEach-Object { $_.FullName.Substring($extractDir.Length).TrimStart('\', '/') }) -join "`n"
}
finally {
if ([IO.Path]::GetFullPath($extractDir).StartsWith($tempRoot, [StringComparison]::OrdinalIgnoreCase) -and
(Split-Path -Leaf $extractDir) -like 'nyaterm-msi-inspect-*') {
Remove-Item -LiteralPath $extractDir -Recurse -Force -ErrorAction SilentlyContinue
}
}
}
else {
$sevenZip = Get-Command 7z.exe -ErrorAction SilentlyContinue
if ($null -eq $sevenZip) {
foreach ($candidate in @(
(Join-Path $env:ProgramFiles "7-Zip\7z.exe"),
(Join-Path ${env:ProgramFiles(x86)} "7-Zip\7z.exe"),
"C:\ProgramData\chocolatey\bin\7z.exe"
)) {
if (Test-Path -LiteralPath $candidate -PathType Leaf) {
$sevenZip = $candidate
break
}
}
}
if ($null -eq $sevenZip) {
throw "7z.exe is required to inspect the NSIS installer."
}
$listing = (& $sevenZip l $path 2>&1 | Out-String)
if ($LASTEXITCODE -ne 0) {
throw "7z.exe could not inspect ${path}:`n$listing"
}
}
}
else {
Add-Type -AssemblyName System.IO.Compression.FileSystem
$path = (Resolve-Path -LiteralPath $PortableZipPath).Path
$archive = [System.IO.Compression.ZipFile]::OpenRead($path)
try {
$listing = ($archive.Entries | ForEach-Object FullName) -join "`n"
}
finally {
$archive.Dispose()
}
}
foreach ($pattern in $required) {
if ($listing -notmatch $pattern) {
throw "ConPTY payload missing from ${path}: $pattern"
}
}
Write-Host "ConPTY payload verified: $path"
@@ -54,4 +54,8 @@ if ($missing.Count -gt 0) {
throw "Windows 7 offline installer is missing expected WebView2 fixed runtime payload entries:`n$($missing -join "`n")"
}
if ($listing -match '(?i)conpty\.dll|OpenConsole\.exe') {
throw "Windows 7 installer must not contain bundled ConPTY files: $resolvedInstaller"
}
Write-Host "Windows 7 offline installer contains the WebView2 fixed runtime payload: $resolvedInstaller"
+21
View File
@@ -207,6 +207,24 @@ jobs:
includeUpdaterJson: false
args: ${{ matrix.args }}
- name: Verify Windows installer contains ConPTY
if: matrix.os_name == 'windows'
shell: pwsh
run: |
if (!(Get-Command 7z.exe -ErrorAction SilentlyContinue) -and
!(Test-Path (Join-Path $env:ProgramFiles "7-Zip/7z.exe"))) {
choco install 7zip -y --no-progress
}
$releaseDir = if ("${{ matrix.target }}" -eq "aarch64-pc-windows-msvc") {
"src-tauri/target/aarch64-pc-windows-msvc/release"
} else {
"src-tauri/target/release"
}
$installer = Get-ChildItem (Join-Path $releaseDir "bundle/nsis") -Filter "*.exe" |
Select-Object -First 1
if ($null -eq $installer) { throw "Missing NSIS installer" }
./.github/scripts/assert-conpty-package.ps1 -InstallerPath $installer.FullName
- name: Collect macOS assets
if: matrix.os_name == 'macos'
shell: bash
@@ -317,10 +335,13 @@ jobs:
New-Item -ItemType Directory -Path $dataDir -Force | Out-Null
Copy-Item -LiteralPath $exe -Destination (Join-Path $portableDir "NyaTerm.exe")
Copy-Item -LiteralPath $sidecar -Destination (Join-Path $portableDir "nyaterm-mcp.exe")
Copy-Item -LiteralPath "src-tauri/resources/windows/conpty" -Destination $portableDir -Recurse
Copy-Item -LiteralPath "src-tauri/conpty-license/LICENSE.txt" -Destination (Join-Path $portableDir "conpty/LICENSE.txt")
New-Item -ItemType File -Path (Join-Path $portableDir "portable.flag") -Force | Out-Null
New-Item -ItemType File -Path (Join-Path $dataDir ".keep") -Force | Out-Null
Compress-Archive -Path $portableDir -DestinationPath $portableZip -Force
./.github/scripts/assert-conpty-package.ps1 -PortableZipPath $portableZip
Get-ChildItem -Path "dist-release" -File | ForEach-Object {
Write-Host $_.FullName
+24
View File
@@ -225,6 +225,27 @@ jobs:
includeUpdaterJson: false
args: ${{ matrix.args }}
- name: Verify Windows installers contain ConPTY
if: matrix.platform == 'windows-latest'
shell: pwsh
run: |
if (!(Get-Command 7z.exe -ErrorAction SilentlyContinue) -and
!(Test-Path (Join-Path $env:ProgramFiles "7-Zip/7z.exe"))) {
choco install 7zip -y --no-progress
}
$releaseDir = if ("${{ matrix.target }}" -eq "aarch64-pc-windows-msvc") {
"src-tauri/target/aarch64-pc-windows-msvc/release"
} else {
"src-tauri/target/release"
}
foreach ($kind in @("nsis", "msi")) {
$installer = Get-ChildItem (Join-Path $releaseDir "bundle/$kind") -File |
Where-Object { $_.Extension -in @(".exe", ".msi") } |
Select-Object -First 1
if ($null -eq $installer) { throw "Missing $kind installer" }
./.github/scripts/assert-conpty-package.ps1 -InstallerPath $installer.FullName
}
- name: Package Windows portable zip
if: startsWith(github.ref, 'refs/tags/v') && matrix.platform == 'windows-latest'
shell: pwsh
@@ -269,10 +290,13 @@ jobs:
New-Item -ItemType Directory -Path $dataDir -Force | Out-Null
Copy-Item -LiteralPath $exe -Destination (Join-Path $portableDir "NyaTerm.exe")
Copy-Item -LiteralPath $sidecar -Destination (Join-Path $portableDir "nyaterm-mcp.exe")
Copy-Item -LiteralPath "src-tauri/resources/windows/conpty" -Destination $portableDir -Recurse
Copy-Item -LiteralPath "src-tauri/conpty-license/LICENSE.txt" -Destination (Join-Path $portableDir "conpty/LICENSE.txt")
New-Item -ItemType File -Path (Join-Path $portableDir "portable.flag") -Force | Out-Null
New-Item -ItemType File -Path (Join-Path $dataDir ".keep") -Force | Out-Null
Compress-Archive -Path $portableDir -DestinationPath $assetPath -Force
./.github/scripts/assert-conpty-package.ps1 -PortableZipPath $assetPath
Remove-Item Env:TAURI_SIGNING_PRIVATE_KEY_PATH -ErrorAction SilentlyContinue
pnpm tauri signer sign --password "$env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD" "$assetPath"
+6
View File
@@ -58,6 +58,9 @@ jobs:
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Prepare official ConPTY package
run: pnpm prepare:conpty
- name: Install stable Rust
uses: dtolnay/rust-toolchain@stable
@@ -70,3 +73,6 @@ jobs:
- name: Build modern Windows target without bundle
run: pnpm tauri build --target x86_64-pc-windows-msvc --no-bundle --ci
- name: Verify bundled ConPTY and system fallback
run: cargo test --manifest-path src-tauri/Cargo.toml --locked --test conpty_backend
+1
View File
@@ -16,6 +16,7 @@ dist-ssr
*.local
target/
src-tauri/binaries/nyaterm-mcp-*
src-tauri/resources/windows/conpty/
temp/
# Editor directories and files
@@ -12,7 +12,7 @@ NyaTerm 支持以下操作系统:
- **macOS** 12+ (Intel & Apple Silicon)
- **Linux**(Ubuntu 20.04+、Fedora 36+、Arch Linux 等)
Windows 本地终端依赖系统 ConPTY,因此 Windows 10 1809(Build 17763)是最低支持版本。
Windows 本地终端优先使用随应用分发的 Microsoft ConPTY;如果加载或创建失败,则使用系统 ConPTY。系统仍须提供 ConPTY API,因此 Windows 10 1809(Build 17763)是最低支持版本。可在“关于”中的支持信息查看实际使用的版本。
## 下载安装
@@ -28,6 +28,8 @@ Windows 本地终端依赖系统 ConPTY,因此 Windows 10 1809(Build 17763
Windows 便携版解压后运行 `NyaTerm.exe` 即可。**Help → 检查更新** 与安装版共用 Cloudflare R2 更新清单和下载源,暂存更新前会强制验证 Tauri updater 签名。重启时 NyaTerm 会自动替换程序文件,并完整保留 `data/` 目录。
首次升级到包含 bundled ConPTY 的便携版时,旧版更新器无法处理新增的 `conpty/` 目录。请手动下载新版便携包,替换程序文件并保留原有 `data/` 目录;之后可继续使用应用内更新。
Windows 便携版直接下载:
- [NyaTerm_1.1.18_windows_x64_portable.zip](https://downloads.nyaterm.app/releases/v1.1.18/NyaTerm_1.1.18_windows_x64_portable.zip)(x64)
@@ -12,7 +12,7 @@ NyaTerm supports the following operating systems:
- **macOS** 12+ (Intel & Apple Silicon)
- **Linux** (Ubuntu 20.04+, Fedora 36+, Arch Linux, and similar distributions)
Local Terminal on Windows depends on the system ConPTY API, so Windows 10 version 1809 (Build 17763) is the minimum supported release.
Local Terminal prefers the Microsoft ConPTY bundled with NyaTerm and falls back to the system ConPTY if loading or creation fails. The system must still provide the ConPTY API, so Windows 10 version 1809 (Build 17763) is the minimum supported release. The About dialog's support information shows the backend actually used.
## Download and install
@@ -28,6 +28,8 @@ Visit the [Releases](https://github.com/nyakang/nyaterm/releases) page and downl
For the Windows portable edition, extract the zip and run `NyaTerm.exe`. **Help → Check Updates** uses the same Cloudflare R2 update manifest and download source as the installed edition. Tauri updater signatures are always verified before staging an update; when restarting, NyaTerm replaces the program files and preserves the complete `data/` folder.
The first upgrade to a portable build with bundled ConPTY requires a manual download because older updaters cannot process the new `conpty/` directory. Replace the program files while keeping your existing `data/` directory. In-app updates work again after that upgrade.
Direct downloads for the Windows portable edition:
- [NyaTerm_1.1.18_windows_x64_portable.zip](https://downloads.nyaterm.app/releases/v1.1.18/NyaTerm_1.1.18_windows_x64_portable.zip) (x64)
+1
View File
@@ -20,6 +20,7 @@
"dev": "vite",
"build": "pnpm build:mcp-sidecar && tsc && vite build",
"build:mcp-sidecar": "node scripts/build-mcp-sidecar.mjs",
"prepare:conpty": "node scripts/prepare-conpty.mjs",
"release": "node scripts/sync-version.mjs --commit && tauri build",
"build:unlock-rescue": "powershell -ExecutionPolicy Bypass -File scripts/build-unlock-rescue.ps1",
"preview": "vite preview",
+13
View File
@@ -0,0 +1,13 @@
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
if (process.platform === "win32") {
const script = fileURLToPath(new URL("./prepare-conpty.ps1", import.meta.url));
const result = spawnSync(
"powershell.exe",
["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", script],
{ stdio: "inherit" },
);
if (result.error) throw result.error;
process.exit(result.status ?? 1);
}
+97
View File
@@ -0,0 +1,97 @@
[CmdletBinding()]
param()
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
Add-Type -AssemblyName System.Net.Http
Add-Type -AssemblyName System.IO.Compression.FileSystem
$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
$tauriRoot = Join-Path $repositoryRoot "src-tauri"
$package = Get-Content (Join-Path $tauriRoot "conpty-package.json") -Raw | ConvertFrom-Json
$packageId = "microsoft.windows.console.conpty"
$url = "https://api.nuget.org/v3-flatcontainer/$packageId/$($package.version)/$packageId.$($package.version).nupkg"
$cacheDir = Join-Path $tauriRoot "target/conpty-package"
$packagePath = Join-Path $cacheDir "$packageId.$($package.version).nupkg"
$outputDir = Join-Path $tauriRoot "resources/windows/conpty"
New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null
if (!(Test-Path -LiteralPath $packagePath -PathType Leaf)) {
$client = [System.Net.Http.HttpClient]::new()
try {
$bytes = $client.GetByteArrayAsync($url).GetAwaiter().GetResult()
[System.IO.File]::WriteAllBytes($packagePath, $bytes)
}
finally {
$client.Dispose()
}
}
$hashAlgorithm = [System.Security.Cryptography.SHA256]::Create()
try {
$packageStream = [System.IO.File]::OpenRead($packagePath)
try {
$actualHash = [System.BitConverter]::ToString($hashAlgorithm.ComputeHash($packageStream)).Replace("-", "").ToLowerInvariant()
}
finally {
$packageStream.Dispose()
}
}
finally {
$hashAlgorithm.Dispose()
}
if ($actualHash -ne $package.sha256) {
throw "ConPTY package SHA-256 mismatch: expected $($package.sha256), got $actualHash"
}
$files = @(
@{ Source = "runtimes/win-x64/native/conpty.dll"; Target = "x64/conpty.dll"; Machine = 0x8664 },
@{ Source = "build/native/runtimes/x64/OpenConsole.exe"; Target = "x64/x64/OpenConsole.exe"; Machine = 0x8664 },
@{ Source = "build/native/runtimes/arm64/OpenConsole.exe"; Target = "x64/arm64/OpenConsole.exe"; Machine = 0xAA64 },
@{ Source = "runtimes/win-arm64/native/conpty.dll"; Target = "arm64/conpty.dll"; Machine = 0xAA64 },
@{ Source = "build/native/runtimes/arm64/OpenConsole.exe"; Target = "arm64/arm64/OpenConsole.exe"; Machine = 0xAA64 }
)
$archive = [System.IO.Compression.ZipFile]::OpenRead($packagePath)
try {
foreach ($file in $files) {
$entry = $archive.GetEntry($file.Source)
if ($null -eq $entry) {
throw "ConPTY package is missing $($file.Source)"
}
$stream = [System.IO.MemoryStream]::new()
try {
$entryStream = $entry.Open()
try {
$entryStream.CopyTo($stream)
}
finally {
$entryStream.Dispose()
}
$bytes = $stream.ToArray()
}
finally {
$stream.Dispose()
}
if ($bytes.Length -lt 64 -or [System.BitConverter]::ToUInt16($bytes, 0) -ne 0x5A4D) {
throw "ConPTY package contains an invalid PE file: $($file.Source)"
}
$peOffset = [System.BitConverter]::ToInt32($bytes, 0x3C)
if ($peOffset -lt 0 -or $peOffset + 6 -gt $bytes.Length -or
[System.BitConverter]::ToUInt32($bytes, $peOffset) -ne 0x00004550 -or
[System.BitConverter]::ToUInt16($bytes, $peOffset + 4) -ne $file.Machine) {
throw "ConPTY package PE architecture mismatch: $($file.Source)"
}
$target = Join-Path $outputDir $file.Target
New-Item -ItemType Directory -Path (Split-Path -Parent $target) -Force | Out-Null
[System.IO.File]::WriteAllBytes($target, $bytes)
}
}
finally {
$archive.Dispose()
}
Write-Host "Prepared Microsoft.Windows.Console.ConPTY $($package.version)"
+33 -89
View File
@@ -879,6 +879,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
[[package]]
name = "cfg_aliases"
version = "0.2.2"
@@ -2229,7 +2235,7 @@ version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f"
dependencies = [
"memoffset 0.9.1",
"memoffset",
"rustc_version",
]
@@ -3542,15 +3548,6 @@ dependencies = [
"mach2",
]
[[package]]
name = "ioctl-rs"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7970510895cee30b3e9128319f2cefd4bde883a39f38baa279567ba3a7eb97d"
dependencies = [
"libc",
]
[[package]]
name = "ipnet"
version = "2.12.1"
@@ -4436,15 +4433,6 @@ version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "memoffset"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce"
dependencies = [
"autocfg",
]
[[package]]
name = "memoffset"
version = "0.9.1"
@@ -4617,20 +4605,6 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nix"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4"
dependencies = [
"autocfg",
"bitflags 1.3.2",
"cfg-if",
"libc",
"memoffset 0.6.5",
"pin-utils",
]
[[package]]
name = "nix"
version = "0.26.4"
@@ -4642,6 +4616,18 @@ dependencies = [
"libc",
]
[[package]]
name = "nix"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4"
dependencies = [
"bitflags 2.13.1",
"cfg-if",
"cfg_aliases 0.1.1",
"libc",
]
[[package]]
name = "nix"
version = "0.29.0"
@@ -4650,9 +4636,9 @@ checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
"bitflags 2.13.1",
"cfg-if",
"cfg_aliases",
"cfg_aliases 0.2.2",
"libc",
"memoffset 0.9.1",
"memoffset",
]
[[package]]
@@ -4663,7 +4649,7 @@ checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
"bitflags 2.13.1",
"cfg-if",
"cfg_aliases",
"cfg_aliases 0.2.2",
"libc",
]
@@ -6126,9 +6112,7 @@ dependencies = [
[[package]]
name = "portable-pty"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "806ee80c2a03dbe1a9fb9534f8d19e4c0546b790cde8fd1fea9d6390644cb0be"
version = "0.9.0"
dependencies = [
"anyhow",
"bitflags 1.3.2",
@@ -6137,8 +6121,8 @@ dependencies = [
"lazy_static",
"libc",
"log",
"nix 0.25.1",
"serial",
"nix 0.28.0",
"serial2",
"shared_library",
"shell-words",
"winapi",
@@ -6326,7 +6310,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
dependencies = [
"bytes",
"cfg_aliases",
"cfg_aliases 0.2.2",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
@@ -6368,7 +6352,7 @@ version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
dependencies = [
"cfg_aliases",
"cfg_aliases 0.2.2",
"libc",
"once_cell",
"socket2",
@@ -7576,45 +7560,14 @@ dependencies = [
]
[[package]]
name = "serial"
version = "0.4.0"
name = "serial2"
version = "0.2.38"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1237a96570fc377c13baa1b88c7589ab66edced652e43ffb17088f003db3e86"
dependencies = [
"serial-core",
"serial-unix",
"serial-windows",
]
[[package]]
name = "serial-core"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f46209b345401737ae2125fe5b19a77acce90cd53e1658cda928e4fe9a64581"
checksum = "b16809bc35793b19ce4e0c53924bc0dce3937f15487997cfdaed936004180730"
dependencies = [
"cfg-if",
"libc",
]
[[package]]
name = "serial-unix"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f03fbca4c9d866e24a459cbca71283f545a37f8e3e002ad8c70593871453cab7"
dependencies = [
"ioctl-rs",
"libc",
"serial-core",
"termios",
]
[[package]]
name = "serial-windows"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15c6d3b776267a75d31bbdfd5d36c0ca051251caafc285827052bc53bcdc8162"
dependencies = [
"libc",
"serial-core",
"windows-sys 0.61.2",
]
[[package]]
@@ -8765,15 +8718,6 @@ dependencies = [
"new_debug_unreachable",
]
[[package]]
name = "termios"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5d9cf598a6d7ce700a4e6a9199da127e6819a61e64b68609683cc9a01b5683a"
dependencies = [
"libc",
]
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -9370,7 +9314,7 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset 0.9.1",
"memoffset",
"tempfile",
"windows-sys 0.61.2",
]
+2 -1
View File
@@ -106,7 +106,7 @@ tokio-util = { version = "0.7", features = ["rt"] }
russh = { path = "vendor/russh/russh", default-features = false, features = ["des", "dsa", "flate2", "ring", "rsa"] }
russh-sftp = { path = "vendor/russh-sftp" }
ssh-key = { version = "0.6", features = ["ed25519", "std"] }
portable-pty = "0.8"
portable-pty = "0.9.0"
uuid = { version = "1", features = ["v4"] }
async-trait = "0.1"
thiserror = "2"
@@ -189,6 +189,7 @@ vnc-rs = { path = "vendor/vnc-rs" }
[patch.crates-io]
picky = { path = "vendor/picky" }
sspi = { path = "vendor/sspi" }
portable-pty = { path = "vendor/portable-pty" }
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-updater = { version = "2", default-features = false, features = [
+22
View File
@@ -0,0 +1,22 @@
Microsoft.Windows.Console.ConPTY 1.24.260710001
Copyright (c) Microsoft Corporation
MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+4
View File
@@ -0,0 +1,4 @@
{
"version": "1.24.260710001",
"sha256": "175640566a3b59c4b132070ee96c2c77e5ab7edd2e92732a5eb3610bbf63d90e"
}
+4
View File
@@ -110,6 +110,8 @@ pub struct AppSupportInfo {
os: String,
architecture: String,
runtime: String,
#[cfg(windows)]
conpty: crate::platform::windows_conpty::LocalConptyInfo,
}
#[tauri::command]
@@ -119,6 +121,8 @@ pub fn get_support_info(state: tauri::State<'_, crate::runtime::AppRuntime>) ->
os: operating_system_label(),
architecture: std::env::consts::ARCH.to_string(),
runtime,
#[cfg(windows)]
conpty: crate::platform::windows_conpty::support_info(),
}
}
+2
View File
@@ -40,6 +40,8 @@ pub fn run() {
platform::prepare_appimage_wayland_backend();
portable_updater::schedule_cleanup_from_environment();
let runtime = runtime::resolve().expect("failed to resolve runtime paths");
#[cfg(windows)]
platform::windows_conpty::configure(&runtime);
runtime::prepare_webview_environment(&runtime);
let session_manager = Arc::new(SessionManager::new());
+3
View File
@@ -1,6 +1,9 @@
#[cfg(windows)]
mod windows_external_drop;
#[cfg(windows)]
pub(crate) mod windows_conpty;
#[cfg(any(windows, test))]
pub(crate) mod windows_version;
+55
View File
@@ -0,0 +1,55 @@
use serde::Serialize;
use crate::runtime::AppRuntime;
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct LocalConptyInfo {
pub available: bool,
pub active_bundled: usize,
pub active_system: usize,
pub last_used: Option<&'static str>,
pub fallback: bool,
pub version: String,
}
pub(crate) fn configure(runtime: &AppRuntime) {
let arch = match std::env::consts::ARCH {
"x86_64" => "x64",
"aarch64" => "arm64",
other => {
tracing::warn!(
arch = other,
"No bundled ConPTY build for this architecture"
);
return;
}
};
let dll = runtime
.executable_dir()
.join("conpty")
.join(arch)
.join("conpty.dll");
if let Err(error) = portable_pty::win::configure_bundled_conpty(dll) {
tracing::warn!(error, "Could not configure bundled ConPTY");
}
}
pub(crate) fn support_info() -> LocalConptyInfo {
let status = portable_pty::win::conpty_status();
let package: serde_json::Value =
serde_json::from_str(include_str!("../../conpty-package.json"))
.expect("valid bundled ConPTY package metadata");
LocalConptyInfo {
available: crate::platform::windows_version::current_windows_version()
.is_none_or(|version| version.supports_conpty()),
active_bundled: status.active_bundled,
active_system: status.active_system,
last_used: status.last_used.map(|backend| match backend {
portable_pty::win::ConptyBackend::Bundled => "bundled",
portable_pty::win::ConptyBackend::System => "system",
}),
fallback: status.fallback_reason.is_some(),
version: package["version"].as_str().unwrap().to_string(),
}
}
+161 -7
View File
@@ -21,6 +21,14 @@ const PORTABLE_ROOT: &str = "NyaTerm-portable";
const PORTABLE_EXE: &str = "NyaTerm.exe";
const PORTABLE_MCP: &str = "nyaterm-mcp.exe";
const PORTABLE_MARKER: &str = "portable.flag";
const CONPTY_FILES: [&str; 6] = [
"conpty/x64/conpty.dll",
"conpty/x64/x64/OpenConsole.exe",
"conpty/x64/arm64/OpenConsole.exe",
"conpty/arm64/conpty.dll",
"conpty/arm64/arm64/OpenConsole.exe",
"conpty/LICENSE.txt",
];
const MCP_BACKUP_PREFIX: &str = ".nyaterm-mcp-update-backup-";
const LEGACY_MCP_BACKUP: &str = ".nyaterm-mcp-update-backup.exe";
const HELPER_FLAG: &str = "--nyaterm-portable-update-helper";
@@ -52,6 +60,7 @@ struct StagedPortableUpdate {
payload_exe: PathBuf,
payload_mcp: PathBuf,
payload_marker: PathBuf,
payload_conpty: PathBuf,
}
#[derive(Default)]
@@ -162,6 +171,9 @@ pub fn apply(
|| !staged.payload_exe.is_file()
|| !staged.payload_mcp.is_file()
|| !staged.payload_marker.is_file()
|| CONPTY_FILES
.iter()
.any(|path| !staged.payload_conpty.parent().unwrap().join(path).is_file())
{
let _ = fs::remove_dir_all(&staged.work_dir);
return Err(AppError::Config(
@@ -177,6 +189,7 @@ pub fn apply(
.arg(&target_exe)
.arg(&staged.work_dir)
.arg(&staged.payload_mcp)
.arg(&staged.payload_conpty)
.spawn();
if let Err(error) = spawn_result {
@@ -253,6 +266,7 @@ fn stage_verified_archive(bytes: &[u8]) -> AppResult<StagedPortableUpdate> {
payload_exe: payload_dir.join(PORTABLE_EXE),
payload_mcp: payload_dir.join(PORTABLE_MCP),
payload_marker: payload_dir.join(PORTABLE_MARKER),
payload_conpty: payload_dir.join("conpty"),
helper_exe,
work_dir: work_dir.clone(),
})
@@ -277,6 +291,7 @@ fn extract_portable_payload(bytes: &[u8], destination: &Path) -> AppResult<()> {
let mut found_exe = false;
let mut found_mcp = false;
let mut found_marker = false;
let mut found_conpty = [false; CONPTY_FILES.len()];
let mut payload_bytes = 0_u64;
for index in 0..archive.len() {
@@ -338,6 +353,18 @@ fn extract_portable_payload(bytes: &[u8], destination: &Path) -> AppResult<()> {
}
found_marker = true;
destination.join(PORTABLE_MARKER)
} else if let Some(index) = CONPTY_FILES
.iter()
.position(|allowed| relative == Path::new(allowed))
{
if found_conpty[index] {
return Err(AppError::Config(format!(
"Portable update archive contains duplicate ConPTY file: {}",
relative.display()
)));
}
found_conpty[index] = true;
destination.join(&relative)
} else {
return Err(AppError::Config(format!(
"Portable update archive contains an unexpected file: {}",
@@ -352,6 +379,9 @@ fn extract_portable_payload(bytes: &[u8], destination: &Path) -> AppResult<()> {
));
}
if let Some(parent) = output.parent() {
fs::create_dir_all(parent)?;
}
let mut file = fs::File::create(output)?;
let copied = std::io::copy(&mut entry.take(MAX_PAYLOAD_BYTES + 1), &mut file)?;
if copied > MAX_PAYLOAD_BYTES {
@@ -361,10 +391,9 @@ fn extract_portable_payload(bytes: &[u8], destination: &Path) -> AppResult<()> {
}
}
if !found_exe || !found_mcp || !found_marker {
if !found_exe || !found_mcp || !found_marker || found_conpty.contains(&false) {
return Err(AppError::Config(
"Portable update archive is missing NyaTerm.exe, nyaterm-mcp.exe, or portable.flag"
.into(),
"Portable update archive is missing a required application or ConPTY file".into(),
));
}
Ok(())
@@ -391,7 +420,7 @@ pub fn run_helper_if_requested() -> bool {
}
fn run_helper(args: &[OsString]) -> AppResult<()> {
if args.len() != 7 {
if args.len() != 8 {
return Err(AppError::Config(
"Invalid portable update helper arguments".into(),
));
@@ -404,9 +433,10 @@ fn run_helper(args: &[OsString]) -> AppResult<()> {
let target_exe = PathBuf::from(&args[4]);
let work_dir = PathBuf::from(&args[5]);
let source_mcp = PathBuf::from(&args[6]);
let source_conpty = PathBuf::from(&args[7]);
wait_for_process_exit(parent_pid)?;
replace_portable_files(&source_exe, &target_exe, &source_mcp)?;
replace_portable_files(&source_exe, &target_exe, &source_mcp, &source_conpty)?;
Command::new(&target_exe)
.env(CLEANUP_ENV, &work_dir)
.spawn()?;
@@ -417,6 +447,7 @@ fn replace_portable_files(
source_exe: &Path,
target_exe: &Path,
source_mcp: &Path,
source_conpty: &Path,
) -> AppResult<()> {
let target_dir = target_exe
.parent()
@@ -425,16 +456,47 @@ fn replace_portable_files(
let new_exe = target_dir.join(".nyaterm-update-new.exe");
let backup_exe = target_dir.join(".nyaterm-update-backup.exe");
let new_mcp = target_dir.join(".nyaterm-mcp-update-new.exe");
let target_conpty = target_dir.join("conpty");
let new_conpty = target_dir.join(".nyaterm-conpty-update-new");
let backup_conpty =
target_dir.join(format!(".nyaterm-conpty-update-backup-{}", Uuid::new_v4()));
cleanup_stale_mcp_backups(target_dir);
let backup_mcp = target_dir.join(format!("{MCP_BACKUP_PREFIX}{}.exe", Uuid::new_v4()));
let _ = fs::remove_file(&new_exe);
let _ = fs::remove_file(&backup_exe);
let _ = fs::remove_file(&new_mcp);
if new_conpty.exists() {
fs::remove_dir_all(&new_conpty)?;
}
fs::copy(source_exe, &new_exe)?;
if let Err(error) = fs::copy(source_mcp, &new_mcp) {
let _ = fs::remove_file(&new_exe);
return Err(error.into());
}
if let Err(error) = stage_conpty_files(source_conpty, &new_conpty) {
let _ = fs::remove_file(&new_exe);
let _ = fs::remove_file(&new_mcp);
let _ = fs::remove_dir_all(&new_conpty);
return Err(error);
}
if target_conpty.exists() && !target_conpty.is_dir() {
let _ = fs::remove_dir_all(&new_conpty);
return Err(AppError::Config(format!(
"Portable ConPTY target is not a directory: {}",
target_conpty.display()
)));
}
let had_conpty = target_conpty.is_dir();
if had_conpty {
fs::rename(&target_conpty, &backup_conpty)?;
}
if let Err(error) = fs::rename(&new_conpty, &target_conpty) {
if had_conpty {
let _ = fs::rename(&backup_conpty, &target_conpty);
}
return Err(error.into());
}
let result = commit_portable_files(
&new_exe,
@@ -447,13 +509,39 @@ fn replace_portable_files(
);
let _ = fs::remove_file(&new_exe);
let _ = fs::remove_file(&new_mcp);
if let Err(error) = &result {
let remove_result = fs::remove_dir_all(&target_conpty);
let restore_result = if had_conpty {
fs::rename(&backup_conpty, &target_conpty)
} else {
Ok(())
};
if remove_result.is_err() || restore_result.is_err() {
return Err(AppError::Config(format!(
"Portable update failed ({error}); ConPTY rollback failed: remove={remove_result:?}, restore={restore_result:?}"
)));
}
}
if result.is_ok() {
let _ = fs::remove_file(&backup_exe);
let _ = fs::remove_file(&backup_mcp);
if had_conpty {
let _ = fs::remove_dir_all(&backup_conpty);
}
}
result
}
fn stage_conpty_files(source: &Path, destination: &Path) -> AppResult<()> {
for path in CONPTY_FILES {
let relative = Path::new(path).strip_prefix("conpty").unwrap();
let target = destination.join(relative);
fs::create_dir_all(target.parent().unwrap())?;
fs::copy(source.join(relative), target)?;
}
Ok(())
}
fn cleanup_stale_mcp_backups(target_dir: &Path) {
let _ = fs::remove_file(target_dir.join(LEGACY_MCP_BACKUP));
let Ok(entries) = fs::read_dir(target_dir) else {
@@ -655,6 +743,17 @@ mod tests {
writer.finish().unwrap().into_inner()
}
fn write_test_conpty(source_dir: &Path) -> PathBuf {
let conpty = source_dir.join("conpty");
for path in CONPTY_FILES {
let relative = Path::new(path).strip_prefix("conpty").unwrap();
let target = conpty.join(relative);
fs::create_dir_all(target.parent().unwrap()).unwrap();
fs::write(target, b"new-conpty").unwrap();
}
conpty
}
#[test]
fn maps_supported_windows_architectures() {
assert_eq!(
@@ -675,6 +774,21 @@ mod tests {
("NyaTerm-portable/NyaTerm.exe", b"new-exe"),
("NyaTerm-portable/nyaterm-mcp.exe", b"new-mcp"),
("NyaTerm-portable/portable.flag", b""),
("NyaTerm-portable/conpty/x64/conpty.dll", b"x64-dll"),
(
"NyaTerm-portable/conpty/x64/x64/OpenConsole.exe",
b"x64-host",
),
(
"NyaTerm-portable/conpty/x64/arm64/OpenConsole.exe",
b"arm64-host",
),
("NyaTerm-portable/conpty/arm64/conpty.dll", b"arm64-dll"),
(
"NyaTerm-portable/conpty/arm64/arm64/OpenConsole.exe",
b"arm64-host",
),
("NyaTerm-portable/conpty/LICENSE.txt", b"license"),
("NyaTerm-portable/data/.keep", b"package-data"),
]);
@@ -689,6 +803,10 @@ mod tests {
b"new-mcp"
);
assert!(destination.join(PORTABLE_MARKER).is_file());
assert_eq!(
fs::read(destination.join("conpty/x64/conpty.dll")).unwrap(),
b"x64-dll"
);
assert!(!destination.join("data").exists());
fs::remove_dir_all(destination).unwrap();
}
@@ -722,6 +840,8 @@ mod tests {
("NyaTerm-portable/portable.flag", b""),
]);
assert!(extract_portable_payload(&missing_mcp, &destination).is_err());
let unexpected_conpty = archive(&[("NyaTerm-portable/conpty/other.dll", b"bad")]);
assert!(extract_portable_payload(&unexpected_conpty, &destination).is_err());
fs::remove_dir_all(destination).unwrap();
}
@@ -737,11 +857,16 @@ mod tests {
fs::write(&source_exe, b"new-exe").unwrap();
fs::write(&source_mcp, b"new-mcp").unwrap();
fs::write(&target_exe, b"old-exe").unwrap();
let source_conpty = write_test_conpty(&source_dir);
replace_portable_files(&source_exe, &target_exe, &source_mcp).unwrap();
replace_portable_files(&source_exe, &target_exe, &source_mcp, &source_conpty).unwrap();
assert_eq!(fs::read(&target_exe).unwrap(), b"new-exe");
assert_eq!(fs::read(&target_mcp).unwrap(), b"new-mcp");
assert_eq!(
fs::read(directory.join("conpty/x64/conpty.dll")).unwrap(),
b"new-conpty"
);
assert!(!directory.join(".nyaterm-update-backup.exe").exists());
assert!(!directory.join(".nyaterm-mcp-update-backup.exe").exists());
fs::remove_dir_all(directory).unwrap();
@@ -765,8 +890,9 @@ mod tests {
// A directory makes remove_file fail on every platform, simulating a stale backup
// that Windows cannot delete while an older sidecar process still has it open.
fs::create_dir(&stale_backup).unwrap();
let source_conpty = write_test_conpty(&source_dir);
replace_portable_files(&source_exe, &target_exe, &source_mcp).unwrap();
replace_portable_files(&source_exe, &target_exe, &source_mcp, &source_conpty).unwrap();
assert_eq!(fs::read(&target_exe).unwrap(), b"new-exe");
assert_eq!(fs::read(&target_mcp).unwrap(), b"new-mcp");
@@ -774,6 +900,34 @@ mod tests {
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn portable_replace_restores_conpty_when_mcp_target_is_invalid() {
let directory = test_dir("portable-conpty-rollback");
let source_dir = directory.join("source");
fs::create_dir(&source_dir).unwrap();
let source_exe = source_dir.join(PORTABLE_EXE);
let source_mcp = source_dir.join(PORTABLE_MCP);
let target_exe = directory.join(PORTABLE_EXE);
fs::write(&source_exe, b"new-exe").unwrap();
fs::write(&source_mcp, b"new-mcp").unwrap();
fs::write(&target_exe, b"old-exe").unwrap();
fs::create_dir(directory.join(PORTABLE_MCP)).unwrap();
let source_conpty = write_test_conpty(&source_dir);
let target_conpty = directory.join("conpty");
fs::create_dir_all(target_conpty.join("x64")).unwrap();
fs::write(target_conpty.join("x64/conpty.dll"), b"old-conpty").unwrap();
assert!(
replace_portable_files(&source_exe, &target_exe, &source_mcp, &source_conpty).is_err()
);
assert_eq!(fs::read(&target_exe).unwrap(), b"old-exe");
assert_eq!(
fs::read(target_conpty.join("x64/conpty.dll")).unwrap(),
b"old-conpty"
);
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn portable_commit_rolls_back_both_files_when_mcp_install_fails() {
let directory = test_dir("portable-rollback");
+2 -2
View File
@@ -4,9 +4,9 @@
"version": "1.2.11",
"identifier": "com.kang.nyaterm",
"build": {
"beforeDevCommand": "pnpm build:mcp-sidecar && pnpm dev",
"beforeDevCommand": "pnpm prepare:conpty && pnpm build:mcp-sidecar && pnpm dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "pnpm build",
"beforeBuildCommand": "pnpm prepare:conpty && pnpm build",
"frontendDist": "../dist"
},
"app": {
+1
View File
@@ -1,5 +1,6 @@
{
"bundle": {
"resources": [],
"targets": ["nsis"],
"createUpdaterArtifacts": false,
"windows": {
+10
View File
@@ -1,4 +1,14 @@
{
"bundle": {
"resources": {
"resources/windows/conpty/x64/conpty.dll": "conpty/x64/conpty.dll",
"resources/windows/conpty/x64/x64/OpenConsole.exe": "conpty/x64/x64/OpenConsole.exe",
"resources/windows/conpty/x64/arm64/OpenConsole.exe": "conpty/x64/arm64/OpenConsole.exe",
"resources/windows/conpty/arm64/conpty.dll": "conpty/arm64/conpty.dll",
"resources/windows/conpty/arm64/arm64/OpenConsole.exe": "conpty/arm64/arm64/OpenConsole.exe",
"conpty-license/LICENSE.txt": "conpty/LICENSE.txt"
}
},
"app": {
"windows": [
{
+126
View File
@@ -0,0 +1,126 @@
#![cfg(windows)]
use portable_pty::win::{ConptyBackend, configure_bundled_conpty, conpty_status};
use portable_pty::{PtySize, native_pty_system};
use std::path::PathBuf;
const CASE_ENV: &str = "NYATERM_CONPTY_TEST_CASE";
fn bundled_dll() -> PathBuf {
let arch = if cfg!(target_arch = "aarch64") {
"arm64"
} else {
"x64"
};
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("resources/windows/conpty")
.join(arch)
.join("conpty.dll")
}
#[test]
fn conpty_backend_cases() {
if let Ok(case) = std::env::var(CASE_ENV) {
run_case(&case);
return;
}
assert!(bundled_dll().is_file(), "run pnpm prepare:conpty first");
for case in ["bundled", "missing_dll", "corrupt_dll", "missing_host"] {
let child = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "conpty_backend_cases", "--nocapture"])
.env(CASE_ENV, case)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
let temp = std::env::temp_dir().join(format!("nyaterm-conpty-test-{}", child.id()));
let output = child.wait_with_output().unwrap();
let _ = std::fs::remove_dir_all(temp);
assert!(
output.status.success(),
"{case} failed:\n{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
}
fn run_case(case: &str) {
let temp = std::env::temp_dir().join(format!("nyaterm-conpty-test-{}", std::process::id()));
std::fs::create_dir_all(&temp).unwrap();
let dll = match case {
"bundled" => bundled_dll(),
"missing_dll" => temp.join("missing.dll"),
"corrupt_dll" => {
let path = temp.join("conpty.dll");
std::fs::write(&path, b"not a DLL").unwrap();
let source_dir = bundled_dll().parent().unwrap().to_path_buf();
for arch in if cfg!(target_arch = "aarch64") {
&["arm64"][..]
} else {
&["x64", "arm64"][..]
} {
let host_dir = temp.join(arch);
std::fs::create_dir_all(&host_dir).unwrap();
std::fs::copy(
source_dir.join(arch).join("OpenConsole.exe"),
host_dir.join("OpenConsole.exe"),
)
.unwrap();
}
path
}
"missing_host" => {
let path = temp.join("conpty.dll");
std::fs::copy(bundled_dll(), &path).unwrap();
path
}
other => panic!("unknown ConPTY test case: {other}"),
};
configure_bundled_conpty(dll).unwrap();
assert!(conpty_status().last_used.is_none());
let pair = native_pty_system()
.openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
})
.unwrap();
pair.master
.resize(PtySize {
rows: 25,
cols: 81,
pixel_width: 0,
pixel_height: 0,
})
.unwrap();
let status = conpty_status();
let expected = if case == "bundled" {
ConptyBackend::Bundled
} else {
ConptyBackend::System
};
assert_eq!(status.last_used, Some(expected));
assert_eq!(
status.active_bundled,
usize::from(expected == ConptyBackend::Bundled)
);
assert_eq!(
status.active_system,
usize::from(expected == ConptyBackend::System)
);
assert_eq!(
status.fallback_reason.is_some(),
expected == ConptyBackend::System
);
drop(pair);
let status = conpty_status();
assert_eq!(status.active_bundled, 0);
assert_eq!(status.active_system, 0);
assert_eq!(status.last_used, Some(expected));
}
+734
View File
@@ -0,0 +1,734 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
[[package]]
name = "anyhow"
version = "1.0.95"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04"
[[package]]
name = "async-channel"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89b47800b0be77592da0afd425cc03468052844aff33b84e33cc696f64e77b6a"
dependencies = [
"concurrent-queue",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-executor"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30ca9a001c1e8ba5149f91a74362376cc6bc5b919d92d988668657bd570bdcec"
dependencies = [
"async-task",
"concurrent-queue",
"fastrand",
"futures-lite",
"slab",
]
[[package]]
name = "async-fs"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebcd09b382f40fcd159c2d695175b2ae620ffa5f3bd6f664131efff4e8b9e04a"
dependencies = [
"async-lock",
"blocking",
"futures-lite",
]
[[package]]
name = "async-io"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43a2b323ccce0a1d90b449fd71f2a06ca7faa7c54c2751f06c9bd851fc061059"
dependencies = [
"async-lock",
"cfg-if",
"concurrent-queue",
"futures-io",
"futures-lite",
"parking",
"polling",
"rustix",
"slab",
"tracing",
"windows-sys",
]
[[package]]
name = "async-lock"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18"
dependencies = [
"event-listener",
"event-listener-strategy",
"pin-project-lite",
]
[[package]]
name = "async-net"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7"
dependencies = [
"async-io",
"blocking",
"futures-lite",
]
[[package]]
name = "async-process"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63255f1dc2381611000436537bbedfe83183faa303a5a0edaf191edef06526bb"
dependencies = [
"async-channel",
"async-io",
"async-lock",
"async-signal",
"async-task",
"blocking",
"cfg-if",
"event-listener",
"futures-lite",
"rustix",
"tracing",
]
[[package]]
name = "async-signal"
version = "0.2.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "637e00349800c0bdf8bfc21ebbc0b6524abea702b0da4168ac00d070d0c0b9f3"
dependencies = [
"async-io",
"async-lock",
"atomic-waker",
"cfg-if",
"futures-core",
"futures-io",
"rustix",
"signal-hook-registry",
"slab",
"windows-sys",
]
[[package]]
name = "async-task"
version = "4.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "autocfg"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f68f53c83ab957f72c32642f3868eec03eb974d1fb82e453128456482613d36"
[[package]]
name = "blocking"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703f41c54fc768e63e091340b424302bb1c29ef4aa0c7f10fe849dfb114d29ea"
dependencies = [
"async-channel",
"async-task",
"futures-io",
"futures-lite",
"piper",
]
[[package]]
name = "cfg-if"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "cfg_aliases"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
[[package]]
name = "concurrent-queue"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "downcast-rs"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "errno"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "event-listener"
version = "5.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3492acde4c3fc54c845eaab3eed8bd00c7a7d881f78bfc801e43a93dec1331ae"
dependencies = [
"concurrent-queue",
"parking",
"pin-project-lite",
]
[[package]]
name = "event-listener-strategy"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3e4e0dd3673c1139bf041f3008816d9cf2946bbfac2945c09e523b8d7b05b2"
dependencies = [
"event-listener",
"pin-project-lite",
]
[[package]]
name = "fastrand"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "filedescriptor"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d"
dependencies = [
"libc",
"thiserror",
"winapi",
]
[[package]]
name = "futures"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
name = "futures-core"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
[[package]]
name = "futures-executor"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-io"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
[[package]]
name = "futures-lite"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5edaec856126859abb19ed65f39e90fea3a9574b9707f13539acf4abf7eb532"
dependencies = [
"fastrand",
"futures-core",
"futures-io",
"parking",
"pin-project-lite",
]
[[package]]
name = "futures-macro"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "futures-sink"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
[[package]]
name = "futures-task"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
[[package]]
name = "futures-util"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task",
"memchr",
"pin-project-lite",
"pin-utils",
"slab",
]
[[package]]
name = "hermit-abi"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc"
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "libc"
version = "0.2.169"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a"
[[package]]
name = "linux-raw-sys"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
[[package]]
name = "log"
version = "0.4.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04cbf5b083de1c7e0222a7a51dbfdba1cbe1c6ab0b15e29fff3f6c077fd9cd9f"
[[package]]
name = "memchr"
version = "2.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
[[package]]
name = "nix"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4"
dependencies = [
"bitflags 2.8.0",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]]
name = "pin-project-lite"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
[[package]]
name = "pin-utils"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "piper"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066"
dependencies = [
"atomic-waker",
"fastrand",
"futures-io",
]
[[package]]
name = "polling"
version = "3.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a604568c3202727d1507653cb121dbd627a58684eb09a820fd746bee38b4442f"
dependencies = [
"cfg-if",
"concurrent-queue",
"hermit-abi",
"pin-project-lite",
"rustix",
"tracing",
"windows-sys",
]
[[package]]
name = "portable-pty"
version = "0.9.0"
dependencies = [
"anyhow",
"bitflags 1.3.2",
"downcast-rs",
"filedescriptor",
"futures",
"lazy_static",
"libc",
"log",
"nix",
"serde",
"serde_derive",
"serial2",
"shared_library",
"shell-words",
"smol",
"winapi",
"winreg",
]
[[package]]
name = "proc-macro2"
version = "1.0.93"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60946a68e5f9d28b0dc1c21bb8a97ee7d018a8b322fa57838ba31cc878e22d99"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.38"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rustix"
version = "0.38.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
dependencies = [
"bitflags 2.8.0",
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
]
[[package]]
name = "serde"
version = "1.0.217"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.217"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serial2"
version = "0.2.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8cd0c773455b60177d1abe4c739cbfa316c4f2f0ef37465befcb72e8a15cdd02"
dependencies = [
"cfg-if",
"libc",
"winapi",
]
[[package]]
name = "shared_library"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11"
dependencies = [
"lazy_static",
"libc",
]
[[package]]
name = "shell-words"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde"
[[package]]
name = "signal-hook-registry"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1"
dependencies = [
"libc",
]
[[package]]
name = "slab"
version = "0.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67"
dependencies = [
"autocfg",
]
[[package]]
name = "smol"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a33bd3e260892199c3ccfc487c88b2da2265080acb316cd920da72fdfd7c599f"
dependencies = [
"async-channel",
"async-executor",
"async-fs",
"async-io",
"async-lock",
"async-net",
"async-process",
"blocking",
"futures-lite",
]
[[package]]
name = "syn"
version = "2.0.98"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36147f1a48ae0ec2b5b3bc5b537d267457555a10dc06f3dbc8cb11ba3006d3b1"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tracing"
version = "0.1.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
dependencies = [
"pin-project-lite",
"tracing-core",
]
[[package]]
name = "tracing-core"
version = "0.1.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c"
[[package]]
name = "unicode-ident"
version = "1.0.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a210d160f08b701c8721ba1c726c11662f877ea6b7094007e1ca9a1041945034"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[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 = "winreg"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
dependencies = [
"winapi",
]
+120
View File
@@ -0,0 +1,120 @@
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
#
# When uploading crates to the registry Cargo will automatically
# "normalize" Cargo.toml files for maximal compatibility
# with all versions of Cargo and also rewrite `path` dependencies
# to registry (e.g., crates.io) dependencies.
#
# If you are reading this file be aware that the original Cargo.toml
# will likely look very different (and much more reasonable).
# See Cargo.toml.orig for the original contents.
[package]
edition = "2018"
name = "portable-pty"
version = "0.9.0"
authors = ["Wez Furlong"]
build = false
autolib = false
autobins = false
autoexamples = false
autotests = false
autobenches = false
description = "Cross platform pty interface"
documentation = "https://docs.rs/portable-pty"
readme = false
license = "MIT"
repository = "https://github.com/wezterm/wezterm"
resolver = "2"
[lib]
name = "portable_pty"
path = "src/lib.rs"
[[example]]
name = "bash"
path = "examples/bash.rs"
[[example]]
name = "narrow"
path = "examples/narrow.rs"
[[example]]
name = "whoami"
path = "examples/whoami.rs"
[[example]]
name = "whoami_async"
path = "examples/whoami_async.rs"
[dependencies.anyhow]
version = "1.0"
[dependencies.downcast-rs]
version = "1.0"
[dependencies.filedescriptor]
version = "0.8.3"
[dependencies.libc]
version = "0.2"
[dependencies.log]
version = "0.4"
[dependencies.nix]
version = "0.28"
features = [
"term",
"fs",
]
[dependencies.serde]
version = "1.0"
optional = true
[dependencies.serde_derive]
version = "1.0"
optional = true
[dependencies.serial2]
version = "0.2"
[dependencies.shell-words]
version = "1.1"
[dev-dependencies.futures]
version = "0.3"
[dev-dependencies.smol]
version = "2.0"
[features]
default = []
serde_support = [
"serde",
"serde_derive",
]
[target."cfg(windows)".dependencies.bitflags]
version = "1.3"
[target."cfg(windows)".dependencies.lazy_static]
version = "1.4"
[target."cfg(windows)".dependencies.shared_library]
version = "0.1"
[target."cfg(windows)".dependencies.winapi]
version = "0.3"
features = [
"winuser",
"consoleapi",
"handleapi",
"fileapi",
"namedpipeapi",
"synchapi",
]
[target."cfg(windows)".dependencies.winreg]
version = "0.10"
+43
View File
@@ -0,0 +1,43 @@
[package]
name = "portable-pty"
version = "0.9.0"
authors = ["Wez Furlong"]
edition = "2018"
repository = "https://github.com/wezterm/wezterm"
description = "Cross platform pty interface"
license = "MIT"
documentation = "https://docs.rs/portable-pty"
[dependencies]
anyhow = "1.0"
downcast-rs = "1.0"
filedescriptor = { version="0.8.3", path = "../filedescriptor" }
log = "0.4"
libc = "0.2"
nix = {version="0.28", features=["term", "fs"]}
shell-words = "1.1"
serde_derive = {version="1.0", optional=true}
serde = {version="1.0", optional=true}
serial2 = "0.2"
[features]
default = []
serde_support = ["serde", "serde_derive"]
[target."cfg(windows)".dependencies]
bitflags = "1.3"
lazy_static = "1.4"
shared_library = "0.1"
winapi = { version = "0.3", features = [
"winuser",
"consoleapi",
"handleapi",
"fileapi",
"namedpipeapi",
"synchapi",
]}
winreg = "0.10"
[dev-dependencies]
smol = "2.0"
futures = "0.3"
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018 Wez Furlong
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+14
View File
@@ -0,0 +1,14 @@
# NyaTerm portable-pty patch
This directory starts from the crates.io release of `portable-pty 0.9.0`
(MIT license, see `LICENSE.md`). NyaTerm changes only its Windows ConPTY
loader and adds a test for creation failure.
The patch accepts an absolute path to a bundled `conpty.dll`, requires the
matching `OpenConsole.exe` host files, and falls back to `kernel32.dll` when
loading or `CreatePseudoConsole` fails. Each PTY retains its selected function
table for resize and close. The status API reports active backends, the last
successful backend, and whether fallback occurred.
When updating this crate, compare `src/win/psuedocon.rs` and `src/win/mod.rs`
against the new upstream release and rerun the Windows backend tests.
+85
View File
@@ -0,0 +1,85 @@
//! This example demonstrates how to spawn a Bash shell using the `portable_pty` crate.
//! based on pty/examples/whoami.rs
use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem};
use std::io::{Read, Write};
use std::sync::mpsc::channel;
use std::thread;
fn main() {
let pty_system = NativePtySystem::default();
// Open the PTY with specified size.
let pair = pty_system
.openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
})
.unwrap();
// Set up the command to launch Bash.
let cmd = CommandBuilder::new("bash");
let mut child = pair.slave.spawn_command(cmd).unwrap();
drop(pair.slave);
// Set up channels for reading and writing.
let (tx, rx) = channel::<String>();
let mut reader = pair.master.try_clone_reader().unwrap();
let master_writer = pair.master.take_writer().unwrap();
// Thread to read from the PTY and send data to the main thread.
thread::spawn(move || {
let mut buffer = [0u8; 1024];
loop {
match reader.read(&mut buffer) {
Ok(0) => break, // EOF
Ok(n) => {
let output = String::from_utf8_lossy(&buffer[..n]);
println!("{}", output); // Print to stdout for visibility.
}
Err(e) => {
eprintln!("Error reading from PTY: {}", e);
break;
}
}
}
});
// Thread to write input into the PTY.
let tx_writer = thread::spawn(move || {
handle_input_stream(rx, master_writer);
});
println!("You can now type commands for Bash (type 'exit' to quit):");
// Main thread sends user input to the writer thread.
loop {
let mut input = String::new();
std::io::stdin().read_line(&mut input).unwrap();
if input.trim() == "exit" {
break;
}
tx.send(input).unwrap();
}
drop(tx);
tx_writer.join().unwrap();
println!("Waiting for Bash to exit...");
let status = child.wait().unwrap();
println!("Bash exited with status: {:?}", status);
}
fn handle_input_stream(rx: std::sync::mpsc::Receiver<String>, mut writer: Box<dyn Write + Send>) {
for input in rx.iter() {
if writer.write_all(input.as_bytes()).is_err() {
eprintln!("Error writing to PTY");
break;
}
}
}
+92
View File
@@ -0,0 +1,92 @@
//! Runs a command with a fixed terminal size.
//! This is used by wezterm's doc building automation to keep
//! the --help output within a reasonable width
use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem};
use std::sync::mpsc::channel;
fn main() {
let pty_system = NativePtySystem::default();
let pair = pty_system
.openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
})
.unwrap();
let mut args = std::env::args_os().skip(1);
let mut cmd = CommandBuilder::new(args.next().unwrap());
cmd.args(args);
let mut child = pair.slave.spawn_command(cmd).unwrap();
// Release any handles owned by the slave: we don't need it now
// that we've spawned the child.
drop(pair.slave);
// Read the output in another thread.
// This is important because it is easy to encounter a situation
// where read/write buffers fill and block either your process
// or the spawned process.
let (tx, rx) = channel();
let mut reader = pair.master.try_clone_reader().unwrap();
std::thread::spawn(move || {
// Consume the output from the child
let mut s = String::new();
reader.read_to_string(&mut s).unwrap();
tx.send(s).unwrap();
});
{
// Obtain the writer.
// When the writer is dropped, EOF will be sent to
// the program that was spawned.
// It is important to take the writer even if you don't
// send anything to its stdin so that EOF can be
// generated, otherwise you risk deadlocking yourself.
let mut writer = pair.master.take_writer().unwrap();
if cfg!(target_os = "macos") {
// macOS quirk: the child and reader must be started and
// allowed a brief grace period to run before we allow
// the writer to drop. Otherwise, the data we send to
// the kernel to trigger EOF is interleaved with the
// data read by the reader! WTF!?
// This appears to be a race condition for very short
// lived processes on macOS.
// I'd love to find a more deterministic solution to
// this than sleeping.
std::thread::sleep(std::time::Duration::from_millis(20));
}
// This example doesn't need to write anything, but if you
// want to send data to the child, you'd set `to_write` to
// that data and do it like this:
let to_write = "";
if !to_write.is_empty() {
// To avoid deadlock, wrt. reading and waiting, we send
// data to the stdin of the child in a different thread.
std::thread::spawn(move || {
writer.write_all(to_write.as_bytes()).unwrap();
});
}
}
// Wait for the child to complete
eprintln!("child status: {:?}", child.wait().unwrap());
// Take care to drop the master after our processes are
// done, as some platforms get unhappy if it is dropped
// sooner than that.
drop(pair.master);
// Now wait for the output to be read by our reader thread
let output = rx.recv().unwrap();
let output = output.replace("\r\n", "\n");
print!("{output}");
}
+94
View File
@@ -0,0 +1,94 @@
//! This is a conceptually simple example that spawns the `whoami` program
//! to print your username. It is made more complex because there are multiple
//! pipes involved and it is easy to get blocked/deadlocked if care and attention
//! is not paid to those pipes!
use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem};
use std::sync::mpsc::channel;
fn main() {
let pty_system = NativePtySystem::default();
let pair = pty_system
.openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
})
.unwrap();
let cmd = CommandBuilder::new("whoami");
let mut child = pair.slave.spawn_command(cmd).unwrap();
// Release any handles owned by the slave: we don't need it now
// that we've spawned the child.
drop(pair.slave);
// Read the output in another thread.
// This is important because it is easy to encounter a situation
// where read/write buffers fill and block either your process
// or the spawned process.
let (tx, rx) = channel();
let mut reader = pair.master.try_clone_reader().unwrap();
std::thread::spawn(move || {
// Consume the output from the child
let mut s = String::new();
reader.read_to_string(&mut s).unwrap();
tx.send(s).unwrap();
});
{
// Obtain the writer.
// When the writer is dropped, EOF will be sent to
// the program that was spawned.
// It is important to take the writer even if you don't
// send anything to its stdin so that EOF can be
// generated, otherwise you risk deadlocking yourself.
let mut writer = pair.master.take_writer().unwrap();
if cfg!(target_os = "macos") {
// macOS quirk: the child and reader must be started and
// allowed a brief grace period to run before we allow
// the writer to drop. Otherwise, the data we send to
// the kernel to trigger EOF is interleaved with the
// data read by the reader! WTF!?
// This appears to be a race condition for very short
// lived processes on macOS.
// I'd love to find a more deterministic solution to
// this than sleeping.
std::thread::sleep(std::time::Duration::from_millis(20));
}
// This example doesn't need to write anything, but if you
// want to send data to the child, you'd set `to_write` to
// that data and do it like this:
let to_write = "";
if !to_write.is_empty() {
// To avoid deadlock, wrt. reading and waiting, we send
// data to the stdin of the child in a different thread.
std::thread::spawn(move || {
writer.write_all(to_write.as_bytes()).unwrap();
});
}
}
// Wait for the child to complete
println!("child status: {:?}", child.wait().unwrap());
// Take care to drop the master after our processes are
// done, as some platforms get unhappy if it is dropped
// sooner than that.
drop(pair.master);
// Now wait for the output to be read by our reader thread
let output = rx.recv().unwrap();
// We print with escapes escaped because the windows conpty
// implementation synthesizes title change escape sequences
// in the output stream and it can be confusing to see those
// printed out raw in another terminal.
print!("output: ");
for c in output.escape_debug() {
print!("{}", c);
}
}
+73
View File
@@ -0,0 +1,73 @@
use anyhow::anyhow;
use futures::prelude::*;
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
// This example shows how to use the `smol` crate to use portable_pty
// in an asynchronous application.
fn main() -> anyhow::Result<()> {
smol::block_on(async {
let pty_system = native_pty_system();
let pair = pty_system.openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
})?;
let cmd = CommandBuilder::new("whoami");
// Move the slave to another thread to block and spawn a
// command.
// Note that this implicitly drops slave and closes out
// file handles which is important to avoid deadlock
// when waiting for the child process!
let slave = pair.slave;
let mut child = smol::unblock(move || slave.spawn_command(cmd)).await?;
{
// Obtain the writer.
// When the writer is dropped, EOF will be sent to
// the program that was spawned.
// It is important to take the writer even if you don't
// send anything to its stdin so that EOF can be
// generated, otherwise you risk deadlocking yourself.
let writer = pair.master.take_writer()?;
// Explicitly generate EOF
drop(writer);
}
println!(
"child status: {:?}",
smol::unblock(move || child
.wait()
.map_err(|e| anyhow!("waiting for child: {}", e)))
.await?
);
let reader = pair.master.try_clone_reader()?;
// Take care to drop the master after our processes are
// done, as some platforms get unhappy if it is dropped
// sooner than that.
drop(pair.master);
let mut lines = smol::io::BufReader::new(smol::Unblock::new(reader)).lines();
while let Some(line) = lines.next().await {
let line = line.map_err(|e| anyhow!("problem reading line: {}", e))?;
// We print with escapes escaped because the windows conpty
// implementation synthesizes title change escape sequences
// in the output stream and it can be confusing to see those
// printed out raw in another terminal.
print!("output: len={} ", line.len());
for c in line.escape_debug() {
print!("{}", c);
}
println!();
}
Ok(())
})
}
+823
View File
@@ -0,0 +1,823 @@
#[cfg(unix)]
use anyhow::Context;
#[cfg(feature = "serde_support")]
use serde_derive::*;
use std::collections::BTreeMap;
use std::ffi::{OsStr, OsString};
#[cfg(windows)]
use std::os::windows::ffi::OsStrExt;
#[cfg(unix)]
use std::path::Component;
use std::path::Path;
/// Used to deal with Windows having case-insensitive environment variables.
#[derive(Clone, Debug, PartialEq, PartialOrd)]
#[cfg_attr(feature = "serde_support", derive(Serialize, Deserialize))]
struct EnvEntry {
/// Whether or not this environment variable came from the base environment,
/// as opposed to having been explicitly set by the caller.
is_from_base_env: bool,
/// For case-insensitive platforms, the environment variable key in its preferred casing.
preferred_key: OsString,
/// The environment variable value.
value: OsString,
}
impl EnvEntry {
fn map_key(k: OsString) -> OsString {
if cfg!(windows) {
// Best-effort lowercase transformation of an os string
match k.to_str() {
Some(s) => s.to_lowercase().into(),
None => k,
}
} else {
k
}
}
}
#[cfg(unix)]
fn get_shell() -> String {
use nix::unistd::{access, AccessFlags};
use std::ffi::CStr;
use std::str;
let ent = unsafe { libc::getpwuid(libc::getuid()) };
if !ent.is_null() {
let shell = unsafe { CStr::from_ptr((*ent).pw_shell) };
match shell.to_str().map(str::to_owned) {
Err(err) => {
log::warn!(
"passwd database shell could not be \
represented as utf-8: {err:#}, \
falling back to /bin/sh"
);
}
Ok(shell) => {
if let Err(err) = access(Path::new(&shell), AccessFlags::X_OK) {
log::warn!(
"passwd database shell={shell:?} which is \
not executable ({err:#}), falling back to /bin/sh"
);
} else {
return shell;
}
}
}
}
"/bin/sh".into()
}
fn get_base_env() -> BTreeMap<OsString, EnvEntry> {
let mut env: BTreeMap<OsString, EnvEntry> = std::env::vars_os()
.map(|(key, value)| {
(
EnvEntry::map_key(key.clone()),
EnvEntry {
is_from_base_env: true,
preferred_key: key,
value,
},
)
})
.collect();
#[cfg(unix)]
{
let key = EnvEntry::map_key("SHELL".into());
// Only set the value of SHELL if it isn't already set
if !env.contains_key(&key) {
env.insert(
EnvEntry::map_key("SHELL".into()),
EnvEntry {
is_from_base_env: true,
preferred_key: "SHELL".into(),
value: get_shell().into(),
},
);
}
}
#[cfg(windows)]
{
use std::os::windows::ffi::OsStringExt;
use winapi::um::processenv::ExpandEnvironmentStringsW;
use winreg::enums::{RegType, HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE};
use winreg::types::FromRegValue;
use winreg::{RegKey, RegValue};
fn reg_value_to_string(value: &RegValue) -> anyhow::Result<OsString> {
match value.vtype {
RegType::REG_EXPAND_SZ => {
let src = unsafe {
std::slice::from_raw_parts(
value.bytes.as_ptr() as *const u16,
value.bytes.len() / 2,
)
};
let size =
unsafe { ExpandEnvironmentStringsW(src.as_ptr(), std::ptr::null_mut(), 0) };
let mut buf = vec![0u16; size as usize + 1];
unsafe {
ExpandEnvironmentStringsW(src.as_ptr(), buf.as_mut_ptr(), buf.len() as u32)
};
let mut buf = buf.as_slice();
while let Some(0) = buf.last() {
buf = &buf[0..buf.len() - 1];
}
Ok(OsString::from_wide(buf))
}
_ => Ok(OsString::from_reg_value(value)?),
}
}
if let Ok(sys_env) = RegKey::predef(HKEY_LOCAL_MACHINE)
.open_subkey("System\\CurrentControlSet\\Control\\Session Manager\\Environment")
{
for res in sys_env.enum_values() {
if let Ok((name, value)) = res {
if name.to_ascii_lowercase() == "username" {
continue;
}
if let Ok(value) = reg_value_to_string(&value) {
log::trace!("adding SYS env: {:?} {:?}", name, value);
env.insert(
EnvEntry::map_key(name.clone().into()),
EnvEntry {
is_from_base_env: true,
preferred_key: name.into(),
value,
},
);
}
}
}
}
if let Ok(sys_env) = RegKey::predef(HKEY_CURRENT_USER).open_subkey("Environment") {
for res in sys_env.enum_values() {
if let Ok((name, value)) = res {
if let Ok(value) = reg_value_to_string(&value) {
// Merge the system and user paths together
let value = if name.to_ascii_lowercase() == "path" {
match env.get(&EnvEntry::map_key(name.clone().into())) {
Some(entry) => {
let mut result = OsString::new();
result.push(&entry.value);
result.push(";");
result.push(&value);
result
}
None => value,
}
} else {
value
};
log::trace!("adding USER env: {:?} {:?}", name, value);
env.insert(
EnvEntry::map_key(name.clone().into()),
EnvEntry {
is_from_base_env: true,
preferred_key: name.into(),
value,
},
);
}
}
}
}
}
env
}
/// `CommandBuilder` is used to prepare a command to be spawned into a pty.
/// The interface is intentionally similar to that of `std::process::Command`.
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde_support", derive(Serialize, Deserialize))]
pub struct CommandBuilder {
args: Vec<OsString>,
envs: BTreeMap<OsString, EnvEntry>,
cwd: Option<OsString>,
#[cfg(unix)]
pub(crate) umask: Option<libc::mode_t>,
controlling_tty: bool,
}
impl CommandBuilder {
/// Create a new builder instance with argv\[0\] set to the specified
/// program.
pub fn new<S: AsRef<OsStr>>(program: S) -> Self {
Self {
args: vec![program.as_ref().to_owned()],
envs: get_base_env(),
cwd: None,
#[cfg(unix)]
umask: None,
controlling_tty: true,
}
}
/// Create a new builder instance from a pre-built argument vector
pub fn from_argv(args: Vec<OsString>) -> Self {
Self {
args,
envs: get_base_env(),
cwd: None,
#[cfg(unix)]
umask: None,
controlling_tty: true,
}
}
/// Set whether we should set the pty as the controlling terminal.
/// The default is true, which is usually what you want, but you
/// may need to set this to false if you are crossing container
/// boundaries (eg: flatpak) to workaround issues like:
/// <https://github.com/flatpak/flatpak/issues/3697>
/// <https://github.com/flatpak/flatpak/issues/3285>
pub fn set_controlling_tty(&mut self, controlling_tty: bool) {
self.controlling_tty = controlling_tty;
}
pub fn get_controlling_tty(&self) -> bool {
self.controlling_tty
}
/// Create a new builder instance that will run some idea of a default
/// program. Such a builder will panic if `arg` is called on it.
pub fn new_default_prog() -> Self {
Self {
args: vec![],
envs: get_base_env(),
cwd: None,
#[cfg(unix)]
umask: None,
controlling_tty: true,
}
}
/// Returns true if this builder was created via `new_default_prog`
pub fn is_default_prog(&self) -> bool {
self.args.is_empty()
}
/// Append an argument to the current command line.
/// Will panic if called on a builder created via `new_default_prog`.
pub fn arg<S: AsRef<OsStr>>(&mut self, arg: S) {
if self.is_default_prog() {
panic!("attempted to add args to a default_prog builder");
}
self.args.push(arg.as_ref().to_owned());
}
/// Append a sequence of arguments to the current command line
pub fn args<I, S>(&mut self, args: I)
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
for arg in args {
self.arg(arg);
}
}
pub fn get_argv(&self) -> &Vec<OsString> {
&self.args
}
pub fn get_argv_mut(&mut self) -> &mut Vec<OsString> {
&mut self.args
}
/// Override the value of an environmental variable
pub fn env<K, V>(&mut self, key: K, value: V)
where
K: AsRef<OsStr>,
V: AsRef<OsStr>,
{
let key: OsString = key.as_ref().into();
let value: OsString = value.as_ref().into();
self.envs.insert(
EnvEntry::map_key(key.clone()),
EnvEntry {
is_from_base_env: false,
preferred_key: key,
value: value,
},
);
}
pub fn env_remove<K>(&mut self, key: K)
where
K: AsRef<OsStr>,
{
let key = key.as_ref().into();
self.envs.remove(&EnvEntry::map_key(key));
}
pub fn env_clear(&mut self) {
self.envs.clear();
}
pub fn get_env<K>(&self, key: K) -> Option<&OsStr>
where
K: AsRef<OsStr>,
{
let key = key.as_ref().into();
self.envs.get(&EnvEntry::map_key(key)).map(
|EnvEntry {
is_from_base_env: _,
preferred_key: _,
value,
}| value.as_os_str(),
)
}
pub fn cwd<D>(&mut self, dir: D)
where
D: AsRef<OsStr>,
{
self.cwd = Some(dir.as_ref().to_owned());
}
pub fn clear_cwd(&mut self) {
self.cwd.take();
}
pub fn get_cwd(&self) -> Option<&OsString> {
self.cwd.as_ref()
}
/// Iterate over the configured environment. Only includes environment
/// variables set by the caller via `env`, not variables set in the base
/// environment.
pub fn iter_extra_env_as_str(&self) -> impl Iterator<Item = (&str, &str)> {
self.envs.values().filter_map(
|EnvEntry {
is_from_base_env,
preferred_key,
value,
}| {
if *is_from_base_env {
None
} else {
let key = preferred_key.to_str()?;
let value = value.to_str()?;
Some((key, value))
}
},
)
}
pub fn iter_full_env_as_str(&self) -> impl Iterator<Item = (&str, &str)> {
self.envs.values().filter_map(
|EnvEntry {
preferred_key,
value,
..
}| {
let key = preferred_key.to_str()?;
let value = value.to_str()?;
Some((key, value))
},
)
}
/// Return the configured command and arguments as a single string,
/// quoted per the unix shell conventions.
pub fn as_unix_command_line(&self) -> anyhow::Result<String> {
let mut strs = vec![];
for arg in &self.args {
let s = arg
.to_str()
.ok_or_else(|| anyhow::anyhow!("argument cannot be represented as utf8"))?;
strs.push(s);
}
Ok(shell_words::join(strs))
}
}
#[cfg(unix)]
impl CommandBuilder {
pub fn umask(&mut self, mask: Option<libc::mode_t>) {
self.umask = mask;
}
fn resolve_path(&self) -> Option<&OsStr> {
self.get_env("PATH")
}
fn search_path(&self, exe: &OsStr, cwd: &OsStr) -> anyhow::Result<OsString> {
use nix::unistd::{access, AccessFlags};
let exe_path: &Path = exe.as_ref();
if exe_path.is_relative() {
let cwd: &Path = cwd.as_ref();
let mut errors = vec![];
// If the requested executable is explicitly relative to cwd,
// then check only there.
if is_cwd_relative_path(exe_path) {
let abs_path = cwd.join(exe_path);
if abs_path.is_dir() {
anyhow::bail!(
"Unable to spawn {} because it is a directory",
abs_path.display()
);
} else if access(&abs_path, AccessFlags::X_OK).is_ok() {
return Ok(abs_path.into_os_string());
} else if access(&abs_path, AccessFlags::F_OK).is_ok() {
anyhow::bail!(
"Unable to spawn {} because it is not executable",
abs_path.display()
);
}
anyhow::bail!(
"Unable to spawn {} because it does not exist",
abs_path.display()
);
}
if let Some(path) = self.resolve_path() {
for path in std::env::split_paths(&path) {
let candidate = cwd.join(&path).join(&exe);
if candidate.is_dir() {
errors.push(format!("{} exists but is a directory", candidate.display()));
} else if access(&candidate, AccessFlags::X_OK).is_ok() {
return Ok(candidate.into_os_string());
} else if access(&candidate, AccessFlags::F_OK).is_ok() {
errors.push(format!(
"{} exists but is not executable",
candidate.display()
));
}
}
errors.push(format!("No viable candidates found in PATH {path:?}"));
} else {
errors.push("Unable to resolve the PATH".to_string());
}
anyhow::bail!(
"Unable to spawn {} because:\n{}",
exe_path.display(),
errors.join(".\n")
);
} else if exe_path.is_dir() {
anyhow::bail!(
"Unable to spawn {} because it is a directory",
exe_path.display()
);
} else {
if let Err(err) = access(exe_path, AccessFlags::X_OK) {
if access(exe_path, AccessFlags::F_OK).is_ok() {
anyhow::bail!(
"Unable to spawn {} because it is not executable ({err:#})",
exe_path.display()
);
} else {
anyhow::bail!(
"Unable to spawn {} because it doesn't exist on the filesystem ({err:#})",
exe_path.display()
);
}
}
Ok(exe.to_owned())
}
}
/// Convert the CommandBuilder to a `std::process::Command` instance.
pub(crate) fn as_command(&self) -> anyhow::Result<std::process::Command> {
use std::os::unix::process::CommandExt;
let home = self.get_home_dir()?;
let dir: &OsStr = self
.cwd
.as_ref()
.map(|dir| dir.as_os_str())
.filter(|dir| std::path::Path::new(dir).is_dir())
.unwrap_or(home.as_ref());
let shell = self.get_shell();
let mut cmd = if self.is_default_prog() {
let mut cmd = std::process::Command::new(&shell);
// Run the shell as a login shell by prefixing the shell's
// basename with `-` and setting that as argv0
let basename = shell.rsplit('/').next().unwrap_or(&shell);
cmd.arg0(&format!("-{}", basename));
cmd
} else {
let resolved = self.search_path(&self.args[0], dir)?;
let mut cmd = std::process::Command::new(&resolved);
cmd.arg0(&self.args[0]);
cmd.args(&self.args[1..]);
cmd
};
cmd.current_dir(dir);
cmd.env_clear();
cmd.env("SHELL", shell);
cmd.envs(self.envs.values().map(
|EnvEntry {
is_from_base_env: _,
preferred_key,
value,
}| (preferred_key.as_os_str(), value.as_os_str()),
));
Ok(cmd)
}
/// Determine which shell to run.
/// We take the contents of the $SHELL env var first, then
/// fall back to looking it up from the password database.
pub fn get_shell(&self) -> String {
use nix::unistd::{access, AccessFlags};
if let Some(shell) = self.get_env("SHELL").and_then(OsStr::to_str) {
match access(shell, AccessFlags::X_OK) {
Ok(()) => return shell.into(),
Err(err) => log::warn!(
"$SHELL -> {shell:?} which is \
not executable ({err:#}), falling back to password db lookup"
),
}
}
get_shell().into()
}
fn get_home_dir(&self) -> anyhow::Result<String> {
if let Some(home_dir) = self.get_env("HOME").and_then(OsStr::to_str) {
return Ok(home_dir.into());
}
let ent = unsafe { libc::getpwuid(libc::getuid()) };
if ent.is_null() {
Ok("/".into())
} else {
use std::ffi::CStr;
use std::str;
let home = unsafe { CStr::from_ptr((*ent).pw_dir) };
home.to_str()
.map(str::to_owned)
.context("failed to resolve home dir")
}
}
}
#[cfg(windows)]
impl CommandBuilder {
fn search_path(&self, exe: &OsStr) -> OsString {
if let Some(path) = self.get_env("PATH") {
let extensions = self.get_env("PATHEXT").unwrap_or(OsStr::new(".EXE"));
for path in std::env::split_paths(&path) {
// Check for exactly the user's string in this path dir
let candidate = path.join(&exe);
if candidate.exists() {
return candidate.into_os_string();
}
// otherwise try tacking on some extensions.
// Note that this really replaces the extension in the
// user specified path, so this is potentially wrong.
for ext in std::env::split_paths(&extensions) {
// PATHEXT includes the leading `.`, but `with_extension`
// doesn't want that
let ext = ext.to_str().expect("PATHEXT entries must be utf8");
let path = path.join(&exe).with_extension(&ext[1..]);
if path.exists() {
return path.into_os_string();
}
}
}
}
exe.to_owned()
}
pub(crate) fn current_directory(&self) -> Option<Vec<u16>> {
let home: Option<&OsStr> = self
.get_env("USERPROFILE")
.filter(|path| Path::new(path).is_dir());
let cwd: Option<&OsStr> = self.cwd.as_deref().filter(|path| Path::new(path).is_dir());
let dir: Option<&OsStr> = cwd.or(home);
dir.map(|dir| {
let mut wide = vec![];
if Path::new(dir).is_relative() {
if let Ok(ccwd) = std::env::current_dir() {
wide.extend(ccwd.join(dir).as_os_str().encode_wide());
} else {
wide.extend(dir.encode_wide());
}
} else {
wide.extend(dir.encode_wide());
}
wide.push(0);
wide
})
}
/// Constructs an environment block for this spawn attempt.
/// Uses the current process environment as the base and then
/// adds/replaces the environment that was specified via the
/// `env` methods.
pub(crate) fn environment_block(&self) -> Vec<u16> {
// encode the environment as wide characters
let mut block = vec![];
for EnvEntry {
is_from_base_env: _,
preferred_key,
value,
} in self.envs.values()
{
block.extend(preferred_key.encode_wide());
block.push(b'=' as u16);
block.extend(value.encode_wide());
block.push(0);
}
// and a final terminator for CreateProcessW
block.push(0);
block
}
pub fn get_shell(&self) -> String {
let exe: OsString = self
.get_env("ComSpec")
.unwrap_or(OsStr::new("cmd.exe"))
.into();
exe.into_string()
.unwrap_or_else(|_| "%CompSpec%".to_string())
}
pub(crate) fn cmdline(&self) -> anyhow::Result<(Vec<u16>, Vec<u16>)> {
let mut cmdline = Vec::<u16>::new();
let exe: OsString = if self.is_default_prog() {
self.get_env("ComSpec")
.unwrap_or(OsStr::new("cmd.exe"))
.into()
} else {
self.search_path(&self.args[0])
};
Self::append_quoted(&exe, &mut cmdline);
// Ensure that we nul terminate the module name, otherwise we'll
// ask CreateProcessW to start something random!
let mut exe: Vec<u16> = exe.encode_wide().collect();
exe.push(0);
for arg in self.args.iter().skip(1) {
cmdline.push(' ' as u16);
anyhow::ensure!(
!arg.encode_wide().any(|c| c == 0),
"invalid encoding for command line argument {:?}",
arg
);
Self::append_quoted(arg, &mut cmdline);
}
// Ensure that the command line is nul terminated too!
cmdline.push(0);
Ok((exe, cmdline))
}
// Borrowed from https://github.com/hniksic/rust-subprocess/blob/873dfed165173e52907beb87118b2c0c05d8b8a1/src/popen.rs#L1117
// which in turn was translated from ArgvQuote at http://tinyurl.com/zmgtnls
fn append_quoted(arg: &OsStr, cmdline: &mut Vec<u16>) {
if !arg.is_empty()
&& !arg.encode_wide().any(|c| {
c == ' ' as u16
|| c == '\t' as u16
|| c == '\n' as u16
|| c == '\x0b' as u16
|| c == '\"' as u16
})
{
cmdline.extend(arg.encode_wide());
return;
}
cmdline.push('"' as u16);
let arg: Vec<_> = arg.encode_wide().collect();
let mut i = 0;
while i < arg.len() {
let mut num_backslashes = 0;
while i < arg.len() && arg[i] == '\\' as u16 {
i += 1;
num_backslashes += 1;
}
if i == arg.len() {
for _ in 0..num_backslashes * 2 {
cmdline.push('\\' as u16);
}
break;
} else if arg[i] == b'"' as u16 {
for _ in 0..num_backslashes * 2 + 1 {
cmdline.push('\\' as u16);
}
cmdline.push(arg[i]);
} else {
for _ in 0..num_backslashes {
cmdline.push('\\' as u16);
}
cmdline.push(arg[i]);
}
i += 1;
}
cmdline.push('"' as u16);
}
}
#[cfg(unix)]
/// Returns true if the path begins with `./` or `../`
fn is_cwd_relative_path<P: AsRef<Path>>(p: P) -> bool {
matches!(
p.as_ref().components().next(),
Some(Component::CurDir | Component::ParentDir)
)
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
#[test]
fn test_cwd_relative() {
assert!(is_cwd_relative_path("."));
assert!(is_cwd_relative_path("./foo"));
assert!(is_cwd_relative_path("../foo"));
assert!(!is_cwd_relative_path("foo"));
assert!(!is_cwd_relative_path("/foo"));
}
#[test]
fn test_env() {
let mut cmd = CommandBuilder::new("dummy");
let package_authors = cmd.get_env("CARGO_PKG_AUTHORS");
println!("package_authors: {:?}", package_authors);
assert!(package_authors == Some(OsStr::new("Wez Furlong")));
cmd.env("foo key", "foo value");
cmd.env("bar key", "bar value");
let iterated_envs = cmd.iter_extra_env_as_str().collect::<Vec<_>>();
println!("iterated_envs: {:?}", iterated_envs);
assert!(iterated_envs == vec![("bar key", "bar value"), ("foo key", "foo value")]);
{
let mut cmd = cmd.clone();
cmd.env_remove("foo key");
let iterated_envs = cmd.iter_extra_env_as_str().collect::<Vec<_>>();
println!("iterated_envs: {:?}", iterated_envs);
assert!(iterated_envs == vec![("bar key", "bar value")]);
}
{
let mut cmd = cmd.clone();
cmd.env_remove("bar key");
let iterated_envs = cmd.iter_extra_env_as_str().collect::<Vec<_>>();
println!("iterated_envs: {:?}", iterated_envs);
assert!(iterated_envs == vec![("foo key", "foo value")]);
}
{
let mut cmd = cmd.clone();
cmd.env_clear();
let iterated_envs = cmd.iter_extra_env_as_str().collect::<Vec<_>>();
println!("iterated_envs: {:?}", iterated_envs);
assert!(iterated_envs.is_empty());
}
}
#[cfg(windows)]
#[test]
fn test_env_case_insensitive_override() {
let mut cmd = CommandBuilder::new("dummy");
cmd.env("Cargo_Pkg_Authors", "Not Wez");
assert!(cmd.get_env("cargo_pkg_authors") == Some(OsStr::new("Not Wez")));
cmd.env_remove("cARGO_pKG_aUTHORS");
assert!(cmd.get_env("CARGO_PKG_AUTHORS").is_none());
}
}
+407
View File
@@ -0,0 +1,407 @@
//! This crate provides a cross platform API for working with the
//! psuedo terminal (pty) interfaces provided by the system.
//! Unlike other crates in this space, this crate provides a set
//! of traits that allow selecting from different implementations
//! at runtime.
//! This crate is part of [wezterm](https://github.com/wezterm/wezterm).
//!
//! ```no_run
//! use portable_pty::{CommandBuilder, PtySize, native_pty_system, PtySystem};
//! use anyhow::Error;
//!
//! // Use the native pty implementation for the system
//! let pty_system = native_pty_system();
//!
//! // Create a new pty
//! let mut pair = pty_system.openpty(PtySize {
//! rows: 24,
//! cols: 80,
//! // Not all systems support pixel_width, pixel_height,
//! // but it is good practice to set it to something
//! // that matches the size of the selected font. That
//! // is more complex than can be shown here in this
//! // brief example though!
//! pixel_width: 0,
//! pixel_height: 0,
//! })?;
//!
//! // Spawn a shell into the pty
//! let cmd = CommandBuilder::new("bash");
//! let child = pair.slave.spawn_command(cmd)?;
//!
//! // Read and parse output from the pty with reader
//! let mut reader = pair.master.try_clone_reader()?;
//!
//! // Send data to the pty by writing to the master
//! writeln!(pair.master.take_writer()?, "ls -l\r\n")?;
//! # Ok::<(), Error>(())
//! ```
//!
use anyhow::Error;
use downcast_rs::{impl_downcast, Downcast};
#[cfg(unix)]
use libc;
#[cfg(feature = "serde_support")]
use serde_derive::*;
use std::io::Result as IoResult;
#[cfg(windows)]
use std::os::windows::prelude::{AsRawHandle, RawHandle};
pub mod cmdbuilder;
pub use cmdbuilder::CommandBuilder;
#[cfg(unix)]
pub mod unix;
#[cfg(windows)]
pub mod win;
pub mod serial;
/// Represents the size of the visible display area in the pty
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde_support", derive(Serialize, Deserialize))]
pub struct PtySize {
/// The number of lines of text
pub rows: u16,
/// The number of columns of text
pub cols: u16,
/// The width of a cell in pixels. Note that some systems never
/// fill this value and ignore it.
pub pixel_width: u16,
/// The height of a cell in pixels. Note that some systems never
/// fill this value and ignore it.
pub pixel_height: u16,
}
impl Default for PtySize {
fn default() -> Self {
PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
}
}
}
/// Represents the master/control end of the pty
pub trait MasterPty: Downcast + Send {
/// Inform the kernel and thus the child process that the window resized.
/// It will update the winsize information maintained by the kernel,
/// and generate a signal for the child to notice and update its state.
fn resize(&self, size: PtySize) -> Result<(), Error>;
/// Retrieves the size of the pty as known by the kernel
fn get_size(&self) -> Result<PtySize, Error>;
/// Obtain a readable handle; output from the slave(s) is readable
/// via this stream.
fn try_clone_reader(&self) -> Result<Box<dyn std::io::Read + Send>, Error>;
/// Obtain a writable handle; writing to it will send data to the
/// slave end.
/// Dropping the writer will send EOF to the slave end.
/// It is invalid to take the writer more than once.
fn take_writer(&self) -> Result<Box<dyn std::io::Write + Send>, Error>;
/// If applicable to the type of the tty, return the local process id
/// of the process group or session leader
#[cfg(unix)]
fn process_group_leader(&self) -> Option<libc::pid_t>;
/// If get_termios() and process_group_leader() are both implemented and
/// return Some, then as_raw_fd() should return the same underlying fd
/// associated with the stream. This is to enable applications that
/// "know things" to query similar information for themselves.
#[cfg(unix)]
fn as_raw_fd(&self) -> Option<unix::RawFd>;
#[cfg(unix)]
fn tty_name(&self) -> Option<std::path::PathBuf>;
/// If applicable to the type of the tty, return the termios
/// associated with the stream
#[cfg(unix)]
fn get_termios(&self) -> Option<nix::sys::termios::Termios> {
None
}
}
impl_downcast!(MasterPty);
/// Represents a child process spawned into the pty.
/// This handle can be used to wait for or terminate that child process.
pub trait Child: std::fmt::Debug + ChildKiller + Downcast + Send {
/// Poll the child to see if it has completed.
/// Does not block.
/// Returns None if the child has not yet terminated,
/// else returns its exit status.
fn try_wait(&mut self) -> IoResult<Option<ExitStatus>>;
/// Blocks execution until the child process has completed,
/// yielding its exit status.
fn wait(&mut self) -> IoResult<ExitStatus>;
/// Returns the process identifier of the child process,
/// if applicable
fn process_id(&self) -> Option<u32>;
/// Returns the process handle of the child process, if applicable.
/// Only available on Windows.
#[cfg(windows)]
fn as_raw_handle(&self) -> Option<std::os::windows::io::RawHandle>;
}
impl_downcast!(Child);
/// Represents the ability to signal a Child to terminate
pub trait ChildKiller: std::fmt::Debug + Downcast + Send {
/// Terminate the child process
fn kill(&mut self) -> IoResult<()>;
/// Clone an object that can be split out from the Child in order
/// to send it signals independently from a thread that may be
/// blocked in `.wait`.
fn clone_killer(&self) -> Box<dyn ChildKiller + Send + Sync>;
}
impl_downcast!(ChildKiller);
/// Represents the slave side of a pty.
/// Can be used to spawn processes into the pty.
pub trait SlavePty {
/// Spawns the command specified by the provided CommandBuilder
fn spawn_command(&self, cmd: CommandBuilder) -> Result<Box<dyn Child + Send + Sync>, Error>;
}
/// Represents the exit status of a child process.
#[derive(Debug, Clone)]
pub struct ExitStatus {
code: u32,
signal: Option<String>,
}
impl ExitStatus {
/// Construct an ExitStatus from a process return code
pub fn with_exit_code(code: u32) -> Self {
Self { code, signal: None }
}
/// Construct an ExitStatus from a signal name
pub fn with_signal(signal: &str) -> Self {
Self {
code: 1,
signal: Some(signal.to_string()),
}
}
/// Returns true if the status indicates successful completion
pub fn success(&self) -> bool {
match self.signal {
None => self.code == 0,
Some(_) => false,
}
}
/// Returns the exit code that this ExitStatus was constructed with
pub fn exit_code(&self) -> u32 {
self.code
}
/// Returns the signal if present that this ExitStatus was constructed with
pub fn signal(&self) -> Option<&str> {
self.signal.as_deref()
}
}
impl From<std::process::ExitStatus> for ExitStatus {
fn from(status: std::process::ExitStatus) -> ExitStatus {
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
if let Some(signal) = status.signal() {
let signame = unsafe { libc::strsignal(signal) };
let signal = if signame.is_null() {
format!("Signal {}", signal)
} else {
let signame = unsafe { std::ffi::CStr::from_ptr(signame) };
signame.to_string_lossy().to_string()
};
return ExitStatus {
code: status.code().map(|c| c as u32).unwrap_or(1),
signal: Some(signal),
};
}
}
let code =
status
.code()
.map(|c| c as u32)
.unwrap_or_else(|| if status.success() { 0 } else { 1 });
ExitStatus { code, signal: None }
}
}
impl std::fmt::Display for ExitStatus {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
if self.success() {
write!(fmt, "Success")
} else {
match &self.signal {
Some(sig) => write!(fmt, "Terminated by {}", sig),
None => write!(fmt, "Exited with code {}", self.code),
}
}
}
}
pub struct PtyPair {
// slave is listed first so that it is dropped first.
// The drop order is stable and specified by rust rfc 1857
pub slave: Box<dyn SlavePty + Send>,
pub master: Box<dyn MasterPty + Send>,
}
/// The `PtySystem` trait allows an application to work with multiple
/// possible Pty implementations at runtime. This is important on
/// Windows systems which have a variety of implementations.
pub trait PtySystem: Downcast {
/// Create a new Pty instance with the window size set to the specified
/// dimensions. Returns a (master, slave) Pty pair. The master side
/// is used to drive the slave side.
fn openpty(&self, size: PtySize) -> anyhow::Result<PtyPair>;
}
impl_downcast!(PtySystem);
impl Child for std::process::Child {
fn try_wait(&mut self) -> IoResult<Option<ExitStatus>> {
std::process::Child::try_wait(self).map(|s| match s {
Some(s) => Some(s.into()),
None => None,
})
}
fn wait(&mut self) -> IoResult<ExitStatus> {
std::process::Child::wait(self).map(Into::into)
}
fn process_id(&self) -> Option<u32> {
Some(self.id())
}
#[cfg(windows)]
fn as_raw_handle(&self) -> Option<std::os::windows::io::RawHandle> {
Some(std::os::windows::io::AsRawHandle::as_raw_handle(self))
}
}
#[derive(Debug)]
struct ProcessSignaller {
pid: Option<u32>,
#[cfg(windows)]
handle: Option<filedescriptor::OwnedHandle>,
}
#[cfg(windows)]
impl ChildKiller for ProcessSignaller {
fn kill(&mut self) -> IoResult<()> {
if let Some(handle) = &self.handle {
unsafe {
if winapi::um::processthreadsapi::TerminateProcess(handle.as_raw_handle() as _, 127)
== 0
{
return Err(std::io::Error::last_os_error());
}
}
}
Ok(())
}
fn clone_killer(&self) -> Box<dyn ChildKiller + Send + Sync> {
Box::new(Self {
pid: self.pid,
handle: self.handle.as_ref().and_then(|h| h.try_clone().ok()),
})
}
}
#[cfg(unix)]
impl ChildKiller for ProcessSignaller {
fn kill(&mut self) -> IoResult<()> {
if let Some(pid) = self.pid {
let result = unsafe { libc::kill(pid as i32, libc::SIGHUP) };
if result != 0 {
return Err(std::io::Error::last_os_error());
}
}
Ok(())
}
fn clone_killer(&self) -> Box<dyn ChildKiller + Send + Sync> {
Box::new(Self { pid: self.pid })
}
}
impl ChildKiller for std::process::Child {
fn kill(&mut self) -> IoResult<()> {
#[cfg(unix)]
{
// On unix, we send the SIGHUP signal instead of trying to kill
// the process. The default behavior of a process receiving this
// signal is to be killed unless it configured a signal handler.
let result = unsafe { libc::kill(self.id() as i32, libc::SIGHUP) };
if result != 0 {
return Err(std::io::Error::last_os_error());
}
// We successfully delivered SIGHUP, but the semantics of Child::kill
// are that on success the process is dead or shortly about to
// terminate. Since SIGUP doesn't guarantee termination, we
// give the process a bit of a grace period to shutdown or do whatever
// it is doing in its signal handler befre we proceed with the
// full on kill.
for attempt in 0..5 {
if attempt > 0 {
std::thread::sleep(std::time::Duration::from_millis(50));
}
if let Ok(Some(_)) = self.try_wait() {
// It completed, so report success!
return Ok(());
}
}
// it's still alive after a grace period, so proceed with a kill
}
std::process::Child::kill(self)
}
#[cfg(windows)]
fn clone_killer(&self) -> Box<dyn ChildKiller + Send + Sync> {
struct RawDup(RawHandle);
impl AsRawHandle for RawDup {
fn as_raw_handle(&self) -> RawHandle {
self.0
}
}
Box::new(ProcessSignaller {
pid: self.process_id(),
handle: Child::as_raw_handle(self)
.as_ref()
.and_then(|h| filedescriptor::OwnedHandle::dup(&RawDup(*h)).ok()),
})
}
#[cfg(unix)]
fn clone_killer(&self) -> Box<dyn ChildKiller + Send + Sync> {
Box::new(ProcessSignaller {
pid: self.process_id(),
})
}
}
pub fn native_pty_system() -> Box<dyn PtySystem + Send> {
Box::new(NativePtySystem::default())
}
#[cfg(unix)]
pub type NativePtySystem = unix::UnixPtySystem;
#[cfg(windows)]
pub type NativePtySystem = win::conpty::ConPtySystem;
+305
View File
@@ -0,0 +1,305 @@
//! This module implements a serial port based tty.
//! This is a bit different from the other implementations in that
//! we cannot explicitly spawn a process into the serial connection,
//! so we can only use a `CommandBuilder::new_default_prog` with the
//! `openpty` method.
//! On most (all?) systems, attempting to open multiple instances of
//! the same serial port will fail.
use crate::{
Child, ChildKiller, CommandBuilder, ExitStatus, MasterPty, PtyPair, PtySize, PtySystem,
SlavePty,
};
use anyhow::{ensure, Context};
use filedescriptor::FileDescriptor;
use serial2::{CharSize, FlowControl, Parity, SerialPort, StopBits};
use std::cell::RefCell;
use std::ffi::{OsStr, OsString};
use std::io::{Read, Result as IoResult, Write};
#[cfg(unix)]
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
type Handle = Arc<SerialPort>;
pub struct SerialTty {
port: OsString,
baud: u32,
char_size: CharSize,
parity: Parity,
stop_bits: StopBits,
flow_control: FlowControl,
}
impl SerialTty {
pub fn new<T: AsRef<OsStr> + ?Sized>(port: &T) -> Self {
Self {
port: port.as_ref().to_owned(),
baud: 9600,
char_size: CharSize::Bits8,
parity: Parity::None,
stop_bits: StopBits::One,
flow_control: FlowControl::XonXoff,
}
}
pub fn set_baud_rate(&mut self, baud: u32) {
self.baud = baud;
}
pub fn set_char_size(&mut self, char_size: CharSize) {
self.char_size = char_size;
}
pub fn set_parity(&mut self, parity: Parity) {
self.parity = parity;
}
pub fn set_stop_bits(&mut self, stop_bits: StopBits) {
self.stop_bits = stop_bits;
}
pub fn set_flow_control(&mut self, flow_control: FlowControl) {
self.flow_control = flow_control;
}
}
impl PtySystem for SerialTty {
fn openpty(&self, _size: PtySize) -> anyhow::Result<PtyPair> {
let mut port = SerialPort::open(&self.port, self.baud)
.with_context(|| format!("openpty on serial port {:?}", self.port))?;
let mut settings = port.get_configuration()?;
settings.set_raw();
settings.set_baud_rate(self.baud)?;
settings.set_char_size(self.char_size);
settings.set_flow_control(self.flow_control);
settings.set_parity(self.parity);
settings.set_stop_bits(self.stop_bits);
log::debug!("serial settings: {:#?}", port.get_configuration());
port.set_configuration(&settings)?;
// The timeout needs to be rather short because, at least on Windows,
// a read with a long timeout will block a concurrent write from
// happening. In wezterm we tend to have a thread looping on read
// while writes happen occasionally from the gui thread, and if we
// make this timeout too long we can block the gui thread.
port.set_read_timeout(Duration::from_millis(50))?;
port.set_write_timeout(Duration::from_millis(50))?;
let port: Handle = Arc::new(port);
Ok(PtyPair {
slave: Box::new(Slave {
port: Arc::clone(&port),
}),
master: Box::new(Master {
port,
took_writer: RefCell::new(false),
}),
})
}
}
struct Slave {
port: Handle,
}
impl SlavePty for Slave {
fn spawn_command(&self, cmd: CommandBuilder) -> anyhow::Result<Box<dyn Child + Send + Sync>> {
ensure!(
cmd.is_default_prog(),
"can only use default prog commands with serial tty implementations"
);
Ok(Box::new(SerialChild {
port: Arc::clone(&self.port),
}))
}
}
/// There isn't really a child process on the end of the serial connection,
/// so all of the Child trait impls are NOP
struct SerialChild {
port: Handle,
}
// An anemic impl of Debug to satisfy some indirect trait bounds
impl std::fmt::Debug for SerialChild {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> Result<(), std::fmt::Error> {
fmt.debug_struct("SerialChild").finish()
}
}
impl Child for SerialChild {
fn try_wait(&mut self) -> IoResult<Option<ExitStatus>> {
Ok(None)
}
fn wait(&mut self) -> IoResult<ExitStatus> {
// There isn't really a child process to wait for,
// as the serial connection never really "dies",
// however, for something like a USB serial port,
// if it is unplugged then it logically is terminated.
// We read the CD (carrier detect) signal periodically
// to see if the device has gone away: we actually discard
// the CD value itself and just look for an error state.
// We could potentially also decide to call CD==false the
// same thing as the "child" completing.
loop {
std::thread::sleep(Duration::from_secs(5));
let port = &self.port;
if let Err(err) = port.read_cd() {
log::error!("Error reading carrier detect: {:#}", err);
return Ok(ExitStatus::with_exit_code(1));
}
}
}
fn process_id(&self) -> Option<u32> {
None
}
#[cfg(windows)]
fn as_raw_handle(&self) -> Option<std::os::windows::io::RawHandle> {
None
}
}
impl ChildKiller for SerialChild {
fn kill(&mut self) -> IoResult<()> {
Ok(())
}
fn clone_killer(&self) -> Box<dyn ChildKiller + Send + Sync> {
Box::new(SerialChildKiller)
}
}
#[derive(Debug)]
struct SerialChildKiller;
impl ChildKiller for SerialChildKiller {
fn kill(&mut self) -> IoResult<()> {
Ok(())
}
fn clone_killer(&self) -> Box<dyn ChildKiller + Send + Sync> {
Box::new(SerialChildKiller)
}
}
struct Master {
port: Handle,
took_writer: RefCell<bool>,
}
struct MasterWriter {
port: Handle,
}
impl Write for MasterWriter {
fn write(&mut self, buf: &[u8]) -> Result<usize, std::io::Error> {
self.port.write(buf)
}
fn flush(&mut self) -> Result<(), std::io::Error> {
self.port.flush()
}
}
impl MasterPty for Master {
fn resize(&self, _size: PtySize) -> anyhow::Result<()> {
// Serial ports have no concept of size
Ok(())
}
fn get_size(&self) -> anyhow::Result<PtySize> {
// Serial ports have no concept of size
Ok(PtySize::default())
}
fn try_clone_reader(&self) -> anyhow::Result<Box<dyn std::io::Read + Send>> {
// We rely on the fact that SystemPort implements the traits
// that expose the underlying file descriptor, and that direct
// reads from that return the raw data that we want
let fd = FileDescriptor::dup(&*self.port)?;
Ok(Box::new(Reader { fd }))
}
fn take_writer(&self) -> anyhow::Result<Box<dyn std::io::Write + Send>> {
if *self.took_writer.borrow() {
anyhow::bail!("cannot take writer more than once");
}
*self.took_writer.borrow_mut() = true;
let port = Arc::clone(&self.port);
Ok(Box::new(MasterWriter { port }))
}
#[cfg(unix)]
fn process_group_leader(&self) -> Option<libc::pid_t> {
// N/A: there is no local process
None
}
#[cfg(unix)]
fn as_raw_fd(&self) -> Option<crate::unix::RawFd> {
None
}
#[cfg(unix)]
fn tty_name(&self) -> Option<PathBuf> {
None
}
}
struct Reader {
fd: FileDescriptor,
}
impl Read for Reader {
fn read(&mut self, buf: &mut [u8]) -> Result<usize, std::io::Error> {
// On windows, this self.fd.read will block for up to the time we set
// as the timeout when we set up the port, but on unix it will
// never block.
loop {
#[cfg(unix)]
{
use filedescriptor::{poll, pollfd, AsRawSocketDescriptor, POLLIN};
// The serial crate puts the serial port in non-blocking mode,
// so we must explicitly poll for ourselves here to avoid a
// busy loop.
let mut poll_array = [pollfd {
fd: self.fd.as_socket_descriptor(),
events: POLLIN,
revents: 0,
}];
let _ = poll(&mut poll_array, None);
}
match self.fd.read(buf) {
Ok(0) => {
if cfg!(windows) {
// Read timeout with no data available yet;
// loop and try again.
continue;
}
return Err(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
"EOF on serial port",
));
}
Ok(size) => {
return Ok(size);
}
Err(e) => {
if e.kind() == std::io::ErrorKind::WouldBlock {
continue;
}
log::error!("serial read error: {}", e);
return Err(e);
}
}
}
}
}
+414
View File
@@ -0,0 +1,414 @@
//! Working with pseudo-terminals
use crate::{Child, CommandBuilder, MasterPty, PtyPair, PtySize, PtySystem, SlavePty};
use anyhow::{bail, Error};
use filedescriptor::FileDescriptor;
use libc::{self, winsize};
use std::cell::RefCell;
use std::ffi::OsStr;
use std::io::{Read, Write};
use std::os::fd::AsFd;
use std::os::unix::ffi::OsStrExt;
use std::os::unix::io::{AsRawFd, FromRawFd};
use std::os::unix::process::CommandExt;
use std::path::PathBuf;
use std::{io, mem, ptr};
pub use std::os::unix::io::RawFd;
#[derive(Default)]
pub struct UnixPtySystem {}
fn openpty(size: PtySize) -> anyhow::Result<(UnixMasterPty, UnixSlavePty)> {
let mut master: RawFd = -1;
let mut slave: RawFd = -1;
let mut size = winsize {
ws_row: size.rows,
ws_col: size.cols,
ws_xpixel: size.pixel_width,
ws_ypixel: size.pixel_height,
};
let result = unsafe {
// BSDish systems may require mut pointers to some args
#[allow(clippy::unnecessary_mut_passed)]
libc::openpty(
&mut master,
&mut slave,
ptr::null_mut(),
ptr::null_mut(),
&mut size,
)
};
if result != 0 {
bail!("failed to openpty: {:?}", io::Error::last_os_error());
}
let tty_name = tty_name(slave);
let master = UnixMasterPty {
fd: PtyFd(unsafe { FileDescriptor::from_raw_fd(master) }),
took_writer: RefCell::new(false),
tty_name,
};
let slave = UnixSlavePty {
fd: PtyFd(unsafe { FileDescriptor::from_raw_fd(slave) }),
};
// Ensure that these descriptors will get closed when we execute
// the child process. This is done after constructing the Pty
// instances so that we ensure that the Ptys get drop()'d if
// the cloexec() functions fail (unlikely!).
cloexec(master.fd.as_raw_fd())?;
cloexec(slave.fd.as_raw_fd())?;
Ok((master, slave))
}
impl PtySystem for UnixPtySystem {
fn openpty(&self, size: PtySize) -> anyhow::Result<PtyPair> {
let (master, slave) = openpty(size)?;
Ok(PtyPair {
master: Box::new(master),
slave: Box::new(slave),
})
}
}
struct PtyFd(pub FileDescriptor);
impl std::ops::Deref for PtyFd {
type Target = FileDescriptor;
fn deref(&self) -> &FileDescriptor {
&self.0
}
}
impl std::ops::DerefMut for PtyFd {
fn deref_mut(&mut self) -> &mut FileDescriptor {
&mut self.0
}
}
impl Read for PtyFd {
fn read(&mut self, buf: &mut [u8]) -> Result<usize, io::Error> {
match self.0.read(buf) {
Err(ref e) if e.raw_os_error() == Some(libc::EIO) => {
// EIO indicates that the slave pty has been closed.
// Treat this as EOF so that std::io::Read::read_to_string
// and similar functions gracefully terminate when they
// encounter this condition
Ok(0)
}
x => x,
}
}
}
fn tty_name(fd: RawFd) -> Option<PathBuf> {
let mut buf = vec![0 as std::ffi::c_char; 128];
loop {
let res = unsafe { libc::ttyname_r(fd, buf.as_mut_ptr(), buf.len()) };
if res == libc::ERANGE {
if buf.len() > 64 * 1024 {
// on macOS, if the buf is "too big", ttyname_r can
// return ERANGE, even though that is supposed to
// indicate buf is "too small".
return None;
}
buf.resize(buf.len() * 2, 0 as std::ffi::c_char);
continue;
}
return if res == 0 {
let cstr = unsafe { std::ffi::CStr::from_ptr(buf.as_ptr()) };
let osstr = OsStr::from_bytes(cstr.to_bytes());
Some(PathBuf::from(osstr))
} else {
None
};
}
}
/// On Big Sur, Cocoa leaks various file descriptors to child processes,
/// so we need to make a pass through the open descriptors beyond just the
/// stdio descriptors and close them all out.
/// This is approximately equivalent to the darwin `posix_spawnattr_setflags`
/// option POSIX_SPAWN_CLOEXEC_DEFAULT which is used as a bit of a cheat
/// on macOS.
/// On Linux, gnome/mutter leak shell extension fds to wezterm too, so we
/// also need to make an effort to clean up the mess.
///
/// This function enumerates the open filedescriptors in the current process
/// and then will forcibly call close(2) on each open fd that is numbered
/// 3 or higher, effectively closing all descriptors except for the stdio
/// streams.
///
/// The implementation of this function relies on `/dev/fd` being available
/// to provide the list of open fds. Any errors in enumerating or closing
/// the fds are silently ignored.
pub fn close_random_fds() {
// FreeBSD, macOS and presumably other BSDish systems have /dev/fd as
// a directory listing the current fd numbers for the process.
//
// On Linux, /dev/fd is a symlink to /proc/self/fd
if let Ok(dir) = std::fs::read_dir("/dev/fd") {
let mut fds = vec![];
for entry in dir {
if let Some(num) = entry
.ok()
.map(|e| e.file_name())
.and_then(|s| s.into_string().ok())
.and_then(|n| n.parse::<libc::c_int>().ok())
{
if num > 2 {
fds.push(num);
}
}
}
for fd in fds {
unsafe {
libc::close(fd);
}
}
}
}
impl PtyFd {
fn resize(&self, size: PtySize) -> Result<(), Error> {
let ws_size = winsize {
ws_row: size.rows,
ws_col: size.cols,
ws_xpixel: size.pixel_width,
ws_ypixel: size.pixel_height,
};
if unsafe {
libc::ioctl(
self.0.as_raw_fd(),
libc::TIOCSWINSZ as _,
&ws_size as *const _,
)
} != 0
{
bail!(
"failed to ioctl(TIOCSWINSZ): {:?}",
io::Error::last_os_error()
);
}
Ok(())
}
fn get_size(&self) -> Result<PtySize, Error> {
let mut size: winsize = unsafe { mem::zeroed() };
if unsafe {
libc::ioctl(
self.0.as_raw_fd(),
libc::TIOCGWINSZ as _,
&mut size as *mut _,
)
} != 0
{
bail!(
"failed to ioctl(TIOCGWINSZ): {:?}",
io::Error::last_os_error()
);
}
Ok(PtySize {
rows: size.ws_row,
cols: size.ws_col,
pixel_width: size.ws_xpixel,
pixel_height: size.ws_ypixel,
})
}
fn spawn_command(&self, builder: CommandBuilder) -> anyhow::Result<std::process::Child> {
let configured_umask = builder.umask;
let mut cmd = builder.as_command()?;
let controlling_tty = builder.get_controlling_tty();
unsafe {
cmd.stdin(self.as_stdio()?)
.stdout(self.as_stdio()?)
.stderr(self.as_stdio()?)
.pre_exec(move || {
// Clean up a few things before we exec the program
// Clear out any potentially problematic signal
// dispositions that we might have inherited
for signo in &[
libc::SIGCHLD,
libc::SIGHUP,
libc::SIGINT,
libc::SIGQUIT,
libc::SIGTERM,
libc::SIGALRM,
] {
libc::signal(*signo, libc::SIG_DFL);
}
let empty_set: libc::sigset_t = std::mem::zeroed();
libc::sigprocmask(libc::SIG_SETMASK, &empty_set, std::ptr::null_mut());
// Establish ourselves as a session leader.
if libc::setsid() == -1 {
return Err(io::Error::last_os_error());
}
// Clippy wants us to explicitly cast TIOCSCTTY using
// type::from(), but the size and potentially signedness
// are system dependent, which is why we're using `as _`.
// Suppress this lint for this section of code.
#[allow(clippy::cast_lossless)]
if controlling_tty {
// Set the pty as the controlling terminal.
// Failure to do this means that delivery of
// SIGWINCH won't happen when we resize the
// terminal, among other undesirable effects.
if libc::ioctl(0, libc::TIOCSCTTY as _, 0) == -1 {
return Err(io::Error::last_os_error());
}
}
close_random_fds();
if let Some(mask) = configured_umask {
libc::umask(mask);
}
Ok(())
})
};
let mut child = cmd.spawn()?;
// Ensure that we close out the slave fds that Child retains;
// they are not what we need (we need the master side to reference
// them) and won't work in the usual way anyway.
// In practice these are None, but it seems best to be move them
// out in case the behavior of Command changes in the future.
child.stdin.take();
child.stdout.take();
child.stderr.take();
Ok(child)
}
}
/// Represents the master end of a pty.
/// The file descriptor will be closed when the Pty is dropped.
struct UnixMasterPty {
fd: PtyFd,
took_writer: RefCell<bool>,
tty_name: Option<PathBuf>,
}
/// Represents the slave end of a pty.
/// The file descriptor will be closed when the Pty is dropped.
struct UnixSlavePty {
fd: PtyFd,
}
/// Helper function to set the close-on-exec flag for a raw descriptor
fn cloexec(fd: RawFd) -> Result<(), Error> {
let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) };
if flags == -1 {
bail!(
"fcntl to read flags failed: {:?}",
io::Error::last_os_error()
);
}
let result = unsafe { libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC) };
if result == -1 {
bail!(
"fcntl to set CLOEXEC failed: {:?}",
io::Error::last_os_error()
);
}
Ok(())
}
impl SlavePty for UnixSlavePty {
fn spawn_command(
&self,
builder: CommandBuilder,
) -> Result<Box<dyn Child + Send + Sync>, Error> {
Ok(Box::new(self.fd.spawn_command(builder)?))
}
}
impl MasterPty for UnixMasterPty {
fn resize(&self, size: PtySize) -> Result<(), Error> {
self.fd.resize(size)
}
fn get_size(&self) -> Result<PtySize, Error> {
self.fd.get_size()
}
fn try_clone_reader(&self) -> Result<Box<dyn Read + Send>, Error> {
let fd = PtyFd(self.fd.try_clone()?);
Ok(Box::new(fd))
}
fn take_writer(&self) -> Result<Box<dyn Write + Send>, Error> {
if *self.took_writer.borrow() {
anyhow::bail!("cannot take writer more than once");
}
*self.took_writer.borrow_mut() = true;
let fd = PtyFd(self.fd.try_clone()?);
Ok(Box::new(UnixMasterWriter { fd }))
}
fn as_raw_fd(&self) -> Option<RawFd> {
Some(self.fd.0.as_raw_fd())
}
fn tty_name(&self) -> Option<PathBuf> {
self.tty_name.clone()
}
fn process_group_leader(&self) -> Option<libc::pid_t> {
match unsafe { libc::tcgetpgrp(self.fd.0.as_raw_fd()) } {
pid if pid > 0 => Some(pid),
_ => None,
}
}
fn get_termios(&self) -> Option<nix::sys::termios::Termios> {
nix::sys::termios::tcgetattr(self.fd.0.as_fd()).ok()
}
}
/// Represents the master end of a pty.
/// EOT will be sent, and then the file descriptor will be closed when
/// the Pty is dropped.
struct UnixMasterWriter {
fd: PtyFd,
}
impl Drop for UnixMasterWriter {
fn drop(&mut self) {
let mut t: libc::termios = unsafe { std::mem::MaybeUninit::zeroed().assume_init() };
if unsafe { libc::tcgetattr(self.fd.0.as_raw_fd(), &mut t) } == 0 {
// EOF is only interpreted after a newline, so if it is set,
// we send a newline followed by EOF.
let eot = t.c_cc[libc::VEOF];
if eot != 0 {
let _ = self.fd.0.write_all(&[b'\n', eot]);
}
}
}
}
impl Write for UnixMasterWriter {
fn write(&mut self, buf: &[u8]) -> Result<usize, io::Error> {
self.fd.write(buf)
}
fn flush(&mut self) -> Result<(), io::Error> {
self.fd.flush()
}
}
+117
View File
@@ -0,0 +1,117 @@
use crate::cmdbuilder::CommandBuilder;
use crate::win::psuedocon::PsuedoCon;
use crate::{Child, MasterPty, PtyPair, PtySize, PtySystem, SlavePty};
use anyhow::Error;
use filedescriptor::{FileDescriptor, Pipe};
use std::sync::{Arc, Mutex};
use winapi::um::wincon::COORD;
#[derive(Default)]
pub struct ConPtySystem {}
impl PtySystem for ConPtySystem {
fn openpty(&self, size: PtySize) -> anyhow::Result<PtyPair> {
let stdin = Pipe::new()?;
let stdout = Pipe::new()?;
let con = PsuedoCon::new(
COORD {
X: size.cols as i16,
Y: size.rows as i16,
},
stdin.read,
stdout.write,
)?;
let master = ConPtyMasterPty {
inner: Arc::new(Mutex::new(Inner {
con,
readable: stdout.read,
writable: Some(stdin.write),
size,
})),
};
let slave = ConPtySlavePty {
inner: master.inner.clone(),
};
Ok(PtyPair {
master: Box::new(master),
slave: Box::new(slave),
})
}
}
struct Inner {
con: PsuedoCon,
readable: FileDescriptor,
writable: Option<FileDescriptor>,
size: PtySize,
}
impl Inner {
pub fn resize(
&mut self,
num_rows: u16,
num_cols: u16,
pixel_width: u16,
pixel_height: u16,
) -> Result<(), Error> {
self.con.resize(COORD {
X: num_cols as i16,
Y: num_rows as i16,
})?;
self.size = PtySize {
rows: num_rows,
cols: num_cols,
pixel_width,
pixel_height,
};
Ok(())
}
}
#[derive(Clone)]
pub struct ConPtyMasterPty {
inner: Arc<Mutex<Inner>>,
}
pub struct ConPtySlavePty {
inner: Arc<Mutex<Inner>>,
}
impl MasterPty for ConPtyMasterPty {
fn resize(&self, size: PtySize) -> anyhow::Result<()> {
let mut inner = self.inner.lock().unwrap();
inner.resize(size.rows, size.cols, size.pixel_width, size.pixel_height)
}
fn get_size(&self) -> Result<PtySize, Error> {
let inner = self.inner.lock().unwrap();
Ok(inner.size.clone())
}
fn try_clone_reader(&self) -> anyhow::Result<Box<dyn std::io::Read + Send>> {
Ok(Box::new(self.inner.lock().unwrap().readable.try_clone()?))
}
fn take_writer(&self) -> anyhow::Result<Box<dyn std::io::Write + Send>> {
Ok(Box::new(
self.inner
.lock()
.unwrap()
.writable
.take()
.ok_or_else(|| anyhow::anyhow!("writer already taken"))?,
))
}
}
impl SlavePty for ConPtySlavePty {
fn spawn_command(&self, cmd: CommandBuilder) -> anyhow::Result<Box<dyn Child + Send + Sync>> {
let inner = self.inner.lock().unwrap();
let child = inner.con.spawn_command(cmd)?;
Ok(Box::new(child))
}
}
+151
View File
@@ -0,0 +1,151 @@
use crate::{Child, ChildKiller, ExitStatus};
use anyhow::Context as _;
use std::io::{Error as IoError, Result as IoResult};
use std::os::windows::io::{AsRawHandle, RawHandle};
use std::pin::Pin;
use std::sync::Mutex;
use std::task::{Context, Poll};
use winapi::shared::minwindef::DWORD;
use winapi::um::minwinbase::STILL_ACTIVE;
use winapi::um::processthreadsapi::*;
use winapi::um::synchapi::WaitForSingleObject;
use winapi::um::winbase::INFINITE;
pub mod conpty;
mod procthreadattr;
mod psuedocon;
pub use psuedocon::{configure_bundled_conpty, conpty_status, ConptyBackend, ConptyStatus};
use filedescriptor::OwnedHandle;
#[derive(Debug)]
pub struct WinChild {
proc: Mutex<OwnedHandle>,
}
impl WinChild {
fn is_complete(&mut self) -> IoResult<Option<ExitStatus>> {
let mut status: DWORD = 0;
let proc = self.proc.lock().unwrap().try_clone().unwrap();
let res = unsafe { GetExitCodeProcess(proc.as_raw_handle() as _, &mut status) };
if res != 0 {
if status == STILL_ACTIVE {
Ok(None)
} else {
Ok(Some(ExitStatus::with_exit_code(status)))
}
} else {
Ok(None)
}
}
fn do_kill(&mut self) -> IoResult<()> {
let proc = self.proc.lock().unwrap().try_clone().unwrap();
let res = unsafe { TerminateProcess(proc.as_raw_handle() as _, 1) };
let err = IoError::last_os_error();
if res != 0 {
Err(err)
} else {
Ok(())
}
}
}
impl ChildKiller for WinChild {
fn kill(&mut self) -> IoResult<()> {
self.do_kill().ok();
Ok(())
}
fn clone_killer(&self) -> Box<dyn ChildKiller + Send + Sync> {
let proc = self.proc.lock().unwrap().try_clone().unwrap();
Box::new(WinChildKiller { proc })
}
}
#[derive(Debug)]
pub struct WinChildKiller {
proc: OwnedHandle,
}
impl ChildKiller for WinChildKiller {
fn kill(&mut self) -> IoResult<()> {
let res = unsafe { TerminateProcess(self.proc.as_raw_handle() as _, 1) };
let err = IoError::last_os_error();
if res != 0 {
Err(err)
} else {
Ok(())
}
}
fn clone_killer(&self) -> Box<dyn ChildKiller + Send + Sync> {
let proc = self.proc.try_clone().unwrap();
Box::new(WinChildKiller { proc })
}
}
impl Child for WinChild {
fn try_wait(&mut self) -> IoResult<Option<ExitStatus>> {
self.is_complete()
}
fn wait(&mut self) -> IoResult<ExitStatus> {
if let Ok(Some(status)) = self.try_wait() {
return Ok(status);
}
let proc = self.proc.lock().unwrap().try_clone().unwrap();
unsafe {
WaitForSingleObject(proc.as_raw_handle() as _, INFINITE);
}
let mut status: DWORD = 0;
let res = unsafe { GetExitCodeProcess(proc.as_raw_handle() as _, &mut status) };
if res != 0 {
Ok(ExitStatus::with_exit_code(status))
} else {
Err(IoError::last_os_error())
}
}
fn process_id(&self) -> Option<u32> {
let res = unsafe { GetProcessId(self.proc.lock().unwrap().as_raw_handle() as _) };
if res == 0 {
None
} else {
Some(res)
}
}
fn as_raw_handle(&self) -> Option<std::os::windows::io::RawHandle> {
let proc = self.proc.lock().unwrap();
Some(proc.as_raw_handle())
}
}
impl std::future::Future for WinChild {
type Output = anyhow::Result<ExitStatus>;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context) -> Poll<anyhow::Result<ExitStatus>> {
match self.is_complete() {
Ok(Some(status)) => Poll::Ready(Ok(status)),
Err(err) => Poll::Ready(Err(err).context("Failed to retrieve process exit status")),
Ok(None) => {
struct PassRawHandleToWaiterThread(pub RawHandle);
unsafe impl Send for PassRawHandleToWaiterThread {}
let proc = self.proc.lock().unwrap().try_clone()?;
let handle = PassRawHandleToWaiterThread(proc.as_raw_handle());
let waker = cx.waker().clone();
std::thread::spawn(move || {
unsafe {
WaitForSingleObject(handle.0 as _, INFINITE);
}
waker.wake();
});
Poll::Pending
}
}
}
}
+72
View File
@@ -0,0 +1,72 @@
use crate::win::psuedocon::HPCON;
use anyhow::{ensure, Error};
use std::io::Error as IoError;
use std::{mem, ptr};
use winapi::shared::minwindef::DWORD;
use winapi::um::processthreadsapi::*;
const PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE: usize = 0x00020016;
pub struct ProcThreadAttributeList {
data: Vec<u8>,
}
impl ProcThreadAttributeList {
pub fn with_capacity(num_attributes: DWORD) -> Result<Self, Error> {
let mut bytes_required: usize = 0;
unsafe {
InitializeProcThreadAttributeList(
ptr::null_mut(),
num_attributes,
0,
&mut bytes_required,
)
};
let mut data = Vec::with_capacity(bytes_required);
// We have the right capacity, so force the vec to consider itself
// that length. The contents of those bytes will be maintained
// by the win32 apis used in this impl.
unsafe { data.set_len(bytes_required) };
let attr_ptr = data.as_mut_slice().as_mut_ptr() as *mut _;
let res = unsafe {
InitializeProcThreadAttributeList(attr_ptr, num_attributes, 0, &mut bytes_required)
};
ensure!(
res != 0,
"InitializeProcThreadAttributeList failed: {}",
IoError::last_os_error()
);
Ok(Self { data })
}
pub fn as_mut_ptr(&mut self) -> LPPROC_THREAD_ATTRIBUTE_LIST {
self.data.as_mut_slice().as_mut_ptr() as *mut _
}
pub fn set_pty(&mut self, con: HPCON) -> Result<(), Error> {
let res = unsafe {
UpdateProcThreadAttribute(
self.as_mut_ptr(),
0,
PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE,
con,
mem::size_of::<HPCON>(),
ptr::null_mut(),
ptr::null_mut(),
)
};
ensure!(
res != 0,
"UpdateProcThreadAttribute failed: {}",
IoError::last_os_error()
);
Ok(())
}
}
impl Drop for ProcThreadAttributeList {
fn drop(&mut self) {
unsafe { DeleteProcThreadAttributeList(self.as_mut_ptr()) };
}
}
+361
View File
@@ -0,0 +1,361 @@
use super::WinChild;
use crate::cmdbuilder::CommandBuilder;
use crate::win::procthreadattr::ProcThreadAttributeList;
use anyhow::{bail, ensure, Error};
use filedescriptor::{FileDescriptor, OwnedHandle};
use shared_library::shared_library;
use std::ffi::OsString;
use std::io::Error as IoError;
use std::os::windows::ffi::OsStringExt;
use std::os::windows::io::{AsRawHandle, FromRawHandle};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicUsize, Ordering};
use std::sync::{Mutex, OnceLock};
use std::{mem, ptr};
use winapi::shared::minwindef::DWORD;
use winapi::shared::winerror::{HRESULT, S_OK};
use winapi::um::handleapi::*;
use winapi::um::processthreadsapi::*;
use winapi::um::winbase::{
CREATE_UNICODE_ENVIRONMENT, EXTENDED_STARTUPINFO_PRESENT, STARTF_USESTDHANDLES, STARTUPINFOEXW,
};
use winapi::um::wincon::COORD;
use winapi::um::winnt::HANDLE;
pub type HPCON = HANDLE;
pub const PSUEDOCONSOLE_INHERIT_CURSOR: DWORD = 0x1;
pub const PSEUDOCONSOLE_RESIZE_QUIRK: DWORD = 0x2;
pub const PSEUDOCONSOLE_WIN32_INPUT_MODE: DWORD = 0x4;
#[allow(dead_code)]
pub const PSEUDOCONSOLE_PASSTHROUGH_MODE: DWORD = 0x8;
shared_library!(ConPtyFuncs,
pub fn CreatePseudoConsole(
size: COORD,
hInput: HANDLE,
hOutput: HANDLE,
flags: DWORD,
hpc: *mut HPCON
) -> HRESULT,
pub fn ResizePseudoConsole(hpc: HPCON, size: COORD) -> HRESULT,
pub fn ClosePseudoConsole(hpc: HPCON),
);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ConptyBackend {
Bundled,
System,
}
#[derive(Clone, Debug)]
pub struct ConptyStatus {
pub active_bundled: usize,
pub active_system: usize,
pub last_used: Option<ConptyBackend>,
pub fallback_reason: Option<String>,
}
struct ConptyState {
system: ConPtyFuncs,
bundled: Option<ConPtyFuncs>,
bundled_disabled: AtomicBool,
active_bundled: AtomicUsize,
active_system: AtomicUsize,
last_used: AtomicU8,
fallback_reason: Mutex<Option<String>>,
}
static BUNDLED_PATH: OnceLock<PathBuf> = OnceLock::new();
static CONPTY: OnceLock<ConptyState> = OnceLock::new();
pub fn configure_bundled_conpty(path: PathBuf) -> Result<(), &'static str> {
if !path.is_absolute() {
return Err("bundled ConPTY path must be absolute");
}
if CONPTY.get().is_some() {
return Err("ConPTY was already initialized");
}
BUNDLED_PATH
.set(path)
.map_err(|_| "bundled ConPTY path was already configured")
}
pub fn conpty_status() -> ConptyStatus {
let Some(state) = CONPTY.get() else {
return ConptyStatus {
active_bundled: 0,
active_system: 0,
last_used: None,
fallback_reason: None,
};
};
let last_used = match state.last_used.load(Ordering::Acquire) {
1 => Some(ConptyBackend::Bundled),
2 => Some(ConptyBackend::System),
_ => None,
};
ConptyStatus {
active_bundled: state.active_bundled.load(Ordering::Acquire),
active_system: state.active_system.load(Ordering::Acquire),
last_used,
fallback_reason: state.fallback_reason.lock().unwrap().clone(),
}
}
fn load_conpty() -> ConptyState {
let system = ConPtyFuncs::open(Path::new("kernel32.dll")).expect(
"this system does not support conpty. Windows 10 October 2018 or newer is required",
);
let (bundled, fallback_reason) = match BUNDLED_PATH.get() {
Some(path) => {
let host_arches: &[&str] = match std::env::consts::ARCH {
"x86_64" => &["x64", "arm64"],
"aarch64" => &["arm64"],
"x86" => &["x86", "x64", "arm64"],
_ => &[],
};
let missing_host = host_arches.iter().find_map(|arch| {
let host = path.parent()?.join(arch).join("OpenConsole.exe");
(!host.is_file()).then_some(host)
});
if let Some(host) = missing_host {
(
None,
Some(format!(
"Bundled ConPTY host is missing: {}",
host.display()
)),
)
} else {
match ConPtyFuncs::open(path) {
Ok(funcs) => (Some(funcs), None),
Err(error) => (None, Some(format!("Bundled ConPTY load failed: {error:?}"))),
}
}
}
None => (None, None),
};
ConptyState {
system,
bundled,
bundled_disabled: AtomicBool::new(false),
active_bundled: AtomicUsize::new(0),
active_system: AtomicUsize::new(0),
last_used: AtomicU8::new(0),
fallback_reason: Mutex::new(fallback_reason),
}
}
pub struct PsuedoCon {
con: HPCON,
backend: ConptyBackend,
}
unsafe impl Send for PsuedoCon {}
unsafe impl Sync for PsuedoCon {}
impl Drop for PsuedoCon {
fn drop(&mut self) {
let state = CONPTY
.get()
.expect("ConPTY initialized before PTY creation");
let funcs = state.funcs(self.backend);
unsafe { (funcs.ClosePseudoConsole)(self.con) };
state.active(self.backend).fetch_sub(1, Ordering::AcqRel);
}
}
impl ConptyState {
fn funcs(&self, backend: ConptyBackend) -> &ConPtyFuncs {
match backend {
ConptyBackend::Bundled => self.bundled.as_ref().expect("bundled ConPTY is loaded"),
ConptyBackend::System => &self.system,
}
}
fn active(&self, backend: ConptyBackend) -> &AtomicUsize {
match backend {
ConptyBackend::Bundled => &self.active_bundled,
ConptyBackend::System => &self.active_system,
}
}
}
impl PsuedoCon {
pub fn new(size: COORD, input: FileDescriptor, output: FileDescriptor) -> Result<Self, Error> {
let state = CONPTY.get_or_init(load_conpty);
if state.bundled.is_some() && !state.bundled_disabled.load(Ordering::Acquire) {
match Self::create(state.funcs(ConptyBackend::Bundled), size, &input, &output) {
Ok(con) => return Ok(Self::record(con, ConptyBackend::Bundled, state)),
Err(error) => {
state.bundled_disabled.store(true, Ordering::Release);
*state.fallback_reason.lock().unwrap() =
Some(format!("Bundled ConPTY creation failed: {error:#}"));
}
}
}
let con = Self::create(&state.system, size, &input, &output)?;
Ok(Self::record(con, ConptyBackend::System, state))
}
fn record(con: HPCON, backend: ConptyBackend, state: &ConptyState) -> Self {
state.active(backend).fetch_add(1, Ordering::AcqRel);
state.last_used.store(
match backend {
ConptyBackend::Bundled => 1,
ConptyBackend::System => 2,
},
Ordering::Release,
);
Self { con, backend }
}
fn create(
funcs: &ConPtyFuncs,
size: COORD,
input: &FileDescriptor,
output: &FileDescriptor,
) -> Result<HPCON, Error> {
let mut con: HPCON = INVALID_HANDLE_VALUE;
let result = unsafe {
(funcs.CreatePseudoConsole)(
size,
input.as_raw_handle() as _,
output.as_raw_handle() as _,
PSUEDOCONSOLE_INHERIT_CURSOR
| PSEUDOCONSOLE_RESIZE_QUIRK
| PSEUDOCONSOLE_WIN32_INPUT_MODE,
&mut con,
)
};
ensure!(
result == S_OK,
"failed to create psuedo console: HRESULT {}",
result
);
Ok(con)
}
pub fn resize(&self, size: COORD) -> Result<(), Error> {
let state = CONPTY
.get()
.expect("ConPTY initialized before PTY creation");
let result = unsafe { (state.funcs(self.backend).ResizePseudoConsole)(self.con, size) };
ensure!(
result == S_OK,
"failed to resize console to {}x{}: HRESULT: {}",
size.X,
size.Y,
result
);
Ok(())
}
pub fn spawn_command(&self, cmd: CommandBuilder) -> anyhow::Result<WinChild> {
let mut si: STARTUPINFOEXW = unsafe { mem::zeroed() };
si.StartupInfo.cb = mem::size_of::<STARTUPINFOEXW>() as u32;
// Explicitly set the stdio handles as invalid handles otherwise
// we can end up with a weird state where the spawned process can
// inherit the explicitly redirected output handles from its parent.
// For example, when daemonizing wezterm-mux-server, the stdio handles
// are redirected to a log file and the spawned process would end up
// writing its output there instead of to the pty we just created.
si.StartupInfo.dwFlags = STARTF_USESTDHANDLES;
si.StartupInfo.hStdInput = INVALID_HANDLE_VALUE;
si.StartupInfo.hStdOutput = INVALID_HANDLE_VALUE;
si.StartupInfo.hStdError = INVALID_HANDLE_VALUE;
let mut attrs = ProcThreadAttributeList::with_capacity(1)?;
attrs.set_pty(self.con)?;
si.lpAttributeList = attrs.as_mut_ptr();
let mut pi: PROCESS_INFORMATION = unsafe { mem::zeroed() };
let (mut exe, mut cmdline) = cmd.cmdline()?;
let cmd_os = OsString::from_wide(&cmdline);
let cwd = cmd.current_directory();
let res = unsafe {
CreateProcessW(
exe.as_mut_slice().as_mut_ptr(),
cmdline.as_mut_slice().as_mut_ptr(),
ptr::null_mut(),
ptr::null_mut(),
0,
EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT,
cmd.environment_block().as_mut_slice().as_mut_ptr() as *mut _,
cwd.as_ref()
.map(|c| c.as_slice().as_ptr())
.unwrap_or(ptr::null()),
&mut si.StartupInfo,
&mut pi,
)
};
if res == 0 {
let err = IoError::last_os_error();
let msg = format!(
"CreateProcessW `{:?}` in cwd `{:?}` failed: {}",
cmd_os,
cwd.as_ref().map(|c| OsString::from_wide(c)),
err
);
log::error!("{}", msg);
bail!("{}", msg);
}
// Make sure we close out the thread handle so we don't leak it;
// we do this simply by making it owned
let _main_thread = unsafe { OwnedHandle::from_raw_handle(pi.hThread as _) };
let proc = unsafe { OwnedHandle::from_raw_handle(pi.hProcess as _) };
Ok(WinChild {
proc: Mutex::new(proc),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use filedescriptor::Pipe;
unsafe extern "C" fn fail_create(
_size: COORD,
_input: HANDLE,
_output: HANDLE,
_flags: DWORD,
_hpc: *mut HPCON,
) -> HRESULT {
0x8000_4005_u32 as HRESULT
}
#[test]
fn bundled_creation_failure_uses_system_functions() {
let system = ConPtyFuncs::open(Path::new("kernel32.dll")).unwrap();
let mut bundled = ConPtyFuncs::open(Path::new("kernel32.dll")).unwrap();
bundled.CreatePseudoConsole = fail_create;
assert!(CONPTY
.set(ConptyState {
system,
bundled: Some(bundled),
bundled_disabled: AtomicBool::new(false),
active_bundled: AtomicUsize::new(0),
active_system: AtomicUsize::new(0),
last_used: AtomicU8::new(0),
fallback_reason: Mutex::new(None),
})
.is_ok());
let input = Pipe::new().unwrap();
let output = Pipe::new().unwrap();
let pty = PsuedoCon::new(COORD { X: 80, Y: 24 }, input.read, output.write).unwrap();
let status = conpty_status();
assert_eq!(status.last_used, Some(ConptyBackend::System));
assert_eq!(status.active_system, 1);
assert!(status.fallback_reason.unwrap().contains("creation failed"));
drop(pty);
assert_eq!(conpty_status().active_system, 0);
}
}
+21
View File
@@ -71,6 +71,25 @@ export default function AboutDialog({ open, onClose }: AboutDialogProps) {
};
}, [open]);
const conpty = supportInfo?.conpty;
const bundledLabel = conpty ? t("about.conptyBundled", { version: conpty.version }) : "";
const systemLabel = conpty?.fallback ? t("about.conptySystemFallback") : t("about.conptySystem");
const conptyDisplay = !conpty
? null
: !conpty.available
? t("about.conptyUnavailable")
: conpty.activeBundled > 0 && conpty.activeSystem > 0
? t("about.conptyMixed", { version: conpty.version })
: conpty.activeBundled > 0
? bundledLabel
: conpty.activeSystem > 0
? systemLabel
: conpty.lastUsed
? t("about.conptyLastUsed", {
source: conpty.lastUsed === "bundled" ? bundledLabel : systemLabel,
})
: t("about.conptyNotStarted");
const copySupportInfo = async () => {
if (!appVersion || !supportInfo) {
return;
@@ -82,6 +101,7 @@ export default function AboutDialog({ open, onClose }: AboutDialogProps) {
`Operating System: ${supportInfo.os}`,
`Application Architecture: ${supportInfo.architecture}`,
`Runtime: ${supportInfo.runtime === "portable" ? t("about.portable") : t("about.installed")}`,
...(conptyDisplay ? [`Local Terminal ConPTY: ${conptyDisplay}`] : []),
].join("\n");
try {
await writeClipboardText(text);
@@ -113,6 +133,7 @@ export default function AboutDialog({ open, onClose }: AboutDialogProps) {
[t("about.operatingSystem"), osDisplay],
[t("about.architecture"), architectureDisplay],
[t("about.runtime"), runtimeDisplay],
...(conptyDisplay ? [[t("about.conpty"), conptyDisplay]] : []),
];
return (
+8
View File
@@ -42,6 +42,14 @@ export interface AppSupportInfo {
os: string;
architecture: string;
runtime: "portable" | "installed";
conpty?: {
available: boolean;
activeBundled: number;
activeSystem: number;
lastUsed: "bundled" | "system" | null;
fallback: boolean;
version: string;
};
}
/** AI Agent command execution wrapper profile. */