diff --git a/.github/workflows/build-artifacts-manual.yml b/.github/workflows/build-artifacts-manual.yml index 7096c6c5..369d72ff 100644 --- a/.github/workflows/build-artifacts-manual.yml +++ b/.github/workflows/build-artifacts-manual.yml @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dcd0d1ba..448f204e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 + } diff --git a/Cargo.lock b/Cargo.lock index 0d62c4fe..8271ea5f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 51a77779..45deda2d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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", +] } diff --git a/build.rs b/build.rs index fc5bdd64..66ed2620 100644 --- a/build.rs +++ b/build.rs @@ -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"); } diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 2752a0d3..402e7caf 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -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 diff --git a/docs/next/website/src/content/docs/install.mdx b/docs/next/website/src/content/docs/install.mdx index 6322c8e0..41139821 100644 --- a/docs/next/website/src/content/docs/install.mdx +++ b/docs/next/website/src/content/docs/install.mdx @@ -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. diff --git a/docs/next/website/src/content/docs/persistence-remote.mdx b/docs/next/website/src/content/docs/persistence-remote.mdx index 3e25dd69..1f60f964 100644 --- a/docs/next/website/src/content/docs/persistence-remote.mdx +++ b/docs/next/website/src/content/docs/persistence-remote.mdx @@ -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 diff --git a/docs/next/website/src/content/docs/windows-beta.mdx b/docs/next/website/src/content/docs/windows-beta.mdx new file mode 100644 index 00000000..1d6e929f --- /dev/null +++ b/docs/next/website/src/content/docs/windows-beta.mdx @@ -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. diff --git a/src/agent_resume.rs b/src/agent_resume.rs index 5cbf5741..5c852dfa 100644 --- a/src/agent_resume.rs +++ b/src/agent_resume.rs @@ -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( diff --git a/src/api/client.rs b/src/api/client.rs index 7d966965..38baf807 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -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 { 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::connect(self.socket_path()) + fn connect(&self) -> io::Result { + 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, + reader: BufReader, } impl EventStream { @@ -181,7 +204,7 @@ impl From 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( - reader: &mut BufReader, + reader: &mut BufReader, ) -> Result { let mut line = String::new(); let read = reader.read_line(&mut line)?; @@ -200,7 +223,7 @@ fn read_json_line( } fn read_optional_json_line( - reader: &mut BufReader, + reader: &mut BufReader, ) -> Result, ApiClientError> { let mut line = String::new(); let read = reader.read_line(&mut line)?; diff --git a/src/api/server.rs b/src/api/server.rs index e2c4b4a1..94e8c725 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -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, capabilities: Option, ) -> 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> { +fn read_initial_request_line(stream: &mut LocalStream) -> std::io::Result> { 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 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(stream: &mut UnixStream, value: &T) -> std::io::Result<()> { +fn write_json_line( + 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( - 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( } pub(super) fn should_stop_connection( - stream: &mut UnixStream, + stream: &mut LocalStream, running: &Arc, ) -> std::io::Result { 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 { +fn probe_stream_closed(stream: &mut LocalStream) -> std::io::Result { 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::(); - 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::(); - 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"}]}}"#, diff --git a/src/api/wait.rs b/src/api/wait.rs index 4bce14f2..cbb39b58 100644 --- a/src/api/wait.rs +++ b/src/api/wait.rs @@ -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, ) -> std::io::Result> { diff --git a/src/app/actions.rs b/src/app/actions.rs index 06ffe51b..371457a8 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -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"]); diff --git a/src/app/agent_resume.rs b/src/app/agent_resume.rs index 8ff6cc4d..894b0b37 100644 --- a/src/app/agent_resume.rs +++ b/src/app/agent_resume.rs @@ -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 { + vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()] + } + + #[cfg(unix)] + fn marker_resume_test_argv() -> Vec { + 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(), }); diff --git a/src/app/api.rs b/src/app/api.rs index 4bb89a77..166ea789 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -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(); diff --git a/src/app/api/worktrees.rs b/src/app/api/worktrees.rs index eb45a6f2..d6a269ca 100644 --- a/src/app/api/worktrees.rs +++ b/src/app/api/worktrees.rs @@ -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(), diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index e8761a35..59fa1ccd 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -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 { diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs index ae10c0fa..e69bb2c1 100644 --- a/src/app/input/mouse.rs +++ b/src/app/input/mouse.rs @@ -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(); diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index 8dacc5c9..9d861f66 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -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(); diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs index c60a9f48..60f2d4cc 100644 --- a/src/app/input/terminal.rs +++ b/src/app/input/terminal.rs @@ -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(); diff --git a/src/app/mod.rs b/src/app/mod.rs index 6a387c9a..3a038dee 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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>, @@ -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(); diff --git a/src/app/state.rs b/src/app/state.rs index 494bee0f..32cf087d 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -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 { diff --git a/src/client/input.rs b/src/client/input.rs index e9592db8..bbc37a58 100644 --- a/src/client/input.rs +++ b/src/client/input.rs @@ -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, should_quit: &Arc) { + #[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, should_quit: &Arc) { 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, should_quit: & } } +#[cfg(windows)] +fn windows_stdin_reader_loop( + event_tx: mpsc::Sender, + should_quit: &Arc, +) { + 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> { + 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, + event_tx: &mpsc::Sender, +) -> bool { + let raw_event_count = events.len(); + let events = events + .into_iter() + .filter_map(windows_client_input_event_from_raw) + .collect::>(); + 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 { + 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(reader: &R, timeout_ms: i32) -> Option { poll_read_ready(reader.as_raw_fd(), timeout_ms) } -#[cfg(not(unix))] -fn stdin_read_ready(_reader: &R, _timeout_ms: i32) -> Option { - None -} - #[cfg(unix)] fn poll_read_ready(fd: i32, timeout_ms: i32) -> Option { #[repr(C)] @@ -113,7 +287,7 @@ fn poll_read_ready(fd: i32, timeout_ms: i32) -> Option { // 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, + } + ); + } +} diff --git a/src/client/mod.rs b/src/client/mod.rs index 564b5e27..a895dae0 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -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>> = OnceLock::new(); @@ -58,17 +64,24 @@ struct ClientState { /// Direct attach prefix escape state. None for full-app clients. attach_escape: Option, /// 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), Scroll { @@ -84,6 +97,7 @@ enum AttachInputAction { } impl AttachEscapeState { + #[cfg(unix)] fn filter_input( &mut self, data: Vec, @@ -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), + /// Structured input events from platforms without Unix-style stdin bytes. + #[cfg(windows)] + StdinEvents(Vec), /// 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, @@ -672,6 +727,9 @@ async fn run_client_loop( negotiated_encoding: RenderEncoding, attach_escape: Option, ) -> 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::>(); + 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, should_quit: &Arc, 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 { } } +#[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(); diff --git a/src/config/io.rs b/src/config/io.rs index 1ea928a3..1bfabf96 100644 --- a/src/config/io.rs +++ b/src/config/io.rs @@ -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(); diff --git a/src/detect/mod.rs b/src/detect/mod.rs index 06536336..902ae999 100644 --- a/src/detect/mod.rs +++ b/src/detect/mod.rs @@ -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 { 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 { + 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 { + 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 { + 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 { 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 { diff --git a/src/events.rs b/src/events.rs index 2b33bebb..b4d49d10 100644 --- a/src/events.rs +++ b/src/events.rs @@ -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 }, + /// 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, diff --git a/src/handoff_runtime.rs b/src/handoff_runtime.rs index 331cff07..6d7b82fe 100644 --- a/src/handoff_runtime.rs +++ b/src/handoff_runtime.rs @@ -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, } diff --git a/src/input/mod.rs b/src/input/mod.rs index ab7192c7..3882b035 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -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; diff --git a/src/input/model.rs b/src/input/model.rs index 777c6a3b..a439217f 100644 --- a/src/input/model.rs +++ b/src/input/model.rs @@ -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 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(); diff --git a/src/integration/mod.rs b/src/integration/mod.rs index b668998d..2989ba90 100644 --- a/src/integration/mod.rs +++ b/src/integration/mod.rs @@ -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 { + 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 { integration_specs() .into_iter() @@ -896,6 +998,17 @@ pub(crate) fn install_pi() -> io::Result { pub(crate) fn install_omp() -> io::Result { 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 { } fn home_dir() -> io::Result { - 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); diff --git a/src/ipc.rs b/src/ipc.rs index 5f973ccf..5c1fe1b7 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -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, +} + +pub(crate) fn connect_local_stream(path: &Path) -> io::Result { + #[cfg(unix)] + { + use interprocess::local_socket::{prelude::*, GenericFilePath}; + + let name = path.to_fs_name::()?; + 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::()?; + LocalStream::connect(name) + } +} + +pub(crate) fn bind_local_listener(path: &Path) -> io::Result { + #[cfg(unix)] + { + use interprocess::local_socket::{prelude::*, GenericFilePath, ListenerOptions}; + + let name = path.to_fs_name::()?; + 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::()?; + 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 { - 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())) + } +} diff --git a/src/main.rs b/src/main.rs index 053ecf26..ffd11140 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 diff --git a/src/pane.rs b/src/pane.rs index 66517ab4..3861a9a0 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -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 { 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, @@ -652,6 +654,7 @@ pub struct PaneRuntime { io: PaneRuntimeIo, current_size: Cell<(u16, u16, u32, u32)>, child_pid: Arc, + reported_cwd: Arc>>, child_wait_completed: Option>, kitty_keyboard_flags: Arc, detect_reset_notify: Arc, @@ -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, ) { 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> { 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> { 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 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 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 { + (cwd.is_absolute() && cwd.is_dir()).then_some(cwd) +} + +fn publish_reported_cwd( + pane_id: PaneId, + cwd: std::path::PathBuf, + reported_cwd: &Arc>>, + events: &mpsc::Sender, +) { + 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 { + 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()), diff --git a/src/pane/kitty_keyboard.rs b/src/pane/kitty_keyboard.rs index 1b7d3274..89824ee3 100644 --- a/src/pane/kitty_keyboard.rs +++ b/src/pane/kitty_keyboard.rs @@ -81,6 +81,7 @@ impl KittyKeyboardTracker { } } + #[cfg(unix)] pub(crate) fn replay_ansi(&self) -> Option { if self.stack.is_empty() { return (self.flags != 0).then(|| format!("\x1b[={}u", self.flags)); diff --git a/src/pane/osc.rs b/src/pane/osc.rs index 37407ade..1d37f1c0 100644 --- a/src/pane/osc.rs +++ b/src/pane/osc.rs @@ -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, + pending: Vec, +} + +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 { + self.pending.drain(..).next_back() + } +} + +fn parse_cwd_osc(body: &[u8]) -> Option { + 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 { + 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 { + 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 { + 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;` and `52;;`. /// 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(); diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index cf8506ba..9bfcf57f 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -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, pub clipboard_writes: Vec>, + pub reported_cwd: Option, pub terminal_responses: Vec, } @@ -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 { 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 { 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); diff --git a/src/persist/restore.rs b/src/persist/restore.rs index 93041017..d5661b30 100644 --- a/src/persist/restore.rs +++ b/src/persist/restore.rs @@ -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) { 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, diff --git a/src/persist/snapshot.rs b/src/persist/snapshot.rs index bc24a322..c04c280f 100644 --- a/src/persist/snapshot.rs +++ b/src/persist/snapshot.rs @@ -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] diff --git a/src/platform/fallback.rs b/src/platform/fallback.rs index 42abfcb2..0f48d32d 100644 --- a/src/platform/fallback.rs +++ b/src/platform/fallback.rs @@ -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 { None } diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 12c275f0..ec9a497f 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -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, 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::*; diff --git a/src/platform/windows.rs b/src/platform/windows.rs new file mode 100644 index 00000000..a8ad2082 --- /dev/null +++ b/src/platform/windows.rs @@ -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, + argv: Option>, + cmdline: Option, +} + +pub fn raise_server_nofile_limit() {} + +pub fn foreground_job(child_pid: u32) -> Option { + let entries = snapshot_processes(); + select_pane_foreground_job(child_pid, &entries) +} + +pub fn foreground_group_leader_job(process_group_id: u32) -> Option { + 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 { + foreground_job(child_pid).map(|job| job.process_group_id) +} + +pub fn process_cwd(pid: u32) -> Option { + 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 { + 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> = 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 { + 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::() 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 { + 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 { + let mut basic_info = MaybeUninit::::uninit(); + let status = unsafe { + NtQueryInformationProcess( + process, + ProcessBasicInformation, + basic_info.as_mut_ptr().cast::(), + size_of::() 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::(process, basic_info.PebBaseAddress.cast::())?; + if peb.process_parameters.is_null() { + return None; + } + + read_process_value::(process, peb.process_parameters.cast()) +} + +fn command_line_to_argv(command_line: &str) -> Option> { + let wide: Vec = 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 { + 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 { + 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 { + None +} + +pub fn show_desktop_notification(_title: &str, _body: Option<&str>) -> std::io::Result { + Ok(false) +} + +fn wide_null(value: &str) -> Vec { + value.encode_utf16().chain(std::iter::once(0)).collect() +} + +struct ProcessHandle(HANDLE); + +impl ProcessHandle { + fn open(pid: u32, access: u32) -> Option { + 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(process: HANDLE, address: *const c_void) -> Option { + if address.is_null() { + return None; + } + + let mut value = MaybeUninit::::uninit(); + let mut bytes_read = 0; + let ok = unsafe { + ReadProcessMemory( + process, + address, + value.as_mut_ptr().cast::(), + size_of::(), + &mut bytes_read, + ) + } != 0; + + (ok && bytes_read == size_of::()).then(|| unsafe { value.assume_init() }) +} + +fn read_unicode_string(process: HANDLE, unicode: UNICODE_STRING) -> Option { + 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::(), + buffer.as_mut_ptr().cast::(), + 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(" ")), + } + } +} diff --git a/src/protocol/wire.rs b/src/protocol/wire.rs index 14a9e3b3..04d1b1ae 100644 --- a/src/protocol/wire.rs +++ b/src/protocol/wire.rs @@ -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 { + 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 { + 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 { + 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, }, + /// Structured input events from platform clients that do not expose Unix-style raw bytes. + InputEvents { events: Vec }, + /// 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; diff --git a/src/pty/actor.rs b/src/pty/actor.rs index 70634475..8c3a6b52 100644 --- a/src/pty/actor.rs +++ b/src/pty/actor.rs @@ -1,1172 +1,231 @@ -use std::{ - collections::VecDeque, - io::{Read, Write}, - os::fd::{AsRawFd, OwnedFd, RawFd}, - sync::{mpsc as std_mpsc, Arc, Mutex}, - time::{Duration, Instant}, -}; +#[cfg(unix)] +mod unix; -use bytes::Bytes; -use tokio::sync::mpsc::{self, error::TryRecvError as DataTryRecvError}; -use tracing::{debug, warn}; +#[cfg(unix)] +pub(crate) use unix::*; -use crate::pty::fd; +#[cfg(windows)] +mod windows { + use std::io::{Read, Write}; + use std::sync::{mpsc as std_mpsc, Arc, Mutex}; + use std::time::Duration; -#[cfg(not(test))] -const ACTOR_POLL_MS: i32 = 50; -#[cfg(test)] -const ACTOR_POLL_MS: i32 = 1000; -const ACTOR_COMMAND_BUFFER: usize = 1024; -const HANDOFF_DRAIN_TIMEOUT: Duration = Duration::from_secs(2); + use bytes::Bytes; + use portable_pty::{MasterPty, PtySize}; + use tokio::sync::mpsc; + use tracing::{debug, warn}; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ActorState { - Running, - Quiesced, - Released, -} - -pub(crate) struct PtyReadResult { - pub terminal_responses: Vec, -} - -impl PtyReadResult { - #[cfg(test)] - pub(crate) fn empty() -> Self { - Self { - terminal_responses: Vec::new(), - } + pub(crate) struct PtyReadResult { + pub terminal_responses: Vec, } -} -type ReadCallback = Box PtyReadResult + Send + 'static>; -type ReaderExitCallback = Box; + type ReadCallback = Box PtyReadResult + Send + 'static>; + type ReaderExitCallback = Box; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct PtyResize { - rows: u16, - cols: u16, - cell_width_px: u32, - cell_height_px: u32, -} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + struct PtyResize { + rows: u16, + cols: u16, + cell_width_px: u32, + cell_height_px: u32, + } -#[derive(Debug, Clone, PartialEq, Eq)] -struct PtyResizeRequest { - resize: PtyResize, - terminal_responses: Vec, -} + struct PtyResizeRequest { + resize: PtyResize, + terminal_responses: Vec, + } -#[derive(Default)] -struct SharedPtyControls { - resize: Option, - nudge: Option, -} + pub(crate) struct PtyIoActorConfig { + pub pane_id: u32, + pub master: Box, + pub initially_quiesced: bool, + pub on_read: ReadCallback, + pub on_reader_exit: Option, + } -pub(crate) struct PtyIoActorConfig { - pub pane_id: u32, - pub master_fd: OwnedFd, - pub initially_quiesced: bool, - pub on_read: ReadCallback, - pub on_reader_exit: Option, -} + enum PtyIoControlCommand { + Resize(PtyResizeRequest), + Shutdown, + } -enum PtyIoDataCommand { - WriteUserInput(Bytes), -} + #[derive(Clone)] + pub(crate) struct PtyIoActorHandle { + data_tx: mpsc::Sender, + control_tx: std_mpsc::Sender, + accepting: Arc>, + } -enum PtyIoControlCommand { - BeginHandoff(std_mpsc::Sender>), - DuplicateForHandoff(std_mpsc::Sender>), - ForegroundProcessGroup(std_mpsc::Sender>), - RollbackHandoff(std_mpsc::Sender>), - ReleaseAfterCommit(std_mpsc::Sender>), - Shutdown, -} - -#[derive(Clone)] -pub(crate) struct PtyIoActorHandle { - data_tx: mpsc::Sender, - control_tx: std_mpsc::Sender, - wake: fd::WakeWriter, - user_writes: Arc>, - controls: Arc>, -} - -#[derive(Debug)] -struct UserWriteGate { - accepting: bool, -} - -impl PtyIoActorHandle { - pub(crate) async fn write_user_input( - &self, - bytes: Bytes, - ) -> Result<(), mpsc::error::SendError> { - { - let user_writes = self - .user_writes + impl PtyIoActorHandle { + pub(crate) async fn write_user_input( + &self, + bytes: Bytes, + ) -> Result<(), mpsc::error::SendError> { + if !*self + .accepting .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - if !user_writes.accepting { + .unwrap_or_else(|poisoned| poisoned.into_inner()) + { return Err(mpsc::error::SendError(bytes)); } + self.data_tx.send(bytes).await } - let permit = match self.data_tx.reserve().await { - Ok(permit) => permit, - Err(_) => return Err(mpsc::error::SendError(bytes)), - }; - - let user_writes = self - .user_writes - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - if !user_writes.accepting { - return Err(mpsc::error::SendError(bytes)); - } - permit.send(PtyIoDataCommand::WriteUserInput(bytes)); - self.wake_actor(); - Ok(()) - } - - pub(crate) fn try_write_user_input( - &self, - bytes: Bytes, - ) -> Result<(), mpsc::error::TrySendError> { - let user_writes = self - .user_writes - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - if !user_writes.accepting { - return Err(mpsc::error::TrySendError::Closed(bytes)); - } - match self - .data_tx - .try_send(PtyIoDataCommand::WriteUserInput(bytes)) - { - Ok(()) => { - self.wake_actor(); - Ok(()) - } - Err(mpsc::error::TrySendError::Full(PtyIoDataCommand::WriteUserInput(bytes))) => { - Err(mpsc::error::TrySendError::Full(bytes)) - } - Err(mpsc::error::TrySendError::Closed(PtyIoDataCommand::WriteUserInput(bytes))) => { - Err(mpsc::error::TrySendError::Closed(bytes)) - } - } - } - - pub(crate) fn resize( - &self, - rows: u16, - cols: u16, - cell_width_px: u32, - cell_height_px: u32, - terminal_responses: Vec, - ) { - { - let mut controls = self - .controls + pub(crate) fn try_write_user_input( + &self, + bytes: Bytes, + ) -> Result<(), mpsc::error::TrySendError> { + if !*self + .accepting .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - controls.resize = Some(PtyResizeRequest { - resize: PtyResize { - rows, - cols, - cell_width_px, - cell_height_px, - }, - terminal_responses, - }); - } - self.wake_actor(); - } - - pub(crate) fn nudge_child_redraw_after_handoff( - &self, - rows: u16, - cols: u16, - cell_width_px: u32, - cell_height_px: u32, - ) { - { - let mut controls = self - .controls - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - controls.nudge = Some(PtyResize { - rows, - cols, - cell_width_px, - cell_height_px, - }); - } - self.wake_actor(); - } - - pub(crate) fn begin_handoff(&self, timeout: Duration) -> std::io::Result<()> { - let (reply_tx, reply_rx) = std_mpsc::channel(); - { - let mut user_writes = self - .user_writes - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - user_writes.accepting = false; - if self - .control_tx - .send(PtyIoControlCommand::BeginHandoff(reply_tx)) - .is_err() + .unwrap_or_else(|poisoned| poisoned.into_inner()) { - user_writes.accepting = true; - return Err(std::io::Error::new( - std::io::ErrorKind::BrokenPipe, - "pty actor closed", - )); + return Err(mpsc::error::TrySendError::Closed(bytes)); } - self.wake_actor(); + self.data_tx.try_send(bytes) } - match reply_rx.recv_timeout(timeout) { - Ok(Ok(())) => Ok(()), - Ok(Err(err)) => { - let _ = self.rollback_handoff(); - Err(err) - } - Err(_) => { - let _ = self.rollback_handoff(); - Err(std::io::Error::new( - std::io::ErrorKind::TimedOut, - "timed out waiting for PTY actor to quiesce", - )) + + pub(crate) fn resize( + &self, + rows: u16, + cols: u16, + cell_width_px: u32, + cell_height_px: u32, + terminal_responses: Vec, + ) { + let _ = self + .control_tx + .send(PtyIoControlCommand::Resize(PtyResizeRequest { + resize: PtyResize { + rows, + cols, + cell_width_px, + cell_height_px, + }, + terminal_responses, + })); + } + + pub(crate) fn shutdown(&self) { + if let Ok(mut accepting) = self.accepting.lock() { + *accepting = false; } + let _ = self.control_tx.send(PtyIoControlCommand::Shutdown); } } - pub(crate) fn duplicate_for_handoff(&self) -> std::io::Result { - let (reply_tx, reply_rx) = std_mpsc::channel(); - self.control_tx - .send(PtyIoControlCommand::DuplicateForHandoff(reply_tx)) - .map_err(|_| std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pty actor closed"))?; - self.wake_actor(); - reply_rx.recv_timeout(Duration::from_secs(1)).map_err(|_| { - std::io::Error::new( - std::io::ErrorKind::TimedOut, - "timed out waiting for PTY handoff duplicate", - ) - })? - } + pub(crate) struct PtyIoActor; - pub(crate) fn foreground_process_group_id(&self) -> Option { - let (reply_tx, reply_rx) = std_mpsc::channel(); - self.control_tx - .send(PtyIoControlCommand::ForegroundProcessGroup(reply_tx)) - .ok()?; - self.wake_actor(); - reply_rx.recv_timeout(Duration::from_secs(1)).ok()? - } + impl PtyIoActor { + pub(crate) fn spawn(config: PtyIoActorConfig) -> std::io::Result { + let PtyIoActorConfig { + pane_id, + master, + initially_quiesced, + mut on_read, + on_reader_exit, + } = config; - pub(crate) fn rollback_handoff(&self) -> std::io::Result<()> { - let (reply_tx, reply_rx) = std_mpsc::channel(); - self.control_tx - .send(PtyIoControlCommand::RollbackHandoff(reply_tx)) - .map_err(|_| std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pty actor closed"))?; - self.wake_actor(); - let result = reply_rx.recv_timeout(Duration::from_secs(1)).map_err(|_| { - std::io::Error::new( - std::io::ErrorKind::TimedOut, - "timed out waiting for PTY handoff rollback", - ) - })?; - if result.is_ok() { - let mut user_writes = self - .user_writes - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - user_writes.accepting = true; - } - result - } + let mut reader = master + .try_clone_reader() + .map_err(|err| std::io::Error::other(err.to_string()))?; + let writer = master + .take_writer() + .map_err(|err| std::io::Error::other(err.to_string()))?; + let writer = Arc::new(Mutex::new(writer)); + let (data_tx, mut data_rx) = mpsc::channel::(1024); + let (control_tx, control_rx) = std_mpsc::channel::(); + let accepting = Arc::new(Mutex::new(!initially_quiesced)); - pub(crate) fn release_after_commit(&self) -> std::io::Result<()> { - { - let mut user_writes = self - .user_writes - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - user_writes.accepting = false; - } - let (reply_tx, reply_rx) = std_mpsc::channel(); - self.control_tx - .send(PtyIoControlCommand::ReleaseAfterCommit(reply_tx)) - .map_err(|_| std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pty actor closed"))?; - self.wake_actor(); - reply_rx.recv_timeout(Duration::from_secs(1)).map_err(|_| { - std::io::Error::new( - std::io::ErrorKind::TimedOut, - "timed out waiting for PTY actor release", - ) - })? - } - - pub(crate) fn shutdown(&self) { - { - let mut user_writes = self - .user_writes - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - user_writes.accepting = false; - } - if self.control_tx.send(PtyIoControlCommand::Shutdown).is_ok() { - self.wake_actor(); - } - } - - fn wake_actor(&self) { - if let Err(err) = self.wake.wake() { - debug!(err = %err, "failed to wake PTY actor"); - } - } -} - -pub(crate) struct PtyIoActor; - -impl PtyIoActor { - pub(crate) fn spawn(config: PtyIoActorConfig) -> std::io::Result { - Self::spawn_inner(config, None) - } - - fn spawn_inner( - config: PtyIoActorConfig, - poll_observer: Option>, - ) -> std::io::Result { - fd::set_cloexec(config.master_fd.as_raw_fd())?; - fd::set_nonblocking(config.master_fd.as_raw_fd())?; - - let (data_tx, data_rx) = mpsc::channel(ACTOR_COMMAND_BUFFER); - let (control_tx, control_rx) = std_mpsc::channel(); - let wake_pipe = fd::create_wake_pipe()?; - let user_writes = Arc::new(Mutex::new(UserWriteGate { - accepting: !config.initially_quiesced, - })); - let controls = Arc::new(Mutex::new(SharedPtyControls::default())); - let handle = PtyIoActorHandle { - data_tx, - control_tx, - wake: wake_pipe.writer, - user_writes, - controls: Arc::clone(&controls), - }; - - let mut runner = PtyIoActorRunner { - pane_id: config.pane_id, - file: std::fs::File::from(config.master_fd), - data_rx, - control_rx, - state: if config.initially_quiesced { - ActorState::Quiesced - } else { - ActorState::Running - }, - pending_writes: VecDeque::new(), - current_write_offset: 0, - wake_read_fd: wake_pipe.read_fd, - controls, - on_read: config.on_read, - on_reader_exit: config.on_reader_exit, - poll_observer, - }; - std::thread::Builder::new() - .name(format!("herdr-pty-{}", config.pane_id)) - .spawn(move || runner.run()) - .map_err(|err| std::io::Error::other(err.to_string()))?; - - Ok(handle) - } - - #[cfg(test)] - fn spawn_with_poll_observer( - config: PtyIoActorConfig, - poll_observer: std_mpsc::Sender<()>, - ) -> std::io::Result { - Self::spawn_inner(config, Some(poll_observer)) - } -} - -struct PtyIoActorRunner { - pane_id: u32, - file: std::fs::File, - data_rx: mpsc::Receiver, - control_rx: std_mpsc::Receiver, - state: ActorState, - pending_writes: VecDeque, - current_write_offset: usize, - wake_read_fd: OwnedFd, - controls: Arc>, - on_read: ReadCallback, - on_reader_exit: Option, - poll_observer: Option>, -} - -impl PtyIoActorRunner { - fn run(&mut self) { - let mut should_exit = false; - while !should_exit { - should_exit = self.drain_commands(); - if should_exit || self.state == ActorState::Released { - break; - } - - self.apply_pending_controls(); - - if !self.pending_writes.is_empty() { - self.flush_pending_writes_once(); - } - - if let Some(poll_observer) = &self.poll_observer { - let _ = poll_observer.send(()); - } - - match fd::poll_pty_and_wake( - self.file.as_raw_fd(), - self.wake_read_fd.as_raw_fd(), - self.state == ActorState::Running, - !self.pending_writes.is_empty(), - ACTOR_POLL_MS, - ) { - Ok(readiness) => { - if readiness.wake_ready { - if let Err(err) = fd::drain_wake_fd(self.wake_read_fd.as_raw_fd()) { - debug!(pane = self.pane_id, err = %err, "PTY actor wake drain failed"); + { + let writer = Arc::clone(&writer); + std::thread::spawn(move || { + while let Some(bytes) = data_rx.blocking_recv() { + if write_all_locked(&writer, &bytes).is_err() { break; } - continue; } - if readiness.pty_write_ready && !self.pending_writes.is_empty() { - self.flush_pending_writes_once(); + debug!(pane_id, "windows pty writer thread exiting"); + }); + } + + { + let writer = Arc::clone(&writer); + std::thread::spawn(move || { + let mut buf = [0u8; 8192]; + loop { + match reader.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + let result = on_read(&buf[..n]); + for response in result.terminal_responses { + if write_all_locked(&writer, &response).is_err() { + break; + } + } + } + Err(err) => { + debug!(pane_id, err = %err, "windows pty reader failed"); + break; + } + } } - if self.state == ActorState::Running - && readiness.pty_read_ready - && !self.read_once() - { - break; + if let Some(on_reader_exit) = on_reader_exit { + on_reader_exit(); } - } - Err(err) => { - debug!(pane = self.pane_id, err = %err, "PTY actor poll failed"); - break; - } + debug!(pane_id, "windows pty reader thread exiting"); + }); } - } - if let Some(on_reader_exit) = self.on_reader_exit.take() { - on_reader_exit(); - } - debug!(pane = self.pane_id, "PTY actor exiting"); - } - - fn drain_commands(&mut self) -> bool { - if self.drain_control_commands() { - return true; - } - self.drain_data_commands() - } - - fn drain_control_commands(&mut self) -> bool { - let mut should_exit = false; - loop { - match self.control_rx.try_recv() { - Ok(command) => { - if self.handle_control_command(command) { - should_exit = true; - break; + { + let writer = Arc::clone(&writer); + std::thread::spawn(move || { + for command in control_rx { + match command { + PtyIoControlCommand::Resize(request) => { + let size = request.resize; + if let Err(err) = master.resize(PtySize { + rows: size.rows, + cols: size.cols, + pixel_width: size.cell_width_px.min(u16::MAX as u32) as u16, + pixel_height: size.cell_height_px.min(u16::MAX as u32) as u16, + }) { + warn!(pane_id, err = %err, "windows pty resize failed"); + } + for response in request.terminal_responses { + if write_all_locked(&writer, &response).is_err() { + break; + } + } + } + PtyIoControlCommand::Shutdown => break, + } } - } - Err(std_mpsc::TryRecvError::Empty) => break, - Err(std_mpsc::TryRecvError::Disconnected) => { - should_exit = true; - break; - } + debug!(pane_id, "windows pty control thread exiting"); + }); } - } - should_exit - } - fn drain_data_commands(&mut self) -> bool { - let mut should_exit = false; - loop { - match self.data_rx.try_recv() { - Ok(command) => { - if self.handle_data_command(command) { - should_exit = true; - break; - } - } - Err(DataTryRecvError::Empty) => break, - Err(DataTryRecvError::Disconnected) => { - should_exit = true; - break; - } - } - } - should_exit - } - - fn handle_data_command(&mut self, command: PtyIoDataCommand) -> bool { - match command { - PtyIoDataCommand::WriteUserInput(bytes) => { - if self.state == ActorState::Running { - self.pending_writes.push_back(bytes); - } - } - } - false - } - - fn handle_control_command(&mut self, command: PtyIoControlCommand) -> bool { - match command { - PtyIoControlCommand::BeginHandoff(reply) => { - let result = self.begin_handoff(); - let _ = reply.send(result); - } - PtyIoControlCommand::DuplicateForHandoff(reply) => { - let result = if self.state == ActorState::Quiesced { - fd::duplicate_cloexec_fd(self.file.as_raw_fd()) - } else { - Err(std::io::Error::other( - "PTY actor must be quiesced before handoff duplication", - )) - }; - let _ = reply.send(result); - } - PtyIoControlCommand::ForegroundProcessGroup(reply) => { - let result = - crate::platform::foreground_process_group_id_for_tty_fd(self.file.as_raw_fd()); - let _ = reply.send(result); - } - PtyIoControlCommand::RollbackHandoff(reply) => { - let result = if self.state == ActorState::Released { - Err(std::io::Error::new( - std::io::ErrorKind::BrokenPipe, - "PTY actor was released before handoff rollback", - )) - } else { - self.state = ActorState::Running; - Ok(()) - }; - let _ = reply.send(result); - } - PtyIoControlCommand::ReleaseAfterCommit(reply) => { - self.state = ActorState::Released; - self.pending_writes.clear(); - let _ = reply.send(Ok(())); - return true; - } - PtyIoControlCommand::Shutdown => return true, - } - false - } - - fn begin_handoff(&mut self) -> std::io::Result<()> { - self.drain_pre_quiesce_commands(); - self.apply_pending_controls(); - if self.state == ActorState::Released { - return Err(std::io::Error::new( - std::io::ErrorKind::BrokenPipe, - "PTY actor was released before handoff quiesce", - )); - } - let deadline = Instant::now() + HANDOFF_DRAIN_TIMEOUT; - while !self.pending_writes.is_empty() { - if Instant::now() >= deadline { - return Err(std::io::Error::new( - std::io::ErrorKind::TimedOut, - "timed out draining PTY writes before handoff", - )); - } - self.flush_pending_writes_once(); - } - self.state = ActorState::Quiesced; - Ok(()) - } - - fn drain_pre_quiesce_commands(&mut self) { - while let Ok(PtyIoDataCommand::WriteUserInput(bytes)) = self.data_rx.try_recv() { - if self.state != ActorState::Released { - self.pending_writes.push_back(bytes); - } + Ok(PtyIoActorHandle { + data_tx, + control_tx, + accepting, + }) } } - fn apply_pending_controls(&mut self) { - let (resize, nudge) = { - let mut controls = self - .controls - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - (controls.resize.take(), controls.nudge.take()) - }; - if self.state == ActorState::Released { - return; - } - if let Some(request) = resize { - self.resize(request.resize); - self.enqueue_terminal_responses(request.terminal_responses); - } - if let Some(nudge) = nudge { - self.nudge(nudge); - } + fn write_all_locked( + writer: &Arc>>, + bytes: &[u8], + ) -> std::io::Result<()> { + let mut writer = writer + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + writer.write_all(bytes)?; + writer.flush() } - fn read_once(&mut self) -> bool { - let mut buf = [0u8; 8192]; - match self.file.read(&mut buf) { - Ok(0) => false, - Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => true, - Err(err) if err.kind() == std::io::ErrorKind::Interrupted => true, - Err(err) => { - debug!(pane = self.pane_id, err = %err, "PTY actor read failed"); - false - } - Ok(n) => { - let result = (self.on_read)(&buf[..n]); - self.enqueue_terminal_responses(result.terminal_responses); - true - } - } - } - - fn enqueue_terminal_responses(&mut self, terminal_responses: Vec) { - if self.state == ActorState::Released { - return; - } - self.pending_writes.extend(terminal_responses); - } - - fn flush_pending_writes_once(&mut self) { - while let Some(bytes) = self.pending_writes.front() { - let chunk = &bytes[self.current_write_offset..]; - match self.file.write(chunk) { - Ok(0) => { - warn!(pane = self.pane_id, "PTY actor write returned zero bytes"); - return; - } - Ok(written) => { - self.current_write_offset += written; - if self.current_write_offset >= bytes.len() { - self.pending_writes.pop_front(); - self.current_write_offset = 0; - } - } - Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { - let _ = fd::poll_write_ready(self.file.as_raw_fd(), ACTOR_POLL_MS); - return; - } - Err(err) if err.kind() == std::io::ErrorKind::Interrupted => return, - Err(err) => { - warn!(pane = self.pane_id, err = %err, "PTY actor write failed"); - self.pending_writes.clear(); - self.current_write_offset = 0; - return; - } - } - } - let _ = self.file.flush(); - } - - fn resize(&self, resize: PtyResize) { - self.log_resize_result(fd::resize_pty_fd( - self.file.as_raw_fd(), - resize.rows, - resize.cols, - resize.cell_width_px, - resize.cell_height_px, - )); - } - - fn nudge(&mut self, resize: PtyResize) { - if self.state == ActorState::Released { - return; - } - let nudge = if resize.rows > 2 { - ( - resize.rows - 1, - resize.cols, - resize.cell_width_px, - resize.cell_height_px, - ) - } else { - ( - resize.rows, - resize.cols.saturating_sub(1).max(4), - resize.cell_width_px, - resize.cell_height_px, - ) - }; - if nudge - == ( - resize.rows, - resize.cols, - resize.cell_width_px, - resize.cell_height_px, - ) - { - return; - } - self.log_resize_result(fd::resize_pty_fd( - self.file.as_raw_fd(), - nudge.0, - nudge.1, - nudge.2, - nudge.3, - )); - std::thread::sleep(Duration::from_millis(30)); - self.log_resize_result(fd::resize_pty_fd( - self.file.as_raw_fd(), - resize.rows, - resize.cols, - resize.cell_width_px, - resize.cell_height_px, - )); - } - - fn log_resize_result(&self, result: std::io::Result<()>) { - if let Err(err) = result { - debug!(pane = self.pane_id, err = %err, "PTY resize failed"); - } - } + #[allow(dead_code)] + fn _assert_duration_send(_: Duration) {} } -#[cfg(test)] -mod tests { - use super::*; - use std::{ - io::{Read, Write}, - os::fd::{AsRawFd, FromRawFd, IntoRawFd}, - os::unix::net::UnixStream, - }; - - fn test_wake_pair() -> (fd::WakeWriter, OwnedFd) { - let pipe = fd::create_wake_pipe().expect("wake pipe"); - (pipe.writer, pipe.read_fd) - } - - fn actor_with_socket_pair( - initially_quiesced: bool, - ) -> (PtyIoActorHandle, UnixStream, std_mpsc::Receiver) { - actor_with_socket_pair_and_poll_observer(initially_quiesced, None) - } - - fn actor_with_socket_pair_and_poll_observer( - initially_quiesced: bool, - poll_observer: Option>, - ) -> (PtyIoActorHandle, UnixStream, std_mpsc::Receiver) { - let (actor_socket, peer) = UnixStream::pair().expect("socket pair"); - actor_socket - .set_nonblocking(true) - .expect("actor socket nonblocking"); - peer.set_read_timeout(Some(Duration::from_secs(1))) - .expect("peer timeout"); - let owned = unsafe { OwnedFd::from_raw_fd(actor_socket.into_raw_fd()) }; - let (read_tx, read_rx) = std_mpsc::channel(); - let config = PtyIoActorConfig { - pane_id: 1, - master_fd: owned, - initially_quiesced, - on_read: Box::new(move |bytes| { - read_tx - .send(Bytes::copy_from_slice(bytes)) - .expect("read callback receiver alive"); - PtyReadResult::empty() - }), - on_reader_exit: None, - }; - let handle = if let Some(poll_observer) = poll_observer { - PtyIoActor::spawn_with_poll_observer(config, poll_observer) - } else { - PtyIoActor::spawn(config) - } - .expect("actor spawn"); - (handle, peer, read_rx) - } - - #[test] - fn actor_writes_user_input_to_owned_fd() { - let (handle, mut peer, _read_rx) = actor_with_socket_pair(false); - - handle - .try_write_user_input(Bytes::from_static(b"hello")) - .expect("write command accepted"); - - let mut buf = [0u8; 5]; - peer.read_exact(&mut buf).expect("peer receives write"); - assert_eq!(&buf, b"hello"); - handle.shutdown(); - } - - #[test] - fn actor_wakes_idle_poll_for_user_input() { - let (poll_tx, poll_rx) = std_mpsc::channel(); - let (handle, mut peer, _read_rx) = - actor_with_socket_pair_and_poll_observer(false, Some(poll_tx)); - peer.set_read_timeout(Some(Duration::from_millis(500))) - .expect("peer timeout"); - poll_rx - .recv_timeout(Duration::from_secs(1)) - .expect("actor entered idle poll"); - - let start = Instant::now(); - handle - .try_write_user_input(Bytes::from_static(b"x")) - .expect("write command accepted"); - - let mut buf = [0u8; 1]; - peer.read_exact(&mut buf) - .expect("peer receives write without waiting for actor poll timeout"); - assert_eq!(&buf, b"x"); - assert!( - start.elapsed() < Duration::from_millis(500), - "actor write should be driven by wake fd, not the idle poll timeout" - ); - handle.shutdown(); - } - - #[test] - fn poll_ignores_pty_hup_without_pty_interest() { - let (actor_socket, peer) = UnixStream::pair().expect("socket pair"); - actor_socket - .set_nonblocking(true) - .expect("actor socket nonblocking"); - drop(peer); - let wake_pipe = fd::create_wake_pipe().expect("wake pipe"); - - let readiness = fd::poll_pty_and_wake( - actor_socket.as_raw_fd(), - wake_pipe.read_fd.as_raw_fd(), - false, - false, - 10, - ) - .expect("poll succeeds"); - - assert!(!readiness.pty_read_ready); - assert!(!readiness.pty_write_ready); - assert!(!readiness.wake_ready); - } - - #[test] - fn actor_delivers_fd_reads_to_callback() { - let (handle, mut peer, read_rx) = actor_with_socket_pair(false); - - peer.write_all(b"from-peer").expect("peer write"); - - let read = read_rx - .recv_timeout(Duration::from_secs(1)) - .expect("actor read callback"); - assert_eq!(read, Bytes::from_static(b"from-peer")); - handle.shutdown(); - } - - #[test] - fn begin_handoff_stops_reads_and_rejects_user_writes_until_rollback() { - let (handle, mut peer, read_rx) = actor_with_socket_pair(false); - - handle - .begin_handoff(Duration::from_secs(1)) - .expect("handoff quiesced"); - assert!(handle - .try_write_user_input(Bytes::from_static(b"blocked")) - .is_err()); - - peer.write_all(b"held").expect("peer write during quiesce"); - assert!( - read_rx.recv_timeout(Duration::from_millis(150)).is_err(), - "actor must not read while quiesced" - ); - - handle.rollback_handoff().expect("rollback resumes actor"); - let read = read_rx - .recv_timeout(Duration::from_secs(1)) - .expect("actor reads held bytes after rollback"); - assert_eq!(read, Bytes::from_static(b"held")); - - handle - .try_write_user_input(Bytes::from_static(b"after")) - .expect("write accepted after rollback"); - let mut buf = [0u8; 5]; - peer.read_exact(&mut buf).expect("peer receives after"); - assert_eq!(&buf, b"after"); - handle.shutdown(); - } - - #[test] - fn duplicate_for_handoff_requires_quiesced_actor() { - let (handle, mut peer, read_rx) = actor_with_socket_pair(false); - - assert!(handle.duplicate_for_handoff().is_err()); - handle - .begin_handoff(Duration::from_secs(1)) - .expect("handoff quiesced"); - let duplicate = handle - .duplicate_for_handoff() - .expect("handoff duplicate created"); - assert!(duplicate >= 0); - unsafe { - libc::close(duplicate); - } - handle.rollback_handoff().expect("rollback resumes actor"); - - peer.write_all(b"still-live").expect("peer write"); - let read = read_rx - .recv_timeout(Duration::from_secs(1)) - .expect("actor still reads after duplicate closes"); - assert_eq!(read, Bytes::from_static(b"still-live")); - handle.shutdown(); - } - - #[test] - fn resize_and_nudge_keep_latest_request_when_command_queue_is_full() { - let (data_tx, _data_rx) = mpsc::channel(1); - let (control_tx, _control_rx) = std_mpsc::channel(); - data_tx - .try_send(PtyIoDataCommand::WriteUserInput(Bytes::from_static( - b"fill", - ))) - .expect("fill command queue"); - let controls = Arc::new(Mutex::new(SharedPtyControls::default())); - let (wake, _wake_read_fd) = test_wake_pair(); - let handle = PtyIoActorHandle { - data_tx, - control_tx, - wake, - user_writes: Arc::new(Mutex::new(UserWriteGate { accepting: true })), - controls: Arc::clone(&controls), - }; - - handle.resize(20, 80, 8, 16, vec![Bytes::from_static(b"old")]); - handle.resize(40, 120, 9, 18, vec![Bytes::from_static(b"new")]); - handle.nudge_child_redraw_after_handoff(41, 121, 10, 20); - - let controls = controls.lock().expect("controls lock"); - assert_eq!( - controls.resize, - Some(PtyResizeRequest { - resize: PtyResize { - rows: 40, - cols: 120, - cell_width_px: 9, - cell_height_px: 18, - }, - terminal_responses: vec![Bytes::from_static(b"new")], - }) - ); - assert_eq!( - controls.nudge, - Some(PtyResize { - rows: 41, - cols: 121, - cell_width_px: 10, - cell_height_px: 20, - }) - ); - } - - #[test] - fn resize_writes_terminal_responses_after_applying_resize() { - let (handle, mut peer, _read_rx) = actor_with_socket_pair(false); - let response = Bytes::from_static(b"\x1B[48;40;100;720;900t"); - - handle.resize(40, 100, 9, 18, vec![response.clone()]); - - let mut buf = vec![0; response.len()]; - peer.read_exact(&mut buf) - .expect("peer receives resize response"); - assert_eq!(Bytes::from(buf), response); - handle.shutdown(); - } - - #[tokio::test] - async fn async_user_input_waits_for_queue_capacity() { - let (data_tx, mut data_rx) = mpsc::channel(1); - let (control_tx, _control_rx) = std_mpsc::channel(); - data_tx - .try_send(PtyIoDataCommand::WriteUserInput(Bytes::from_static( - b"fill", - ))) - .expect("fill data queue"); - let (wake, _wake_read_fd) = test_wake_pair(); - let handle = PtyIoActorHandle { - data_tx, - control_tx, - wake, - user_writes: Arc::new(Mutex::new(UserWriteGate { accepting: true })), - controls: Arc::new(Mutex::new(SharedPtyControls::default())), - }; - - let write = tokio::spawn(async move { - handle - .write_user_input(Bytes::from_static(b"wait-for-capacity")) - .await - }); - tokio::time::sleep(Duration::from_millis(50)).await; - assert!( - !write.is_finished(), - "async input should wait for queue capacity" - ); - - assert!(matches!( - data_rx.recv().await, - Some(PtyIoDataCommand::WriteUserInput(_)) - )); - write - .await - .expect("write task joins") - .expect("write succeeds after capacity opens"); - match data_rx.recv().await { - Some(PtyIoDataCommand::WriteUserInput(bytes)) => { - assert_eq!(bytes, Bytes::from_static(b"wait-for-capacity")); - } - _ => panic!("expected queued user input"), - } - } - - #[tokio::test] - async fn async_user_input_waiting_for_capacity_is_rejected_after_handoff_begins() { - let (data_tx, mut data_rx) = mpsc::channel(1); - let (control_tx, control_rx) = std_mpsc::channel(); - data_tx - .try_send(PtyIoDataCommand::WriteUserInput(Bytes::from_static( - b"fill", - ))) - .expect("fill data queue"); - let (wake, _wake_read_fd) = test_wake_pair(); - let handle = PtyIoActorHandle { - data_tx, - control_tx, - wake, - user_writes: Arc::new(Mutex::new(UserWriteGate { accepting: true })), - controls: Arc::new(Mutex::new(SharedPtyControls::default())), - }; - let write_handle = handle.clone(); - let write = tokio::spawn(async move { - write_handle - .write_user_input(Bytes::from_static(b"after-handoff-start")) - .await - }); - tokio::time::sleep(Duration::from_millis(50)).await; - - let handoff = std::thread::spawn(move || handle.begin_handoff(Duration::from_secs(1))); - match control_rx - .recv_timeout(Duration::from_secs(1)) - .expect("handoff control command") - { - PtyIoControlCommand::BeginHandoff(reply) => { - reply.send(Ok(())).expect("handoff waiter alive"); - } - _ => panic!("expected begin handoff command"), - } - handoff - .join() - .expect("handoff thread joins") - .expect("handoff succeeds"); - assert!(matches!( - data_rx.recv().await, - Some(PtyIoDataCommand::WriteUserInput(_)) - )); - - let err = write.await.expect("write task joins").expect_err( - "write waiting for capacity must be rejected after handoff closes the input gate", - ); - assert_eq!(err.0, Bytes::from_static(b"after-handoff-start")); - match tokio::time::timeout(Duration::from_millis(50), data_rx.recv()).await { - Err(_) | Ok(None) => {} - Ok(Some(_)) => panic!("rejected write must not be queued"), - } - } - - #[test] - fn handoff_control_is_not_blocked_by_full_data_queue() { - let (data_tx, _data_rx) = mpsc::channel(1); - let (control_tx, control_rx) = std_mpsc::channel(); - data_tx - .try_send(PtyIoDataCommand::WriteUserInput(Bytes::from_static( - b"fill", - ))) - .expect("fill data queue"); - let (wake, _wake_read_fd) = test_wake_pair(); - let handle = PtyIoActorHandle { - data_tx, - control_tx, - wake, - user_writes: Arc::new(Mutex::new(UserWriteGate { accepting: true })), - controls: Arc::new(Mutex::new(SharedPtyControls::default())), - }; - - let handoff = std::thread::spawn(move || handle.begin_handoff(Duration::from_secs(1))); - match control_rx - .recv_timeout(Duration::from_secs(1)) - .expect("handoff control command") - { - PtyIoControlCommand::BeginHandoff(reply) => { - reply.send(Ok(())).expect("handoff waiter alive"); - } - _ => panic!("expected begin handoff command"), - } - - handoff - .join() - .expect("handoff thread joins") - .expect("handoff succeeds despite full data queue"); - } - - #[test] - fn begin_handoff_drains_user_writes_already_in_command_queue() { - let (actor_socket, mut peer) = UnixStream::pair().expect("socket pair"); - actor_socket - .set_nonblocking(true) - .expect("actor socket nonblocking"); - peer.set_read_timeout(Some(Duration::from_secs(1))) - .expect("peer timeout"); - let (data_tx, data_rx) = mpsc::channel(ACTOR_COMMAND_BUFFER); - let (_control_tx, control_rx) = std_mpsc::channel(); - data_tx - .try_send(PtyIoDataCommand::WriteUserInput(Bytes::from_static( - b"queued-before-ack", - ))) - .expect("queued write"); - let mut runner = PtyIoActorRunner { - pane_id: 1, - file: std::fs::File::from(unsafe { OwnedFd::from_raw_fd(actor_socket.into_raw_fd()) }), - data_rx, - control_rx, - state: ActorState::Running, - pending_writes: VecDeque::new(), - current_write_offset: 0, - wake_read_fd: fd::create_wake_pipe().expect("wake pipe").read_fd, - controls: Arc::new(Mutex::new(SharedPtyControls::default())), - on_read: Box::new(|_| PtyReadResult::empty()), - on_reader_exit: None, - poll_observer: None, - }; - - runner.begin_handoff().expect("handoff drains queued write"); - - let mut buf = [0u8; 17]; - peer.read_exact(&mut buf) - .expect("queued write reaches peer before quiesce ack"); - assert_eq!(&buf, b"queued-before-ack"); - assert_eq!(runner.state, ActorState::Quiesced); - } - - #[test] - fn release_after_commit_prevents_further_io() { - let (handle, mut peer, read_rx) = actor_with_socket_pair(false); - - handle.release_after_commit().expect("actor released"); - assert!(handle - .try_write_user_input(Bytes::from_static(b"blocked")) - .is_err()); - - let _ = peer.write_all(b"ignored"); - assert!(read_rx.recv_timeout(Duration::from_millis(150)).is_err()); - } -} +#[cfg(windows)] +pub(crate) use windows::*; diff --git a/src/pty/actor/unix.rs b/src/pty/actor/unix.rs new file mode 100644 index 00000000..70634475 --- /dev/null +++ b/src/pty/actor/unix.rs @@ -0,0 +1,1172 @@ +use std::{ + collections::VecDeque, + io::{Read, Write}, + os::fd::{AsRawFd, OwnedFd, RawFd}, + sync::{mpsc as std_mpsc, Arc, Mutex}, + time::{Duration, Instant}, +}; + +use bytes::Bytes; +use tokio::sync::mpsc::{self, error::TryRecvError as DataTryRecvError}; +use tracing::{debug, warn}; + +use crate::pty::fd; + +#[cfg(not(test))] +const ACTOR_POLL_MS: i32 = 50; +#[cfg(test)] +const ACTOR_POLL_MS: i32 = 1000; +const ACTOR_COMMAND_BUFFER: usize = 1024; +const HANDOFF_DRAIN_TIMEOUT: Duration = Duration::from_secs(2); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ActorState { + Running, + Quiesced, + Released, +} + +pub(crate) struct PtyReadResult { + pub terminal_responses: Vec, +} + +impl PtyReadResult { + #[cfg(test)] + pub(crate) fn empty() -> Self { + Self { + terminal_responses: Vec::new(), + } + } +} + +type ReadCallback = Box PtyReadResult + Send + 'static>; +type ReaderExitCallback = Box; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct PtyResize { + rows: u16, + cols: u16, + cell_width_px: u32, + cell_height_px: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct PtyResizeRequest { + resize: PtyResize, + terminal_responses: Vec, +} + +#[derive(Default)] +struct SharedPtyControls { + resize: Option, + nudge: Option, +} + +pub(crate) struct PtyIoActorConfig { + pub pane_id: u32, + pub master_fd: OwnedFd, + pub initially_quiesced: bool, + pub on_read: ReadCallback, + pub on_reader_exit: Option, +} + +enum PtyIoDataCommand { + WriteUserInput(Bytes), +} + +enum PtyIoControlCommand { + BeginHandoff(std_mpsc::Sender>), + DuplicateForHandoff(std_mpsc::Sender>), + ForegroundProcessGroup(std_mpsc::Sender>), + RollbackHandoff(std_mpsc::Sender>), + ReleaseAfterCommit(std_mpsc::Sender>), + Shutdown, +} + +#[derive(Clone)] +pub(crate) struct PtyIoActorHandle { + data_tx: mpsc::Sender, + control_tx: std_mpsc::Sender, + wake: fd::WakeWriter, + user_writes: Arc>, + controls: Arc>, +} + +#[derive(Debug)] +struct UserWriteGate { + accepting: bool, +} + +impl PtyIoActorHandle { + pub(crate) async fn write_user_input( + &self, + bytes: Bytes, + ) -> Result<(), mpsc::error::SendError> { + { + let user_writes = self + .user_writes + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if !user_writes.accepting { + return Err(mpsc::error::SendError(bytes)); + } + } + + let permit = match self.data_tx.reserve().await { + Ok(permit) => permit, + Err(_) => return Err(mpsc::error::SendError(bytes)), + }; + + let user_writes = self + .user_writes + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if !user_writes.accepting { + return Err(mpsc::error::SendError(bytes)); + } + permit.send(PtyIoDataCommand::WriteUserInput(bytes)); + self.wake_actor(); + Ok(()) + } + + pub(crate) fn try_write_user_input( + &self, + bytes: Bytes, + ) -> Result<(), mpsc::error::TrySendError> { + let user_writes = self + .user_writes + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if !user_writes.accepting { + return Err(mpsc::error::TrySendError::Closed(bytes)); + } + match self + .data_tx + .try_send(PtyIoDataCommand::WriteUserInput(bytes)) + { + Ok(()) => { + self.wake_actor(); + Ok(()) + } + Err(mpsc::error::TrySendError::Full(PtyIoDataCommand::WriteUserInput(bytes))) => { + Err(mpsc::error::TrySendError::Full(bytes)) + } + Err(mpsc::error::TrySendError::Closed(PtyIoDataCommand::WriteUserInput(bytes))) => { + Err(mpsc::error::TrySendError::Closed(bytes)) + } + } + } + + pub(crate) fn resize( + &self, + rows: u16, + cols: u16, + cell_width_px: u32, + cell_height_px: u32, + terminal_responses: Vec, + ) { + { + let mut controls = self + .controls + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + controls.resize = Some(PtyResizeRequest { + resize: PtyResize { + rows, + cols, + cell_width_px, + cell_height_px, + }, + terminal_responses, + }); + } + self.wake_actor(); + } + + pub(crate) fn nudge_child_redraw_after_handoff( + &self, + rows: u16, + cols: u16, + cell_width_px: u32, + cell_height_px: u32, + ) { + { + let mut controls = self + .controls + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + controls.nudge = Some(PtyResize { + rows, + cols, + cell_width_px, + cell_height_px, + }); + } + self.wake_actor(); + } + + pub(crate) fn begin_handoff(&self, timeout: Duration) -> std::io::Result<()> { + let (reply_tx, reply_rx) = std_mpsc::channel(); + { + let mut user_writes = self + .user_writes + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + user_writes.accepting = false; + if self + .control_tx + .send(PtyIoControlCommand::BeginHandoff(reply_tx)) + .is_err() + { + user_writes.accepting = true; + return Err(std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "pty actor closed", + )); + } + self.wake_actor(); + } + match reply_rx.recv_timeout(timeout) { + Ok(Ok(())) => Ok(()), + Ok(Err(err)) => { + let _ = self.rollback_handoff(); + Err(err) + } + Err(_) => { + let _ = self.rollback_handoff(); + Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "timed out waiting for PTY actor to quiesce", + )) + } + } + } + + pub(crate) fn duplicate_for_handoff(&self) -> std::io::Result { + let (reply_tx, reply_rx) = std_mpsc::channel(); + self.control_tx + .send(PtyIoControlCommand::DuplicateForHandoff(reply_tx)) + .map_err(|_| std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pty actor closed"))?; + self.wake_actor(); + reply_rx.recv_timeout(Duration::from_secs(1)).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::TimedOut, + "timed out waiting for PTY handoff duplicate", + ) + })? + } + + pub(crate) fn foreground_process_group_id(&self) -> Option { + let (reply_tx, reply_rx) = std_mpsc::channel(); + self.control_tx + .send(PtyIoControlCommand::ForegroundProcessGroup(reply_tx)) + .ok()?; + self.wake_actor(); + reply_rx.recv_timeout(Duration::from_secs(1)).ok()? + } + + pub(crate) fn rollback_handoff(&self) -> std::io::Result<()> { + let (reply_tx, reply_rx) = std_mpsc::channel(); + self.control_tx + .send(PtyIoControlCommand::RollbackHandoff(reply_tx)) + .map_err(|_| std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pty actor closed"))?; + self.wake_actor(); + let result = reply_rx.recv_timeout(Duration::from_secs(1)).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::TimedOut, + "timed out waiting for PTY handoff rollback", + ) + })?; + if result.is_ok() { + let mut user_writes = self + .user_writes + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + user_writes.accepting = true; + } + result + } + + pub(crate) fn release_after_commit(&self) -> std::io::Result<()> { + { + let mut user_writes = self + .user_writes + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + user_writes.accepting = false; + } + let (reply_tx, reply_rx) = std_mpsc::channel(); + self.control_tx + .send(PtyIoControlCommand::ReleaseAfterCommit(reply_tx)) + .map_err(|_| std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pty actor closed"))?; + self.wake_actor(); + reply_rx.recv_timeout(Duration::from_secs(1)).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::TimedOut, + "timed out waiting for PTY actor release", + ) + })? + } + + pub(crate) fn shutdown(&self) { + { + let mut user_writes = self + .user_writes + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + user_writes.accepting = false; + } + if self.control_tx.send(PtyIoControlCommand::Shutdown).is_ok() { + self.wake_actor(); + } + } + + fn wake_actor(&self) { + if let Err(err) = self.wake.wake() { + debug!(err = %err, "failed to wake PTY actor"); + } + } +} + +pub(crate) struct PtyIoActor; + +impl PtyIoActor { + pub(crate) fn spawn(config: PtyIoActorConfig) -> std::io::Result { + Self::spawn_inner(config, None) + } + + fn spawn_inner( + config: PtyIoActorConfig, + poll_observer: Option>, + ) -> std::io::Result { + fd::set_cloexec(config.master_fd.as_raw_fd())?; + fd::set_nonblocking(config.master_fd.as_raw_fd())?; + + let (data_tx, data_rx) = mpsc::channel(ACTOR_COMMAND_BUFFER); + let (control_tx, control_rx) = std_mpsc::channel(); + let wake_pipe = fd::create_wake_pipe()?; + let user_writes = Arc::new(Mutex::new(UserWriteGate { + accepting: !config.initially_quiesced, + })); + let controls = Arc::new(Mutex::new(SharedPtyControls::default())); + let handle = PtyIoActorHandle { + data_tx, + control_tx, + wake: wake_pipe.writer, + user_writes, + controls: Arc::clone(&controls), + }; + + let mut runner = PtyIoActorRunner { + pane_id: config.pane_id, + file: std::fs::File::from(config.master_fd), + data_rx, + control_rx, + state: if config.initially_quiesced { + ActorState::Quiesced + } else { + ActorState::Running + }, + pending_writes: VecDeque::new(), + current_write_offset: 0, + wake_read_fd: wake_pipe.read_fd, + controls, + on_read: config.on_read, + on_reader_exit: config.on_reader_exit, + poll_observer, + }; + std::thread::Builder::new() + .name(format!("herdr-pty-{}", config.pane_id)) + .spawn(move || runner.run()) + .map_err(|err| std::io::Error::other(err.to_string()))?; + + Ok(handle) + } + + #[cfg(test)] + fn spawn_with_poll_observer( + config: PtyIoActorConfig, + poll_observer: std_mpsc::Sender<()>, + ) -> std::io::Result { + Self::spawn_inner(config, Some(poll_observer)) + } +} + +struct PtyIoActorRunner { + pane_id: u32, + file: std::fs::File, + data_rx: mpsc::Receiver, + control_rx: std_mpsc::Receiver, + state: ActorState, + pending_writes: VecDeque, + current_write_offset: usize, + wake_read_fd: OwnedFd, + controls: Arc>, + on_read: ReadCallback, + on_reader_exit: Option, + poll_observer: Option>, +} + +impl PtyIoActorRunner { + fn run(&mut self) { + let mut should_exit = false; + while !should_exit { + should_exit = self.drain_commands(); + if should_exit || self.state == ActorState::Released { + break; + } + + self.apply_pending_controls(); + + if !self.pending_writes.is_empty() { + self.flush_pending_writes_once(); + } + + if let Some(poll_observer) = &self.poll_observer { + let _ = poll_observer.send(()); + } + + match fd::poll_pty_and_wake( + self.file.as_raw_fd(), + self.wake_read_fd.as_raw_fd(), + self.state == ActorState::Running, + !self.pending_writes.is_empty(), + ACTOR_POLL_MS, + ) { + Ok(readiness) => { + if readiness.wake_ready { + if let Err(err) = fd::drain_wake_fd(self.wake_read_fd.as_raw_fd()) { + debug!(pane = self.pane_id, err = %err, "PTY actor wake drain failed"); + break; + } + continue; + } + if readiness.pty_write_ready && !self.pending_writes.is_empty() { + self.flush_pending_writes_once(); + } + if self.state == ActorState::Running + && readiness.pty_read_ready + && !self.read_once() + { + break; + } + } + Err(err) => { + debug!(pane = self.pane_id, err = %err, "PTY actor poll failed"); + break; + } + } + } + + if let Some(on_reader_exit) = self.on_reader_exit.take() { + on_reader_exit(); + } + debug!(pane = self.pane_id, "PTY actor exiting"); + } + + fn drain_commands(&mut self) -> bool { + if self.drain_control_commands() { + return true; + } + self.drain_data_commands() + } + + fn drain_control_commands(&mut self) -> bool { + let mut should_exit = false; + loop { + match self.control_rx.try_recv() { + Ok(command) => { + if self.handle_control_command(command) { + should_exit = true; + break; + } + } + Err(std_mpsc::TryRecvError::Empty) => break, + Err(std_mpsc::TryRecvError::Disconnected) => { + should_exit = true; + break; + } + } + } + should_exit + } + + fn drain_data_commands(&mut self) -> bool { + let mut should_exit = false; + loop { + match self.data_rx.try_recv() { + Ok(command) => { + if self.handle_data_command(command) { + should_exit = true; + break; + } + } + Err(DataTryRecvError::Empty) => break, + Err(DataTryRecvError::Disconnected) => { + should_exit = true; + break; + } + } + } + should_exit + } + + fn handle_data_command(&mut self, command: PtyIoDataCommand) -> bool { + match command { + PtyIoDataCommand::WriteUserInput(bytes) => { + if self.state == ActorState::Running { + self.pending_writes.push_back(bytes); + } + } + } + false + } + + fn handle_control_command(&mut self, command: PtyIoControlCommand) -> bool { + match command { + PtyIoControlCommand::BeginHandoff(reply) => { + let result = self.begin_handoff(); + let _ = reply.send(result); + } + PtyIoControlCommand::DuplicateForHandoff(reply) => { + let result = if self.state == ActorState::Quiesced { + fd::duplicate_cloexec_fd(self.file.as_raw_fd()) + } else { + Err(std::io::Error::other( + "PTY actor must be quiesced before handoff duplication", + )) + }; + let _ = reply.send(result); + } + PtyIoControlCommand::ForegroundProcessGroup(reply) => { + let result = + crate::platform::foreground_process_group_id_for_tty_fd(self.file.as_raw_fd()); + let _ = reply.send(result); + } + PtyIoControlCommand::RollbackHandoff(reply) => { + let result = if self.state == ActorState::Released { + Err(std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "PTY actor was released before handoff rollback", + )) + } else { + self.state = ActorState::Running; + Ok(()) + }; + let _ = reply.send(result); + } + PtyIoControlCommand::ReleaseAfterCommit(reply) => { + self.state = ActorState::Released; + self.pending_writes.clear(); + let _ = reply.send(Ok(())); + return true; + } + PtyIoControlCommand::Shutdown => return true, + } + false + } + + fn begin_handoff(&mut self) -> std::io::Result<()> { + self.drain_pre_quiesce_commands(); + self.apply_pending_controls(); + if self.state == ActorState::Released { + return Err(std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "PTY actor was released before handoff quiesce", + )); + } + let deadline = Instant::now() + HANDOFF_DRAIN_TIMEOUT; + while !self.pending_writes.is_empty() { + if Instant::now() >= deadline { + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "timed out draining PTY writes before handoff", + )); + } + self.flush_pending_writes_once(); + } + self.state = ActorState::Quiesced; + Ok(()) + } + + fn drain_pre_quiesce_commands(&mut self) { + while let Ok(PtyIoDataCommand::WriteUserInput(bytes)) = self.data_rx.try_recv() { + if self.state != ActorState::Released { + self.pending_writes.push_back(bytes); + } + } + } + + fn apply_pending_controls(&mut self) { + let (resize, nudge) = { + let mut controls = self + .controls + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + (controls.resize.take(), controls.nudge.take()) + }; + if self.state == ActorState::Released { + return; + } + if let Some(request) = resize { + self.resize(request.resize); + self.enqueue_terminal_responses(request.terminal_responses); + } + if let Some(nudge) = nudge { + self.nudge(nudge); + } + } + + fn read_once(&mut self) -> bool { + let mut buf = [0u8; 8192]; + match self.file.read(&mut buf) { + Ok(0) => false, + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => true, + Err(err) if err.kind() == std::io::ErrorKind::Interrupted => true, + Err(err) => { + debug!(pane = self.pane_id, err = %err, "PTY actor read failed"); + false + } + Ok(n) => { + let result = (self.on_read)(&buf[..n]); + self.enqueue_terminal_responses(result.terminal_responses); + true + } + } + } + + fn enqueue_terminal_responses(&mut self, terminal_responses: Vec) { + if self.state == ActorState::Released { + return; + } + self.pending_writes.extend(terminal_responses); + } + + fn flush_pending_writes_once(&mut self) { + while let Some(bytes) = self.pending_writes.front() { + let chunk = &bytes[self.current_write_offset..]; + match self.file.write(chunk) { + Ok(0) => { + warn!(pane = self.pane_id, "PTY actor write returned zero bytes"); + return; + } + Ok(written) => { + self.current_write_offset += written; + if self.current_write_offset >= bytes.len() { + self.pending_writes.pop_front(); + self.current_write_offset = 0; + } + } + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { + let _ = fd::poll_write_ready(self.file.as_raw_fd(), ACTOR_POLL_MS); + return; + } + Err(err) if err.kind() == std::io::ErrorKind::Interrupted => return, + Err(err) => { + warn!(pane = self.pane_id, err = %err, "PTY actor write failed"); + self.pending_writes.clear(); + self.current_write_offset = 0; + return; + } + } + } + let _ = self.file.flush(); + } + + fn resize(&self, resize: PtyResize) { + self.log_resize_result(fd::resize_pty_fd( + self.file.as_raw_fd(), + resize.rows, + resize.cols, + resize.cell_width_px, + resize.cell_height_px, + )); + } + + fn nudge(&mut self, resize: PtyResize) { + if self.state == ActorState::Released { + return; + } + let nudge = if resize.rows > 2 { + ( + resize.rows - 1, + resize.cols, + resize.cell_width_px, + resize.cell_height_px, + ) + } else { + ( + resize.rows, + resize.cols.saturating_sub(1).max(4), + resize.cell_width_px, + resize.cell_height_px, + ) + }; + if nudge + == ( + resize.rows, + resize.cols, + resize.cell_width_px, + resize.cell_height_px, + ) + { + return; + } + self.log_resize_result(fd::resize_pty_fd( + self.file.as_raw_fd(), + nudge.0, + nudge.1, + nudge.2, + nudge.3, + )); + std::thread::sleep(Duration::from_millis(30)); + self.log_resize_result(fd::resize_pty_fd( + self.file.as_raw_fd(), + resize.rows, + resize.cols, + resize.cell_width_px, + resize.cell_height_px, + )); + } + + fn log_resize_result(&self, result: std::io::Result<()>) { + if let Err(err) = result { + debug!(pane = self.pane_id, err = %err, "PTY resize failed"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + io::{Read, Write}, + os::fd::{AsRawFd, FromRawFd, IntoRawFd}, + os::unix::net::UnixStream, + }; + + fn test_wake_pair() -> (fd::WakeWriter, OwnedFd) { + let pipe = fd::create_wake_pipe().expect("wake pipe"); + (pipe.writer, pipe.read_fd) + } + + fn actor_with_socket_pair( + initially_quiesced: bool, + ) -> (PtyIoActorHandle, UnixStream, std_mpsc::Receiver) { + actor_with_socket_pair_and_poll_observer(initially_quiesced, None) + } + + fn actor_with_socket_pair_and_poll_observer( + initially_quiesced: bool, + poll_observer: Option>, + ) -> (PtyIoActorHandle, UnixStream, std_mpsc::Receiver) { + let (actor_socket, peer) = UnixStream::pair().expect("socket pair"); + actor_socket + .set_nonblocking(true) + .expect("actor socket nonblocking"); + peer.set_read_timeout(Some(Duration::from_secs(1))) + .expect("peer timeout"); + let owned = unsafe { OwnedFd::from_raw_fd(actor_socket.into_raw_fd()) }; + let (read_tx, read_rx) = std_mpsc::channel(); + let config = PtyIoActorConfig { + pane_id: 1, + master_fd: owned, + initially_quiesced, + on_read: Box::new(move |bytes| { + read_tx + .send(Bytes::copy_from_slice(bytes)) + .expect("read callback receiver alive"); + PtyReadResult::empty() + }), + on_reader_exit: None, + }; + let handle = if let Some(poll_observer) = poll_observer { + PtyIoActor::spawn_with_poll_observer(config, poll_observer) + } else { + PtyIoActor::spawn(config) + } + .expect("actor spawn"); + (handle, peer, read_rx) + } + + #[test] + fn actor_writes_user_input_to_owned_fd() { + let (handle, mut peer, _read_rx) = actor_with_socket_pair(false); + + handle + .try_write_user_input(Bytes::from_static(b"hello")) + .expect("write command accepted"); + + let mut buf = [0u8; 5]; + peer.read_exact(&mut buf).expect("peer receives write"); + assert_eq!(&buf, b"hello"); + handle.shutdown(); + } + + #[test] + fn actor_wakes_idle_poll_for_user_input() { + let (poll_tx, poll_rx) = std_mpsc::channel(); + let (handle, mut peer, _read_rx) = + actor_with_socket_pair_and_poll_observer(false, Some(poll_tx)); + peer.set_read_timeout(Some(Duration::from_millis(500))) + .expect("peer timeout"); + poll_rx + .recv_timeout(Duration::from_secs(1)) + .expect("actor entered idle poll"); + + let start = Instant::now(); + handle + .try_write_user_input(Bytes::from_static(b"x")) + .expect("write command accepted"); + + let mut buf = [0u8; 1]; + peer.read_exact(&mut buf) + .expect("peer receives write without waiting for actor poll timeout"); + assert_eq!(&buf, b"x"); + assert!( + start.elapsed() < Duration::from_millis(500), + "actor write should be driven by wake fd, not the idle poll timeout" + ); + handle.shutdown(); + } + + #[test] + fn poll_ignores_pty_hup_without_pty_interest() { + let (actor_socket, peer) = UnixStream::pair().expect("socket pair"); + actor_socket + .set_nonblocking(true) + .expect("actor socket nonblocking"); + drop(peer); + let wake_pipe = fd::create_wake_pipe().expect("wake pipe"); + + let readiness = fd::poll_pty_and_wake( + actor_socket.as_raw_fd(), + wake_pipe.read_fd.as_raw_fd(), + false, + false, + 10, + ) + .expect("poll succeeds"); + + assert!(!readiness.pty_read_ready); + assert!(!readiness.pty_write_ready); + assert!(!readiness.wake_ready); + } + + #[test] + fn actor_delivers_fd_reads_to_callback() { + let (handle, mut peer, read_rx) = actor_with_socket_pair(false); + + peer.write_all(b"from-peer").expect("peer write"); + + let read = read_rx + .recv_timeout(Duration::from_secs(1)) + .expect("actor read callback"); + assert_eq!(read, Bytes::from_static(b"from-peer")); + handle.shutdown(); + } + + #[test] + fn begin_handoff_stops_reads_and_rejects_user_writes_until_rollback() { + let (handle, mut peer, read_rx) = actor_with_socket_pair(false); + + handle + .begin_handoff(Duration::from_secs(1)) + .expect("handoff quiesced"); + assert!(handle + .try_write_user_input(Bytes::from_static(b"blocked")) + .is_err()); + + peer.write_all(b"held").expect("peer write during quiesce"); + assert!( + read_rx.recv_timeout(Duration::from_millis(150)).is_err(), + "actor must not read while quiesced" + ); + + handle.rollback_handoff().expect("rollback resumes actor"); + let read = read_rx + .recv_timeout(Duration::from_secs(1)) + .expect("actor reads held bytes after rollback"); + assert_eq!(read, Bytes::from_static(b"held")); + + handle + .try_write_user_input(Bytes::from_static(b"after")) + .expect("write accepted after rollback"); + let mut buf = [0u8; 5]; + peer.read_exact(&mut buf).expect("peer receives after"); + assert_eq!(&buf, b"after"); + handle.shutdown(); + } + + #[test] + fn duplicate_for_handoff_requires_quiesced_actor() { + let (handle, mut peer, read_rx) = actor_with_socket_pair(false); + + assert!(handle.duplicate_for_handoff().is_err()); + handle + .begin_handoff(Duration::from_secs(1)) + .expect("handoff quiesced"); + let duplicate = handle + .duplicate_for_handoff() + .expect("handoff duplicate created"); + assert!(duplicate >= 0); + unsafe { + libc::close(duplicate); + } + handle.rollback_handoff().expect("rollback resumes actor"); + + peer.write_all(b"still-live").expect("peer write"); + let read = read_rx + .recv_timeout(Duration::from_secs(1)) + .expect("actor still reads after duplicate closes"); + assert_eq!(read, Bytes::from_static(b"still-live")); + handle.shutdown(); + } + + #[test] + fn resize_and_nudge_keep_latest_request_when_command_queue_is_full() { + let (data_tx, _data_rx) = mpsc::channel(1); + let (control_tx, _control_rx) = std_mpsc::channel(); + data_tx + .try_send(PtyIoDataCommand::WriteUserInput(Bytes::from_static( + b"fill", + ))) + .expect("fill command queue"); + let controls = Arc::new(Mutex::new(SharedPtyControls::default())); + let (wake, _wake_read_fd) = test_wake_pair(); + let handle = PtyIoActorHandle { + data_tx, + control_tx, + wake, + user_writes: Arc::new(Mutex::new(UserWriteGate { accepting: true })), + controls: Arc::clone(&controls), + }; + + handle.resize(20, 80, 8, 16, vec![Bytes::from_static(b"old")]); + handle.resize(40, 120, 9, 18, vec![Bytes::from_static(b"new")]); + handle.nudge_child_redraw_after_handoff(41, 121, 10, 20); + + let controls = controls.lock().expect("controls lock"); + assert_eq!( + controls.resize, + Some(PtyResizeRequest { + resize: PtyResize { + rows: 40, + cols: 120, + cell_width_px: 9, + cell_height_px: 18, + }, + terminal_responses: vec![Bytes::from_static(b"new")], + }) + ); + assert_eq!( + controls.nudge, + Some(PtyResize { + rows: 41, + cols: 121, + cell_width_px: 10, + cell_height_px: 20, + }) + ); + } + + #[test] + fn resize_writes_terminal_responses_after_applying_resize() { + let (handle, mut peer, _read_rx) = actor_with_socket_pair(false); + let response = Bytes::from_static(b"\x1B[48;40;100;720;900t"); + + handle.resize(40, 100, 9, 18, vec![response.clone()]); + + let mut buf = vec![0; response.len()]; + peer.read_exact(&mut buf) + .expect("peer receives resize response"); + assert_eq!(Bytes::from(buf), response); + handle.shutdown(); + } + + #[tokio::test] + async fn async_user_input_waits_for_queue_capacity() { + let (data_tx, mut data_rx) = mpsc::channel(1); + let (control_tx, _control_rx) = std_mpsc::channel(); + data_tx + .try_send(PtyIoDataCommand::WriteUserInput(Bytes::from_static( + b"fill", + ))) + .expect("fill data queue"); + let (wake, _wake_read_fd) = test_wake_pair(); + let handle = PtyIoActorHandle { + data_tx, + control_tx, + wake, + user_writes: Arc::new(Mutex::new(UserWriteGate { accepting: true })), + controls: Arc::new(Mutex::new(SharedPtyControls::default())), + }; + + let write = tokio::spawn(async move { + handle + .write_user_input(Bytes::from_static(b"wait-for-capacity")) + .await + }); + tokio::time::sleep(Duration::from_millis(50)).await; + assert!( + !write.is_finished(), + "async input should wait for queue capacity" + ); + + assert!(matches!( + data_rx.recv().await, + Some(PtyIoDataCommand::WriteUserInput(_)) + )); + write + .await + .expect("write task joins") + .expect("write succeeds after capacity opens"); + match data_rx.recv().await { + Some(PtyIoDataCommand::WriteUserInput(bytes)) => { + assert_eq!(bytes, Bytes::from_static(b"wait-for-capacity")); + } + _ => panic!("expected queued user input"), + } + } + + #[tokio::test] + async fn async_user_input_waiting_for_capacity_is_rejected_after_handoff_begins() { + let (data_tx, mut data_rx) = mpsc::channel(1); + let (control_tx, control_rx) = std_mpsc::channel(); + data_tx + .try_send(PtyIoDataCommand::WriteUserInput(Bytes::from_static( + b"fill", + ))) + .expect("fill data queue"); + let (wake, _wake_read_fd) = test_wake_pair(); + let handle = PtyIoActorHandle { + data_tx, + control_tx, + wake, + user_writes: Arc::new(Mutex::new(UserWriteGate { accepting: true })), + controls: Arc::new(Mutex::new(SharedPtyControls::default())), + }; + let write_handle = handle.clone(); + let write = tokio::spawn(async move { + write_handle + .write_user_input(Bytes::from_static(b"after-handoff-start")) + .await + }); + tokio::time::sleep(Duration::from_millis(50)).await; + + let handoff = std::thread::spawn(move || handle.begin_handoff(Duration::from_secs(1))); + match control_rx + .recv_timeout(Duration::from_secs(1)) + .expect("handoff control command") + { + PtyIoControlCommand::BeginHandoff(reply) => { + reply.send(Ok(())).expect("handoff waiter alive"); + } + _ => panic!("expected begin handoff command"), + } + handoff + .join() + .expect("handoff thread joins") + .expect("handoff succeeds"); + assert!(matches!( + data_rx.recv().await, + Some(PtyIoDataCommand::WriteUserInput(_)) + )); + + let err = write.await.expect("write task joins").expect_err( + "write waiting for capacity must be rejected after handoff closes the input gate", + ); + assert_eq!(err.0, Bytes::from_static(b"after-handoff-start")); + match tokio::time::timeout(Duration::from_millis(50), data_rx.recv()).await { + Err(_) | Ok(None) => {} + Ok(Some(_)) => panic!("rejected write must not be queued"), + } + } + + #[test] + fn handoff_control_is_not_blocked_by_full_data_queue() { + let (data_tx, _data_rx) = mpsc::channel(1); + let (control_tx, control_rx) = std_mpsc::channel(); + data_tx + .try_send(PtyIoDataCommand::WriteUserInput(Bytes::from_static( + b"fill", + ))) + .expect("fill data queue"); + let (wake, _wake_read_fd) = test_wake_pair(); + let handle = PtyIoActorHandle { + data_tx, + control_tx, + wake, + user_writes: Arc::new(Mutex::new(UserWriteGate { accepting: true })), + controls: Arc::new(Mutex::new(SharedPtyControls::default())), + }; + + let handoff = std::thread::spawn(move || handle.begin_handoff(Duration::from_secs(1))); + match control_rx + .recv_timeout(Duration::from_secs(1)) + .expect("handoff control command") + { + PtyIoControlCommand::BeginHandoff(reply) => { + reply.send(Ok(())).expect("handoff waiter alive"); + } + _ => panic!("expected begin handoff command"), + } + + handoff + .join() + .expect("handoff thread joins") + .expect("handoff succeeds despite full data queue"); + } + + #[test] + fn begin_handoff_drains_user_writes_already_in_command_queue() { + let (actor_socket, mut peer) = UnixStream::pair().expect("socket pair"); + actor_socket + .set_nonblocking(true) + .expect("actor socket nonblocking"); + peer.set_read_timeout(Some(Duration::from_secs(1))) + .expect("peer timeout"); + let (data_tx, data_rx) = mpsc::channel(ACTOR_COMMAND_BUFFER); + let (_control_tx, control_rx) = std_mpsc::channel(); + data_tx + .try_send(PtyIoDataCommand::WriteUserInput(Bytes::from_static( + b"queued-before-ack", + ))) + .expect("queued write"); + let mut runner = PtyIoActorRunner { + pane_id: 1, + file: std::fs::File::from(unsafe { OwnedFd::from_raw_fd(actor_socket.into_raw_fd()) }), + data_rx, + control_rx, + state: ActorState::Running, + pending_writes: VecDeque::new(), + current_write_offset: 0, + wake_read_fd: fd::create_wake_pipe().expect("wake pipe").read_fd, + controls: Arc::new(Mutex::new(SharedPtyControls::default())), + on_read: Box::new(|_| PtyReadResult::empty()), + on_reader_exit: None, + poll_observer: None, + }; + + runner.begin_handoff().expect("handoff drains queued write"); + + let mut buf = [0u8; 17]; + peer.read_exact(&mut buf) + .expect("queued write reaches peer before quiesce ack"); + assert_eq!(&buf, b"queued-before-ack"); + assert_eq!(runner.state, ActorState::Quiesced); + } + + #[test] + fn release_after_commit_prevents_further_io() { + let (handle, mut peer, read_rx) = actor_with_socket_pair(false); + + handle.release_after_commit().expect("actor released"); + assert!(handle + .try_write_user_input(Bytes::from_static(b"blocked")) + .is_err()); + + let _ = peer.write_all(b"ignored"); + assert!(read_rx.recv_timeout(Duration::from_millis(150)).is_err()); + } +} diff --git a/src/pty/backend.rs b/src/pty/backend.rs index 5871004a..d09922b7 100644 --- a/src/pty/backend.rs +++ b/src/pty/backend.rs @@ -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, pub child: Box, } +#[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> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())) - } - - fn parent_pty_fd_targets() -> Vec { - let Ok(entries) = std::fs::read_dir("/proc/self/fd") else { - return Vec::new(); - }; - let mut targets: Vec = 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); - } -} diff --git a/src/pty/backend/unix.rs b/src/pty/backend/unix.rs new file mode 100644 index 00000000..5871004a --- /dev/null +++ b/src/pty/backend/unix.rs @@ -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, +} + +pub(crate) fn spawn_with_portable_pty( + rows: u16, + cols: u16, + cmd: CommandBuilder, +) -> std::io::Result { + 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> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + + fn parent_pty_fd_targets() -> Vec { + let Ok(entries) = std::fs::read_dir("/proc/self/fd") else { + return Vec::new(); + }; + let mut targets: Vec = 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); + } +} diff --git a/src/pty/mod.rs b/src/pty/mod.rs index 74bbb124..ce838679 100644 --- a/src/pty/mod.rs +++ b/src/pty/mod.rs @@ -1,6 +1,4 @@ -#[cfg(unix)] pub(crate) mod actor; -#[cfg(unix)] pub(crate) mod backend; #[cfg(unix)] pub(crate) mod fd; diff --git a/src/remote.rs b/src/remote.rs index ce56ba8a..edfb9269 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -1,38 +1,22 @@ -//! Remote thin-client launcher over SSH command stdio. +#[cfg(unix)] +mod unix; -use std::collections::BTreeMap; -use std::fs::{self, File}; -use std::io::{self, IsTerminal, Write as _}; -use std::os::unix::net::{UnixListener, UnixStream}; -use std::path::{Path, PathBuf}; -use std::process::{Command, Output, Stdio}; +#[cfg(unix)] +pub(crate) use unix::*; -use serde::Deserialize; -use std::sync::{ - atomic::{AtomicBool, Ordering}, - Arc, -}; -use std::thread::{self, JoinHandle}; -use std::time::{Duration, Instant}; - -const BRIDGE_ACCEPT_POLL: Duration = Duration::from_millis(50); -const BRIDGE_SOCKET_PERMISSION_MODE: u32 = 0o600; -const REMOTE_SERVER_SHUTDOWN_CONFIRM_TIMEOUT: Duration = Duration::from_secs(5); -const REMOTE_SERVER_SHUTDOWN_POLL_INTERVAL: Duration = Duration::from_millis(100); -const CURRENT_PROTOCOL: u32 = crate::protocol::PROTOCOL_VERSION; -const STABLE_UPDATE_MANIFEST_URL: &str = "https://herdr.dev/latest.json"; -const PREVIEW_UPDATE_MANIFEST_URL: &str = "https://herdr.dev/preview.json"; -const REMOTE_BINARY_ENV_VAR: &str = "HERDR_REMOTE_BINARY"; +#[cfg(windows)] pub(crate) const REATTACH_COMMAND_ENV_VAR: &str = "HERDR_REATTACH_COMMAND"; - +#[cfg(windows)] pub(crate) const REMOTE_KEYBINDINGS_ENV_VAR: &str = "HERDR_REMOTE_KEYBINDINGS"; +#[cfg(windows)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum RemoteKeybindings { Local, Server, } +#[cfg(windows)] impl RemoteKeybindings { fn parse(value: &str) -> Result { match value { @@ -41,15 +25,9 @@ impl RemoteKeybindings { _ => Err("--remote-keybindings must be 'local' or 'server'".to_string()), } } - - fn as_str(self) -> &'static str { - match self { - Self::Local => "local", - Self::Server => "server", - } - } } +#[cfg(windows)] #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct RemoteLaunch { pub(crate) target: String, @@ -57,6 +35,7 @@ pub(crate) struct RemoteLaunch { pub(crate) live_handoff: bool, } +#[cfg(windows)] pub(crate) fn extract_remote_args( args: &[String], ) -> Result<(Vec, Option), String> { @@ -141,6 +120,7 @@ pub(crate) fn extract_remote_args( Ok((cleaned, remote)) } +#[cfg(windows)] fn validate_remote_target(target: &str) -> Result<&str, String> { if target.is_empty() { return Err("missing value for --remote".to_string()); @@ -151,2346 +131,18 @@ fn validate_remote_target(target: &str) -> Result<&str, String> { Ok(target) } -pub(crate) fn run_remote(remote: RemoteLaunch) -> io::Result<()> { - let session_name = crate::session::active_name() - .unwrap_or_else(|| crate::session::DEFAULT_SESSION_NAME.to_string()); - let local_socket = local_forward_socket_path(&remote.target, &session_name); - let program = std::env::args() - .next() - .unwrap_or_else(|| "herdr".to_string()); - let reattach_command = reattach_command( - &program, - &remote.target, - &session_name, - remote.keybindings, - remote.live_handoff, - ); - let prepared_remote = prepare_remote_herdr(&remote.target, remote.live_handoff)?; - ensure_remote_server_ready( - &remote.target, - &prepared_remote.remote_herdr, - prepared_remote.installed_or_replaced, - prepared_remote.stop_after_install_approved, - remote.live_handoff, - )?; - - let manage_ssh_config = crate::config::Config::load() - .config - .remote - .manage_ssh_config; - let _bridge = SshStdioBridge::start( - remote.target, - prepared_remote.remote_herdr, - local_socket.clone(), - session_name, - manage_ssh_config, - )?; - - run_client_process(&local_socket, &reattach_command, remote.keybindings) -} - -pub(crate) fn run_remote_client_bridge() -> io::Result<()> { - ensure_remote_server_running()?; - - let socket_path = crate::server::socket_paths::client_socket_path(); - let stream = UnixStream::connect(&socket_path).map_err(|err| { - io::Error::new( - err.kind(), - format!( - "failed to connect to remote Herdr client socket {}: {err}", - socket_path.display() - ), - ) - })?; - - let mut stdout = io::stdout().lock(); - let mut socket_to_stdout = stream.try_clone()?; - let mut stdin_to_socket = stream; - - let _upload = thread::spawn(move || { - let mut stdin = io::stdin(); - let _ = copy_flush(&mut stdin, &mut stdin_to_socket); - let _ = stdin_to_socket.shutdown(std::net::Shutdown::Write); - }); - - copy_flush(&mut socket_to_stdout, &mut stdout).map(|_| ()) -} - -fn ensure_remote_server_running() -> io::Result<()> { - let socket_path = crate::server::socket_paths::client_socket_path(); - if crate::server::autodetect::is_server_listening() { - let status = crate::api::read_runtime_status_at( - &crate::api::socket_path(), - Duration::from_millis(500), - )? - .ok_or_else(|| io::Error::other("remote server status API is unavailable"))?; - if status.protocol == Some(CURRENT_PROTOCOL) { - return Ok(()); - } - return Err(io::Error::other( - "remote herdr server must restart before this bridge can attach; rerun `herdr --remote` from an interactive terminal to approve stopping it", - )); - } - - crate::server::autodetect::spawn_server_daemon()?; - crate::server::autodetect::wait_for_server_socket(&socket_path, Duration::from_secs(5)) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct RemotePlatform { - os: &'static str, - arch: &'static str, -} - -impl RemotePlatform { - fn from_uname(os: &str, arch: &str) -> Option { - let os = match os.trim() { - "Linux" => "linux", - "Darwin" => "macos", - _ => return None, - }; - let arch = match arch.trim() { - "x86_64" | "amd64" => "x86_64", - "aarch64" | "arm64" => "aarch64", - _ => return None, - }; - Some(Self { os, arch }) - } - - fn local() -> Self { - let os = if cfg!(target_os = "linux") { - "linux" - } else if cfg!(target_os = "macos") { - "macos" - } else { - "unknown" - }; - - let arch = if cfg!(target_arch = "x86_64") { - "x86_64" - } else if cfg!(target_arch = "aarch64") { - "aarch64" - } else { - "unknown" - }; - - Self { os, arch } - } - - fn asset_key(&self) -> String { - format!("{}-{}", self.os, self.arch) - } -} - -#[derive(Debug, Clone)] -struct RemoteHerdr { - install_suffix: String, - shell_path: String, - platform: RemotePlatform, -} - -impl RemoteHerdr { - fn for_platform(platform: RemotePlatform) -> Self { - let install_suffix = ".local/bin/herdr".to_string(); - let shell_path = format!("\"$HOME/{install_suffix}\""); - Self { - install_suffix, - shell_path, - platform, - } - } - - fn with_shell_path(mut self, shell_path: String) -> Self { - self.shell_path = shell_path; - self - } -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(untagged)] -enum RemoteAssetRef { - Url(String), - Object { url: String, sha256: Option }, -} - -impl RemoteAssetRef { - fn url(&self) -> &str { - match self { - Self::Url(url) => url, - Self::Object { url, .. } => url, - } - } - - fn sha256(&self) -> Option<&str> { - match self { - Self::Url(_) => None, - Self::Object { sha256, .. } => { - sha256.as_deref().filter(|value| !value.trim().is_empty()) - } - } - } -} - -#[derive(Deserialize)] -struct RemoteUpdateManifest { - version: String, - protocol: Option, - assets: BTreeMap, - #[serde(default, deserialize_with = "deserialize_remote_manifest_releases")] - releases: BTreeMap, -} - -#[derive(Deserialize)] -struct RemoteReleaseMetadata { - protocol: Option, - #[serde(default)] - assets: BTreeMap, -} - -#[derive(Deserialize)] -struct RemotePreviewManifest { - build_id: String, - protocol: u32, - assets: BTreeMap, - #[serde(default)] - builds: BTreeMap, -} - -#[derive(Deserialize)] -struct RemotePreviewBuildMetadata { - protocol: u32, - assets: BTreeMap, -} - -fn deserialize_remote_manifest_releases<'de, D>( - deserializer: D, -) -> Result, D::Error> -where - D: serde::Deserializer<'de>, -{ - let value = Option::::deserialize(deserializer)?; - Ok(match value { - Some(serde_json::Value::Object(object)) => object - .into_iter() - .filter_map(|(version, release)| { - serde_json::from_value::(release) - .ok() - .map(|metadata| (version, metadata)) - }) - .collect(), - _ => BTreeMap::new(), - }) -} - -impl RemoteUpdateManifest { - fn release_for_version(&self, version: &str) -> Option> { - if self.version.trim_start_matches('v') == version { - return Some(RemoteManifestReleaseRef { - protocol: self.protocol, - assets: &self.assets, - }); - } - - self.releases.get(version).and_then(|release| { - (!release.assets.is_empty()).then_some(RemoteManifestReleaseRef { - protocol: release.protocol, - assets: &release.assets, - }) - }) - } -} - -#[derive(Clone, Copy)] -struct RemoteManifestReleaseRef<'a> { - protocol: Option, - assets: &'a BTreeMap, -} - -fn current_version() -> String { - crate::build_info::version() -} - -fn current_channel() -> &'static str { - crate::build_info::channel() -} - -struct InstallSource { - path: PathBuf, - temporary_dir: Option, -} - -struct RemoteReleaseAsset { - url: String, - sha256: Option, -} - -struct PreparedRemoteHerdr { - remote_herdr: RemoteHerdr, - installed_or_replaced: bool, - stop_after_install_approved: bool, -} - -impl InstallSource { - fn persistent(path: PathBuf) -> Self { - Self { - path, - temporary_dir: None, - } - } - - fn temporary(path: PathBuf, temporary_dir: PathBuf) -> Self { - Self { - path, - temporary_dir: Some(temporary_dir), - } - } - - fn cleanup(&self) { - if let Some(dir) = &self.temporary_dir { - let _ = fs::remove_dir_all(dir); - } - } -} - -fn prepare_remote_herdr( - target: &str, - live_handoff_enabled: bool, -) -> io::Result { - let platform = detect_remote_platform(target)?; - let remote_herdr = RemoteHerdr::for_platform(platform); - let override_binary = remote_binary_override_path()?; - let path_remote_herdr = remote_binary_on_path_any(target, &remote_herdr)?; - - if override_binary.is_none() { - if let Some(path_remote_herdr) = path_remote_herdr - .as_ref() - .filter(|candidate| remote_binary_matches(target, candidate).unwrap_or(false)) - { - return Ok(PreparedRemoteHerdr { - remote_herdr: path_remote_herdr.clone(), - installed_or_replaced: false, - stop_after_install_approved: false, - }); - } - if remote_binary_matches(target, &remote_herdr)? { - return Ok(PreparedRemoteHerdr { - remote_herdr, - installed_or_replaced: false, - stop_after_install_approved: false, - }); - } - } - - let mut stop_after_install_approved = false; - if let Some(status_probe_herdr) = path_remote_herdr.as_ref().or_else(|| { - remote_binary_exists(target, &remote_herdr) - .ok() - .and_then(|exists| exists.then_some(&remote_herdr)) - }) { - stop_after_install_approved = confirm_remote_install_with_running_server( - target, - status_probe_herdr, - live_handoff_enabled, - )?; - } - confirm_remote_install( - target, - &remote_herdr, - &install_source_description(&remote_herdr.platform, override_binary.as_deref()), - )?; - let source = resolve_install_source(&remote_herdr.platform, override_binary)?; - let install_result = install_remote_herdr(target, &remote_herdr, &source.path); - source.cleanup(); - install_result?; - - if !remote_binary_matches(target, &remote_herdr)? { - return Err(io::Error::other(format!( - "installed remote herdr at {}, but it did not report version {}", - remote_herdr.shell_path, - current_version() - ))); - } - warn_if_remote_bin_not_on_path(target)?; - - Ok(PreparedRemoteHerdr { - remote_herdr, - installed_or_replaced: true, - stop_after_install_approved, - }) -} - -fn detect_remote_platform(target: &str) -> io::Result { - let output = ssh_sh_output(target, "uname -s\nuname -m\n")?; - if !output.status.success() { - return Err(command_failed("remote platform detection failed", &output)); - } - - let stdout = String::from_utf8_lossy(&output.stdout); - let mut lines = stdout.lines(); - let os = lines.next().unwrap_or_default(); - let arch = lines.next().unwrap_or_default(); - RemotePlatform::from_uname(os, arch).ok_or_else(|| { - io::Error::other(format!( - "unsupported remote platform: {} {}", - os.trim(), - arch.trim() - )) - }) -} - -fn remote_binary_on_path_any( - target: &str, - remote_herdr: &RemoteHerdr, -) -> io::Result> { - let output = ssh_user_shell_output(target, "command -v herdr")?; - if !output.status.success() { - return Ok(None); - } - - let stdout = String::from_utf8_lossy(&output.stdout); - Ok(remote_herdr_from_path_discovery(remote_herdr, &stdout)) -} - -fn remote_herdr_from_path_discovery( - remote_herdr: &RemoteHerdr, - stdout: &str, -) -> Option { - let mut lines = stdout.lines(); - let path = lines.next()?; - if !path.starts_with('/') { - return None; - } - Some(remote_herdr.clone().with_shell_path(shell_quote(path))) -} - -fn remote_binary_matches(target: &str, remote_herdr: &RemoteHerdr) -> io::Result { - let command = format!( - "test -x {0} && {0} --version && {0} status client --json", - remote_herdr.shell_path - ); - let output = ssh_sh_output(target, &command)?; - if !output.status.success() { - return Ok(false); - } - - let stdout = String::from_utf8_lossy(&output.stdout); - let mut lines = stdout.lines(); - let version = lines.next().unwrap_or_default().trim(); - let status = lines.next().unwrap_or_default(); - Ok(version == format!("herdr {}", current_version()) - && parse_client_status_json(status) - .map(|status| status.protocol == CURRENT_PROTOCOL) - .unwrap_or(false)) -} - -fn remote_binary_exists(target: &str, remote_herdr: &RemoteHerdr) -> io::Result { - let command = format!("test -x {}", remote_herdr.shell_path); - Ok(ssh_sh_output(target, &command)?.status.success()) -} - -fn remote_binary_override_path() -> io::Result> { - let Some(value) = std::env::var_os(REMOTE_BINARY_ENV_VAR) else { - return Ok(None); - }; - if value.is_empty() { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!("{REMOTE_BINARY_ENV_VAR} must not be empty"), - )); - } - - let path = PathBuf::from(value); - let metadata = fs::metadata(&path).map_err(|err| { - io::Error::new( - err.kind(), - format!( - "failed to inspect {REMOTE_BINARY_ENV_VAR} path {}: {err}", - path.display() - ), - ) - })?; - if !metadata.is_file() { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!( - "{REMOTE_BINARY_ENV_VAR} path is not a file: {}", - path.display() - ), - )); - } - - Ok(Some(path)) -} - -fn install_source_description(platform: &RemotePlatform, override_binary: Option<&Path>) -> String { - install_source_description_for( - platform, - override_binary, - local_binary_can_seed_remote(platform), - ) -} - -fn install_source_description_for( - platform: &RemotePlatform, - override_binary: Option<&Path>, - local_binary_can_seed_remote: bool, -) -> String { - if let Some(path) = override_binary { - return format!("{REMOTE_BINARY_ENV_VAR} ({})", path.display()); - } - - if local_binary_can_seed_remote { - "the current local herdr binary".to_string() - } else { - format!( - "the {} {} asset for {}", - current_version(), - current_channel(), - platform.asset_key() - ) - } -} - -fn resolve_install_source( - platform: &RemotePlatform, - override_binary: Option, -) -> io::Result { - if let Some(path) = override_binary { - return Ok(InstallSource::persistent(path)); - } - - if *platform == RemotePlatform::local() { - let path = std::env::current_exe()?; - if !crate::update::is_package_manager_managed_exe_path(&path) { - return Ok(InstallSource::persistent(path)); - } - } - - download_release_asset(platform) -} - -fn local_binary_can_seed_remote(platform: &RemotePlatform) -> bool { - if *platform != RemotePlatform::local() { - return false; - } - - std::env::current_exe() - .map(|path| !crate::update::is_package_manager_managed_exe_path(&path)) - .unwrap_or(false) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum RemoteServerStatus { - Running { - version: Option, - protocol: Option, - live_handoff: bool, - }, - NotRunning, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum RemoteServerRestartReason { - ProtocolMismatch, - BinaryUpdated, - VersionMismatch, -} - -fn ensure_remote_server_ready( - target: &str, - remote_herdr: &RemoteHerdr, - remote_binary_changed: bool, - stop_after_install_approved: bool, - live_handoff_enabled: bool, -) -> io::Result<()> { - let status = remote_server_status(target, remote_herdr)?; - let RemoteServerStatus::Running { - version, - protocol, - live_handoff, - } = status - else { - return Ok(()); - }; - - let Some(reason) = - remote_server_restart_reason(version.as_deref(), protocol, remote_binary_changed) - else { - return Ok(()); - }; - - if live_handoff_enabled && live_handoff { - match live_handoff_remote_server(target, remote_herdr) { - Ok(()) => return Ok(()), - Err(err) => { - eprintln!("remote live handoff failed: {err}"); - eprintln!("falling back to remote server restart."); - } - } - } - - if stop_after_install_approved { - stop_remote_server(target, remote_herdr)?; - return Ok(()); - } - - if confirm_remote_server_stop(target, version.as_deref(), protocol, reason)? { - stop_remote_server(target, remote_herdr)?; - } - Ok(()) -} - -fn remote_server_restart_reason( - version: Option<&str>, - protocol: Option, - remote_binary_changed: bool, -) -> Option { - if protocol != Some(CURRENT_PROTOCOL) { - return Some(RemoteServerRestartReason::ProtocolMismatch); - } - if remote_binary_changed { - return Some(RemoteServerRestartReason::BinaryUpdated); - } - if version != Some(current_version().as_str()) { - return Some(RemoteServerRestartReason::VersionMismatch); - } - None -} - -fn confirm_remote_install_with_running_server( - target: &str, - remote_herdr: &RemoteHerdr, - live_handoff_enabled: bool, -) -> io::Result { - let status = match remote_server_status(target, remote_herdr) { - Ok(status) => status, - Err(err) => { - if !io::stdin().is_terminal() { - return Err(io::Error::other(format!( - "could not inspect the running remote herdr server on {target} before installing: {err}; run from an interactive terminal to approve updating the remote binary" - ))); - } - eprintln!( - "could not inspect the running remote herdr server on {target} before installing: {err}" - ); - eprint!("continue installing the remote herdr binary? [y/N] "); - io::stderr().flush()?; - - let mut answer = String::new(); - io::stdin().read_line(&mut answer)?; - let answer = answer.trim().to_ascii_lowercase(); - if answer != "y" && answer != "yes" { - return Err(io::Error::new( - io::ErrorKind::Interrupted, - "remote herdr install cancelled", - )); - } - return Ok(false); - } - }; - let RemoteServerStatus::Running { - version, - protocol: _, - live_handoff, - } = status - else { - return Ok(false); - }; - if !io::stdin().is_terminal() { - if live_handoff_enabled && live_handoff { - return Ok(false); - } - return Err(io::Error::other(format!( - "remote herdr server on {target} is running v{}; run from an interactive terminal to approve stopping it for the update", - version_label(version.as_deref()) - ))); - } - - if live_handoff_enabled && live_handoff { - eprintln!("remote herdr server on {target} is currently running:"); - eprintln!(" server: v{}", version_label(version.as_deref())); - eprintln!( - "Herdr will install {} and hand off live pane processes to the prepared server.", - current_version() - ); - return Ok(false); - } - - eprintln!("remote herdr server on {target} is currently running:"); - eprintln!(" server: v{}", version_label(version.as_deref())); - eprintln!( - "To complete the remote update, Herdr must stop the running remote server after installing." - ); - eprintln!("This stops active remote pane processes, including shells, dev servers, and tests."); - eprintln!(); - eprint!( - "Install {} and stop the remote server now? [y/N] ", - current_version() - ); - io::stderr().flush()?; - - let mut answer = String::new(); - io::stdin().read_line(&mut answer)?; - let answer = answer.trim().to_ascii_lowercase(); - if answer != "y" && answer != "yes" { - return Err(io::Error::new( - io::ErrorKind::Interrupted, - "remote herdr install cancelled", - )); - } - - Ok(true) -} - -fn remote_server_status( - target: &str, - remote_herdr: &RemoteHerdr, -) -> io::Result { - let command = format!("{} status server --json", remote_herdr.shell_path); - let output = ssh_sh_output(target, &command)?; - if !output.status.success() { - return Err(command_failed("remote server status failed", &output)); - } - - let stdout = String::from_utf8_lossy(&output.stdout); - parse_remote_server_status_json(stdout.trim()) -} - -#[derive(Debug, Deserialize)] -struct RemoteClientStatusJson { - protocol: u32, -} - -#[derive(Debug, Deserialize)] -struct RemoteServerStatusJson { - running: bool, - version: Option, - protocol: Option, - capabilities: Option, -} - -#[derive(Debug, Deserialize)] -struct RemoteServerCapabilitiesJson { - live_handoff: bool, -} - -fn parse_client_status_json(status: &str) -> Option { - serde_json::from_str(status).ok() -} - -fn parse_remote_server_status_json(status: &str) -> io::Result { - let parsed: RemoteServerStatusJson = serde_json::from_str(status).map_err(|err| { - io::Error::other(format!( - "could not parse remote server status JSON from `{status}`: {err}" - )) - })?; - if !parsed.running { - return Ok(RemoteServerStatus::NotRunning); - } - - Ok(RemoteServerStatus::Running { - version: parsed.version, - protocol: parsed.protocol, - live_handoff: parsed - .capabilities - .is_some_and(|capabilities| capabilities.live_handoff), - }) -} - -fn confirm_remote_server_stop( - target: &str, - version: Option<&str>, - _protocol: Option, - reason: RemoteServerRestartReason, -) -> io::Result { - if !io::stdin().is_terminal() { - if reason == RemoteServerRestartReason::ProtocolMismatch { - return Err(io::Error::other(format!( - "remote herdr server on {target} must stop before this client can attach; run from an interactive terminal to approve stopping it" - ))); - } - - eprintln!( - "remote herdr server on {target} is still running v{}; it will use {} after it restarts.", - version_label(version), - current_version() - ); - return Ok(false); - } - - eprintln!("remote herdr server on {target} is currently running:"); - eprintln!(" server: v{}", version_label(version)); - eprintln!(" prepared binary: {}", current_version()); - eprintln!(); - - match reason { - RemoteServerRestartReason::ProtocolMismatch => { - eprintln!("the remote server must stop before this client can attach."); - } - RemoteServerRestartReason::BinaryUpdated => { - eprintln!( - "the remote herdr binary was installed or replaced. restart the remote server so it uses the prepared binary." - ); - } - RemoteServerRestartReason::VersionMismatch => { - eprintln!( - "the remote server is still running a different herdr version. restart it so it uses the prepared binary." - ); - } - } - - let prompt = if reason == RemoteServerRestartReason::ProtocolMismatch { - "stop the remote server and continue attaching? [Y/n] " - } else { - "restart the remote server now? [y/N] " - }; - eprint!("{prompt}"); - io::stderr().flush()?; - - let mut answer = String::new(); - io::stdin().read_line(&mut answer)?; - let answer = answer.trim().to_ascii_lowercase(); - if answer == "y" || answer == "yes" { - return Ok(true); - } - if answer.is_empty() && reason == RemoteServerRestartReason::ProtocolMismatch { - return Ok(true); - } - if reason == RemoteServerRestartReason::ProtocolMismatch { - return Err(io::Error::new( - io::ErrorKind::Interrupted, - "remote herdr server stop cancelled", - )); - } - - Ok(false) -} - -fn live_handoff_remote_server(target: &str, remote_herdr: &RemoteHerdr) -> io::Result<()> { - let command = format!( - "{} server live-handoff --import-exe {} --expected-protocol {} --expected-version {}", - remote_herdr.shell_path, - remote_herdr.shell_path, - CURRENT_PROTOCOL, - current_version() - ); - let output = ssh_sh_output(target, &command)?; - if !output.status.success() { - return Err(command_failed("remote server live handoff failed", &output)); - } - - eprintln!( - "handed off the remote herdr server on {target}; reconnecting to the prepared server." - ); - Ok(()) -} - -fn stop_remote_server(target: &str, remote_herdr: &RemoteHerdr) -> io::Result<()> { - let command = format!("{} server stop", remote_herdr.shell_path); - let output = ssh_sh_output(target, &command)?; - if !output.status.success() { - return Err(command_failed("remote server stop failed", &output)); - } - - wait_for_remote_server_shutdown(target, remote_herdr)?; - eprintln!("stopped the remote herdr server on {target}; it will restart when the remote client bridge attaches."); - Ok(()) -} - -fn wait_for_remote_server_shutdown(target: &str, remote_herdr: &RemoteHerdr) -> io::Result<()> { - let deadline = Instant::now() + REMOTE_SERVER_SHUTDOWN_CONFIRM_TIMEOUT; - loop { - if remote_server_status(target, remote_herdr)? == RemoteServerStatus::NotRunning { - return Ok(()); - } - if Instant::now() >= deadline { - return Err(io::Error::new( - io::ErrorKind::TimedOut, - format!( - "shutdown was requested, but the old remote herdr server on {target} is still responding after {} seconds", - REMOTE_SERVER_SHUTDOWN_CONFIRM_TIMEOUT.as_secs() - ), - )); - } - thread::sleep(REMOTE_SERVER_SHUTDOWN_POLL_INTERVAL); - } -} - -fn version_label(version: Option<&str>) -> &str { - version.unwrap_or("unknown") -} - -fn warn_if_remote_bin_not_on_path(target: &str) -> io::Result<()> { - let output = ssh_user_shell_output(target, "command -v herdr")?; - if output.status.success() - && remote_shell_resolves_managed_install(&String::from_utf8_lossy(&output.stdout)) - { - return Ok(()); - } - - eprintln!( - "herdr: installed remote binary to ~/.local/bin/herdr, but the remote shell does not resolve `herdr` to that path" - ); - Ok(()) -} - -fn remote_shell_resolves_managed_install(stdout: &str) -> bool { - stdout - .lines() - .next() - .map(str::trim) - .is_some_and(|path| path.ends_with("/.local/bin/herdr")) -} - -fn download_release_asset(platform: &RemotePlatform) -> io::Result { - let asset_key = platform.asset_key(); - let asset = remote_release_asset(&asset_key)?; - - let dir = private_download_dir(&asset_key)?; - let path = dir.join("herdr.tmp"); - let status = Command::new("curl") - .args(["-sfL", "--max-time", "120", "-o"]) - .arg(&path) - .arg(&asset.url) - .status() - .map_err(|err| io::Error::new(err.kind(), format!("download failed: {err}")))?; - if !status.success() { - let _ = fs::remove_dir_all(&dir); - return Err(io::Error::other("download failed")); - } - if let Some(expected) = &asset.sha256 { - if let Err(err) = crate::checksum::verify_sha256(&path, expected) { - let _ = fs::remove_dir_all(&dir); - return Err(io::Error::new( - err.kind(), - format!("downloaded remote asset checksum verification failed: {err}"), - )); - } - } - - Ok(InstallSource::temporary(path, dir)) -} - -fn fetch_remote_manifest(url: &str) -> io::Result> { - let output = Command::new("curl") - .args([ - "-sfL", - "--retry", - "3", - "--connect-timeout", - "10", - "--max-time", - "20", - url, - ]) - .output() - .map_err(|err| io::Error::new(err.kind(), format!("curl failed: {err}")))?; - if !output.status.success() { - return Err(command_failed("failed to fetch update manifest", &output)); - } - Ok(output.stdout) -} - -fn remote_asset_info(asset: &RemoteAssetRef) -> RemoteReleaseAsset { - RemoteReleaseAsset { - url: asset.url().to_string(), - sha256: asset.sha256().map(str::to_string), - } -} - -fn preview_assets_for_build<'a>( - manifest: &'a RemotePreviewManifest, - build_id: &str, -) -> io::Result<(u32, &'a BTreeMap)> { - if manifest.build_id == build_id { - return Ok((manifest.protocol, &manifest.assets)); - } - let build = manifest.builds.get(build_id).ok_or_else(|| { - io::Error::other(format!( - "preview manifest no longer includes build {build_id}; run `herdr update` locally or set {REMOTE_BINARY_ENV_VAR}=target/release/herdr" - )) - })?; - Ok((build.protocol, &build.assets)) -} - -fn remote_release_asset(asset_key: &str) -> io::Result { - if crate::build_info::is_preview() { - let build_id = crate::build_info::build_id().ok_or_else(|| { - io::Error::other("preview client has no build id; set HERDR_REMOTE_BINARY or install Herdr on the remote manually") - })?; - let manifest_bytes = fetch_remote_manifest(PREVIEW_UPDATE_MANIFEST_URL)?; - let manifest: RemotePreviewManifest = - serde_json::from_slice(&manifest_bytes).map_err(|err| { - io::Error::other(format!("failed to parse preview manifest JSON: {err}")) - })?; - let (protocol, assets) = preview_assets_for_build(&manifest, build_id)?; - if protocol != CURRENT_PROTOCOL { - return Err(io::Error::other(format!( - "preview manifest has build {build_id} protocol {protocol}, but this client needs protocol {CURRENT_PROTOCOL}; set {REMOTE_BINARY_ENV_VAR}=target/release/herdr or install a matching Herdr on the remote host manually" - ))); - } - return assets.get(asset_key).map(remote_asset_info).ok_or_else(|| { - io::Error::other(format!( - "no {asset_key} binary in the preview manifest for build {build_id}" - )) - }); - } - - let current_version = current_version(); - let manifest_bytes = fetch_remote_manifest(STABLE_UPDATE_MANIFEST_URL)?; - let manifest: RemoteUpdateManifest = serde_json::from_slice(&manifest_bytes) - .map_err(|err| io::Error::other(format!("failed to parse update manifest JSON: {err}")))?; - let release = manifest.release_for_version(¤t_version).ok_or_else(|| { - io::Error::other(format!( - "release manifest does not include herdr {current_version}; build herdr for {} or install it there manually", - asset_key - )) - })?; - if let Some(protocol) = release.protocol { - if protocol != CURRENT_PROTOCOL { - return Err(io::Error::other(format!( - "release manifest has herdr {current_version} protocol {protocol}, but this client needs protocol {CURRENT_PROTOCOL}; set {REMOTE_BINARY_ENV_VAR}=target/release/herdr or install a matching herdr on the remote host manually" - ))); - } - } - release - .assets - .get(asset_key) - .map(remote_asset_info) - .ok_or_else(|| { - io::Error::other(format!( - "no {asset_key} binary in the release manifest for herdr {current_version}" - )) - }) -} - -fn private_download_dir(asset_key: &str) -> io::Result { - let base = std::env::temp_dir(); - for attempt in 0..100 { - let dir = base.join(format!( - "herdr-remote-{}-{}-{attempt}", - std::process::id(), - asset_key - )); - match fs::create_dir(&dir) { - Ok(()) => return Ok(dir), - Err(err) if err.kind() == io::ErrorKind::AlreadyExists => continue, - Err(err) => return Err(err), - } - } - - Err(io::Error::new( - io::ErrorKind::AlreadyExists, - "failed to create private herdr remote download directory", +#[cfg(windows)] +pub(crate) fn run_remote(_remote: RemoteLaunch) -> std::io::Result<()> { + debug_assert!(!crate::platform::capabilities().remote_attach); + Err(std::io::Error::other( + "remote mode is not supported on Windows yet", )) } -fn confirm_remote_install( - target: &str, - remote_herdr: &RemoteHerdr, - source_description: &str, -) -> io::Result<()> { - if !io::stdin().is_terminal() { - return Err(io::Error::other(format!( - "matching remote herdr {} is not installed at {}; run from an interactive terminal to approve installation", - current_version(), - remote_herdr.shell_path - ))); - } - - eprintln!( - "matching herdr {} is not installed on {target} for {}.", - current_version(), - remote_herdr.platform.asset_key() - ); - eprint!( - "Install {} to {}? [Y/n] ", - source_description, remote_herdr.shell_path - ); - io::stderr().flush()?; - - let mut answer = String::new(); - io::stdin().read_line(&mut answer)?; - let answer = answer.trim().to_ascii_lowercase(); - if answer == "n" || answer == "no" { - return Err(io::Error::new( - io::ErrorKind::Interrupted, - "remote herdr installation cancelled", - )); - } - - Ok(()) -} - -fn install_remote_herdr( - target: &str, - remote_herdr: &RemoteHerdr, - source_path: &Path, -) -> io::Result<()> { - let script = format!( - r#"dest="$HOME/{install_suffix}" -dir="${{dest%/*}}" -mkdir -p "$dir" -tmp="${{dest}}.tmp.$$" -cat > "$tmp" -chmod 755 "$tmp" -mv "$tmp" "$dest" -"#, - install_suffix = remote_herdr.install_suffix - ); - - let mut child = Command::new("ssh") - .arg("-T") - .arg(target) - .arg(format!("/bin/sh -eu -c {}", shell_quote(&script))) - .stdin(Stdio::piped()) - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()) - .spawn() - .map_err(|err| io::Error::new(err.kind(), format!("failed to start ssh install: {err}")))?; - - let mut source = File::open(source_path)?; - let copy_result = if let Some(mut stdin) = child.stdin.take() { - io::copy(&mut source, &mut stdin).map(|_| ()) - } else { - Err(io::Error::new( - io::ErrorKind::BrokenPipe, - "ssh install stdin missing", - )) - }; - let status = child.wait()?; - copy_result?; - - if status.success() { - Ok(()) - } else { - Err(io::Error::other(format!( - "remote install exited with {status}" - ))) - } -} - -fn ssh_sh_output(target: &str, script: &str) -> io::Result { - // Feed POSIX bootstrap scripts to /bin/sh so the user's login shell only - // has to parse a simple executable invocation. - let mut child = Command::new("ssh") - .arg("-T") - .arg(target) - .arg("/bin/sh -s") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn()?; - - let write_result = if let Some(mut stdin) = child.stdin.take() { - stdin.write_all(script.as_bytes()) - } else { - Err(io::Error::new( - io::ErrorKind::BrokenPipe, - "ssh bootstrap stdin missing", - )) - }; - let output = child.wait_with_output()?; - write_result?; - Ok(output) -} - -fn ssh_user_shell_output(target: &str, command: &str) -> io::Result { - Command::new("ssh") - .arg("-T") - .arg(target) - .arg(command) - .output() -} - -fn remote_bridge_command(remote_herdr: &RemoteHerdr, session_name: &str) -> String { - let mut command = format!("exec {}", remote_herdr.shell_path); - if session_name != crate::session::DEFAULT_SESSION_NAME { - command.push_str(" --session "); - command.push_str(&shell_quote(session_name)); - } - command.push_str(" remote-client-bridge"); - command -} - -fn reattach_command( - program: &str, - target: &str, - session_name: &str, - keybindings: RemoteKeybindings, - live_handoff: bool, -) -> String { - let program = if program.is_empty() { "herdr" } else { program }; - let mut command = format!("{} --remote {}", shell_quote(program), shell_quote(target)); - if keybindings != RemoteKeybindings::Local { - command.push_str(" --remote-keybindings "); - command.push_str(keybindings.as_str()); - } - if live_handoff { - command.push_str(" --handoff"); - } - if session_name != crate::session::DEFAULT_SESSION_NAME { - command.push_str(" --session "); - command.push_str(&shell_quote(session_name)); - } - command -} - -fn shell_quote(value: &str) -> String { - if !value.is_empty() - && value.chars().all(|ch| { - ch.is_ascii_alphanumeric() - || matches!( - ch, - '@' | '%' | '_' | '+' | '=' | ':' | ',' | '.' | '/' | '-' - ) - }) - { - return value.to_string(); - } - - format!("'{}'", value.replace('\'', "'\\''")) -} - -fn command_failed(context: &str, output: &Output) -> io::Error { - let stderr = String::from_utf8_lossy(&output.stderr); - let stderr = stderr.trim(); - if stderr.is_empty() { - io::Error::other(format!("{context}: {}", output.status)) - } else { - io::Error::other(format!("{context}: {stderr}")) - } -} - -struct SshStdioBridge { - local_socket: PathBuf, - keepalive_ssh_config: Option, - should_stop: Arc, - thread: Option>, -} - -impl SshStdioBridge { - fn start( - target: String, - remote_herdr: RemoteHerdr, - local_socket: PathBuf, - session_name: String, - manage_ssh_config: bool, - ) -> io::Result { - let _ = std::fs::remove_file(&local_socket); - let listener = UnixListener::bind(&local_socket)?; - crate::ipc::restrict_socket_permissions(&local_socket, BRIDGE_SOCKET_PERMISSION_MODE)?; - listener.set_nonblocking(true)?; - - let keepalive_ssh_config = if manage_ssh_config { - write_keepalive_ssh_config() - .inspect_err(|err| { - tracing::debug!(%err, "could not write ssh keepalive config; using plain ssh"); - }) - .ok() - } else { - None - }; - - let should_stop = Arc::new(AtomicBool::new(false)); - let thread_stop = Arc::clone(&should_stop); - let thread_ssh_config = keepalive_ssh_config.clone(); - let thread = thread::spawn(move || { - while !thread_stop.load(Ordering::Acquire) { - match listener.accept() { - Ok((stream, _addr)) => { - if let Err(err) = stream.set_nonblocking(false) { - eprintln!( - "herdr: remote bridge failed to prepare client socket: {err}" - ); - continue; - } - if let Err(err) = bridge_connection( - stream, - &target, - &remote_herdr, - &session_name, - thread_ssh_config.as_deref(), - ) { - eprintln!("herdr: remote bridge failed: {err}"); - } - } - Err(err) if err.kind() == io::ErrorKind::WouldBlock => { - thread::sleep(BRIDGE_ACCEPT_POLL); - } - Err(err) => { - eprintln!("herdr: remote bridge listener failed: {err}"); - break; - } - } - } - }); - - Ok(Self { - local_socket, - keepalive_ssh_config, - should_stop, - thread: Some(thread), - }) - } -} - -impl Drop for SshStdioBridge { - fn drop(&mut self) { - self.should_stop.store(true, Ordering::Release); - let _ = std::fs::remove_file(&self.local_socket); - if let Some(thread) = self.thread.take() { - let _ = thread.join(); - } - // Remove the generated ssh config only after the bridge thread has - // joined, so it can never start a connection with a config path that - // was just deleted. - if let Some(dir) = self.keepalive_ssh_config.as_deref().and_then(Path::parent) { - let _ = std::fs::remove_dir_all(dir); - } - } -} - -/// Creates a fresh user-only (`0700`) directory under the temp dir for the -/// bridge's generated ssh config, returning its path. -/// -/// Using a private directory created with fail-if-exists semantics — rather -/// than a predictable file in the world-writable temp dir — stops a local user -/// from pre-planting a symlink or world-writable file that herdr would write -/// and `ssh -F` would then read. -fn private_ssh_config_dir() -> io::Result { - use std::os::unix::fs::DirBuilderExt; - - let base = std::env::temp_dir(); - for attempt in 0..100 { - let dir = base.join(format!("herdr-ssh-{}-{attempt}", std::process::id())); - match fs::DirBuilder::new().mode(0o700).create(&dir) { - Ok(()) => return Ok(dir), - Err(err) if err.kind() == io::ErrorKind::AlreadyExists => continue, - Err(err) => return Err(err), - } - } - - Err(io::Error::new( - io::ErrorKind::AlreadyExists, - "failed to create private herdr ssh config directory", +#[cfg(windows)] +pub(crate) fn run_remote_client_bridge() -> std::io::Result<()> { + debug_assert!(!crate::platform::capabilities().remote_attach); + Err(std::io::Error::other( + "remote client bridge is not supported on Windows yet", )) } - -/// Quotes a path for an ssh_config `Include` so a path containing spaces (or -/// glob metacharacters) is treated as one literal token instead of being split -/// or expanded by ssh — otherwise the user's config might not be Included and -/// herdr's fallback would wrongly take effect. -fn ssh_config_quote(path: &str) -> String { - format!("\"{path}\"") -} - -/// Builds a temporary ssh config that keeps the bridge tunnel alive without -/// overriding the user's own settings, returning its path. -/// -/// The file `Include`s the user's real ssh config first, so ssh's -/// first-value-wins rule keeps any `ServerAlive*` the user set there (including -/// an explicit `0` to disable it); herdr's values apply only when the user has -/// none. -fn write_keepalive_ssh_config() -> io::Result { - use std::os::unix::fs::OpenOptionsExt; - - let path = private_ssh_config_dir()?.join("config"); - - let mut contents = String::new(); - if let Some(home) = std::env::var_os("HOME") { - let user_config = PathBuf::from(home).join(".ssh").join("config"); - if user_config.is_file() { - contents.push_str(&format!( - "Include {}\n", - ssh_config_quote(&user_config.to_string_lossy()) - )); - } - } - if Path::new("/etc/ssh/ssh_config").is_file() { - contents.push_str("Include /etc/ssh/ssh_config\n"); - } - contents.push_str("Host *\n"); - contents.push_str(" ServerAliveInterval 15\n"); - contents.push_str(" ServerAliveCountMax 4\n"); - - let mut file = fs::OpenOptions::new() - .write(true) - .create_new(true) - .mode(BRIDGE_SOCKET_PERMISSION_MODE) - .open(&path)?; - file.write_all(contents.as_bytes())?; - Ok(path) -} - -fn bridge_connection( - stream: UnixStream, - target: &str, - remote_herdr: &RemoteHerdr, - session_name: &str, - keepalive_ssh_config: Option<&Path>, -) -> io::Result<()> { - let mut command = Command::new("ssh"); - // Use the generated keepalive ssh config when present; otherwise plain ssh. - if let Some(ssh_config) = keepalive_ssh_config { - command.arg("-F").arg(ssh_config); - } - command - .arg("-T") - .arg(target) - .arg(remote_bridge_command(remote_herdr, session_name)); - command - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::inherit()); - - let mut child = command - .spawn() - .map_err(|err| io::Error::new(err.kind(), format!("failed to start ssh bridge: {err}")))?; - let mut child_stdin = child - .stdin - .take() - .ok_or_else(|| io::Error::new(io::ErrorKind::BrokenPipe, "ssh bridge stdin missing"))?; - let mut child_stdout = child - .stdout - .take() - .ok_or_else(|| io::Error::new(io::ErrorKind::BrokenPipe, "ssh bridge stdout missing"))?; - let mut stream_to_child = stream.try_clone()?; - let mut child_to_stream = stream; - - let upload = thread::spawn(move || { - let _ = copy_flush(&mut stream_to_child, &mut child_stdin); - }); - let download = thread::spawn(move || { - let _ = copy_flush(&mut child_stdout, &mut child_to_stream); - let _ = child_to_stream.shutdown(std::net::Shutdown::Write); - }); - - let status = child.wait()?; - let _ = upload.join(); - let _ = download.join(); - - if status.success() { - Ok(()) - } else { - Err(io::Error::new( - io::ErrorKind::ConnectionAborted, - format!("ssh bridge exited with {status}"), - )) - } -} - -fn copy_flush(reader: &mut R, writer: &mut W) -> io::Result { - let mut buffer = [0_u8; 16 * 1024]; - let mut total = 0; - - loop { - let bytes_read = match reader.read(&mut buffer) { - Ok(0) => return Ok(total), - Ok(bytes_read) => bytes_read, - Err(err) if err.kind() == io::ErrorKind::Interrupted => continue, - Err(err) => return Err(err), - }; - - writer.write_all(&buffer[..bytes_read])?; - writer.flush()?; - total += bytes_read as u64; - } -} - -fn run_client_process( - local_socket: &Path, - reattach_command: &str, - keybindings: RemoteKeybindings, -) -> io::Result<()> { - let exe = std::env::current_exe()?; - let status = Command::new(exe) - .arg("client") - .env( - crate::server::socket_paths::CLIENT_SOCKET_PATH_ENV_VAR, - local_socket, - ) - .env("HERDR_RENDER_ENCODING", "terminal-ansi") - .env(REATTACH_COMMAND_ENV_VAR, reattach_command) - .env(REMOTE_KEYBINDINGS_ENV_VAR, keybindings.as_str()) - .env_remove(crate::api::SOCKET_PATH_ENV_VAR) - .stdin(Stdio::inherit()) - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()) - .status()?; - - if status.success() { - Ok(()) - } else { - Err(io::Error::new( - io::ErrorKind::Interrupted, - format!("remote client exited with {status}"), - )) - } -} - -fn local_forward_socket_path(target: &str, session_name: &str) -> PathBuf { - let pid = std::process::id(); - let target_clean = sanitize_path_component(target); - let session_clean = sanitize_path_component(session_name); - - let tmpdir = std::env::temp_dir(); - let readable = tmpdir.join(format!( - "herdr-remote-{pid}-{target_clean}-{session_clean}.sock" - )); - if fits_unix_socket_path(&readable) { - return readable; - } - - // macOS' per-user TMPDIR (~49 chars under /var/folders/...) can push the - // readable name past sun_path's 104-byte ceiling. Fall back to a hashed - // short name in TMPDIR, then to /tmp as a last resort when TMPDIR itself - // is longer than the budget. The hash covers the full unsanitized - // target/session so uniqueness does not depend on the prefix truncation; - // the prefix is kept only for debuggability. - let target_prefix: String = target_clean.chars().take(8).collect(); - let hash = short_socket_hash(target, session_name); - let short_name = format!("herdr-r-{pid}-{target_prefix}-{hash}.sock"); - let short_in_tmp = tmpdir.join(&short_name); - if fits_unix_socket_path(&short_in_tmp) { - return short_in_tmp; - } - PathBuf::from("/tmp").join(short_name) -} - -fn fits_unix_socket_path(path: &Path) -> bool { - use std::os::unix::ffi::OsStrExt; - // sun_path is byte-limited: 104 bytes on macOS, 108 on Linux. Reserve - // 1 byte for the trailing NUL and use the smaller cap for portability. - const MAX: usize = 103; - path.as_os_str().as_bytes().len() <= MAX -} - -fn short_socket_hash(target: &str, session: &str) -> String { - use std::collections::hash_map::DefaultHasher; - use std::hash::{Hash, Hasher}; - let mut hasher = DefaultHasher::new(); - target.hash(&mut hasher); - 0u8.hash(&mut hasher); - session.hash(&mut hasher); - format!("{:016x}", hasher.finish()) -} - -fn sanitize_path_component(input: &str) -> String { - let sanitized: String = input - .chars() - .map(|ch| { - if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-') { - ch - } else { - '-' - } - }) - .collect(); - - sanitized.trim_matches('-').chars().take(32).collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn bridge_socket_is_user_only() { - use std::os::unix::fs::PermissionsExt; - - let socket = std::env::temp_dir().join(format!( - "herdr-bridge-permissions-test-{}.sock", - std::process::id() - )); - let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { - os: "linux", - arch: "x86_64", - }); - let bridge = SshStdioBridge::start( - "example".to_string(), - remote_herdr, - socket.clone(), - "default".to_string(), - false, - ) - .expect("start bridge listener"); - - let mode = std::fs::metadata(&socket).unwrap().permissions().mode() & 0o777; - assert_eq!(mode, BRIDGE_SOCKET_PERMISSION_MODE); - - drop(bridge); - let _ = std::fs::remove_file(socket); - } - - #[test] - fn keepalive_ssh_config_includes_user_config_then_fallback() { - use std::os::unix::fs::PermissionsExt; - - let path = write_keepalive_ssh_config().expect("write keepalive config"); - let contents = std::fs::read_to_string(&path).expect("read keepalive config"); - - // herdr's fallback keepalive is present... - assert!( - contents.contains("Host *"), - "config should add a Host * fallback block: {contents}" - ); - assert!( - contents.contains("ServerAliveInterval 15"), - "config should set the keepalive interval: {contents}" - ); - assert!( - contents.contains("ServerAliveCountMax 4"), - "config should set the keepalive count: {contents}" - ); - // ...and any user config is Included (quoted) BEFORE it so first-value-wins - // keeps the user's own settings. - if let Some(home) = std::env::var_os("HOME") { - let user_config = PathBuf::from(home).join(".ssh").join("config"); - if user_config.is_file() { - let include = format!( - "Include {}", - ssh_config_quote(&user_config.to_string_lossy()) - ); - let include_at = contents.find(&include).expect("user config Included"); - let fallback_at = contents.find("Host *").expect("fallback present"); - assert!( - include_at < fallback_at, - "user config must be Included before herdr's fallback: {contents}" - ); - } - } - - let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; - assert_eq!( - mode, BRIDGE_SOCKET_PERMISSION_MODE, - "keepalive config must be user-only" - ); - // The config lives in a private 0700 dir, not a predictable temp path. - let dir = path.parent().expect("config has a parent dir"); - let dir_mode = std::fs::metadata(dir).unwrap().permissions().mode() & 0o777; - assert_eq!(dir_mode, 0o700, "ssh config dir must be user-only"); - - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn ssh_config_quote_wraps_path_with_spaces() { - assert_eq!( - ssh_config_quote("/home/a b/.ssh/config"), - "\"/home/a b/.ssh/config\"" - ); - } - - #[test] - fn extract_remote_args_removes_space_form() { - let args = vec![ - "herdr".into(), - "--remote".into(), - "dev".into(), - "--help".into(), - ]; - let (cleaned, remote) = extract_remote_args(&args).unwrap(); - assert_eq!(cleaned, vec!["herdr", "--help"]); - let remote = remote.unwrap(); - assert_eq!(remote.target, "dev"); - assert_eq!(remote.keybindings, RemoteKeybindings::Local); - } - - #[test] - fn extract_remote_args_removes_equals_form() { - let args = vec!["herdr".into(), "--remote=user@host".into()]; - let (cleaned, remote) = extract_remote_args(&args).unwrap(); - assert_eq!(cleaned, vec!["herdr"]); - let remote = remote.unwrap(); - assert_eq!(remote.target, "user@host"); - assert_eq!(remote.keybindings, RemoteKeybindings::Local); - } - - #[test] - fn extract_remote_args_accepts_remote_keybindings_server() { - let args = vec![ - "herdr".into(), - "--remote".into(), - "dev".into(), - "--remote-keybindings=server".into(), - ]; - let (cleaned, remote) = extract_remote_args(&args).unwrap(); - assert_eq!(cleaned, vec!["herdr"]); - let remote = remote.unwrap(); - assert_eq!(remote.target, "dev"); - assert_eq!(remote.keybindings, RemoteKeybindings::Server); - } - - #[test] - fn extract_remote_args_accepts_remote_keybindings_space_form() { - let args = vec![ - "herdr".into(), - "--remote=dev".into(), - "--remote-keybindings".into(), - "server".into(), - ]; - let (cleaned, remote) = extract_remote_args(&args).unwrap(); - assert_eq!(cleaned, vec!["herdr"]); - assert_eq!(remote.unwrap().keybindings, RemoteKeybindings::Server); - } - - #[test] - fn extract_remote_args_accepts_explicit_handoff() { - let args = vec!["herdr".into(), "--remote=dev".into(), "--handoff".into()]; - - let (cleaned, remote) = extract_remote_args(&args).unwrap(); - - assert_eq!(cleaned, vec!["herdr"]); - let remote = remote.unwrap(); - assert_eq!(remote.target, "dev"); - assert!(remote.live_handoff); - } - - #[test] - fn extract_remote_args_preserves_child_remote_options_after_separator() { - let args = vec![ - "herdr".into(), - "agent".into(), - "start".into(), - "repro".into(), - "--".into(), - "child".into(), - "--remote".into(), - "dev".into(), - "--remote-keybindings=server".into(), - "--handoff".into(), - ]; - - let (cleaned, remote) = extract_remote_args(&args).unwrap(); - - assert_eq!(cleaned, args); - assert!(remote.is_none()); - } - - #[test] - fn extract_remote_args_preserves_handoff_without_remote() { - let args = vec!["herdr".into(), "update".into(), "--handoff".into()]; - - let (cleaned, remote) = extract_remote_args(&args).unwrap(); - - assert_eq!(cleaned, args); - assert!(remote.is_none()); - } - - #[test] - fn extract_remote_args_rejects_remote_keybindings_without_remote() { - let args = vec!["herdr".into(), "--remote-keybindings=server".into()]; - let err = extract_remote_args(&args).unwrap_err(); - assert_eq!(err, "--remote-keybindings requires --remote"); - } - - #[test] - fn extract_remote_args_rejects_duplicate_remote_keybindings() { - let args = vec![ - "herdr".into(), - "--remote=dev".into(), - "--remote-keybindings=local".into(), - "--remote-keybindings=server".into(), - ]; - let err = extract_remote_args(&args).unwrap_err(); - assert_eq!(err, "--remote-keybindings can only be specified once"); - } - - #[test] - fn extract_remote_args_requires_value() { - let args = vec!["herdr".into(), "--remote".into()]; - let err = extract_remote_args(&args).unwrap_err(); - assert_eq!(err, "missing value for --remote"); - } - - #[test] - fn extract_remote_args_rejects_empty_value() { - let args = vec!["herdr".into(), "--remote=".into()]; - let err = extract_remote_args(&args).unwrap_err(); - assert_eq!(err, "missing value for --remote"); - } - - #[test] - fn extract_remote_args_rejects_duplicate_values() { - let args = vec![ - "herdr".into(), - "--remote=dev".into(), - "--remote=prod".into(), - ]; - let err = extract_remote_args(&args).unwrap_err(); - assert_eq!(err, "--remote can only be specified once"); - } - - #[test] - fn extract_remote_args_rejects_option_like_target() { - let args = vec!["herdr".into(), "--remote".into(), "-oProxyCommand=x".into()]; - let err = extract_remote_args(&args).unwrap_err(); - assert_eq!(err, "--remote target must not start with '-'"); - } - - #[test] - fn sanitize_path_component_removes_shell_sensitive_chars() { - assert_eq!(sanitize_path_component("user@host:22"), "user-host-22"); - } - - #[test] - fn remote_platform_maps_uname_values() { - assert_eq!( - RemotePlatform::from_uname("Linux", "amd64") - .unwrap() - .asset_key(), - "linux-x86_64" - ); - assert_eq!( - RemotePlatform::from_uname("Darwin", "arm64") - .unwrap() - .asset_key(), - "macos-aarch64" - ); - assert!(RemotePlatform::from_uname("FreeBSD", "x86_64").is_none()); - } - - #[test] - fn reattach_command_includes_remote_and_session() { - assert_eq!( - reattach_command( - "target/release/herdr", - "user@host", - "work", - RemoteKeybindings::Local, - false, - ), - "target/release/herdr --remote user@host --session work" - ); - assert_eq!( - reattach_command( - "herdr", - "host name", - crate::session::DEFAULT_SESSION_NAME, - RemoteKeybindings::Local, - false, - ), - "herdr --remote 'host name'" - ); - assert_eq!( - reattach_command( - "herdr", - "host", - crate::session::DEFAULT_SESSION_NAME, - RemoteKeybindings::Server, - false, - ), - "herdr --remote host --remote-keybindings server" - ); - assert_eq!( - reattach_command( - "herdr", - "host", - crate::session::DEFAULT_SESSION_NAME, - RemoteKeybindings::Local, - true, - ), - "herdr --remote host --handoff" - ); - } - - #[test] - fn remote_bridge_command_uses_installed_binary() { - let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { - os: "linux", - arch: "x86_64", - }); - assert_eq!( - remote_bridge_command(&remote_herdr, crate::session::DEFAULT_SESSION_NAME), - "exec \"$HOME/.local/bin/herdr\" remote-client-bridge" - ); - } - - #[test] - fn remote_path_discovery_uses_path_binary() { - let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { - os: "linux", - arch: "x86_64", - }); - let remote_herdr = remote_herdr_from_path_discovery(&remote_herdr, "/usr/bin/herdr\n") - .expect("path binary"); - - assert_eq!( - remote_bridge_command(&remote_herdr, crate::session::DEFAULT_SESSION_NAME), - "exec /usr/bin/herdr remote-client-bridge" - ); - } - - #[test] - fn remote_path_discovery_quotes_discovered_binary() { - let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { - os: "linux", - arch: "x86_64", - }); - let remote_herdr = - remote_herdr_from_path_discovery(&remote_herdr, "/opt/herdr bin/herdr\n") - .expect("path binary"); - - assert_eq!( - remote_bridge_command(&remote_herdr, crate::session::DEFAULT_SESSION_NAME), - "exec '/opt/herdr bin/herdr' remote-client-bridge" - ); - } - - #[test] - fn remote_path_discovery_uses_macos_path_binary() { - let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { - os: "macos", - arch: "aarch64", - }); - let remote_herdr = - remote_herdr_from_path_discovery(&remote_herdr, "/opt/homebrew/bin/herdr\n") - .expect("path binary"); - - assert_eq!( - remote_bridge_command(&remote_herdr, crate::session::DEFAULT_SESSION_NAME), - "exec /opt/homebrew/bin/herdr remote-client-bridge" - ); - assert_eq!(remote_herdr.platform.asset_key(), "macos-aarch64"); - } - - #[test] - fn remote_path_discovery_quotes_single_quotes_in_discovered_binary() { - let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { - os: "linux", - arch: "x86_64", - }); - let remote_herdr = - remote_herdr_from_path_discovery(&remote_herdr, "/opt/herdr's/bin/herdr\n") - .expect("path binary"); - - assert_eq!( - remote_bridge_command(&remote_herdr, crate::session::DEFAULT_SESSION_NAME), - "exec '/opt/herdr'\\''s/bin/herdr' remote-client-bridge" - ); - } - - #[test] - fn remote_path_discovery_ignores_relative_paths() { - let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { - os: "linux", - arch: "x86_64", - }); - let remote_herdr = remote_herdr_from_path_discovery(&remote_herdr, "bin/herdr\n"); - - assert!(remote_herdr.is_none()); - } - - #[test] - fn remote_path_discovery_ignores_empty_output() { - let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { - os: "linux", - arch: "x86_64", - }); - let remote_herdr = remote_herdr_from_path_discovery(&remote_herdr, "\n"); - - assert!(remote_herdr.is_none()); - } - - #[test] - fn remote_shell_path_warning_accepts_managed_install() { - assert!(remote_shell_resolves_managed_install( - "/home/can/.local/bin/herdr\n" - )); - assert!(remote_shell_resolves_managed_install( - "/Users/can/.local/bin/herdr\n" - )); - assert!(!remote_shell_resolves_managed_install( - "/usr/local/bin/herdr\n" - )); - assert!(!remote_shell_resolves_managed_install("")); - } - - #[test] - fn parse_client_status_json_reads_protocol() { - assert_eq!( - parse_client_status_json(r#"{"version":"x","protocol":8,"binary":"/bin/herdr"}"#) - .map(|status| status.protocol), - Some(8) - ); - assert!(parse_client_status_json(r#"{"protocol":"unknown"}"#).is_none()); - } - - #[test] - fn parse_remote_server_status_json_reads_running_server() { - assert_eq!( - parse_remote_server_status_json( - r#"{"status":"running","running":true,"version":"0.6.0","protocol":8,"capabilities":{"live_handoff":true}}"# - ) - .unwrap(), - RemoteServerStatus::Running { - version: Some("0.6.0".into()), - protocol: Some(8), - live_handoff: true - } - ); - } - - #[test] - fn parse_remote_server_status_json_treats_missing_capability_as_no_handoff() { - assert_eq!( - parse_remote_server_status_json( - r#"{"status":"running","running":true,"version":"0.6.0","protocol":8}"# - ) - .unwrap(), - RemoteServerStatus::Running { - version: Some("0.6.0".into()), - protocol: Some(8), - live_handoff: false - } - ); - } - - #[test] - fn parse_remote_server_status_json_reads_stopped_server() { - assert_eq!( - parse_remote_server_status_json( - r#"{"status":"not_running","running":false,"version":null,"protocol":null}"# - ) - .unwrap(), - RemoteServerStatus::NotRunning - ); - } - - #[test] - fn remote_update_manifest_uses_root_assets_for_latest_version() { - let manifest: RemoteUpdateManifest = serde_json::from_str( - r#"{ - "version": "1.2.3", - "assets": { - "linux-x86_64": "https://example.com/latest" - }, - "releases": { - "1.2.3": { - "assets": { - "linux-x86_64": "https://example.com/archive" - } - } - } - }"#, - ) - .unwrap(); - - assert_eq!( - manifest - .release_for_version("1.2.3") - .and_then(|release| release.assets.get("linux-x86_64")) - .map(RemoteAssetRef::url), - Some("https://example.com/latest") - ); - } - - #[test] - fn remote_update_manifest_reads_archived_release_assets() { - let manifest: RemoteUpdateManifest = serde_json::from_str( - r#"{ - "version": "1.2.4", - "assets": { - "linux-x86_64": "https://example.com/latest" - }, - "releases": { - "1.2.3": { - "notes": "ignored", - "assets": { - "linux-x86_64": "https://example.com/archive" - } - } - } - }"#, - ) - .unwrap(); - - assert_eq!( - manifest - .release_for_version("1.2.3") - .and_then(|release| release.assets.get("linux-x86_64")) - .map(RemoteAssetRef::url), - Some("https://example.com/archive") - ); - } - - #[test] - fn remote_update_manifest_uses_archived_release_protocol() { - let manifest: RemoteUpdateManifest = serde_json::from_str( - r#"{ - "version": "1.2.4", - "protocol": 42, - "assets": { - "linux-x86_64": "https://example.com/latest" - }, - "releases": { - "1.2.3": { - "notes": "ignored", - "protocol": 41, - "assets": { - "linux-x86_64": "https://example.com/archive" - } - } - } - }"#, - ) - .unwrap(); - - assert_eq!( - manifest - .release_for_version("1.2.3") - .and_then(|release| release.protocol), - Some(41) - ); - } - - #[test] - fn remote_update_manifest_does_not_inherit_latest_protocol_for_archived_assets() { - let manifest: RemoteUpdateManifest = serde_json::from_str( - r#"{ - "version": "1.2.4", - "protocol": 42, - "assets": { - "linux-x86_64": "https://example.com/latest" - }, - "releases": { - "1.2.3": { - "notes": "ignored", - "assets": { - "linux-x86_64": "https://example.com/archive" - } - } - } - }"#, - ) - .unwrap(); - - assert_eq!( - manifest - .release_for_version("1.2.3") - .and_then(|release| release.protocol), - None - ); - } - - #[test] - fn remote_preview_manifest_falls_back_to_archived_exact_build_assets() { - let manifest: RemotePreviewManifest = serde_json::from_str( - r#"{ - "build_id": "2026-06-06-new", - "protocol": 12, - "assets": { - "linux-x86_64": { - "url": "https://example.com/new", - "sha256": "new" - } - }, - "builds": { - "2026-06-02-old": { - "protocol": 11, - "assets": { - "linux-x86_64": { - "url": "https://example.com/old", - "sha256": "old" - } - } - } - } - }"#, - ) - .unwrap(); - - let (protocol, assets) = - preview_assets_for_build(&manifest, "2026-06-02-old").expect("archived build"); - let asset = assets.get("linux-x86_64").expect("asset"); - assert_eq!(protocol, 11); - assert_eq!(asset.url(), "https://example.com/old"); - assert_eq!(asset.sha256(), Some("old")); - } - - #[test] - fn remote_server_restart_reason_requires_stop_for_protocol_mismatch() { - assert_eq!( - remote_server_restart_reason(Some(¤t_version()), Some(0), false), - Some(RemoteServerRestartReason::ProtocolMismatch) - ); - } - - #[test] - fn remote_server_restart_reason_offers_restart_after_binary_update() { - assert_eq!( - remote_server_restart_reason(Some(¤t_version()), Some(CURRENT_PROTOCOL), true), - Some(RemoteServerRestartReason::BinaryUpdated) - ); - } - - #[test] - fn remote_server_restart_reason_offers_restart_for_version_mismatch() { - assert_eq!( - remote_server_restart_reason(Some("0.0.0"), Some(CURRENT_PROTOCOL), false), - Some(RemoteServerRestartReason::VersionMismatch) - ); - assert_eq!( - remote_server_restart_reason(None, Some(CURRENT_PROTOCOL), false), - Some(RemoteServerRestartReason::VersionMismatch) - ); - } - - #[test] - fn remote_server_restart_reason_allows_current_server() { - assert_eq!( - remote_server_restart_reason(Some(¤t_version()), Some(CURRENT_PROTOCOL), false), - None - ); - } - - #[test] - fn install_source_description_uses_override_binary() { - let platform = RemotePlatform { - os: "linux", - arch: "aarch64", - }; - assert_eq!( - install_source_description_for(&platform, Some(Path::new("/tmp/herdr-aarch64")), false), - "HERDR_REMOTE_BINARY (/tmp/herdr-aarch64)" - ); - } - - #[test] - fn install_source_description_uses_local_binary_when_allowed() { - let platform = RemotePlatform::local(); - - assert_eq!( - install_source_description_for(&platform, None, true), - "the current local herdr binary" - ); - } - - #[test] - fn install_source_description_uses_release_asset_when_local_binary_cannot_seed_remote() { - let platform = RemotePlatform::local(); - - assert_eq!( - install_source_description_for(&platform, None, false), - format!( - "the {} {} asset for {}", - current_version(), - current_channel(), - platform.asset_key() - ) - ); - } - - #[test] - fn resolve_install_source_uses_override_binary_without_temporary_cleanup() { - let platform = RemotePlatform { - os: "linux", - arch: "aarch64", - }; - let source = resolve_install_source(&platform, Some(PathBuf::from("/tmp/herdr-aarch64"))) - .expect("override source"); - assert_eq!(source.path, PathBuf::from("/tmp/herdr-aarch64")); - assert!(source.temporary_dir.is_none()); - } - - fn remote_env_lock() -> &'static std::sync::Mutex<()> { - static LOCK: std::sync::OnceLock> = std::sync::OnceLock::new(); - LOCK.get_or_init(|| std::sync::Mutex::new(())) - } - - fn socket_path_byte_len(path: &Path) -> usize { - use std::os::unix::ffi::OsStrExt; - path.as_os_str().as_bytes().len() - } - - #[test] - fn local_forward_socket_path_uses_readable_name_when_it_fits() { - let _guard = remote_env_lock().lock().unwrap(); - // Short target + session leave plenty of room — keep the human- - // readable form so the socket path stays grep-friendly. - let path = local_forward_socket_path("dev", "default"); - let filename = path - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or("") - .to_string(); - assert!( - filename.starts_with("herdr-remote-"), - "expected readable name, got {filename}" - ); - assert!(filename.contains("-dev-default."), "got {filename}"); - assert!( - fits_unix_socket_path(&path), - "socket path too long: {} ({} bytes)", - path.display(), - socket_path_byte_len(&path) - ); - } - - #[test] - fn local_forward_socket_path_fits_in_sun_path() { - let _guard = remote_env_lock().lock().unwrap(); - // Worst case for the readable form: macOS-style 49-char TMPDIR + - // max-length sanitized components. Should fall back to the hashed - // short name, which fits under TMPDIR. - let target = "longish-host.example.com"; - let session = "a-fairly-long-session-name-here"; - let path = local_forward_socket_path(target, session); - assert!( - fits_unix_socket_path(&path), - "socket path too long for sun_path: {} ({} bytes)", - path.display(), - socket_path_byte_len(&path) - ); - } - - #[test] - fn local_forward_socket_path_falls_back_to_tmp_when_dir_is_long() { - let _guard = remote_env_lock().lock().unwrap(); - // Force a TMPDIR long enough that even the hashed short name cannot - // fit inside it. The fallback should drop to /tmp. - let prior = std::env::var_os("TMPDIR"); - let long_dir = std::env::temp_dir().join("a".repeat(80)); - let _ = fs::create_dir_all(&long_dir); - std::env::set_var("TMPDIR", &long_dir); - - let path = local_forward_socket_path("longish-host.example.com", "default"); - let fits = fits_unix_socket_path(&path); - let parent = path.parent().map(Path::to_path_buf); - let filename = path - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or("") - .to_string(); - - match prior { - Some(v) => std::env::set_var("TMPDIR", v), - None => std::env::remove_var("TMPDIR"), - } - let _ = fs::remove_dir_all(&long_dir); - - assert!(fits, "fallback path still overflows: {}", path.display()); - assert_eq!(parent.as_deref(), Some(Path::new("/tmp"))); - assert!( - filename.starts_with("herdr-r-"), - "expected hashed fallback, got {filename}" - ); - } - - #[test] - fn install_source_cleanup_removes_temporary_directory() { - let dir = std::env::temp_dir().join(format!( - "herdr-install-source-cleanup-test-{}", - std::process::id() - )); - let _ = fs::remove_dir_all(&dir); - fs::create_dir(&dir).expect("create temp dir"); - let path = dir.join("herdr.tmp"); - fs::write(&path, b"test").expect("write temp file"); - - InstallSource::temporary(path, dir.clone()).cleanup(); - - assert!(!dir.exists()); - } -} diff --git a/src/remote/unix.rs b/src/remote/unix.rs new file mode 100644 index 00000000..ce56ba8a --- /dev/null +++ b/src/remote/unix.rs @@ -0,0 +1,2496 @@ +//! Remote thin-client launcher over SSH command stdio. + +use std::collections::BTreeMap; +use std::fs::{self, File}; +use std::io::{self, IsTerminal, Write as _}; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; + +use serde::Deserialize; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +const BRIDGE_ACCEPT_POLL: Duration = Duration::from_millis(50); +const BRIDGE_SOCKET_PERMISSION_MODE: u32 = 0o600; +const REMOTE_SERVER_SHUTDOWN_CONFIRM_TIMEOUT: Duration = Duration::from_secs(5); +const REMOTE_SERVER_SHUTDOWN_POLL_INTERVAL: Duration = Duration::from_millis(100); +const CURRENT_PROTOCOL: u32 = crate::protocol::PROTOCOL_VERSION; +const STABLE_UPDATE_MANIFEST_URL: &str = "https://herdr.dev/latest.json"; +const PREVIEW_UPDATE_MANIFEST_URL: &str = "https://herdr.dev/preview.json"; +const REMOTE_BINARY_ENV_VAR: &str = "HERDR_REMOTE_BINARY"; +pub(crate) const REATTACH_COMMAND_ENV_VAR: &str = "HERDR_REATTACH_COMMAND"; + +pub(crate) const REMOTE_KEYBINDINGS_ENV_VAR: &str = "HERDR_REMOTE_KEYBINDINGS"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RemoteKeybindings { + Local, + Server, +} + +impl RemoteKeybindings { + fn parse(value: &str) -> Result { + match value { + "local" => Ok(Self::Local), + "server" => Ok(Self::Server), + _ => Err("--remote-keybindings must be 'local' or 'server'".to_string()), + } + } + + fn as_str(self) -> &'static str { + match self { + Self::Local => "local", + Self::Server => "server", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct RemoteLaunch { + pub(crate) target: String, + pub(crate) keybindings: RemoteKeybindings, + pub(crate) live_handoff: bool, +} + +pub(crate) fn extract_remote_args( + args: &[String], +) -> Result<(Vec, Option), String> { + let mut cleaned = Vec::with_capacity(args.len()); + if let Some(program) = args.first() { + cleaned.push(program.clone()); + } + + let mut remote_target = None; + let mut keybindings = RemoteKeybindings::Local; + let mut keybindings_seen = false; + let mut live_handoff = false; + let mut index = 1; + while index < args.len() { + let arg = &args[index]; + if arg == "--" { + cleaned.extend_from_slice(&args[index..]); + break; + } + if arg == "--handoff" { + live_handoff = true; + index += 1; + continue; + } + if arg == "--remote" { + if remote_target.is_some() { + return Err("--remote can only be specified once".to_string()); + } + let Some(value) = args.get(index + 1) else { + return Err("missing value for --remote".to_string()); + }; + remote_target = Some(validate_remote_target(value)?.to_owned()); + index += 2; + continue; + } + if let Some(value) = arg.strip_prefix("--remote=") { + if remote_target.is_some() { + return Err("--remote can only be specified once".to_string()); + } + remote_target = Some(validate_remote_target(value)?.to_owned()); + index += 1; + continue; + } + if arg == "--remote-keybindings" { + if keybindings_seen { + return Err("--remote-keybindings can only be specified once".to_string()); + } + let Some(value) = args.get(index + 1) else { + return Err("missing value for --remote-keybindings".to_string()); + }; + keybindings = RemoteKeybindings::parse(value)?; + keybindings_seen = true; + index += 2; + continue; + } + if let Some(value) = arg.strip_prefix("--remote-keybindings=") { + if keybindings_seen { + return Err("--remote-keybindings can only be specified once".to_string()); + } + keybindings = RemoteKeybindings::parse(value)?; + keybindings_seen = true; + index += 1; + continue; + } + + cleaned.push(arg.clone()); + index += 1; + } + + let remote = remote_target.map(|target| RemoteLaunch { + target, + keybindings, + live_handoff, + }); + if remote.is_none() && keybindings_seen { + return Err("--remote-keybindings requires --remote".to_string()); + } + if remote.is_none() && live_handoff { + cleaned.push("--handoff".to_string()); + } + + Ok((cleaned, remote)) +} + +fn validate_remote_target(target: &str) -> Result<&str, String> { + if target.is_empty() { + return Err("missing value for --remote".to_string()); + } + if target.starts_with('-') { + return Err("--remote target must not start with '-'".to_string()); + } + Ok(target) +} + +pub(crate) fn run_remote(remote: RemoteLaunch) -> io::Result<()> { + let session_name = crate::session::active_name() + .unwrap_or_else(|| crate::session::DEFAULT_SESSION_NAME.to_string()); + let local_socket = local_forward_socket_path(&remote.target, &session_name); + let program = std::env::args() + .next() + .unwrap_or_else(|| "herdr".to_string()); + let reattach_command = reattach_command( + &program, + &remote.target, + &session_name, + remote.keybindings, + remote.live_handoff, + ); + let prepared_remote = prepare_remote_herdr(&remote.target, remote.live_handoff)?; + ensure_remote_server_ready( + &remote.target, + &prepared_remote.remote_herdr, + prepared_remote.installed_or_replaced, + prepared_remote.stop_after_install_approved, + remote.live_handoff, + )?; + + let manage_ssh_config = crate::config::Config::load() + .config + .remote + .manage_ssh_config; + let _bridge = SshStdioBridge::start( + remote.target, + prepared_remote.remote_herdr, + local_socket.clone(), + session_name, + manage_ssh_config, + )?; + + run_client_process(&local_socket, &reattach_command, remote.keybindings) +} + +pub(crate) fn run_remote_client_bridge() -> io::Result<()> { + ensure_remote_server_running()?; + + let socket_path = crate::server::socket_paths::client_socket_path(); + let stream = UnixStream::connect(&socket_path).map_err(|err| { + io::Error::new( + err.kind(), + format!( + "failed to connect to remote Herdr client socket {}: {err}", + socket_path.display() + ), + ) + })?; + + let mut stdout = io::stdout().lock(); + let mut socket_to_stdout = stream.try_clone()?; + let mut stdin_to_socket = stream; + + let _upload = thread::spawn(move || { + let mut stdin = io::stdin(); + let _ = copy_flush(&mut stdin, &mut stdin_to_socket); + let _ = stdin_to_socket.shutdown(std::net::Shutdown::Write); + }); + + copy_flush(&mut socket_to_stdout, &mut stdout).map(|_| ()) +} + +fn ensure_remote_server_running() -> io::Result<()> { + let socket_path = crate::server::socket_paths::client_socket_path(); + if crate::server::autodetect::is_server_listening() { + let status = crate::api::read_runtime_status_at( + &crate::api::socket_path(), + Duration::from_millis(500), + )? + .ok_or_else(|| io::Error::other("remote server status API is unavailable"))?; + if status.protocol == Some(CURRENT_PROTOCOL) { + return Ok(()); + } + return Err(io::Error::other( + "remote herdr server must restart before this bridge can attach; rerun `herdr --remote` from an interactive terminal to approve stopping it", + )); + } + + crate::server::autodetect::spawn_server_daemon()?; + crate::server::autodetect::wait_for_server_socket(&socket_path, Duration::from_secs(5)) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct RemotePlatform { + os: &'static str, + arch: &'static str, +} + +impl RemotePlatform { + fn from_uname(os: &str, arch: &str) -> Option { + let os = match os.trim() { + "Linux" => "linux", + "Darwin" => "macos", + _ => return None, + }; + let arch = match arch.trim() { + "x86_64" | "amd64" => "x86_64", + "aarch64" | "arm64" => "aarch64", + _ => return None, + }; + Some(Self { os, arch }) + } + + fn local() -> Self { + let os = if cfg!(target_os = "linux") { + "linux" + } else if cfg!(target_os = "macos") { + "macos" + } else { + "unknown" + }; + + let arch = if cfg!(target_arch = "x86_64") { + "x86_64" + } else if cfg!(target_arch = "aarch64") { + "aarch64" + } else { + "unknown" + }; + + Self { os, arch } + } + + fn asset_key(&self) -> String { + format!("{}-{}", self.os, self.arch) + } +} + +#[derive(Debug, Clone)] +struct RemoteHerdr { + install_suffix: String, + shell_path: String, + platform: RemotePlatform, +} + +impl RemoteHerdr { + fn for_platform(platform: RemotePlatform) -> Self { + let install_suffix = ".local/bin/herdr".to_string(); + let shell_path = format!("\"$HOME/{install_suffix}\""); + Self { + install_suffix, + shell_path, + platform, + } + } + + fn with_shell_path(mut self, shell_path: String) -> Self { + self.shell_path = shell_path; + self + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +enum RemoteAssetRef { + Url(String), + Object { url: String, sha256: Option }, +} + +impl RemoteAssetRef { + fn url(&self) -> &str { + match self { + Self::Url(url) => url, + Self::Object { url, .. } => url, + } + } + + fn sha256(&self) -> Option<&str> { + match self { + Self::Url(_) => None, + Self::Object { sha256, .. } => { + sha256.as_deref().filter(|value| !value.trim().is_empty()) + } + } + } +} + +#[derive(Deserialize)] +struct RemoteUpdateManifest { + version: String, + protocol: Option, + assets: BTreeMap, + #[serde(default, deserialize_with = "deserialize_remote_manifest_releases")] + releases: BTreeMap, +} + +#[derive(Deserialize)] +struct RemoteReleaseMetadata { + protocol: Option, + #[serde(default)] + assets: BTreeMap, +} + +#[derive(Deserialize)] +struct RemotePreviewManifest { + build_id: String, + protocol: u32, + assets: BTreeMap, + #[serde(default)] + builds: BTreeMap, +} + +#[derive(Deserialize)] +struct RemotePreviewBuildMetadata { + protocol: u32, + assets: BTreeMap, +} + +fn deserialize_remote_manifest_releases<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + Ok(match value { + Some(serde_json::Value::Object(object)) => object + .into_iter() + .filter_map(|(version, release)| { + serde_json::from_value::(release) + .ok() + .map(|metadata| (version, metadata)) + }) + .collect(), + _ => BTreeMap::new(), + }) +} + +impl RemoteUpdateManifest { + fn release_for_version(&self, version: &str) -> Option> { + if self.version.trim_start_matches('v') == version { + return Some(RemoteManifestReleaseRef { + protocol: self.protocol, + assets: &self.assets, + }); + } + + self.releases.get(version).and_then(|release| { + (!release.assets.is_empty()).then_some(RemoteManifestReleaseRef { + protocol: release.protocol, + assets: &release.assets, + }) + }) + } +} + +#[derive(Clone, Copy)] +struct RemoteManifestReleaseRef<'a> { + protocol: Option, + assets: &'a BTreeMap, +} + +fn current_version() -> String { + crate::build_info::version() +} + +fn current_channel() -> &'static str { + crate::build_info::channel() +} + +struct InstallSource { + path: PathBuf, + temporary_dir: Option, +} + +struct RemoteReleaseAsset { + url: String, + sha256: Option, +} + +struct PreparedRemoteHerdr { + remote_herdr: RemoteHerdr, + installed_or_replaced: bool, + stop_after_install_approved: bool, +} + +impl InstallSource { + fn persistent(path: PathBuf) -> Self { + Self { + path, + temporary_dir: None, + } + } + + fn temporary(path: PathBuf, temporary_dir: PathBuf) -> Self { + Self { + path, + temporary_dir: Some(temporary_dir), + } + } + + fn cleanup(&self) { + if let Some(dir) = &self.temporary_dir { + let _ = fs::remove_dir_all(dir); + } + } +} + +fn prepare_remote_herdr( + target: &str, + live_handoff_enabled: bool, +) -> io::Result { + let platform = detect_remote_platform(target)?; + let remote_herdr = RemoteHerdr::for_platform(platform); + let override_binary = remote_binary_override_path()?; + let path_remote_herdr = remote_binary_on_path_any(target, &remote_herdr)?; + + if override_binary.is_none() { + if let Some(path_remote_herdr) = path_remote_herdr + .as_ref() + .filter(|candidate| remote_binary_matches(target, candidate).unwrap_or(false)) + { + return Ok(PreparedRemoteHerdr { + remote_herdr: path_remote_herdr.clone(), + installed_or_replaced: false, + stop_after_install_approved: false, + }); + } + if remote_binary_matches(target, &remote_herdr)? { + return Ok(PreparedRemoteHerdr { + remote_herdr, + installed_or_replaced: false, + stop_after_install_approved: false, + }); + } + } + + let mut stop_after_install_approved = false; + if let Some(status_probe_herdr) = path_remote_herdr.as_ref().or_else(|| { + remote_binary_exists(target, &remote_herdr) + .ok() + .and_then(|exists| exists.then_some(&remote_herdr)) + }) { + stop_after_install_approved = confirm_remote_install_with_running_server( + target, + status_probe_herdr, + live_handoff_enabled, + )?; + } + confirm_remote_install( + target, + &remote_herdr, + &install_source_description(&remote_herdr.platform, override_binary.as_deref()), + )?; + let source = resolve_install_source(&remote_herdr.platform, override_binary)?; + let install_result = install_remote_herdr(target, &remote_herdr, &source.path); + source.cleanup(); + install_result?; + + if !remote_binary_matches(target, &remote_herdr)? { + return Err(io::Error::other(format!( + "installed remote herdr at {}, but it did not report version {}", + remote_herdr.shell_path, + current_version() + ))); + } + warn_if_remote_bin_not_on_path(target)?; + + Ok(PreparedRemoteHerdr { + remote_herdr, + installed_or_replaced: true, + stop_after_install_approved, + }) +} + +fn detect_remote_platform(target: &str) -> io::Result { + let output = ssh_sh_output(target, "uname -s\nuname -m\n")?; + if !output.status.success() { + return Err(command_failed("remote platform detection failed", &output)); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let mut lines = stdout.lines(); + let os = lines.next().unwrap_or_default(); + let arch = lines.next().unwrap_or_default(); + RemotePlatform::from_uname(os, arch).ok_or_else(|| { + io::Error::other(format!( + "unsupported remote platform: {} {}", + os.trim(), + arch.trim() + )) + }) +} + +fn remote_binary_on_path_any( + target: &str, + remote_herdr: &RemoteHerdr, +) -> io::Result> { + let output = ssh_user_shell_output(target, "command -v herdr")?; + if !output.status.success() { + return Ok(None); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + Ok(remote_herdr_from_path_discovery(remote_herdr, &stdout)) +} + +fn remote_herdr_from_path_discovery( + remote_herdr: &RemoteHerdr, + stdout: &str, +) -> Option { + let mut lines = stdout.lines(); + let path = lines.next()?; + if !path.starts_with('/') { + return None; + } + Some(remote_herdr.clone().with_shell_path(shell_quote(path))) +} + +fn remote_binary_matches(target: &str, remote_herdr: &RemoteHerdr) -> io::Result { + let command = format!( + "test -x {0} && {0} --version && {0} status client --json", + remote_herdr.shell_path + ); + let output = ssh_sh_output(target, &command)?; + if !output.status.success() { + return Ok(false); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let mut lines = stdout.lines(); + let version = lines.next().unwrap_or_default().trim(); + let status = lines.next().unwrap_or_default(); + Ok(version == format!("herdr {}", current_version()) + && parse_client_status_json(status) + .map(|status| status.protocol == CURRENT_PROTOCOL) + .unwrap_or(false)) +} + +fn remote_binary_exists(target: &str, remote_herdr: &RemoteHerdr) -> io::Result { + let command = format!("test -x {}", remote_herdr.shell_path); + Ok(ssh_sh_output(target, &command)?.status.success()) +} + +fn remote_binary_override_path() -> io::Result> { + let Some(value) = std::env::var_os(REMOTE_BINARY_ENV_VAR) else { + return Ok(None); + }; + if value.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("{REMOTE_BINARY_ENV_VAR} must not be empty"), + )); + } + + let path = PathBuf::from(value); + let metadata = fs::metadata(&path).map_err(|err| { + io::Error::new( + err.kind(), + format!( + "failed to inspect {REMOTE_BINARY_ENV_VAR} path {}: {err}", + path.display() + ), + ) + })?; + if !metadata.is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "{REMOTE_BINARY_ENV_VAR} path is not a file: {}", + path.display() + ), + )); + } + + Ok(Some(path)) +} + +fn install_source_description(platform: &RemotePlatform, override_binary: Option<&Path>) -> String { + install_source_description_for( + platform, + override_binary, + local_binary_can_seed_remote(platform), + ) +} + +fn install_source_description_for( + platform: &RemotePlatform, + override_binary: Option<&Path>, + local_binary_can_seed_remote: bool, +) -> String { + if let Some(path) = override_binary { + return format!("{REMOTE_BINARY_ENV_VAR} ({})", path.display()); + } + + if local_binary_can_seed_remote { + "the current local herdr binary".to_string() + } else { + format!( + "the {} {} asset for {}", + current_version(), + current_channel(), + platform.asset_key() + ) + } +} + +fn resolve_install_source( + platform: &RemotePlatform, + override_binary: Option, +) -> io::Result { + if let Some(path) = override_binary { + return Ok(InstallSource::persistent(path)); + } + + if *platform == RemotePlatform::local() { + let path = std::env::current_exe()?; + if !crate::update::is_package_manager_managed_exe_path(&path) { + return Ok(InstallSource::persistent(path)); + } + } + + download_release_asset(platform) +} + +fn local_binary_can_seed_remote(platform: &RemotePlatform) -> bool { + if *platform != RemotePlatform::local() { + return false; + } + + std::env::current_exe() + .map(|path| !crate::update::is_package_manager_managed_exe_path(&path)) + .unwrap_or(false) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum RemoteServerStatus { + Running { + version: Option, + protocol: Option, + live_handoff: bool, + }, + NotRunning, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RemoteServerRestartReason { + ProtocolMismatch, + BinaryUpdated, + VersionMismatch, +} + +fn ensure_remote_server_ready( + target: &str, + remote_herdr: &RemoteHerdr, + remote_binary_changed: bool, + stop_after_install_approved: bool, + live_handoff_enabled: bool, +) -> io::Result<()> { + let status = remote_server_status(target, remote_herdr)?; + let RemoteServerStatus::Running { + version, + protocol, + live_handoff, + } = status + else { + return Ok(()); + }; + + let Some(reason) = + remote_server_restart_reason(version.as_deref(), protocol, remote_binary_changed) + else { + return Ok(()); + }; + + if live_handoff_enabled && live_handoff { + match live_handoff_remote_server(target, remote_herdr) { + Ok(()) => return Ok(()), + Err(err) => { + eprintln!("remote live handoff failed: {err}"); + eprintln!("falling back to remote server restart."); + } + } + } + + if stop_after_install_approved { + stop_remote_server(target, remote_herdr)?; + return Ok(()); + } + + if confirm_remote_server_stop(target, version.as_deref(), protocol, reason)? { + stop_remote_server(target, remote_herdr)?; + } + Ok(()) +} + +fn remote_server_restart_reason( + version: Option<&str>, + protocol: Option, + remote_binary_changed: bool, +) -> Option { + if protocol != Some(CURRENT_PROTOCOL) { + return Some(RemoteServerRestartReason::ProtocolMismatch); + } + if remote_binary_changed { + return Some(RemoteServerRestartReason::BinaryUpdated); + } + if version != Some(current_version().as_str()) { + return Some(RemoteServerRestartReason::VersionMismatch); + } + None +} + +fn confirm_remote_install_with_running_server( + target: &str, + remote_herdr: &RemoteHerdr, + live_handoff_enabled: bool, +) -> io::Result { + let status = match remote_server_status(target, remote_herdr) { + Ok(status) => status, + Err(err) => { + if !io::stdin().is_terminal() { + return Err(io::Error::other(format!( + "could not inspect the running remote herdr server on {target} before installing: {err}; run from an interactive terminal to approve updating the remote binary" + ))); + } + eprintln!( + "could not inspect the running remote herdr server on {target} before installing: {err}" + ); + eprint!("continue installing the remote herdr binary? [y/N] "); + io::stderr().flush()?; + + let mut answer = String::new(); + io::stdin().read_line(&mut answer)?; + let answer = answer.trim().to_ascii_lowercase(); + if answer != "y" && answer != "yes" { + return Err(io::Error::new( + io::ErrorKind::Interrupted, + "remote herdr install cancelled", + )); + } + return Ok(false); + } + }; + let RemoteServerStatus::Running { + version, + protocol: _, + live_handoff, + } = status + else { + return Ok(false); + }; + if !io::stdin().is_terminal() { + if live_handoff_enabled && live_handoff { + return Ok(false); + } + return Err(io::Error::other(format!( + "remote herdr server on {target} is running v{}; run from an interactive terminal to approve stopping it for the update", + version_label(version.as_deref()) + ))); + } + + if live_handoff_enabled && live_handoff { + eprintln!("remote herdr server on {target} is currently running:"); + eprintln!(" server: v{}", version_label(version.as_deref())); + eprintln!( + "Herdr will install {} and hand off live pane processes to the prepared server.", + current_version() + ); + return Ok(false); + } + + eprintln!("remote herdr server on {target} is currently running:"); + eprintln!(" server: v{}", version_label(version.as_deref())); + eprintln!( + "To complete the remote update, Herdr must stop the running remote server after installing." + ); + eprintln!("This stops active remote pane processes, including shells, dev servers, and tests."); + eprintln!(); + eprint!( + "Install {} and stop the remote server now? [y/N] ", + current_version() + ); + io::stderr().flush()?; + + let mut answer = String::new(); + io::stdin().read_line(&mut answer)?; + let answer = answer.trim().to_ascii_lowercase(); + if answer != "y" && answer != "yes" { + return Err(io::Error::new( + io::ErrorKind::Interrupted, + "remote herdr install cancelled", + )); + } + + Ok(true) +} + +fn remote_server_status( + target: &str, + remote_herdr: &RemoteHerdr, +) -> io::Result { + let command = format!("{} status server --json", remote_herdr.shell_path); + let output = ssh_sh_output(target, &command)?; + if !output.status.success() { + return Err(command_failed("remote server status failed", &output)); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + parse_remote_server_status_json(stdout.trim()) +} + +#[derive(Debug, Deserialize)] +struct RemoteClientStatusJson { + protocol: u32, +} + +#[derive(Debug, Deserialize)] +struct RemoteServerStatusJson { + running: bool, + version: Option, + protocol: Option, + capabilities: Option, +} + +#[derive(Debug, Deserialize)] +struct RemoteServerCapabilitiesJson { + live_handoff: bool, +} + +fn parse_client_status_json(status: &str) -> Option { + serde_json::from_str(status).ok() +} + +fn parse_remote_server_status_json(status: &str) -> io::Result { + let parsed: RemoteServerStatusJson = serde_json::from_str(status).map_err(|err| { + io::Error::other(format!( + "could not parse remote server status JSON from `{status}`: {err}" + )) + })?; + if !parsed.running { + return Ok(RemoteServerStatus::NotRunning); + } + + Ok(RemoteServerStatus::Running { + version: parsed.version, + protocol: parsed.protocol, + live_handoff: parsed + .capabilities + .is_some_and(|capabilities| capabilities.live_handoff), + }) +} + +fn confirm_remote_server_stop( + target: &str, + version: Option<&str>, + _protocol: Option, + reason: RemoteServerRestartReason, +) -> io::Result { + if !io::stdin().is_terminal() { + if reason == RemoteServerRestartReason::ProtocolMismatch { + return Err(io::Error::other(format!( + "remote herdr server on {target} must stop before this client can attach; run from an interactive terminal to approve stopping it" + ))); + } + + eprintln!( + "remote herdr server on {target} is still running v{}; it will use {} after it restarts.", + version_label(version), + current_version() + ); + return Ok(false); + } + + eprintln!("remote herdr server on {target} is currently running:"); + eprintln!(" server: v{}", version_label(version)); + eprintln!(" prepared binary: {}", current_version()); + eprintln!(); + + match reason { + RemoteServerRestartReason::ProtocolMismatch => { + eprintln!("the remote server must stop before this client can attach."); + } + RemoteServerRestartReason::BinaryUpdated => { + eprintln!( + "the remote herdr binary was installed or replaced. restart the remote server so it uses the prepared binary." + ); + } + RemoteServerRestartReason::VersionMismatch => { + eprintln!( + "the remote server is still running a different herdr version. restart it so it uses the prepared binary." + ); + } + } + + let prompt = if reason == RemoteServerRestartReason::ProtocolMismatch { + "stop the remote server and continue attaching? [Y/n] " + } else { + "restart the remote server now? [y/N] " + }; + eprint!("{prompt}"); + io::stderr().flush()?; + + let mut answer = String::new(); + io::stdin().read_line(&mut answer)?; + let answer = answer.trim().to_ascii_lowercase(); + if answer == "y" || answer == "yes" { + return Ok(true); + } + if answer.is_empty() && reason == RemoteServerRestartReason::ProtocolMismatch { + return Ok(true); + } + if reason == RemoteServerRestartReason::ProtocolMismatch { + return Err(io::Error::new( + io::ErrorKind::Interrupted, + "remote herdr server stop cancelled", + )); + } + + Ok(false) +} + +fn live_handoff_remote_server(target: &str, remote_herdr: &RemoteHerdr) -> io::Result<()> { + let command = format!( + "{} server live-handoff --import-exe {} --expected-protocol {} --expected-version {}", + remote_herdr.shell_path, + remote_herdr.shell_path, + CURRENT_PROTOCOL, + current_version() + ); + let output = ssh_sh_output(target, &command)?; + if !output.status.success() { + return Err(command_failed("remote server live handoff failed", &output)); + } + + eprintln!( + "handed off the remote herdr server on {target}; reconnecting to the prepared server." + ); + Ok(()) +} + +fn stop_remote_server(target: &str, remote_herdr: &RemoteHerdr) -> io::Result<()> { + let command = format!("{} server stop", remote_herdr.shell_path); + let output = ssh_sh_output(target, &command)?; + if !output.status.success() { + return Err(command_failed("remote server stop failed", &output)); + } + + wait_for_remote_server_shutdown(target, remote_herdr)?; + eprintln!("stopped the remote herdr server on {target}; it will restart when the remote client bridge attaches."); + Ok(()) +} + +fn wait_for_remote_server_shutdown(target: &str, remote_herdr: &RemoteHerdr) -> io::Result<()> { + let deadline = Instant::now() + REMOTE_SERVER_SHUTDOWN_CONFIRM_TIMEOUT; + loop { + if remote_server_status(target, remote_herdr)? == RemoteServerStatus::NotRunning { + return Ok(()); + } + if Instant::now() >= deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + format!( + "shutdown was requested, but the old remote herdr server on {target} is still responding after {} seconds", + REMOTE_SERVER_SHUTDOWN_CONFIRM_TIMEOUT.as_secs() + ), + )); + } + thread::sleep(REMOTE_SERVER_SHUTDOWN_POLL_INTERVAL); + } +} + +fn version_label(version: Option<&str>) -> &str { + version.unwrap_or("unknown") +} + +fn warn_if_remote_bin_not_on_path(target: &str) -> io::Result<()> { + let output = ssh_user_shell_output(target, "command -v herdr")?; + if output.status.success() + && remote_shell_resolves_managed_install(&String::from_utf8_lossy(&output.stdout)) + { + return Ok(()); + } + + eprintln!( + "herdr: installed remote binary to ~/.local/bin/herdr, but the remote shell does not resolve `herdr` to that path" + ); + Ok(()) +} + +fn remote_shell_resolves_managed_install(stdout: &str) -> bool { + stdout + .lines() + .next() + .map(str::trim) + .is_some_and(|path| path.ends_with("/.local/bin/herdr")) +} + +fn download_release_asset(platform: &RemotePlatform) -> io::Result { + let asset_key = platform.asset_key(); + let asset = remote_release_asset(&asset_key)?; + + let dir = private_download_dir(&asset_key)?; + let path = dir.join("herdr.tmp"); + let status = Command::new("curl") + .args(["-sfL", "--max-time", "120", "-o"]) + .arg(&path) + .arg(&asset.url) + .status() + .map_err(|err| io::Error::new(err.kind(), format!("download failed: {err}")))?; + if !status.success() { + let _ = fs::remove_dir_all(&dir); + return Err(io::Error::other("download failed")); + } + if let Some(expected) = &asset.sha256 { + if let Err(err) = crate::checksum::verify_sha256(&path, expected) { + let _ = fs::remove_dir_all(&dir); + return Err(io::Error::new( + err.kind(), + format!("downloaded remote asset checksum verification failed: {err}"), + )); + } + } + + Ok(InstallSource::temporary(path, dir)) +} + +fn fetch_remote_manifest(url: &str) -> io::Result> { + let output = Command::new("curl") + .args([ + "-sfL", + "--retry", + "3", + "--connect-timeout", + "10", + "--max-time", + "20", + url, + ]) + .output() + .map_err(|err| io::Error::new(err.kind(), format!("curl failed: {err}")))?; + if !output.status.success() { + return Err(command_failed("failed to fetch update manifest", &output)); + } + Ok(output.stdout) +} + +fn remote_asset_info(asset: &RemoteAssetRef) -> RemoteReleaseAsset { + RemoteReleaseAsset { + url: asset.url().to_string(), + sha256: asset.sha256().map(str::to_string), + } +} + +fn preview_assets_for_build<'a>( + manifest: &'a RemotePreviewManifest, + build_id: &str, +) -> io::Result<(u32, &'a BTreeMap)> { + if manifest.build_id == build_id { + return Ok((manifest.protocol, &manifest.assets)); + } + let build = manifest.builds.get(build_id).ok_or_else(|| { + io::Error::other(format!( + "preview manifest no longer includes build {build_id}; run `herdr update` locally or set {REMOTE_BINARY_ENV_VAR}=target/release/herdr" + )) + })?; + Ok((build.protocol, &build.assets)) +} + +fn remote_release_asset(asset_key: &str) -> io::Result { + if crate::build_info::is_preview() { + let build_id = crate::build_info::build_id().ok_or_else(|| { + io::Error::other("preview client has no build id; set HERDR_REMOTE_BINARY or install Herdr on the remote manually") + })?; + let manifest_bytes = fetch_remote_manifest(PREVIEW_UPDATE_MANIFEST_URL)?; + let manifest: RemotePreviewManifest = + serde_json::from_slice(&manifest_bytes).map_err(|err| { + io::Error::other(format!("failed to parse preview manifest JSON: {err}")) + })?; + let (protocol, assets) = preview_assets_for_build(&manifest, build_id)?; + if protocol != CURRENT_PROTOCOL { + return Err(io::Error::other(format!( + "preview manifest has build {build_id} protocol {protocol}, but this client needs protocol {CURRENT_PROTOCOL}; set {REMOTE_BINARY_ENV_VAR}=target/release/herdr or install a matching Herdr on the remote host manually" + ))); + } + return assets.get(asset_key).map(remote_asset_info).ok_or_else(|| { + io::Error::other(format!( + "no {asset_key} binary in the preview manifest for build {build_id}" + )) + }); + } + + let current_version = current_version(); + let manifest_bytes = fetch_remote_manifest(STABLE_UPDATE_MANIFEST_URL)?; + let manifest: RemoteUpdateManifest = serde_json::from_slice(&manifest_bytes) + .map_err(|err| io::Error::other(format!("failed to parse update manifest JSON: {err}")))?; + let release = manifest.release_for_version(¤t_version).ok_or_else(|| { + io::Error::other(format!( + "release manifest does not include herdr {current_version}; build herdr for {} or install it there manually", + asset_key + )) + })?; + if let Some(protocol) = release.protocol { + if protocol != CURRENT_PROTOCOL { + return Err(io::Error::other(format!( + "release manifest has herdr {current_version} protocol {protocol}, but this client needs protocol {CURRENT_PROTOCOL}; set {REMOTE_BINARY_ENV_VAR}=target/release/herdr or install a matching herdr on the remote host manually" + ))); + } + } + release + .assets + .get(asset_key) + .map(remote_asset_info) + .ok_or_else(|| { + io::Error::other(format!( + "no {asset_key} binary in the release manifest for herdr {current_version}" + )) + }) +} + +fn private_download_dir(asset_key: &str) -> io::Result { + let base = std::env::temp_dir(); + for attempt in 0..100 { + let dir = base.join(format!( + "herdr-remote-{}-{}-{attempt}", + std::process::id(), + asset_key + )); + match fs::create_dir(&dir) { + Ok(()) => return Ok(dir), + Err(err) if err.kind() == io::ErrorKind::AlreadyExists => continue, + Err(err) => return Err(err), + } + } + + Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "failed to create private herdr remote download directory", + )) +} + +fn confirm_remote_install( + target: &str, + remote_herdr: &RemoteHerdr, + source_description: &str, +) -> io::Result<()> { + if !io::stdin().is_terminal() { + return Err(io::Error::other(format!( + "matching remote herdr {} is not installed at {}; run from an interactive terminal to approve installation", + current_version(), + remote_herdr.shell_path + ))); + } + + eprintln!( + "matching herdr {} is not installed on {target} for {}.", + current_version(), + remote_herdr.platform.asset_key() + ); + eprint!( + "Install {} to {}? [Y/n] ", + source_description, remote_herdr.shell_path + ); + io::stderr().flush()?; + + let mut answer = String::new(); + io::stdin().read_line(&mut answer)?; + let answer = answer.trim().to_ascii_lowercase(); + if answer == "n" || answer == "no" { + return Err(io::Error::new( + io::ErrorKind::Interrupted, + "remote herdr installation cancelled", + )); + } + + Ok(()) +} + +fn install_remote_herdr( + target: &str, + remote_herdr: &RemoteHerdr, + source_path: &Path, +) -> io::Result<()> { + let script = format!( + r#"dest="$HOME/{install_suffix}" +dir="${{dest%/*}}" +mkdir -p "$dir" +tmp="${{dest}}.tmp.$$" +cat > "$tmp" +chmod 755 "$tmp" +mv "$tmp" "$dest" +"#, + install_suffix = remote_herdr.install_suffix + ); + + let mut child = Command::new("ssh") + .arg("-T") + .arg(target) + .arg(format!("/bin/sh -eu -c {}", shell_quote(&script))) + .stdin(Stdio::piped()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + .map_err(|err| io::Error::new(err.kind(), format!("failed to start ssh install: {err}")))?; + + let mut source = File::open(source_path)?; + let copy_result = if let Some(mut stdin) = child.stdin.take() { + io::copy(&mut source, &mut stdin).map(|_| ()) + } else { + Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "ssh install stdin missing", + )) + }; + let status = child.wait()?; + copy_result?; + + if status.success() { + Ok(()) + } else { + Err(io::Error::other(format!( + "remote install exited with {status}" + ))) + } +} + +fn ssh_sh_output(target: &str, script: &str) -> io::Result { + // Feed POSIX bootstrap scripts to /bin/sh so the user's login shell only + // has to parse a simple executable invocation. + let mut child = Command::new("ssh") + .arg("-T") + .arg(target) + .arg("/bin/sh -s") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + + let write_result = if let Some(mut stdin) = child.stdin.take() { + stdin.write_all(script.as_bytes()) + } else { + Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "ssh bootstrap stdin missing", + )) + }; + let output = child.wait_with_output()?; + write_result?; + Ok(output) +} + +fn ssh_user_shell_output(target: &str, command: &str) -> io::Result { + Command::new("ssh") + .arg("-T") + .arg(target) + .arg(command) + .output() +} + +fn remote_bridge_command(remote_herdr: &RemoteHerdr, session_name: &str) -> String { + let mut command = format!("exec {}", remote_herdr.shell_path); + if session_name != crate::session::DEFAULT_SESSION_NAME { + command.push_str(" --session "); + command.push_str(&shell_quote(session_name)); + } + command.push_str(" remote-client-bridge"); + command +} + +fn reattach_command( + program: &str, + target: &str, + session_name: &str, + keybindings: RemoteKeybindings, + live_handoff: bool, +) -> String { + let program = if program.is_empty() { "herdr" } else { program }; + let mut command = format!("{} --remote {}", shell_quote(program), shell_quote(target)); + if keybindings != RemoteKeybindings::Local { + command.push_str(" --remote-keybindings "); + command.push_str(keybindings.as_str()); + } + if live_handoff { + command.push_str(" --handoff"); + } + if session_name != crate::session::DEFAULT_SESSION_NAME { + command.push_str(" --session "); + command.push_str(&shell_quote(session_name)); + } + command +} + +fn shell_quote(value: &str) -> String { + if !value.is_empty() + && value.chars().all(|ch| { + ch.is_ascii_alphanumeric() + || matches!( + ch, + '@' | '%' | '_' | '+' | '=' | ':' | ',' | '.' | '/' | '-' + ) + }) + { + return value.to_string(); + } + + format!("'{}'", value.replace('\'', "'\\''")) +} + +fn command_failed(context: &str, output: &Output) -> io::Error { + let stderr = String::from_utf8_lossy(&output.stderr); + let stderr = stderr.trim(); + if stderr.is_empty() { + io::Error::other(format!("{context}: {}", output.status)) + } else { + io::Error::other(format!("{context}: {stderr}")) + } +} + +struct SshStdioBridge { + local_socket: PathBuf, + keepalive_ssh_config: Option, + should_stop: Arc, + thread: Option>, +} + +impl SshStdioBridge { + fn start( + target: String, + remote_herdr: RemoteHerdr, + local_socket: PathBuf, + session_name: String, + manage_ssh_config: bool, + ) -> io::Result { + let _ = std::fs::remove_file(&local_socket); + let listener = UnixListener::bind(&local_socket)?; + crate::ipc::restrict_socket_permissions(&local_socket, BRIDGE_SOCKET_PERMISSION_MODE)?; + listener.set_nonblocking(true)?; + + let keepalive_ssh_config = if manage_ssh_config { + write_keepalive_ssh_config() + .inspect_err(|err| { + tracing::debug!(%err, "could not write ssh keepalive config; using plain ssh"); + }) + .ok() + } else { + None + }; + + let should_stop = Arc::new(AtomicBool::new(false)); + let thread_stop = Arc::clone(&should_stop); + let thread_ssh_config = keepalive_ssh_config.clone(); + let thread = thread::spawn(move || { + while !thread_stop.load(Ordering::Acquire) { + match listener.accept() { + Ok((stream, _addr)) => { + if let Err(err) = stream.set_nonblocking(false) { + eprintln!( + "herdr: remote bridge failed to prepare client socket: {err}" + ); + continue; + } + if let Err(err) = bridge_connection( + stream, + &target, + &remote_herdr, + &session_name, + thread_ssh_config.as_deref(), + ) { + eprintln!("herdr: remote bridge failed: {err}"); + } + } + Err(err) if err.kind() == io::ErrorKind::WouldBlock => { + thread::sleep(BRIDGE_ACCEPT_POLL); + } + Err(err) => { + eprintln!("herdr: remote bridge listener failed: {err}"); + break; + } + } + } + }); + + Ok(Self { + local_socket, + keepalive_ssh_config, + should_stop, + thread: Some(thread), + }) + } +} + +impl Drop for SshStdioBridge { + fn drop(&mut self) { + self.should_stop.store(true, Ordering::Release); + let _ = std::fs::remove_file(&self.local_socket); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + // Remove the generated ssh config only after the bridge thread has + // joined, so it can never start a connection with a config path that + // was just deleted. + if let Some(dir) = self.keepalive_ssh_config.as_deref().and_then(Path::parent) { + let _ = std::fs::remove_dir_all(dir); + } + } +} + +/// Creates a fresh user-only (`0700`) directory under the temp dir for the +/// bridge's generated ssh config, returning its path. +/// +/// Using a private directory created with fail-if-exists semantics — rather +/// than a predictable file in the world-writable temp dir — stops a local user +/// from pre-planting a symlink or world-writable file that herdr would write +/// and `ssh -F` would then read. +fn private_ssh_config_dir() -> io::Result { + use std::os::unix::fs::DirBuilderExt; + + let base = std::env::temp_dir(); + for attempt in 0..100 { + let dir = base.join(format!("herdr-ssh-{}-{attempt}", std::process::id())); + match fs::DirBuilder::new().mode(0o700).create(&dir) { + Ok(()) => return Ok(dir), + Err(err) if err.kind() == io::ErrorKind::AlreadyExists => continue, + Err(err) => return Err(err), + } + } + + Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "failed to create private herdr ssh config directory", + )) +} + +/// Quotes a path for an ssh_config `Include` so a path containing spaces (or +/// glob metacharacters) is treated as one literal token instead of being split +/// or expanded by ssh — otherwise the user's config might not be Included and +/// herdr's fallback would wrongly take effect. +fn ssh_config_quote(path: &str) -> String { + format!("\"{path}\"") +} + +/// Builds a temporary ssh config that keeps the bridge tunnel alive without +/// overriding the user's own settings, returning its path. +/// +/// The file `Include`s the user's real ssh config first, so ssh's +/// first-value-wins rule keeps any `ServerAlive*` the user set there (including +/// an explicit `0` to disable it); herdr's values apply only when the user has +/// none. +fn write_keepalive_ssh_config() -> io::Result { + use std::os::unix::fs::OpenOptionsExt; + + let path = private_ssh_config_dir()?.join("config"); + + let mut contents = String::new(); + if let Some(home) = std::env::var_os("HOME") { + let user_config = PathBuf::from(home).join(".ssh").join("config"); + if user_config.is_file() { + contents.push_str(&format!( + "Include {}\n", + ssh_config_quote(&user_config.to_string_lossy()) + )); + } + } + if Path::new("/etc/ssh/ssh_config").is_file() { + contents.push_str("Include /etc/ssh/ssh_config\n"); + } + contents.push_str("Host *\n"); + contents.push_str(" ServerAliveInterval 15\n"); + contents.push_str(" ServerAliveCountMax 4\n"); + + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(BRIDGE_SOCKET_PERMISSION_MODE) + .open(&path)?; + file.write_all(contents.as_bytes())?; + Ok(path) +} + +fn bridge_connection( + stream: UnixStream, + target: &str, + remote_herdr: &RemoteHerdr, + session_name: &str, + keepalive_ssh_config: Option<&Path>, +) -> io::Result<()> { + let mut command = Command::new("ssh"); + // Use the generated keepalive ssh config when present; otherwise plain ssh. + if let Some(ssh_config) = keepalive_ssh_config { + command.arg("-F").arg(ssh_config); + } + command + .arg("-T") + .arg(target) + .arg(remote_bridge_command(remote_herdr, session_name)); + command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); + + let mut child = command + .spawn() + .map_err(|err| io::Error::new(err.kind(), format!("failed to start ssh bridge: {err}")))?; + let mut child_stdin = child + .stdin + .take() + .ok_or_else(|| io::Error::new(io::ErrorKind::BrokenPipe, "ssh bridge stdin missing"))?; + let mut child_stdout = child + .stdout + .take() + .ok_or_else(|| io::Error::new(io::ErrorKind::BrokenPipe, "ssh bridge stdout missing"))?; + let mut stream_to_child = stream.try_clone()?; + let mut child_to_stream = stream; + + let upload = thread::spawn(move || { + let _ = copy_flush(&mut stream_to_child, &mut child_stdin); + }); + let download = thread::spawn(move || { + let _ = copy_flush(&mut child_stdout, &mut child_to_stream); + let _ = child_to_stream.shutdown(std::net::Shutdown::Write); + }); + + let status = child.wait()?; + let _ = upload.join(); + let _ = download.join(); + + if status.success() { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::ConnectionAborted, + format!("ssh bridge exited with {status}"), + )) + } +} + +fn copy_flush(reader: &mut R, writer: &mut W) -> io::Result { + let mut buffer = [0_u8; 16 * 1024]; + let mut total = 0; + + loop { + let bytes_read = match reader.read(&mut buffer) { + Ok(0) => return Ok(total), + Ok(bytes_read) => bytes_read, + Err(err) if err.kind() == io::ErrorKind::Interrupted => continue, + Err(err) => return Err(err), + }; + + writer.write_all(&buffer[..bytes_read])?; + writer.flush()?; + total += bytes_read as u64; + } +} + +fn run_client_process( + local_socket: &Path, + reattach_command: &str, + keybindings: RemoteKeybindings, +) -> io::Result<()> { + let exe = std::env::current_exe()?; + let status = Command::new(exe) + .arg("client") + .env( + crate::server::socket_paths::CLIENT_SOCKET_PATH_ENV_VAR, + local_socket, + ) + .env("HERDR_RENDER_ENCODING", "terminal-ansi") + .env(REATTACH_COMMAND_ENV_VAR, reattach_command) + .env(REMOTE_KEYBINDINGS_ENV_VAR, keybindings.as_str()) + .env_remove(crate::api::SOCKET_PATH_ENV_VAR) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status()?; + + if status.success() { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::Interrupted, + format!("remote client exited with {status}"), + )) + } +} + +fn local_forward_socket_path(target: &str, session_name: &str) -> PathBuf { + let pid = std::process::id(); + let target_clean = sanitize_path_component(target); + let session_clean = sanitize_path_component(session_name); + + let tmpdir = std::env::temp_dir(); + let readable = tmpdir.join(format!( + "herdr-remote-{pid}-{target_clean}-{session_clean}.sock" + )); + if fits_unix_socket_path(&readable) { + return readable; + } + + // macOS' per-user TMPDIR (~49 chars under /var/folders/...) can push the + // readable name past sun_path's 104-byte ceiling. Fall back to a hashed + // short name in TMPDIR, then to /tmp as a last resort when TMPDIR itself + // is longer than the budget. The hash covers the full unsanitized + // target/session so uniqueness does not depend on the prefix truncation; + // the prefix is kept only for debuggability. + let target_prefix: String = target_clean.chars().take(8).collect(); + let hash = short_socket_hash(target, session_name); + let short_name = format!("herdr-r-{pid}-{target_prefix}-{hash}.sock"); + let short_in_tmp = tmpdir.join(&short_name); + if fits_unix_socket_path(&short_in_tmp) { + return short_in_tmp; + } + PathBuf::from("/tmp").join(short_name) +} + +fn fits_unix_socket_path(path: &Path) -> bool { + use std::os::unix::ffi::OsStrExt; + // sun_path is byte-limited: 104 bytes on macOS, 108 on Linux. Reserve + // 1 byte for the trailing NUL and use the smaller cap for portability. + const MAX: usize = 103; + path.as_os_str().as_bytes().len() <= MAX +} + +fn short_socket_hash(target: &str, session: &str) -> String { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + let mut hasher = DefaultHasher::new(); + target.hash(&mut hasher); + 0u8.hash(&mut hasher); + session.hash(&mut hasher); + format!("{:016x}", hasher.finish()) +} + +fn sanitize_path_component(input: &str) -> String { + let sanitized: String = input + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-') { + ch + } else { + '-' + } + }) + .collect(); + + sanitized.trim_matches('-').chars().take(32).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bridge_socket_is_user_only() { + use std::os::unix::fs::PermissionsExt; + + let socket = std::env::temp_dir().join(format!( + "herdr-bridge-permissions-test-{}.sock", + std::process::id() + )); + let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { + os: "linux", + arch: "x86_64", + }); + let bridge = SshStdioBridge::start( + "example".to_string(), + remote_herdr, + socket.clone(), + "default".to_string(), + false, + ) + .expect("start bridge listener"); + + let mode = std::fs::metadata(&socket).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, BRIDGE_SOCKET_PERMISSION_MODE); + + drop(bridge); + let _ = std::fs::remove_file(socket); + } + + #[test] + fn keepalive_ssh_config_includes_user_config_then_fallback() { + use std::os::unix::fs::PermissionsExt; + + let path = write_keepalive_ssh_config().expect("write keepalive config"); + let contents = std::fs::read_to_string(&path).expect("read keepalive config"); + + // herdr's fallback keepalive is present... + assert!( + contents.contains("Host *"), + "config should add a Host * fallback block: {contents}" + ); + assert!( + contents.contains("ServerAliveInterval 15"), + "config should set the keepalive interval: {contents}" + ); + assert!( + contents.contains("ServerAliveCountMax 4"), + "config should set the keepalive count: {contents}" + ); + // ...and any user config is Included (quoted) BEFORE it so first-value-wins + // keeps the user's own settings. + if let Some(home) = std::env::var_os("HOME") { + let user_config = PathBuf::from(home).join(".ssh").join("config"); + if user_config.is_file() { + let include = format!( + "Include {}", + ssh_config_quote(&user_config.to_string_lossy()) + ); + let include_at = contents.find(&include).expect("user config Included"); + let fallback_at = contents.find("Host *").expect("fallback present"); + assert!( + include_at < fallback_at, + "user config must be Included before herdr's fallback: {contents}" + ); + } + } + + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!( + mode, BRIDGE_SOCKET_PERMISSION_MODE, + "keepalive config must be user-only" + ); + // The config lives in a private 0700 dir, not a predictable temp path. + let dir = path.parent().expect("config has a parent dir"); + let dir_mode = std::fs::metadata(dir).unwrap().permissions().mode() & 0o777; + assert_eq!(dir_mode, 0o700, "ssh config dir must be user-only"); + + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn ssh_config_quote_wraps_path_with_spaces() { + assert_eq!( + ssh_config_quote("/home/a b/.ssh/config"), + "\"/home/a b/.ssh/config\"" + ); + } + + #[test] + fn extract_remote_args_removes_space_form() { + let args = vec![ + "herdr".into(), + "--remote".into(), + "dev".into(), + "--help".into(), + ]; + let (cleaned, remote) = extract_remote_args(&args).unwrap(); + assert_eq!(cleaned, vec!["herdr", "--help"]); + let remote = remote.unwrap(); + assert_eq!(remote.target, "dev"); + assert_eq!(remote.keybindings, RemoteKeybindings::Local); + } + + #[test] + fn extract_remote_args_removes_equals_form() { + let args = vec!["herdr".into(), "--remote=user@host".into()]; + let (cleaned, remote) = extract_remote_args(&args).unwrap(); + assert_eq!(cleaned, vec!["herdr"]); + let remote = remote.unwrap(); + assert_eq!(remote.target, "user@host"); + assert_eq!(remote.keybindings, RemoteKeybindings::Local); + } + + #[test] + fn extract_remote_args_accepts_remote_keybindings_server() { + let args = vec![ + "herdr".into(), + "--remote".into(), + "dev".into(), + "--remote-keybindings=server".into(), + ]; + let (cleaned, remote) = extract_remote_args(&args).unwrap(); + assert_eq!(cleaned, vec!["herdr"]); + let remote = remote.unwrap(); + assert_eq!(remote.target, "dev"); + assert_eq!(remote.keybindings, RemoteKeybindings::Server); + } + + #[test] + fn extract_remote_args_accepts_remote_keybindings_space_form() { + let args = vec![ + "herdr".into(), + "--remote=dev".into(), + "--remote-keybindings".into(), + "server".into(), + ]; + let (cleaned, remote) = extract_remote_args(&args).unwrap(); + assert_eq!(cleaned, vec!["herdr"]); + assert_eq!(remote.unwrap().keybindings, RemoteKeybindings::Server); + } + + #[test] + fn extract_remote_args_accepts_explicit_handoff() { + let args = vec!["herdr".into(), "--remote=dev".into(), "--handoff".into()]; + + let (cleaned, remote) = extract_remote_args(&args).unwrap(); + + assert_eq!(cleaned, vec!["herdr"]); + let remote = remote.unwrap(); + assert_eq!(remote.target, "dev"); + assert!(remote.live_handoff); + } + + #[test] + fn extract_remote_args_preserves_child_remote_options_after_separator() { + let args = vec![ + "herdr".into(), + "agent".into(), + "start".into(), + "repro".into(), + "--".into(), + "child".into(), + "--remote".into(), + "dev".into(), + "--remote-keybindings=server".into(), + "--handoff".into(), + ]; + + let (cleaned, remote) = extract_remote_args(&args).unwrap(); + + assert_eq!(cleaned, args); + assert!(remote.is_none()); + } + + #[test] + fn extract_remote_args_preserves_handoff_without_remote() { + let args = vec!["herdr".into(), "update".into(), "--handoff".into()]; + + let (cleaned, remote) = extract_remote_args(&args).unwrap(); + + assert_eq!(cleaned, args); + assert!(remote.is_none()); + } + + #[test] + fn extract_remote_args_rejects_remote_keybindings_without_remote() { + let args = vec!["herdr".into(), "--remote-keybindings=server".into()]; + let err = extract_remote_args(&args).unwrap_err(); + assert_eq!(err, "--remote-keybindings requires --remote"); + } + + #[test] + fn extract_remote_args_rejects_duplicate_remote_keybindings() { + let args = vec![ + "herdr".into(), + "--remote=dev".into(), + "--remote-keybindings=local".into(), + "--remote-keybindings=server".into(), + ]; + let err = extract_remote_args(&args).unwrap_err(); + assert_eq!(err, "--remote-keybindings can only be specified once"); + } + + #[test] + fn extract_remote_args_requires_value() { + let args = vec!["herdr".into(), "--remote".into()]; + let err = extract_remote_args(&args).unwrap_err(); + assert_eq!(err, "missing value for --remote"); + } + + #[test] + fn extract_remote_args_rejects_empty_value() { + let args = vec!["herdr".into(), "--remote=".into()]; + let err = extract_remote_args(&args).unwrap_err(); + assert_eq!(err, "missing value for --remote"); + } + + #[test] + fn extract_remote_args_rejects_duplicate_values() { + let args = vec![ + "herdr".into(), + "--remote=dev".into(), + "--remote=prod".into(), + ]; + let err = extract_remote_args(&args).unwrap_err(); + assert_eq!(err, "--remote can only be specified once"); + } + + #[test] + fn extract_remote_args_rejects_option_like_target() { + let args = vec!["herdr".into(), "--remote".into(), "-oProxyCommand=x".into()]; + let err = extract_remote_args(&args).unwrap_err(); + assert_eq!(err, "--remote target must not start with '-'"); + } + + #[test] + fn sanitize_path_component_removes_shell_sensitive_chars() { + assert_eq!(sanitize_path_component("user@host:22"), "user-host-22"); + } + + #[test] + fn remote_platform_maps_uname_values() { + assert_eq!( + RemotePlatform::from_uname("Linux", "amd64") + .unwrap() + .asset_key(), + "linux-x86_64" + ); + assert_eq!( + RemotePlatform::from_uname("Darwin", "arm64") + .unwrap() + .asset_key(), + "macos-aarch64" + ); + assert!(RemotePlatform::from_uname("FreeBSD", "x86_64").is_none()); + } + + #[test] + fn reattach_command_includes_remote_and_session() { + assert_eq!( + reattach_command( + "target/release/herdr", + "user@host", + "work", + RemoteKeybindings::Local, + false, + ), + "target/release/herdr --remote user@host --session work" + ); + assert_eq!( + reattach_command( + "herdr", + "host name", + crate::session::DEFAULT_SESSION_NAME, + RemoteKeybindings::Local, + false, + ), + "herdr --remote 'host name'" + ); + assert_eq!( + reattach_command( + "herdr", + "host", + crate::session::DEFAULT_SESSION_NAME, + RemoteKeybindings::Server, + false, + ), + "herdr --remote host --remote-keybindings server" + ); + assert_eq!( + reattach_command( + "herdr", + "host", + crate::session::DEFAULT_SESSION_NAME, + RemoteKeybindings::Local, + true, + ), + "herdr --remote host --handoff" + ); + } + + #[test] + fn remote_bridge_command_uses_installed_binary() { + let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { + os: "linux", + arch: "x86_64", + }); + assert_eq!( + remote_bridge_command(&remote_herdr, crate::session::DEFAULT_SESSION_NAME), + "exec \"$HOME/.local/bin/herdr\" remote-client-bridge" + ); + } + + #[test] + fn remote_path_discovery_uses_path_binary() { + let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { + os: "linux", + arch: "x86_64", + }); + let remote_herdr = remote_herdr_from_path_discovery(&remote_herdr, "/usr/bin/herdr\n") + .expect("path binary"); + + assert_eq!( + remote_bridge_command(&remote_herdr, crate::session::DEFAULT_SESSION_NAME), + "exec /usr/bin/herdr remote-client-bridge" + ); + } + + #[test] + fn remote_path_discovery_quotes_discovered_binary() { + let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { + os: "linux", + arch: "x86_64", + }); + let remote_herdr = + remote_herdr_from_path_discovery(&remote_herdr, "/opt/herdr bin/herdr\n") + .expect("path binary"); + + assert_eq!( + remote_bridge_command(&remote_herdr, crate::session::DEFAULT_SESSION_NAME), + "exec '/opt/herdr bin/herdr' remote-client-bridge" + ); + } + + #[test] + fn remote_path_discovery_uses_macos_path_binary() { + let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { + os: "macos", + arch: "aarch64", + }); + let remote_herdr = + remote_herdr_from_path_discovery(&remote_herdr, "/opt/homebrew/bin/herdr\n") + .expect("path binary"); + + assert_eq!( + remote_bridge_command(&remote_herdr, crate::session::DEFAULT_SESSION_NAME), + "exec /opt/homebrew/bin/herdr remote-client-bridge" + ); + assert_eq!(remote_herdr.platform.asset_key(), "macos-aarch64"); + } + + #[test] + fn remote_path_discovery_quotes_single_quotes_in_discovered_binary() { + let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { + os: "linux", + arch: "x86_64", + }); + let remote_herdr = + remote_herdr_from_path_discovery(&remote_herdr, "/opt/herdr's/bin/herdr\n") + .expect("path binary"); + + assert_eq!( + remote_bridge_command(&remote_herdr, crate::session::DEFAULT_SESSION_NAME), + "exec '/opt/herdr'\\''s/bin/herdr' remote-client-bridge" + ); + } + + #[test] + fn remote_path_discovery_ignores_relative_paths() { + let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { + os: "linux", + arch: "x86_64", + }); + let remote_herdr = remote_herdr_from_path_discovery(&remote_herdr, "bin/herdr\n"); + + assert!(remote_herdr.is_none()); + } + + #[test] + fn remote_path_discovery_ignores_empty_output() { + let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { + os: "linux", + arch: "x86_64", + }); + let remote_herdr = remote_herdr_from_path_discovery(&remote_herdr, "\n"); + + assert!(remote_herdr.is_none()); + } + + #[test] + fn remote_shell_path_warning_accepts_managed_install() { + assert!(remote_shell_resolves_managed_install( + "/home/can/.local/bin/herdr\n" + )); + assert!(remote_shell_resolves_managed_install( + "/Users/can/.local/bin/herdr\n" + )); + assert!(!remote_shell_resolves_managed_install( + "/usr/local/bin/herdr\n" + )); + assert!(!remote_shell_resolves_managed_install("")); + } + + #[test] + fn parse_client_status_json_reads_protocol() { + assert_eq!( + parse_client_status_json(r#"{"version":"x","protocol":8,"binary":"/bin/herdr"}"#) + .map(|status| status.protocol), + Some(8) + ); + assert!(parse_client_status_json(r#"{"protocol":"unknown"}"#).is_none()); + } + + #[test] + fn parse_remote_server_status_json_reads_running_server() { + assert_eq!( + parse_remote_server_status_json( + r#"{"status":"running","running":true,"version":"0.6.0","protocol":8,"capabilities":{"live_handoff":true}}"# + ) + .unwrap(), + RemoteServerStatus::Running { + version: Some("0.6.0".into()), + protocol: Some(8), + live_handoff: true + } + ); + } + + #[test] + fn parse_remote_server_status_json_treats_missing_capability_as_no_handoff() { + assert_eq!( + parse_remote_server_status_json( + r#"{"status":"running","running":true,"version":"0.6.0","protocol":8}"# + ) + .unwrap(), + RemoteServerStatus::Running { + version: Some("0.6.0".into()), + protocol: Some(8), + live_handoff: false + } + ); + } + + #[test] + fn parse_remote_server_status_json_reads_stopped_server() { + assert_eq!( + parse_remote_server_status_json( + r#"{"status":"not_running","running":false,"version":null,"protocol":null}"# + ) + .unwrap(), + RemoteServerStatus::NotRunning + ); + } + + #[test] + fn remote_update_manifest_uses_root_assets_for_latest_version() { + let manifest: RemoteUpdateManifest = serde_json::from_str( + r#"{ + "version": "1.2.3", + "assets": { + "linux-x86_64": "https://example.com/latest" + }, + "releases": { + "1.2.3": { + "assets": { + "linux-x86_64": "https://example.com/archive" + } + } + } + }"#, + ) + .unwrap(); + + assert_eq!( + manifest + .release_for_version("1.2.3") + .and_then(|release| release.assets.get("linux-x86_64")) + .map(RemoteAssetRef::url), + Some("https://example.com/latest") + ); + } + + #[test] + fn remote_update_manifest_reads_archived_release_assets() { + let manifest: RemoteUpdateManifest = serde_json::from_str( + r#"{ + "version": "1.2.4", + "assets": { + "linux-x86_64": "https://example.com/latest" + }, + "releases": { + "1.2.3": { + "notes": "ignored", + "assets": { + "linux-x86_64": "https://example.com/archive" + } + } + } + }"#, + ) + .unwrap(); + + assert_eq!( + manifest + .release_for_version("1.2.3") + .and_then(|release| release.assets.get("linux-x86_64")) + .map(RemoteAssetRef::url), + Some("https://example.com/archive") + ); + } + + #[test] + fn remote_update_manifest_uses_archived_release_protocol() { + let manifest: RemoteUpdateManifest = serde_json::from_str( + r#"{ + "version": "1.2.4", + "protocol": 42, + "assets": { + "linux-x86_64": "https://example.com/latest" + }, + "releases": { + "1.2.3": { + "notes": "ignored", + "protocol": 41, + "assets": { + "linux-x86_64": "https://example.com/archive" + } + } + } + }"#, + ) + .unwrap(); + + assert_eq!( + manifest + .release_for_version("1.2.3") + .and_then(|release| release.protocol), + Some(41) + ); + } + + #[test] + fn remote_update_manifest_does_not_inherit_latest_protocol_for_archived_assets() { + let manifest: RemoteUpdateManifest = serde_json::from_str( + r#"{ + "version": "1.2.4", + "protocol": 42, + "assets": { + "linux-x86_64": "https://example.com/latest" + }, + "releases": { + "1.2.3": { + "notes": "ignored", + "assets": { + "linux-x86_64": "https://example.com/archive" + } + } + } + }"#, + ) + .unwrap(); + + assert_eq!( + manifest + .release_for_version("1.2.3") + .and_then(|release| release.protocol), + None + ); + } + + #[test] + fn remote_preview_manifest_falls_back_to_archived_exact_build_assets() { + let manifest: RemotePreviewManifest = serde_json::from_str( + r#"{ + "build_id": "2026-06-06-new", + "protocol": 12, + "assets": { + "linux-x86_64": { + "url": "https://example.com/new", + "sha256": "new" + } + }, + "builds": { + "2026-06-02-old": { + "protocol": 11, + "assets": { + "linux-x86_64": { + "url": "https://example.com/old", + "sha256": "old" + } + } + } + } + }"#, + ) + .unwrap(); + + let (protocol, assets) = + preview_assets_for_build(&manifest, "2026-06-02-old").expect("archived build"); + let asset = assets.get("linux-x86_64").expect("asset"); + assert_eq!(protocol, 11); + assert_eq!(asset.url(), "https://example.com/old"); + assert_eq!(asset.sha256(), Some("old")); + } + + #[test] + fn remote_server_restart_reason_requires_stop_for_protocol_mismatch() { + assert_eq!( + remote_server_restart_reason(Some(¤t_version()), Some(0), false), + Some(RemoteServerRestartReason::ProtocolMismatch) + ); + } + + #[test] + fn remote_server_restart_reason_offers_restart_after_binary_update() { + assert_eq!( + remote_server_restart_reason(Some(¤t_version()), Some(CURRENT_PROTOCOL), true), + Some(RemoteServerRestartReason::BinaryUpdated) + ); + } + + #[test] + fn remote_server_restart_reason_offers_restart_for_version_mismatch() { + assert_eq!( + remote_server_restart_reason(Some("0.0.0"), Some(CURRENT_PROTOCOL), false), + Some(RemoteServerRestartReason::VersionMismatch) + ); + assert_eq!( + remote_server_restart_reason(None, Some(CURRENT_PROTOCOL), false), + Some(RemoteServerRestartReason::VersionMismatch) + ); + } + + #[test] + fn remote_server_restart_reason_allows_current_server() { + assert_eq!( + remote_server_restart_reason(Some(¤t_version()), Some(CURRENT_PROTOCOL), false), + None + ); + } + + #[test] + fn install_source_description_uses_override_binary() { + let platform = RemotePlatform { + os: "linux", + arch: "aarch64", + }; + assert_eq!( + install_source_description_for(&platform, Some(Path::new("/tmp/herdr-aarch64")), false), + "HERDR_REMOTE_BINARY (/tmp/herdr-aarch64)" + ); + } + + #[test] + fn install_source_description_uses_local_binary_when_allowed() { + let platform = RemotePlatform::local(); + + assert_eq!( + install_source_description_for(&platform, None, true), + "the current local herdr binary" + ); + } + + #[test] + fn install_source_description_uses_release_asset_when_local_binary_cannot_seed_remote() { + let platform = RemotePlatform::local(); + + assert_eq!( + install_source_description_for(&platform, None, false), + format!( + "the {} {} asset for {}", + current_version(), + current_channel(), + platform.asset_key() + ) + ); + } + + #[test] + fn resolve_install_source_uses_override_binary_without_temporary_cleanup() { + let platform = RemotePlatform { + os: "linux", + arch: "aarch64", + }; + let source = resolve_install_source(&platform, Some(PathBuf::from("/tmp/herdr-aarch64"))) + .expect("override source"); + assert_eq!(source.path, PathBuf::from("/tmp/herdr-aarch64")); + assert!(source.temporary_dir.is_none()); + } + + fn remote_env_lock() -> &'static std::sync::Mutex<()> { + static LOCK: std::sync::OnceLock> = std::sync::OnceLock::new(); + LOCK.get_or_init(|| std::sync::Mutex::new(())) + } + + fn socket_path_byte_len(path: &Path) -> usize { + use std::os::unix::ffi::OsStrExt; + path.as_os_str().as_bytes().len() + } + + #[test] + fn local_forward_socket_path_uses_readable_name_when_it_fits() { + let _guard = remote_env_lock().lock().unwrap(); + // Short target + session leave plenty of room — keep the human- + // readable form so the socket path stays grep-friendly. + let path = local_forward_socket_path("dev", "default"); + let filename = path + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_string(); + assert!( + filename.starts_with("herdr-remote-"), + "expected readable name, got {filename}" + ); + assert!(filename.contains("-dev-default."), "got {filename}"); + assert!( + fits_unix_socket_path(&path), + "socket path too long: {} ({} bytes)", + path.display(), + socket_path_byte_len(&path) + ); + } + + #[test] + fn local_forward_socket_path_fits_in_sun_path() { + let _guard = remote_env_lock().lock().unwrap(); + // Worst case for the readable form: macOS-style 49-char TMPDIR + + // max-length sanitized components. Should fall back to the hashed + // short name, which fits under TMPDIR. + let target = "longish-host.example.com"; + let session = "a-fairly-long-session-name-here"; + let path = local_forward_socket_path(target, session); + assert!( + fits_unix_socket_path(&path), + "socket path too long for sun_path: {} ({} bytes)", + path.display(), + socket_path_byte_len(&path) + ); + } + + #[test] + fn local_forward_socket_path_falls_back_to_tmp_when_dir_is_long() { + let _guard = remote_env_lock().lock().unwrap(); + // Force a TMPDIR long enough that even the hashed short name cannot + // fit inside it. The fallback should drop to /tmp. + let prior = std::env::var_os("TMPDIR"); + let long_dir = std::env::temp_dir().join("a".repeat(80)); + let _ = fs::create_dir_all(&long_dir); + std::env::set_var("TMPDIR", &long_dir); + + let path = local_forward_socket_path("longish-host.example.com", "default"); + let fits = fits_unix_socket_path(&path); + let parent = path.parent().map(Path::to_path_buf); + let filename = path + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_string(); + + match prior { + Some(v) => std::env::set_var("TMPDIR", v), + None => std::env::remove_var("TMPDIR"), + } + let _ = fs::remove_dir_all(&long_dir); + + assert!(fits, "fallback path still overflows: {}", path.display()); + assert_eq!(parent.as_deref(), Some(Path::new("/tmp"))); + assert!( + filename.starts_with("herdr-r-"), + "expected hashed fallback, got {filename}" + ); + } + + #[test] + fn install_source_cleanup_removes_temporary_directory() { + let dir = std::env::temp_dir().join(format!( + "herdr-install-source-cleanup-test-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&dir); + fs::create_dir(&dir).expect("create temp dir"); + let path = dir.join("herdr.tmp"); + fs::write(&path, b"test").expect("write temp file"); + + InstallSource::temporary(path, dir.clone()).cleanup(); + + assert!(!dir.exists()); + } +} diff --git a/src/server/autodetect.rs b/src/server/autodetect.rs index a4ea721b..cdba5bd2 100644 --- a/src/server/autodetect.rs +++ b/src/server/autodetect.rs @@ -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> { 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 { + 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"); diff --git a/src/server/client_accept.rs b/src/server/client_accept.rs index afb2c398..ccfe7b1a 100644 --- a/src/server/client_accept.rs +++ b/src/server/client_accept.rs @@ -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, server_event_tx: &mpsc::Sender, ) -> 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"); diff --git a/src/server/client_transport.rs b/src/server/client_transport.rs index 931f1100..4f0f12bc 100644 --- a/src/server/client_transport.rs +++ b/src/server/client_transport.rs @@ -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 }, + /// A client sent structured input events. + ClientInputEvents { + client_id: u64, + events: Vec, + }, /// 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, should_quit: &Arc, @@ -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::>(); @@ -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>, render_rx: std::sync::mpsc::Receiver>, @@ -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, should_quit: &Arc, @@ -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 diff --git a/src/server/clipboard_image.rs b/src/server/clipboard_image.rs index 299267d3..70072f2d 100644 --- a/src/server/clipboard_image.rs +++ b/src/server/clipboard_image.rs @@ -15,8 +15,6 @@ pub(crate) fn stage( extension: &str, data: &[u8], ) -> io::Result { - 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 { - 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 { 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; diff --git a/src/server/headless.rs b/src/server/headless.rs index c78df8a1..f313161f 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -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, + #[cfg(unix)] api_server: Option, - client_listener: UnixListener, + #[cfg(unix)] + client_listener: LocalListener, client_socket_path: PathBuf, client_socket_identity: SocketFileIdentity, clients: HashMap, + #[cfg(unix)] next_client_id: u64, /// The client currently driving the shared pane runtime size, theme, and input keybindings. foreground_client_id: Option, @@ -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, @@ -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, + server_event_tx: mpsc::Sender, +) { + 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::>(); 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, + ) -> 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 { + 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(); diff --git a/src/server/mod.rs b/src/server/mod.rs index 3386d0d7..58b552e2 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -1,4 +1,5 @@ pub mod autodetect; +#[cfg(unix)] pub(crate) mod client_accept; pub(crate) mod client_transport; pub(crate) mod clients; diff --git a/src/server/notifications.rs b/src/server/notifications.rs index f0b2d746..711087f5 100644 --- a/src/server/notifications.rs +++ b/src/server/notifications.rs @@ -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(); diff --git a/src/server/render_stream.rs b/src/server/render_stream.rs index 97baf38d..e158a6d4 100644 --- a/src/server/render_stream.rs +++ b/src/server/render_stream.rs @@ -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); diff --git a/src/server/socket_paths.rs b/src/server/socket_paths.rs index 1630ae7c..8780f932 100644 --- a/src/server/socket_paths.rs +++ b/src/server/socket_paths.rs @@ -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; diff --git a/src/session.rs b/src/session.rs index 67b77dc0..34d202a5 100644 --- a/src/session.rs +++ b/src/session.rs @@ -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 Result { } fn send_stop_request( - mut stream: UnixStream, + mut stream: LocalStream, request: &serde_json::Value, deadline: Instant, ) -> Result, 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> { @@ -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, 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(); diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs index 6d761b11..c74301ad 100644 --- a/src/terminal/runtime.rs +++ b/src/terminal/runtime.rs @@ -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(); } diff --git a/src/terminal/state.rs b/src/terminal/state.rs index ec113b14..bb2642d9 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -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), ); diff --git a/src/ui/mobile.rs b/src/ui/mobile.rs index 9b400f51..3d6911ca 100644 --- a/src/ui/mobile.rs +++ b/src/ui/mobile.rs @@ -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!( diff --git a/src/ui/panes.rs b/src/ui/panes.rs index 1be5782b..472dd4b1 100644 --- a/src/ui/panes.rs +++ b/src/ui/panes.rs @@ -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); diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs index 7d1227df..085f4371 100644 --- a/src/ui/sidebar.rs +++ b/src/ui/sidebar.rs @@ -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!( diff --git a/src/update.rs b/src/update.rs index c5dab475..4f192e3a 100644 --- a/src/update.rs +++ b/src/update.rs @@ -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 { 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 { 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;