mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-27 08:01:14 +00:00
feat: add native windows beta support
This commit is contained in:
@@ -11,6 +11,7 @@ on:
|
||||
options:
|
||||
- linux
|
||||
- macos
|
||||
- windows
|
||||
- all
|
||||
libghostty_optimize:
|
||||
description: libghostty-vt optimize mode for this build
|
||||
@@ -179,3 +180,55 @@ jobs:
|
||||
${{ matrix.name }}
|
||||
BUILD_INFO.txt
|
||||
retention-days: 7
|
||||
|
||||
build-windows:
|
||||
if: ${{ inputs.build_group == 'windows' || inputs.build_group == 'all' }}
|
||||
runs-on: windows-latest
|
||||
env:
|
||||
LIBGHOSTTY_VT_OPTIMIZE: ${{ inputs.libghostty_optimize }}
|
||||
LIBGHOSTTY_VT_SIMD: ${{ inputs.libghostty_simd }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: x86_64-pc-windows-msvc
|
||||
|
||||
- name: Install Zig
|
||||
uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1
|
||||
with:
|
||||
version: 0.15.2
|
||||
|
||||
- name: Remove Zig caches
|
||||
shell: pwsh
|
||||
run: |
|
||||
Remove-Item -Recurse -Force .zig-cache -ErrorAction SilentlyContinue
|
||||
Remove-Item -Recurse -Force vendor/libghostty-vt/.zig-cache -ErrorAction SilentlyContinue
|
||||
Remove-Item -Recurse -Force vendor/libghostty-vt/zig-out -ErrorAction SilentlyContinue
|
||||
|
||||
- name: Build
|
||||
run: cargo build --release --locked --target x86_64-pc-windows-msvc
|
||||
|
||||
- name: Package artifact
|
||||
shell: pwsh
|
||||
run: |
|
||||
Copy-Item target\x86_64-pc-windows-msvc\release\herdr.exe herdr-windows-x86_64.exe
|
||||
"commit=$env:GITHUB_SHA" | Out-File -Encoding utf8 BUILD_INFO.txt
|
||||
"target=x86_64-pc-windows-msvc" | Out-File -Encoding utf8 -Append BUILD_INFO.txt
|
||||
"libghostty_vt_optimize=$env:LIBGHOSTTY_VT_OPTIMIZE" | Out-File -Encoding utf8 -Append BUILD_INFO.txt
|
||||
"libghostty_vt_simd=$env:LIBGHOSTTY_VT_SIMD" | Out-File -Encoding utf8 -Append BUILD_INFO.txt
|
||||
$hash = (Get-FileHash -Algorithm SHA256 herdr-windows-x86_64.exe).Hash.ToLowerInvariant()
|
||||
"sha256=$hash herdr-windows-x86_64.exe" | Out-File -Encoding utf8 -Append BUILD_INFO.txt
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: herdr-windows-x86_64-${{ inputs.libghostty_optimize }}-simd-${{ inputs.libghostty_simd }}
|
||||
path: |
|
||||
herdr-windows-x86_64.exe
|
||||
BUILD_INFO.txt
|
||||
retention-days: 7
|
||||
|
||||
@@ -76,3 +76,79 @@ jobs:
|
||||
|
||||
- name: Run checks
|
||||
run: just ci '${{ matrix.nextest_filter }}'
|
||||
|
||||
check-windows:
|
||||
name: check (windows)
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: x86_64-pc-windows-msvc
|
||||
|
||||
- name: Install Rust tools
|
||||
uses: taiki-e/install-action@b550161ef8a7bc4f2a671c0b03a18ac9ccedea1e # v2
|
||||
with:
|
||||
tool: cargo-nextest
|
||||
|
||||
- name: Install Zig
|
||||
uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1
|
||||
with:
|
||||
version: 0.15.2
|
||||
|
||||
- name: Restore cargo cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
cache-bin: false
|
||||
key: windows-x86_64-pc-windows-msvc
|
||||
|
||||
- name: Run Windows checks
|
||||
shell: pwsh
|
||||
run: |
|
||||
cargo fmt --check
|
||||
cargo clippy --bin herdr --locked --target x86_64-pc-windows-msvc -- -D warnings
|
||||
cargo nextest run --locked --target x86_64-pc-windows-msvc -E "binary(herdr)" --status-level fail --final-status-level slow --failure-output final --success-output never
|
||||
cargo build --locked --target x86_64-pc-windows-msvc
|
||||
|
||||
- name: Smoke ConPTY pane
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
$env:HERDR_SESSION = "ci-windows-$env:GITHUB_RUN_ID-$env:GITHUB_RUN_ATTEMPT"
|
||||
$exe = Join-Path $PWD "target\x86_64-pc-windows-msvc\debug\herdr.exe"
|
||||
|
||||
& $exe --version
|
||||
& $exe --default-config | Out-Null
|
||||
|
||||
$server = Start-Process -FilePath $exe -ArgumentList "server" -PassThru -WindowStyle Hidden
|
||||
try {
|
||||
$deadline = (Get-Date).AddSeconds(10)
|
||||
do {
|
||||
Start-Sleep -Milliseconds 250
|
||||
$status = & $exe status server 2>&1
|
||||
if ($LASTEXITCODE -eq 0 -and (($status -join "`n") -match "status: running")) {
|
||||
break
|
||||
}
|
||||
} while ((Get-Date) -lt $deadline)
|
||||
|
||||
if ((Get-Date) -ge $deadline) {
|
||||
throw "server did not become ready"
|
||||
}
|
||||
|
||||
& $exe agent start smoke -- "$env:ComSpec" /K dir
|
||||
Start-Sleep -Seconds 2
|
||||
$read = & $exe agent read smoke --source recent --lines 40 --format text
|
||||
$text = $read -join "`n"
|
||||
if ($text -notmatch "Directory of") {
|
||||
throw "pane read did not include cmd.exe directory output: $text"
|
||||
}
|
||||
} finally {
|
||||
& $exe server stop 2>$null
|
||||
Wait-Process -Id $server.Id -Timeout 10 -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
Generated
+33
@@ -352,6 +352,12 @@ dependencies = [
|
||||
"objc2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "doctest-file"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2db04e74f0a9a93103b50e90b96024c9b2bdca8bce6a632ec71b88736d3d359"
|
||||
|
||||
[[package]]
|
||||
name = "document-features"
|
||||
version = "0.2.12"
|
||||
@@ -538,6 +544,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"crossterm",
|
||||
"ctrlc",
|
||||
"interprocess",
|
||||
"libc",
|
||||
"png",
|
||||
"portable-pty",
|
||||
@@ -551,6 +558,7 @@ dependencies = [
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"unicode-width",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -605,6 +613,19 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "interprocess"
|
||||
version = "2.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "069323743400cb7ab06a8fe5c1ed911d36b6919ec531661d034c89083629595b"
|
||||
dependencies = [
|
||||
"doctest-file",
|
||||
"libc",
|
||||
"recvmsg",
|
||||
"widestring",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.14.0"
|
||||
@@ -1209,6 +1230,12 @@ dependencies = [
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "recvmsg"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3edd4d5d42c92f0a659926464d4cce56b562761267ecf0f469d85b7de384175"
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
@@ -2025,6 +2052,12 @@ dependencies = [
|
||||
"wezterm-dynamic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "widestring"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
|
||||
+13
@@ -17,6 +17,7 @@ bincode = { version = "2", features = ["serde"] }
|
||||
bytes = "1"
|
||||
crossterm = "0.29"
|
||||
ctrlc = "3"
|
||||
interprocess = "2.4.2"
|
||||
libc = "0.2"
|
||||
portable-pty = "0.9"
|
||||
png = "0.17"
|
||||
@@ -30,3 +31,15 @@ toml = "0.8"
|
||||
tracing = "0.1.44"
|
||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||
unicode-width = "0.2"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61.2", features = [
|
||||
"Wdk_System_Threading",
|
||||
"Win32_Foundation",
|
||||
"Win32_System_Diagnostics_Debug",
|
||||
"Win32_System_Diagnostics_ToolHelp",
|
||||
"Win32_System_Kernel",
|
||||
"Win32_System_Threading",
|
||||
"Win32_UI_Shell",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
|
||||
@@ -11,6 +11,8 @@ fn zig_target(target: &str) -> &str {
|
||||
"aarch64-unknown-linux-musl" => "aarch64-linux-musl",
|
||||
"x86_64-apple-darwin" => "x86_64-macos",
|
||||
"aarch64-apple-darwin" => "aarch64-macos",
|
||||
"x86_64-pc-windows-msvc" => "x86_64-windows-msvc",
|
||||
"aarch64-pc-windows-msvc" => "aarch64-windows-msvc",
|
||||
other => panic!("unsupported target for libghostty-vt build: {other}"),
|
||||
}
|
||||
}
|
||||
@@ -83,6 +85,8 @@ fn main() {
|
||||
if target.contains("apple-darwin") {
|
||||
let static_lib = lib_dir.join("libghostty-vt.a");
|
||||
println!("cargo:rustc-link-arg={}", static_lib.display());
|
||||
} else if target.contains("windows-msvc") {
|
||||
println!("cargo:rustc-link-lib=static=ghostty-vt-static");
|
||||
} else {
|
||||
println!("cargo:rustc-link-lib=static=ghostty-vt");
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
- Added directional pane swap with `prefix+shift+h/j/k/l`, a pane context-menu swap action, pane layout/neighbor/edge/focus/resize socket APIs, matching CLI commands, and optional `pane split --ratio` support.
|
||||
- Added `herdr pane zoom` and the `pane.zoom` socket API to toggle, set, or clear tab-local pane zoom from scripts and integrations.
|
||||
- Added toast ergonomics controls for delayed agent notifications, in-app toast placement, copied-to-clipboard feedback, and the `notification.show` socket API with `herdr notification show` and optional `none`, `done`, or `request` sounds. (#486)
|
||||
- Added native Windows beta documentation and platform capability tracking for ConPTY panes, semantic client input, Windows agent discovery, known partial cwd behavior, and unsupported Unix-only features such as live handoff, direct terminal attach, and `herdr --remote` from the Windows binary.
|
||||
|
||||
## [0.6.8] - 2026-06-04
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
title: Install Herdr
|
||||
description: Install, update, and verify Herdr on Linux and macOS.
|
||||
description: Install, update, and verify Herdr on Linux, macOS, and Windows beta.
|
||||
---
|
||||
|
||||
Herdr ships as a single binary for Linux and macOS.
|
||||
Herdr ships as a single binary for Linux and macOS. Native Windows support is beta.
|
||||
|
||||
## Install
|
||||
|
||||
@@ -120,6 +120,7 @@ Choose the asset that matches your system:
|
||||
| Linux aarch64 | `herdr-linux-aarch64` |
|
||||
| macOS Intel | `herdr-macos-x86_64` |
|
||||
| macOS Apple silicon | `herdr-macos-aarch64` |
|
||||
| Windows x86_64 beta | `herdr-windows-x86_64.exe` |
|
||||
|
||||
Make it executable and move it somewhere on your PATH.
|
||||
|
||||
@@ -130,4 +131,4 @@ mv herdr-linux-x86_64 ~/.local/bin/herdr
|
||||
|
||||
## Requirements
|
||||
|
||||
Herdr supports Linux and macOS. Native Windows support is not available yet; use Herdr inside WSL for now.
|
||||
Herdr supports Linux and macOS. Native Windows support is beta; see [Windows beta](/docs/windows-beta/) for supported workflows and known limitations.
|
||||
|
||||
@@ -92,6 +92,8 @@ herdr --remote workbox
|
||||
|
||||
Remote attach supports Linux and macOS hosts on x86_64 and aarch64. Herdr checks the remote platform, prefers a matching `herdr` already on the remote `PATH`, then checks `~/.local/bin/herdr`. If no matching binary exists, interactive runs prompt to install one to `~/.local/bin/herdr`; non-interactive runs fail instead of modifying the host. If `~/.local/bin` is not on the remote `PATH`, Herdr warns after install.
|
||||
|
||||
Native Windows `herdr --remote` is not part of the Windows beta. From Windows, SSH into the server and run `herdr` there.
|
||||
|
||||
By default, `herdr --remote` runs the bridge through a temporary SSH config that includes your SSH config first, then adds fallback keepalive settings. Existing user keepalive settings win. Set `[remote].manage_ssh_config = false` to use plain `ssh` without Herdr's generated bridge config.
|
||||
|
||||
By default, remote attach uses the normal restart/stop flow if it needs to replace or restart a running remote server. To opt into experimental live handoff for a supported running remote server, pass `--handoff`:
|
||||
@@ -122,6 +124,8 @@ herdr --remote workbox --session agents
|
||||
|
||||
Full Herdr attach opens the whole workspace UI. Direct attach opens one server-owned terminal in your current terminal.
|
||||
|
||||
Direct terminal attach is Unix-only in the Windows beta.
|
||||
|
||||
Attach by agent target:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
title: Windows beta
|
||||
description: Native Windows support status, supported workflows, and known limitations.
|
||||
---
|
||||
|
||||
Native Windows support is beta. It runs as the same Herdr version as Linux and macOS, but some Unix features are intentionally unavailable on Windows.
|
||||
|
||||
## Supported in beta
|
||||
|
||||
| Capability | Status |
|
||||
| --- | --- |
|
||||
| Local persistent sessions | beta |
|
||||
| Native panes through ConPTY | beta |
|
||||
| Windows Terminal / PowerShell app attach | beta |
|
||||
| `cmd.exe` panes | beta |
|
||||
| Startup cwd and workspace labels | beta |
|
||||
| Pane launch cwd | beta |
|
||||
| Agent command discovery | beta |
|
||||
| Agent self-report integrations | beta |
|
||||
| Agent process-tree detection | beta |
|
||||
| Git/worktree detection from known cwd | beta |
|
||||
| Pane screen history | beta |
|
||||
| Nested launch override | beta |
|
||||
|
||||
Windows agent process detection scans descendants of the pane shell and recognizes direct agents plus common command wrappers. It is useful for Codex, Claude, and similar agents, but it is not the same as Unix foreground process-group detection.
|
||||
|
||||
## Partial support
|
||||
|
||||
| Capability | Status |
|
||||
| --- | --- |
|
||||
| Live cwd after shell `cd` | partial |
|
||||
| Live cwd via shell integration/OSC7 | beta |
|
||||
| Clipboard image paste to agents | unverified |
|
||||
| CJK hidden-cursor reveal | beta |
|
||||
| Kitty graphics rendering | unverified |
|
||||
|
||||
Herdr can launch panes in the right directory and can create the initial workspace from the directory where you started Herdr. PowerShell directory changes after startup are different: the process field Herdr can inspect does not reliably track later logical `cd` changes. Use Herdr integrations or prompt shell integration for live cwd reporting.
|
||||
|
||||
Windows Terminal may support image paste paths for specific agents, but Herdr's own clipboard-image reader is not wired on Windows yet. Treat `alt+v` image paste as unverified until the Windows clipboard bridge is implemented and tested. Remote clipboard image bridging is separate and remains tied to Unix/macOS `herdr --remote`.
|
||||
|
||||
Kitty graphics remains experimental and is not claimed as Windows-supported yet. Leave `experimental.kitty_graphics = false` unless you are specifically testing image rendering in Windows Terminal.
|
||||
|
||||
## Not supported on Windows beta
|
||||
|
||||
| Capability | Status |
|
||||
| --- | --- |
|
||||
| Direct terminal attach | unsupported |
|
||||
| `herdr --remote` from the Windows binary | unsupported |
|
||||
| Live server handoff | unsupported |
|
||||
| Unix file-descriptor handoff | unsupported |
|
||||
| Unix foreground process groups | unsupported |
|
||||
| Remote clipboard image bridge | unsupported |
|
||||
| Prefix input-source switching | unsupported |
|
||||
| Signed binary / SmartScreen avoidance | unsupported |
|
||||
|
||||
For remote work from Windows, SSH into the server and run `herdr` there:
|
||||
|
||||
```powershell
|
||||
ssh you@server
|
||||
herdr
|
||||
```
|
||||
|
||||
That mode runs Herdr on the remote host. Native Windows `herdr --remote` is not part of the beta.
|
||||
|
||||
Windows updates may require stopping the running Herdr server and starting it again. Live handoff is Unix-only.
|
||||
|
||||
## Reporting Windows beta issues
|
||||
|
||||
Include:
|
||||
|
||||
- Herdr version.
|
||||
- Windows version.
|
||||
- Terminal app.
|
||||
- Shell, such as PowerShell or cmd.
|
||||
- Whether you used a named `HERDR_SESSION`.
|
||||
- Relevant Herdr logs.
|
||||
- Exact steps to reproduce.
|
||||
+31
-22
@@ -184,8 +184,17 @@ fn valid_session_path(value: &str) -> bool {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn absolute_test_path(name: &str) -> String {
|
||||
std::env::current_dir()
|
||||
.unwrap()
|
||||
.join(name)
|
||||
.display()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planner_allows_supported_agents() {
|
||||
let pi_session = absolute_test_path("pi-session.jsonl");
|
||||
assert_eq!(
|
||||
plan(
|
||||
"herdr:claude",
|
||||
@@ -230,11 +239,11 @@ mod tests {
|
||||
plan(
|
||||
"herdr:pi",
|
||||
"pi",
|
||||
&AgentSessionRef::path("/tmp/pi-session.jsonl").unwrap()
|
||||
&AgentSessionRef::path(&pi_session).unwrap()
|
||||
)
|
||||
.unwrap()
|
||||
.argv,
|
||||
vec!["pi", "--session", "/tmp/pi-session.jsonl"]
|
||||
vec!["pi", "--session", pi_session.as_str()]
|
||||
);
|
||||
assert_eq!(
|
||||
plan(
|
||||
@@ -260,6 +269,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn planner_rejects_custom_and_unsupported_path_refs() {
|
||||
let claude_session = absolute_test_path("claude-session");
|
||||
assert!(plan(
|
||||
"custom:claude",
|
||||
"claude",
|
||||
@@ -269,22 +279,25 @@ mod tests {
|
||||
assert!(plan(
|
||||
"herdr:claude",
|
||||
"claude",
|
||||
&AgentSessionRef::path("/tmp/claude-session").unwrap()
|
||||
&AgentSessionRef::path(&claude_session).unwrap()
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_ref_prefers_pi_path_and_validates_values() {
|
||||
let pi_session = absolute_test_path("pi-session.jsonl");
|
||||
let claude_session = absolute_test_path("claude-session");
|
||||
let copilot_session = absolute_test_path("copilot-session");
|
||||
let session_ref = session_ref_from_report(
|
||||
"herdr:pi",
|
||||
"pi",
|
||||
Some("pi-id".into()),
|
||||
Some("/tmp/pi-session.jsonl".into()),
|
||||
Some(pi_session.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(session_ref.kind, AgentSessionRefKind::Path);
|
||||
assert_eq!(session_ref.value, "/tmp/pi-session.jsonl");
|
||||
assert_eq!(session_ref.value, pi_session);
|
||||
|
||||
assert!(session_ref_from_report("herdr:pi", "pi", Some("bad\nid".into()), None).is_none());
|
||||
assert!(
|
||||
@@ -292,26 +305,19 @@ mod tests {
|
||||
.is_none()
|
||||
);
|
||||
assert!(session_ref_from_report("custom:pi", "pi", Some("pi-id".into()), None).is_none());
|
||||
assert!(session_ref_from_report(
|
||||
"herdr:claude",
|
||||
"claude",
|
||||
None,
|
||||
Some("/tmp/claude-session".into())
|
||||
)
|
||||
.is_none());
|
||||
assert!(
|
||||
session_ref_from_report("herdr:claude", "claude", None, Some(claude_session)).is_none()
|
||||
);
|
||||
|
||||
let session_ref =
|
||||
session_ref_from_report("herdr:copilot", "copilot", Some("copilot-id".into()), None)
|
||||
.unwrap();
|
||||
assert_eq!(session_ref.kind, AgentSessionRefKind::Id);
|
||||
assert_eq!(session_ref.value, "copilot-id");
|
||||
assert!(session_ref_from_report(
|
||||
"herdr:copilot",
|
||||
"copilot",
|
||||
None,
|
||||
Some("/tmp/copilot-session".into())
|
||||
)
|
||||
.is_none());
|
||||
assert!(
|
||||
session_ref_from_report("herdr:copilot", "copilot", None, Some(copilot_session))
|
||||
.is_none()
|
||||
);
|
||||
|
||||
let session_ref =
|
||||
session_ref_from_report("herdr:droid", "droid", Some("droid-id".into()), None).unwrap();
|
||||
@@ -343,22 +349,25 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn planner_rejects_path_refs_for_id_only_agents() {
|
||||
let hermes_session = absolute_test_path("hermes-session");
|
||||
let opencode_session = absolute_test_path("opencode-session");
|
||||
let copilot_session = absolute_test_path("copilot-session");
|
||||
assert!(plan(
|
||||
"herdr:hermes",
|
||||
"hermes",
|
||||
&AgentSessionRef::path("/tmp/hermes-session").unwrap()
|
||||
&AgentSessionRef::path(&hermes_session).unwrap()
|
||||
)
|
||||
.is_none());
|
||||
assert!(plan(
|
||||
"herdr:opencode",
|
||||
"opencode",
|
||||
&AgentSessionRef::path("/tmp/opencode-session").unwrap()
|
||||
&AgentSessionRef::path(&opencode_session).unwrap()
|
||||
)
|
||||
.is_none());
|
||||
assert!(plan(
|
||||
"herdr:copilot",
|
||||
"copilot",
|
||||
&AgentSessionRef::path("/tmp/copilot-session").unwrap()
|
||||
&AgentSessionRef::path(&copilot_session).unwrap()
|
||||
)
|
||||
.is_none());
|
||||
assert!(session_ref_from_snapshot(
|
||||
|
||||
+33
-10
@@ -1,15 +1,16 @@
|
||||
use std::fmt;
|
||||
use std::io::{self, BufRead, BufReader, Write};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use interprocess::local_socket::traits::Stream as _;
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
use crate::api::schema::{
|
||||
ErrorResponse, EventsSubscribeParams, Method, PingParams, Request, ResponseResult,
|
||||
SubscriptionEventEnvelope, SuccessResponse,
|
||||
};
|
||||
use crate::ipc::LocalStream;
|
||||
|
||||
/// API connection target resolved by clients at the process edge.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -66,8 +67,8 @@ impl ApiClient {
|
||||
timeout: Duration,
|
||||
) -> Result<serde_json::Value, ApiClientError> {
|
||||
let mut stream = self.connect()?;
|
||||
stream.set_write_timeout(Some(timeout))?;
|
||||
stream.set_read_timeout(Some(timeout))?;
|
||||
set_timeout_best_effort(&stream, TimeoutKind::Send, timeout)?;
|
||||
set_timeout_best_effort(&stream, TimeoutKind::Recv, timeout)?;
|
||||
write_request(&mut stream, request)?;
|
||||
|
||||
let mut reader = BufReader::new(stream);
|
||||
@@ -97,7 +98,7 @@ impl ApiClient {
|
||||
let mut stream = self.connect()?;
|
||||
write_request(&mut stream, request)?;
|
||||
if let Some(timeout) = read_timeout {
|
||||
stream.set_read_timeout(Some(timeout))?;
|
||||
set_timeout_best_effort(&stream, TimeoutKind::Recv, timeout)?;
|
||||
}
|
||||
|
||||
let mut reader = BufReader::new(stream);
|
||||
@@ -124,13 +125,35 @@ impl ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
fn connect(&self) -> io::Result<UnixStream> {
|
||||
UnixStream::connect(self.socket_path())
|
||||
fn connect(&self) -> io::Result<LocalStream> {
|
||||
crate::ipc::connect_local_stream(&self.socket_path())
|
||||
}
|
||||
}
|
||||
|
||||
enum TimeoutKind {
|
||||
Send,
|
||||
Recv,
|
||||
}
|
||||
|
||||
fn set_timeout_best_effort(
|
||||
stream: &LocalStream,
|
||||
kind: TimeoutKind,
|
||||
timeout: Duration,
|
||||
) -> io::Result<()> {
|
||||
let result = match kind {
|
||||
TimeoutKind::Send => stream.set_send_timeout(Some(timeout)),
|
||||
TimeoutKind::Recv => stream.set_recv_timeout(Some(timeout)),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => Ok(()),
|
||||
#[cfg(windows)]
|
||||
Err(err) if err.kind() == io::ErrorKind::Unsupported => Ok(()),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
pub struct EventStream {
|
||||
reader: BufReader<UnixStream>,
|
||||
reader: BufReader<LocalStream>,
|
||||
}
|
||||
|
||||
impl EventStream {
|
||||
@@ -181,7 +204,7 @@ impl From<serde_json::Error> for ApiClientError {
|
||||
}
|
||||
}
|
||||
|
||||
fn write_request(stream: &mut UnixStream, request: &Request) -> Result<(), ApiClientError> {
|
||||
fn write_request(stream: &mut LocalStream, request: &Request) -> Result<(), ApiClientError> {
|
||||
stream.write_all(serde_json::to_string(request)?.as_bytes())?;
|
||||
stream.write_all(b"\n")?;
|
||||
stream.flush()?;
|
||||
@@ -189,7 +212,7 @@ fn write_request(stream: &mut UnixStream, request: &Request) -> Result<(), ApiCl
|
||||
}
|
||||
|
||||
fn read_json_line<T: DeserializeOwned>(
|
||||
reader: &mut BufReader<UnixStream>,
|
||||
reader: &mut BufReader<LocalStream>,
|
||||
) -> Result<T, ApiClientError> {
|
||||
let mut line = String::new();
|
||||
let read = reader.read_line(&mut line)?;
|
||||
@@ -200,7 +223,7 @@ fn read_json_line<T: DeserializeOwned>(
|
||||
}
|
||||
|
||||
fn read_optional_json_line<T: DeserializeOwned>(
|
||||
reader: &mut BufReader<UnixStream>,
|
||||
reader: &mut BufReader<LocalStream>,
|
||||
) -> Result<Option<T>, ApiClientError> {
|
||||
let mut line = String::new();
|
||||
let read = reader.read_line(&mut line)?;
|
||||
|
||||
+39
-21
@@ -1,13 +1,13 @@
|
||||
use std::io::{self, Read, Write};
|
||||
use std::os::unix::net::{UnixListener, UnixStream};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use interprocess::local_socket::traits::{ListenerExt as _, Stream as _};
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, unix))]
|
||||
use std::fs;
|
||||
|
||||
use crate::api::schema::{
|
||||
@@ -16,7 +16,10 @@ use crate::api::schema::{
|
||||
use crate::api::subscriptions::ActiveSubscription;
|
||||
use crate::api::wait::wait_for_output;
|
||||
use crate::api::{request_changes_ui, socket_path, ApiRequestMessage, ApiRequestSender, EventHub};
|
||||
use crate::ipc::{remove_socket_file_if_owned, socket_file_identity, SocketFileIdentity};
|
||||
use crate::ipc::{
|
||||
bind_local_listener, remove_socket_file_if_owned, socket_file_identity, LocalStream,
|
||||
SocketFileIdentity,
|
||||
};
|
||||
|
||||
const SOCKET_PERMISSION_MODE: u32 = 0o600;
|
||||
pub(super) const CONNECTION_POLL_INTERVAL: Duration = Duration::from_millis(100);
|
||||
@@ -46,7 +49,7 @@ impl Drop for ServerHandle {
|
||||
|
||||
impl ServerHandle {
|
||||
pub(crate) fn remove_socket_file_if_owned(&self) -> std::io::Result<()> {
|
||||
remove_socket_file_if_owned(&self.path, self.identity)
|
||||
remove_socket_file_if_owned(&self.path, &self.identity)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +60,9 @@ pub fn start_server(
|
||||
start_server_with_capabilities(
|
||||
api_tx,
|
||||
event_hub,
|
||||
Some(ServerCapabilities { live_handoff: true }),
|
||||
Some(ServerCapabilities {
|
||||
live_handoff: crate::platform::capabilities().live_handoff,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -69,7 +74,7 @@ pub fn start_server_with_capabilities(
|
||||
let path = socket_path();
|
||||
prepare_socket_path(&path)?;
|
||||
|
||||
let listener = UnixListener::bind(&path)?;
|
||||
let listener = bind_local_listener(&path)?;
|
||||
restrict_socket_permissions(&path)?;
|
||||
let identity = socket_file_identity(&path)?;
|
||||
info!(path = %path.display(), "api server listening");
|
||||
@@ -127,13 +132,13 @@ fn restrict_socket_permissions(path: &Path) -> std::io::Result<()> {
|
||||
}
|
||||
|
||||
fn handle_connection(
|
||||
mut stream: UnixStream,
|
||||
mut stream: LocalStream,
|
||||
api_tx: &ApiRequestSender,
|
||||
event_hub: &EventHub,
|
||||
running: &Arc<AtomicBool>,
|
||||
capabilities: Option<ServerCapabilities>,
|
||||
) -> std::io::Result<()> {
|
||||
if let Err(err) = stream.set_write_timeout(Some(STREAM_WRITE_TIMEOUT)) {
|
||||
if let Err(err) = stream.set_send_timeout(Some(STREAM_WRITE_TIMEOUT)) {
|
||||
debug!(err = %err, "api connection write timeout unavailable");
|
||||
}
|
||||
|
||||
@@ -340,7 +345,7 @@ fn api_response_outcome(response: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn read_initial_request_line(stream: &mut UnixStream) -> std::io::Result<Option<String>> {
|
||||
fn read_initial_request_line(stream: &mut LocalStream) -> std::io::Result<Option<String>> {
|
||||
stream.set_nonblocking(true)?;
|
||||
let deadline = Instant::now() + INITIAL_REQUEST_TIMEOUT;
|
||||
let mut bytes = Vec::new();
|
||||
@@ -387,7 +392,7 @@ fn read_initial_request_line(stream: &mut UnixStream) -> std::io::Result<Option<
|
||||
}
|
||||
|
||||
fn stream_subscriptions(
|
||||
mut stream: UnixStream,
|
||||
mut stream: LocalStream,
|
||||
request_id: String,
|
||||
params: crate::api::schema::EventsSubscribeParams,
|
||||
api_tx: &ApiRequestSender,
|
||||
@@ -444,27 +449,30 @@ fn stream_subscriptions(
|
||||
}
|
||||
}
|
||||
|
||||
fn write_text_line(stream: &mut UnixStream, value: &str) -> std::io::Result<()> {
|
||||
fn write_text_line(stream: &mut LocalStream, value: &str) -> std::io::Result<()> {
|
||||
stream.write_all(value.as_bytes())?;
|
||||
stream.write_all(b"\n")?;
|
||||
stream.flush()
|
||||
}
|
||||
|
||||
fn write_text_line_allow_disconnect(stream: &mut UnixStream, value: &str) -> std::io::Result<()> {
|
||||
fn write_text_line_allow_disconnect(stream: &mut LocalStream, value: &str) -> std::io::Result<()> {
|
||||
match write_text_line(stream, value) {
|
||||
Err(err) if is_connection_closed_error(&err) => Ok(()),
|
||||
result => result,
|
||||
}
|
||||
}
|
||||
|
||||
fn write_json_line<T: serde::Serialize>(stream: &mut UnixStream, value: &T) -> std::io::Result<()> {
|
||||
fn write_json_line<T: serde::Serialize>(
|
||||
stream: &mut LocalStream,
|
||||
value: &T,
|
||||
) -> std::io::Result<()> {
|
||||
let encoded = serde_json::to_string(value)
|
||||
.map_err(|err| std::io::Error::other(format!("failed to encode json: {err}")))?;
|
||||
write_text_line(stream, &encoded)
|
||||
}
|
||||
|
||||
fn write_json_line_allow_disconnect<T: serde::Serialize>(
|
||||
stream: &mut UnixStream,
|
||||
stream: &mut LocalStream,
|
||||
value: &T,
|
||||
) -> std::io::Result<()> {
|
||||
let encoded = serde_json::to_string(value)
|
||||
@@ -473,7 +481,7 @@ fn write_json_line_allow_disconnect<T: serde::Serialize>(
|
||||
}
|
||||
|
||||
pub(super) fn should_stop_connection(
|
||||
stream: &mut UnixStream,
|
||||
stream: &mut LocalStream,
|
||||
running: &Arc<AtomicBool>,
|
||||
) -> std::io::Result<bool> {
|
||||
if !running.load(Ordering::Relaxed) {
|
||||
@@ -483,7 +491,7 @@ pub(super) fn should_stop_connection(
|
||||
probe_stream_closed(stream)
|
||||
}
|
||||
|
||||
fn probe_stream_closed(stream: &mut UnixStream) -> std::io::Result<bool> {
|
||||
fn probe_stream_closed(stream: &mut LocalStream) -> std::io::Result<bool> {
|
||||
stream.set_nonblocking(true)?;
|
||||
let mut probe = [0u8; 1];
|
||||
let status = match stream.read(&mut probe) {
|
||||
@@ -581,11 +589,13 @@ fn error_response_json(id: String, code: &str, message: String) -> String {
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use interprocess::local_socket::traits::Listener as _;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::os::unix::net::UnixListener;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
@@ -602,13 +612,21 @@ mod tests {
|
||||
std::env::temp_dir().join(format!("herdr-{name}-{}-{nanos}", std::process::id()))
|
||||
}
|
||||
|
||||
fn read_line(stream: &mut UnixStream) -> String {
|
||||
fn read_line(stream: &mut LocalStream) -> String {
|
||||
let mut reader = BufReader::new(stream);
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
line
|
||||
}
|
||||
|
||||
fn local_stream_pair(name: &str) -> (LocalStream, LocalStream, PathBuf) {
|
||||
let path = unique_test_path(name);
|
||||
let listener = crate::ipc::bind_local_listener(&path).unwrap();
|
||||
let client = crate::ipc::connect_local_stream(&path).unwrap();
|
||||
let server = listener.accept().unwrap();
|
||||
(client, server, path)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn socket_path_prefers_explicit_env_override() {
|
||||
let _guard = env_lock().lock().unwrap();
|
||||
@@ -770,7 +788,7 @@ mod tests {
|
||||
}
|
||||
});
|
||||
|
||||
let (mut client, server) = UnixStream::pair().unwrap();
|
||||
let (mut client, server, _path) = local_stream_pair("api-wait-disconnect");
|
||||
client
|
||||
.write_all(br#"{"id":"req_wait","method":"pane.wait_for_output","params":{"pane_id":"pane_1","source":"recent","match":{"type":"substring","value":"never"}}}"#)
|
||||
.unwrap();
|
||||
@@ -800,7 +818,7 @@ mod tests {
|
||||
#[test]
|
||||
fn subscriptions_stop_when_client_disconnects() {
|
||||
let (api_tx, _api_rx) = mpsc::unbounded_channel::<ApiRequestMessage>();
|
||||
let (mut client, server) = UnixStream::pair().unwrap();
|
||||
let (mut client, server, _path) = local_stream_pair("api-sub-disconnect");
|
||||
client
|
||||
.write_all(
|
||||
br#"{"id":"sub_1","method":"events.subscribe","params":{"subscriptions":[{"type":"workspace.created"}]}}"#,
|
||||
@@ -832,7 +850,7 @@ mod tests {
|
||||
#[test]
|
||||
fn subscriptions_stop_when_server_shuts_down() {
|
||||
let (api_tx, _api_rx) = mpsc::unbounded_channel::<ApiRequestMessage>();
|
||||
let (mut client, server) = UnixStream::pair().unwrap();
|
||||
let (mut client, server, _path) = local_stream_pair("api-sub-shutdown");
|
||||
client
|
||||
.write_all(
|
||||
br#"{"id":"sub_2","method":"events.subscribe","params":{"subscriptions":[{"type":"workspace.created"}]}}"#,
|
||||
|
||||
+2
-2
@@ -1,4 +1,3 @@
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -13,11 +12,12 @@ use crate::api::server::{
|
||||
};
|
||||
use crate::api::subscriptions::{match_output, output_match_read_source};
|
||||
use crate::api::ApiRequestSender;
|
||||
use crate::ipc::LocalStream;
|
||||
|
||||
pub(super) fn wait_for_output(
|
||||
request_id: String,
|
||||
params: crate::api::schema::PaneWaitForOutputParams,
|
||||
stream: &mut UnixStream,
|
||||
stream: &mut LocalStream,
|
||||
api_tx: &ApiRequestSender,
|
||||
running: &Arc<AtomicBool>,
|
||||
) -> std::io::Result<Option<String>> {
|
||||
|
||||
+49
-2
@@ -2334,6 +2334,25 @@ impl AppState {
|
||||
// Intercepted in App::handle_internal_event before reaching this
|
||||
// dispatch; never touches AppState.
|
||||
AppEvent::ClipboardWrite { .. } => Vec::new(),
|
||||
AppEvent::TerminalCwdReported { pane_id, cwd } => {
|
||||
if !cwd.is_absolute() || !cwd.is_dir() {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(terminal_id) = self.workspaces.iter().find_map(|ws| {
|
||||
ws.pane_state(pane_id)
|
||||
.map(|pane| pane.attached_terminal_id.clone())
|
||||
}) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(terminal) = self.terminals.get_mut(&terminal_id) else {
|
||||
return Vec::new();
|
||||
};
|
||||
if terminal.cwd != cwd {
|
||||
terminal.cwd = cwd;
|
||||
self.mark_session_dirty();
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
AppEvent::GitStatusRefreshed {
|
||||
results,
|
||||
cache_updates,
|
||||
@@ -4282,6 +4301,9 @@ mod tests {
|
||||
fn hidden_session_ref_only_update_marks_session_dirty_without_visible_update() {
|
||||
let mut state = app_with_workspaces(&["active"]);
|
||||
let pane_id = *state.workspaces[0].panes.keys().next().unwrap();
|
||||
let test_dir = std::env::current_dir().unwrap();
|
||||
let first_session = test_dir.join("one.jsonl").display().to_string();
|
||||
let second_session = test_dir.join("two.jsonl").display().to_string();
|
||||
|
||||
let first_updates = state.handle_app_event(AppEvent::HookStateReported {
|
||||
pane_id,
|
||||
@@ -4291,7 +4313,7 @@ mod tests {
|
||||
message: None,
|
||||
custom_status: None,
|
||||
seq: Some(20),
|
||||
session_ref: crate::agent_resume::AgentSessionRef::path("/tmp/one.jsonl"),
|
||||
session_ref: crate::agent_resume::AgentSessionRef::path(first_session),
|
||||
});
|
||||
assert_eq!(first_updates.len(), 1);
|
||||
state.session_dirty = false;
|
||||
@@ -4304,13 +4326,38 @@ mod tests {
|
||||
message: None,
|
||||
custom_status: None,
|
||||
seq: Some(21),
|
||||
session_ref: crate::agent_resume::AgentSessionRef::path("/tmp/two.jsonl"),
|
||||
session_ref: crate::agent_resume::AgentSessionRef::path(second_session),
|
||||
});
|
||||
|
||||
assert!(second_updates.is_empty());
|
||||
assert!(state.session_dirty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_cwd_report_updates_terminal_cwd_and_marks_session_dirty() {
|
||||
let mut state = app_with_workspaces(&["active"]);
|
||||
let pane_id = *state.workspaces[0].panes.keys().next().unwrap();
|
||||
let terminal_id = state.workspaces[0]
|
||||
.pane_state(pane_id)
|
||||
.unwrap()
|
||||
.attached_terminal_id
|
||||
.clone();
|
||||
let cwd =
|
||||
std::env::temp_dir().join(format!("herdr-cwd-report-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&cwd).unwrap();
|
||||
state.session_dirty = false;
|
||||
|
||||
let updates = state.handle_app_event(AppEvent::TerminalCwdReported {
|
||||
pane_id,
|
||||
cwd: cwd.clone(),
|
||||
});
|
||||
|
||||
assert!(updates.is_empty());
|
||||
assert_eq!(state.terminals.get(&terminal_id).unwrap().cwd, cwd);
|
||||
assert!(state.session_dirty);
|
||||
let _ = std::fs::remove_dir_all(cwd);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_idle_sets_finished_toast() {
|
||||
let mut state = app_with_workspaces(&["active", "background"]);
|
||||
|
||||
+29
-11
@@ -339,6 +339,7 @@ fn shell_quote(value: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(unix)]
|
||||
fn test_app() -> App {
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
App::new(
|
||||
@@ -350,6 +351,21 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn long_running_test_argv() -> Vec<String> {
|
||||
vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()]
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn marker_resume_test_argv() -> Vec<String> {
|
||||
vec![
|
||||
"/bin/sh".into(),
|
||||
"-c".into(),
|
||||
"printf '%s' 'restored agent: shell quoted | marker'; sleep 5".into(),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn pending_agent_resume_waits_for_host_theme_before_launch() {
|
||||
let mut app = test_app();
|
||||
@@ -371,11 +387,7 @@ mod tests {
|
||||
.expect("test terminal should exist");
|
||||
terminal.pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan {
|
||||
agent: "codex".into(),
|
||||
argv: vec![
|
||||
"/bin/sh".into(),
|
||||
"-c".into(),
|
||||
"printf '%s' 'restored agent: shell quoted | marker'; sleep 5".into(),
|
||||
],
|
||||
argv: marker_resume_test_argv(),
|
||||
dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(),
|
||||
});
|
||||
|
||||
@@ -432,6 +444,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn pending_agent_resume_can_launch_after_theme_wait_expires() {
|
||||
let mut app = test_app();
|
||||
@@ -451,7 +464,7 @@ mod tests {
|
||||
.expect("test terminal should exist")
|
||||
.pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan {
|
||||
agent: "codex".into(),
|
||||
argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()],
|
||||
argv: long_running_test_argv(),
|
||||
dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(),
|
||||
});
|
||||
|
||||
@@ -465,6 +478,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[tokio::test]
|
||||
async fn pending_agent_resume_launches_hidden_panes_with_current_terminal_area() {
|
||||
let mut app = test_app();
|
||||
@@ -500,7 +514,7 @@ mod tests {
|
||||
.expect("test terminal should exist")
|
||||
.pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan {
|
||||
agent: "codex".into(),
|
||||
argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()],
|
||||
argv: long_running_test_argv(),
|
||||
dedupe_key: format!("herdr:codex\0codex\0Id\0{terminal_id}"),
|
||||
});
|
||||
}
|
||||
@@ -520,6 +534,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[tokio::test]
|
||||
async fn pending_agent_resume_launches_inactive_tab_panes_with_current_terminal_area() {
|
||||
let mut app = test_app();
|
||||
@@ -562,7 +577,7 @@ mod tests {
|
||||
.expect("inactive tab terminal should exist")
|
||||
.pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan {
|
||||
agent: "codex".into(),
|
||||
argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()],
|
||||
argv: long_running_test_argv(),
|
||||
dedupe_key: "herdr:codex\0codex\0Id\0inactive-tab-session".into(),
|
||||
});
|
||||
|
||||
@@ -583,6 +598,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[tokio::test]
|
||||
async fn pending_agent_resume_launches_zoom_hidden_active_tab_panes() {
|
||||
let mut app = test_app();
|
||||
@@ -620,7 +636,7 @@ mod tests {
|
||||
.expect("hidden zoom pane terminal should exist")
|
||||
.pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan {
|
||||
agent: "codex".into(),
|
||||
argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()],
|
||||
argv: long_running_test_argv(),
|
||||
dedupe_key: "herdr:codex\0codex\0Id\0zoom-hidden-session".into(),
|
||||
});
|
||||
|
||||
@@ -641,6 +657,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[tokio::test]
|
||||
async fn pending_agent_resume_uses_current_terminal_area_for_background_panes() {
|
||||
let mut app = test_app();
|
||||
@@ -676,7 +693,7 @@ mod tests {
|
||||
.expect("test terminal should exist")
|
||||
.pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan {
|
||||
agent: "codex".into(),
|
||||
argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()],
|
||||
argv: long_running_test_argv(),
|
||||
dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(),
|
||||
});
|
||||
|
||||
@@ -699,6 +716,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn pending_agent_resume_launches_with_inner_rect_size() {
|
||||
let mut app = test_app();
|
||||
@@ -734,7 +752,7 @@ mod tests {
|
||||
.expect("test terminal should exist")
|
||||
.pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan {
|
||||
agent: "codex".into(),
|
||||
argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()],
|
||||
argv: long_running_test_argv(),
|
||||
dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(),
|
||||
});
|
||||
|
||||
|
||||
@@ -117,8 +117,12 @@ impl App {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let terminal_cwd_reported = matches!(ev, AppEvent::TerminalCwdReported { .. });
|
||||
let previous_toast = self.state.toast.clone();
|
||||
let pane_updates = self.state.handle_app_event(ev);
|
||||
if terminal_cwd_reported {
|
||||
self.mark_git_status_refresh_due(Instant::now());
|
||||
}
|
||||
for update in &pane_updates {
|
||||
self.refresh_new_herdr_toast_context_for_update(update, &previous_toast);
|
||||
self.emit_pane_state_update(update);
|
||||
@@ -811,6 +815,7 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::detect::{Agent, AgentState};
|
||||
|
||||
#[cfg(unix)]
|
||||
fn init_repo(path: &std::path::Path) {
|
||||
let status = std::process::Command::new("git")
|
||||
.args(["init", "-q"])
|
||||
@@ -820,6 +825,7 @@ mod tests {
|
||||
assert!(status.success(), "git init failed for {}", path.display());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn herdr_toast_context_uses_live_root_runtime_cwd_label() {
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
@@ -912,6 +918,7 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(temp_root);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn delayed_herdr_toast_context_uses_live_root_runtime_cwd_label() {
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
|
||||
@@ -898,8 +898,19 @@ mod tests {
|
||||
App::new(&Config::default(), true, None, api_rx, event_hub)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn test_shell() -> &'static str {
|
||||
"C:\\Windows\\System32\\whoami.exe"
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn test_shell() -> &'static str {
|
||||
"/usr/bin/true"
|
||||
}
|
||||
|
||||
fn app_with_parent(repo: &Path) -> App {
|
||||
let mut app = test_app();
|
||||
app.state.default_shell = test_shell().into();
|
||||
let mut parent = Workspace::test_new("main");
|
||||
parent.identity_cwd = repo.to_path_buf();
|
||||
app.state.workspaces = vec![parent];
|
||||
@@ -954,6 +965,9 @@ mod tests {
|
||||
);
|
||||
assert!(workspace.worktree.unwrap().is_linked_worktree);
|
||||
|
||||
for (_, runtime) in app.terminal_runtimes.drain() {
|
||||
runtime.shutdown();
|
||||
}
|
||||
let remove =
|
||||
crate::worktree::build_worktree_remove_command(&repo, Path::new(&worktree.path), false);
|
||||
crate::worktree::run_worktree_command(&remove).unwrap();
|
||||
@@ -968,6 +982,7 @@ mod tests {
|
||||
let event_hub = crate::api::EventHub::default();
|
||||
let mut app = test_app_with_event_hub(event_hub.clone());
|
||||
app.state.worktree_directory = worktree_root.clone();
|
||||
app.state.default_shell = test_shell().into();
|
||||
|
||||
let response = app.handle_api_request(Request {
|
||||
id: "req".into(),
|
||||
@@ -1000,6 +1015,9 @@ mod tests {
|
||||
"auto-created parent workspace event should include parent worktree membership"
|
||||
);
|
||||
|
||||
for (_, runtime) in app.terminal_runtimes.drain() {
|
||||
runtime.shutdown();
|
||||
}
|
||||
let remove =
|
||||
crate::worktree::build_worktree_remove_command(&repo, Path::new(&worktree.path), false);
|
||||
crate::worktree::run_worktree_command(&remove).unwrap();
|
||||
@@ -1230,7 +1248,7 @@ mod tests {
|
||||
let repo = create_committed_repo("api-worktree-open-source-repo");
|
||||
let event_hub = crate::api::EventHub::default();
|
||||
let mut app = test_app_with_event_hub(event_hub.clone());
|
||||
app.state.default_shell = "/usr/bin/true".into();
|
||||
app.state.default_shell = test_shell().into();
|
||||
|
||||
let response = app.handle_api_request(Request {
|
||||
id: "req".into(),
|
||||
|
||||
@@ -526,6 +526,7 @@ fn unique_temp_path(name: &str) -> std::path::PathBuf {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(unix)]
|
||||
fn wait_for_file(path: &std::path::Path) -> String {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
|
||||
while std::time::Instant::now() < deadline {
|
||||
|
||||
@@ -2519,6 +2519,7 @@ mod tests {
|
||||
assert_eq!(app.state.workspaces[0].display_name(), "a");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn keyboard_context_menu_split_keeps_new_runtime() {
|
||||
let mut app = app_for_mouse_test();
|
||||
|
||||
@@ -988,12 +988,15 @@ fn shell_quote(value: &str) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[cfg(unix)]
|
||||
use std::time::Duration;
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::layout::Direction;
|
||||
|
||||
use super::super::{state_with_workspaces, unique_temp_path, wait_for_file};
|
||||
#[cfg(unix)]
|
||||
use super::super::wait_for_file;
|
||||
use super::super::{state_with_workspaces, unique_temp_path};
|
||||
use super::*;
|
||||
use crate::{
|
||||
app::App, config::Config, input::TerminalKey, terminal::TerminalState, workspace::Workspace,
|
||||
@@ -1897,6 +1900,7 @@ last_pane = "prefix+tab"
|
||||
assert_eq!(state.workspaces.len(), 2);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn custom_command_runs_from_prefix_key_in_navigate_mode() {
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
@@ -1946,6 +1950,7 @@ last_pane = "prefix+tab"
|
||||
let _ = std::fs::remove_file(output_path);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn pane_overlay_command_opens_and_closes_after_exit() {
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
@@ -2033,6 +2038,7 @@ last_pane = "prefix+tab"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn edit_scrollback_key_opens_focused_runtime_scrollback_in_editor_pane() {
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
|
||||
@@ -197,9 +197,9 @@ mod tests {
|
||||
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind};
|
||||
use ratatui::layout::Rect;
|
||||
|
||||
use super::super::{
|
||||
app_for_mouse_test, mouse, numbered_lines_bytes, unique_temp_path, wait_for_file,
|
||||
};
|
||||
use super::super::{app_for_mouse_test, mouse, numbered_lines_bytes};
|
||||
#[cfg(unix)]
|
||||
use super::super::{unique_temp_path, wait_for_file};
|
||||
use super::*;
|
||||
use crate::{config::Config, events::AppEvent, workspace::Workspace};
|
||||
|
||||
@@ -738,6 +738,7 @@ mod tests {
|
||||
assert_eq!(app.state.mode, Mode::Terminal);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn terminal_direct_edit_scrollback_opens_editor_pane() {
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
@@ -791,6 +792,7 @@ mod tests {
|
||||
let _ = std::fs::remove_file(output_path);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn direct_custom_command_runs_before_forwarding_to_pane() {
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
@@ -827,6 +829,7 @@ mod tests {
|
||||
let _ = std::fs::remove_file(output_path);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn direct_custom_pane_command_opens_overlay_pane() {
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
|
||||
+13
-3
@@ -1511,6 +1511,16 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn exiting_test_command() -> &'static str {
|
||||
"C:\\Windows\\System32\\whoami.exe"
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn exiting_test_command() -> &'static str {
|
||||
"/usr/bin/true"
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct FakePrefixInputSource {
|
||||
switch_calls: Rc<Cell<usize>>,
|
||||
@@ -3112,7 +3122,7 @@ mod tests {
|
||||
async fn pane_split_request_targets_pane_in_background_tab() {
|
||||
let _guard = config_env_lock().lock().unwrap();
|
||||
let original_shell = std::env::var_os("SHELL");
|
||||
std::env::set_var("SHELL", "/usr/bin/true");
|
||||
std::env::set_var("SHELL", exiting_test_command());
|
||||
|
||||
let mut app = test_app();
|
||||
let mut workspace = Workspace::test_new("api-pane-split-background-tab");
|
||||
@@ -3209,7 +3219,7 @@ mod tests {
|
||||
async fn pane_split_request_focuses_new_pane_when_requested() {
|
||||
let _guard = config_env_lock().lock().unwrap();
|
||||
let original_shell = std::env::var_os("SHELL");
|
||||
std::env::set_var("SHELL", "/usr/bin/true");
|
||||
std::env::set_var("SHELL", exiting_test_command());
|
||||
|
||||
let mut app = test_app();
|
||||
let mut workspace = Workspace::test_new("api-pane-split-focus-background-tab");
|
||||
@@ -3363,7 +3373,7 @@ mod tests {
|
||||
tab_id: None,
|
||||
split: Some(crate::api::schema::SplitDirection::Right),
|
||||
focus: true,
|
||||
argv: vec!["/usr/bin/true".into()],
|
||||
argv: vec![exiting_test_command().into()],
|
||||
}),
|
||||
});
|
||||
let response: serde_json::Value = serde_json::from_str(&response).unwrap();
|
||||
|
||||
@@ -1419,6 +1419,22 @@ impl AppState {
|
||||
self.switch_ascii_input_source_in_prefix
|
||||
}
|
||||
|
||||
pub(crate) fn pane_exposes_host_cursor(
|
||||
&self,
|
||||
ws_idx: usize,
|
||||
pane_id: crate::layout::PaneId,
|
||||
) -> bool {
|
||||
let has_effective_agent_label = self
|
||||
.workspaces
|
||||
.get(ws_idx)
|
||||
.and_then(|ws| ws.terminal_id(pane_id))
|
||||
.and_then(|terminal_id| self.terminals.get(terminal_id))
|
||||
.and_then(crate::terminal::TerminalState::effective_agent_label)
|
||||
.is_some();
|
||||
|
||||
pane_exposes_host_cursor_for_target(has_effective_agent_label, cfg!(windows))
|
||||
}
|
||||
|
||||
pub(crate) fn integration_updates_available(&self) -> bool {
|
||||
self.integration_recommendations
|
||||
.iter()
|
||||
@@ -1734,11 +1750,30 @@ impl AppState {
|
||||
}
|
||||
}
|
||||
|
||||
fn pane_exposes_host_cursor_for_target(
|
||||
has_effective_agent_label: bool,
|
||||
target_is_windows: bool,
|
||||
) -> bool {
|
||||
!target_is_windows || !has_effective_agent_label
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crossterm::event::KeyEvent;
|
||||
|
||||
#[test]
|
||||
fn windows_agent_panes_do_not_expose_host_cursor() {
|
||||
assert!(!pane_exposes_host_cursor_for_target(true, true));
|
||||
assert!(pane_exposes_host_cursor_for_target(false, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_windows_agent_panes_keep_existing_host_cursor_behavior() {
|
||||
assert!(pane_exposes_host_cursor_for_target(true, false));
|
||||
assert!(pane_exposes_host_cursor_for_target(false, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn built_in_theme_names_resolve() {
|
||||
for name in THEME_NAMES {
|
||||
|
||||
+298
-12
@@ -1,22 +1,24 @@
|
||||
//! Stdin input reading for the thin client.
|
||||
//!
|
||||
//! Reads stdin bytes and forwards framed input to the main event loop.
|
||||
//! Unlike the monolithic herdr, the thin client does NOT parse input into
|
||||
//! key/mouse/paste events. It keeps enough byte-framing state to avoid splitting
|
||||
//! terminal control strings, then sends bytes to the server as `ClientMessage::Input`.
|
||||
//! The server handles semantic parsing.
|
||||
//! On Unix, reads stdin bytes and forwards framed input to the main event loop.
|
||||
//! The server handles semantic parsing. On Windows, crossterm may surface
|
||||
//! terminal control strings as character key events, so the reader re-frames
|
||||
//! those control bytes before forwarding semantic client input events.
|
||||
//!
|
||||
//! This is simpler and more reliable because:
|
||||
//! - The server has the same input parsing code
|
||||
//! - We avoid duplicating parsing logic in the client
|
||||
//! - Host terminal control replies can be buffered or discarded before they leak
|
||||
|
||||
use std::io::{self, Read};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[cfg(unix)]
|
||||
use std::io::{self, Read};
|
||||
#[cfg(unix)]
|
||||
use std::os::fd::AsRawFd;
|
||||
#[cfg(windows)]
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use super::ClientLoopEvent;
|
||||
@@ -31,6 +33,15 @@ use super::ClientLoopEvent;
|
||||
/// The main loop receives the raw bytes and forwards them as
|
||||
/// `ClientMessage::Input` to the server.
|
||||
pub fn stdin_reader_loop(event_tx: mpsc::Sender<ClientLoopEvent>, should_quit: &Arc<AtomicBool>) {
|
||||
#[cfg(windows)]
|
||||
return windows_stdin_reader_loop(event_tx, should_quit);
|
||||
|
||||
#[cfg(unix)]
|
||||
unix_stdin_reader_loop(event_tx, should_quit);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn unix_stdin_reader_loop(event_tx: mpsc::Sender<ClientLoopEvent>, should_quit: &Arc<AtomicBool>) {
|
||||
let stdin = io::stdin();
|
||||
let mut reader = stdin.lock();
|
||||
let mut scratch = [0u8; 4096];
|
||||
@@ -70,16 +81,179 @@ pub fn stdin_reader_loop(event_tx: mpsc::Sender<ClientLoopEvent>, should_quit: &
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn windows_stdin_reader_loop(
|
||||
event_tx: mpsc::Sender<ClientLoopEvent>,
|
||||
should_quit: &Arc<AtomicBool>,
|
||||
) {
|
||||
let mut framer = crate::raw_input::RawInputFramer::default();
|
||||
let mut raw_sequence_pending = false;
|
||||
|
||||
while !should_quit.load(Ordering::Acquire) {
|
||||
match crossterm::event::poll(Duration::from_millis(10)) {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
if raw_sequence_pending {
|
||||
tracing::debug!("windows input raw sequence timed out; flushing");
|
||||
if !send_windows_raw_events(framer.flush_timeout(), &event_tx) {
|
||||
return;
|
||||
}
|
||||
raw_sequence_pending = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
|
||||
let event = match crossterm::event::read() {
|
||||
Ok(event) => event,
|
||||
Err(_) => break,
|
||||
};
|
||||
|
||||
if let Some(bytes) = windows_key_raw_bytes(&event, raw_sequence_pending) {
|
||||
tracing::debug!(
|
||||
bytes = ?bytes,
|
||||
pending_before = raw_sequence_pending,
|
||||
"windows input routed through raw framer"
|
||||
);
|
||||
let events = framer.push(&bytes);
|
||||
raw_sequence_pending = events.is_empty();
|
||||
if !send_windows_raw_events(events, &event_tx) {
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if raw_sequence_pending {
|
||||
tracing::debug!("windows input raw sequence interrupted by semantic event; flushing");
|
||||
if !send_windows_raw_events(framer.flush_timeout(), &event_tx) {
|
||||
return;
|
||||
}
|
||||
raw_sequence_pending = false;
|
||||
}
|
||||
|
||||
if windows_event_is_control_key(&event) {
|
||||
tracing::debug!(event = ?event, "windows control key forwarded as semantic input");
|
||||
}
|
||||
|
||||
let Some(event) = crate::protocol::ClientInputEvent::from_crossterm(event) else {
|
||||
continue;
|
||||
};
|
||||
if event_tx
|
||||
.blocking_send(ClientLoopEvent::StdinEvents(vec![event]))
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if raw_sequence_pending {
|
||||
let _ = send_windows_raw_events(framer.flush_timeout(), &event_tx);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn windows_event_is_control_key(event: &crossterm::event::Event) -> bool {
|
||||
use crossterm::event::{Event, KeyModifiers};
|
||||
|
||||
matches!(
|
||||
event,
|
||||
Event::Key(key)
|
||||
if key.modifiers.contains(KeyModifiers::CONTROL)
|
||||
|| matches!(key.code, crossterm::event::KeyCode::Char(ch) if ch.is_control())
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn windows_key_raw_bytes(
|
||||
event: &crossterm::event::Event,
|
||||
raw_sequence_pending: bool,
|
||||
) -> Option<Vec<u8>> {
|
||||
use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers};
|
||||
|
||||
let Event::Key(key) = event else {
|
||||
return None;
|
||||
};
|
||||
if key.kind == KeyEventKind::Release {
|
||||
return None;
|
||||
}
|
||||
|
||||
match key.code {
|
||||
KeyCode::Esc if key.modifiers.is_empty() => Some(vec![0x1b]),
|
||||
KeyCode::Char(ch) if raw_sequence_pending || ch.is_control() => {
|
||||
let mut bytes = Vec::new();
|
||||
if key.modifiers.contains(KeyModifiers::ALT) {
|
||||
bytes.push(0x1b);
|
||||
}
|
||||
let mut buf = [0; 4];
|
||||
bytes.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
|
||||
Some(bytes)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn send_windows_raw_events(
|
||||
events: Vec<crate::raw_input::RawInputEvent>,
|
||||
event_tx: &mpsc::Sender<ClientLoopEvent>,
|
||||
) -> bool {
|
||||
let raw_event_count = events.len();
|
||||
let events = events
|
||||
.into_iter()
|
||||
.filter_map(windows_client_input_event_from_raw)
|
||||
.collect::<Vec<_>>();
|
||||
if events.is_empty() {
|
||||
return true;
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
raw_event_count,
|
||||
forwarded_event_count = events.len(),
|
||||
"windows raw-framed input events forwarded"
|
||||
);
|
||||
event_tx
|
||||
.blocking_send(ClientLoopEvent::StdinEvents(events))
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn windows_client_input_event_from_raw(
|
||||
event: crate::raw_input::RawInputEvent,
|
||||
) -> Option<crate::protocol::ClientInputEvent> {
|
||||
match event {
|
||||
crate::raw_input::RawInputEvent::Key(key) => Some(crate::protocol::ClientInputEvent::Key {
|
||||
code: crate::protocol::ClientKeyCode::from_crossterm(key.code)?,
|
||||
modifiers: key.modifiers.bits(),
|
||||
kind: crate::protocol::ClientKeyKind::from_crossterm(key.kind),
|
||||
}),
|
||||
crate::raw_input::RawInputEvent::Mouse(mouse) => {
|
||||
Some(crate::protocol::ClientInputEvent::Mouse {
|
||||
kind: crate::protocol::ClientMouseKind::from_crossterm(mouse.kind)?,
|
||||
column: mouse.column,
|
||||
row: mouse.row,
|
||||
modifiers: mouse.modifiers.bits(),
|
||||
})
|
||||
}
|
||||
crate::raw_input::RawInputEvent::Paste(text) => {
|
||||
Some(crate::protocol::ClientInputEvent::Paste { text })
|
||||
}
|
||||
crate::raw_input::RawInputEvent::OuterFocusGained => {
|
||||
Some(crate::protocol::ClientInputEvent::FocusGained)
|
||||
}
|
||||
crate::raw_input::RawInputEvent::OuterFocusLost => {
|
||||
Some(crate::protocol::ClientInputEvent::FocusLost)
|
||||
}
|
||||
crate::raw_input::RawInputEvent::HostDefaultColor { .. }
|
||||
| crate::raw_input::RawInputEvent::Unsupported => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn stdin_read_ready<R: AsRawFd>(reader: &R, timeout_ms: i32) -> Option<bool> {
|
||||
poll_read_ready(reader.as_raw_fd(), timeout_ms)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn stdin_read_ready<R>(_reader: &R, _timeout_ms: i32) -> Option<bool> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn poll_read_ready(fd: i32, timeout_ms: i32) -> Option<bool> {
|
||||
#[repr(C)]
|
||||
@@ -113,7 +287,7 @@ fn poll_read_ready(fd: i32, timeout_ms: i32) -> Option<bool> {
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
// The stdin reader thread is hard to unit test since it reads from actual stdin.
|
||||
// Integration tests will verify the full client→server input flow.
|
||||
@@ -121,6 +295,7 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn stdin_input_event_carries_raw_bytes() {
|
||||
let data = vec![0x1b, b'[', b'A']; // Up arrow escape sequence
|
||||
@@ -131,3 +306,114 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, windows))]
|
||||
mod windows_tests {
|
||||
use super::*;
|
||||
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
#[test]
|
||||
fn windows_control_chars_are_reframed_as_raw_bytes() {
|
||||
let escape = Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()));
|
||||
assert_eq!(
|
||||
windows_key_raw_bytes(&escape, false).as_deref(),
|
||||
Some(b"\x1b".as_slice())
|
||||
);
|
||||
|
||||
let enter = Event::Key(KeyEvent::new(KeyCode::Char('\r'), KeyModifiers::empty()));
|
||||
assert_eq!(
|
||||
windows_key_raw_bytes(&enter, false).as_deref(),
|
||||
Some(b"\r".as_slice())
|
||||
);
|
||||
|
||||
let printable = Event::Key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::empty()));
|
||||
assert_eq!(windows_key_raw_bytes(&printable, false), None);
|
||||
|
||||
let pending_arrow_tail =
|
||||
Event::Key(KeyEvent::new(KeyCode::Char('['), KeyModifiers::empty()));
|
||||
assert_eq!(
|
||||
windows_key_raw_bytes(&pending_arrow_tail, true).as_deref(),
|
||||
Some(b"[".as_slice())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_ctrl_d_semantic_event_encodes_to_eot() {
|
||||
let event = Event::Key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL));
|
||||
assert_eq!(windows_key_raw_bytes(&event, false), None);
|
||||
|
||||
let event =
|
||||
crate::protocol::ClientInputEvent::from_crossterm(event).expect("ctrl-d converts");
|
||||
let raw = event.to_raw_input_event();
|
||||
let crate::raw_input::RawInputEvent::Key(key) = raw else {
|
||||
panic!("expected key");
|
||||
};
|
||||
assert_eq!(key.code, KeyCode::Char('d'));
|
||||
assert_eq!(key.modifiers, KeyModifiers::CONTROL);
|
||||
assert_eq!(
|
||||
crate::input::encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy),
|
||||
b"\x04"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_eot_control_char_normalizes_to_ctrl_d() {
|
||||
let event = Event::Key(KeyEvent::new(KeyCode::Char('\u{4}'), KeyModifiers::empty()));
|
||||
let bytes = windows_key_raw_bytes(&event, false).expect("eot routes through raw framer");
|
||||
assert_eq!(bytes, b"\x04");
|
||||
|
||||
let mut framer = crate::raw_input::RawInputFramer::default();
|
||||
let events = framer.push(&bytes);
|
||||
assert_eq!(events.len(), 1);
|
||||
|
||||
let event = windows_client_input_event_from_raw(events.into_iter().next().unwrap())
|
||||
.expect("raw eot converts");
|
||||
assert_eq!(
|
||||
event,
|
||||
crate::protocol::ClientInputEvent::Key {
|
||||
code: crate::protocol::ClientKeyCode::Char('d'),
|
||||
modifiers: KeyModifiers::CONTROL.bits(),
|
||||
kind: crate::protocol::ClientKeyKind::Press,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_pending_escape_sequence_converts_to_semantic_arrow() {
|
||||
let mut framer = crate::raw_input::RawInputFramer::default();
|
||||
assert!(framer.push(b"\x1b").is_empty());
|
||||
assert!(framer.push(b"[").is_empty());
|
||||
let events = framer.push(b"A");
|
||||
assert_eq!(events.len(), 1);
|
||||
|
||||
let event = windows_client_input_event_from_raw(events.into_iter().next().unwrap())
|
||||
.expect("raw arrow converts");
|
||||
assert_eq!(
|
||||
event,
|
||||
crate::protocol::ClientInputEvent::Key {
|
||||
code: crate::protocol::ClientKeyCode::Up,
|
||||
modifiers: 0,
|
||||
kind: crate::protocol::ClientKeyKind::Press,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_bare_escape_flushes_to_semantic_escape() {
|
||||
let mut framer = crate::raw_input::RawInputFramer::default();
|
||||
assert!(framer.push(b"\x1b").is_empty());
|
||||
let events = framer.flush_timeout();
|
||||
assert_eq!(events.len(), 1);
|
||||
|
||||
let event = windows_client_input_event_from_raw(events.into_iter().next().unwrap())
|
||||
.expect("raw escape converts");
|
||||
assert_eq!(
|
||||
event,
|
||||
crate::protocol::ClientInputEvent::Key {
|
||||
code: crate::protocol::ClientKeyCode::Esc,
|
||||
modifiers: 0,
|
||||
kind: crate::protocol::ClientKeyKind::Press,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+134
-36
@@ -16,25 +16,31 @@ mod input;
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::io::{self, Write as _};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use crossterm::event::{
|
||||
DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste,
|
||||
EnableFocusChange, EnableMouseCapture, KeyCode, KeyEventKind, KeyModifiers, MouseEventKind,
|
||||
PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
|
||||
EnableFocusChange, EnableMouseCapture,
|
||||
};
|
||||
#[cfg(unix)]
|
||||
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers, MouseEventKind};
|
||||
#[cfg(not(windows))]
|
||||
use crossterm::event::{PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags};
|
||||
use crossterm::execute;
|
||||
use interprocess::local_socket::traits::Stream as _;
|
||||
use interprocess::TryClone as _;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::ipc::LocalStream;
|
||||
use crate::protocol::render_ansi;
|
||||
use crate::protocol::{
|
||||
self, AttachScrollDirection, AttachScrollSource, ClientKeybindings, ClientLaunchMode,
|
||||
ClientMessage, NotifyKind, RenderEncoding, ServerMessage, MAX_CLIPBOARD_IMAGE_PAYLOAD,
|
||||
MAX_FRAME_SIZE, MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION,
|
||||
self, ClientKeybindings, ClientLaunchMode, ClientMessage, NotifyKind, RenderEncoding,
|
||||
ServerMessage, MAX_FRAME_SIZE, MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION,
|
||||
};
|
||||
#[cfg(unix)]
|
||||
use crate::protocol::{AttachScrollDirection, AttachScrollSource, MAX_CLIPBOARD_IMAGE_PAYLOAD};
|
||||
use crate::server::socket_paths::client_socket_path;
|
||||
|
||||
static RECEIVED_KITTY_GRAPHICS_IDS: OnceLock<Mutex<HashSet<u32>>> = OnceLock::new();
|
||||
@@ -58,17 +64,24 @@ struct ClientState {
|
||||
/// Direct attach prefix escape state. None for full-app clients.
|
||||
attach_escape: Option<AttachEscapeState>,
|
||||
/// Rows scrolled for one direct-attach wheel notch.
|
||||
#[cfg(unix)]
|
||||
mouse_scroll_lines: usize,
|
||||
/// Whether outer focus gain should force a full host-terminal redraw.
|
||||
redraw_on_focus_gained: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
#[cfg(windows)]
|
||||
struct AttachEscapeState;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
#[cfg(unix)]
|
||||
struct AttachEscapeState {
|
||||
pending_prefix: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[cfg(unix)]
|
||||
enum AttachInputAction {
|
||||
Forward(Vec<u8>),
|
||||
Scroll {
|
||||
@@ -84,6 +97,7 @@ enum AttachInputAction {
|
||||
}
|
||||
|
||||
impl AttachEscapeState {
|
||||
#[cfg(unix)]
|
||||
fn filter_input(
|
||||
&mut self,
|
||||
data: Vec<u8>,
|
||||
@@ -126,6 +140,7 @@ impl AttachEscapeState {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn attach_scroll_action(
|
||||
data: &[u8],
|
||||
viewport_rows: u16,
|
||||
@@ -304,20 +319,16 @@ fn setup_terminal_with_capabilities(
|
||||
|
||||
if enable_client_protocols {
|
||||
if mouse_capture {
|
||||
execute!(io::stdout(), EnableMouseCapture)?;
|
||||
set_mouse_capture(true)?;
|
||||
} else {
|
||||
execute!(io::stdout(), DisableMouseCapture)?;
|
||||
set_mouse_capture(false)?;
|
||||
}
|
||||
execute!(
|
||||
io::stdout(),
|
||||
EnableBracketedPaste,
|
||||
EnableFocusChange,
|
||||
PushKeyboardEnhancementFlags(crate::input::ime_compatible_keyboard_enhancement_flags())
|
||||
)?;
|
||||
execute!(io::stdout(), EnableBracketedPaste, EnableFocusChange)?;
|
||||
push_keyboard_enhancement_flags()?;
|
||||
} else if mouse_capture {
|
||||
execute!(io::stdout(), EnableMouseCapture)?;
|
||||
set_mouse_capture(true)?;
|
||||
} else {
|
||||
execute!(io::stdout(), DisableMouseCapture)?;
|
||||
set_mouse_capture(false)?;
|
||||
}
|
||||
|
||||
let modify_other_keys_mode = enable_client_protocols
|
||||
@@ -354,7 +365,12 @@ fn set_mouse_capture(enabled: bool) -> io::Result<()> {
|
||||
if enabled {
|
||||
execute!(io::stdout(), EnableMouseCapture)
|
||||
} else {
|
||||
execute!(io::stdout(), DisableMouseCapture)
|
||||
match execute!(io::stdout(), DisableMouseCapture) {
|
||||
Ok(()) => Ok(()),
|
||||
#[cfg(windows)]
|
||||
Err(err) if err.to_string() == "Initial console modes not set" => Ok(()),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,9 +383,9 @@ fn restore_terminal_state(reset_modify_other_keys: bool) {
|
||||
let _ = io::stdout().flush();
|
||||
}
|
||||
|
||||
let _ = pop_keyboard_enhancement_flags();
|
||||
let _ = execute!(
|
||||
io::stdout(),
|
||||
PopKeyboardEnhancementFlags,
|
||||
DisableFocusChange,
|
||||
DisableBracketedPaste,
|
||||
DisableMouseCapture
|
||||
@@ -378,6 +394,29 @@ fn restore_terminal_state(reset_modify_other_keys: bool) {
|
||||
let _ = write_terminal_restore_postlude(&mut io::stdout());
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn push_keyboard_enhancement_flags() -> io::Result<()> {
|
||||
execute!(
|
||||
io::stdout(),
|
||||
PushKeyboardEnhancementFlags(crate::input::ime_compatible_keyboard_enhancement_flags())
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn push_keyboard_enhancement_flags() -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn pop_keyboard_enhancement_flags() -> io::Result<()> {
|
||||
execute!(io::stdout(), PopKeyboardEnhancementFlags)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn pop_keyboard_enhancement_flags() -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Drop for TerminalGuard {
|
||||
fn drop(&mut self) {
|
||||
restore_terminal_state(self.reset_modify_other_keys);
|
||||
@@ -414,7 +453,7 @@ fn requested_keybindings() -> ClientKeybindings {
|
||||
/// Sends Hello with the terminal size and protocol version, reads the Welcome
|
||||
/// response. Returns Ok(()) on success, or an error if the server rejects us.
|
||||
fn do_handshake(
|
||||
stream: &mut UnixStream,
|
||||
stream: &mut LocalStream,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
cell_width_px: u32,
|
||||
@@ -445,13 +484,13 @@ fn do_handshake(
|
||||
.map_err(|e| ClientError::ConnectionFailed(io::Error::other(e.to_string())))?;
|
||||
|
||||
// Read Welcome.
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(5)))
|
||||
.map_err(ClientError::ConnectionFailed)?;
|
||||
if let Err(err) = stream.set_recv_timeout(Some(Duration::from_secs(5))) {
|
||||
debug!(err = %err, "client handshake read timeout unavailable");
|
||||
}
|
||||
let welcome: ServerMessage = protocol::read_message(stream, MAX_FRAME_SIZE)?;
|
||||
stream
|
||||
.set_read_timeout(None)
|
||||
.map_err(ClientError::ConnectionFailed)?;
|
||||
if let Err(err) = stream.set_recv_timeout(None) {
|
||||
debug!(err = %err, "failed to clear client handshake read timeout");
|
||||
}
|
||||
|
||||
match welcome {
|
||||
ServerMessage::Welcome {
|
||||
@@ -478,7 +517,11 @@ fn do_handshake(
|
||||
/// Internal events for the client event loop.
|
||||
enum ClientLoopEvent {
|
||||
/// Raw input bytes from stdin.
|
||||
#[cfg(unix)]
|
||||
StdinInput(Vec<u8>),
|
||||
/// Structured input events from platforms without Unix-style stdin bytes.
|
||||
#[cfg(windows)]
|
||||
StdinEvents(Vec<crate::protocol::ClientInputEvent>),
|
||||
/// Terminal resize detected.
|
||||
Resize(u16, u16, u32, u32),
|
||||
/// Server message received.
|
||||
@@ -503,6 +546,7 @@ pub fn run_client() -> io::Result<()> {
|
||||
}
|
||||
|
||||
/// Runs a direct terminal attach client.
|
||||
#[cfg(unix)]
|
||||
pub fn run_terminal_attach(terminal_id: String, takeover: bool) -> io::Result<()> {
|
||||
run_client_with_mode(
|
||||
RenderEncoding::TerminalAnsi,
|
||||
@@ -512,6 +556,16 @@ pub fn run_terminal_attach(terminal_id: String, takeover: bool) -> io::Result<()
|
||||
)
|
||||
}
|
||||
|
||||
/// Direct terminal attach is Unix raw-byte input only until Windows gets a semantic attach path.
|
||||
#[cfg(windows)]
|
||||
pub fn run_terminal_attach(_terminal_id: String, _takeover: bool) -> io::Result<()> {
|
||||
debug_assert!(!crate::platform::capabilities().direct_terminal_attach);
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"direct terminal attach is not supported on Windows yet",
|
||||
))
|
||||
}
|
||||
|
||||
fn run_client_with_mode(
|
||||
requested_encoding: RenderEncoding,
|
||||
attach_request: Option<(String, bool)>,
|
||||
@@ -521,6 +575,7 @@ fn run_client_with_mode(
|
||||
init_logging();
|
||||
|
||||
let loaded_config = crate::config::Config::load();
|
||||
let mouse_capture = loaded_config.config.ui.mouse_capture;
|
||||
let mouse_scroll_lines = loaded_config.config.ui.mouse_scroll_lines();
|
||||
let redraw_on_focus_gained = loaded_config.config.ui.redraw_on_focus_gained;
|
||||
let sound_config = loaded_config.config.ui.sound;
|
||||
@@ -533,7 +588,7 @@ fn run_client_with_mode(
|
||||
info!(path = %socket_path.display(), "{log_message}");
|
||||
|
||||
// Try to connect to the server.
|
||||
let mut stream = match UnixStream::connect(&socket_path) {
|
||||
let mut stream = match crate::ipc::connect_local_stream(&socket_path) {
|
||||
Ok(s) => s,
|
||||
Err(err) => {
|
||||
// Server unreachable — show clear error and exit.
|
||||
@@ -578,10 +633,10 @@ fn run_client_with_mode(
|
||||
// Now set up the terminal. This must happen AFTER the handshake succeeds,
|
||||
// so we don't leave the terminal in raw mode if the server rejects us.
|
||||
let direct_attach = attach_escape.is_some();
|
||||
let _guard = if direct_attach {
|
||||
let terminal_guard = if direct_attach {
|
||||
setup_direct_attach_terminal()
|
||||
} else {
|
||||
setup_terminal(false)
|
||||
setup_terminal(mouse_capture)
|
||||
}
|
||||
.map_err(|err| {
|
||||
eprintln!("herdr: failed to set up terminal: {err}");
|
||||
@@ -589,10 +644,10 @@ fn run_client_with_mode(
|
||||
})?;
|
||||
|
||||
// Install a panic hook to restore the terminal on panic (same as monolithic).
|
||||
let in_tmux = std::env::var("TMUX").is_ok();
|
||||
let panic_resets_modify_other_keys = terminal_guard.reset_modify_other_keys;
|
||||
let original_hook = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
restore_terminal_state(in_tmux);
|
||||
restore_terminal_state(panic_resets_modify_other_keys);
|
||||
original_hook(info);
|
||||
}));
|
||||
|
||||
@@ -620,7 +675,7 @@ fn run_client_with_mode(
|
||||
mouse_scroll_lines,
|
||||
redraw_on_focus_gained,
|
||||
kitty_graphics_enabled,
|
||||
false,
|
||||
mouse_capture,
|
||||
negotiated_encoding,
|
||||
attach_escape,
|
||||
)
|
||||
@@ -628,7 +683,7 @@ fn run_client_with_mode(
|
||||
});
|
||||
|
||||
// Restore the terminal before printing any final status message.
|
||||
drop(_guard);
|
||||
drop(terminal_guard);
|
||||
|
||||
if let Err(err) = result {
|
||||
eprintln!("herdr: {err}");
|
||||
@@ -660,7 +715,7 @@ fn run_client_with_mode(
|
||||
/// - server reader thread → reads ServerMessages and sends to main loop
|
||||
/// - main loop: coordinates input, output, and server communication
|
||||
async fn run_client_loop(
|
||||
stream: UnixStream,
|
||||
stream: LocalStream,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
should_quit: Arc<AtomicBool>,
|
||||
@@ -672,6 +727,9 @@ async fn run_client_loop(
|
||||
negotiated_encoding: RenderEncoding,
|
||||
attach_escape: Option<AttachEscapeState>,
|
||||
) -> Result<(), ClientError> {
|
||||
#[cfg(windows)]
|
||||
let _ = mouse_scroll_lines;
|
||||
|
||||
let mut state = ClientState {
|
||||
blit_encoder: render_ansi::BlitEncoder::new(),
|
||||
mouse_capture_active,
|
||||
@@ -679,6 +737,7 @@ async fn run_client_loop(
|
||||
sound_config,
|
||||
kitty_graphics_enabled,
|
||||
attach_escape,
|
||||
#[cfg(unix)]
|
||||
mouse_scroll_lines,
|
||||
redraw_on_focus_gained,
|
||||
};
|
||||
@@ -694,7 +753,7 @@ async fn run_client_loop(
|
||||
input::stdin_reader_loop(stdin_tx, &stdin_quit);
|
||||
});
|
||||
|
||||
if state.attach_escape.is_none() {
|
||||
if state.attach_escape.is_none() && should_query_host_terminal_theme() {
|
||||
query_host_terminal_theme();
|
||||
}
|
||||
|
||||
@@ -739,6 +798,7 @@ async fn run_client_loop(
|
||||
};
|
||||
|
||||
match event {
|
||||
#[cfg(unix)]
|
||||
ClientLoopEvent::StdinInput(data) => {
|
||||
let data = if let Some(attach_escape) = &mut state.attach_escape {
|
||||
match attach_escape.filter_input(
|
||||
@@ -817,6 +877,26 @@ async fn run_client_loop(
|
||||
return Err(ClientError::ConnectionLost(e));
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
ClientLoopEvent::StdinEvents(events) => {
|
||||
if state.attach_escape.is_some() {
|
||||
continue;
|
||||
}
|
||||
let raw_events = events
|
||||
.iter()
|
||||
.map(crate::protocol::ClientInputEvent::to_raw_input_event)
|
||||
.collect::<Vec<_>>();
|
||||
if crate::raw_input::events_require_host_surface_redraw(
|
||||
&raw_events,
|
||||
state.redraw_on_focus_gained,
|
||||
) {
|
||||
state.request_full_redraw();
|
||||
}
|
||||
let msg = ClientMessage::InputEvents { events };
|
||||
if let Err(e) = write_to_server(&mut write_stream, &msg) {
|
||||
return Err(ClientError::ConnectionLost(e));
|
||||
}
|
||||
}
|
||||
ClientLoopEvent::Resize(new_cols, new_rows, cell_width_px, cell_height_px) => {
|
||||
state.reported_size = (new_cols, new_rows);
|
||||
let msg = ClientMessage::Resize {
|
||||
@@ -917,7 +997,7 @@ async fn run_client_loop(
|
||||
/// Blocking thread that reads ServerMessages from the server and sends them
|
||||
/// to the main event loop.
|
||||
fn server_reader_thread(
|
||||
mut stream: UnixStream,
|
||||
mut stream: LocalStream,
|
||||
event_tx: tokio::sync::mpsc::Sender<ClientLoopEvent>,
|
||||
should_quit: &Arc<AtomicBool>,
|
||||
max_frame_size: usize,
|
||||
@@ -970,7 +1050,7 @@ fn server_reader_thread(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Writes a message to the server stream (blocking).
|
||||
fn write_to_server(stream: &mut UnixStream, msg: &ClientMessage) -> io::Result<()> {
|
||||
fn write_to_server(stream: &mut LocalStream, msg: &ClientMessage) -> io::Result<()> {
|
||||
protocol::write_message(stream, msg).map_err(|e| io::Error::other(e.to_string()))
|
||||
}
|
||||
|
||||
@@ -1063,6 +1143,7 @@ fn sound_from_notify_message(message: &str) -> Option<crate::sound::Sound> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn should_bridge_clipboard_image_paste(data: &[u8]) -> bool {
|
||||
if data == b"\x1b[200~\x1b[201~" {
|
||||
return true;
|
||||
@@ -1254,6 +1335,10 @@ fn query_host_terminal_theme() {
|
||||
let _ = write_host_terminal_theme_query(io::stdout());
|
||||
}
|
||||
|
||||
fn should_query_host_terminal_theme() -> bool {
|
||||
!cfg!(windows)
|
||||
}
|
||||
|
||||
fn write_host_terminal_theme_query(mut writer: impl io::Write) -> io::Result<()> {
|
||||
writer.write_all(crate::terminal_theme::HOST_COLOR_QUERY_SEQUENCE.as_bytes())?;
|
||||
writer.flush()
|
||||
@@ -1330,6 +1415,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn clipboard_image_paste_bridge_triggers_on_ctrl_v_and_empty_paste() {
|
||||
assert!(should_bridge_clipboard_image_paste(&[0x16]));
|
||||
@@ -1399,6 +1485,11 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_terminal_theme_query_is_disabled_on_windows() {
|
||||
assert_eq!(should_query_host_terminal_theme(), !cfg!(windows));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_restore_postlude_restores_visible_default_cursor() {
|
||||
let mut output = Vec::new();
|
||||
@@ -1406,6 +1497,7 @@ mod tests {
|
||||
assert_eq!(output, b"\x1b[?25h\x1b[0 q");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn attach_escape_detaches_on_prefix_q() {
|
||||
let mut escape = AttachEscapeState::default();
|
||||
@@ -1419,6 +1511,7 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn attach_escape_sends_literal_prefix_on_double_prefix() {
|
||||
let mut escape = AttachEscapeState::default();
|
||||
@@ -1432,6 +1525,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn attach_escape_forwards_prefix_before_non_escape_key() {
|
||||
let mut escape = AttachEscapeState::default();
|
||||
@@ -1445,6 +1539,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn attach_escape_turns_wheel_into_scroll_action() {
|
||||
let mut escape = AttachEscapeState::default();
|
||||
@@ -1467,6 +1562,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn attach_escape_swallows_non_wheel_mouse_reports() {
|
||||
let mut escape = AttachEscapeState::default();
|
||||
@@ -1476,6 +1572,7 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn attach_escape_turns_plain_page_keys_into_scroll_actions() {
|
||||
let mut escape = AttachEscapeState::default();
|
||||
@@ -1518,6 +1615,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn attach_escape_forwards_modified_page_key() {
|
||||
let mut escape = AttachEscapeState::default();
|
||||
|
||||
+50
-2
@@ -15,23 +15,71 @@ pub fn app_dir_name() -> &'static str {
|
||||
pub fn config_dir() -> PathBuf {
|
||||
if let Ok(dir) = std::env::var("XDG_CONFIG_HOME") {
|
||||
PathBuf::from(dir).join(app_dir_name())
|
||||
} else if cfg!(windows) {
|
||||
windows_config_dir()
|
||||
} else if let Ok(home) = std::env::var("HOME") {
|
||||
PathBuf::from(home).join(format!(".config/{}", app_dir_name()))
|
||||
} else {
|
||||
PathBuf::from(format!("/tmp/{}", app_dir_name()))
|
||||
std::env::temp_dir().join(app_dir_name())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn state_dir() -> PathBuf {
|
||||
if let Ok(dir) = std::env::var("XDG_STATE_HOME") {
|
||||
PathBuf::from(dir).join(app_dir_name())
|
||||
} else if cfg!(windows) {
|
||||
windows_state_dir()
|
||||
} else if let Ok(home) = std::env::var("HOME") {
|
||||
PathBuf::from(home).join(format!(".local/state/{}", app_dir_name()))
|
||||
} else {
|
||||
PathBuf::from(format!("/tmp/{}-state", app_dir_name()))
|
||||
std::env::temp_dir().join(format!("{}-state", app_dir_name()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn windows_config_dir() -> PathBuf {
|
||||
if let Ok(dir) = std::env::var("APPDATA") {
|
||||
return PathBuf::from(dir).join(app_dir_name());
|
||||
}
|
||||
if let Ok(profile) = std::env::var("USERPROFILE") {
|
||||
return PathBuf::from(profile)
|
||||
.join("AppData")
|
||||
.join("Roaming")
|
||||
.join(app_dir_name());
|
||||
}
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
return PathBuf::from(home).join(format!(".config/{}", app_dir_name()));
|
||||
}
|
||||
std::env::temp_dir().join(app_dir_name())
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn windows_config_dir() -> PathBuf {
|
||||
unreachable!("windows_config_dir is only called on Windows")
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn windows_state_dir() -> PathBuf {
|
||||
if let Ok(dir) = std::env::var("LOCALAPPDATA") {
|
||||
return PathBuf::from(dir).join(app_dir_name());
|
||||
}
|
||||
if let Ok(profile) = std::env::var("USERPROFILE") {
|
||||
return PathBuf::from(profile)
|
||||
.join("AppData")
|
||||
.join("Local")
|
||||
.join(app_dir_name());
|
||||
}
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
return PathBuf::from(home).join(format!(".local/state/{}", app_dir_name()));
|
||||
}
|
||||
std::env::temp_dir().join(format!("{}-state", app_dir_name()))
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn windows_state_dir() -> PathBuf {
|
||||
unreachable!("windows_state_dir is only called on Windows")
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load() -> LoadedConfig {
|
||||
let path = config_path();
|
||||
|
||||
+146
-8
@@ -375,17 +375,96 @@ fn normalized_process_name(process: &crate::platform::ForegroundProcess) -> Stri
|
||||
|
||||
fn wrapped_agent_name_from_runtime_argv(runtime: &str, argv: Option<&[String]>) -> Option<String> {
|
||||
let argv = argv?;
|
||||
let runtime = path_basename(runtime).to_lowercase();
|
||||
let runtime = normalized_agent_lookup_name(path_basename(runtime));
|
||||
|
||||
match runtime.as_str() {
|
||||
"node" | "bun" => script_arg_agent_name(argv, &["-e", "--eval", "-p", "--print"], &[]),
|
||||
"python" | "python3" => script_arg_agent_name(argv, &["-c"], &["-m"]),
|
||||
"sh" | "bash" | "zsh" | "fish" => script_arg_agent_name(argv, &["-c"], &[]),
|
||||
"cmd" => windows_cmd_arg_agent_name(argv),
|
||||
"powershell" | "pwsh" => powershell_arg_agent_name(argv),
|
||||
"tmux" => None,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn windows_cmd_arg_agent_name(argv: &[String]) -> Option<String> {
|
||||
let mut args = argv.iter().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
let flag = arg.trim_matches('"').to_lowercase();
|
||||
match flag.as_str() {
|
||||
"/c" | "/k" => {
|
||||
return args
|
||||
.next()
|
||||
.and_then(|command| command_text_agent_name(command))
|
||||
}
|
||||
"/d" | "/s" | "/q" | "/a" | "/u" | "/e:on" | "/e:off" | "/f:on" | "/f:off"
|
||||
| "/v:on" | "/v:off" => continue,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn powershell_arg_agent_name(argv: &[String]) -> Option<String> {
|
||||
let mut args = argv.iter().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
let flag = arg.trim_matches('"').to_lowercase();
|
||||
match flag.as_str() {
|
||||
"-file" | "-f" | "/file" => {
|
||||
return args
|
||||
.next()
|
||||
.and_then(|path| agent_name_from_path_token(path));
|
||||
}
|
||||
"-command" | "-c" | "/command" | "/c" => {
|
||||
return args
|
||||
.next()
|
||||
.and_then(|command| command_text_agent_name(command));
|
||||
}
|
||||
"-encodedcommand" | "-enc" | "/encodedcommand" | "/enc" => return None,
|
||||
"-configurationname" | "-executionpolicy" | "-outputformat" | "-psconsolefile"
|
||||
| "-version" | "-windowstyle" | "-workingdirectory" => {
|
||||
let _ = args.next();
|
||||
}
|
||||
_ if flag.starts_with('-') || flag.starts_with('/') => {}
|
||||
_ => return agent_name_from_path_token(arg),
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn command_text_agent_name(command: &str) -> Option<String> {
|
||||
let mut rest = command;
|
||||
while let Some((token, next)) = command_text_token(rest) {
|
||||
let token = token.trim();
|
||||
if token.eq_ignore_ascii_case("&")
|
||||
|| token.eq_ignore_ascii_case(".")
|
||||
|| token.eq_ignore_ascii_case("call")
|
||||
{
|
||||
rest = next;
|
||||
continue;
|
||||
}
|
||||
return agent_name_from_path_token(token);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn command_text_token(input: &str) -> Option<(&str, &str)> {
|
||||
let input = input.trim_start();
|
||||
let first = input.chars().next()?;
|
||||
if first == '"' || first == '\'' {
|
||||
let start = first.len_utf8();
|
||||
if let Some(end) = input[start..].find(first) {
|
||||
let end = start + end;
|
||||
return Some((&input[start..end], &input[end + first.len_utf8()..]));
|
||||
}
|
||||
return Some((&input[start..], ""));
|
||||
}
|
||||
|
||||
let end = input.find(char::is_whitespace).unwrap_or(input.len());
|
||||
Some((&input[..end], &input[end..]))
|
||||
}
|
||||
|
||||
fn script_arg_agent_name(
|
||||
argv: &[String],
|
||||
eval_flags: &[&str],
|
||||
@@ -488,16 +567,18 @@ fn agent_name_from_basename(basename: &str) -> Option<String> {
|
||||
|
||||
fn normalized_agent_lookup_name(name: &str) -> String {
|
||||
let mut name = name.trim().to_lowercase();
|
||||
if name.ends_with(".exe") {
|
||||
name.truncate(name.len() - ".exe".len());
|
||||
for suffix in [".exe", ".cmd", ".bat", ".ps1", ".js"] {
|
||||
if name.ends_with(suffix) {
|
||||
name.truncate(name.len() - suffix.len());
|
||||
break;
|
||||
}
|
||||
}
|
||||
name
|
||||
}
|
||||
|
||||
fn path_basename(path: &str) -> &str {
|
||||
std::path::Path::new(path)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
path.rsplit(['/', '\\'])
|
||||
.find(|component| !component.is_empty())
|
||||
.unwrap_or(path)
|
||||
}
|
||||
|
||||
@@ -513,9 +594,20 @@ fn process_priority(process: &crate::platform::ForegroundProcess, normalized_nam
|
||||
}
|
||||
|
||||
fn is_generic_runtime_or_shell(name: &str) -> bool {
|
||||
let name = normalized_agent_lookup_name(path_basename(name));
|
||||
matches!(
|
||||
name,
|
||||
"sh" | "bash" | "zsh" | "fish" | "tmux" | "node" | "bun" | "python" | "python3"
|
||||
name.as_str(),
|
||||
"sh" | "bash"
|
||||
| "zsh"
|
||||
| "fish"
|
||||
| "tmux"
|
||||
| "node"
|
||||
| "bun"
|
||||
| "python"
|
||||
| "python3"
|
||||
| "cmd"
|
||||
| "powershell"
|
||||
| "pwsh"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -541,6 +633,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn temp_detection_path(name: &str) -> std::path::PathBuf {
|
||||
let unique = format!(
|
||||
"herdr-detect-tests-{}-{}-{}",
|
||||
@@ -786,6 +879,51 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_agent_in_job_detects_windows_cmd_wrapped_codex() {
|
||||
let job = crate::platform::ForegroundJob {
|
||||
process_group_id: 123,
|
||||
processes: vec![foreground_process(
|
||||
1,
|
||||
"cmd.exe",
|
||||
&[
|
||||
"cmd.exe",
|
||||
"/D",
|
||||
"/S",
|
||||
"/C",
|
||||
"C:\\Users\\herdr\\AppData\\Roaming\\npm\\codex.cmd --model gpt-5",
|
||||
],
|
||||
)],
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
identify_agent_in_job(&job),
|
||||
Some((Agent::Codex, "codex".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_agent_in_job_detects_powershell_file_wrapped_claude() {
|
||||
let job = crate::platform::ForegroundJob {
|
||||
process_group_id: 123,
|
||||
processes: vec![foreground_process(
|
||||
1,
|
||||
"powershell.exe",
|
||||
&[
|
||||
"powershell.exe",
|
||||
"-NoProfile",
|
||||
"-File",
|
||||
"C:\\Users\\herdr\\Documents\\PowerShell\\Scripts\\claude.ps1",
|
||||
],
|
||||
)],
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
identify_agent_in_job(&job),
|
||||
Some((Agent::Claude, "claude".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_agent_in_job_detects_opencode_exe_from_pnpm_package() {
|
||||
let job = crate::platform::ForegroundJob {
|
||||
|
||||
@@ -96,6 +96,12 @@ pub enum AppEvent {
|
||||
/// A pane child emitted a valid OSC 52 clipboard write. The main loop
|
||||
/// re-emits it through herdr's own clipboard writer.
|
||||
ClipboardWrite { content: Vec<u8> },
|
||||
/// A pane child reported its shell current directory through terminal
|
||||
/// metadata such as OSC 7.
|
||||
TerminalCwdReported {
|
||||
pane_id: PaneId,
|
||||
cwd: std::path::PathBuf,
|
||||
},
|
||||
/// Background git status refresh completed for workspaces.
|
||||
GitStatusRefreshed {
|
||||
results: Vec<WorkspaceGitStatus>,
|
||||
|
||||
@@ -28,9 +28,10 @@ impl HandoffRuntimeState {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ImportedHandoffRuntime {
|
||||
#[cfg(unix)]
|
||||
pub master_fd: std::os::fd::RawFd,
|
||||
#[cfg(unix)]
|
||||
pub state: HandoffRuntimeState,
|
||||
}
|
||||
|
||||
+5
-3
@@ -4,10 +4,12 @@ mod parse;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub use encode::{
|
||||
encode_cursor_key, encode_mouse_button, encode_mouse_scroll, encode_terminal_key,
|
||||
encode_cursor_key, encode_key, encode_mouse_button, encode_mouse_scroll, encode_terminal_key,
|
||||
};
|
||||
#[cfg(not(windows))]
|
||||
pub use model::ime_compatible_keyboard_enhancement_flags;
|
||||
pub use model::{
|
||||
host_modify_other_keys_mode, ime_compatible_keyboard_enhancement_flags, KeyboardProtocol,
|
||||
MouseProtocolEncoding, MouseProtocolMode, TerminalKey,
|
||||
host_modify_other_keys_mode, KeyboardProtocol, MouseProtocolEncoding, MouseProtocolMode,
|
||||
TerminalKey,
|
||||
};
|
||||
pub use parse::parse_terminal_key_sequence;
|
||||
|
||||
+5
-1
@@ -1,4 +1,6 @@
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, KeyboardEnhancementFlags};
|
||||
#[cfg(not(windows))]
|
||||
use crossterm::event::KeyboardEnhancementFlags;
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -41,6 +43,7 @@ impl From<KeyEvent> for TerminalKey {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn ime_compatible_keyboard_enhancement_flags() -> KeyboardEnhancementFlags {
|
||||
KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
|
||||
| KeyboardEnhancementFlags::REPORT_EVENT_TYPES
|
||||
@@ -138,6 +141,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[test]
|
||||
fn keyboard_enhancement_flags_stay_ime_compatible() {
|
||||
let flags = ime_compatible_keyboard_enhancement_flags();
|
||||
|
||||
+453
-50
@@ -656,29 +656,84 @@ pub(crate) fn integration_target_label(
|
||||
}
|
||||
|
||||
fn integration_target_command(target: crate::api::schema::IntegrationTarget) -> &'static str {
|
||||
integration_target_command_names(target)[0]
|
||||
}
|
||||
|
||||
fn integration_target_command_names(
|
||||
target: crate::api::schema::IntegrationTarget,
|
||||
) -> &'static [&'static str] {
|
||||
match target {
|
||||
crate::api::schema::IntegrationTarget::Pi => "pi",
|
||||
crate::api::schema::IntegrationTarget::Omp => "omp",
|
||||
crate::api::schema::IntegrationTarget::Claude => "claude",
|
||||
crate::api::schema::IntegrationTarget::Codex => "codex",
|
||||
crate::api::schema::IntegrationTarget::Copilot => "copilot",
|
||||
crate::api::schema::IntegrationTarget::Droid => "droid",
|
||||
crate::api::schema::IntegrationTarget::Kimi => "kimi",
|
||||
crate::api::schema::IntegrationTarget::Opencode => "opencode",
|
||||
crate::api::schema::IntegrationTarget::Hermes => "hermes",
|
||||
crate::api::schema::IntegrationTarget::Qodercli => "qodercli",
|
||||
crate::api::schema::IntegrationTarget::Pi => &["pi"],
|
||||
crate::api::schema::IntegrationTarget::Omp => &["omp"],
|
||||
crate::api::schema::IntegrationTarget::Claude => &["claude"],
|
||||
crate::api::schema::IntegrationTarget::Codex => &["codex"],
|
||||
crate::api::schema::IntegrationTarget::Copilot => &["copilot"],
|
||||
crate::api::schema::IntegrationTarget::Droid => &["droid"],
|
||||
crate::api::schema::IntegrationTarget::Kimi => &["kimi"],
|
||||
crate::api::schema::IntegrationTarget::Opencode => &["opencode"],
|
||||
crate::api::schema::IntegrationTarget::Hermes => &["hermes"],
|
||||
crate::api::schema::IntegrationTarget::Qodercli => qodercli_command_names(),
|
||||
}
|
||||
}
|
||||
|
||||
fn integration_target_available(target: crate::api::schema::IntegrationTarget) -> bool {
|
||||
command_available(integration_target_command(target))
|
||||
integration_target_command_names(target)
|
||||
.iter()
|
||||
.any(|command| command_available(command))
|
||||
|| integration_target_install_layout_available(target)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn qodercli_command_names() -> &'static [&'static str] {
|
||||
&["qodercli", "qoder", "qoderclicn", "qodercn"]
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn qodercli_command_names() -> &'static [&'static str] {
|
||||
&["qodercli"]
|
||||
}
|
||||
|
||||
fn integration_target_install_layout_available(
|
||||
target: crate::api::schema::IntegrationTarget,
|
||||
) -> bool {
|
||||
match target {
|
||||
crate::api::schema::IntegrationTarget::Codex => codex_standalone_binary_available(),
|
||||
crate::api::schema::IntegrationTarget::Hermes => hermes_install_layout_available(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn command_available(command: &str) -> bool {
|
||||
let Some(paths) = std::env::var_os("PATH") else {
|
||||
return false;
|
||||
};
|
||||
std::env::split_paths(&paths).any(|dir| executable_file_exists(&dir.join(command)))
|
||||
std::env::split_paths(&paths).any(|dir| {
|
||||
command_path_candidates(&dir, command)
|
||||
.into_iter()
|
||||
.any(|path| executable_file_exists(&path))
|
||||
})
|
||||
}
|
||||
|
||||
fn command_path_candidates(dir: &Path, command: &str) -> Vec<PathBuf> {
|
||||
let base = dir.join(command);
|
||||
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
vec![base]
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if Path::new(command).extension().is_some() {
|
||||
return vec![base];
|
||||
}
|
||||
|
||||
let mut candidates = vec![base];
|
||||
for extension in [".exe", ".cmd", ".bat", ".ps1"] {
|
||||
candidates.push(dir.join(format!("{command}{extension}")));
|
||||
}
|
||||
candidates
|
||||
}
|
||||
}
|
||||
|
||||
fn executable_file_exists(path: &Path) -> bool {
|
||||
@@ -701,6 +756,53 @@ fn executable_file_exists(path: &Path) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn codex_standalone_binary_available() -> bool {
|
||||
let Ok(releases_dir) =
|
||||
codex_dir().map(|dir| dir.join("packages").join("standalone").join("releases"))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Ok(entries) = fs::read_dir(releases_dir) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
entries.filter_map(Result::ok).any(|entry| {
|
||||
executable_file_exists(&entry.path().join("bin").join(codex_executable_name()))
|
||||
})
|
||||
}
|
||||
|
||||
fn codex_executable_name() -> &'static str {
|
||||
if cfg!(windows) {
|
||||
"codex.exe"
|
||||
} else {
|
||||
"codex"
|
||||
}
|
||||
}
|
||||
|
||||
fn hermes_install_layout_available() -> bool {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let Some(local_app_data) =
|
||||
std::env::var_os("LOCALAPPDATA").filter(|value| !value.is_empty())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let dir = PathBuf::from(local_app_data).join("hermes");
|
||||
[
|
||||
dir.join("hermes.exe"),
|
||||
dir.join("bin").join("hermes.exe"),
|
||||
dir.join("Scripts").join("hermes.exe"),
|
||||
]
|
||||
.into_iter()
|
||||
.any(|path| executable_file_exists(&path))
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn installed_integration_statuses() -> Vec<IntegrationStatus> {
|
||||
integration_specs()
|
||||
.into_iter()
|
||||
@@ -896,6 +998,17 @@ pub(crate) fn install_pi() -> io::Result<PathBuf> {
|
||||
|
||||
pub(crate) fn install_omp() -> io::Result<OmpInstallPaths> {
|
||||
let dir = omp_extension_dir()?;
|
||||
if !dir.is_dir() {
|
||||
if dir.parent().is_some_and(|parent| parent.is_dir()) {
|
||||
fs::create_dir_all(&dir)?;
|
||||
} else {
|
||||
return Err(io::Error::other(format!(
|
||||
"omp extension directory not found at {}. install omp and create the extensions directory first",
|
||||
dir.display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
if !dir.is_dir() {
|
||||
return Err(io::Error::other(format!(
|
||||
"omp extension directory not found at {}. install omp and create the extensions directory first",
|
||||
@@ -2537,14 +2650,14 @@ fn shell_single_quote(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\'', "'\"'\"'"))
|
||||
}
|
||||
|
||||
fn make_executable(path: &Path) -> io::Result<()> {
|
||||
fn make_executable(_path: &Path) -> io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let mut perms = fs::metadata(path)?.permissions();
|
||||
let mut perms = fs::metadata(_path)?.permissions();
|
||||
perms.set_mode(0o755);
|
||||
fs::set_permissions(path, perms)?;
|
||||
fs::set_permissions(_path, perms)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -2638,9 +2751,28 @@ fn qodercli_dir() -> io::Result<PathBuf> {
|
||||
}
|
||||
|
||||
fn home_dir() -> io::Result<PathBuf> {
|
||||
std::env::var("HOME")
|
||||
.map(PathBuf::from)
|
||||
.map_err(|_| io::Error::other("HOME is not set; cannot locate home directory"))
|
||||
if let Some(home) = std::env::var_os("HOME").filter(|value| !value.is_empty()) {
|
||||
return Ok(PathBuf::from(home));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Some(profile) = std::env::var_os("USERPROFILE").filter(|value| !value.is_empty()) {
|
||||
return Ok(PathBuf::from(profile));
|
||||
}
|
||||
if let (Some(drive), Some(path)) = (
|
||||
std::env::var_os("HOMEDRIVE").filter(|value| !value.is_empty()),
|
||||
std::env::var_os("HOMEPATH").filter(|value| !value.is_empty()),
|
||||
) {
|
||||
let mut home = PathBuf::from(drive);
|
||||
home.push(path);
|
||||
return Ok(home);
|
||||
}
|
||||
}
|
||||
|
||||
Err(io::Error::other(
|
||||
"home directory is not set; cannot locate home directory",
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -2703,6 +2835,28 @@ mod tests {
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn home_dir_uses_userprofile_when_home_is_missing() {
|
||||
let _lock = integration_env_lock();
|
||||
let base = unique_base();
|
||||
let previous_home = std::env::var_os("HOME");
|
||||
let previous_userprofile = std::env::var_os("USERPROFILE");
|
||||
std::env::remove_var("HOME");
|
||||
std::env::set_var("USERPROFILE", &base);
|
||||
|
||||
assert_eq!(home_dir().unwrap(), base);
|
||||
|
||||
if let Some(home) = previous_home {
|
||||
std::env::set_var("HOME", home);
|
||||
}
|
||||
if let Some(userprofile) = previous_userprofile {
|
||||
std::env::set_var("USERPROFILE", userprofile);
|
||||
} else {
|
||||
std::env::remove_var("USERPROFILE");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn command_available_requires_executable_file_on_path() {
|
||||
@@ -2731,6 +2885,162 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn command_available_finds_windows_command_shims_on_path() {
|
||||
let _lock = integration_env_lock();
|
||||
let base = unique_base();
|
||||
let bin = base.join("bin");
|
||||
fs::create_dir_all(&bin).unwrap();
|
||||
let original_path = std::env::var_os("PATH");
|
||||
std::env::set_var("PATH", &bin);
|
||||
|
||||
fs::write(bin.join("claude.cmd"), "@echo off\r\n").unwrap();
|
||||
assert!(command_available("claude"));
|
||||
|
||||
fs::write(bin.join("codex.exe"), "").unwrap();
|
||||
assert!(command_available("codex"));
|
||||
|
||||
assert!(!command_available("missing-agent"));
|
||||
|
||||
if let Some(path) = original_path {
|
||||
std::env::set_var("PATH", path);
|
||||
} else {
|
||||
std::env::remove_var("PATH");
|
||||
}
|
||||
let _ = fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn qodercli_availability_checks_windows_aliases() {
|
||||
let _lock = integration_env_lock();
|
||||
let base = unique_base();
|
||||
let bin = base.join("bin");
|
||||
fs::create_dir_all(&bin).unwrap();
|
||||
let original_path = std::env::var_os("PATH");
|
||||
std::env::set_var("PATH", &bin);
|
||||
|
||||
fs::write(bin.join("qoder.cmd"), "@echo off\r\n").unwrap();
|
||||
|
||||
assert!(integration_target_available(
|
||||
crate::api::schema::IntegrationTarget::Qodercli
|
||||
));
|
||||
|
||||
if let Some(path) = original_path {
|
||||
std::env::set_var("PATH", path);
|
||||
} else {
|
||||
std::env::remove_var("PATH");
|
||||
}
|
||||
let _ = fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn hermes_availability_checks_windows_install_layout() {
|
||||
let _lock = integration_env_lock();
|
||||
let base = unique_base();
|
||||
let local_app_data = base.join("local-app-data");
|
||||
let hermes_bin = local_app_data.join("hermes").join("bin");
|
||||
fs::create_dir_all(&hermes_bin).unwrap();
|
||||
fs::write(hermes_bin.join("hermes.exe"), "").unwrap();
|
||||
let original_local_app_data = std::env::var_os("LOCALAPPDATA");
|
||||
let original_path = std::env::var_os("PATH");
|
||||
std::env::set_var("LOCALAPPDATA", &local_app_data);
|
||||
std::env::set_var("PATH", "");
|
||||
|
||||
assert!(integration_target_available(
|
||||
crate::api::schema::IntegrationTarget::Hermes
|
||||
));
|
||||
|
||||
if let Some(local_app_data) = original_local_app_data {
|
||||
std::env::set_var("LOCALAPPDATA", local_app_data);
|
||||
} else {
|
||||
std::env::remove_var("LOCALAPPDATA");
|
||||
}
|
||||
if let Some(path) = original_path {
|
||||
std::env::set_var("PATH", path);
|
||||
} else {
|
||||
std::env::remove_var("PATH");
|
||||
}
|
||||
let _ = fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_availability_finds_standalone_binary_under_codex_home() {
|
||||
let _lock = integration_env_lock();
|
||||
let base = unique_base();
|
||||
let home = base.join("home");
|
||||
let bin = home
|
||||
.join(".codex/packages/standalone/releases/0.137.0-test")
|
||||
.join("bin");
|
||||
fs::create_dir_all(&bin).unwrap();
|
||||
let binary = bin.join(codex_executable_name());
|
||||
fs::write(&binary, "").unwrap();
|
||||
make_executable(&binary).unwrap();
|
||||
let original_home = std::env::var_os("HOME");
|
||||
let original_path = std::env::var_os("PATH");
|
||||
std::env::set_var("HOME", &home);
|
||||
std::env::set_var("PATH", "");
|
||||
|
||||
assert!(integration_target_available(
|
||||
crate::api::schema::IntegrationTarget::Codex
|
||||
));
|
||||
|
||||
if let Some(home) = original_home {
|
||||
std::env::set_var("HOME", home);
|
||||
} else {
|
||||
std::env::remove_var("HOME");
|
||||
}
|
||||
if let Some(path) = original_path {
|
||||
std::env::set_var("PATH", path);
|
||||
} else {
|
||||
std::env::remove_var("PATH");
|
||||
}
|
||||
let _ = fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integration_recommendations_mark_standalone_codex_available() {
|
||||
let _lock = integration_env_lock();
|
||||
let base = unique_base();
|
||||
let home = base.join("home");
|
||||
let bin = home
|
||||
.join(".codex/packages/standalone/releases/0.137.0-test")
|
||||
.join("bin");
|
||||
fs::create_dir_all(&bin).unwrap();
|
||||
let binary = bin.join(codex_executable_name());
|
||||
fs::write(&binary, "").unwrap();
|
||||
make_executable(&binary).unwrap();
|
||||
let original_home = std::env::var_os("HOME");
|
||||
let original_path = std::env::var_os("PATH");
|
||||
std::env::set_var("HOME", &home);
|
||||
std::env::set_var("PATH", "");
|
||||
|
||||
let codex = integration_recommendations()
|
||||
.into_iter()
|
||||
.find(|recommendation| {
|
||||
recommendation.target == crate::api::schema::IntegrationTarget::Codex
|
||||
})
|
||||
.expect("codex recommendation should be present");
|
||||
|
||||
assert!(codex.available);
|
||||
assert_eq!(codex.state, IntegrationStatusKind::NotInstalled);
|
||||
assert!(codex.needs_install());
|
||||
|
||||
if let Some(home) = original_home {
|
||||
std::env::set_var("HOME", home);
|
||||
} else {
|
||||
std::env::remove_var("HOME");
|
||||
}
|
||||
if let Some(path) = original_path {
|
||||
std::env::set_var("PATH", path);
|
||||
} else {
|
||||
std::env::remove_var("PATH");
|
||||
}
|
||||
let _ = fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integration_recommendation_installs_available_or_outdated_targets() {
|
||||
let mut recommendation = IntegrationRecommendation {
|
||||
@@ -2905,6 +3215,29 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_omp_creates_extensions_dir_when_agent_dir_exists() {
|
||||
let _lock = integration_env_lock();
|
||||
let base = unique_base();
|
||||
let home = base.join("home");
|
||||
let agent_dir = home.join(".omp/agent");
|
||||
let ext_dir = agent_dir.join("extensions");
|
||||
fs::create_dir_all(&agent_dir).unwrap();
|
||||
std::env::set_var("HOME", &home);
|
||||
|
||||
let installed = install_omp().unwrap();
|
||||
|
||||
assert_eq!(
|
||||
installed.extension_path,
|
||||
ext_dir.join(OMP_EXTENSION_INSTALL_NAME)
|
||||
);
|
||||
assert!(ext_dir.is_dir());
|
||||
assert!(!installed.removed_legacy_pi_extension);
|
||||
|
||||
std::env::remove_var("HOME");
|
||||
let _ = fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uninstall_omp_removes_embedded_extension_when_present() {
|
||||
let _lock = integration_env_lock();
|
||||
@@ -3133,15 +3466,41 @@ mod tests {
|
||||
let hooks_dir = claude_dir.join("hooks");
|
||||
fs::create_dir_all(&hooks_dir).unwrap();
|
||||
let hook_path = hooks_dir.join(CLAUDE_HOOK_INSTALL_NAME);
|
||||
let settings = serde_json::json!({
|
||||
"hooks": {
|
||||
"PostToolUse": [{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{"type": "command", "command": format!("bash '{}' working", hook_path.display()), "timeout": 10},
|
||||
{"type": "command", "command": "echo keep-post", "timeout": 10}
|
||||
]
|
||||
}],
|
||||
"PostToolUseFailure": [{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{"type": "command", "command": format!("bash '{}' working", hook_path.display()), "timeout": 10},
|
||||
{"type": "command", "command": "echo keep-failure", "timeout": 10}
|
||||
]
|
||||
}],
|
||||
"SubagentStop": [{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{"type": "command", "command": format!("bash '{}' working", hook_path.display()), "timeout": 10},
|
||||
{"type": "command", "command": "echo keep-subagent", "timeout": 10}
|
||||
]
|
||||
}],
|
||||
"SessionEnd": [{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{"type": "command", "command": format!("bash '{}' release", hook_path.display()), "timeout": 10},
|
||||
{"type": "command", "command": "echo keep-session-end", "timeout": 10}
|
||||
]
|
||||
}]
|
||||
}
|
||||
});
|
||||
fs::write(
|
||||
claude_dir.join("settings.json"),
|
||||
format!(
|
||||
r#"{{"hooks":{{"PostToolUse":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}},{{"type":"command","command":"echo keep-post","timeout":10}}]}}],"PostToolUseFailure":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}},{{"type":"command","command":"echo keep-failure","timeout":10}}]}}],"SubagentStop":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}},{{"type":"command","command":"echo keep-subagent","timeout":10}}]}}],"SessionEnd":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' release","timeout":10}},{{"type":"command","command":"echo keep-session-end","timeout":10}}]}}]}}}}"#,
|
||||
hook_path.display(),
|
||||
hook_path.display(),
|
||||
hook_path.display(),
|
||||
hook_path.display(),
|
||||
),
|
||||
serde_json::to_string(&settings).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
std::env::set_var("HOME", &home);
|
||||
@@ -3245,19 +3604,48 @@ mod tests {
|
||||
fs::create_dir_all(&hooks_dir).unwrap();
|
||||
let hook_path = hooks_dir.join(CLAUDE_HOOK_INSTALL_NAME);
|
||||
fs::write(&hook_path, CLAUDE_HOOK_ASSET).unwrap();
|
||||
let settings = serde_json::json!({
|
||||
"hooks": {
|
||||
"SessionStart": [{
|
||||
"matcher": "*",
|
||||
"hooks": [{"type": "command", "command": format!("bash '{}' idle", hook_path.display()), "timeout": 10}]
|
||||
}],
|
||||
"UserPromptSubmit": [{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{"type": "command", "command": format!("bash '{}' working", hook_path.display()), "timeout": 10},
|
||||
{"type": "command", "command": "echo keep", "timeout": 10}
|
||||
]
|
||||
}],
|
||||
"PermissionRequest": [{
|
||||
"matcher": "*",
|
||||
"hooks": [{"type": "command", "command": format!("bash '{}' blocked", hook_path.display()), "timeout": 10}]
|
||||
}],
|
||||
"PostToolUse": [{
|
||||
"matcher": "*",
|
||||
"hooks": [{"type": "command", "command": format!("bash '{}' working", hook_path.display()), "timeout": 10}]
|
||||
}],
|
||||
"PostToolUseFailure": [{
|
||||
"matcher": "*",
|
||||
"hooks": [{"type": "command", "command": format!("bash '{}' working", hook_path.display()), "timeout": 10}]
|
||||
}],
|
||||
"SubagentStop": [{
|
||||
"matcher": "*",
|
||||
"hooks": [{"type": "command", "command": format!("bash '{}' working", hook_path.display()), "timeout": 10}]
|
||||
}],
|
||||
"Stop": [{
|
||||
"matcher": "*",
|
||||
"hooks": [{"type": "command", "command": format!("bash '{}' idle", hook_path.display()), "timeout": 10}]
|
||||
}],
|
||||
"SessionEnd": [{
|
||||
"matcher": "*",
|
||||
"hooks": [{"type": "command", "command": format!("bash '{}' release", hook_path.display()), "timeout": 10}]
|
||||
}]
|
||||
}
|
||||
});
|
||||
fs::write(
|
||||
claude_dir.join("settings.json"),
|
||||
format!(
|
||||
r#"{{"hooks":{{"SessionStart":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' idle","timeout":10}}]}}],"UserPromptSubmit":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}},{{"type":"command","command":"echo keep","timeout":10}}]}}],"PermissionRequest":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' blocked","timeout":10}}]}}],"PostToolUse":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}}]}}],"PostToolUseFailure":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}}]}}],"SubagentStop":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}}]}}],"Stop":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' idle","timeout":10}}]}}],"SessionEnd":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' release","timeout":10}}]}}]}}}}"#,
|
||||
hook_path.display(),
|
||||
hook_path.display(),
|
||||
hook_path.display(),
|
||||
hook_path.display(),
|
||||
hook_path.display(),
|
||||
hook_path.display(),
|
||||
hook_path.display(),
|
||||
hook_path.display(),
|
||||
),
|
||||
serde_json::to_string(&settings).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
std::env::set_var("HOME", &home);
|
||||
@@ -3464,16 +3852,21 @@ mod tests {
|
||||
fs::create_dir_all(&codex_dir).unwrap();
|
||||
let hook_path = codex_dir.join(CODEX_HOOK_INSTALL_NAME);
|
||||
fs::write(&hook_path, CODEX_HOOK_ASSET).unwrap();
|
||||
let hooks = serde_json::json!({
|
||||
"hooks": {
|
||||
"SessionStart": [{"hooks": [{"type": "command", "command": format!("bash '{}' idle", hook_path.display()), "timeout": 10}]}],
|
||||
"UserPromptSubmit": [{"hooks": [
|
||||
{"type": "command", "command": format!("bash '{}' working", hook_path.display()), "timeout": 10},
|
||||
{"type": "command", "command": "echo keep", "timeout": 10}
|
||||
]}],
|
||||
"PreToolUse": [{"hooks": [{"type": "command", "command": format!("bash '{}' working", hook_path.display()), "timeout": 10}]}],
|
||||
"PermissionRequest": [{"hooks": [{"type": "command", "command": format!("bash '{}' blocked", hook_path.display()), "timeout": 10}]}],
|
||||
"Stop": [{"hooks": [{"type": "command", "command": format!("bash '{}' idle", hook_path.display()), "timeout": 10}]}]
|
||||
}
|
||||
});
|
||||
fs::write(
|
||||
codex_dir.join("hooks.json"),
|
||||
format!(
|
||||
r#"{{"hooks":{{"SessionStart":[{{"hooks":[{{"type":"command","command":"bash '{}' idle","timeout":10}}]}}],"UserPromptSubmit":[{{"hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}},{{"type":"command","command":"echo keep","timeout":10}}]}}],"PreToolUse":[{{"hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}}]}}],"PermissionRequest":[{{"hooks":[{{"type":"command","command":"bash '{}' blocked","timeout":10}}]}}],"Stop":[{{"hooks":[{{"type":"command","command":"bash '{}' idle","timeout":10}}]}}]}}}}"#,
|
||||
hook_path.display(),
|
||||
hook_path.display(),
|
||||
hook_path.display(),
|
||||
hook_path.display(),
|
||||
hook_path.display(),
|
||||
),
|
||||
serde_json::to_string(&hooks).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
@@ -3808,14 +4201,24 @@ mod tests {
|
||||
"bash {}",
|
||||
shell_single_quote(&hook_path.display().to_string())
|
||||
);
|
||||
let settings = serde_json::json!({
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{"type": "command", "command": command, "timeoutSec": 10},
|
||||
{"type": "command", "command": "echo keep", "timeoutSec": 10}
|
||||
],
|
||||
"PostToolUse": [{"type": "command", "command": command, "timeoutSec": 10}],
|
||||
"notification": [{
|
||||
"type": "command",
|
||||
"matcher": "permission_prompt|elicitation_dialog|agent_idle",
|
||||
"command": command,
|
||||
"timeoutSec": 10
|
||||
}]
|
||||
}
|
||||
});
|
||||
fs::write(
|
||||
copilot_dir.join("settings.json"),
|
||||
format!(
|
||||
r#"{{"hooks":{{"PreToolUse":[{{"type":"command","command":"{}","timeoutSec":10}},{{"type":"command","command":"echo keep","timeoutSec":10}}],"PostToolUse":[{{"type":"command","command":"{}","timeoutSec":10}}],"notification":[{{"type":"command","matcher":"permission_prompt|elicitation_dialog|agent_idle","command":"{}","timeoutSec":10}}]}}}}"#,
|
||||
command,
|
||||
command,
|
||||
command,
|
||||
),
|
||||
serde_json::to_string(&settings).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
std::env::set_var("HOME", &home);
|
||||
|
||||
+116
-12
@@ -1,14 +1,66 @@
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::os::unix::net::UnixStream;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::{MetadataExt, PermissionsExt};
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) type LocalListener = interprocess::local_socket::Listener;
|
||||
pub(crate) type LocalStream = interprocess::local_socket::Stream;
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct SocketFileIdentity {
|
||||
#[cfg(unix)]
|
||||
dev: u64,
|
||||
#[cfg(unix)]
|
||||
ino: u64,
|
||||
#[cfg(windows)]
|
||||
marker: Vec<u8>,
|
||||
}
|
||||
|
||||
pub(crate) fn connect_local_stream(path: &Path) -> io::Result<LocalStream> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use interprocess::local_socket::{prelude::*, GenericFilePath};
|
||||
|
||||
let name = path.to_fs_name::<GenericFilePath>()?;
|
||||
LocalStream::connect(name)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use interprocess::local_socket::{prelude::*, GenericNamespaced};
|
||||
|
||||
let name = path.to_string_lossy().to_string();
|
||||
let name = name.to_ns_name::<GenericNamespaced>()?;
|
||||
LocalStream::connect(name)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn bind_local_listener(path: &Path) -> io::Result<LocalListener> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use interprocess::local_socket::{prelude::*, GenericFilePath, ListenerOptions};
|
||||
|
||||
let name = path.to_fs_name::<GenericFilePath>()?;
|
||||
ListenerOptions::new()
|
||||
.name(name)
|
||||
.reclaim_name(false)
|
||||
.create_sync()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use interprocess::local_socket::{prelude::*, GenericNamespaced, ListenerOptions};
|
||||
|
||||
let name = path.to_string_lossy().to_string();
|
||||
let name = name.to_ns_name::<GenericNamespaced>()?;
|
||||
let listener = ListenerOptions::new()
|
||||
.name(name)
|
||||
.reclaim_name(false)
|
||||
.create_sync()?;
|
||||
fs::write(path, windows_socket_marker())?;
|
||||
Ok(listener)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn prepare_socket_path(
|
||||
@@ -23,7 +75,7 @@ pub(crate) fn prepare_socket_path(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match UnixStream::connect(path) {
|
||||
match connect_local_stream(path) {
|
||||
Ok(_) => {
|
||||
return Err(io::Error::new(io::ErrorKind::AddrInUse, busy_message(path)));
|
||||
}
|
||||
@@ -33,6 +85,7 @@ pub(crate) fn prepare_socket_path(
|
||||
io::ErrorKind::ConnectionRefused
|
||||
| io::ErrorKind::NotFound
|
||||
| io::ErrorKind::TimedOut
|
||||
| io::ErrorKind::WouldBlock
|
||||
) => {}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
@@ -47,16 +100,26 @@ pub(crate) fn prepare_socket_path(
|
||||
}
|
||||
|
||||
pub(crate) fn socket_file_identity(path: &Path) -> io::Result<SocketFileIdentity> {
|
||||
let metadata = fs::metadata(path)?;
|
||||
Ok(SocketFileIdentity {
|
||||
dev: metadata.dev(),
|
||||
ino: metadata.ino(),
|
||||
})
|
||||
#[cfg(windows)]
|
||||
{
|
||||
Ok(SocketFileIdentity {
|
||||
marker: fs::read(path)?,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let metadata = fs::metadata(path)?;
|
||||
Ok(SocketFileIdentity {
|
||||
dev: metadata.dev(),
|
||||
ino: metadata.ino(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn remove_socket_file_if_owned(
|
||||
path: &Path,
|
||||
identity: SocketFileIdentity,
|
||||
identity: &SocketFileIdentity,
|
||||
) -> io::Result<()> {
|
||||
let current = match socket_file_identity(path) {
|
||||
Ok(current) => current,
|
||||
@@ -64,7 +127,7 @@ pub(crate) fn remove_socket_file_if_owned(
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
|
||||
if current != identity {
|
||||
if current != *identity {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -75,8 +138,49 @@ pub(crate) fn remove_socket_file_if_owned(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn windows_socket_marker() -> String {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|duration| duration.as_nanos())
|
||||
.unwrap_or(0);
|
||||
format!("{}:{now}", std::process::id())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn restrict_socket_permissions(path: &Path, mode: u32) -> io::Result<()> {
|
||||
let mut permissions = fs::metadata(path)?.permissions();
|
||||
permissions.set_mode(mode);
|
||||
fs::set_permissions(path, permissions)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn restrict_socket_permissions(_path: &Path, _mode: u32) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(all(test, windows))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn remove_socket_file_if_owned_compares_windows_marker_contents() {
|
||||
let path = temp_socket_marker_path("same-len-marker");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
fs::write(&path, b"marker-aa").expect("write first marker");
|
||||
let identity = socket_file_identity(&path).expect("read first identity");
|
||||
fs::write(&path, b"marker-bb").expect("replace with same-length marker");
|
||||
|
||||
remove_socket_file_if_owned(&path, &identity).expect("remove owned marker");
|
||||
|
||||
assert!(path.exists(), "same-length replacement marker must survive");
|
||||
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
fn temp_socket_marker_path(name: &str) -> PathBuf {
|
||||
std::env::temp_dir().join(format!("herdr-{name}-{}.sock", std::process::id()))
|
||||
}
|
||||
}
|
||||
|
||||
+30
-10
@@ -2,9 +2,10 @@ use std::io;
|
||||
|
||||
use crossterm::event::{
|
||||
DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste,
|
||||
EnableFocusChange, EnableMouseCapture, PopKeyboardEnhancementFlags,
|
||||
PushKeyboardEnhancementFlags,
|
||||
EnableFocusChange, EnableMouseCapture,
|
||||
};
|
||||
#[cfg(not(windows))]
|
||||
use crossterm::event::{PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags};
|
||||
use crossterm::execute;
|
||||
|
||||
pub(crate) const HERDR_ENV_VAR: &str = "HERDR_ENV";
|
||||
@@ -18,6 +19,29 @@ const NESTED_HERDR_MESSAGES: [&str; 6] = [
|
||||
"recursion detected. base case not found. aborting.",
|
||||
];
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn push_keyboard_enhancement_flags() -> io::Result<()> {
|
||||
execute!(
|
||||
io::stdout(),
|
||||
PushKeyboardEnhancementFlags(crate::input::ime_compatible_keyboard_enhancement_flags())
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn push_keyboard_enhancement_flags() -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn pop_keyboard_enhancement_flags() -> io::Result<()> {
|
||||
execute!(io::stdout(), PopKeyboardEnhancementFlags)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn pop_keyboard_enhancement_flags() -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
mod agent_resume;
|
||||
mod api;
|
||||
mod app;
|
||||
@@ -632,11 +656,11 @@ fn main() -> io::Result<()> {
|
||||
}
|
||||
let _ = execute!(
|
||||
io::stdout(),
|
||||
PopKeyboardEnhancementFlags,
|
||||
DisableFocusChange,
|
||||
DisableBracketedPaste,
|
||||
DisableMouseCapture
|
||||
);
|
||||
let _ = pop_keyboard_enhancement_flags();
|
||||
ratatui::restore();
|
||||
original_hook(info);
|
||||
}));
|
||||
@@ -661,12 +685,8 @@ fn main() -> io::Result<()> {
|
||||
} else {
|
||||
execute!(io::stdout(), DisableMouseCapture)?;
|
||||
}
|
||||
execute!(
|
||||
io::stdout(),
|
||||
EnableBracketedPaste,
|
||||
EnableFocusChange,
|
||||
PushKeyboardEnhancementFlags(crate::input::ime_compatible_keyboard_enhancement_flags())
|
||||
)?;
|
||||
execute!(io::stdout(), EnableBracketedPaste, EnableFocusChange)?;
|
||||
push_keyboard_enhancement_flags()?;
|
||||
|
||||
// Some hosts do not honor Kitty keyboard enhancement pushes for
|
||||
// Shift+Enter. Enable xterm modifyOtherKeys only on hosts where we
|
||||
@@ -696,9 +716,9 @@ fn main() -> io::Result<()> {
|
||||
if crate::kitty_graphics::is_enabled() {
|
||||
crate::kitty_graphics::clear_all_host_graphics()?;
|
||||
}
|
||||
pop_keyboard_enhancement_flags()?;
|
||||
execute!(
|
||||
io::stdout(),
|
||||
PopKeyboardEnhancementFlags,
|
||||
DisableFocusChange,
|
||||
DisableBracketedPaste,
|
||||
DisableMouseCapture
|
||||
|
||||
+164
-12
@@ -8,7 +8,7 @@ use std::sync::{
|
||||
|
||||
use bytes::Bytes;
|
||||
use portable_pty::CommandBuilder;
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, unix))]
|
||||
use portable_pty::{native_pty_system, PtySize};
|
||||
use ratatui::{layout::Rect, Frame};
|
||||
#[cfg(test)]
|
||||
@@ -19,7 +19,6 @@ use tracing::{debug, error, info, warn};
|
||||
use crate::detect::{Agent, AgentState};
|
||||
use crate::events::AppEvent;
|
||||
use crate::layout::PaneId;
|
||||
#[cfg(unix)]
|
||||
use crate::pty::actor::{PtyIoActor, PtyIoActorConfig, PtyIoActorHandle, PtyReadResult};
|
||||
|
||||
mod input;
|
||||
@@ -136,10 +135,12 @@ fn should_clear_agent_for_foreground_shell(
|
||||
previous_agent.is_some() && new_agent.is_none() && foreground_is_pane_shell
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn usable_process_cwd(pid: u32) -> Option<std::path::PathBuf> {
|
||||
crate::platform::process_cwd(pid).filter(|cwd| cwd.is_absolute() && cwd.is_dir())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn foreground_member_cwd_different_from_shell(
|
||||
shell_pid: u32,
|
||||
shell_cwd: Option<&std::path::PathBuf>,
|
||||
@@ -391,6 +392,7 @@ fn detection_update_for_publish(
|
||||
(!detection.skip_state_update).then_some(detection)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn spawn_basic_detection_task(
|
||||
pane_id: PaneId,
|
||||
child_pid: Arc<AtomicU32>,
|
||||
@@ -652,6 +654,7 @@ pub struct PaneRuntime {
|
||||
io: PaneRuntimeIo,
|
||||
current_size: Cell<(u16, u16, u32, u32)>,
|
||||
child_pid: Arc<AtomicU32>,
|
||||
reported_cwd: Arc<Mutex<Option<std::path::PathBuf>>>,
|
||||
child_wait_completed: Option<Arc<AtomicBool>>,
|
||||
kitty_keyboard_flags: Arc<AtomicU16>,
|
||||
detect_reset_notify: Arc<Notify>,
|
||||
@@ -662,7 +665,6 @@ pub struct PaneRuntime {
|
||||
}
|
||||
|
||||
enum PaneRuntimeIo {
|
||||
#[cfg(unix)]
|
||||
Actor(PtyIoActorHandle),
|
||||
#[cfg(test)]
|
||||
TestChannel {
|
||||
@@ -674,7 +676,6 @@ enum PaneRuntimeIo {
|
||||
impl PaneRuntimeIo {
|
||||
fn shutdown(&self) {
|
||||
match self {
|
||||
#[cfg(unix)]
|
||||
PaneRuntimeIo::Actor(actor) => actor.shutdown(),
|
||||
#[cfg(test)]
|
||||
PaneRuntimeIo::TestChannel { .. } => {}
|
||||
@@ -743,7 +744,6 @@ impl PaneRuntimeIo {
|
||||
terminal_responses: Vec<Bytes>,
|
||||
) {
|
||||
match self {
|
||||
#[cfg(unix)]
|
||||
PaneRuntimeIo::Actor(actor) => {
|
||||
actor.resize(
|
||||
rows,
|
||||
@@ -760,6 +760,7 @@ impl PaneRuntimeIo {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn nudge_child_redraw_after_handoff(
|
||||
&self,
|
||||
rows: u16,
|
||||
@@ -768,7 +769,6 @@ impl PaneRuntimeIo {
|
||||
cell_height_px: u32,
|
||||
) {
|
||||
match self {
|
||||
#[cfg(unix)]
|
||||
PaneRuntimeIo::Actor(actor) => {
|
||||
actor.nudge_child_redraw_after_handoff(rows, cols, cell_width_px, cell_height_px);
|
||||
}
|
||||
@@ -779,7 +779,6 @@ impl PaneRuntimeIo {
|
||||
|
||||
async fn send_bytes(&self, bytes: Bytes) -> Result<(), mpsc::error::SendError<Bytes>> {
|
||||
match self {
|
||||
#[cfg(unix)]
|
||||
PaneRuntimeIo::Actor(actor) => actor.write_user_input(bytes).await,
|
||||
#[cfg(test)]
|
||||
PaneRuntimeIo::TestChannel { sender, .. } => sender.send(bytes).await,
|
||||
@@ -788,7 +787,6 @@ impl PaneRuntimeIo {
|
||||
|
||||
fn try_send_bytes(&self, bytes: Bytes) -> Result<(), mpsc::error::TrySendError<Bytes>> {
|
||||
match self {
|
||||
#[cfg(unix)]
|
||||
PaneRuntimeIo::Actor(actor) => actor.try_write_user_input(bytes),
|
||||
#[cfg(test)]
|
||||
PaneRuntimeIo::TestChannel { sender, .. } => sender.try_send(bytes),
|
||||
@@ -934,10 +932,27 @@ fn pane_shell_from(configured_shell: &str, env_shell: Option<String>) -> String
|
||||
return configured_shell.to_string();
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = env_shell;
|
||||
return default_pane_shell();
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
env_shell
|
||||
.map(|shell| shell.trim().to_string())
|
||||
.filter(|shell| !shell.is_empty())
|
||||
.unwrap_or_else(|| "/bin/sh".into())
|
||||
.unwrap_or_else(default_pane_shell)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn default_pane_shell() -> String {
|
||||
"powershell.exe".into()
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn default_pane_shell() -> String {
|
||||
"/bin/sh".into()
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -1022,7 +1037,9 @@ fn pane_shell_command_builder_for_target(
|
||||
cmd.env("SHELL", resolve_shell_for_login_mode(&shell)?);
|
||||
Ok(cmd)
|
||||
} else {
|
||||
Ok(CommandBuilder::new(&shell))
|
||||
let mut cmd = CommandBuilder::new(&shell);
|
||||
apply_windows_powershell_cwd_reporting(&mut cmd, &shell);
|
||||
Ok(cmd)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1030,6 +1047,67 @@ fn pane_shell_command_builder(shell_config: PaneShellConfig<'_>) -> io::Result<C
|
||||
pane_shell_command_builder_for_target(shell_config, cfg!(target_os = "macos"))
|
||||
}
|
||||
|
||||
fn apply_windows_powershell_cwd_reporting(cmd: &mut CommandBuilder, shell: &str) {
|
||||
if !is_windows_powershell_shell(shell) {
|
||||
return;
|
||||
}
|
||||
cmd.arg("-NoExit");
|
||||
cmd.arg("-Command");
|
||||
cmd.arg(windows_powershell_cwd_prompt_wrapper());
|
||||
}
|
||||
|
||||
fn is_windows_powershell_shell(shell: &str) -> bool {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let name = Path::new(shell)
|
||||
.file_name()
|
||||
.and_then(std::ffi::OsStr::to_str)
|
||||
.unwrap_or(shell)
|
||||
.to_ascii_lowercase();
|
||||
matches!(
|
||||
name.as_str(),
|
||||
"powershell" | "powershell.exe" | "pwsh" | "pwsh.exe"
|
||||
)
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
let _ = shell;
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn windows_powershell_cwd_prompt_wrapper() -> &'static str {
|
||||
r#"$global:__HERDR_ORIGINAL_PROMPT = if (Test-Path Function:\prompt) { (Get-Command prompt -CommandType Function).ScriptBlock } else { { "PS $($executionContext.SessionState.Path.CurrentLocation)$('>' * ($nestedPromptLevel + 1)) " } }; function global:prompt { try { if ($PWD.Provider.Name -eq 'FileSystem') { $uri = ([System.Uri]$PWD.ProviderPath).AbsoluteUri; [Console]::Write("$([char]27)]7;$uri$([char]7)") } } catch {}; & $global:__HERDR_ORIGINAL_PROMPT }"#
|
||||
}
|
||||
|
||||
fn usable_reported_cwd(cwd: std::path::PathBuf) -> Option<std::path::PathBuf> {
|
||||
(cwd.is_absolute() && cwd.is_dir()).then_some(cwd)
|
||||
}
|
||||
|
||||
fn publish_reported_cwd(
|
||||
pane_id: PaneId,
|
||||
cwd: std::path::PathBuf,
|
||||
reported_cwd: &Arc<Mutex<Option<std::path::PathBuf>>>,
|
||||
events: &mpsc::Sender<AppEvent>,
|
||||
) {
|
||||
let Some(cwd) = usable_reported_cwd(cwd) else {
|
||||
return;
|
||||
};
|
||||
if let Ok(mut current) = reported_cwd.lock() {
|
||||
if current.as_ref() == Some(&cwd) {
|
||||
return;
|
||||
}
|
||||
*current = Some(cwd.clone());
|
||||
}
|
||||
if let Err(err) = events.try_send(AppEvent::TerminalCwdReported { pane_id, cwd }) {
|
||||
warn!(
|
||||
pane = pane_id.raw(),
|
||||
err = %err,
|
||||
"failed to send terminal cwd report"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl PaneRuntime {
|
||||
pub fn shutdown(mut self) {
|
||||
self.detect_handle.abort();
|
||||
@@ -1319,6 +1397,7 @@ impl PaneRuntime {
|
||||
}
|
||||
let terminal = Arc::new(PaneTerminal::new(pane_terminal));
|
||||
let child_pid = Arc::new(AtomicU32::new(child_pid));
|
||||
let reported_cwd = Arc::new(Mutex::new(None));
|
||||
let kitty_keyboard_flags = Arc::new(AtomicU16::new(keyboard_protocol_flags));
|
||||
|
||||
let io = {
|
||||
@@ -1328,6 +1407,7 @@ impl PaneRuntime {
|
||||
let render_dirty = render_dirty.clone();
|
||||
let child_pid = child_pid.clone();
|
||||
let read_events = events.clone();
|
||||
let reported_cwd = reported_cwd.clone();
|
||||
let rt = tokio::runtime::Handle::current();
|
||||
let delay_rt = rt.clone();
|
||||
let on_read = Box::new(move |bytes: &[u8]| {
|
||||
@@ -1347,6 +1427,9 @@ impl PaneRuntime {
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(cwd) = result.reported_cwd.clone() {
|
||||
publish_reported_cwd(pane_id, cwd, &reported_cwd, &read_events);
|
||||
}
|
||||
for content in result.clipboard_writes {
|
||||
if let Err(err) = read_events.try_send(AppEvent::ClipboardWrite { content }) {
|
||||
warn!(
|
||||
@@ -1383,6 +1466,7 @@ impl PaneRuntime {
|
||||
io,
|
||||
current_size: Cell::new((rows, cols, cell_width_px, cell_height_px)),
|
||||
child_pid,
|
||||
reported_cwd,
|
||||
child_wait_completed: None,
|
||||
kitty_keyboard_flags,
|
||||
detect_reset_notify,
|
||||
@@ -1431,6 +1515,7 @@ impl PaneRuntime {
|
||||
|
||||
// --- Child watcher task ---
|
||||
let child_pid = Arc::new(AtomicU32::new(0));
|
||||
let reported_cwd = Arc::new(Mutex::new(None));
|
||||
let child_wait_completed = Arc::new(AtomicBool::new(false));
|
||||
{
|
||||
let child_pid = child_pid.clone();
|
||||
@@ -1465,6 +1550,7 @@ impl PaneRuntime {
|
||||
let render_dirty = render_dirty.clone();
|
||||
let child_pid = child_pid.clone();
|
||||
let events = events.clone();
|
||||
let reported_cwd = reported_cwd.clone();
|
||||
let rt = tokio::runtime::Handle::current();
|
||||
let on_read = Box::new(move |bytes: &[u8]| {
|
||||
let shell_pid = child_pid.load(Ordering::Acquire);
|
||||
@@ -1483,6 +1569,9 @@ impl PaneRuntime {
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(cwd) = result.reported_cwd.clone() {
|
||||
publish_reported_cwd(pane_id, cwd, &reported_cwd, &events);
|
||||
}
|
||||
for content in result.clipboard_writes {
|
||||
if let Err(err) = events.try_send(AppEvent::ClipboardWrite { content }) {
|
||||
warn!(
|
||||
@@ -1498,7 +1587,10 @@ impl PaneRuntime {
|
||||
});
|
||||
PaneRuntimeIo::Actor(PtyIoActor::spawn(PtyIoActorConfig {
|
||||
pane_id: pane_id.raw(),
|
||||
#[cfg(unix)]
|
||||
master_fd: spawned.master_fd,
|
||||
#[cfg(windows)]
|
||||
master: spawned.master,
|
||||
initially_quiesced: false,
|
||||
on_read,
|
||||
on_reader_exit: None,
|
||||
@@ -1815,6 +1907,7 @@ impl PaneRuntime {
|
||||
io,
|
||||
current_size: Cell::new((rows, cols, 0, 0)),
|
||||
child_pid,
|
||||
reported_cwd,
|
||||
child_wait_completed: Some(child_wait_completed),
|
||||
kitty_keyboard_flags,
|
||||
detect_reset_notify,
|
||||
@@ -1860,6 +1953,7 @@ impl PaneRuntime {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn nudge_child_redraw_after_handoff(&self) {
|
||||
let (rows, cols, cell_width_px, cell_height_px) = self.current_size.get();
|
||||
self.io
|
||||
@@ -2092,6 +2186,15 @@ impl PaneRuntime {
|
||||
|
||||
/// Get the current working directory of the child shell process.
|
||||
pub fn cwd(&self) -> Option<std::path::PathBuf> {
|
||||
if let Some(cwd) = self
|
||||
.reported_cwd
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|reported_cwd| reported_cwd.clone())
|
||||
.and_then(usable_reported_cwd)
|
||||
{
|
||||
return Some(cwd);
|
||||
}
|
||||
let pid = self.child_pid.load(Ordering::Relaxed);
|
||||
crate::platform::process_cwd(pid)
|
||||
}
|
||||
@@ -2180,6 +2283,7 @@ impl PaneRuntime {
|
||||
},
|
||||
current_size: Cell::new((rows, cols, 0, 0)),
|
||||
child_pid: Arc::new(AtomicU32::new(0)),
|
||||
reported_cwd: Arc::new(Mutex::new(None)),
|
||||
child_wait_completed: None,
|
||||
kitty_keyboard_flags: Arc::new(AtomicU16::new(0)),
|
||||
detect_reset_notify: Arc::new(Notify::new()),
|
||||
@@ -2216,6 +2320,7 @@ mod tests {
|
||||
assert!(!process_alive_for_shutdown(43, 42, false, |_| false));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn capture_shell_output(command: &str, extra_env: &[(&str, &str)]) -> String {
|
||||
let pair = native_pty_system()
|
||||
.openpty(PtySize {
|
||||
@@ -2261,6 +2366,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[test]
|
||||
fn pane_shell_falls_back_to_shell_env() {
|
||||
assert_eq!(
|
||||
@@ -2269,10 +2375,22 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn pane_shell_ignores_shell_env_on_windows() {
|
||||
assert_eq!(
|
||||
pane_shell_from("", Some("c:\\windows\\system32\\cmd.exe".to_string())),
|
||||
default_pane_shell()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pane_shell_ignores_empty_values() {
|
||||
assert_eq!(pane_shell_from(" ", Some(" ".to_string())), "/bin/sh");
|
||||
assert_eq!(pane_shell_from("", None), "/bin/sh");
|
||||
assert_eq!(
|
||||
pane_shell_from(" ", Some(" ".to_string())),
|
||||
default_pane_shell()
|
||||
);
|
||||
assert_eq!(pane_shell_from("", None), default_pane_shell());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2295,6 +2413,7 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn login_shell_builder_uses_default_prog_with_resolved_shell_env() {
|
||||
let cmd = pane_shell_command_builder_for_target(
|
||||
@@ -2309,6 +2428,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn auto_shell_builder_uses_login_shell_on_macos_target() {
|
||||
let cmd = pane_shell_command_builder_for_target(
|
||||
@@ -2334,6 +2454,27 @@ mod tests {
|
||||
assert_eq!(cmd.get_argv(), &[std::ffi::OsString::from("/bin/sh")]);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_powershell_shell_builder_wraps_cwd_reporting_prompt() {
|
||||
let cmd = pane_shell_command_builder_for_target(
|
||||
PaneShellConfig::new("powershell.exe", crate::config::ShellModeConfig::NonLogin),
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let argv: Vec<_> = cmd
|
||||
.get_argv()
|
||||
.iter()
|
||||
.map(|arg| arg.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
|
||||
assert_eq!(argv[0], "powershell.exe");
|
||||
assert!(argv.iter().any(|arg| arg == "-NoExit"));
|
||||
assert!(argv
|
||||
.iter()
|
||||
.any(|arg| arg.contains("]7;") && arg.contains("Function:\\prompt")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_shell_builder_rejects_missing_shell_instead_of_falling_back() {
|
||||
let err = pane_shell_command_builder_for_target(
|
||||
@@ -2347,6 +2488,7 @@ mod tests {
|
||||
assert_eq!(err.kind(), io::ErrorKind::NotFound);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn login_shell_builder_resolves_bare_shell_names_from_path() {
|
||||
let _lock = crate::integration::integration_env_lock();
|
||||
@@ -2388,6 +2530,7 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn login_shell_resolution_preserves_shell_paths() {
|
||||
assert_eq!(resolve_shell_for_login_mode("/bin/sh").unwrap(), "/bin/sh");
|
||||
@@ -2404,12 +2547,14 @@ mod tests {
|
||||
assert_eq!(cmd.get_argv(), &[std::ffi::OsString::from("/bin/sh")]);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn pane_terminal_identity_overrides_outer_terminal_env() {
|
||||
let output = capture_shell_output("printf '%s\\n%s\\n' \"$TERM\" \"$COLORTERM\"", &[]);
|
||||
assert_eq!(output, "xterm-256color\ntruecolor\n");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn pane_terminal_identity_allows_explicit_override() {
|
||||
let output = capture_shell_output(
|
||||
@@ -2419,6 +2564,7 @@ mod tests {
|
||||
assert_eq!(output, "vt100\n24bit\n");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn handoff_history_ansi_captures_primary_screen() {
|
||||
let runtime =
|
||||
@@ -2429,6 +2575,7 @@ mod tests {
|
||||
assert!(history.contains("handoff-primary-history"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn handoff_history_ansi_skips_alternate_screen() {
|
||||
let runtime = PaneRuntime::test_with_scrollback_bytes(
|
||||
@@ -2441,6 +2588,7 @@ mod tests {
|
||||
assert!(runtime.handoff_history_ansi().is_none());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn handoff_runtime_state_captures_terminal_input_state() {
|
||||
let runtime = PaneRuntime::test_with_screen_bytes(
|
||||
@@ -2467,6 +2615,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn truncate_handoff_history_keeps_recent_utf8_boundary() {
|
||||
let history = format!("old\n{}\nrecent\n", "é".repeat(8));
|
||||
@@ -2477,6 +2626,7 @@ mod tests {
|
||||
assert!(truncated.is_char_boundary(0));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn truncate_handoff_history_drops_partial_long_line() {
|
||||
let history = format!("old\n{}", "x".repeat(64));
|
||||
@@ -2505,6 +2655,7 @@ mod tests {
|
||||
},
|
||||
current_size: Cell::new((80, 24, 0, 0)),
|
||||
child_pid: Arc::new(AtomicU32::new(0)),
|
||||
reported_cwd: Arc::new(Mutex::new(None)),
|
||||
child_wait_completed: None,
|
||||
kitty_keyboard_flags: Arc::new(AtomicU16::new(0)),
|
||||
detect_reset_notify: Arc::new(Notify::new()),
|
||||
@@ -2533,6 +2684,7 @@ mod tests {
|
||||
},
|
||||
current_size: Cell::new((80, 24, 0, 0)),
|
||||
child_pid: Arc::new(AtomicU32::new(0)),
|
||||
reported_cwd: Arc::new(Mutex::new(None)),
|
||||
child_wait_completed: None,
|
||||
kitty_keyboard_flags: Arc::new(AtomicU16::new(0)),
|
||||
detect_reset_notify: Arc::new(Notify::new()),
|
||||
|
||||
@@ -81,6 +81,7 @@ impl KittyKeyboardTracker {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn replay_ansi(&self) -> Option<String> {
|
||||
if self.stack.is_empty() {
|
||||
return (self.flags != 0).then(|| format!("\x1b[={}u", self.flags));
|
||||
|
||||
+166
@@ -1,4 +1,5 @@
|
||||
use std::borrow::Cow;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use tracing::info;
|
||||
|
||||
@@ -359,6 +360,145 @@ impl Osc52Forwarder {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconstructs cwd-reporting OSC sequences from child output. Shell
|
||||
/// integrations commonly use OSC 7 (`file://...`), while Windows Terminal
|
||||
/// documents OSC 9;9 for the same practical purpose.
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct CwdOscTracker {
|
||||
state: Osc52ForwarderState,
|
||||
body: Vec<u8>,
|
||||
pending: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl CwdOscTracker {
|
||||
pub(super) fn observe(&mut self, bytes: &[u8]) {
|
||||
for &byte in bytes {
|
||||
match self.state {
|
||||
Osc52ForwarderState::Ground => {
|
||||
if byte == 0x1b {
|
||||
self.state = Osc52ForwarderState::Escape;
|
||||
}
|
||||
}
|
||||
Osc52ForwarderState::Escape => {
|
||||
if byte == b']' {
|
||||
self.body.clear();
|
||||
self.state = Osc52ForwarderState::OscBody;
|
||||
} else if byte == 0x1b {
|
||||
self.state = Osc52ForwarderState::Escape;
|
||||
} else {
|
||||
self.state = Osc52ForwarderState::Ground;
|
||||
}
|
||||
}
|
||||
Osc52ForwarderState::OscBody => match byte {
|
||||
0x07 => {
|
||||
self.finalize();
|
||||
self.state = Osc52ForwarderState::Ground;
|
||||
}
|
||||
0x1b => self.state = Osc52ForwarderState::OscEscape,
|
||||
_ => self.body.push(byte),
|
||||
},
|
||||
Osc52ForwarderState::OscEscape => {
|
||||
if byte == b'\\' {
|
||||
self.finalize();
|
||||
self.state = Osc52ForwarderState::Ground;
|
||||
} else {
|
||||
self.body.push(0x1b);
|
||||
self.body.push(byte);
|
||||
self.state = Osc52ForwarderState::OscBody;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if self.body.len() > 4096 {
|
||||
self.body.clear();
|
||||
self.state = Osc52ForwarderState::Ground;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn finalize(&mut self) {
|
||||
if let Some(cwd) = parse_cwd_osc(&self.body) {
|
||||
self.pending.push(cwd);
|
||||
}
|
||||
self.body.clear();
|
||||
}
|
||||
|
||||
pub(super) fn drain_latest(&mut self) -> Option<PathBuf> {
|
||||
self.pending.drain(..).next_back()
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_cwd_osc(body: &[u8]) -> Option<PathBuf> {
|
||||
let body = std::str::from_utf8(body).ok()?;
|
||||
if let Some(uri) = body.strip_prefix("7;") {
|
||||
return parse_file_uri_cwd(uri);
|
||||
}
|
||||
if let Some(path) = body.strip_prefix("9;9;") {
|
||||
let path = path.trim().trim_matches('"');
|
||||
return (!path.is_empty()).then(|| PathBuf::from(path));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_file_uri_cwd(uri: &str) -> Option<PathBuf> {
|
||||
let rest = uri.strip_prefix("file://")?;
|
||||
let path = if rest.starts_with('/') {
|
||||
rest
|
||||
} else if let Some(slash) = rest.find('/') {
|
||||
let host = &rest[..slash];
|
||||
if !(host.is_empty() || host.eq_ignore_ascii_case("localhost")) {
|
||||
return None;
|
||||
}
|
||||
&rest[slash..]
|
||||
} else {
|
||||
rest
|
||||
};
|
||||
let path = percent_decode_utf8(path)?;
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let mut path = path;
|
||||
if path.len() >= 3
|
||||
&& path.as_bytes()[0] == b'/'
|
||||
&& path.as_bytes()[2] == b':'
|
||||
&& path.as_bytes()[1].is_ascii_alphabetic()
|
||||
{
|
||||
path.remove(0);
|
||||
}
|
||||
return Some(PathBuf::from(path.replace('/', "\\")));
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
Some(PathBuf::from(path))
|
||||
}
|
||||
|
||||
fn percent_decode_utf8(input: &str) -> Option<String> {
|
||||
let bytes = input.as_bytes();
|
||||
let mut output = Vec::with_capacity(bytes.len());
|
||||
let mut idx = 0;
|
||||
while idx < bytes.len() {
|
||||
if bytes[idx] == b'%' {
|
||||
let hi = *bytes.get(idx + 1)?;
|
||||
let lo = *bytes.get(idx + 2)?;
|
||||
output.push(hex_value(hi)? * 16 + hex_value(lo)?);
|
||||
idx += 3;
|
||||
} else {
|
||||
output.push(bytes[idx]);
|
||||
idx += 1;
|
||||
}
|
||||
}
|
||||
String::from_utf8(output).ok()
|
||||
}
|
||||
|
||||
fn hex_value(byte: u8) -> Option<u8> {
|
||||
match byte {
|
||||
b'0'..=b'9' => Some(byte - b'0'),
|
||||
b'a'..=b'f' => Some(byte - b'a' + 10),
|
||||
b'A'..=b'F' => Some(byte - b'A' + 10),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Accepts `52;c;<base64>` and `52;;<base64>`.
|
||||
/// Queries (`?`) are rejected because herdr has no reply path.
|
||||
/// The payload must decode as base64 before it is forwarded.
|
||||
@@ -600,6 +740,32 @@ mod tests {
|
||||
assert!(!tracker.observe(b"\x1b]11;?\x07"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cwd_osc_tracker_detects_split_osc7_sequence() {
|
||||
let mut tracker = CwdOscTracker::default();
|
||||
|
||||
tracker.observe(b"\x1b]7;file:///tmp/herdr%20repo");
|
||||
assert_eq!(tracker.drain_latest(), None);
|
||||
tracker.observe(b"\x07");
|
||||
|
||||
assert_eq!(
|
||||
tracker.drain_latest(),
|
||||
Some(std::path::PathBuf::from("/tmp/herdr repo"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cwd_osc_tracker_detects_windows_terminal_cwd_sequence() {
|
||||
let mut tracker = CwdOscTracker::default();
|
||||
|
||||
tracker.observe(b"\x1b]9;9;C:\\Users\\herdr\\src\\herdr\x1b\\");
|
||||
|
||||
assert_eq!(
|
||||
tracker.drain_latest(),
|
||||
Some(std::path::PathBuf::from("C:\\Users\\herdr\\src\\herdr"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_color_event_tracker_detects_queries_sets_and_resets() {
|
||||
let mut tracker = DefaultColorEventTracker::default();
|
||||
|
||||
+16
-1
@@ -23,7 +23,7 @@ use super::{
|
||||
osc::{
|
||||
contains_scrollback_clear_sequence, current_transient_default_color_owner,
|
||||
maybe_filter_primary_screen_scrollback_clear, restore_host_terminal_theme_if_needed,
|
||||
write_host_terminal_theme, DefaultColorEvent, DefaultColorEventTracker,
|
||||
write_host_terminal_theme, CwdOscTracker, DefaultColorEvent, DefaultColorEventTracker,
|
||||
DefaultColorOscTracker, DefaultColorQuery, DefaultColorTrackedEvent, Osc52Forwarder,
|
||||
},
|
||||
xtgettcap::{XtgettcapQueryTracker, XtgettcapResponse},
|
||||
@@ -101,6 +101,7 @@ pub(crate) struct ProcessBytesResult {
|
||||
pub request_render: bool,
|
||||
pub render_delay: Option<Duration>,
|
||||
pub clipboard_writes: Vec<Vec<u8>>,
|
||||
pub reported_cwd: Option<std::path::PathBuf>,
|
||||
pub terminal_responses: Vec<Bytes>,
|
||||
}
|
||||
|
||||
@@ -123,6 +124,7 @@ pub(crate) struct GhosttyPaneCore {
|
||||
pub child_default_foreground_changed: bool,
|
||||
pub child_default_background_changed: bool,
|
||||
pub osc52_forwarder: Osc52Forwarder,
|
||||
pub cwd_osc_tracker: CwdOscTracker,
|
||||
pub xtgettcap_query_tracker: XtgettcapQueryTracker,
|
||||
}
|
||||
|
||||
@@ -264,6 +266,7 @@ impl PaneTerminal {
|
||||
self.ghostty.keyboard_protocol().unwrap_or(fallback)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn kitty_keyboard_state_ansi(&self) -> Option<String> {
|
||||
self.ghostty
|
||||
.kitty_keyboard_state_ansi()
|
||||
@@ -352,6 +355,7 @@ impl GhosttyPaneTerminal {
|
||||
child_default_foreground_changed: false,
|
||||
child_default_background_changed: false,
|
||||
osc52_forwarder: Osc52Forwarder::default(),
|
||||
cwd_osc_tracker: CwdOscTracker::default(),
|
||||
xtgettcap_query_tracker: XtgettcapQueryTracker::default(),
|
||||
}),
|
||||
key_encoder: Mutex::new(key_encoder),
|
||||
@@ -419,6 +423,7 @@ impl GhosttyPaneTerminal {
|
||||
request_render: false,
|
||||
render_delay: None,
|
||||
clipboard_writes: Vec::new(),
|
||||
reported_cwd: None,
|
||||
terminal_responses: Vec::new(),
|
||||
};
|
||||
};
|
||||
@@ -436,6 +441,8 @@ impl GhosttyPaneTerminal {
|
||||
|
||||
core.osc52_forwarder.observe(bytes);
|
||||
let clipboard_writes = core.osc52_forwarder.drain_pending();
|
||||
core.cwd_osc_tracker.observe(bytes);
|
||||
let reported_cwd = core.cwd_osc_tracker.drain_latest();
|
||||
|
||||
let alternate_screen = core
|
||||
.terminal
|
||||
@@ -507,6 +514,7 @@ impl GhosttyPaneTerminal {
|
||||
request_render,
|
||||
render_delay,
|
||||
clipboard_writes,
|
||||
reported_cwd,
|
||||
terminal_responses,
|
||||
}
|
||||
}
|
||||
@@ -576,6 +584,7 @@ impl GhosttyPaneTerminal {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn seed_handoff_input_state(&self, input_state: InputState) {
|
||||
let Ok(mut core) = self.core.lock() else {
|
||||
return;
|
||||
@@ -647,6 +656,7 @@ impl GhosttyPaneTerminal {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn seed_keyboard_protocol_flags(&self, flags: u16) {
|
||||
if flags == 0 {
|
||||
return;
|
||||
@@ -654,6 +664,7 @@ impl GhosttyPaneTerminal {
|
||||
self.seed_keyboard_protocol_ansi(&format!("\x1b[>{flags}u"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn seed_keyboard_protocol_ansi(&self, ansi: &str) {
|
||||
if ansi.is_empty() {
|
||||
return;
|
||||
@@ -785,6 +796,7 @@ impl GhosttyPaneTerminal {
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn kitty_keyboard_state_ansi(&self) -> Option<String> {
|
||||
let core = self.core.lock().ok()?;
|
||||
core.kitty_keyboard.replay_ansi()
|
||||
@@ -2144,6 +2156,7 @@ mod tests {
|
||||
assert_eq!(encoded, b"\x1bOA");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn ghostty_seed_handoff_input_state_restores_input_modes() {
|
||||
let (tx, _rx) = mpsc::channel(4);
|
||||
@@ -2254,6 +2267,7 @@ mod tests {
|
||||
assert_eq!(encoded, b"\x1b[13;2u");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn ghostty_seed_keyboard_protocol_flags_restores_shift_enter_encoding() {
|
||||
let (tx, _rx) = mpsc::channel(4);
|
||||
@@ -2271,6 +2285,7 @@ mod tests {
|
||||
assert_eq!(encoded, b"\x1b[13;2u");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn ghostty_keyboard_protocol_state_replays_nested_stack() {
|
||||
let (tx, _rx) = mpsc::channel(4);
|
||||
|
||||
+89
-40
@@ -455,32 +455,58 @@ fn restore_tab(
|
||||
continue;
|
||||
}
|
||||
|
||||
let runtime_result = if let Some(imported) = imported_runtime {
|
||||
TerminalRuntime::from_handoff_fd(
|
||||
crate::handoff_runtime::ImportedHandoffRuntime {
|
||||
master_fd: imported.master_fd,
|
||||
state: imported.state.with_pane_id(*id),
|
||||
},
|
||||
runtime_context.scrollback_limit_bytes,
|
||||
crate::terminal_theme::TerminalTheme::default(),
|
||||
runtime_context.events.clone(),
|
||||
runtime_context.render_notify.clone(),
|
||||
runtime_context.render_dirty.clone(),
|
||||
)
|
||||
} else {
|
||||
TerminalRuntime::spawn_with_initial_history(
|
||||
*id,
|
||||
rows,
|
||||
cols,
|
||||
cwd.clone(),
|
||||
runtime_context.scrollback_limit_bytes,
|
||||
crate::terminal_theme::TerminalTheme::default(),
|
||||
runtime_context.shell_config,
|
||||
startup.initial_history_ansi,
|
||||
runtime_context.events.clone(),
|
||||
runtime_context.render_notify.clone(),
|
||||
runtime_context.render_dirty.clone(),
|
||||
)
|
||||
#[cfg(not(unix))]
|
||||
if imported_runtime.is_some() {
|
||||
failed_imports += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let runtime_result = {
|
||||
#[cfg(unix)]
|
||||
if let Some(imported) = imported_runtime {
|
||||
TerminalRuntime::from_handoff_fd(
|
||||
crate::handoff_runtime::ImportedHandoffRuntime {
|
||||
master_fd: imported.master_fd,
|
||||
state: imported.state.with_pane_id(*id),
|
||||
},
|
||||
runtime_context.scrollback_limit_bytes,
|
||||
crate::terminal_theme::TerminalTheme::default(),
|
||||
runtime_context.events.clone(),
|
||||
runtime_context.render_notify.clone(),
|
||||
runtime_context.render_dirty.clone(),
|
||||
)
|
||||
} else {
|
||||
TerminalRuntime::spawn_with_initial_history(
|
||||
*id,
|
||||
rows,
|
||||
cols,
|
||||
cwd.clone(),
|
||||
runtime_context.scrollback_limit_bytes,
|
||||
crate::terminal_theme::TerminalTheme::default(),
|
||||
runtime_context.shell_config,
|
||||
startup.initial_history_ansi,
|
||||
runtime_context.events.clone(),
|
||||
runtime_context.render_notify.clone(),
|
||||
runtime_context.render_dirty.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
TerminalRuntime::spawn_with_initial_history(
|
||||
*id,
|
||||
rows,
|
||||
cols,
|
||||
cwd.clone(),
|
||||
runtime_context.scrollback_limit_bytes,
|
||||
crate::terminal_theme::TerminalTheme::default(),
|
||||
runtime_context.shell_config,
|
||||
startup.initial_history_ansi,
|
||||
runtime_context.events.clone(),
|
||||
runtime_context.render_notify.clone(),
|
||||
runtime_context.render_dirty.clone(),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
match runtime_result {
|
||||
@@ -771,6 +797,24 @@ fn collect_ids_inner(node: &Node, ids: &mut Vec<PaneId>) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_session_path(name: &str) -> String {
|
||||
std::env::current_dir()
|
||||
.unwrap()
|
||||
.join(name)
|
||||
.display()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn test_restore_shell() -> &'static str {
|
||||
"C:\\Windows\\System32\\whoami.exe"
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn test_restore_shell() -> &'static str {
|
||||
"/bin/sh"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_and_restore_node_round_trip() {
|
||||
let node = Node::Split {
|
||||
@@ -847,35 +891,37 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn restore_plan_respects_opt_in_and_allowlist() {
|
||||
let pi_session_path = test_session_path("pi-session.jsonl");
|
||||
let session = super::super::snapshot::PaneAgentSessionSnapshot {
|
||||
source: "herdr:pi".into(),
|
||||
agent: "pi".into(),
|
||||
kind: crate::agent_resume::AgentSessionRefKind::Path,
|
||||
value: "/tmp/pi-session.jsonl".into(),
|
||||
value: pi_session_path.clone(),
|
||||
};
|
||||
|
||||
assert!(restore_plan_for_snapshot(&session, false).is_none());
|
||||
assert_eq!(
|
||||
restore_plan_for_snapshot(&session, true).unwrap().argv,
|
||||
vec!["pi", "--session", "/tmp/pi-session.jsonl"]
|
||||
vec!["pi", "--session", pi_session_path.as_str()]
|
||||
);
|
||||
|
||||
let unsupported_path = super::super::snapshot::PaneAgentSessionSnapshot {
|
||||
source: "herdr:claude".into(),
|
||||
agent: "claude".into(),
|
||||
kind: crate::agent_resume::AgentSessionRefKind::Path,
|
||||
value: "/tmp/claude-session".into(),
|
||||
value: test_session_path("claude-session"),
|
||||
};
|
||||
assert!(restore_plan_for_snapshot(&unsupported_path, true).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_plan_selection_suppresses_duplicates() {
|
||||
let pi_session_path = test_session_path("pi-session.jsonl");
|
||||
let session = super::super::snapshot::PaneAgentSessionSnapshot {
|
||||
source: "herdr:pi".into(),
|
||||
agent: "pi".into(),
|
||||
kind: crate::agent_resume::AgentSessionRefKind::Path,
|
||||
value: "/tmp/pi-session.jsonl".into(),
|
||||
value: pi_session_path.clone(),
|
||||
};
|
||||
let mut resumed = HashSet::new();
|
||||
|
||||
@@ -884,7 +930,10 @@ mod tests {
|
||||
|
||||
let first = take_restore_plan_for_snapshot(&session, true, &mut resumed)
|
||||
.expect("first restore should get a plan");
|
||||
assert_eq!(first.argv, vec!["pi", "--session", "/tmp/pi-session.jsonl"]);
|
||||
assert_eq!(
|
||||
first.argv,
|
||||
vec!["pi", "--session", pi_session_path.as_str()]
|
||||
);
|
||||
assert!(take_restore_plan_for_snapshot(&session, true, &mut resumed).is_none());
|
||||
}
|
||||
|
||||
@@ -894,7 +943,7 @@ mod tests {
|
||||
source: "herdr:pi".into(),
|
||||
agent: "pi".into(),
|
||||
kind: crate::agent_resume::AgentSessionRefKind::Path,
|
||||
value: "/tmp/pi-session.jsonl".into(),
|
||||
value: test_session_path("pi-session.jsonl"),
|
||||
};
|
||||
let history = super::super::snapshot::PaneHistorySnapshot {
|
||||
ansi: "RESTORED_HISTORY\r\n".into(),
|
||||
@@ -919,7 +968,7 @@ mod tests {
|
||||
source: "herdr:pi".into(),
|
||||
agent: "pi".into(),
|
||||
kind: crate::agent_resume::AgentSessionRefKind::Path,
|
||||
value: "/tmp/pi-session.jsonl".into(),
|
||||
value: test_session_path("pi-session.jsonl"),
|
||||
};
|
||||
let history = super::super::snapshot::PaneHistorySnapshot {
|
||||
ansi: "RESTORED_HISTORY\r\n".into(),
|
||||
@@ -947,7 +996,7 @@ mod tests {
|
||||
source: "herdr:pi".into(),
|
||||
agent: "pi".into(),
|
||||
kind: crate::agent_resume::AgentSessionRefKind::Path,
|
||||
value: "/tmp/pi-session.jsonl".into(),
|
||||
value: test_session_path("pi-session.jsonl"),
|
||||
};
|
||||
let history = super::super::snapshot::PaneHistorySnapshot {
|
||||
ansi: "RESTORED_HISTORY\r\n".into(),
|
||||
@@ -989,7 +1038,7 @@ mod tests {
|
||||
source: "herdr:pi".into(),
|
||||
agent: "pi".into(),
|
||||
kind: crate::agent_resume::AgentSessionRefKind::Path,
|
||||
value: "/tmp/pi-session.jsonl".into(),
|
||||
value: test_session_path("pi-session.jsonl"),
|
||||
};
|
||||
let mut resumed = HashSet::new();
|
||||
assert!(take_restore_plan_for_snapshot(&session, true, &mut resumed).is_some());
|
||||
@@ -1047,7 +1096,7 @@ mod tests {
|
||||
24,
|
||||
80,
|
||||
0,
|
||||
"/usr/bin/true",
|
||||
test_restore_shell(),
|
||||
crate::config::ShellModeConfig::NonLogin,
|
||||
false,
|
||||
events,
|
||||
@@ -1122,7 +1171,7 @@ mod tests {
|
||||
24,
|
||||
80,
|
||||
0,
|
||||
"/bin/sh",
|
||||
test_restore_shell(),
|
||||
crate::config::ShellModeConfig::NonLogin,
|
||||
true,
|
||||
events,
|
||||
@@ -1150,7 +1199,7 @@ mod tests {
|
||||
let (_handoff_workspaces, handoff_terminals, handoff_runtimes) = restore_handoff(
|
||||
&snapshot,
|
||||
0,
|
||||
"/bin/sh",
|
||||
test_restore_shell(),
|
||||
crate::config::ShellModeConfig::NonLogin,
|
||||
&mut imports,
|
||||
mpsc::channel(4).0,
|
||||
@@ -1185,7 +1234,7 @@ mod tests {
|
||||
5,
|
||||
40,
|
||||
4096,
|
||||
"/bin/sh",
|
||||
test_restore_shell(),
|
||||
crate::config::ShellModeConfig::NonLogin,
|
||||
false,
|
||||
events,
|
||||
@@ -1224,7 +1273,7 @@ mod tests {
|
||||
5,
|
||||
40,
|
||||
4096,
|
||||
"/bin/sh",
|
||||
test_restore_shell(),
|
||||
crate::config::ShellModeConfig::NonLogin,
|
||||
false,
|
||||
events,
|
||||
|
||||
+11
-2
@@ -488,6 +488,14 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn test_session_path(name: &str) -> String {
|
||||
std::env::current_dir()
|
||||
.unwrap()
|
||||
.join(name)
|
||||
.display()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn state_with_workspaces(names: &[&str]) -> AppState {
|
||||
let mut state = AppState::test_new();
|
||||
state.workspaces = names.iter().map(|name| Workspace::test_new(name)).collect();
|
||||
@@ -1024,6 +1032,7 @@ mod tests {
|
||||
#[test]
|
||||
fn capture_contract_tracks_hook_authority_agent_session() {
|
||||
let mut state = state_with_workspaces(&["one"]);
|
||||
let session_path = test_session_path("pi-session.jsonl");
|
||||
let root = state.workspaces[0].tabs[0].root_pane;
|
||||
state.ensure_test_terminals();
|
||||
let terminal_id = state.workspaces[0].tabs[0].panes[&root]
|
||||
@@ -1039,7 +1048,7 @@ mod tests {
|
||||
crate::detect::AgentState::Working,
|
||||
None,
|
||||
None,
|
||||
crate::agent_resume::AgentSessionRef::path("/tmp/pi-session.jsonl"),
|
||||
crate::agent_resume::AgentSessionRef::path(session_path.clone()),
|
||||
Some(20),
|
||||
);
|
||||
|
||||
@@ -1055,7 +1064,7 @@ mod tests {
|
||||
agent_session.kind,
|
||||
crate::agent_resume::AgentSessionRefKind::Path
|
||||
);
|
||||
assert_eq!(agent_session.value, "/tmp/pi-session.jsonl");
|
||||
assert_eq!(agent_session.value, session_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -52,6 +52,8 @@ pub fn open_url(_url: &str) -> std::io::Result<()> {
|
||||
}
|
||||
|
||||
/// Unsupported platform stub.
|
||||
// Windows does not wire clipboard-image bridging into semantic input yet.
|
||||
#[cfg_attr(windows, allow(dead_code))]
|
||||
pub fn read_clipboard_image() -> Option<ClipboardImage> {
|
||||
None
|
||||
}
|
||||
|
||||
+28
-3
@@ -25,6 +25,22 @@ pub enum Signal {
|
||||
Kill,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct PlatformCapabilities {
|
||||
pub(crate) live_handoff: bool,
|
||||
pub(crate) remote_attach: bool,
|
||||
pub(crate) direct_terminal_attach: bool,
|
||||
}
|
||||
|
||||
pub(crate) const fn capabilities() -> PlatformCapabilities {
|
||||
PlatformCapabilities {
|
||||
live_handoff: cfg!(unix),
|
||||
remote_attach: cfg!(unix),
|
||||
direct_terminal_attach: cfg!(unix),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ClipboardCommand {
|
||||
pub program: &'static str,
|
||||
@@ -32,11 +48,14 @@ pub struct ClipboardCommand {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
// Windows does not wire clipboard-image bridging into semantic input yet.
|
||||
#[cfg_attr(windows, allow(dead_code))]
|
||||
pub struct ClipboardImage {
|
||||
pub bytes: Vec<u8>,
|
||||
pub extension: &'static str,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) enum LimitedRead {
|
||||
Empty,
|
||||
@@ -44,6 +63,7 @@ pub(crate) enum LimitedRead {
|
||||
Oversized,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn read_limited_reader(
|
||||
mut reader: impl std::io::Read,
|
||||
max_bytes: usize,
|
||||
@@ -91,9 +111,14 @@ mod macos;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub use macos::*;
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub use windows::*;
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
|
||||
mod fallback;
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
|
||||
pub use fallback::*;
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
@@ -139,7 +164,7 @@ impl PrefixInputSource for RealPrefixInputSource {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
|
||||
@@ -0,0 +1,590 @@
|
||||
use std::{
|
||||
collections::{HashMap, VecDeque},
|
||||
ffi::c_void,
|
||||
mem::{size_of, MaybeUninit},
|
||||
path::PathBuf,
|
||||
ptr::null_mut,
|
||||
};
|
||||
|
||||
use windows_sys::{
|
||||
Wdk::System::Threading::{NtQueryInformationProcess, ProcessBasicInformation},
|
||||
Win32::{
|
||||
Foundation::{
|
||||
CloseHandle, LocalFree, HANDLE, INVALID_HANDLE_VALUE, NTSTATUS, STATUS_SUCCESS,
|
||||
UNICODE_STRING,
|
||||
},
|
||||
System::{
|
||||
Diagnostics::{
|
||||
Debug::ReadProcessMemory,
|
||||
ToolHelp::{
|
||||
CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W,
|
||||
TH32CS_SNAPPROCESS,
|
||||
},
|
||||
},
|
||||
Threading::{
|
||||
GetExitCodeProcess, OpenProcess, TerminateProcess, PROCESS_BASIC_INFORMATION,
|
||||
PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_VM_READ,
|
||||
},
|
||||
},
|
||||
UI::Shell::{CommandLineToArgvW, ShellExecuteW},
|
||||
},
|
||||
};
|
||||
|
||||
use super::{ClipboardImage, ForegroundJob, Signal};
|
||||
|
||||
const STILL_ACTIVE: u32 = 259;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct WindowsProcessEntry {
|
||||
pid: u32,
|
||||
parent_pid: u32,
|
||||
name: String,
|
||||
argv0: Option<String>,
|
||||
argv: Option<Vec<String>>,
|
||||
cmdline: Option<String>,
|
||||
}
|
||||
|
||||
pub fn raise_server_nofile_limit() {}
|
||||
|
||||
pub fn foreground_job(child_pid: u32) -> Option<ForegroundJob> {
|
||||
let entries = snapshot_processes();
|
||||
select_pane_foreground_job(child_pid, &entries)
|
||||
}
|
||||
|
||||
pub fn foreground_group_leader_job(process_group_id: u32) -> Option<ForegroundJob> {
|
||||
let entries = snapshot_processes();
|
||||
let entry = entries.iter().find(|entry| entry.pid == process_group_id)?;
|
||||
Some(ForegroundJob {
|
||||
process_group_id,
|
||||
processes: vec![foreground_process_from_entry(entry)],
|
||||
})
|
||||
}
|
||||
|
||||
pub fn foreground_process_group_id(child_pid: u32) -> Option<u32> {
|
||||
foreground_job(child_pid).map(|job| job.process_group_id)
|
||||
}
|
||||
|
||||
pub fn process_cwd(pid: u32) -> Option<PathBuf> {
|
||||
let process = ProcessHandle::open(pid, PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_VM_READ)?;
|
||||
let process_parameters = read_process_parameters(process.0)?;
|
||||
read_unicode_string(process.0, process_parameters.current_directory.dos_path)
|
||||
.map(PathBuf::from)
|
||||
.filter(|path| path.is_absolute())
|
||||
}
|
||||
|
||||
fn select_pane_foreground_job(
|
||||
shell_pid: u32,
|
||||
entries: &[WindowsProcessEntry],
|
||||
) -> Option<ForegroundJob> {
|
||||
let shell = entries.iter().find(|entry| entry.pid == shell_pid)?;
|
||||
let shell_job = || ForegroundJob {
|
||||
process_group_id: shell_pid,
|
||||
processes: vec![foreground_process_from_entry(shell)],
|
||||
};
|
||||
|
||||
let descendants = descendant_entries(shell_pid, entries);
|
||||
let mut candidates = Vec::new();
|
||||
for entry in descendants {
|
||||
let process = foreground_process_from_entry(entry);
|
||||
let job = ForegroundJob {
|
||||
process_group_id: entry.pid,
|
||||
processes: vec![process],
|
||||
};
|
||||
if crate::detect::identify_agent_in_job(&job).is_some() {
|
||||
candidates.push(job);
|
||||
}
|
||||
}
|
||||
|
||||
match candidates.len() {
|
||||
1 => candidates.pop(),
|
||||
_ => Some(shell_job()),
|
||||
}
|
||||
}
|
||||
|
||||
fn descendant_entries(root_pid: u32, entries: &[WindowsProcessEntry]) -> Vec<&WindowsProcessEntry> {
|
||||
let mut children: HashMap<u32, Vec<&WindowsProcessEntry>> = HashMap::new();
|
||||
for entry in entries {
|
||||
children.entry(entry.parent_pid).or_default().push(entry);
|
||||
}
|
||||
|
||||
let mut output = Vec::new();
|
||||
let mut queue = VecDeque::new();
|
||||
if let Some(root_children) = children.get(&root_pid) {
|
||||
queue.extend(root_children.iter().copied());
|
||||
}
|
||||
while let Some(entry) = queue.pop_front() {
|
||||
output.push(entry);
|
||||
if let Some(next) = children.get(&entry.pid) {
|
||||
queue.extend(next.iter().copied());
|
||||
}
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
fn foreground_process_from_entry(entry: &WindowsProcessEntry) -> super::ForegroundProcess {
|
||||
super::ForegroundProcess {
|
||||
pid: entry.pid,
|
||||
name: entry.name.clone(),
|
||||
argv0: entry.argv0.clone(),
|
||||
argv: entry.argv.clone(),
|
||||
cmdline: entry.cmdline.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_processes() -> Vec<WindowsProcessEntry> {
|
||||
let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
|
||||
if snapshot == INVALID_HANDLE_VALUE {
|
||||
return Vec::new();
|
||||
}
|
||||
let _snapshot = ProcessHandle(snapshot);
|
||||
|
||||
let mut entry = PROCESSENTRY32W {
|
||||
dwSize: size_of::<PROCESSENTRY32W>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let mut output = Vec::new();
|
||||
let mut ok = unsafe { Process32FirstW(snapshot, &mut entry) } != 0;
|
||||
while ok {
|
||||
let pid = entry.th32ProcessID;
|
||||
let name = nul_terminated_utf16_to_string(&entry.szExeFile);
|
||||
let cmdline = process_command_line(pid);
|
||||
let argv = cmdline.as_deref().and_then(command_line_to_argv);
|
||||
let argv0 = argv
|
||||
.as_ref()
|
||||
.and_then(|argv| argv.first().cloned())
|
||||
.or_else(|| (!name.is_empty()).then(|| name.clone()));
|
||||
output.push(WindowsProcessEntry {
|
||||
pid,
|
||||
parent_pid: entry.th32ParentProcessID,
|
||||
name,
|
||||
argv0,
|
||||
argv,
|
||||
cmdline,
|
||||
});
|
||||
ok = unsafe { Process32NextW(snapshot, &mut entry) } != 0;
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
fn process_command_line(pid: u32) -> Option<String> {
|
||||
let process = ProcessHandle::open(pid, PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_VM_READ)?;
|
||||
let parameters = read_process_parameters(process.0)?;
|
||||
read_unicode_string(process.0, parameters.command_line)
|
||||
}
|
||||
|
||||
fn read_process_parameters(process: HANDLE) -> Option<RtlUserProcessParameters> {
|
||||
let mut basic_info = MaybeUninit::<PROCESS_BASIC_INFORMATION>::uninit();
|
||||
let status = unsafe {
|
||||
NtQueryInformationProcess(
|
||||
process,
|
||||
ProcessBasicInformation,
|
||||
basic_info.as_mut_ptr().cast::<c_void>(),
|
||||
size_of::<PROCESS_BASIC_INFORMATION>() as u32,
|
||||
null_mut(),
|
||||
)
|
||||
};
|
||||
if status != STATUS_SUCCESS as NTSTATUS {
|
||||
return None;
|
||||
}
|
||||
|
||||
let basic_info = unsafe { basic_info.assume_init() };
|
||||
if basic_info.PebBaseAddress.is_null() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let peb = read_process_value::<Peb>(process, basic_info.PebBaseAddress.cast::<c_void>())?;
|
||||
if peb.process_parameters.is_null() {
|
||||
return None;
|
||||
}
|
||||
|
||||
read_process_value::<RtlUserProcessParameters>(process, peb.process_parameters.cast())
|
||||
}
|
||||
|
||||
fn command_line_to_argv(command_line: &str) -> Option<Vec<String>> {
|
||||
let wide: Vec<u16> = command_line
|
||||
.encode_utf16()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
let mut argc = 0;
|
||||
let argv_ptr = unsafe { CommandLineToArgvW(wide.as_ptr(), &mut argc) };
|
||||
if argv_ptr.is_null() || argc <= 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let argv_slice = unsafe { std::slice::from_raw_parts(argv_ptr, argc as usize) };
|
||||
let mut argv = Vec::with_capacity(argc as usize);
|
||||
for &arg in argv_slice {
|
||||
if arg.is_null() {
|
||||
continue;
|
||||
}
|
||||
let mut len = 0;
|
||||
unsafe {
|
||||
while *arg.add(len) != 0 {
|
||||
len += 1;
|
||||
}
|
||||
argv.push(String::from_utf16_lossy(std::slice::from_raw_parts(
|
||||
arg, len,
|
||||
)));
|
||||
}
|
||||
}
|
||||
unsafe {
|
||||
LocalFree(argv_ptr.cast());
|
||||
}
|
||||
Some(argv)
|
||||
}
|
||||
|
||||
fn nul_terminated_utf16_to_string(buffer: &[u16]) -> String {
|
||||
let len = buffer
|
||||
.iter()
|
||||
.position(|&value| value == 0)
|
||||
.unwrap_or(buffer.len());
|
||||
String::from_utf16_lossy(&buffer[..len])
|
||||
}
|
||||
|
||||
pub fn session_processes(child_pid: u32) -> Vec<u32> {
|
||||
if child_pid == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let entries = snapshot_processes();
|
||||
session_processes_from_entries(child_pid, &entries)
|
||||
}
|
||||
|
||||
fn session_processes_from_entries(child_pid: u32, entries: &[WindowsProcessEntry]) -> Vec<u32> {
|
||||
if !entries.iter().any(|entry| entry.pid == child_pid) {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut pids = vec![child_pid];
|
||||
pids.extend(
|
||||
descendant_entries(child_pid, &entries)
|
||||
.into_iter()
|
||||
.map(|entry| entry.pid),
|
||||
);
|
||||
pids
|
||||
}
|
||||
|
||||
pub fn signal_processes(pids: &[u32], signal: Signal) {
|
||||
if signal == Signal::Hangup {
|
||||
return;
|
||||
}
|
||||
|
||||
for &pid in pids {
|
||||
let Some(process) = ProcessHandle::open(pid, PROCESS_QUERY_LIMITED_INFORMATION) else {
|
||||
continue;
|
||||
};
|
||||
unsafe {
|
||||
TerminateProcess(process.0, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn process_exists(pid: u32) -> bool {
|
||||
let Some(process) = ProcessHandle::open(pid, PROCESS_QUERY_LIMITED_INFORMATION) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let mut exit_code = 0;
|
||||
let ok = unsafe { GetExitCodeProcess(process.0, &mut exit_code) } != 0;
|
||||
ok && exit_code == STILL_ACTIVE
|
||||
}
|
||||
|
||||
pub fn write_clipboard(_bytes: &[u8]) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
pub fn open_url(url: &str) -> std::io::Result<()> {
|
||||
let operation = wide_null("open");
|
||||
let url = wide_null(url);
|
||||
let result = unsafe {
|
||||
ShellExecuteW(
|
||||
std::ptr::null_mut(),
|
||||
operation.as_ptr(),
|
||||
url.as_ptr(),
|
||||
std::ptr::null(),
|
||||
std::ptr::null(),
|
||||
1,
|
||||
)
|
||||
};
|
||||
if result as isize > 32 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(std::io::Error::other(format!(
|
||||
"failed to open URL with ShellExecuteW: code {}",
|
||||
result as isize
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
// Windows does not wire clipboard-image bridging into semantic input yet.
|
||||
#[cfg_attr(windows, allow(dead_code))]
|
||||
pub fn read_clipboard_image() -> Option<ClipboardImage> {
|
||||
None
|
||||
}
|
||||
|
||||
pub fn show_desktop_notification(_title: &str, _body: Option<&str>) -> std::io::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn wide_null(value: &str) -> Vec<u16> {
|
||||
value.encode_utf16().chain(std::iter::once(0)).collect()
|
||||
}
|
||||
|
||||
struct ProcessHandle(HANDLE);
|
||||
|
||||
impl ProcessHandle {
|
||||
fn open(pid: u32, access: u32) -> Option<Self> {
|
||||
if pid == 0 {
|
||||
return None;
|
||||
}
|
||||
let handle = unsafe { OpenProcess(access, 0, pid) };
|
||||
(!handle.is_null()).then_some(Self(handle))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ProcessHandle {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
CloseHandle(self.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct Peb {
|
||||
reserved1: [u8; 2],
|
||||
being_debugged: u8,
|
||||
reserved2: [u8; 1],
|
||||
reserved3: [*mut c_void; 2],
|
||||
ldr: *mut c_void,
|
||||
process_parameters: *mut RtlUserProcessParameters,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct CurDir {
|
||||
dos_path: UNICODE_STRING,
|
||||
handle: HANDLE,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct RtlUserProcessParameters {
|
||||
maximum_length: u32,
|
||||
length: u32,
|
||||
flags: u32,
|
||||
debug_flags: u32,
|
||||
console_handle: HANDLE,
|
||||
console_flags: u32,
|
||||
standard_input: HANDLE,
|
||||
standard_output: HANDLE,
|
||||
standard_error: HANDLE,
|
||||
current_directory: CurDir,
|
||||
dll_path: UNICODE_STRING,
|
||||
image_path_name: UNICODE_STRING,
|
||||
command_line: UNICODE_STRING,
|
||||
}
|
||||
|
||||
fn read_process_value<T: Copy>(process: HANDLE, address: *const c_void) -> Option<T> {
|
||||
if address.is_null() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut value = MaybeUninit::<T>::uninit();
|
||||
let mut bytes_read = 0;
|
||||
let ok = unsafe {
|
||||
ReadProcessMemory(
|
||||
process,
|
||||
address,
|
||||
value.as_mut_ptr().cast::<c_void>(),
|
||||
size_of::<T>(),
|
||||
&mut bytes_read,
|
||||
)
|
||||
} != 0;
|
||||
|
||||
(ok && bytes_read == size_of::<T>()).then(|| unsafe { value.assume_init() })
|
||||
}
|
||||
|
||||
fn read_unicode_string(process: HANDLE, unicode: UNICODE_STRING) -> Option<String> {
|
||||
if unicode.Buffer.is_null() || unicode.Length == 0 || unicode.Length % 2 != 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let char_len = usize::from(unicode.Length / 2);
|
||||
let mut buffer = vec![0_u16; char_len];
|
||||
let mut bytes_read = 0;
|
||||
let ok = unsafe {
|
||||
ReadProcessMemory(
|
||||
process,
|
||||
unicode.Buffer.cast::<c_void>(),
|
||||
buffer.as_mut_ptr().cast::<c_void>(),
|
||||
usize::from(unicode.Length),
|
||||
&mut bytes_read,
|
||||
)
|
||||
} != 0;
|
||||
|
||||
if !ok || bytes_read != usize::from(unicode.Length) {
|
||||
return None;
|
||||
}
|
||||
|
||||
String::from_utf16(&buffer).ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
fs,
|
||||
process::{Command, Stdio},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn windows_process_cwd_reads_child_launch_directory() {
|
||||
let cwd = std::env::temp_dir().join(format!("herdr-cwd-test-{}", std::process::id()));
|
||||
fs::create_dir_all(&cwd).expect("create cwd fixture");
|
||||
|
||||
let mut child = Command::new("powershell.exe")
|
||||
.args(["-NoProfile", "-Command", "Start-Sleep -Seconds 10"])
|
||||
.current_dir(&cwd)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("spawn powershell");
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
let mut observed = None;
|
||||
while Instant::now() < deadline {
|
||||
observed = super::process_cwd(child.id());
|
||||
if observed.as_deref() == Some(cwd.as_path()) {
|
||||
break;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
let _ = fs::remove_dir_all(&cwd);
|
||||
|
||||
assert_eq!(observed.as_deref(), Some(cwd.as_path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_process_tree_selects_direct_agent_descendant() {
|
||||
let entries = vec![
|
||||
test_entry(10, 1, "powershell.exe", &["powershell.exe"]),
|
||||
test_entry(20, 10, "codex.exe", &["codex.exe"]),
|
||||
];
|
||||
|
||||
let job = super::select_pane_foreground_job(10, &entries).unwrap();
|
||||
|
||||
assert_eq!(job.process_group_id, 20);
|
||||
assert_eq!(job.processes.len(), 1);
|
||||
assert_eq!(job.processes[0].name, "codex.exe");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_process_tree_selects_wrapped_agent_descendant() {
|
||||
let entries = vec![
|
||||
test_entry(10, 1, "cmd.exe", &["cmd.exe"]),
|
||||
test_entry(
|
||||
20,
|
||||
10,
|
||||
"node.exe",
|
||||
&[
|
||||
"node.exe",
|
||||
"C:\\Users\\herdr\\AppData\\Roaming\\npm\\node_modules\\codex\\bin\\codex.js",
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
let job = super::select_pane_foreground_job(10, &entries).unwrap();
|
||||
|
||||
assert_eq!(job.process_group_id, 20);
|
||||
assert_eq!(job.processes[0].name, "node.exe");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_process_tree_selects_cmd_wrapped_agent_descendant() {
|
||||
let entries = vec![
|
||||
test_entry(10, 1, "powershell.exe", &["powershell.exe"]),
|
||||
test_entry(
|
||||
20,
|
||||
10,
|
||||
"cmd.exe",
|
||||
&[
|
||||
"cmd.exe",
|
||||
"/D",
|
||||
"/S",
|
||||
"/C",
|
||||
"C:\\Users\\herdr\\AppData\\Roaming\\npm\\codex.cmd --model gpt-5",
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
let job = super::select_pane_foreground_job(10, &entries).unwrap();
|
||||
|
||||
assert_eq!(job.process_group_id, 20);
|
||||
assert_eq!(job.processes[0].name, "cmd.exe");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_process_tree_returns_shell_for_plain_descendant() {
|
||||
let entries = vec![
|
||||
test_entry(10, 1, "powershell.exe", &["powershell.exe"]),
|
||||
test_entry(20, 10, "git.exe", &["git.exe", "status"]),
|
||||
];
|
||||
|
||||
let job = super::select_pane_foreground_job(10, &entries).unwrap();
|
||||
|
||||
assert_eq!(job.process_group_id, 10);
|
||||
assert_eq!(job.processes[0].name, "powershell.exe");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_process_tree_returns_shell_for_multiple_agent_descendants() {
|
||||
let entries = vec![
|
||||
test_entry(10, 1, "powershell.exe", &["powershell.exe"]),
|
||||
test_entry(20, 10, "codex.exe", &["codex.exe"]),
|
||||
test_entry(30, 10, "claude.exe", &["claude.exe"]),
|
||||
];
|
||||
|
||||
let job = super::select_pane_foreground_job(10, &entries).unwrap();
|
||||
|
||||
assert_eq!(job.process_group_id, 10);
|
||||
assert_eq!(job.processes[0].name, "powershell.exe");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_session_processes_collects_shell_and_descendants() {
|
||||
let entries = vec![
|
||||
test_entry(10, 1, "powershell.exe", &["powershell.exe"]),
|
||||
test_entry(20, 10, "cmd.exe", &["cmd.exe"]),
|
||||
test_entry(30, 20, "node.exe", &["node.exe"]),
|
||||
test_entry(40, 1, "unrelated.exe", &["unrelated.exe"]),
|
||||
];
|
||||
|
||||
let mut pids = super::session_processes_from_entries(10, &entries);
|
||||
pids.sort_unstable();
|
||||
|
||||
assert_eq!(pids, vec![10, 20, 30]);
|
||||
}
|
||||
|
||||
fn test_entry(
|
||||
pid: u32,
|
||||
parent_pid: u32,
|
||||
name: &str,
|
||||
argv: &[&str],
|
||||
) -> super::WindowsProcessEntry {
|
||||
super::WindowsProcessEntry {
|
||||
pid,
|
||||
parent_pid,
|
||||
name: name.to_string(),
|
||||
argv0: argv.first().map(|value| (*value).to_string()),
|
||||
argv: Some(argv.iter().map(|value| (*value).to_string()).collect()),
|
||||
cmdline: Some(argv.join(" ")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,248 @@ pub enum ClientLaunchMode {
|
||||
TerminalAttach,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ClientKeyKind {
|
||||
Press,
|
||||
Repeat,
|
||||
Release,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ClientKeyCode {
|
||||
Backspace,
|
||||
Enter,
|
||||
Left,
|
||||
Right,
|
||||
Up,
|
||||
Down,
|
||||
Home,
|
||||
End,
|
||||
PageUp,
|
||||
PageDown,
|
||||
Tab,
|
||||
BackTab,
|
||||
Delete,
|
||||
Insert,
|
||||
Esc,
|
||||
Char(char),
|
||||
F(u8),
|
||||
Null,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ClientMouseButton {
|
||||
Left,
|
||||
Right,
|
||||
Middle,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ClientMouseKind {
|
||||
Down(ClientMouseButton),
|
||||
Up(ClientMouseButton),
|
||||
Drag(ClientMouseButton),
|
||||
Moved,
|
||||
ScrollUp,
|
||||
ScrollDown,
|
||||
ScrollLeft,
|
||||
ScrollRight,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ClientInputEvent {
|
||||
Key {
|
||||
code: ClientKeyCode,
|
||||
modifiers: u8,
|
||||
kind: ClientKeyKind,
|
||||
},
|
||||
Mouse {
|
||||
kind: ClientMouseKind,
|
||||
column: u16,
|
||||
row: u16,
|
||||
modifiers: u8,
|
||||
},
|
||||
Paste {
|
||||
text: String,
|
||||
},
|
||||
FocusGained,
|
||||
FocusLost,
|
||||
}
|
||||
|
||||
impl ClientKeyKind {
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn from_crossterm(kind: crossterm::event::KeyEventKind) -> Self {
|
||||
match kind {
|
||||
crossterm::event::KeyEventKind::Press => Self::Press,
|
||||
crossterm::event::KeyEventKind::Repeat => Self::Repeat,
|
||||
crossterm::event::KeyEventKind::Release => Self::Release,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn to_crossterm(self) -> crossterm::event::KeyEventKind {
|
||||
match self {
|
||||
Self::Press => crossterm::event::KeyEventKind::Press,
|
||||
Self::Repeat => crossterm::event::KeyEventKind::Repeat,
|
||||
Self::Release => crossterm::event::KeyEventKind::Release,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ClientKeyCode {
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn from_crossterm(code: crossterm::event::KeyCode) -> Option<Self> {
|
||||
use crossterm::event::KeyCode;
|
||||
Some(match code {
|
||||
KeyCode::Backspace => Self::Backspace,
|
||||
KeyCode::Enter => Self::Enter,
|
||||
KeyCode::Left => Self::Left,
|
||||
KeyCode::Right => Self::Right,
|
||||
KeyCode::Up => Self::Up,
|
||||
KeyCode::Down => Self::Down,
|
||||
KeyCode::Home => Self::Home,
|
||||
KeyCode::End => Self::End,
|
||||
KeyCode::PageUp => Self::PageUp,
|
||||
KeyCode::PageDown => Self::PageDown,
|
||||
KeyCode::Tab => Self::Tab,
|
||||
KeyCode::BackTab => Self::BackTab,
|
||||
KeyCode::Delete => Self::Delete,
|
||||
KeyCode::Insert => Self::Insert,
|
||||
KeyCode::Esc => Self::Esc,
|
||||
KeyCode::Char(ch) => Self::Char(ch),
|
||||
KeyCode::F(n) => Self::F(n),
|
||||
KeyCode::Null => Self::Null,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn to_crossterm(&self) -> crossterm::event::KeyCode {
|
||||
use crossterm::event::KeyCode;
|
||||
match self {
|
||||
Self::Backspace => KeyCode::Backspace,
|
||||
Self::Enter => KeyCode::Enter,
|
||||
Self::Left => KeyCode::Left,
|
||||
Self::Right => KeyCode::Right,
|
||||
Self::Up => KeyCode::Up,
|
||||
Self::Down => KeyCode::Down,
|
||||
Self::Home => KeyCode::Home,
|
||||
Self::End => KeyCode::End,
|
||||
Self::PageUp => KeyCode::PageUp,
|
||||
Self::PageDown => KeyCode::PageDown,
|
||||
Self::Tab => KeyCode::Tab,
|
||||
Self::BackTab => KeyCode::BackTab,
|
||||
Self::Delete => KeyCode::Delete,
|
||||
Self::Insert => KeyCode::Insert,
|
||||
Self::Esc => KeyCode::Esc,
|
||||
Self::Char(ch) => KeyCode::Char(*ch),
|
||||
Self::F(n) => KeyCode::F(*n),
|
||||
Self::Null => KeyCode::Null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ClientMouseButton {
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn from_crossterm(button: crossterm::event::MouseButton) -> Self {
|
||||
match button {
|
||||
crossterm::event::MouseButton::Left => Self::Left,
|
||||
crossterm::event::MouseButton::Right => Self::Right,
|
||||
crossterm::event::MouseButton::Middle => Self::Middle,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn to_crossterm(self) -> crossterm::event::MouseButton {
|
||||
match self {
|
||||
Self::Left => crossterm::event::MouseButton::Left,
|
||||
Self::Right => crossterm::event::MouseButton::Right,
|
||||
Self::Middle => crossterm::event::MouseButton::Middle,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ClientMouseKind {
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn from_crossterm(kind: crossterm::event::MouseEventKind) -> Option<Self> {
|
||||
use crossterm::event::MouseEventKind;
|
||||
Some(match kind {
|
||||
MouseEventKind::Down(button) => Self::Down(ClientMouseButton::from_crossterm(button)),
|
||||
MouseEventKind::Up(button) => Self::Up(ClientMouseButton::from_crossterm(button)),
|
||||
MouseEventKind::Drag(button) => Self::Drag(ClientMouseButton::from_crossterm(button)),
|
||||
MouseEventKind::Moved => Self::Moved,
|
||||
MouseEventKind::ScrollUp => Self::ScrollUp,
|
||||
MouseEventKind::ScrollDown => Self::ScrollDown,
|
||||
MouseEventKind::ScrollLeft => Self::ScrollLeft,
|
||||
MouseEventKind::ScrollRight => Self::ScrollRight,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn to_crossterm(self) -> crossterm::event::MouseEventKind {
|
||||
use crossterm::event::MouseEventKind;
|
||||
match self {
|
||||
Self::Down(button) => MouseEventKind::Down(button.to_crossterm()),
|
||||
Self::Up(button) => MouseEventKind::Up(button.to_crossterm()),
|
||||
Self::Drag(button) => MouseEventKind::Drag(button.to_crossterm()),
|
||||
Self::Moved => MouseEventKind::Moved,
|
||||
Self::ScrollUp => MouseEventKind::ScrollUp,
|
||||
Self::ScrollDown => MouseEventKind::ScrollDown,
|
||||
Self::ScrollLeft => MouseEventKind::ScrollLeft,
|
||||
Self::ScrollRight => MouseEventKind::ScrollRight,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ClientInputEvent {
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn from_crossterm(event: crossterm::event::Event) -> Option<Self> {
|
||||
match event {
|
||||
crossterm::event::Event::Key(key) => Some(Self::Key {
|
||||
code: ClientKeyCode::from_crossterm(key.code)?,
|
||||
modifiers: key.modifiers.bits(),
|
||||
kind: ClientKeyKind::from_crossterm(key.kind),
|
||||
}),
|
||||
crossterm::event::Event::Mouse(mouse) => Some(Self::Mouse {
|
||||
kind: ClientMouseKind::from_crossterm(mouse.kind)?,
|
||||
column: mouse.column,
|
||||
row: mouse.row,
|
||||
modifiers: mouse.modifiers.bits(),
|
||||
}),
|
||||
crossterm::event::Event::Paste(text) => Some(Self::Paste { text }),
|
||||
crossterm::event::Event::FocusGained => Some(Self::FocusGained),
|
||||
crossterm::event::Event::FocusLost => Some(Self::FocusLost),
|
||||
crossterm::event::Event::Resize(_, _) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn to_raw_input_event(&self) -> crate::raw_input::RawInputEvent {
|
||||
match self {
|
||||
Self::Key {
|
||||
code,
|
||||
modifiers,
|
||||
kind,
|
||||
} => crate::raw_input::RawInputEvent::Key(
|
||||
crate::input::TerminalKey::new(
|
||||
code.to_crossterm(),
|
||||
crossterm::event::KeyModifiers::from_bits_truncate(*modifiers),
|
||||
)
|
||||
.with_kind(kind.to_crossterm()),
|
||||
),
|
||||
Self::Mouse {
|
||||
kind,
|
||||
column,
|
||||
row,
|
||||
modifiers,
|
||||
} => crate::raw_input::RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||||
kind: kind.to_crossterm(),
|
||||
column: *column,
|
||||
row: *row,
|
||||
modifiers: crossterm::event::KeyModifiers::from_bits_truncate(*modifiers),
|
||||
}),
|
||||
Self::Paste { text } => crate::raw_input::RawInputEvent::Paste(text.clone()),
|
||||
Self::FocusGained => crate::raw_input::RawInputEvent::OuterFocusGained,
|
||||
Self::FocusLost => crate::raw_input::RawInputEvent::OuterFocusLost,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Messages sent from the client to the server over the client protocol socket.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ClientMessage {
|
||||
@@ -90,6 +332,9 @@ pub enum ClientMessage {
|
||||
data: Vec<u8>,
|
||||
},
|
||||
|
||||
/// Structured input events from platform clients that do not expose Unix-style raw bytes.
|
||||
InputEvents { events: Vec<ClientInputEvent> },
|
||||
|
||||
/// Image bytes read from the client's local clipboard for remote paste bridging.
|
||||
ClipboardImage {
|
||||
/// Image file extension without a leading dot.
|
||||
@@ -683,6 +928,67 @@ mod tests {
|
||||
assert_eq!(msg, decoded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_input_events_roundtrip() {
|
||||
let msg = ClientMessage::InputEvents {
|
||||
events: vec![
|
||||
ClientInputEvent::Key {
|
||||
code: ClientKeyCode::Char('N'),
|
||||
modifiers: crossterm::event::KeyModifiers::SHIFT.bits(),
|
||||
kind: ClientKeyKind::Press,
|
||||
},
|
||||
ClientInputEvent::Key {
|
||||
code: ClientKeyCode::Backspace,
|
||||
modifiers: 0,
|
||||
kind: ClientKeyKind::Press,
|
||||
},
|
||||
ClientInputEvent::Mouse {
|
||||
kind: ClientMouseKind::Down(ClientMouseButton::Left),
|
||||
column: 3,
|
||||
row: 4,
|
||||
modifiers: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
|
||||
let (decoded, _): (ClientMessage, _) =
|
||||
bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
|
||||
assert_eq!(msg, decoded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_input_events_convert_to_raw_keys() {
|
||||
let shifted = ClientInputEvent::Key {
|
||||
code: ClientKeyCode::Char('N'),
|
||||
modifiers: crossterm::event::KeyModifiers::SHIFT.bits(),
|
||||
kind: ClientKeyKind::Press,
|
||||
}
|
||||
.to_raw_input_event();
|
||||
match shifted {
|
||||
crate::raw_input::RawInputEvent::Key(key) => {
|
||||
assert_eq!(key.code, crossterm::event::KeyCode::Char('N'));
|
||||
assert_eq!(key.modifiers, crossterm::event::KeyModifiers::SHIFT);
|
||||
assert_eq!(key.kind, crossterm::event::KeyEventKind::Press);
|
||||
}
|
||||
other => panic!("expected shifted key event, got {other:?}"),
|
||||
}
|
||||
|
||||
let backspace = ClientInputEvent::Key {
|
||||
code: ClientKeyCode::Backspace,
|
||||
modifiers: 0,
|
||||
kind: ClientKeyKind::Press,
|
||||
}
|
||||
.to_raw_input_event();
|
||||
match backspace {
|
||||
crate::raw_input::RawInputEvent::Key(key) => {
|
||||
assert_eq!(key.code, crossterm::event::KeyCode::Backspace);
|
||||
assert_eq!(key.modifiers, crossterm::event::KeyModifiers::empty());
|
||||
assert_eq!(key.kind, crossterm::event::KeyEventKind::Press);
|
||||
}
|
||||
other => panic!("expected backspace key event, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_clipboard_image_roundtrip() {
|
||||
let msg = ClientMessage::ClipboardImage {
|
||||
@@ -1497,6 +1803,7 @@ mod tests {
|
||||
|
||||
// ---- Unix socketpair integration test ----
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn framing_over_unix_socketpair() {
|
||||
use std::os::unix::net::UnixStream;
|
||||
|
||||
+186
-1127
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+10
-64
@@ -1,14 +1,19 @@
|
||||
use std::os::fd::{FromRawFd, OwnedFd};
|
||||
#[cfg(unix)]
|
||||
mod unix;
|
||||
|
||||
use portable_pty::{native_pty_system, Child, CommandBuilder, PtySize};
|
||||
#[cfg(unix)]
|
||||
pub(crate) use unix::*;
|
||||
|
||||
use crate::pty::fd;
|
||||
#[cfg(windows)]
|
||||
use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(crate) struct SpawnedPty {
|
||||
pub master_fd: OwnedFd,
|
||||
pub master: Box<dyn MasterPty + Send>,
|
||||
pub child: Box<dyn Child + Send + Sync>,
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn spawn_with_portable_pty(
|
||||
rows: u16,
|
||||
cols: u16,
|
||||
@@ -23,72 +28,13 @@ pub(crate) fn spawn_with_portable_pty(
|
||||
pixel_height: 0,
|
||||
})
|
||||
.map_err(|err| std::io::Error::other(err.to_string()))?;
|
||||
let master_fd = pair
|
||||
.master
|
||||
.as_raw_fd()
|
||||
.ok_or_else(|| std::io::Error::other("pty master fd is unavailable"))?;
|
||||
let actor_fd = fd::duplicate_cloexec_fd(master_fd)?;
|
||||
let actor_fd = unsafe { OwnedFd::from_raw_fd(actor_fd) };
|
||||
let child = pair
|
||||
.slave
|
||||
.spawn_command(cmd)
|
||||
.map_err(|err| std::io::Error::other(err.to_string()))?;
|
||||
drop(pair);
|
||||
|
||||
Ok(SpawnedPty {
|
||||
master_fd: actor_fd,
|
||||
master: pair.master,
|
||||
child,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
fn pty_fd_test_lock() -> &'static Mutex<()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
|
||||
fn parent_pty_fd_targets() -> Vec<String> {
|
||||
let Ok(entries) = std::fs::read_dir("/proc/self/fd") else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut targets: Vec<String> = entries
|
||||
.filter_map(Result::ok)
|
||||
.filter_map(|entry| std::fs::read_link(entry.path()).ok())
|
||||
.map(|target| target.to_string_lossy().into_owned())
|
||||
.filter(|target| target.starts_with("/dev/pts/") || target == "/dev/ptmx")
|
||||
.collect();
|
||||
targets.sort();
|
||||
targets
|
||||
}
|
||||
|
||||
fn parent_pty_fd_count() -> usize {
|
||||
parent_pty_fd_targets().len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portable_pty_setup_leaves_one_parent_pty_fd() {
|
||||
let _guard = pty_fd_test_lock().lock().expect("pty fd test lock");
|
||||
let before = parent_pty_fd_count();
|
||||
let mut cmd = CommandBuilder::new("/bin/cat");
|
||||
cmd.env(crate::HERDR_ENV_VAR, crate::HERDR_ENV_VALUE);
|
||||
|
||||
let mut spawned =
|
||||
spawn_with_portable_pty(24, 80, cmd).expect("portable pty setup succeeds");
|
||||
let after_spawn = parent_pty_fd_count();
|
||||
|
||||
assert_eq!(
|
||||
after_spawn,
|
||||
before + 1,
|
||||
"portable-pty setup should leave only the Herdr-owned master fd in the parent: {:?}",
|
||||
parent_pty_fd_targets()
|
||||
);
|
||||
|
||||
let _ = spawned.child.kill();
|
||||
let _ = spawned.child.wait();
|
||||
drop(spawned.master_fd);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
use std::os::fd::{FromRawFd, OwnedFd};
|
||||
|
||||
use portable_pty::{native_pty_system, Child, CommandBuilder, PtySize};
|
||||
|
||||
use crate::pty::fd;
|
||||
|
||||
pub(crate) struct SpawnedPty {
|
||||
pub master_fd: OwnedFd,
|
||||
pub child: Box<dyn Child + Send + Sync>,
|
||||
}
|
||||
|
||||
pub(crate) fn spawn_with_portable_pty(
|
||||
rows: u16,
|
||||
cols: u16,
|
||||
cmd: CommandBuilder,
|
||||
) -> std::io::Result<SpawnedPty> {
|
||||
let pty_system = native_pty_system();
|
||||
let pair = pty_system
|
||||
.openpty(PtySize {
|
||||
rows,
|
||||
cols,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
})
|
||||
.map_err(|err| std::io::Error::other(err.to_string()))?;
|
||||
let master_fd = pair
|
||||
.master
|
||||
.as_raw_fd()
|
||||
.ok_or_else(|| std::io::Error::other("pty master fd is unavailable"))?;
|
||||
let actor_fd = fd::duplicate_cloexec_fd(master_fd)?;
|
||||
let actor_fd = unsafe { OwnedFd::from_raw_fd(actor_fd) };
|
||||
let child = pair
|
||||
.slave
|
||||
.spawn_command(cmd)
|
||||
.map_err(|err| std::io::Error::other(err.to_string()))?;
|
||||
drop(pair);
|
||||
|
||||
Ok(SpawnedPty {
|
||||
master_fd: actor_fd,
|
||||
child,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
fn pty_fd_test_lock() -> &'static Mutex<()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
|
||||
fn parent_pty_fd_targets() -> Vec<String> {
|
||||
let Ok(entries) = std::fs::read_dir("/proc/self/fd") else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut targets: Vec<String> = entries
|
||||
.filter_map(Result::ok)
|
||||
.filter_map(|entry| std::fs::read_link(entry.path()).ok())
|
||||
.map(|target| target.to_string_lossy().into_owned())
|
||||
.filter(|target| target.starts_with("/dev/pts/") || target == "/dev/ptmx")
|
||||
.collect();
|
||||
targets.sort();
|
||||
targets
|
||||
}
|
||||
|
||||
fn parent_pty_fd_count() -> usize {
|
||||
parent_pty_fd_targets().len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portable_pty_setup_leaves_one_parent_pty_fd() {
|
||||
let _guard = pty_fd_test_lock().lock().expect("pty fd test lock");
|
||||
let before = parent_pty_fd_count();
|
||||
let mut cmd = CommandBuilder::new("/bin/cat");
|
||||
cmd.env(crate::HERDR_ENV_VAR, crate::HERDR_ENV_VALUE);
|
||||
|
||||
let mut spawned =
|
||||
spawn_with_portable_pty(24, 80, cmd).expect("portable pty setup succeeds");
|
||||
let after_spawn = parent_pty_fd_count();
|
||||
|
||||
assert_eq!(
|
||||
after_spawn,
|
||||
before + 1,
|
||||
"portable-pty setup should leave only the Herdr-owned master fd in the parent: {:?}",
|
||||
parent_pty_fd_targets()
|
||||
);
|
||||
|
||||
let _ = spawned.child.kill();
|
||||
let _ = spawned.child.wait();
|
||||
drop(spawned.master_fd);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
#[cfg(unix)]
|
||||
pub(crate) mod actor;
|
||||
#[cfg(unix)]
|
||||
pub(crate) mod backend;
|
||||
#[cfg(unix)]
|
||||
pub(crate) mod fd;
|
||||
|
||||
+21
-2369
File diff suppressed because it is too large
Load Diff
+2496
File diff suppressed because it is too large
Load Diff
+127
-32
@@ -9,14 +9,14 @@
|
||||
//! (escape hatch for users who want the traditional single-process behavior).
|
||||
|
||||
use std::io;
|
||||
use std::os::unix::net::UnixStream;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
use tracing::{info, warn};
|
||||
use tracing::info;
|
||||
|
||||
use super::socket_paths::client_socket_path;
|
||||
|
||||
@@ -30,6 +30,10 @@ const SOCKET_POLL_INTERVAL: Duration = Duration::from_millis(50);
|
||||
/// Timeout for checking the stable JSON API before attaching to the binary protocol socket.
|
||||
const STATUS_REQUEST_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Private daemon-start hint used to seed a fresh headless server from the
|
||||
/// directory where the user ran `herdr`.
|
||||
pub(crate) const STARTUP_CWD_ENV_VAR: &str = "HERDR_STARTUP_CWD";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server detection
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -39,7 +43,7 @@ const STATUS_REQUEST_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
/// This works by attempting to connect to the client socket. If the connection
|
||||
/// succeeds, a server is running. If the socket file doesn't exist or the
|
||||
/// connection is refused, no server is running. Stale sockets (from a crashed
|
||||
/// server) are detected because `UnixStream::connect` returns `ConnectionRefused`
|
||||
/// server) are detected because connect returns `ConnectionRefused`
|
||||
/// when nobody is listening.
|
||||
#[allow(dead_code)] // Public API for external use and testing
|
||||
pub fn is_server_listening() -> bool {
|
||||
@@ -48,34 +52,43 @@ pub fn is_server_listening() -> bool {
|
||||
|
||||
/// Checks whether a herdr server is listening at a specific socket path.
|
||||
fn is_server_listening_at(socket_path: &Path) -> bool {
|
||||
if !socket_path.exists() {
|
||||
return false;
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = socket_path;
|
||||
return read_server_status().ok().flatten().is_some();
|
||||
}
|
||||
|
||||
match UnixStream::connect(socket_path) {
|
||||
Ok(_) => {
|
||||
// Server is listening. Close the test connection immediately.
|
||||
// The server's handshake handler will time out on this connection
|
||||
// since we don't send Hello, which is fine.
|
||||
true
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
if !socket_path.exists() {
|
||||
return false;
|
||||
}
|
||||
Err(err)
|
||||
if matches!(
|
||||
err.kind(),
|
||||
io::ErrorKind::ConnectionRefused | io::ErrorKind::TimedOut
|
||||
) =>
|
||||
{
|
||||
// Socket file exists but nobody is listening — stale socket.
|
||||
false
|
||||
}
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => {
|
||||
// Socket file disappeared between exists() and connect().
|
||||
false
|
||||
}
|
||||
Err(err) => {
|
||||
// Other errors (permission denied, etc.) — assume not listening.
|
||||
warn!(err = %err, "unexpected error checking server socket");
|
||||
false
|
||||
|
||||
match crate::ipc::connect_local_stream(socket_path) {
|
||||
Ok(_) => {
|
||||
// Server is listening. Close the test connection immediately.
|
||||
// The server's handshake handler will time out on this connection
|
||||
// since we don't send Hello, which is fine.
|
||||
true
|
||||
}
|
||||
Err(err)
|
||||
if matches!(
|
||||
err.kind(),
|
||||
io::ErrorKind::ConnectionRefused | io::ErrorKind::TimedOut
|
||||
) =>
|
||||
{
|
||||
// Socket file exists but nobody is listening — stale socket.
|
||||
false
|
||||
}
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => {
|
||||
// Socket file disappeared between exists() and connect().
|
||||
false
|
||||
}
|
||||
Err(err) => {
|
||||
// Other errors (permission denied, etc.) — assume not listening.
|
||||
tracing::warn!(err = %err, "unexpected error checking server socket");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,6 +97,58 @@ fn read_server_status() -> io::Result<Option<crate::api::RuntimeStatus>> {
|
||||
crate::api::read_runtime_status_at(&crate::api::socket_path(), STATUS_REQUEST_TIMEOUT)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn client_protocol_accepts_hello(socket_path: &Path) -> io::Result<bool> {
|
||||
if !socket_path.exists() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let mut stream = match crate::ipc::connect_local_stream(socket_path) {
|
||||
Ok(stream) => stream,
|
||||
Err(err)
|
||||
if matches!(
|
||||
err.kind(),
|
||||
io::ErrorKind::ConnectionRefused
|
||||
| io::ErrorKind::NotFound
|
||||
| io::ErrorKind::TimedOut
|
||||
| io::ErrorKind::WouldBlock
|
||||
) =>
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
|
||||
let hello = crate::protocol::ClientMessage::Hello {
|
||||
version: crate::protocol::PROTOCOL_VERSION,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cell_width_px: 0,
|
||||
cell_height_px: 0,
|
||||
requested_encoding: crate::protocol::RenderEncoding::SemanticFrame,
|
||||
keybindings: crate::protocol::ClientKeybindings::Server,
|
||||
launch_mode: crate::protocol::ClientLaunchMode::App,
|
||||
};
|
||||
|
||||
match crate::protocol::write_message(&mut stream, &hello) {
|
||||
Ok(()) => Ok(true),
|
||||
Err(crate::protocol::FramingError::Io(err))
|
||||
if matches!(
|
||||
err.kind(),
|
||||
io::ErrorKind::ConnectionRefused
|
||||
| io::ErrorKind::NotFound
|
||||
| io::ErrorKind::TimedOut
|
||||
| io::ErrorKind::WouldBlock
|
||||
| io::ErrorKind::BrokenPipe
|
||||
| io::ErrorKind::ConnectionReset
|
||||
) =>
|
||||
{
|
||||
Ok(false)
|
||||
}
|
||||
Err(err) => Err(io::Error::other(err.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_running_server_compatibility() -> io::Result<()> {
|
||||
let Some(status) = read_server_status()? else {
|
||||
return Err(io::Error::other(format!(
|
||||
@@ -149,13 +214,25 @@ fn build_server_daemon_command(exe: PathBuf) -> Command {
|
||||
let mut command = Command::new(&exe);
|
||||
command
|
||||
.arg("server")
|
||||
// Create a new process group so the server survives the parent's exit
|
||||
// and doesn't receive SIGHUP when the client's terminal closes.
|
||||
.process_group(0)
|
||||
// Redirect stdio to /dev/null
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// Create a new process group so the server survives the parent's exit
|
||||
// and doesn't receive SIGHUP when the client's terminal closes.
|
||||
command.process_group(0);
|
||||
}
|
||||
|
||||
match std::env::current_dir() {
|
||||
Ok(cwd) => {
|
||||
command.env(STARTUP_CWD_ENV_VAR, cwd);
|
||||
}
|
||||
Err(_) => {
|
||||
command.env_remove(STARTUP_CWD_ENV_VAR);
|
||||
}
|
||||
}
|
||||
|
||||
if crate::session::explicit_session_requested() {
|
||||
command
|
||||
@@ -179,6 +256,13 @@ pub fn wait_for_server_socket(socket_path: &Path, timeout: Duration) -> io::Resu
|
||||
let deadline = std::time::Instant::now() + timeout;
|
||||
|
||||
while std::time::Instant::now() < deadline {
|
||||
#[cfg(windows)]
|
||||
if client_protocol_accepts_hello(socket_path)? {
|
||||
info!(path = %socket_path.display(), "server client protocol ready");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
if is_server_listening_at(socket_path) {
|
||||
info!(path = %socket_path.display(), "server socket ready");
|
||||
return Ok(());
|
||||
@@ -232,7 +316,7 @@ pub fn auto_detect_launch() -> io::Result<()> {
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::ffi::OsStr;
|
||||
@@ -289,6 +373,17 @@ mod tests {
|
||||
crate::session::clear_explicit_session_for_test();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_daemon_command_passes_current_dir_as_startup_cwd() {
|
||||
let expected = std::env::current_dir().unwrap();
|
||||
let command = build_server_daemon_command(PathBuf::from("/tmp/herdr-test"));
|
||||
let envs: Vec<_> = command.get_envs().collect();
|
||||
|
||||
assert!(envs.iter().any(|(key, value)| {
|
||||
*key == OsStr::new(STARTUP_CWD_ENV_VAR) && value == &Some(expected.as_os_str())
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_server_listening_returns_true_for_live_socket() {
|
||||
let dir = unique_test_dir("live");
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
use std::io;
|
||||
use std::os::unix::net::UnixListener;
|
||||
use std::sync::{atomic::AtomicBool, Arc};
|
||||
|
||||
use interprocess::local_socket::traits::{Listener as _, Stream as _};
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, error, warn};
|
||||
|
||||
use crate::ipc::LocalListener;
|
||||
use crate::server::client_transport::{self, ServerEvent};
|
||||
|
||||
/// Accepts pending thin-client connections and starts their handshake readers.
|
||||
pub(crate) fn accept_pending_client_connections(
|
||||
listener: &UnixListener,
|
||||
listener: &LocalListener,
|
||||
next_client_id: &mut u64,
|
||||
should_quit: &Arc<AtomicBool>,
|
||||
server_event_tx: &mpsc::Sender<ServerEvent>,
|
||||
) -> io::Result<()> {
|
||||
loop {
|
||||
match listener.accept() {
|
||||
Ok((stream, _addr)) => {
|
||||
Ok(stream) => {
|
||||
let client_id = *next_client_id;
|
||||
*next_client_id = next_client_id.saturating_add(1);
|
||||
|
||||
@@ -53,10 +54,10 @@ pub(crate) fn accept_pending_client_connections(
|
||||
///
|
||||
/// During live handoff the old server must not let clients sit in the Unix
|
||||
/// listener backlog waiting for a welcome frame that will never be sent.
|
||||
pub(crate) fn reject_pending_client_connections(listener: &UnixListener) -> io::Result<()> {
|
||||
pub(crate) fn reject_pending_client_connections(listener: &LocalListener) -> io::Result<()> {
|
||||
loop {
|
||||
match listener.accept() {
|
||||
Ok((_stream, _addr)) => {}
|
||||
Ok(_stream) => {}
|
||||
Err(ref err) if err.kind() == io::ErrorKind::WouldBlock => break,
|
||||
Err(err) => {
|
||||
error!(err = %err, "client listener reject failed");
|
||||
|
||||
+196
-15
@@ -5,18 +5,20 @@
|
||||
//! `HeadlessServer`.
|
||||
|
||||
use std::io::{self, Write};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use interprocess::local_socket::traits::Stream as _;
|
||||
use interprocess::TryClone as _;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::ipc::LocalStream;
|
||||
use crate::protocol::{
|
||||
self, AttachScrollDirection, AttachScrollSource, ClientKeybindings, ClientLaunchMode,
|
||||
ClientMessage, RenderEncoding, ServerMessage, MAX_CLIPBOARD_IMAGE_PAYLOAD, MAX_FRAME_SIZE,
|
||||
MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION,
|
||||
self, AttachScrollDirection, AttachScrollSource, ClientInputEvent, ClientKeybindings,
|
||||
ClientLaunchMode, ClientMessage, RenderEncoding, ServerMessage, MAX_CLIPBOARD_IMAGE_PAYLOAD,
|
||||
MAX_FRAME_SIZE, MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION,
|
||||
};
|
||||
|
||||
/// Minimum accepted attached client size.
|
||||
@@ -35,6 +37,8 @@ const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(4);
|
||||
|
||||
/// Maximum input payload size (bytes) for a single `ClientMessage::Input`.
|
||||
const MAX_INPUT_PAYLOAD: usize = 1024 * 1024; // 1 MB
|
||||
/// Maximum structured input events accepted in one client message.
|
||||
const MAX_INPUT_EVENT_BATCH: usize = 4096;
|
||||
|
||||
/// Channels owned by the server side of a client writer thread.
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -62,6 +66,11 @@ pub(crate) enum ServerEvent {
|
||||
},
|
||||
/// A client sent an input message.
|
||||
ClientInput { client_id: u64, data: Vec<u8> },
|
||||
/// A client sent structured input events.
|
||||
ClientInputEvents {
|
||||
client_id: u64,
|
||||
events: Vec<crate::protocol::ClientInputEvent>,
|
||||
},
|
||||
/// A client sent local clipboard image bytes to paste into a remote pane.
|
||||
ClientClipboardImage {
|
||||
client_id: u64,
|
||||
@@ -126,12 +135,30 @@ fn parse_client_keybindings(
|
||||
}
|
||||
}
|
||||
|
||||
fn input_events_within_limits(events: &[ClientInputEvent]) -> bool {
|
||||
if events.len() > MAX_INPUT_EVENT_BATCH {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut paste_bytes = 0usize;
|
||||
for event in events {
|
||||
if let ClientInputEvent::Paste { text } = event {
|
||||
paste_bytes = paste_bytes.saturating_add(text.len());
|
||||
if paste_bytes > MAX_INPUT_PAYLOAD {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Handles the client handshake on a blocking thread.
|
||||
///
|
||||
/// Reads the `Hello` message, validates the version, sends `Welcome`,
|
||||
/// and then enters a read loop forwarding messages to the server event channel.
|
||||
pub(crate) fn handle_client_handshake(
|
||||
mut stream: UnixStream,
|
||||
mut stream: LocalStream,
|
||||
client_id: u64,
|
||||
server_event_tx: &mpsc::Sender<ServerEvent>,
|
||||
should_quit: &Arc<AtomicBool>,
|
||||
@@ -140,8 +167,9 @@ pub(crate) fn handle_client_handshake(
|
||||
// the handshake thread needs blocking I/O for read_message/write_message.
|
||||
stream.set_nonblocking(false)?;
|
||||
|
||||
// Set a read timeout for the handshake.
|
||||
stream.set_read_timeout(Some(HANDSHAKE_TIMEOUT))?;
|
||||
if let Err(err) = stream.set_recv_timeout(Some(HANDSHAKE_TIMEOUT)) {
|
||||
debug!(client_id, err = %err, "client handshake read timeout unavailable");
|
||||
}
|
||||
|
||||
// Read the Hello message.
|
||||
let hello: ClientMessage = match protocol::read_message(&mut stream, MAX_FRAME_SIZE) {
|
||||
@@ -240,8 +268,9 @@ pub(crate) fn handle_client_handshake(
|
||||
};
|
||||
protocol::write_message(&mut stream, &welcome).map_err(|e| io::Error::other(e.to_string()))?;
|
||||
|
||||
// Clear read timeout for normal operation.
|
||||
stream.set_read_timeout(None)?;
|
||||
if let Err(err) = stream.set_recv_timeout(None) {
|
||||
debug!(client_id, err = %err, "failed to clear client handshake read timeout");
|
||||
}
|
||||
|
||||
// Create separate channels for reliable control messages and droppable renders.
|
||||
let (control_tx, control_rx) = std::sync::mpsc::channel::<Vec<u8>>();
|
||||
@@ -283,7 +312,7 @@ pub(crate) fn handle_client_handshake(
|
||||
|
||||
/// The client writer loop — prioritizes control messages over render frames.
|
||||
fn client_writer_loop(
|
||||
mut stream: UnixStream,
|
||||
mut stream: LocalStream,
|
||||
client_id: u64,
|
||||
control_rx: std::sync::mpsc::Receiver<Vec<u8>>,
|
||||
render_rx: std::sync::mpsc::Receiver<Vec<u8>>,
|
||||
@@ -349,7 +378,7 @@ fn client_writer_loop(
|
||||
debug!("client writer thread exiting");
|
||||
}
|
||||
|
||||
fn write_framed_bytes(stream: &mut UnixStream, data: &[u8]) -> bool {
|
||||
fn write_framed_bytes(stream: &mut LocalStream, data: &[u8]) -> bool {
|
||||
if let Err(err) = stream.write_all(data) {
|
||||
debug!(err = %err, "client write failed, closing writer");
|
||||
return false;
|
||||
@@ -363,7 +392,7 @@ fn write_framed_bytes(stream: &mut UnixStream, data: &[u8]) -> bool {
|
||||
|
||||
/// The client read loop — reads messages from the client and forwards to the server event channel.
|
||||
fn client_read_loop(
|
||||
mut stream: UnixStream,
|
||||
mut stream: LocalStream,
|
||||
client_id: u64,
|
||||
server_event_tx: &mpsc::Sender<ServerEvent>,
|
||||
should_quit: &Arc<AtomicBool>,
|
||||
@@ -411,6 +440,20 @@ fn client_read_loop(
|
||||
ServerEvent::ClientInput { client_id, data }
|
||||
}
|
||||
}
|
||||
ClientMessage::InputEvents { events } => {
|
||||
if !input_events_within_limits(&events) {
|
||||
warn!(
|
||||
client_id,
|
||||
count = events.len(),
|
||||
"oversized input events from client, closing"
|
||||
);
|
||||
let _ = server_event_tx
|
||||
.blocking_send(ServerEvent::ClientDisconnected { client_id });
|
||||
break;
|
||||
} else {
|
||||
ServerEvent::ClientInputEvents { client_id, events }
|
||||
}
|
||||
}
|
||||
ClientMessage::ClipboardImage { extension, data } => {
|
||||
if data.len() > MAX_CLIPBOARD_IMAGE_PAYLOAD {
|
||||
warn!(
|
||||
@@ -487,6 +530,23 @@ fn client_read_loop(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use interprocess::local_socket::traits::Listener as _;
|
||||
|
||||
fn unique_test_path(name: &str) -> std::path::PathBuf {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!("herdr-{name}-{}-{nanos}", std::process::id()))
|
||||
}
|
||||
|
||||
fn local_stream_pair(name: &str) -> (LocalStream, LocalStream, std::path::PathBuf) {
|
||||
let path = unique_test_path(name);
|
||||
let listener = crate::ipc::bind_local_listener(&path).unwrap();
|
||||
let client = crate::ipc::connect_local_stream(&path).unwrap();
|
||||
let server = listener.accept().unwrap();
|
||||
(client, server, path)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamp_terminal_size_zero_zero() {
|
||||
@@ -569,7 +629,7 @@ new_tab = "ctrl+notakey"
|
||||
|
||||
#[test]
|
||||
fn handshake_negotiates_terminal_ansi_encoding() {
|
||||
let (mut client_stream, server_stream) = UnixStream::pair().expect("socket pair");
|
||||
let (mut client_stream, server_stream, _path) = local_stream_pair("client-handshake-ansi");
|
||||
let (server_event_tx, mut server_event_rx) = mpsc::channel(4);
|
||||
let should_quit = Arc::new(AtomicBool::new(false));
|
||||
let handshake_quit = should_quit.clone();
|
||||
@@ -643,7 +703,8 @@ new_tab = "ctrl+notakey"
|
||||
|
||||
#[test]
|
||||
fn handshake_marks_terminal_attach_launch_mode() {
|
||||
let (mut client_stream, server_stream) = UnixStream::pair().expect("socket pair");
|
||||
let (mut client_stream, server_stream, _path) =
|
||||
local_stream_pair("client-handshake-terminal-attach");
|
||||
let (server_event_tx, mut server_event_rx) = mpsc::channel(4);
|
||||
let should_quit = Arc::new(AtomicBool::new(false));
|
||||
let handshake_quit = should_quit.clone();
|
||||
@@ -706,7 +767,7 @@ new_tab = "ctrl+notakey"
|
||||
|
||||
#[test]
|
||||
fn client_read_loop_rejects_oversized_input() {
|
||||
let (mut client_stream, server_stream) = UnixStream::pair().expect("socket pair");
|
||||
let (mut client_stream, server_stream, _path) = local_stream_pair("client-read-oversized");
|
||||
let (server_event_tx, mut server_event_rx) = mpsc::channel(4);
|
||||
let should_quit = Arc::new(AtomicBool::new(false));
|
||||
let read_quit = should_quit.clone();
|
||||
@@ -738,6 +799,126 @@ new_tab = "ctrl+notakey"
|
||||
.expect("read thread result");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_read_loop_forwards_input_events() {
|
||||
let (mut client_stream, server_stream, _path) = local_stream_pair("client-read-events");
|
||||
let (server_event_tx, mut server_event_rx) = mpsc::channel(4);
|
||||
let should_quit = Arc::new(AtomicBool::new(false));
|
||||
let read_quit = should_quit.clone();
|
||||
let handle = std::thread::spawn(move || {
|
||||
client_read_loop(server_stream, 7, &server_event_tx, &read_quit)
|
||||
});
|
||||
let events = vec![
|
||||
ClientInputEvent::Key {
|
||||
code: crate::protocol::ClientKeyCode::Enter,
|
||||
modifiers: 0,
|
||||
kind: crate::protocol::ClientKeyKind::Press,
|
||||
},
|
||||
ClientInputEvent::FocusGained,
|
||||
];
|
||||
|
||||
protocol::write_message(
|
||||
&mut client_stream,
|
||||
&ClientMessage::InputEvents {
|
||||
events: events.clone(),
|
||||
},
|
||||
)
|
||||
.expect("write input events");
|
||||
|
||||
match server_event_rx
|
||||
.blocking_recv()
|
||||
.expect("client input events event")
|
||||
{
|
||||
ServerEvent::ClientInputEvents {
|
||||
client_id,
|
||||
events: actual,
|
||||
} => {
|
||||
assert_eq!(client_id, 7);
|
||||
assert_eq!(actual, events);
|
||||
}
|
||||
other => panic!("expected ClientInputEvents, got {other:?}"),
|
||||
}
|
||||
|
||||
drop(client_stream);
|
||||
should_quit.store(true, Ordering::Release);
|
||||
handle
|
||||
.join()
|
||||
.expect("read thread join")
|
||||
.expect("read thread result");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_read_loop_rejects_oversized_input_event_batch() {
|
||||
let (mut client_stream, server_stream, _path) =
|
||||
local_stream_pair("client-read-oversized-events");
|
||||
let (server_event_tx, mut server_event_rx) = mpsc::channel(4);
|
||||
let should_quit = Arc::new(AtomicBool::new(false));
|
||||
let read_quit = should_quit.clone();
|
||||
let handle = std::thread::spawn(move || {
|
||||
client_read_loop(server_stream, 7, &server_event_tx, &read_quit)
|
||||
});
|
||||
|
||||
protocol::write_message(
|
||||
&mut client_stream,
|
||||
&ClientMessage::InputEvents {
|
||||
events: vec![ClientInputEvent::FocusGained; MAX_INPUT_EVENT_BATCH + 1],
|
||||
},
|
||||
)
|
||||
.expect("write oversized input events");
|
||||
|
||||
match server_event_rx
|
||||
.blocking_recv()
|
||||
.expect("client disconnected event")
|
||||
{
|
||||
ServerEvent::ClientDisconnected { client_id } => assert_eq!(client_id, 7),
|
||||
other => panic!("expected ClientDisconnected, got {other:?}"),
|
||||
}
|
||||
|
||||
drop(client_stream);
|
||||
should_quit.store(true, Ordering::Release);
|
||||
handle
|
||||
.join()
|
||||
.expect("read thread join")
|
||||
.expect("read thread result");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_read_loop_rejects_oversized_input_event_paste() {
|
||||
let (mut client_stream, server_stream, _path) =
|
||||
local_stream_pair("client-read-oversized-paste");
|
||||
let (server_event_tx, mut server_event_rx) = mpsc::channel(4);
|
||||
let should_quit = Arc::new(AtomicBool::new(false));
|
||||
let read_quit = should_quit.clone();
|
||||
let handle = std::thread::spawn(move || {
|
||||
client_read_loop(server_stream, 7, &server_event_tx, &read_quit)
|
||||
});
|
||||
|
||||
protocol::write_message(
|
||||
&mut client_stream,
|
||||
&ClientMessage::InputEvents {
|
||||
events: vec![ClientInputEvent::Paste {
|
||||
text: "x".repeat(MAX_INPUT_PAYLOAD + 1),
|
||||
}],
|
||||
},
|
||||
)
|
||||
.expect("write oversized paste event");
|
||||
|
||||
match server_event_rx
|
||||
.blocking_recv()
|
||||
.expect("client disconnected event")
|
||||
{
|
||||
ServerEvent::ClientDisconnected { client_id } => assert_eq!(client_id, 7),
|
||||
other => panic!("expected ClientDisconnected, got {other:?}"),
|
||||
}
|
||||
|
||||
drop(client_stream);
|
||||
should_quit.store(true, Ordering::Release);
|
||||
handle
|
||||
.join()
|
||||
.expect("read thread join")
|
||||
.expect("read thread result");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handshake_timeout_is_within_five_second_deadline() {
|
||||
// The handshake timeout must be short enough that
|
||||
|
||||
@@ -15,8 +15,6 @@ pub(crate) fn stage(
|
||||
extension: &str,
|
||||
data: &[u8],
|
||||
) -> io::Result<StagedClipboardImage> {
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
|
||||
let extension = sanitize_extension(extension);
|
||||
let dir = ensure_staging_dir()?;
|
||||
cleanup_stale(&dir);
|
||||
@@ -30,12 +28,10 @@ pub(crate) fn stage(
|
||||
let path = dir.join(format!(
|
||||
"client-{client_id}-clipboard-{unique}-{attempt}.{extension}"
|
||||
));
|
||||
let mut file = match fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(&path)
|
||||
{
|
||||
let mut options = fs::OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
restrict_file_options(&mut options);
|
||||
let mut file = match options.open(&path) {
|
||||
Ok(file) => file,
|
||||
Err(err) if err.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||
Err(err) => return Err(err),
|
||||
@@ -76,13 +72,14 @@ fn sanitize_extension(extension: &str) -> &'static str {
|
||||
}
|
||||
|
||||
fn staging_dir() -> PathBuf {
|
||||
#[cfg(unix)]
|
||||
let user_id = unsafe { libc::geteuid() };
|
||||
#[cfg(windows)]
|
||||
let user_id = std::process::id();
|
||||
std::env::temp_dir().join(format!("herdr-clipboard-images-{user_id}"))
|
||||
}
|
||||
|
||||
fn ensure_staging_dir() -> io::Result<PathBuf> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = staging_dir();
|
||||
fs::create_dir_all(&dir)?;
|
||||
let metadata = fs::metadata(&dir)?;
|
||||
@@ -92,10 +89,32 @@ fn ensure_staging_dir() -> io::Result<PathBuf> {
|
||||
dir.display()
|
||||
)));
|
||||
}
|
||||
fs::set_permissions(&dir, fs::Permissions::from_mode(0o700))?;
|
||||
restrict_dir_permissions(&dir)?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn restrict_file_options(options: &mut fs::OpenOptions) {
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
|
||||
options.mode(0o600);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn restrict_file_options(_options: &mut fs::OpenOptions) {}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn restrict_dir_permissions(dir: &Path) -> io::Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
fs::set_permissions(dir, fs::Permissions::from_mode(0o700))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn restrict_dir_permissions(_dir: &Path) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cleanup_stale(dir: &Path) {
|
||||
let Ok(entries) = fs::read_dir(dir) else {
|
||||
return;
|
||||
|
||||
+319
-86
@@ -16,13 +16,17 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io;
|
||||
use std::os::unix::net::UnixListener;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crossterm::event::{KeyModifiers, MouseEventKind};
|
||||
use interprocess::local_socket::traits::Listener as _;
|
||||
#[cfg(windows)]
|
||||
use interprocess::local_socket::traits::Stream as _;
|
||||
#[cfg(unix)]
|
||||
use interprocess::local_socket::ListenerNonblockingMode;
|
||||
use ratatui::layout::Rect;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, error, info, warn};
|
||||
@@ -34,11 +38,15 @@ use crate::api;
|
||||
use crate::app;
|
||||
use crate::config;
|
||||
use crate::events::AppEvent;
|
||||
use crate::ipc::{remove_socket_file_if_owned, socket_file_identity, SocketFileIdentity};
|
||||
use crate::ipc::{
|
||||
bind_local_listener, remove_socket_file_if_owned, socket_file_identity, LocalListener,
|
||||
SocketFileIdentity,
|
||||
};
|
||||
use crate::protocol::{
|
||||
self, AttachScrollDirection, AttachScrollSource, FrameData, ServerMessage, MAX_FRAME_SIZE,
|
||||
MAX_GRAPHICS_FRAME_SIZE,
|
||||
};
|
||||
#[cfg(unix)]
|
||||
use crate::server::client_accept::{
|
||||
accept_pending_client_connections, reject_pending_client_connections,
|
||||
};
|
||||
@@ -167,7 +175,7 @@ const MIN_ROWS: u16 = 24;
|
||||
#[allow(dead_code)]
|
||||
const SHUTDOWN_API_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// How often the idle headless loop wakes to poll the std UnixListener for new
|
||||
/// How often the idle headless loop wakes to poll the local listener for new
|
||||
/// client connections.
|
||||
///
|
||||
/// The listener is non-blocking and not integrated into `tokio::select!`, so
|
||||
@@ -183,12 +191,16 @@ const CLIENT_ACCEPT_POLL_INTERVAL: Duration = Duration::from_millis(250);
|
||||
/// The headless server — runs the herdr event loop without a real terminal.
|
||||
pub struct HeadlessServer {
|
||||
app: app::App,
|
||||
#[cfg(unix)]
|
||||
api_tx: Option<api::ApiRequestSender>,
|
||||
#[cfg(unix)]
|
||||
api_server: Option<api::ServerHandle>,
|
||||
client_listener: UnixListener,
|
||||
#[cfg(unix)]
|
||||
client_listener: LocalListener,
|
||||
client_socket_path: PathBuf,
|
||||
client_socket_identity: SocketFileIdentity,
|
||||
clients: HashMap<u64, ClientConnection>,
|
||||
#[cfg(unix)]
|
||||
next_client_id: u64,
|
||||
/// The client currently driving the shared pane runtime size, theme, and input keybindings.
|
||||
foreground_client_id: Option<u64>,
|
||||
@@ -210,6 +222,7 @@ pub struct HeadlessServer {
|
||||
/// Flag set while exporting live PTYs to a replacement server.
|
||||
handoff_in_progress: bool,
|
||||
/// Imported panes get one app-safe resize nudge after the first client attaches.
|
||||
#[cfg(unix)]
|
||||
pending_handoff_repaint_nudge: bool,
|
||||
/// Flag set by Ctrl+C or `server stop` signal.
|
||||
should_quit: Arc<AtomicBool>,
|
||||
@@ -292,6 +305,51 @@ fn apply_terminal_attach_input(
|
||||
.map_err(|err| format!("terminal attach input failed: {err}"))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn spawn_windows_client_accept_thread(
|
||||
listener: LocalListener,
|
||||
should_quit: Arc<AtomicBool>,
|
||||
server_event_tx: mpsc::Sender<ServerEvent>,
|
||||
) {
|
||||
std::thread::spawn(move || {
|
||||
let mut next_client_id = 1_u64;
|
||||
while !should_quit.load(Ordering::Acquire) {
|
||||
let stream = match listener.accept() {
|
||||
Ok(stream) => stream,
|
||||
Err(err) => {
|
||||
if should_quit.load(Ordering::Acquire) {
|
||||
break;
|
||||
}
|
||||
error!(err = %err, "client listener accept failed");
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let client_id = next_client_id;
|
||||
next_client_id = next_client_id.saturating_add(1);
|
||||
|
||||
if let Err(err) = stream.set_nonblocking(true) {
|
||||
warn!(err = %err, "failed to set client stream nonblocking");
|
||||
continue;
|
||||
}
|
||||
|
||||
let should_quit = should_quit.clone();
|
||||
let server_event_tx = server_event_tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Err(err) = crate::server::client_transport::handle_client_handshake(
|
||||
stream,
|
||||
client_id,
|
||||
&server_event_tx,
|
||||
&should_quit,
|
||||
) {
|
||||
debug!(client_id, err = %err, "client handshake failed");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
impl HeadlessServer {
|
||||
/// Creates and starts the headless server.
|
||||
///
|
||||
@@ -308,30 +366,40 @@ impl HeadlessServer {
|
||||
let client_path = client_socket_path();
|
||||
prepare_socket_path(&client_path)?;
|
||||
|
||||
let listener = UnixListener::bind(&client_path)?;
|
||||
let listener = bind_local_listener(&client_path)?;
|
||||
restrict_socket_permissions(&client_path)?;
|
||||
let client_socket_identity = socket_file_identity(&client_path)?;
|
||||
info!(path = %client_path.display(), "client protocol socket listening");
|
||||
|
||||
// Set non-blocking on the listener so we can poll it from the event loop.
|
||||
listener.set_nonblocking(true)?;
|
||||
// Set non-blocking on Unix so we can poll it from the event loop.
|
||||
#[cfg(unix)]
|
||||
listener.set_nonblocking(ListenerNonblockingMode::Accept)?;
|
||||
|
||||
let should_quit = Arc::new(AtomicBool::new(false));
|
||||
|
||||
// Channel for server events from client threads.
|
||||
let (server_event_tx, server_event_rx) = mpsc::channel(64);
|
||||
#[cfg(windows)]
|
||||
spawn_windows_client_accept_thread(listener, should_quit.clone(), server_event_tx.clone());
|
||||
|
||||
let server_keybindings = app_keybindings(&app);
|
||||
let (server_config_diagnostic, server_config_diagnostic_without_keybindings) =
|
||||
server_config_diagnostic_summaries(config_diagnostics);
|
||||
#[cfg(not(unix))]
|
||||
let _ = (&api_tx, &api_server);
|
||||
|
||||
Ok(Self {
|
||||
app,
|
||||
#[cfg(unix)]
|
||||
api_tx,
|
||||
#[cfg(unix)]
|
||||
api_server,
|
||||
#[cfg(unix)]
|
||||
client_listener: listener,
|
||||
client_socket_path: client_path,
|
||||
client_socket_identity,
|
||||
clients: HashMap::new(),
|
||||
#[cfg(unix)]
|
||||
next_client_id: 1,
|
||||
foreground_client_id: None,
|
||||
server_keybindings,
|
||||
@@ -342,6 +410,7 @@ impl HeadlessServer {
|
||||
effective_size: (MIN_COLS, MIN_ROWS),
|
||||
shutting_down: false,
|
||||
handoff_in_progress: false,
|
||||
#[cfg(unix)]
|
||||
pending_handoff_repaint_nudge: false,
|
||||
should_quit,
|
||||
server_event_rx,
|
||||
@@ -891,7 +960,7 @@ impl HeadlessServer {
|
||||
} else {
|
||||
let _ = std::fs::remove_file(crate::api::socket_path());
|
||||
}
|
||||
let _ = remove_socket_file_if_owned(&self.client_socket_path, self.client_socket_identity);
|
||||
let _ = remove_socket_file_if_owned(&self.client_socket_path, &self.client_socket_identity);
|
||||
if let Err(err) = crate::server::handoff::wait_ready(&mut stream) {
|
||||
crate::server::handoff::cleanup_failed_import_child(&mut import_child);
|
||||
match self.wait_then_restore_public_sockets_after_failed_handoff() {
|
||||
@@ -972,10 +1041,10 @@ impl HeadlessServer {
|
||||
|
||||
let client_path = client_socket_path();
|
||||
prepare_socket_path(&client_path)?;
|
||||
let listener = UnixListener::bind(&client_path)?;
|
||||
let listener = bind_local_listener(&client_path)?;
|
||||
restrict_socket_permissions(&client_path)?;
|
||||
let client_socket_identity = socket_file_identity(&client_path)?;
|
||||
listener.set_nonblocking(true)?;
|
||||
listener.set_nonblocking(ListenerNonblockingMode::Accept)?;
|
||||
|
||||
self.api_server = Some(api_server);
|
||||
self.client_listener = listener;
|
||||
@@ -1188,6 +1257,7 @@ impl HeadlessServer {
|
||||
}
|
||||
|
||||
/// Accepts pending client connections from the non-blocking listener.
|
||||
#[cfg(unix)]
|
||||
fn accept_client_connections(&mut self) -> io::Result<()> {
|
||||
if self.handoff_in_progress {
|
||||
return reject_pending_client_connections(&self.client_listener);
|
||||
@@ -1200,6 +1270,13 @@ impl HeadlessServer {
|
||||
)
|
||||
}
|
||||
|
||||
/// Windows named-pipe clients can block in connect unless the server has a
|
||||
/// pending blocking accept. The dedicated accept thread handles that path.
|
||||
#[cfg(windows)]
|
||||
fn accept_client_connections(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drains server events from the dedicated channel.
|
||||
///
|
||||
/// Returns true if any input was processed (requiring a re-render).
|
||||
@@ -1863,6 +1940,7 @@ impl HeadlessServer {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn disconnect_all_clients_for_handoff(&mut self) {
|
||||
let client_ids = self.clients.keys().copied().collect::<Vec<_>>();
|
||||
for client_id in client_ids {
|
||||
@@ -1961,6 +2039,68 @@ impl HeadlessServer {
|
||||
}
|
||||
|
||||
/// Handles a server event. Returns true if the event requires a re-render.
|
||||
fn handle_client_input_events(
|
||||
&mut self,
|
||||
client_id: u64,
|
||||
events: Vec<crate::raw_input::RawInputEvent>,
|
||||
) -> bool {
|
||||
let host_surface_redraw = crate::raw_input::events_require_host_surface_redraw(
|
||||
&events,
|
||||
self.app.state.redraw_on_focus_gained,
|
||||
);
|
||||
if let Some(client) = self.clients.get_mut(&client_id) {
|
||||
if host_surface_redraw {
|
||||
client.request_full_redraw();
|
||||
client.render_pending = true;
|
||||
} else {
|
||||
// Ensure semantic clients receive one post-input frame even if the
|
||||
// semantic buffer compares equal. Terminal-ANSI clients must keep their
|
||||
// server-side blit baseline; resetting it here forces a full redraw on
|
||||
// every keypress and makes remote sessions feel extremely slow.
|
||||
client.request_semantic_redraw_after_input();
|
||||
}
|
||||
}
|
||||
self.update_client_outer_focus_from_events(client_id, &events);
|
||||
let interaction = events_include_interaction(&events);
|
||||
let foreground_changed = if interaction {
|
||||
self.promote_client_to_foreground(client_id)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if foreground_changed {
|
||||
self.resize_shared_runtime_to_effective_size_before_input();
|
||||
}
|
||||
let theme_changed = self.update_client_host_theme_from_events(client_id, &events);
|
||||
self.app
|
||||
.route_client_events(events, self.foreground_client_id == Some(client_id));
|
||||
if self.app.take_config_reloaded_from_disk() {
|
||||
self.reload_server_config(false);
|
||||
} else {
|
||||
self.sync_foreground_client_state();
|
||||
}
|
||||
|
||||
if self.app.state.detach_requested {
|
||||
self.app.state.detach_requested = false;
|
||||
info!(client_id, "client detach requested via keybind");
|
||||
|
||||
self.send_client_graphics_cleanup(client_id);
|
||||
self.send_to_client(
|
||||
client_id,
|
||||
ServerMessage::ServerShutdown {
|
||||
reason: Some("detached".to_owned()),
|
||||
},
|
||||
);
|
||||
|
||||
if let Some(client) = self.clients.get_mut(&client_id) {
|
||||
client.writer = None;
|
||||
}
|
||||
|
||||
false
|
||||
} else {
|
||||
foreground_changed || theme_changed || interaction
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_server_event(&mut self, ev: ServerEvent) -> bool {
|
||||
if self.handoff_in_progress && Self::ignore_client_event_during_handoff(&ev) {
|
||||
return false;
|
||||
@@ -2079,78 +2219,27 @@ impl HeadlessServer {
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let host_surface_redraw = crate::raw_input::events_require_host_surface_redraw(
|
||||
&events,
|
||||
self.app.state.redraw_on_focus_gained,
|
||||
);
|
||||
if let Some(client) = self.clients.get_mut(&client_id) {
|
||||
if host_surface_redraw {
|
||||
client.request_full_redraw();
|
||||
client.render_pending = true;
|
||||
} else {
|
||||
// Ensure semantic clients receive one post-input frame even if the
|
||||
// semantic buffer compares equal. Terminal-ANSI clients must keep their
|
||||
// server-side blit baseline; resetting it here forces a full redraw on
|
||||
// every keypress and makes remote sessions feel extremely slow.
|
||||
client.request_semantic_redraw_after_input();
|
||||
}
|
||||
}
|
||||
self.update_client_outer_focus_from_events(client_id, &events);
|
||||
let interaction = events_include_interaction(&events);
|
||||
let foreground_changed = if interaction {
|
||||
self.promote_client_to_foreground(client_id)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if foreground_changed {
|
||||
self.resize_shared_runtime_to_effective_size_before_input();
|
||||
}
|
||||
let theme_changed = self.update_client_host_theme_from_events(client_id, &events);
|
||||
self.app
|
||||
.route_client_events(events, self.foreground_client_id == Some(client_id));
|
||||
if self.app.take_config_reloaded_from_disk() {
|
||||
self.reload_server_config(false);
|
||||
} else {
|
||||
self.sync_foreground_client_state();
|
||||
}
|
||||
|
||||
// Check if the detach keybind was triggered during input processing.
|
||||
if self.app.state.detach_requested {
|
||||
self.app.state.detach_requested = false;
|
||||
info!(client_id, "client detach requested via keybind");
|
||||
|
||||
// Clear client-local host graphics before sending ServerShutdown
|
||||
// so the outer terminal does not retain stale images.
|
||||
self.send_client_graphics_cleanup(client_id);
|
||||
|
||||
// Send a ServerShutdown with "detached" reason to this client
|
||||
// so it exits cleanly (not with a connection-lost error).
|
||||
// The client will close its connection after receiving this,
|
||||
// which triggers a ClientDisconnected event that removes it.
|
||||
self.send_to_client(
|
||||
self.handle_client_input_events(client_id, events)
|
||||
}
|
||||
ServerEvent::ClientInputEvents { client_id, events } => {
|
||||
if self.handoff_in_progress {
|
||||
debug!(
|
||||
client_id,
|
||||
ServerMessage::ServerShutdown {
|
||||
reason: Some("detached".to_owned()),
|
||||
},
|
||||
len = events.len(),
|
||||
"ignored client input events during handoff"
|
||||
);
|
||||
|
||||
// Don't remove the client here — let the client disconnect
|
||||
// naturally after receiving the ServerShutdown. The client's
|
||||
// read loop will see EOF and the server will get a
|
||||
// ClientDisconnected event which handles cleanup.
|
||||
//
|
||||
// However, we do need to stop sending frames to this client
|
||||
// since it's detaching. Drop the writer channel so no more
|
||||
// frames are queued for this client.
|
||||
if let Some(client) = self.clients.get_mut(&client_id) {
|
||||
client.writer = None;
|
||||
}
|
||||
|
||||
// No re-render needed for remaining clients.
|
||||
false
|
||||
} else {
|
||||
foreground_changed || theme_changed || interaction
|
||||
return false;
|
||||
}
|
||||
debug!(
|
||||
client_id,
|
||||
len = events.len(),
|
||||
"client input events received"
|
||||
);
|
||||
let events = events
|
||||
.iter()
|
||||
.map(crate::protocol::ClientInputEvent::to_raw_input_event)
|
||||
.collect();
|
||||
self.handle_client_input_events(client_id, events)
|
||||
}
|
||||
ServerEvent::ClientClipboardImage {
|
||||
client_id,
|
||||
@@ -3241,7 +3330,7 @@ impl HeadlessServer {
|
||||
/// Removes socket files created by the server.
|
||||
fn cleanup_sockets(&self) -> io::Result<()> {
|
||||
if let Err(err) =
|
||||
remove_socket_file_if_owned(&self.client_socket_path, self.client_socket_identity)
|
||||
remove_socket_file_if_owned(&self.client_socket_path, &self.client_socket_identity)
|
||||
{
|
||||
if err.kind() != io::ErrorKind::NotFound {
|
||||
warn!(
|
||||
@@ -3389,6 +3478,7 @@ pub fn run_server() -> io::Result<()> {
|
||||
api_rx,
|
||||
event_hub,
|
||||
);
|
||||
seed_startup_workspace_if_empty(&mut app);
|
||||
|
||||
// The server runs headless — disable local notification side effects.
|
||||
// Sound and terminal notifications are forwarded to connected clients
|
||||
@@ -3427,6 +3517,36 @@ pub fn run_server() -> io::Result<()> {
|
||||
result
|
||||
}
|
||||
|
||||
fn seed_startup_workspace_if_empty(app: &mut app::App) {
|
||||
let Some(cwd) = take_startup_cwd() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if !app.state.workspaces.is_empty() {
|
||||
info!(
|
||||
cwd = %cwd.display(),
|
||||
"restored session already has workspaces; ignoring startup cwd"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
match app.create_workspace_with_options(cwd.clone(), true) {
|
||||
Ok(_) => {
|
||||
info!(cwd = %cwd.display(), "created startup workspace");
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(cwd = %cwd.display(), err = %err, "failed to create startup workspace");
|
||||
app.state.mode = app::Mode::Navigate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn take_startup_cwd() -> Option<PathBuf> {
|
||||
let cwd = std::env::var_os(crate::server::autodetect::STARTUP_CWD_ENV_VAR)?;
|
||||
std::env::remove_var(crate::server::autodetect::STARTUP_CWD_ENV_VAR);
|
||||
(!cwd.is_empty()).then(|| PathBuf::from(cwd))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn run_handoff_import_server(socket_path: &Path, token: &str) -> io::Result<()> {
|
||||
let loaded_config = config::Config::load();
|
||||
@@ -3499,14 +3619,13 @@ fn run_handoff_import_server(socket_path: &Path, token: &str) -> io::Result<()>
|
||||
|
||||
#[cfg(unix)]
|
||||
fn wait_for_old_public_sockets_to_close(timeout: Duration) -> io::Result<()> {
|
||||
use std::os::unix::net::UnixStream;
|
||||
|
||||
let deadline = Instant::now() + timeout;
|
||||
let api_socket = api::socket_path();
|
||||
let client_socket = client_socket_path();
|
||||
while Instant::now() < deadline {
|
||||
let api_open = api_socket.exists() && UnixStream::connect(&api_socket).is_ok();
|
||||
let client_open = client_socket.exists() && UnixStream::connect(&client_socket).is_ok();
|
||||
let api_open = api_socket.exists() && crate::ipc::connect_local_stream(&api_socket).is_ok();
|
||||
let client_open =
|
||||
client_socket.exists() && crate::ipc::connect_local_stream(&client_socket).is_ok();
|
||||
if !api_open && !client_open {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -3570,23 +3689,32 @@ mod tests {
|
||||
let _ = fs::create_dir_all(&dir);
|
||||
let socket_path = dir.join("client.sock");
|
||||
let _ = fs::remove_file(&socket_path);
|
||||
let listener = UnixListener::bind(&socket_path).expect("bind test listener");
|
||||
let listener = bind_local_listener(&socket_path).expect("bind test listener");
|
||||
let client_socket_identity =
|
||||
socket_file_identity(&socket_path).expect("test listener socket identity");
|
||||
#[cfg(unix)]
|
||||
listener
|
||||
.set_nonblocking(true)
|
||||
.set_nonblocking(ListenerNonblockingMode::Accept)
|
||||
.expect("set listener nonblocking");
|
||||
let (server_event_tx, server_event_rx) = mpsc::channel(64);
|
||||
#[cfg(windows)]
|
||||
let should_quit = Arc::new(AtomicBool::new(false));
|
||||
#[cfg(windows)]
|
||||
spawn_windows_client_accept_thread(listener, should_quit.clone(), server_event_tx.clone());
|
||||
let server_keybindings = app_keybindings(&app);
|
||||
|
||||
HeadlessServer {
|
||||
app,
|
||||
#[cfg(unix)]
|
||||
api_tx: None,
|
||||
#[cfg(unix)]
|
||||
api_server: None,
|
||||
#[cfg(unix)]
|
||||
client_listener: listener,
|
||||
client_socket_path: socket_path,
|
||||
client_socket_identity,
|
||||
clients: HashMap::new(),
|
||||
#[cfg(unix)]
|
||||
next_client_id: 1,
|
||||
foreground_client_id: None,
|
||||
server_keybindings,
|
||||
@@ -3597,8 +3725,12 @@ mod tests {
|
||||
effective_size: (MIN_COLS, MIN_ROWS),
|
||||
shutting_down: false,
|
||||
handoff_in_progress: false,
|
||||
#[cfg(unix)]
|
||||
pending_handoff_repaint_nudge: false,
|
||||
#[cfg(unix)]
|
||||
should_quit: Arc::new(AtomicBool::new(false)),
|
||||
#[cfg(windows)]
|
||||
should_quit,
|
||||
server_event_rx,
|
||||
server_event_tx,
|
||||
}
|
||||
@@ -5186,6 +5318,107 @@ next_tab = ""
|
||||
assert_eq!(server.app.state.mode, crate::app::Mode::Terminal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_client_input_events_route_through_app_input() {
|
||||
let mut server = test_headless_server();
|
||||
server.app.state.mode = crate::app::Mode::Onboarding;
|
||||
server.clients.insert(
|
||||
1,
|
||||
ClientConnection::new(
|
||||
(80, 24),
|
||||
crate::kitty_graphics::HostCellSize::default(),
|
||||
crate::terminal_theme::TerminalTheme::default(),
|
||||
Some(true),
|
||||
1,
|
||||
RenderEncoding::SemanticFrame,
|
||||
None,
|
||||
),
|
||||
);
|
||||
server.foreground_client_id = Some(1);
|
||||
server.sync_foreground_client_state();
|
||||
|
||||
assert!(server.handle_server_event(ServerEvent::ClientInputEvents {
|
||||
client_id: 1,
|
||||
events: vec![crate::protocol::ClientInputEvent::Key {
|
||||
code: crate::protocol::ClientKeyCode::Enter,
|
||||
modifiers: 0,
|
||||
kind: crate::protocol::ClientKeyKind::Press,
|
||||
}],
|
||||
}));
|
||||
|
||||
assert_eq!(server.app.state.mode, crate::app::Mode::Settings);
|
||||
assert_eq!(
|
||||
server.app.state.settings.section,
|
||||
crate::app::state::SettingsSection::Integrations
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_client_escape_closes_keybind_help() {
|
||||
let mut server = test_headless_server();
|
||||
server.app.state.mode = crate::app::Mode::KeybindHelp;
|
||||
server.clients.insert(
|
||||
1,
|
||||
ClientConnection::new(
|
||||
(100, 30),
|
||||
crate::kitty_graphics::HostCellSize::default(),
|
||||
crate::terminal_theme::TerminalTheme::default(),
|
||||
Some(true),
|
||||
1,
|
||||
RenderEncoding::SemanticFrame,
|
||||
None,
|
||||
),
|
||||
);
|
||||
server.foreground_client_id = Some(1);
|
||||
server.sync_foreground_client_state();
|
||||
server.resize_shared_runtime_to_effective_size();
|
||||
|
||||
assert!(server.handle_server_event(ServerEvent::ClientInputEvents {
|
||||
client_id: 1,
|
||||
events: vec![crate::protocol::ClientInputEvent::Key {
|
||||
code: crate::protocol::ClientKeyCode::Esc,
|
||||
modifiers: 0,
|
||||
kind: crate::protocol::ClientKeyKind::Press,
|
||||
}],
|
||||
}));
|
||||
|
||||
assert_eq!(server.app.state.mode, crate::app::Mode::Navigate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_client_down_scrolls_keybind_help() {
|
||||
let mut server = test_headless_server();
|
||||
server.app.state.mode = crate::app::Mode::KeybindHelp;
|
||||
server.clients.insert(
|
||||
1,
|
||||
ClientConnection::new(
|
||||
(100, 30),
|
||||
crate::kitty_graphics::HostCellSize::default(),
|
||||
crate::terminal_theme::TerminalTheme::default(),
|
||||
Some(true),
|
||||
1,
|
||||
RenderEncoding::SemanticFrame,
|
||||
None,
|
||||
),
|
||||
);
|
||||
server.foreground_client_id = Some(1);
|
||||
server.sync_foreground_client_state();
|
||||
server.resize_shared_runtime_to_effective_size();
|
||||
|
||||
assert!(server.app.state.keybind_help_max_scroll() > 0);
|
||||
assert!(server.handle_server_event(ServerEvent::ClientInputEvents {
|
||||
client_id: 1,
|
||||
events: vec![crate::protocol::ClientInputEvent::Key {
|
||||
code: crate::protocol::ClientKeyCode::Down,
|
||||
modifiers: 0,
|
||||
kind: crate::protocol::ClientKeyKind::Press,
|
||||
}],
|
||||
}));
|
||||
|
||||
assert_eq!(server.app.state.mode, crate::app::Mode::KeybindHelp);
|
||||
assert_eq!(server.app.state.keybind_help.scroll, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn split_default_background_response_updates_theme_without_forwarding_tail() {
|
||||
let mut server = test_headless_server();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod autodetect;
|
||||
#[cfg(unix)]
|
||||
pub(crate) mod client_accept;
|
||||
pub(crate) mod client_transport;
|
||||
pub(crate) mod clients;
|
||||
|
||||
@@ -64,10 +64,14 @@ fn toast_event_text(kind: app::state::ToastKind) -> &'static str {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[cfg(unix)]
|
||||
use super::*;
|
||||
#[cfg(unix)]
|
||||
use crate::detect::Agent;
|
||||
#[cfg(unix)]
|
||||
use crate::terminal::TerminalState;
|
||||
|
||||
#[cfg(unix)]
|
||||
fn init_repo(path: &std::path::Path) {
|
||||
let status = std::process::Command::new("git")
|
||||
.args(["init", "-q"])
|
||||
@@ -77,6 +81,7 @@ mod tests {
|
||||
assert!(status.success(), "git init failed for {}", path.display());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn toast_message_uses_live_root_runtime_cwd_label() {
|
||||
let mut state = AppState::test_new();
|
||||
|
||||
@@ -351,6 +351,9 @@ pub(crate) fn focused_terminal_cursor(
|
||||
.pane_infos
|
||||
.iter()
|
||||
.find(|info| info.is_focused)?;
|
||||
if !app_state.pane_exposes_host_cursor(ws_idx, info.id) {
|
||||
return None;
|
||||
}
|
||||
let rt = app_state.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id)?;
|
||||
let scrolled_back = crate::ui::pane_is_scrolled_back(rt);
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ pub(crate) fn restrict_socket_permissions(path: &Path) -> io::Result<()> {
|
||||
crate::ipc::restrict_socket_permissions(path, SOCKET_PERMISSION_MODE)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
+34
-8
@@ -1,9 +1,12 @@
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use interprocess::local_socket::traits::Stream as _;
|
||||
|
||||
use crate::ipc::LocalStream;
|
||||
|
||||
pub const SESSION_ENV_VAR: &str = "HERDR_SESSION";
|
||||
pub const DEFAULT_SESSION_NAME: &str = "default";
|
||||
|
||||
@@ -238,7 +241,7 @@ fn stop_session_with_timeout(name: Option<&str>, timeout: Duration) -> Result<Se
|
||||
"method": "server.stop",
|
||||
"params": {}
|
||||
});
|
||||
let stream = UnixStream::connect(&socket_path).map_err(|err| {
|
||||
let stream = crate::ipc::connect_local_stream(&socket_path).map_err(|err| {
|
||||
format!(
|
||||
"session {} is not running or cannot be reached at {}: {err}",
|
||||
name.unwrap_or(DEFAULT_SESSION_NAME),
|
||||
@@ -283,14 +286,14 @@ pub fn delete_session(name: &str) -> Result<SessionInfo, String> {
|
||||
}
|
||||
|
||||
fn send_stop_request(
|
||||
mut stream: UnixStream,
|
||||
mut stream: LocalStream,
|
||||
request: &serde_json::Value,
|
||||
deadline: Instant,
|
||||
) -> Result<Option<serde_json::Value>, String> {
|
||||
let Some(write_timeout) = socket_timeout_until(deadline) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if let Err(err) = stream.set_write_timeout(Some(write_timeout)) {
|
||||
if let Err(err) = stream.set_send_timeout(Some(write_timeout)) {
|
||||
if err.kind() != std::io::ErrorKind::InvalidInput {
|
||||
return Err(err.to_string());
|
||||
}
|
||||
@@ -308,7 +311,7 @@ fn send_stop_request(
|
||||
}
|
||||
|
||||
fn send_stop_request_inner(
|
||||
stream: &mut UnixStream,
|
||||
stream: &mut LocalStream,
|
||||
request: &serde_json::Value,
|
||||
deadline: Instant,
|
||||
) -> std::io::Result<Option<String>> {
|
||||
@@ -319,7 +322,7 @@ fn send_stop_request_inner(
|
||||
let Some(read_timeout) = socket_timeout_until(deadline) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if let Err(err) = stream.set_read_timeout(Some(read_timeout)) {
|
||||
if let Err(err) = stream.set_recv_timeout(Some(read_timeout)) {
|
||||
if err.kind() == std::io::ErrorKind::InvalidInput {
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -347,7 +350,7 @@ fn stop_request_error_allows_wait(err: &std::io::Error) -> bool {
|
||||
}
|
||||
|
||||
fn is_running_at(socket_path: &Path) -> bool {
|
||||
socket_path.exists() && UnixStream::connect(socket_path).is_ok()
|
||||
socket_path.exists() && crate::ipc::connect_local_stream(socket_path).is_ok()
|
||||
}
|
||||
|
||||
fn wait_until_stopped_until(socket_path: &Path, deadline: Instant) -> bool {
|
||||
@@ -420,6 +423,8 @@ fn normalize_name(name: &str) -> Result<Option<String>, String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[cfg(unix)]
|
||||
use interprocess::local_socket::traits::Listener as _;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
fn env_lock() -> &'static Mutex<()> {
|
||||
@@ -427,6 +432,24 @@ mod tests {
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn unique_test_path(name: &str) -> std::path::PathBuf {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!("herdr-{name}-{}-{nanos}", std::process::id()))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn local_stream_pair(name: &str) -> (LocalStream, LocalStream, std::path::PathBuf) {
|
||||
let path = unique_test_path(name);
|
||||
let listener = crate::ipc::bind_local_listener(&path).unwrap();
|
||||
let client = crate::ipc::connect_local_stream(&path).unwrap();
|
||||
let server = listener.accept().unwrap();
|
||||
(client, server, path)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_request_errors_wait_for_socket_state() {
|
||||
for kind in [
|
||||
@@ -455,9 +478,10 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn stop_request_empty_response_waits_for_socket_state() {
|
||||
let (client, server) = UnixStream::pair().unwrap();
|
||||
let (client, server, _path) = local_stream_pair("stop-empty-response");
|
||||
let handle = std::thread::spawn(move || {
|
||||
let mut request = String::new();
|
||||
let _ = BufReader::new(server).read_line(&mut request);
|
||||
@@ -481,6 +505,7 @@ mod tests {
|
||||
assert!(handle.join().unwrap().contains("server.stop"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn stop_session_times_out_when_socket_stays_open_without_response() {
|
||||
let _guard = env_lock().lock().unwrap();
|
||||
@@ -874,6 +899,7 @@ mod tests {
|
||||
std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn stop_session_fails_when_socket_remains_reachable_after_timeout() {
|
||||
let _guard = env_lock().lock().unwrap();
|
||||
|
||||
@@ -201,6 +201,7 @@ impl TerminalRuntime {
|
||||
self.0.resize(rows, cols, cell_width_px, cell_height_px);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn nudge_child_redraw_after_handoff(&self) {
|
||||
self.0.nudge_child_redraw_after_handoff();
|
||||
}
|
||||
|
||||
+22
-8
@@ -838,6 +838,14 @@ mod tests {
|
||||
TerminalState::new(TerminalId::alloc(), "/tmp".into())
|
||||
}
|
||||
|
||||
fn test_session_path(name: &str) -> String {
|
||||
std::env::current_dir()
|
||||
.unwrap()
|
||||
.join(name)
|
||||
.display()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_working_is_sticky_for_short_gap() {
|
||||
let now = std::time::Instant::now();
|
||||
@@ -1805,6 +1813,7 @@ mod tests {
|
||||
#[test]
|
||||
fn accepted_hook_report_stores_session_ref() {
|
||||
let mut terminal = test_terminal();
|
||||
let session_path = test_session_path("pi.jsonl");
|
||||
let mutation = terminal
|
||||
.set_hook_authority_with_session_ref(
|
||||
"herdr:pi".into(),
|
||||
@@ -1812,7 +1821,7 @@ mod tests {
|
||||
AgentState::Working,
|
||||
None,
|
||||
None,
|
||||
crate::agent_resume::AgentSessionRef::path("/tmp/pi.jsonl"),
|
||||
crate::agent_resume::AgentSessionRef::path(session_path.clone()),
|
||||
Some(20),
|
||||
)
|
||||
.expect("accepted report");
|
||||
@@ -1826,7 +1835,7 @@ mod tests {
|
||||
.map(|session_ref| (&session_ref.kind, session_ref.value.as_str())),
|
||||
Some((
|
||||
&crate::agent_resume::AgentSessionRefKind::Path,
|
||||
"/tmp/pi.jsonl"
|
||||
session_path.as_str()
|
||||
))
|
||||
);
|
||||
}
|
||||
@@ -1834,13 +1843,15 @@ mod tests {
|
||||
#[test]
|
||||
fn stale_hook_report_cannot_overwrite_session_ref() {
|
||||
let mut terminal = test_terminal();
|
||||
let session_path = test_session_path("pi.jsonl");
|
||||
let new_session_path = test_session_path("new.jsonl");
|
||||
terminal.set_hook_authority_with_session_ref(
|
||||
"herdr:pi".into(),
|
||||
"pi".into(),
|
||||
AgentState::Working,
|
||||
None,
|
||||
None,
|
||||
crate::agent_resume::AgentSessionRef::path("/tmp/pi.jsonl"),
|
||||
crate::agent_resume::AgentSessionRef::path(session_path.clone()),
|
||||
Some(20),
|
||||
);
|
||||
|
||||
@@ -1850,7 +1861,7 @@ mod tests {
|
||||
AgentState::Working,
|
||||
None,
|
||||
None,
|
||||
crate::agent_resume::AgentSessionRef::path("/tmp/new.jsonl"),
|
||||
crate::agent_resume::AgentSessionRef::path(new_session_path),
|
||||
Some(19),
|
||||
);
|
||||
|
||||
@@ -1861,20 +1872,21 @@ mod tests {
|
||||
.as_ref()
|
||||
.and_then(|authority| authority.session_ref.as_ref())
|
||||
.map(|session_ref| session_ref.value.as_str()),
|
||||
Some("/tmp/pi.jsonl")
|
||||
Some(session_path.as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepted_hook_report_without_session_ref_clears_previous_ref() {
|
||||
let mut terminal = test_terminal();
|
||||
let session_path = test_session_path("pi.jsonl");
|
||||
terminal.set_hook_authority_with_session_ref(
|
||||
"herdr:pi".into(),
|
||||
"pi".into(),
|
||||
AgentState::Working,
|
||||
None,
|
||||
None,
|
||||
crate::agent_resume::AgentSessionRef::path("/tmp/pi.jsonl"),
|
||||
crate::agent_resume::AgentSessionRef::path(session_path),
|
||||
Some(20),
|
||||
);
|
||||
|
||||
@@ -1927,13 +1939,14 @@ mod tests {
|
||||
#[test]
|
||||
fn clearing_hook_authority_clears_session_ref() {
|
||||
let mut terminal = test_terminal();
|
||||
let session_path = test_session_path("pi.jsonl");
|
||||
terminal.set_hook_authority_with_session_ref(
|
||||
"herdr:pi".into(),
|
||||
"pi".into(),
|
||||
AgentState::Working,
|
||||
None,
|
||||
None,
|
||||
crate::agent_resume::AgentSessionRef::path("/tmp/pi.jsonl"),
|
||||
crate::agent_resume::AgentSessionRef::path(session_path),
|
||||
Some(20),
|
||||
);
|
||||
|
||||
@@ -1948,13 +1961,14 @@ mod tests {
|
||||
#[test]
|
||||
fn release_agent_clears_session_ref() {
|
||||
let mut terminal = test_terminal();
|
||||
let session_path = test_session_path("pi.jsonl");
|
||||
terminal.set_hook_authority_with_session_ref(
|
||||
"herdr:pi".into(),
|
||||
"pi".into(),
|
||||
AgentState::Working,
|
||||
None,
|
||||
None,
|
||||
crate::agent_resume::AgentSessionRef::path("/tmp/pi.jsonl"),
|
||||
crate::agent_resume::AgentSessionRef::path(session_path),
|
||||
Some(20),
|
||||
);
|
||||
|
||||
|
||||
@@ -965,6 +965,7 @@ mod tests {
|
||||
assert_eq!(mobile_agent_detail(&entry), " idle · pi");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn mobile_header_uses_live_root_runtime_cwd_for_workspace_label() {
|
||||
let unique = format!(
|
||||
|
||||
+4
-1
@@ -289,7 +289,10 @@ pub(super) fn render_panes(
|
||||
frame.render_widget(block, info.rect);
|
||||
}
|
||||
|
||||
let show_cursor = info.is_focused && terminal_active && !pane_is_scrolled_back(rt);
|
||||
let show_cursor = info.is_focused
|
||||
&& terminal_active
|
||||
&& !pane_is_scrolled_back(rt)
|
||||
&& app.pane_exposes_host_cursor(ws_idx, info.id);
|
||||
rt.render(frame, info.inner_rect, show_cursor);
|
||||
render_pane_scrollbar(app, frame, info, rt);
|
||||
|
||||
|
||||
@@ -1286,6 +1286,7 @@ mod tests {
|
||||
assert_eq!(entries[1].agent_label.as_deref(), Some("claude"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn all_workspaces_agent_panel_entries_use_live_root_runtime_cwd_for_workspace_label() {
|
||||
let unique = format!(
|
||||
|
||||
+10
-9
@@ -10,11 +10,11 @@ use std::collections::BTreeMap;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::io::{self, BufRead, BufReader, IsTerminal, Write};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use interprocess::local_socket::traits::Stream as _;
|
||||
use serde::{Deserialize, Deserializer};
|
||||
|
||||
const STABLE_UPDATE_MANIFEST_URL: &str = "https://herdr.dev/latest.json";
|
||||
@@ -216,7 +216,7 @@ struct HomebrewFormulaVersions {
|
||||
}
|
||||
|
||||
impl UpdateManifest {
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, unix))]
|
||||
fn download_url_for(&self, os: &str, arch: &str) -> Option<String> {
|
||||
self.assets
|
||||
.get(&format!("{os}-{arch}"))
|
||||
@@ -626,7 +626,7 @@ fn client_protocol_server_is_running_at(socket_path: &Path) -> bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
UnixStream::connect(socket_path).is_ok()
|
||||
crate::ipc::connect_local_stream(socket_path).is_ok()
|
||||
}
|
||||
|
||||
fn client_protocol_server_is_running() -> bool {
|
||||
@@ -1309,13 +1309,13 @@ fn send_server_update_method_at(
|
||||
method,
|
||||
};
|
||||
|
||||
let mut stream = UnixStream::connect(socket_path)
|
||||
let mut stream = crate::ipc::connect_local_stream(socket_path)
|
||||
.map_err(|e| format!("failed to connect to running server: {e}"))?;
|
||||
stream
|
||||
.set_write_timeout(Some(timeout))
|
||||
.set_send_timeout(Some(timeout))
|
||||
.map_err(|e| format!("failed to set {error_prefix} write timeout: {e}"))?;
|
||||
stream
|
||||
.set_read_timeout(Some(timeout))
|
||||
.set_recv_timeout(Some(timeout))
|
||||
.map_err(|e| format!("failed to set {error_prefix} read timeout: {e}"))?;
|
||||
stream
|
||||
.write_all(
|
||||
@@ -1348,7 +1348,7 @@ fn send_server_update_method_at(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, unix))]
|
||||
fn live_handoff_server_via_api_at(socket_path: &Path, timeout: Duration) -> Result<(), String> {
|
||||
use crate::api::schema::{Method, ServerLiveHandoffParams};
|
||||
|
||||
@@ -1404,7 +1404,7 @@ fn server_shutdown_confirmed_at(socket_path: &Path) -> Result<bool, String> {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
match UnixStream::connect(socket_path) {
|
||||
match crate::ipc::connect_local_stream(socket_path) {
|
||||
Ok(_) => Ok(false),
|
||||
Err(err)
|
||||
if matches!(
|
||||
@@ -1715,6 +1715,7 @@ fn preview_channel_rejection_for_exe_path(path: &Path) -> Option<&'static str> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn is_package_manager_managed_exe_path(path: &Path) -> bool {
|
||||
is_homebrew_managed_exe_path_following_links(path)
|
||||
|| is_mise_managed_exe_path_following_links(path)
|
||||
@@ -2095,7 +2096,7 @@ fn platform_target() -> (&'static str, &'static str) {
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::os::unix::net::UnixListener;
|
||||
|
||||
Reference in New Issue
Block a user