From 207be3c771d281baae6e5fa0fb74be9a056e97a2 Mon Sep 17 00:00:00 2001 From: Can Celik Date: Tue, 1 Sep 2026 15:48:22 +0300 Subject: refactor: render the shell in the client (#3487) * feat: add opt-in client-rendered shell * feat: add client-owned shell mouse controls * feat: complete client-owned shell chrome * feat: add client-owned shell settings and notifications * feat: run client shell custom commands * feat: render popup terminals in client shell * feat: add client-owned scrollback and copy mode * fix: preserve client worktree trust defaults * feat: render startup diagnostics in client shell * feat: render onboarding in client shell * feat: render product announcements in client shell * feat: render release notes in client shell * feat: render mobile switcher in client shell * feat: render kitty graphics in client shell * fix: preserve client shell lifecycle coherence * refactor: route client shell commands through endpoint connections * refactor: make the client-rendered shell the default * refactor: remove monolithic launch mode * refactor: complete client-rendered shell cutover * perf: retain client pane surface updates * perf: scale retained pane surface updates * perf: remove remote pane input latency * test: make client shell checks platform-independent * fix: prevent client timer starvation * test: fix client shell platform gates * test: accept retained surfaces in cross-area checks * test: make client mode assertions platform-neutral * fix: badge only outdated integrations --- src/client/handshake.rs | 189 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 src/client/handshake.rs (limited to 'src/client/handshake.rs') diff --git a/src/client/handshake.rs b/src/client/handshake.rs new file mode 100644 index 00000000..2140516e --- /dev/null +++ b/src/client/handshake.rs @@ -0,0 +1,189 @@ +use std::io; +#[cfg(unix)] +use std::io::IsTerminal as _; +use std::time::Duration; + +use interprocess::local_socket::traits::Stream as _; +#[cfg(windows)] +use tracing::debug; +use tracing::info; + +use crate::ipc::LocalStream; +use crate::protocol::{ + self, ClientMessage, RenderEncoding, ServerMessage, MAX_FRAME_SIZE, PROTOCOL_VERSION, +}; + +#[cfg(unix)] +use super::terminal_setup::is_ssh_session; +use super::{shell, ClientError}; + +/// Time to wait for the server's Welcome reply during the handshake. +/// +/// A local client talks to an already-connected server, so 5s is plenty. The +/// remote bridge client (`herdr --remote`) sits behind a fresh per-attach ssh +/// connection whose cold-connect (TCP + key exchange + auth) happens inside this +/// window; on a high-latency link that easily exceeds 5s, so it gets a far +/// larger budget. See issue #753. +pub(super) const LOCAL_HANDSHAKE_READ_TIMEOUT: Duration = Duration::from_secs(5); +pub(super) const REMOTE_HANDSHAKE_READ_TIMEOUT: Duration = Duration::from_secs(60); + +pub(super) fn is_remote_client_process() -> bool { + std::env::var(crate::remote::REMOTE_KEYBINDINGS_ENV_VAR).is_ok() +} + +pub(super) fn client_shell_keybinding_source() -> shell::ClientShellKeybindingSource { + match std::env::var(crate::remote::REMOTE_KEYBINDINGS_ENV_VAR) + .ok() + .as_deref() + { + Some("server") => shell::ClientShellKeybindingSource::Endpoint, + Some(_) => shell::ClientShellKeybindingSource::RemoteLocal, + None => shell::ClientShellKeybindingSource::Local, + } +} + +pub(super) fn handshake_read_timeout() -> Duration { + if is_remote_client_process() { + return REMOTE_HANDSHAKE_READ_TIMEOUT; + } + LOCAL_HANDSHAKE_READ_TIMEOUT +} + +#[cfg(any(unix, test))] +pub(super) fn direct_graphics_profile_values( + term_program: &str, + term: &str, + kitty_window: bool, + blocked_transport: bool, + terminals: bool, +) -> bool { + let supported = term_program.eq_ignore_ascii_case("ghostty") + || term_program.eq_ignore_ascii_case("wezterm") + || matches!(term, "xterm-ghostty" | "xterm-kitty" | "xterm-wezterm") + || kitty_window; + supported && !blocked_transport && terminals +} + +#[cfg(unix)] +fn direct_graphics_profile_allowed() -> bool { + let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default(); + let term = std::env::var("TERM").unwrap_or_default(); + direct_graphics_profile_values( + &term_program, + &term, + std::env::var_os("KITTY_WINDOW_ID").is_some(), + is_remote_client_process() + || is_ssh_session() + || std::env::var_os("TMUX").is_some() + || std::env::var_os("STY").is_some(), + io::stdin().is_terminal() && io::stdout().is_terminal(), + ) +} + +#[cfg(not(unix))] +fn direct_graphics_profile_allowed() -> bool { + false +} + +#[cfg(windows)] +fn set_handshake_recv_timeout( + stream: &LocalStream, + timeout: Option, + context: &'static str, +) -> Result<(), ClientError> { + match stream.set_recv_timeout(timeout) { + Ok(()) => Ok(()), + Err(err) if err.kind() == io::ErrorKind::Unsupported => { + debug!(err = %err, context, "client socket receive timeout unavailable"); + Ok(()) + } + Err(err) => Err(ClientError::ConnectionFailed(err)), + } +} + +#[cfg(not(windows))] +fn set_handshake_recv_timeout( + stream: &LocalStream, + timeout: Option, + _context: &'static str, +) -> Result<(), ClientError> { + stream + .set_recv_timeout(timeout) + .map_err(ClientError::ConnectionFailed) +} + +/// Performs the client→server handshake. +/// +/// Sends TerminalHello (or ClientShellHello) with the terminal size and protocol +/// version, then reads the Welcome response. +pub(super) fn do_handshake( + stream: &mut LocalStream, + cols: u16, + rows: u16, + cell_width_px: u32, + cell_height_px: u32, + exact_cell_size: bool, + shell_surface_size: Option, + endpoint_keybindings: bool, + mouse_capture: bool, +) -> Result { + stream + .set_nonblocking(false) + .map_err(ClientError::ConnectionFailed)?; + + let hello = if let Some(surface_size) = shell_surface_size { + ClientMessage::ClientShellHello { + version: PROTOCOL_VERSION, + cell_width_px, + cell_height_px, + surface_size, + pixel_mouse: exact_cell_size && cfg!(unix), + direct_graphics: exact_cell_size + && cell_width_px > 0 + && cell_height_px > 0 + && direct_graphics_profile_allowed(), + endpoint_keybindings, + mouse_capture, + } + } else { + ClientMessage::TerminalHello { + version: PROTOCOL_VERSION, + cols, + rows, + cell_width_px, + cell_height_px, + pixel_mouse: exact_cell_size && cfg!(unix), + } + }; + protocol::write_message(stream, &hello) + .map_err(|e| ClientError::ConnectionFailed(io::Error::other(e.to_string())))?; + + set_handshake_recv_timeout( + stream, + Some(handshake_read_timeout()), + "client handshake read timeout unavailable", + )?; + let welcome: ServerMessage = protocol::read_message(stream, MAX_FRAME_SIZE)?; + set_handshake_recv_timeout( + stream, + None, + "failed to clear client handshake read timeout", + )?; + + match welcome { + ServerMessage::Welcome { + version, + encoding, + error, + } => { + if let Some(error) = error { + return Err(ClientError::HandshakeRejected { version, error }); + } + info!(version, ?encoding, "handshake succeeded"); + Ok(encoding) + } + _ => Err(ClientError::Protocol(protocol::FramingError::Io( + io::Error::new(io::ErrorKind::InvalidData, "expected Welcome message"), + ))), + } +} -- cgit v1.2.3