diff options
Diffstat (limited to 'src/client/mod.rs')
| -rw-r--r-- | src/client/mod.rs | 3829 |
1 files changed, 1027 insertions, 2802 deletions
diff --git a/src/client/mod.rs b/src/client/mod.rs index 53e818d7..12efa8e3 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -1,7 +1,7 @@ //! Thin client mode — connects to the server's client socket. //! //! The client: -//! - Connects to `herdr-client.sock`, sends Hello with terminal size and protocol version +//! - Connects to `herdr-client.sock`, sends TerminalHello with terminal size and protocol version //! - Sets up the real terminal (raw mode, mouse capture, keyboard enhancements) //! - Receives Frame messages and blits them to the terminal (diff against last frame) //! - Reads stdin events (keystrokes, mouse, paste) and sends them as ClientMessage::Input @@ -12,45 +12,105 @@ //! - Forwards OSC 52 clipboard writes from server to its own stdout //! - Displays sound/toast notifications forwarded from server +mod attach; +mod clipboard_images; #[cfg(unix)] mod direct_graphics; +mod endpoint_commands; +mod errors; +mod frame_output; +mod handshake; mod input; +mod notifications; +mod shell; +mod terminal_geometry; +mod terminal_sessions; +mod terminal_setup; +mod timer; -use std::collections::HashSet; +#[cfg(test)] +pub(crate) use shell::{ClientShellConfig, ClientShellState}; +pub use terminal_sessions::{run_terminal_session_control, run_terminal_session_observe}; + +#[cfg(not(windows))] +use terminal_geometry::query_host_terminal_appearance; +#[cfg(test)] +use terminal_geometry::{ + cell_size_fallback, ioctl_cell_size, pack_cell_size, resize_report_required, + should_query_host_cell_size, write_host_cell_size_query, write_host_terminal_appearance_query, + write_host_terminal_theme_query, +}; +use terminal_geometry::{ + host_cell_size_query_required, initial_terminal_geometry, query_host_cell_size, + query_host_terminal_theme, resize_poll_loop, should_query_host_terminal_theme, +}; #[cfg(unix)] -use std::io::IsTerminal as _; -use std::io::{self, BufRead, Write as _}; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::{Arc, Mutex, OnceLock}; -use std::time::Duration; +use terminal_geometry::{reported_cell_size_from_events, store_reported_cell_size}; +use terminal_setup::{ + effective_mouse_capture, effective_sgr_pixel_mouse, set_mouse_capture, + setup_direct_attach_terminal, setup_terminal, should_draw_host_cursor, +}; +#[cfg(windows)] +use terminal_setup::{ + enable_windows_virtual_terminal_input, is_ssh_session, windows_vti_input_backend_enabled, +}; +#[cfg(test)] +use terminal_setup::{ + should_enable_host_color_scheme_reports, windows_virtual_terminal_input_mode, + write_host_color_scheme_report_mode, write_terminal_restore_postlude, +}; -use base64::Engine; -use crossterm::event::{ - DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste, - EnableFocusChange, EnableMouseCapture, +#[cfg(unix)] +use attach::direct_attach_pixel_mouse; +use attach::AttachEscapeState; +#[cfg(unix)] +use attach::{write_attach_semantic_action, AttachInputAction}; +use clipboard_images::{client_remote_image_paste_key, write_remote_image_to_server}; +#[cfg(windows)] +use clipboard_images::{read_image_file_from_client_events, should_bridge_clipboard_image_events}; +#[cfg(unix)] +use clipboard_images::{read_image_file_from_terminal_drop, should_bridge_clipboard_image_paste}; +pub use errors::ClientError; +#[cfg(test)] +use frame_output::{clear_received_kitty_graphics, kitty_graphics_image_ids}; +use frame_output::{ + contains_kitty_graphics_bytes, record_received_kitty_graphics, + write_encoded_frame_with_graphics, +}; +use handshake::{client_shell_keybinding_source, do_handshake, is_remote_client_process}; +#[cfg(test)] +use handshake::{ + direct_graphics_profile_values, handshake_read_timeout, REMOTE_HANDSHAKE_READ_TIMEOUT, }; +use notifications::{handle_notify, handle_shell_notification_effects}; +#[cfg(test)] +use notifications::{handle_notify_with_notifiers, sound_from_notify_message}; +#[cfg(test)] +use terminal_sessions::terminal_control_command_from_json; + #[cfg(unix)] -use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers, MouseEventKind}; -#[cfg(not(windows))] -use crossterm::event::{PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags}; -use crossterm::execute; -use crossterm::terminal::{DisableLineWrap, EnableLineWrap}; +use std::collections::HashMap; +use std::io::{self, Write as _}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; +#[cfg(unix)] +use std::sync::Mutex; +use std::time::Duration; + 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::MAX_CLIPBOARD_IMAGE_PAYLOAD; use crate::protocol::{ - self, AttachScrollDirection, AttachScrollSource, ClientKeybindings, ClientLaunchMode, - ClientMessage, NotifyKind, RenderEncoding, ServerMessage, MAX_FRAME_SIZE, - MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION, + self, ClientMessage, FrameData, RenderEncoding, ServerMessage, MAX_FRAME_SIZE, + MAX_GRAPHICS_FRAME_SIZE, }; +#[cfg(test)] +use crate::protocol::{AttachScrollDirection, AttachScrollSource, NotifyKind}; use crate::server::socket_paths::client_socket_path; -static RECEIVED_KITTY_GRAPHICS_IDS: OnceLock<Mutex<HashSet<u32>>> = OnceLock::new(); - // --------------------------------------------------------------------------- // Client state // --------------------------------------------------------------------------- @@ -61,8 +121,11 @@ struct ClientLoopConfig { redraw_on_focus_gained: bool, host_cursor: crate::config::HostCursorModeConfig, kitty_graphics_enabled: bool, + pixel_geometry_enabled: bool, + pixel_geometry_fallback: bool, mouse_capture_active: bool, remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, + shell_config: Option<shell::ClientShellConfig>, } /// State tracking for the thin client. @@ -71,21 +134,42 @@ struct ClientState { blit_encoder: render_ansi::BlitEncoder, /// Whether host mouse capture is currently active. mouse_capture_active: bool, - /// Whether the host terminal currently reports all keys as Kitty sequences. + /// Last mouse-capture demand published by the endpoint. + endpoint_mouse_capture_requested: bool, + /// Last exact-pixel mouse demand published by the endpoint. + endpoint_sgr_pixels_requested: bool, + /// Client-local direct-attach preference, combined with child mouse demand. + direct_mouse_capture_preference: bool, + /// Client-owned shell mouse-capture preference. + shell_mouse_capture_preference: bool, + /// Host keyboard protocol state currently owned for a direct terminal attach. + direct_keyboard_protocol: crate::terminal_modes::DirectHostKeyboardState, + /// Focused endpoint pane/popup demand for Kitty report-all input. + pane_keyboard_report_all: bool, + /// Whether the client-owned shell currently enabled host report-all input. keyboard_report_all_active: bool, - /// The terminal size we reported to the server in our last Hello/Resize. + /// The terminal size we reported to the server in our last handshake/Resize. reported_size: (u16, u16), + /// Last exact host cell size used by client-rendered surfaces. + reported_cell_size: (u32, u32), /// Client-local sound playback config, refreshed on server request. sound_config: crate::config::SoundConfig, /// Whether this client may write Kitty graphics bytes to its host terminal. kitty_graphics_enabled: bool, + /// Whether resize reports inspect host pixel geometry. + pixel_geometry_enabled: bool, + /// Whether the latest host pixel geometry is exact enough for pixel mouse input. + pixel_geometry_exact: bool, /// One bounded matcher, inactive unless a direct transmission is armed. #[cfg(unix)] direct_graphics_response: Arc<Mutex<direct_graphics::ResponseMatcher>>, /// One server-retired direct transfer to suppress if it was still queued. #[cfg(unix)] retired_direct_graphics: Option<(u64, u32)>, - /// Direct attach prefix escape state. None for full-app clients. + /// ClientShell assets waiting for the host terminal's direct-upload response. + #[cfg(unix)] + pending_surface_graphics: HashMap<u64, crate::protocol::SurfaceGraphicsAssetKey>, + /// Direct attach prefix escape state. None for ClientShell connections. attach_escape: Option<AttachEscapeState>, /// Rows scrolled for one direct-attach wheel notch. #[cfg(unix)] @@ -98,142 +182,22 @@ struct ClientState { repaint_pending: bool, /// Whether this client draws the cursor into frame cells instead of using the host cursor. draw_host_cursor: bool, + /// Browser opener processes launched by client-owned link activation. + detached_process_children: Vec<std::process::Child>, + /// Experimental client-owned shell state. + shell: Option<shell::ClientShellState>, } -#[derive(Debug, Default)] -#[cfg(windows)] -struct AttachEscapeState; - -#[derive(Debug, Default)] -#[cfg(unix)] -struct AttachEscapeState { - pending_prefix: bool, -} - -#[derive(Debug)] -#[cfg(unix)] -enum AttachInputAction { - Forward(Vec<u8>), - ForwardAfterPendingPrefix(Vec<u8>), - Scroll { - source: AttachScrollSource, - direction: AttachScrollDirection, - lines: u16, - column: Option<u16>, - row: Option<u16>, - modifiers: u8, - }, - Detach, - None, -} - -impl AttachEscapeState { - #[cfg(unix)] - fn filter_input( - &mut self, - data: Vec<u8>, - viewport_rows: u16, - mouse_scroll_lines: usize, - ) -> AttachInputAction { - const PREFIX: u8 = 0x02; // Ctrl+B - - if crate::raw_input::is_complete_text_bracketed_paste(&data) { - return if std::mem::take(&mut self.pending_prefix) { - AttachInputAction::ForwardAfterPendingPrefix(data) - } else { - AttachInputAction::Forward(data) - }; - } - - let mut output = Vec::with_capacity(data.len()); - for byte in data { - if self.pending_prefix { - self.pending_prefix = false; - match byte { - b'q' => return AttachInputAction::Detach, - PREFIX => output.push(PREFIX), - other => { - output.push(PREFIX); - output.push(other); - } - } - continue; - } - - if byte == PREFIX { - self.pending_prefix = true; - } else { - output.push(byte); - } - } - - if output.is_empty() { - AttachInputAction::None - } else if let Some(action) = - attach_scroll_action(&output, viewport_rows, mouse_scroll_lines) - { - action - } else { - AttachInputAction::Forward(output) - } - } -} - -#[cfg(unix)] -fn attach_scroll_action( - data: &[u8], - viewport_rows: u16, - mouse_scroll_lines: usize, -) -> Option<AttachInputAction> { - let mut events = crate::raw_input::parse_raw_input_bytes_sync(data); - if events.len() != 1 { - return None; - } - - match events.pop()? { - crate::raw_input::RawInputEvent::Mouse(mouse) => { - let direction = match mouse.kind { - MouseEventKind::ScrollUp => AttachScrollDirection::Up, - MouseEventKind::ScrollDown => AttachScrollDirection::Down, - _ => return Some(AttachInputAction::None), - }; - Some(AttachInputAction::Scroll { - source: AttachScrollSource::Wheel, - direction, - lines: mouse_scroll_lines.max(1).min(u16::MAX as usize) as u16, - column: Some(mouse.column), - row: Some(mouse.row), - modifiers: mouse.modifiers.bits(), - }) - } - crate::raw_input::RawInputEvent::Key(key) - if key.modifiers.is_empty() - && matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) => - { - let direction = match key.code { - KeyCode::PageUp => AttachScrollDirection::Up, - KeyCode::PageDown => AttachScrollDirection::Down, - _ => return None, - }; - Some(AttachInputAction::Scroll { - source: AttachScrollSource::PageKey { - input: data.to_vec(), - }, - direction, - lines: viewport_rows.saturating_sub(1).max(1), - column: None, - row: None, - modifiers: KeyModifiers::empty().bits(), - }) - } - crate::raw_input::RawInputEvent::Key(key) - if key.modifiers.is_empty() - && key.kind == KeyEventKind::Release - && matches!(key.code, KeyCode::PageUp | KeyCode::PageDown) => - { - Some(AttachInputAction::None) +impl Drop for ClientState { + fn drop(&mut self) { + if self.attach_escape.is_some() { + let _ = crate::terminal_modes::set_direct_host_keyboard_protocol( + &mut io::stdout(), + &mut self.direct_keyboard_protocol, + 0, + 0, + ); } - _ => None, } } @@ -241,666 +205,81 @@ impl ClientState { fn request_repaint(&mut self) { self.repaint_pending = true; } -} - -// --------------------------------------------------------------------------- -// Error types -// --------------------------------------------------------------------------- - -/// Errors that can occur during client operation. -#[derive(Debug)] -pub enum ClientError { - /// Could not connect to the server's client socket. - ConnectionFailed(io::Error), - /// Server rejected our handshake. - HandshakeRejected { version: u32, error: String }, - /// Server shut down. - ServerShutdown { reason: Option<String> }, - /// Lost connection to the server. - ConnectionLost(io::Error), - /// Protocol error (framing, deserialization). - Protocol(protocol::FramingError), -} - -impl std::fmt::Display for ClientError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ClientError::ConnectionFailed(err) => { - write!(f, "failed to connect to server: {err}")?; - let path = client_socket_path(); - write!( - f, - "\nIs herdr server running? Start it with `herdr server`." - )?; - write!(f, "\nSocket path: {}", path.display()) - } - ClientError::HandshakeRejected { version, error } => { - write!(f, "server rejected handshake (version {version}): {error}") - } - ClientError::ServerShutdown { reason } => { - match reason.as_deref() { - Some("detached") => { - if let Ok(reattach_command) = - std::env::var(crate::remote::REATTACH_COMMAND_ENV_VAR) - { - write!(f, "detached from remote server")?; - write!(f, "\nRun `{reattach_command}` to reattach")?; - } else { - write!(f, "detached from server")?; - write!( - f, - "\nRun `{}` to reattach", - crate::session::local_attach_command() - )?; - } - } - _ => { - write!(f, "server shut down")?; - if let Some(reason) = reason { - write!(f, ": {reason}")?; - } - } - } - Ok(()) - } - ClientError::ConnectionLost(err) => { - if let Ok(reattach_command) = std::env::var(crate::remote::REATTACH_COMMAND_ENV_VAR) - { - write!(f, "lost connection to remote Herdr: {err}")?; - write!(f, "\nIf the remote server survived the SSH or network drop, its panes may still be running.")?; - write!(f, "\nRun `{reattach_command}` to reattach") - } else { - write!(f, "lost connection to server: {err}") - } - } - ClientError::Protocol(err) => { - write!(f, "protocol error: {err}") - } - } - } -} -impl std::error::Error for ClientError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - ClientError::ConnectionFailed(err) => Some(err), - ClientError::ConnectionLost(err) => Some(err), - ClientError::Protocol(err) => Some(err), - _ => None, + fn present_graphics(&mut self, graphics: &[u8]) { + if graphics.is_empty() || !self.kitty_graphics_enabled { + return; } + let mut stdout = io::stdout(); + let _ = write_encoded_frame_with_graphics(&mut stdout, &[], graphics); + let _ = stdout.flush(); } -} -impl From<protocol::FramingError> for ClientError { - fn from(err: protocol::FramingError) -> Self { - match err { - protocol::FramingError::UnexpectedEof => ClientError::ConnectionLost(io::Error::new( - io::ErrorKind::UnexpectedEof, - "server closed connection", - )), - protocol::FramingError::Io(err) => ClientError::ConnectionLost(err), - err => ClientError::Protocol(err), + fn present_surface_patch( + &mut self, + patch: shell::ClientComposedSurfacePatch, + ) -> io::Result<bool> { + if self.repaint_pending { + crate::render_prof::event("client_surface_patch.fallback.repaint"); + return Ok(false); } - } -} - -// --------------------------------------------------------------------------- -// Terminal setup / restore -// --------------------------------------------------------------------------- - -/// Sets up the terminal for client mode (raw mode, optional mouse, keyboard enhancements). -/// -/// Returns a guard that restores the terminal when dropped. -fn setup_terminal(mouse_capture: bool) -> io::Result<TerminalGuard> { - setup_terminal_with_capabilities(true, mouse_capture) -} - -/// Sets up a direct attach terminal. -/// -/// Direct attach forwards stdin to the attached PTY. When configured, mouse -/// capture lets wheel events drive the attached viewport or reach child -/// programs that requested mouse input. -fn setup_direct_attach_terminal(mouse_capture: bool) -> io::Result<TerminalGuard> { - setup_terminal_with_capabilities(false, mouse_capture) -} - -fn setup_terminal_with_capabilities( - enable_client_protocols: bool, - mouse_capture: bool, -) -> io::Result<TerminalGuard> { - ratatui::init(); - crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout())?; - let host_color_scheme_reports = - should_enable_host_color_scheme_reports(enable_client_protocols); - - #[cfg(windows)] - let windows_ssh_session = is_ssh_session(); - #[cfg(windows)] - let mut windows_virtual_terminal_input = - if windows_vti_input_backend_enabled() && windows_ssh_session { - enable_windows_virtual_terminal_input() + let rows = if self.draw_host_cursor { + let Some(rows) = self + .blit_encoder + .patch_rows_with_drawn_cursor(&patch.rows, patch.cursor.as_ref()) + else { + crate::render_prof::event("client_surface_patch.fallback.drawn_cursor"); + return Ok(false); + }; + rows } else { - WindowsVirtualTerminalInputSetup::default() + patch.rows }; - - if enable_client_protocols { - set_mouse_capture(mouse_capture, false)?; - execute!(io::stdout(), EnableBracketedPaste, EnableFocusChange)?; - if host_color_scheme_reports { - write_host_color_scheme_report_mode(&mut io::stdout(), true)?; - } - push_keyboard_enhancement_flags()?; - } else { - if should_query_host_terminal_theme() { - write_host_color_scheme_report_mode(&mut io::stdout(), false)?; - } - set_mouse_capture(mouse_capture, false)?; - execute!(io::stdout(), EnableBracketedPaste)?; - } - - #[cfg(windows)] - if enable_client_protocols && windows_vti_input_backend_enabled() && !windows_ssh_session { - windows_virtual_terminal_input = enable_windows_virtual_terminal_input(); - } - - #[cfg(windows)] - if enable_client_protocols - && windows_vti_input_backend_enabled() - && windows_virtual_terminal_input.active - && windows_win32_input_mode_enabled() - { - if let Err(err) = enable_windows_win32_input_mode(&mut io::stdout()) { - if let Some(mode) = windows_virtual_terminal_input.restore_mode { - restore_windows_input_mode_value(mode); - } - return Err(err); - } - } - - let modify_other_keys_mode = enable_client_protocols - .then(crate::input::host_modify_other_keys_mode) - .flatten(); - if let Some(mode) = modify_other_keys_mode { - io::stdout().write_all(mode.set_sequence())?; - io::stdout().flush()?; - } - - execute!(io::stdout(), DisableLineWrap)?; - - Ok(TerminalGuard { - reset_modify_other_keys: modify_other_keys_mode.is_some(), - reset_host_color_scheme_reports: host_color_scheme_reports, - restored: false, - #[cfg(windows)] - restore_windows_input_mode: windows_virtual_terminal_input.restore_mode, - }) -} - -fn should_enable_host_color_scheme_reports(enable_client_protocols: bool) -> bool { - enable_client_protocols && should_query_host_terminal_theme() -} - -/// Guard that restores the terminal when dropped. -struct TerminalGuard { - reset_modify_other_keys: bool, - reset_host_color_scheme_reports: bool, - restored: bool, - #[cfg(windows)] - restore_windows_input_mode: Option<u32>, -} - -fn write_host_color_scheme_report_mode( - writer: &mut impl io::Write, - enabled: bool, -) -> io::Result<()> { - let sequence = if enabled { - crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_ENABLE_SEQUENCE - } else { - crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_DISABLE_SEQUENCE - }; - writer.write_all(sequence.as_bytes())?; - writer.flush() -} - -fn write_terminal_restore_postlude( - writer: &mut impl io::Write, - reset_host_color_scheme_reports: bool, -) -> io::Result<()> { - if reset_host_color_scheme_reports { - writer.write_all( - crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_DISABLE_SEQUENCE.as_bytes(), - )?; - } - // Restore a visible cursor and reset DECSCUSR back to the terminal default. - writer.write_all(b"\x1b[?25h\x1b[0 q")?; - writer.flush() -} - -fn should_draw_host_cursor(mode: crate::config::HostCursorModeConfig) -> bool { - match mode { - crate::config::HostCursorModeConfig::Auto => { - crate::platform::should_draw_host_cursor_by_default() - } - crate::config::HostCursorModeConfig::Native => false, - crate::config::HostCursorModeConfig::Drawn => true, - } -} - -#[cfg(windows)] -#[derive(Default)] -struct WindowsVirtualTerminalInputSetup { - active: bool, - restore_mode: Option<u32>, -} - -#[cfg(windows)] -fn enable_windows_virtual_terminal_input() -> WindowsVirtualTerminalInputSetup { - use windows_sys::Win32::Foundation::{HANDLE, INVALID_HANDLE_VALUE}; - use windows_sys::Win32::System::Console::{ - GetConsoleMode, GetStdHandle, SetConsoleMode, ENABLE_VIRTUAL_TERMINAL_INPUT, - STD_INPUT_HANDLE, - }; - - let handle: HANDLE = unsafe { GetStdHandle(STD_INPUT_HANDLE) }; - if handle.is_null() || handle == INVALID_HANDLE_VALUE { - tracing::warn!("failed to get Windows console input handle for VT input"); - return WindowsVirtualTerminalInputSetup::default(); - } - - let mut mode = 0; - if unsafe { GetConsoleMode(handle, &mut mode) } == 0 { - tracing::warn!("failed to read Windows console input mode for VT input"); - return WindowsVirtualTerminalInputSetup::default(); - } - - let desired = windows_virtual_terminal_input_mode(mode); - if desired == mode { - return WindowsVirtualTerminalInputSetup { - active: true, - restore_mode: None, + let encode_started = crate::render_prof::timer(); + let Some(encoded) = + self.blit_encoder + .encode_patch(&rows, patch.cursor.clone(), self.draw_host_cursor) + else { + crate::render_prof::event("client_surface_patch.fallback.encode"); + return Ok(false); }; + crate::render_prof::duration_since("client_surface_patch.encode", encode_started); + let write_started = crate::render_prof::timer(); + let mut stdout = io::stdout(); + stdout.write_all(&encoded.bytes)?; + stdout.flush()?; + crate::render_prof::duration_since("client_surface_patch.write", write_started); + let committed = self.blit_encoder.commit_patch(&rows, patch.cursor, encoded); + crate::render_prof::event(if committed { + "client_surface_patch.success" + } else { + "client_surface_patch.fallback.commit" + }); + Ok(committed) } - if unsafe { SetConsoleMode(handle, desired) } == 0 { - tracing::warn!("failed to enable Windows virtual terminal input"); - return WindowsVirtualTerminalInputSetup::default(); - } - - let mut applied = 0; - if unsafe { GetConsoleMode(handle, &mut applied) } == 0 { - tracing::warn!("failed to verify Windows virtual terminal input mode"); - let _ = unsafe { SetConsoleMode(handle, mode) }; - return WindowsVirtualTerminalInputSetup::default(); - } - if applied & ENABLE_VIRTUAL_TERMINAL_INPUT == 0 { - tracing::warn!("Windows virtual terminal input bit did not stick"); - let _ = unsafe { SetConsoleMode(handle, mode) }; - return WindowsVirtualTerminalInputSetup::default(); - } - - WindowsVirtualTerminalInputSetup { - active: true, - restore_mode: Some(mode), - } -} - -#[cfg(windows)] -fn windows_vti_input_backend_enabled() -> bool { - std::env::var("HERDR_WINDOWS_INPUT_BACKEND") - .map(|backend| !backend.eq_ignore_ascii_case("crossterm")) - .unwrap_or(true) -} - -#[cfg(any(windows, test))] -fn windows_virtual_terminal_input_mode(mode: u32) -> u32 { - mode | 0x0200 -} - -#[cfg(windows)] -fn restore_windows_input_mode_value(mode: u32) { - use windows_sys::Win32::Foundation::{HANDLE, INVALID_HANDLE_VALUE}; - use windows_sys::Win32::System::Console::{GetStdHandle, SetConsoleMode, STD_INPUT_HANDLE}; - - let handle: HANDLE = unsafe { GetStdHandle(STD_INPUT_HANDLE) }; - if handle.is_null() || handle == INVALID_HANDLE_VALUE { - return; - } - if unsafe { SetConsoleMode(handle, mode) } == 0 { - tracing::warn!("failed to restore Windows console input mode"); - } -} - -fn set_mouse_capture(enabled: bool, sgr_pixels: bool) -> io::Result<()> { - crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout())?; - #[cfg(windows)] - if is_ssh_session() && windows_vti_input_backend_enabled() { - return crate::terminal_modes::set_windows_ssh_mouse_reporting( - &mut io::stdout(), - enabled, - sgr_pixels, - ); - } - if enabled { - execute!(io::stdout(), EnableMouseCapture)?; - if sgr_pixels { - io::stdout().write_all(b"\x1b[?1016h")?; - io::stdout().flush()?; - } - Ok(()) - } else { - 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), - } - } -} - -fn restore_terminal_state( - reset_modify_other_keys: bool, - reset_host_color_scheme_reports: bool, - #[cfg(windows)] restore_windows_input_mode: Option<u32>, -) -> io::Result<()> { - let _ = clear_received_kitty_graphics(&mut io::stdout()); - - // Reset modifyOtherKeys if we enabled it. - if reset_modify_other_keys { - let _ = io::stdout().write_all(b"\x1b[>4;0m"); - let _ = io::stdout().flush(); - } - - let _ = pop_keyboard_enhancement_flags(); - - let _ = execute!( - io::stdout(), - EnableLineWrap, - DisableFocusChange, - DisableBracketedPaste - ); - let _ = set_mouse_capture(false, false); - #[cfg(windows)] - if let Some(mode) = restore_windows_input_mode { - restore_windows_input_mode_value(mode); - } - - let restore_result = ratatui::try_restore(); - let postlude_result = - write_terminal_restore_postlude(&mut io::stdout(), reset_host_color_scheme_reports); - - #[cfg(windows)] - if windows_vti_input_backend_enabled() && windows_win32_input_mode_enabled() { - let _ = disable_windows_win32_input_mode(&mut io::stdout()); - } - - restore_result.and(postlude_result) -} - -#[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(()) -} - -#[cfg(windows)] -fn windows_win32_input_mode_enabled() -> bool { - std::env::var("HERDR_WINDOWS_INPUT_PROBE") - .map(|probe| probe.eq_ignore_ascii_case("win32")) - .unwrap_or(true) -} - -#[cfg(windows)] -fn enable_windows_win32_input_mode(writer: &mut impl std::io::Write) -> io::Result<()> { - writer.write_all(b"\x1b[?9001h")?; - writer.flush() -} - -#[cfg(windows)] -fn disable_windows_win32_input_mode(writer: &mut impl std::io::Write) -> io::Result<()> { - writer.write_all(b"\x1b[?9001l")?; - writer.flush() -} - -impl TerminalGuard { - fn restore(mut self) -> io::Result<()> { - self.restored = true; - restore_terminal_state( - self.reset_modify_other_keys, - self.reset_host_color_scheme_reports, - #[cfg(windows)] - self.restore_windows_input_mode, - ) - } -} - -impl Drop for TerminalGuard { - fn drop(&mut self) { - if !self.restored { - let _ = restore_terminal_state( - self.reset_modify_other_keys, - self.reset_host_color_scheme_reports, - #[cfg(windows)] - self.restore_windows_input_mode, - ); - } - } -} - -// --------------------------------------------------------------------------- -// Handshake -// --------------------------------------------------------------------------- - -fn requested_render_encoding() -> RenderEncoding { - match std::env::var("HERDR_RENDER_ENCODING").ok().as_deref() { - Some("terminal-ansi" | "terminal_ansi" | "ansi") => RenderEncoding::TerminalAnsi, - _ => RenderEncoding::SemanticFrame, - } -} - -fn is_remote_client_process() -> bool { - std::env::var(crate::remote::REMOTE_KEYBINDINGS_ENV_VAR).is_ok() -} - -fn is_ssh_session() -> bool { - std::env::var_os("SSH_CONNECTION").is_some() || std::env::var_os("SSH_TTY").is_some() -} - -/// 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. -const LOCAL_HANDSHAKE_READ_TIMEOUT: Duration = Duration::from_secs(5); -const REMOTE_HANDSHAKE_READ_TIMEOUT: Duration = Duration::from_secs(60); - -fn handshake_read_timeout() -> Duration { - if is_remote_client_process() { - return REMOTE_HANDSHAKE_READ_TIMEOUT; - } - LOCAL_HANDSHAKE_READ_TIMEOUT -} - -#[cfg(any(unix, test))] -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(direct_attach: bool) -> 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(), - direct_attach - || 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(_direct_attach: bool) -> bool { - false -} - -fn requested_keybindings() -> ClientKeybindings { - match std::env::var(crate::remote::REMOTE_KEYBINDINGS_ENV_VAR) - .ok() - .as_deref() - { - Some("local") => crate::config::Config::load() - .config - .local_keybindings_profile_toml() - .map(|keys_toml| ClientKeybindings::Local { keys_toml }) - .unwrap_or(ClientKeybindings::Server), - _ => ClientKeybindings::Server, - } -} - -#[cfg(windows)] -fn set_handshake_recv_timeout( - stream: &LocalStream, - timeout: Option<Duration>, - 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<Duration>, - _context: &'static str, -) -> Result<(), ClientError> { - stream - .set_recv_timeout(timeout) - .map_err(ClientError::ConnectionFailed) -} - -fn client_launch_mode( - direct_attach_requested: bool, - exact_cell_size: bool, - cell_width_px: u32, - cell_height_px: u32, -) -> ClientLaunchMode { - if direct_attach_requested { - ClientLaunchMode::TerminalAttach - } else if exact_cell_size - && cell_width_px > 0 - && cell_height_px > 0 - && direct_graphics_profile_allowed(false) - { - ClientLaunchMode::AppDirectGraphics - } else { - ClientLaunchMode::App - } -} - -/// Performs the client→server handshake. -/// -/// 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 LocalStream, - cols: u16, - rows: u16, - cell_width_px: u32, - cell_height_px: u32, - exact_cell_size: bool, - requested_encoding: RenderEncoding, - direct_attach_requested: bool, -) -> Result<RenderEncoding, ClientError> { - stream - .set_nonblocking(false) - .map_err(ClientError::ConnectionFailed)?; - - // Send Hello. - let hello = ClientMessage::Hello { - version: PROTOCOL_VERSION, - cols, - rows, - cell_width_px, - cell_height_px, - requested_encoding, - keybindings: requested_keybindings(), - launch_mode: client_launch_mode( - direct_attach_requested, - exact_cell_size, - cell_width_px, - cell_height_px, - ), - }; - protocol::write_message(stream, &hello) - .map_err(|e| ClientError::ConnectionFailed(io::Error::other(e.to_string())))?; - - // Read Welcome. - 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"), - ))), + fn present_frame(&mut self, frame_data: FrameData) { + let frame_data = if self.draw_host_cursor { + render_ansi::frame_with_drawn_cursor(frame_data) + } else { + frame_data + }; + let encoded = if self.draw_host_cursor { + self.blit_encoder + .encode_with_suppressed_visible_cursor(&frame_data, self.repaint_pending) + } else { + self.blit_encoder.encode(&frame_data, self.repaint_pending) + }; + let mut stdout = io::stdout(); + let graphics = if self.kitty_graphics_enabled { + frame_data.graphics.as_slice() + } else { + &[] + }; + let _ = write_encoded_frame_with_graphics(&mut stdout, &encoded.bytes, graphics); + let _ = stdout.flush(); + self.blit_encoder.commit(frame_data, encoded); + self.repaint_pending = false; } } @@ -921,10 +300,10 @@ enum ClientLoopEvent { /// Structured input events from platforms without Unix-style stdin bytes. #[cfg(windows)] StdinEvents(Vec<crate::protocol::ClientInputEvent>), - /// Terminal resize detected. - Resize(u16, u16, u32, u32), + /// Terminal resize detected, including current exact-pixel eligibility. + Resize(u16, u16, u32, u32, bool), /// Server message received. - ServerMessage(ServerMessage), + ServerMessage(Box<ServerMessage>), /// Server reader thread exited (connection lost). ServerDisconnected, /// Timer tick. @@ -936,19 +315,13 @@ enum ClientLoopEvent { /// /// This is the entry point called from `main.rs` when running in client mode. pub fn run_client() -> io::Result<()> { - run_client_with_mode( - requested_render_encoding(), - None, - None, - "connecting to server", - ) + run_client_with_mode(None, None, "connecting to server") } /// 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, Some((terminal_id, takeover)), Some(AttachEscapeState::default()), "attaching to terminal", @@ -965,266 +338,7 @@ pub fn run_terminal_attach(_terminal_id: String, _takeover: bool) -> io::Result< )) } -/// Runs a read-only terminal session observer and prints one JSON envelope per frame. -pub fn run_terminal_session_observe(target: String, cols: u16, rows: u16) -> io::Result<()> { - let mut stream = - connect_terminal_session_stream(target.clone(), cols, rows, "observing terminal session")?; - write_to_server(&mut stream, &ClientMessage::ObserveTerminal { target })?; - write_terminal_session_output(stream) -} - -/// Runs a writable terminal session controller. -pub fn run_terminal_session_control( - target: String, - takeover: bool, - cols: u16, - rows: u16, -) -> io::Result<()> { - let mut stream = connect_terminal_session_stream( - target.clone(), - cols, - rows, - "controlling terminal session", - )?; - write_to_server( - &mut stream, - &ClientMessage::ControlTerminal { target, takeover }, - )?; - - let mut write_stream = stream.try_clone()?; - let _input_thread = std::thread::spawn(move || { - let stdin = io::stdin(); - for line in stdin.lock().lines() { - let Ok(line) = line else { - break; - }; - if line.trim().is_empty() { - continue; - } - match terminal_control_command_from_json(&line) { - Ok(message) => { - let release = matches!(message, ClientMessage::Detach); - if write_to_server(&mut write_stream, &message).is_err() { - return; - } - if release { - return; - } - } - Err(err) => eprintln!("herdr: terminal session control input ignored: {err}"), - } - } - let _ = write_to_server(&mut write_stream, &ClientMessage::Detach); - }); - - write_terminal_session_output(stream) -} - -fn connect_terminal_session_stream( - target: String, - cols: u16, - rows: u16, - log_message: &'static str, -) -> io::Result<LocalStream> { - init_logging(); - - let socket_path = client_socket_path(); - crate::logging::startup("client"); - info!(path = %socket_path.display(), target = %target, cols, rows, "{log_message}"); - - let mut stream = match crate::ipc::connect_local_stream(&socket_path) { - Ok(stream) => stream, - Err(err) => { - eprintln!("herdr: {}", ClientError::ConnectionFailed(err)); - std::process::exit(1); - } - }; - - match do_handshake( - &mut stream, - cols, - rows, - 0, - 0, - false, - RenderEncoding::TerminalAnsi, - true, - ) { - Ok(RenderEncoding::TerminalAnsi) => {} - Ok(encoding) => { - eprintln!( - "herdr: terminal session observe negotiated unsupported encoding {encoding:?}" - ); - std::process::exit(1); - } - Err(err) => { - eprintln!("herdr: {err}"); - std::process::exit(1); - } - } - - stream.set_nonblocking(false)?; - Ok(stream) -} - -fn write_terminal_session_output(mut stream: LocalStream) -> io::Result<()> { - let mut stdout = io::stdout().lock(); - loop { - match protocol::read_message(&mut stream, MAX_GRAPHICS_FRAME_SIZE) { - Ok(ServerMessage::Terminal(frame)) => { - let encoded = base64::engine::general_purpose::STANDARD.encode(&frame.bytes); - let line = serde_json::json!({ - "type": "terminal.frame", - "seq": frame.seq, - "encoding": "ansi", - "width": frame.width, - "height": frame.height, - "full": frame.full, - "bytes": encoded, - }); - serde_json::to_writer(&mut stdout, &line)?; - stdout.write_all(b"\n")?; - stdout.flush()?; - } - Ok(ServerMessage::ServerShutdown { reason }) => { - let line = serde_json::json!({ - "type": "terminal.closed", - "reason": reason, - }); - serde_json::to_writer(&mut stdout, &line)?; - stdout.write_all(b"\n")?; - stdout.flush()?; - return Ok(()); - } - Ok(ServerMessage::Graphics { .. }) => {} - Ok(_) => {} - Err(protocol::FramingError::UnexpectedEof) => return Ok(()), - Err(err) => return Err(io::Error::other(err.to_string())), - } - } -} - -#[derive(serde::Deserialize)] -#[serde(tag = "type")] -enum TerminalControlCommand { - #[serde(rename = "terminal.input")] - Input { - text: Option<String>, - bytes: Option<String>, - }, - #[serde(rename = "terminal.resize")] - Resize { - cols: u16, - rows: u16, - #[serde(default)] - cell_width_px: u32, - #[serde(default)] - cell_height_px: u32, - }, - #[serde(rename = "terminal.scroll")] - Scroll { - direction: TerminalControlScrollDirection, - lines: u16, - #[serde(default)] - source: TerminalControlScrollSource, - #[serde(default)] - column: Option<u16>, - #[serde(default)] - row: Option<u16>, - #[serde(default)] - modifiers: u8, - }, - #[serde(rename = "terminal.release")] - Release {}, -} - -#[derive(Clone, Copy, serde::Deserialize)] -#[serde(rename_all = "snake_case")] -enum TerminalControlScrollDirection { - Up, - Down, -} - -#[derive(Clone, Copy, Default, serde::Deserialize)] -#[serde(rename_all = "snake_case")] -enum TerminalControlScrollSource { - #[default] - Wheel, - PageKey, -} - -fn terminal_control_command_from_json(raw: &str) -> Result<ClientMessage, String> { - let command = serde_json::from_str::<TerminalControlCommand>(raw) - .map_err(|err| format!("invalid json command: {err}"))?; - match command { - TerminalControlCommand::Input { text, bytes } => { - let data = match (text, bytes) { - (Some(_), Some(_)) => { - return Err("terminal.input accepts text or bytes, not both".into()) - } - (Some(text), None) => text.into_bytes(), - (None, Some(bytes)) => base64::engine::general_purpose::STANDARD - .decode(bytes) - .map_err(|err| format!("invalid terminal.input bytes: {err}"))?, - (None, None) => Vec::new(), - }; - Ok(ClientMessage::Input { data }) - } - TerminalControlCommand::Resize { - cols, - rows, - cell_width_px, - cell_height_px, - } => { - if cols == 0 || rows == 0 { - return Err("terminal.resize cols and rows must be greater than 0".into()); - } - Ok(ClientMessage::Resize { - cols, - rows, - cell_width_px, - cell_height_px, - }) - } - TerminalControlCommand::Scroll { - direction, - lines, - source, - column, - row, - modifiers, - } => { - if lines == 0 { - return Err("terminal.scroll lines must be greater than 0".into()); - } - let direction = match direction { - TerminalControlScrollDirection::Up => AttachScrollDirection::Up, - TerminalControlScrollDirection::Down => AttachScrollDirection::Down, - }; - let source = match source { - TerminalControlScrollSource::Wheel => AttachScrollSource::Wheel, - TerminalControlScrollSource::PageKey => AttachScrollSource::PageKey { - input: match direction { - AttachScrollDirection::Up => b"\x1b[5~".to_vec(), - AttachScrollDirection::Down => b"\x1b[6~".to_vec(), - }, - }, - }; - Ok(ClientMessage::AttachScroll { - source, - direction, - lines, - column, - row, - modifiers, - }) - } - TerminalControlCommand::Release {} => Ok(ClientMessage::Detach), - } -} - fn run_client_with_mode( - requested_encoding: RenderEncoding, attach_request: Option<(String, bool)>, attach_escape: Option<AttachEscapeState>, log_message: &'static str, @@ -1233,25 +347,43 @@ fn run_client_with_mode( let loaded_config = crate::config::Config::load(); crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout())?; + let client_rendered_shell = attach_request.is_none(); + let socket_path = client_socket_path(); + let keybinding_source = client_shell_keybinding_source(); + let startup_config_diagnostic = + if keybinding_source == shell::ClientShellKeybindingSource::Endpoint { + crate::config::config_diagnostic_summary_without_keybindings(&loaded_config.diagnostics) + } else { + crate::config::config_diagnostic_summary(&loaded_config.diagnostics) + }; + let shell_config = client_rendered_shell.then(|| { + shell::ClientShellConfig::from_config(&loaded_config.config) + .with_startup_config_diagnostic(startup_config_diagnostic) + .with_startup_onboarding(loaded_config.config.should_show_onboarding()) + .with_keybinding_source(keybinding_source) + .with_local_endpoint(&socket_path) + }); 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 host_cursor = loaded_config.config.ui.host_cursor; - let direct_attach_requested = attach_request.is_some(); let remote_image_paste_key = client_remote_image_paste_key(&loaded_config.config); let kitty_graphics_enabled = - loaded_config.config.experimental.kitty_graphics && !direct_attach_requested; + loaded_config.config.experimental.kitty_graphics && client_rendered_shell; + let pixel_geometry_enabled = kitty_graphics_enabled || attach_escape.is_some(); let loop_config = ClientLoopConfig { sound_config: loaded_config.config.ui.sound, mouse_scroll_lines, redraw_on_focus_gained, host_cursor, kitty_graphics_enabled, + pixel_geometry_enabled, + pixel_geometry_fallback: kitty_graphics_enabled, mouse_capture_active: mouse_capture, remote_image_paste_key, + shell_config, }; - let socket_path = client_socket_path(); crate::logging::startup("client"); info!(path = %socket_path.display(), "{log_message}"); @@ -1268,7 +400,16 @@ fn run_client_with_mode( // Get the terminal geometry before handshake (before raw mode). let (cols, rows, cell_width_px, cell_height_px, exact_cell_size) = - initial_terminal_geometry(kitty_graphics_enabled); + initial_terminal_geometry(pixel_geometry_enabled, kitty_graphics_enabled); + + let shell_surface_size = loop_config + .shell_config + .as_ref() + .map(|shell| shell.initial_surface_size(cols, rows)); + let endpoint_keybindings = loop_config + .shell_config + .as_ref() + .is_some_and(shell::ClientShellConfig::uses_endpoint_keybindings); // Perform handshake while the stream is still in blocking mode. let negotiated_encoding = match do_handshake( @@ -1278,8 +419,9 @@ fn run_client_with_mode( cell_width_px, cell_height_px, exact_cell_size, - requested_encoding, - direct_attach_requested, + shell_surface_size, + endpoint_keybindings, + loop_config.mouse_capture_active, ) { Ok(encoding) => encoding, Err(err) => { @@ -1312,19 +454,11 @@ fn run_client_with_mode( err })?; - // Install a panic hook to restore the terminal on panic (same as monolithic). - let panic_resets_modify_other_keys = terminal_guard.reset_modify_other_keys; - let panic_resets_host_color_scheme_reports = terminal_guard.reset_host_color_scheme_reports; - #[cfg(windows)] - let panic_restore_windows_input_mode = terminal_guard.restore_windows_input_mode; + // Install a panic hook so the foreground client always restores its terminal. + let panic_restore = terminal_guard.panic_restore(); let original_hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { - let _ = restore_terminal_state( - panic_resets_modify_other_keys, - panic_resets_host_color_scheme_reports, - #[cfg(windows)] - panic_restore_windows_input_mode, - ); + panic_restore(); original_hook(info); })); @@ -1352,6 +486,7 @@ fn run_client_with_mode( rows, cell_width_px, cell_height_px, + exact_cell_size, should_quit, loop_config, negotiated_encoding, @@ -1388,6 +523,148 @@ fn run_client_with_mode( Ok(()) } +fn dispatch_client_shell_actions( + actions: Vec<shell::ClientShellAction>, + endpoint_commands: &mut endpoint_commands::EndpointCommands, + write_stream: &mut LocalStream, + detached_process_children: &mut Vec<std::process::Child>, +) -> Result<Vec<crossterm::event::MouseEvent>, ClientError> { + let mut replay_mouse = Vec::new(); + for action in actions { + match action { + shell::ClientShellAction::Endpoint { boot_id, request } => { + endpoint_commands.enqueue(boot_id, request); + } + shell::ClientShellAction::ClipboardWrite(bytes) => { + crate::selection::write_osc52_bytes(&bytes); + } + shell::ClientShellAction::Request(request) => { + write_to_server(write_stream, &request).map_err(ClientError::ConnectionLost)?; + } + shell::ClientShellAction::OpenSafeWebUrl(url) => { + if crate::app::actions::safe_web_url(&url).is_some() { + match crate::platform::open_url(&url) { + Ok(Some(child)) => detached_process_children.push(child), + Ok(None) => {} + Err(err) => warn!(err = %err, url = %url, "failed to open pane URL"), + } + } + } + shell::ClientShellAction::ReplayMouse(events) => replay_mouse.extend(events), + shell::ClientShellAction::Keybind(action) => { + debug!( + ?action, + "client shell action awaits its presentation family" + ); + } + } + } + endpoint_commands + .send_next(write_stream) + .map_err(ClientError::ConnectionLost)?; + Ok(replay_mouse) +} + +fn client_shell_resize_message( + shell: &shell::ClientShellState, + cols: u16, + rows: u16, + cell_width_px: u32, + cell_height_px: u32, + pixel_mouse: bool, +) -> ClientMessage { + ClientMessage::ClientShellResize { + cell_width_px, + cell_height_px, + surface_size: shell.surface_size(cols, rows), + pixel_mouse, + } +} + +fn sync_client_shell_keyboard_report_all(state: &mut ClientState) -> Result<(), ClientError> { + let Some(shell) = state.shell.as_ref() else { + return Ok(()); + }; + let desired = state.pane_keyboard_report_all || shell.host_keyboard_report_all_requested(); + if desired == state.keyboard_report_all_active { + return Ok(()); + } + crate::terminal_modes::set_host_kitty_keyboard_report_all(&mut io::stdout(), desired) + .map_err(ClientError::ConnectionFailed)?; + state.keyboard_report_all_active = desired; + Ok(()) +} + +fn apply_client_shell_input_source_changes( + state: &mut ClientState, + prefix_input_source: &mut impl crate::platform::PrefixInputSource, +) { + let changes = state + .shell + .as_mut() + .map(shell::ClientShellState::take_input_source_changes) + .unwrap_or_default(); + for active in changes { + if active { + prefix_input_source.switch_to_ascii(); + } else { + prefix_input_source.restore(); + } + } +} + +fn finish_client_shell_input( + state: &mut ClientState, + outcome: shell::ClientShellInput, + frame: Option<FrameData>, + write_stream: &mut LocalStream, + endpoint_commands: &mut endpoint_commands::EndpointCommands, + prefix_input_source: &mut impl crate::platform::PrefixInputSource, +) -> Result<bool, ClientError> { + apply_client_shell_input_source_changes(state, prefix_input_source); + if outcome.detach { + let _ = write_to_server(write_stream, &ClientMessage::Detach); + return Ok(true); + } + if outcome.resize { + let shell = state.shell.as_ref().expect("shell mode remains active"); + let resize = client_shell_resize_message( + shell, + state.reported_size.0, + state.reported_size.1, + state.reported_cell_size.0, + state.reported_cell_size.1, + state.pixel_geometry_exact, + ); + write_to_server(write_stream, &resize).map_err(ClientError::ConnectionLost)?; + } + #[cfg(not(windows))] + if outcome.query_host_appearance { + query_host_terminal_appearance(); + } + if outcome.query_host_theme { + query_host_terminal_theme(); + } + sync_client_shell_keyboard_report_all(state)?; + let replay = dispatch_client_shell_actions( + outcome.actions, + endpoint_commands, + write_stream, + &mut state.detached_process_children, + )?; + debug_assert!( + replay.is_empty(), + "mouse replay only follows endpoint results" + ); + for request in outcome.requests { + write_to_server(write_stream, &request).map_err(ClientError::ConnectionLost)?; + } + if let Some(frame) = frame { + state.present_frame(frame); + } + Ok(false) +} + /// The main client event loop. /// /// Uses a threaded architecture: @@ -1401,6 +678,7 @@ async fn run_client_loop( rows: u16, initial_cell_width_px: u32, initial_cell_height_px: u32, + initial_pixel_geometry_exact: bool, should_quit: Arc<AtomicBool>, config: ClientLoopConfig, negotiated_encoding: RenderEncoding, @@ -1414,14 +692,25 @@ async fn run_client_loop( let mut state = ClientState { blit_encoder: render_ansi::BlitEncoder::new(), mouse_capture_active: config.mouse_capture_active, + endpoint_mouse_capture_requested: false, + endpoint_sgr_pixels_requested: false, + direct_mouse_capture_preference: attach_escape.is_some() && config.mouse_capture_active, + shell_mouse_capture_preference: config.mouse_capture_active, + direct_keyboard_protocol: crate::terminal_modes::DirectHostKeyboardState::default(), + pane_keyboard_report_all: false, keyboard_report_all_active: false, reported_size: (cols, rows), + reported_cell_size: (initial_cell_width_px, initial_cell_height_px), sound_config: config.sound_config, kitty_graphics_enabled: config.kitty_graphics_enabled, + pixel_geometry_enabled: config.pixel_geometry_enabled, + pixel_geometry_exact: initial_pixel_geometry_exact, #[cfg(unix)] direct_graphics_response: Arc::new(Mutex::new(direct_graphics::ResponseMatcher::default())), #[cfg(unix)] retired_direct_graphics: None, + #[cfg(unix)] + pending_surface_graphics: HashMap::new(), attach_escape, #[cfg(unix)] mouse_scroll_lines: config.mouse_scroll_lines, @@ -1429,7 +718,12 @@ async fn run_client_loop( redraw_on_focus_gained: config.redraw_on_focus_gained, repaint_pending: false, draw_host_cursor, + detached_process_children: Vec::new(), + shell: config.shell_config.map(shell::ClientShellState::new), }; + if let Some(shell) = state.shell.as_mut() { + shell.set_graphics_cell_size(initial_cell_width_px, initial_cell_height_px); + } debug!(?negotiated_encoding, "client render encoding active"); let host_mouse_capture_active = Arc::new(AtomicBool::new(state.mouse_capture_active)); // Cell size reported by the host terminal, packed as width<<32 | height. @@ -1440,6 +734,8 @@ async fn run_client_loop( // Channel for events from the stdin, resize, and server reader threads. let (event_tx, mut event_rx) = tokio::sync::mpsc::channel::<ClientLoopEvent>(256); + let mut endpoint_commands = endpoint_commands::EndpointCommands::default(); + // Spawn the stdin reader thread. let will_query_host_terminal_theme = state.attach_escape.is_none() && should_query_host_terminal_theme(); @@ -1475,6 +771,10 @@ async fn run_client_loop( if will_query_host_terminal_theme { query_host_terminal_theme(); + #[cfg(not(windows))] + if state.shell.is_some() { + query_host_terminal_appearance(); + } } if will_query_host_cell_size { @@ -1486,6 +786,8 @@ async fn run_client_loop( let resize_tx = event_tx.clone(); let resize_cell_size = reported_cell_size.clone(); let kitty_graphics_enabled = state.kitty_graphics_enabled; + let pixel_geometry_enabled = state.pixel_geometry_enabled; + let pixel_geometry_fallback = config.pixel_geometry_fallback; std::thread::spawn(move || { resize_poll_loop( resize_tx, @@ -1493,7 +795,9 @@ async fn run_client_loop( rows, initial_cell_width_px, initial_cell_height_px, - kitty_graphics_enabled, + initial_pixel_geometry_exact, + pixel_geometry_enabled, + pixel_geometry_fallback, &resize_cell_size, &resize_quit, ); @@ -1527,19 +831,95 @@ async fn run_client_loop( // This (foreground) client owns the prefix ASCII input-source switch // (implemented on macOS and Windows; a no-op on other platforms). - use crate::platform::PrefixInputSource; let mut prefix_input_source = crate::platform::RealPrefixInputSource::default(); // Main event loop. + let mut client_timer = timer::ClientLoopTimer::new(); while !should_quit.load(Ordering::Acquire) { + let timer_delay = state + .shell + .as_ref() + .map_or(Duration::from_millis(100), |shell| { + shell.timer_delay(std::time::Instant::now()) + }); + let timer_deadline = client_timer.deadline(std::time::Instant::now(), timer_delay); let event = tokio::select! { + biased; + _ = tokio::time::sleep_until(timer_deadline.into()) => ClientLoopEvent::Timer, ev = event_rx.recv() => ev.unwrap_or(ClientLoopEvent::Timer), - _ = tokio::time::sleep(Duration::from_millis(100)) => ClientLoopEvent::Timer, }; + let now = std::time::Instant::now(); + if let Some(shell) = state.shell.as_mut() { + shell.tick_popup_pending(now); + } match event { #[cfg(unix)] ClientLoopEvent::StdinInput(data) => { + if state.shell.is_some() { + if will_query_host_cell_size { + let events = crate::raw_input::parse_raw_input_bytes_sync(&data); + if let Some((width_px, height_px)) = reported_cell_size_from_events(&events) + { + store_reported_cell_size(&reported_cell_size, width_px, height_px); + } + } + let image_target = state + .shell + .as_ref() + .and_then(|shell| shell.clipboard_image_target()); + if let Some(target) = image_target.clone() { + if should_bridge_clipboard_image_paste( + &data, + is_remote_client, + state.remote_image_paste_key, + ) { + if let Some(image) = crate::platform::read_clipboard_image() { + write_remote_image_to_server( + &mut write_stream, + target, + image, + "clipboard paste", + )?; + continue; + } + info!( + "clipboard image paste trigger received, but local clipboard has no image" + ); + } + if let Some(image) = + read_image_file_from_terminal_drop(&data, is_remote_client) + { + write_remote_image_to_server( + &mut write_stream, + target, + image, + "file drop", + )?; + continue; + } + } + let (outcome, frame) = { + let shell = state.shell.as_mut().expect("checked shell mode"); + let outcome = shell.handle_input_bytes(&data); + let frame = outcome + .repaint + .then(|| shell.compose(state.reported_size.0, state.reported_size.1)) + .flatten(); + (outcome, frame) + }; + if finish_client_shell_input( + &mut state, + outcome, + frame, + &mut write_stream, + &mut endpoint_commands, + &mut prefix_input_source, + )? { + return Ok(()); + } + continue; + } let data = if let Some(attach_escape) = &mut state.attach_escape { match attach_escape.filter_input( data, @@ -1547,30 +927,33 @@ async fn run_client_loop( state.mouse_scroll_lines, ) { AttachInputAction::Forward(data) => data, - AttachInputAction::ForwardAfterPendingPrefix(data) => { - let prefix = ClientMessage::Input { data: vec![0x02] }; - if let Err(e) = write_to_server(&mut write_stream, &prefix) { + AttachInputAction::ForwardPair(first, second) => { + for data in [first, second] { + if let Err(e) = write_to_server( + &mut write_stream, + &ClientMessage::Input { data }, + ) { + return Err(ClientError::ConnectionLost(e)); + } + } + continue; + } + AttachInputAction::Semantic(action) => { + if let Err(e) = write_attach_semantic_action(&mut write_stream, action) + { return Err(ClientError::ConnectionLost(e)); } - data + continue; } - AttachInputAction::Scroll { - source, - direction, - lines, - column, - row, - modifiers, - } => { - let msg = ClientMessage::AttachScroll { - source, - direction, - lines, - column, - row, - modifiers, - }; - if let Err(e) = write_to_server(&mut write_stream, &msg) { + AttachInputAction::ForwardThenSemantic(prefix, action) => { + if let Err(e) = write_to_server( + &mut write_stream, + &ClientMessage::Input { data: prefix }, + ) { + return Err(ClientError::ConnectionLost(e)); + } + if let Err(e) = write_attach_semantic_action(&mut write_stream, action) + { return Err(ClientError::ConnectionLost(e)); } continue; @@ -1606,7 +989,12 @@ async fn run_client_loop( state.remote_image_paste_key, ) { if let Some(image) = crate::platform::read_clipboard_image() { - write_remote_image_to_server(&mut write_stream, image, "clipboard paste")?; + write_remote_image_to_server( + &mut write_stream, + crate::protocol::ClientClipboardImageTarget::DirectTerminal, + image, + "clipboard paste", + )?; continue; } info!( @@ -1614,7 +1002,12 @@ async fn run_client_loop( ); } if let Some(image) = read_image_file_from_terminal_drop(&data, is_remote_client) { - write_remote_image_to_server(&mut write_stream, image, "file drop")?; + write_remote_image_to_server( + &mut write_stream, + crate::protocol::ClientClipboardImageTarget::DirectTerminal, + image, + "file drop", + )?; continue; } let msg = ClientMessage::Input { data }; @@ -1624,6 +1017,17 @@ async fn run_client_loop( } #[cfg(unix)] ClientLoopEvent::DirectGraphicsResponse(response) => { + let composed = state + .pending_surface_graphics + .remove(&response.transfer_id) + .filter(|_| response.success) + .and_then(|asset| { + let shell = state.shell.as_mut()?; + shell + .trust_direct_graphics_asset(&asset, response.image_id) + .then(|| shell.compose(state.reported_size.0, state.reported_size.1)) + .flatten() + }); let message = ClientMessage::GraphicsTransmissionResult { transfer_id: response.transfer_id, image_id: response.image_id, @@ -1632,99 +1036,251 @@ async fn run_client_loop( if let Err(err) = write_to_server(&mut write_stream, &message) { return Err(ClientError::ConnectionLost(err)); } + if let Some(frame) = composed { + state.present_frame(frame); + } } #[cfg(unix)] ClientLoopEvent::PixelMouse(data, geometry) => { - let message = ClientMessage::InputPixels { - data, - cols: geometry.cols, - rows: geometry.rows, - width_px: geometry.width_px, - height_px: geometry.height_px, - }; - if let Err(err) = write_to_server(&mut write_stream, &message) { - return Err(ClientError::ConnectionLost(err)); + if state.shell.is_some() { + let (outcome, frame) = { + let shell = state.shell.as_mut().expect("checked shell mode"); + let outcome = shell.handle_pixel_mouse(&data, geometry); + let frame = outcome + .repaint + .then(|| shell.compose(state.reported_size.0, state.reported_size.1)) + .flatten(); + (outcome, frame) + }; + if finish_client_shell_input( + &mut state, + outcome, + frame, + &mut write_stream, + &mut endpoint_commands, + &mut prefix_input_source, + )? { + return Ok(()); + } + continue; + } + if let Some(attach_escape) = state.attach_escape.as_mut() { + if let Some(prefix) = attach_escape.take_pending_prefix() { + if let Err(err) = write_to_server( + &mut write_stream, + &ClientMessage::Input { data: prefix }, + ) { + return Err(ClientError::ConnectionLost(err)); + } + } + if let Some((kind, position, modifiers)) = + direct_attach_pixel_mouse(&data, geometry) + { + let message = ClientMessage::AttachMouse { + kind, + position, + geometry: Some(crate::protocol::ClientMouseGeometry { + cols: geometry.cols, + rows: geometry.rows, + width_px: geometry.width_px, + height_px: geometry.height_px, + }), + modifiers, + lines: state.mouse_scroll_lines.max(1).min(u16::MAX as usize) as u16, + }; + if let Err(err) = write_to_server(&mut write_stream, &message) { + return Err(ClientError::ConnectionLost(err)); + } + } } } #[cfg(windows)] ClientLoopEvent::StdinEvents(events) => { - if state.attach_escape.is_some() { - continue; - } - if should_bridge_clipboard_image_events( - &events, - is_remote_client, - state.remote_image_paste_key, - ) { - if let Some(image) = crate::platform::read_clipboard_image() { - write_remote_image_to_server(&mut write_stream, image, "clipboard paste")?; - continue; + if state.shell.is_some() { + let image_target = state + .shell + .as_ref() + .and_then(|shell| shell.clipboard_image_target()); + if let Some(target) = image_target.clone() { + if should_bridge_clipboard_image_events( + &events, + is_remote_client, + state.remote_image_paste_key, + ) { + if let Some(image) = crate::platform::read_clipboard_image() { + write_remote_image_to_server( + &mut write_stream, + target, + image, + "clipboard paste", + )?; + continue; + } + info!( + "clipboard image paste trigger received, but local clipboard has no image" + ); + } + if let Some(image) = + read_image_file_from_client_events(&events, is_remote_client) + { + write_remote_image_to_server( + &mut write_stream, + target, + image, + "file drop", + )?; + continue; + } + } + let (outcome, frame) = { + let shell = state.shell.as_mut().expect("checked shell mode"); + let outcome = shell.handle_client_events(&events); + let frame = outcome + .repaint + .then(|| shell.compose(state.reported_size.0, state.reported_size.1)) + .flatten(); + (outcome, frame) + }; + if finish_client_shell_input( + &mut state, + outcome, + frame, + &mut write_stream, + &mut endpoint_commands, + &mut prefix_input_source, + )? { + return Ok(()); } - info!( - "clipboard image paste trigger received, but local clipboard has no image" - ); - } - if let Some(image) = read_image_file_from_client_events(&events, is_remote_client) { - write_remote_image_to_server(&mut write_stream, image, "file drop")?; continue; } - let raw_events = events - .iter() - .map(crate::protocol::ClientInputEvent::to_raw_input_event) - .collect::<Vec<_>>(); - if crate::raw_input::events_require_host_surface_redraw( - &raw_events, - state.redraw_on_focus_gained, - ) { - state.request_repaint(); - } - let msg = ClientMessage::InputEvents { events }; - if let Err(e) = write_to_server(&mut write_stream, &msg) { - return Err(ClientError::ConnectionLost(e)); - } + // Direct terminal attach is Unix-only; every Windows client uses ClientShell. } - ClientLoopEvent::Resize(new_cols, new_rows, cell_width_px, cell_height_px) => { + ClientLoopEvent::Resize( + new_cols, + new_rows, + cell_width_px, + cell_height_px, + pixel_geometry_exact, + ) => { + if !pixel_geometry_exact && host_sgr_pixels_active.load(Ordering::Acquire) { + set_mouse_capture(state.mouse_capture_active, false) + .map_err(ClientError::ConnectionFailed)?; + host_sgr_pixels_active.store(false, Ordering::Release); + } state.reported_size = (new_cols, new_rows); - // Resizing invalidates the host-side blit baseline. + state.reported_cell_size = (cell_width_px, cell_height_px); + state.pixel_geometry_exact = pixel_geometry_exact; + // Resizing invalidates both the host-side blit baseline and pane hit geometry. state.request_repaint(); - let msg = ClientMessage::Resize { - cols: new_cols, - rows: new_rows, - cell_width_px, - cell_height_px, + if let Some(shell) = state.shell.as_mut() { + shell.set_graphics_cell_size(cell_width_px, cell_height_px); + shell.invalidate_pane_surface(); + } + let msg = if let Some(shell) = &state.shell { + client_shell_resize_message( + shell, + new_cols, + new_rows, + cell_width_px, + cell_height_px, + pixel_geometry_exact, + ) + } else { + ClientMessage::Resize { + cols: new_cols, + rows: new_rows, + cell_width_px, + cell_height_px, + pixel_mouse: pixel_geometry_exact, + } }; if let Err(e) = write_to_server(&mut write_stream, &msg) { return Err(ClientError::ConnectionLost(e)); } } - ClientLoopEvent::ServerMessage(msg) => match msg { - ServerMessage::Frame(frame_data) => { - let frame_data = if state.draw_host_cursor { - render_ansi::frame_with_drawn_cursor(frame_data) - } else { - frame_data - }; - let encoded = if state.draw_host_cursor { - state.blit_encoder.encode_with_suppressed_visible_cursor( - &frame_data, - state.repaint_pending, + ClientLoopEvent::ServerMessage(msg) => match *msg { + ServerMessage::ClientShellSnapshot(snapshot) => { + let (composed, resize, graphics_cleanup) = if let Some(shell) = &mut state.shell + { + let previous_size = + shell.surface_size(state.reported_size.0, state.reported_size.1); + shell.set_snapshot(snapshot); + let graphics_cleanup = shell.take_pending_graphics_cleanup(); + let next_size = + shell.surface_size(state.reported_size.0, state.reported_size.1); + ( + shell.compose(state.reported_size.0, state.reported_size.1), + (previous_size != next_size).then(|| { + client_shell_resize_message( + shell, + state.reported_size.0, + state.reported_size.1, + state.reported_cell_size.0, + state.reported_cell_size.1, + state.pixel_geometry_exact, + ) + }), + graphics_cleanup, ) } else { - state - .blit_encoder - .encode(&frame_data, state.repaint_pending) + (None, None, Vec::new()) }; - let mut stdout = io::stdout(); - let graphics = if state.kitty_graphics_enabled { - frame_data.graphics.as_slice() + apply_client_shell_input_source_changes(&mut state, &mut prefix_input_source); + state.present_graphics(&graphics_cleanup); + if let Some(resize) = resize { + if let Err(err) = write_to_server(&mut write_stream, &resize) { + return Err(ClientError::ConnectionLost(err)); + } + } + if let Some(frame) = composed { + state.present_frame(frame); + } + } + ServerMessage::PaneSurface(surface) => { + let composed = if let Some(shell) = &mut state.shell { + shell.set_pane_surface(surface); + shell.compose(state.reported_size.0, state.reported_size.1) } else { - &[] + None }; - let _ = - write_encoded_frame_with_graphics(&mut stdout, &encoded.bytes, graphics); - let _ = stdout.flush(); - state.blit_encoder.commit(frame_data, encoded); - state.repaint_pending = false; + apply_client_shell_input_source_changes(&mut state, &mut prefix_input_source); + if let Some(frame) = composed { + state.present_frame(frame); + } + } + ServerMessage::PaneSurfacePatch(patch) => { + let patch_started = crate::render_prof::timer(); + let apply_started = crate::render_prof::timer(); + let outcome = state + .shell + .as_mut() + .map(|shell| shell.apply_pane_surface_patch(patch)); + crate::render_prof::duration_since("client_surface_patch.apply", apply_started); + let compose_fallback = match outcome { + Some(shell::ClientPaneSurfacePatchOutcome::Applied(Some(patch))) => { + match state.present_surface_patch(patch) { + Ok(presented) => !presented, + Err(error) => { + warn!(%error, "failed to present retained pane surface patch"); + state.request_repaint(); + false + } + } + } + Some(shell::ClientPaneSurfacePatchOutcome::Applied(None)) => true, + Some(shell::ClientPaneSurfacePatchOutcome::Rejected) | None => false, + }; + apply_client_shell_input_source_changes(&mut state, &mut prefix_input_source); + if compose_fallback { + let composed = state.shell.as_mut().and_then(|shell| { + shell.compose(state.reported_size.0, state.reported_size.1) + }); + if let Some(frame) = composed { + state.present_frame(frame); + } + } + crate::render_prof::duration_since("client_surface_patch.total", patch_started); + crate::render_prof::flush_if_due(); } ServerMessage::Terminal(frame) => { if state.kitty_graphics_enabled && contains_kitty_graphics_bytes(&frame.bytes) { @@ -1756,13 +1312,25 @@ async fn run_client_loop( transfer_id, leading, control, + surface_asset, } => { #[cfg(unix)] { if state.retired_direct_graphics.take() == Some((transfer_id, image_id)) { continue; } + let surface_asset_valid = match (state.shell.as_ref(), &surface_asset) { + (Some(shell), Some(asset)) => { + crate::kitty_graphics::surface::host_image_id( + shell.graphics_scope(), + asset, + ) == image_id + } + (None, None) => true, + _ => false, + }; let valid = state.kitty_graphics_enabled + && surface_asset_valid && usize::try_from(expected_len).ok().is_some_and(|len| { crate::pane_graphics_files::validate_direct_source( std::path::Path::new(&path), @@ -1796,6 +1364,9 @@ async fn run_client_loop( false }; if sent { + if let Some(asset) = surface_asset { + state.pending_surface_graphics.insert(transfer_id, asset); + } if let Ok(mut matcher) = state.direct_graphics_response.lock() { matcher.start(transfer_id); } @@ -1807,6 +1378,7 @@ async fn run_client_loop( return Err(ClientError::ConnectionLost(err)); } } else { + state.pending_surface_graphics.remove(&transfer_id); if let Ok(mut matcher) = state.direct_graphics_response.lock() { if valid { matcher.retire(transfer_id); @@ -1825,7 +1397,15 @@ async fn run_client_loop( } } #[cfg(not(unix))] - let _ = (path, expected_len, image_id, transfer_id, leading, control); + let _ = ( + path, + expected_len, + image_id, + transfer_id, + leading, + control, + surface_asset, + ); } ServerMessage::GraphicsTransmissionRetired { transfer_id, @@ -1834,6 +1414,15 @@ async fn run_client_loop( #[cfg(unix)] { state.retired_direct_graphics = Some((transfer_id, image_id)); + state.pending_surface_graphics.remove(&transfer_id); + let cleanup = state.shell.as_mut().map_or_else(Vec::new, |shell| { + shell.retire_direct_graphics_image(image_id); + shell + .compose(state.reported_size.0, state.reported_size.1) + .map(|frame| frame.graphics) + .unwrap_or_else(|| shell.take_pending_graphics_cleanup()) + }); + state.present_graphics(&cleanup); if let Ok(mut matcher) = state.direct_graphics_response.lock() { matcher.retire(transfer_id); } @@ -1849,10 +1438,117 @@ async fn run_client_loop( message, body, } => { - handle_notify(kind, &message, body.as_deref(), &state.sound_config); + if state.shell.is_none() { + handle_notify(kind, &message, body.as_deref(), &state.sound_config); + } + } + ServerMessage::SemanticNotification(event) => { + if state.shell.is_some() { + let (effects, frame) = { + let shell = state.shell.as_mut().expect("checked shell mode"); + let (effects, repaint) = + shell.receive_notification(event, std::time::Instant::now()); + let frame = repaint + .then(|| { + shell.compose(state.reported_size.0, state.reported_size.1) + }) + .flatten(); + (effects, frame) + }; + handle_shell_notification_effects(effects, &state.sound_config); + if let Some(frame) = frame { + state.present_frame(frame); + } + } + } + ServerMessage::ClientShellError { message } => { + if let Some(shell) = state.shell.as_mut() { + if shell.receive_endpoint_error(message) { + let frame = shell.compose(state.reported_size.0, state.reported_size.1); + if let Some(frame) = frame { + state.present_frame(frame); + } + } + } + } + ServerMessage::ClientShellEndpointResponseChunk { + boot_id, + request_id, + final_chunk, + data, + } => { + let completed = endpoint_commands + .receive_chunk(&boot_id, &request_id, final_chunk, data) + .map_err(ClientError::ConnectionLost)?; + let Some(completed) = completed else { + continue; + }; + let (repaint, actions) = state.shell.as_mut().map_or_else( + || (false, Vec::new()), + |shell| { + shell.handle_endpoint_result( + &completed.boot_id, + &completed.request_id, + completed.result, + ) + }, + ); + if let Some(shell) = state.shell.as_mut() { + shell.reconcile_input_source(); + } + apply_client_shell_input_source_changes(&mut state, &mut prefix_input_source); + let replay_mouse = dispatch_client_shell_actions( + actions, + &mut endpoint_commands, + &mut write_stream, + &mut state.detached_process_children, + )?; + if replay_mouse.is_empty() { + if repaint { + if let Some(frame) = state.shell.as_mut().and_then(|shell| { + shell.compose(state.reported_size.0, state.reported_size.1) + }) { + state.present_frame(frame); + } + } + } else { + let (outcome, frame) = { + let shell = state.shell.as_mut().expect("shell endpoint response"); + let mut outcome = shell.replay_mouse_events(replay_mouse); + outcome.repaint |= repaint; + let frame = outcome + .repaint + .then(|| { + shell.compose(state.reported_size.0, state.reported_size.1) + }) + .flatten(); + (outcome, frame) + }; + if finish_client_shell_input( + &mut state, + outcome, + frame, + &mut write_stream, + &mut endpoint_commands, + &mut prefix_input_source, + )? { + return Ok(()); + } + } } ServerMessage::Clipboard { data } => { - forward_clipboard(&data); + if forward_clipboard(&data) { + let (width, height) = state.reported_size; + let frame = state.shell.as_mut().and_then(|shell| { + shell + .show_copy_feedback(std::time::Instant::now()) + .then(|| shell.compose(width, height)) + .flatten() + }); + if let Some(frame) = frame { + state.present_frame(frame); + } + } let _ = io::stdout().flush(); } ServerMessage::WindowTitle { title } => { @@ -1862,18 +1558,90 @@ async fn run_client_loop( ); } ServerMessage::ReloadSoundConfig => { + let previous_mouse_capture = state.shell_mouse_capture_preference; + let mut mouse_capture = previous_mouse_capture; reload_local_client_config( &mut state.sound_config, &mut state.redraw_on_focus_gained, &mut state.draw_host_cursor, &mut state.remote_image_paste_key, + &mut mouse_capture, ); + state.shell_mouse_capture_preference = mouse_capture; + state.direct_mouse_capture_preference = + state.attach_escape.is_some() && mouse_capture; + if state.shell.is_some() && previous_mouse_capture != mouse_capture { + write_to_server( + &mut write_stream, + &ClientMessage::ClientShellMouseCapture { + enabled: mouse_capture, + }, + ) + .map_err(ClientError::ConnectionLost)?; + } + if state.attach_escape.is_some() { + let enabled = effective_mouse_capture( + state.endpoint_mouse_capture_requested, + state.direct_mouse_capture_preference, + ); + let sgr_pixels = effective_sgr_pixel_mouse( + enabled, + state.endpoint_sgr_pixels_requested, + state.pixel_geometry_exact, + ); + if enabled != state.mouse_capture_active + || sgr_pixels != host_sgr_pixels_active.load(Ordering::Acquire) + { + set_mouse_capture(enabled, sgr_pixels) + .map_err(ClientError::ConnectionFailed)?; + } + state.mouse_capture_active = enabled; + host_mouse_capture_active.store(enabled, Ordering::Release); + host_sgr_pixels_active.store(sgr_pixels, Ordering::Release); + } + let (frame, resize) = if let Some(shell) = state.shell.as_mut() { + let previous_size = + shell.surface_size(state.reported_size.0, state.reported_size.1); + shell.reload_client_config(); + let next_size = + shell.surface_size(state.reported_size.0, state.reported_size.1); + let resize = (previous_size != next_size).then(|| { + shell.invalidate_pane_surface(); + client_shell_resize_message( + shell, + state.reported_size.0, + state.reported_size.1, + state.reported_cell_size.0, + state.reported_cell_size.1, + state.pixel_geometry_exact, + ) + }); + ( + shell.compose(state.reported_size.0, state.reported_size.1), + resize, + ) + } else { + (None, None) + }; + apply_client_shell_input_source_changes(&mut state, &mut prefix_input_source); + if let Some(resize) = resize { + write_to_server(&mut write_stream, &resize) + .map_err(ClientError::ConnectionLost)?; + } + if let Some(frame) = frame { + state.present_frame(frame); + } } ServerMessage::MouseCapture { enabled, sgr_pixels, } => { - let next_sgr_pixels = enabled && sgr_pixels; + state.endpoint_mouse_capture_requested = enabled; + state.endpoint_sgr_pixels_requested = sgr_pixels; + let enabled = + effective_mouse_capture(enabled, state.direct_mouse_capture_preference); + let next_sgr_pixels = + effective_sgr_pixel_mouse(enabled, sgr_pixels, state.pixel_geometry_exact); let mouse_mode_changed = enabled != state.mouse_capture_active || next_sgr_pixels != host_sgr_pixels_active.load(Ordering::Acquire); if mouse_mode_changed { @@ -1892,21 +1660,24 @@ async fn run_client_loop( host_mouse_capture_active.store(enabled, Ordering::Release); host_sgr_pixels_active.store(next_sgr_pixels, Ordering::Release); } - ServerMessage::KittyKeyboardReportAll { enabled } => { - if enabled != state.keyboard_report_all_active { - crate::terminal_modes::set_host_kitty_keyboard_report_all( + ServerMessage::DirectTerminalKeyboardProtocol { + flags, + modify_other_keys_level, + } => { + if state.attach_escape.is_some() { + crate::terminal_modes::set_direct_host_keyboard_protocol( &mut io::stdout(), - enabled, + &mut state.direct_keyboard_protocol, + flags, + modify_other_keys_level, ) .map_err(ClientError::ConnectionFailed)?; - state.keyboard_report_all_active = enabled; } } - ServerMessage::PrefixInputSource { active } => { - if active { - prefix_input_source.switch_to_ascii(); - } else { - prefix_input_source.restore(); + ServerMessage::ClientShellKeyboardReportAll { enabled } => { + if state.shell.is_some() { + state.pane_keyboard_report_all = enabled; + sync_client_shell_keyboard_report_all(&mut state)?; } } ServerMessage::Welcome { .. } => { @@ -1920,10 +1691,38 @@ async fn run_client_loop( ))); } ClientLoopEvent::Timer => { + client_timer.fired(); #[cfg(unix)] if let Ok(mut matcher) = state.direct_graphics_response.lock() { matcher.expire(); } + state + .detached_process_children + .retain_mut(|child| child.try_wait().ok().flatten().is_none()); + if state.shell.is_some() { + let (effects, outcome, frame) = { + let shell = state.shell.as_mut().expect("checked shell mode"); + let (effects, notification_repaint) = shell.tick_notifications(now); + let mut outcome = shell.tick_selection_autoscroll(now); + outcome.repaint |= notification_repaint | shell.tick_copy_feedback(now); + let frame = outcome + .repaint + .then(|| shell.compose(state.reported_size.0, state.reported_size.1)) + .flatten(); + (effects, outcome, frame) + }; + handle_shell_notification_effects(effects, &state.sound_config); + if finish_client_shell_input( + &mut state, + outcome, + frame, + &mut write_stream, + &mut endpoint_commands, + &mut prefix_input_source, + )? { + return Ok(()); + } + } } } } @@ -1965,7 +1764,7 @@ fn server_reader_thread( match protocol::read_message(&mut stream, max_frame_size) { Ok(msg) => { if event_tx - .blocking_send(ClientLoopEvent::ServerMessage(msg)) + .blocking_send(ClientLoopEvent::ServerMessage(Box::new(msg))) .is_err() { break; // Main loop gone. @@ -2000,57 +1799,10 @@ 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())) } -fn write_remote_image_to_server( - stream: &mut LocalStream, - image: crate::platform::ClipboardImage, - source: &'static str, -) -> Result<(), ClientError> { - if image.bytes.len() > MAX_CLIPBOARD_IMAGE_PAYLOAD { - warn!( - bytes = image.bytes.len(), - max = MAX_CLIPBOARD_IMAGE_PAYLOAD, - source, - "local image is too large to bridge" - ); - return Ok(()); - } - - info!( - bytes = image.bytes.len(), - extension = image.extension, - source, - "bridging local image to remote server" - ); - write_to_server( - stream, - &ClientMessage::ClipboardImage { - extension: image.extension.to_owned(), - data: image.bytes, - }, - ) - .map_err(ClientError::ConnectionLost) -} - // --------------------------------------------------------------------------- // Notifications // --------------------------------------------------------------------------- -fn client_remote_image_paste_key( - config: &crate::config::Config, -) -> Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)> { - if !is_remote_client_process() { - return None; - } - - match config.remote_image_paste_key() { - Ok(key) => key, - Err(diagnostic) => { - warn!(diagnostic = %diagnostic, "local remote image paste key config diagnostic"); - None - } - } -} - fn reload_local_client_config( sound_config: &mut crate::config::SoundConfig, redraw_on_focus_gained: &mut bool, @@ -2059,17 +1811,29 @@ fn reload_local_client_config( crossterm::event::KeyCode, crossterm::event::KeyModifiers, )>, + mouse_capture: &mut bool, ) { match crate::config::load_live_config() { Ok(loaded) => { - for diagnostic in loaded.config.ui.sound.diagnostics() { - warn!(diagnostic = %diagnostic, "local sound config diagnostic"); + let invalid_section = |section: &str| { + loaded + .invalid_sections + .iter() + .any(|invalid| invalid == section) + }; + if !invalid_section("ui") && loaded.config.invalid_sidebar_bounds_diagnostic().is_none() + { + for diagnostic in loaded.config.ui.sound.diagnostics() { + warn!(diagnostic = %diagnostic, "local sound config diagnostic"); + } + *sound_config = loaded.config.ui.sound.clone(); + *redraw_on_focus_gained = loaded.config.ui.redraw_on_focus_gained; + *draw_host_cursor = should_draw_host_cursor(loaded.config.ui.host_cursor); + *mouse_capture = loaded.config.ui.mouse_capture; + } + if !invalid_section("keys") { + *remote_image_paste_key = client_remote_image_paste_key(&loaded.config); } - let loaded_remote_image_paste_key = client_remote_image_paste_key(&loaded.config); - *sound_config = loaded.config.ui.sound; - *redraw_on_focus_gained = loaded.config.ui.redraw_on_focus_gained; - *draw_host_cursor = should_draw_host_cursor(loaded.config.ui.host_cursor); - *remote_image_paste_key = loaded_remote_image_paste_key; debug!("reloaded local client config"); } Err(diagnostics) => { @@ -2078,264 +1842,6 @@ fn reload_local_client_config( } } -fn handle_notify( - kind: NotifyKind, - message: &str, - body: Option<&str>, - sound_config: &crate::config::SoundConfig, -) { - handle_notify_with_notifiers( - kind, - message, - body, - sound_config, - crate::terminal_notify::show_notification, - crate::platform::show_desktop_notification, - ); -} - -fn handle_notify_with_notifiers( - kind: NotifyKind, - message: &str, - body: Option<&str>, - sound_config: &crate::config::SoundConfig, - mut show_terminal_notification: impl FnMut(&str, Option<&str>) -> io::Result<bool>, - mut show_system_notification: impl FnMut(&str, Option<&str>) -> io::Result<bool>, -) { - match kind { - NotifyKind::Sound => { - let Some(sound) = sound_from_notify_message(message) else { - warn!( - message = message, - "received unknown sound notification from server" - ); - return; - }; - if sound_config.enabled { - crate::sound::play(sound, sound_config); - } - } - NotifyKind::Toast => { - debug!( - message = message, - "received terminal toast notification from server" - ); - if let Err(err) = show_terminal_notification(message, body) { - warn!(err = %err, "failed to emit terminal notification"); - } - } - NotifyKind::SystemToast => { - debug!( - message = message, - "received system toast notification from server" - ); - if let Err(err) = show_system_notification(message, body) { - warn!(err = %err, "failed to emit system notification"); - } - } - } -} - -fn sound_from_notify_message(message: &str) -> Option<crate::sound::Sound> { - match message { - "agent done" => Some(crate::sound::Sound::Done), - "agent attention" => Some(crate::sound::Sound::Request), - _ => None, - } -} - -#[cfg(unix)] -fn should_bridge_clipboard_image_paste( - data: &[u8], - is_remote_client: bool, - remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, -) -> bool { - if data == b"\x1b[200~\x1b[201~" { - return is_remote_client; - } - - let Some(remote_image_paste_key) = remote_image_paste_key else { - return false; - }; - - let events = crate::raw_input::parse_raw_input_bytes_sync(data); - matches!( - events.as_slice(), - [crate::raw_input::RawInputEvent::Key(key)] - if key.kind == crossterm::event::KeyEventKind::Press - && crate::config::terminal_key_matches_combo(key, remote_image_paste_key) - ) -} - -#[cfg(any(windows, test))] -fn should_bridge_clipboard_image_events( - events: &[crate::protocol::ClientInputEvent], - is_remote_client: bool, - remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, -) -> bool { - if !is_remote_client { - return false; - } - if matches!( - events, - [crate::protocol::ClientInputEvent::Paste { text }] if text.is_empty() - ) { - return true; - } - - let Some(remote_image_paste_key) = remote_image_paste_key else { - return false; - }; - matches!( - events, - [event] - if matches!( - event.to_raw_input_event(), - crate::raw_input::RawInputEvent::Key(key) - if key.kind == crossterm::event::KeyEventKind::Press - && crate::config::terminal_key_matches_combo( - &key, - remote_image_paste_key, - ) - ) - ) -} - -#[cfg(unix)] -fn read_image_file_from_terminal_drop( - data: &[u8], - is_remote_client: bool, -) -> Option<crate::platform::ClipboardImage> { - let (path, extension) = image_path_from_terminal_drop(data, is_remote_client)?; - read_image_file(path, extension) -} - -#[cfg(any(windows, test))] -fn read_image_file_from_client_events( - events: &[crate::protocol::ClientInputEvent], - is_remote_client: bool, -) -> Option<crate::platform::ClipboardImage> { - let [crate::protocol::ClientInputEvent::Paste { text }] = events else { - return None; - }; - let text = normalized_terminal_drop_text(text)?; - // Windows events already carry native paths; Unix backslash unescaping would corrupt them. - let (path, extension) = - image_path_from_drop_text(strip_matching_path_quotes(text), is_remote_client)?; - read_image_file(path, extension) -} - -fn read_image_file( - path: std::path::PathBuf, - extension: &'static str, -) -> Option<crate::platform::ClipboardImage> { - let metadata = std::fs::metadata(&path).ok()?; - if !metadata.is_file() { - return None; - } - - let file = std::fs::File::open(&path).ok()?; - let bytes = - match crate::platform::read_limited_reader(file, MAX_CLIPBOARD_IMAGE_PAYLOAD).ok()? { - crate::platform::LimitedRead::Complete(bytes) => bytes, - crate::platform::LimitedRead::Empty => return None, - crate::platform::LimitedRead::Oversized => { - warn!( - max = MAX_CLIPBOARD_IMAGE_PAYLOAD, - "local image file drop is too large to bridge" - ); - return None; - } - }; - - Some(crate::platform::ClipboardImage { bytes, extension }) -} - -#[cfg(unix)] -fn image_path_from_terminal_drop( - data: &[u8], - is_remote_client: bool, -) -> Option<(std::path::PathBuf, &'static str)> { - let bytes = bracketed_paste_payload(data).unwrap_or(data); - let text = std::str::from_utf8(bytes).ok()?; - let text = normalized_terminal_drop_text(text)?; - let text = unescape_terminal_drop_path(strip_matching_path_quotes(text)); - image_path_from_drop_text(&text, is_remote_client) -} - -fn normalized_terminal_drop_text(text: &str) -> Option<&str> { - let text = text.trim_end_matches(['\r', '\n']); - (!text.is_empty() && !text.contains(['\r', '\n'])).then_some(text) -} - -fn image_path_from_drop_text( - text: &str, - is_remote_client: bool, -) -> Option<(std::path::PathBuf, &'static str)> { - if !is_remote_client { - return None; - } - let path = std::path::PathBuf::from(text); - if !path.is_absolute() { - return None; - } - let extension = recognized_image_extension(path.extension()?.to_str()?)?; - Some((path, extension)) -} - -#[cfg(unix)] -fn bracketed_paste_payload(data: &[u8]) -> Option<&[u8]> { - const START: &[u8] = b"\x1b[200~"; - const END: &[u8] = b"\x1b[201~"; - data.strip_prefix(START)?.strip_suffix(END) -} - -fn strip_matching_path_quotes(text: &str) -> &str { - if text.len() < 2 { - return text; - } - - let bytes = text.as_bytes(); - match (bytes.first(), bytes.last()) { - (Some(b'\''), Some(b'\'')) | (Some(b'"'), Some(b'"')) => &text[1..text.len() - 1], - _ => text, - } -} - -#[cfg(unix)] -fn unescape_terminal_drop_path(text: &str) -> String { - let mut unescaped = String::with_capacity(text.len()); - let mut chars = text.chars(); - while let Some(ch) = chars.next() { - if ch == '\\' { - if let Some(escaped) = chars.next() { - unescaped.push(escaped); - } else { - unescaped.push(ch); - } - } else { - unescaped.push(ch); - } - } - unescaped -} - -fn recognized_image_extension(extension: &str) -> Option<&'static str> { - if extension.eq_ignore_ascii_case("png") { - Some("png") - } else if extension.eq_ignore_ascii_case("jpg") || extension.eq_ignore_ascii_case("jpeg") { - Some("jpg") - } else if extension.eq_ignore_ascii_case("gif") { - Some("gif") - } else if extension.eq_ignore_ascii_case("webp") { - Some("webp") - } else if extension.eq_ignore_ascii_case("bmp") { - Some("bmp") - } else { - None - } -} - // --------------------------------------------------------------------------- // Clipboard forwarding // --------------------------------------------------------------------------- @@ -2347,308 +1853,14 @@ fn decode_clipboard_payload(data: &str) -> Option<Vec<u8>> { } /// Forwards a clipboard write from the server to the local client clipboard. -fn forward_clipboard(data: &str) { +fn forward_clipboard(data: &str) -> bool { let Some(bytes) = decode_clipboard_payload(data) else { warn!("received invalid clipboard payload from server"); - return; + return false; }; crate::selection::write_osc52_bytes(&bytes); -} - -// --------------------------------------------------------------------------- -// Frame output -// --------------------------------------------------------------------------- - -fn write_encoded_frame_with_graphics( - mut writer: impl io::Write, - encoded: &[u8], - graphics: &[u8], -) -> io::Result<()> { - if graphics.is_empty() { - return writer.write_all(encoded); - } - - let insertion = render_ansi::final_sync_output_end(encoded).unwrap_or(encoded.len()); - - writer.write_all(&encoded[..insertion])?; - record_received_kitty_graphics(graphics); - writer.write_all(b"\x1b7")?; - writer.write_all(graphics)?; - writer.write_all(b"\x1b8")?; - writer.write_all(&encoded[insertion..]) -} - -fn contains_kitty_graphics_bytes(bytes: &[u8]) -> bool { - bytes.windows(3).any(|window| window == b"\x1b_G") -} - -fn record_received_kitty_graphics(bytes: &[u8]) { - let ids = kitty_graphics_image_ids(bytes); - if ids.is_empty() { - return; - } - let set = RECEIVED_KITTY_GRAPHICS_IDS.get_or_init(|| Mutex::new(HashSet::new())); - if let Ok(mut set) = set.lock() { - set.extend(ids); - } -} - -fn clear_received_kitty_graphics(mut writer: impl io::Write) -> io::Result<()> { - let Some(set) = RECEIVED_KITTY_GRAPHICS_IDS.get() else { - return Ok(()); - }; - let Ok(mut set) = set.lock() else { - return Ok(()); - }; - for id in set.drain() { - write!(writer, "\x1b_Ga=d,d=I,i={id},q=2;\x1b\\")?; - } - writer.flush() -} - -fn kitty_graphics_image_ids(bytes: &[u8]) -> Vec<u32> { - let mut ids = Vec::new(); - let mut index = 0usize; - while let Some(start) = find_subslice(&bytes[index..], b"\x1b_G") { - let command_start = index + start + 3; - let Some(end) = find_subslice(&bytes[command_start..], b"\x1b\\") else { - break; - }; - let command = &bytes[command_start..command_start + end]; - if let Some(id) = kitty_graphics_command_image_id(command) { - ids.push(id); - } - index = command_start + end + 2; - } - ids -} - -fn kitty_graphics_command_image_id(command: &[u8]) -> Option<u32> { - let header_end = command - .iter() - .position(|byte| *byte == b';') - .unwrap_or(command.len()); - for part in command[..header_end].split(|byte| *byte == b',') { - let Some(value) = part.strip_prefix(b"i=") else { - continue; - }; - let text = std::str::from_utf8(value).ok()?; - if let Ok(id) = text.parse::<u32>() { - return Some(id); - } - } - None -} - -fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> { - if needle.is_empty() || needle.len() > haystack.len() { - return None; - } - haystack - .windows(needle.len()) - .position(|window| window == needle) -} - -// --------------------------------------------------------------------------- -// Resize polling -// --------------------------------------------------------------------------- - -/// Cell size assumed when neither the terminal size ioctl nor the host -/// terminal reports pixel dimensions. -const DEFAULT_CELL_WIDTH_PX: u32 = 8; -const DEFAULT_CELL_HEIGHT_PX: u32 = 16; - -/// Cell size derived from the terminal size ioctl, if it reports pixels. -fn ioctl_cell_size() -> Option<(u32, u32)> { - let size = crossterm::terminal::window_size().ok()?; - if size.columns == 0 || size.rows == 0 || size.width == 0 || size.height == 0 { - return None; - } - Some(( - (size.width as u32 / size.columns as u32).max(1), - (size.height as u32 / size.rows as u32).max(1), - )) -} - -/// Cell size used when the ioctl reports no pixels. -fn cell_size_fallback(reported: u64, last: Option<(u32, u32)>) -> (u32, u32) { - unpack_cell_size(reported) - .or(last.filter(|(width, height)| *width > 0 && *height > 0)) - .unwrap_or((DEFAULT_CELL_WIDTH_PX, DEFAULT_CELL_HEIGHT_PX)) -} - -#[cfg(any(unix, test))] -fn pack_cell_size(width_px: u32, height_px: u32) -> u64 { - (u64::from(width_px) << 32) | u64::from(height_px) -} - -fn unpack_cell_size(packed: u64) -> Option<(u32, u32)> { - let width_px = (packed >> 32) as u32; - let height_px = (packed & u64::from(u32::MAX)) as u32; - (width_px > 0 && height_px > 0).then_some((width_px, height_px)) -} - -fn current_terminal_geometry( - kitty_graphics_enabled: bool, - reported_cell_size: &AtomicU64, - last_cell_size: Option<(u32, u32)>, -) -> (u16, u16, u32, u32) { - let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24)); - if !kitty_graphics_enabled { - return (cols, rows, 0, 0); - } - let (cell_width_px, cell_height_px) = ioctl_cell_size().unwrap_or_else(|| { - cell_size_fallback(reported_cell_size.load(Ordering::Acquire), last_cell_size) - }); - (cols, rows, cell_width_px, cell_height_px) -} - -/// Reads terminal geometry before the handshake. Direct graphics is eligible -/// only when the host supplied exact pixel dimensions through the ioctl. -fn initial_terminal_geometry(kitty_graphics_enabled: bool) -> (u16, u16, u32, u32, bool) { - let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24)); - if !kitty_graphics_enabled { - return (cols, rows, 0, 0, false); - } - match ioctl_cell_size() { - Some((width, height)) => (cols, rows, width, height, true), - None => ( - cols, - rows, - DEFAULT_CELL_WIDTH_PX, - DEFAULT_CELL_HEIGHT_PX, - false, - ), - } -} - -/// Reports polled changes and signalled resizes that return to the same size. -fn resize_report_required( - signalled: bool, - new_size: (u16, u16, u32, u32), - last_size: (u16, u16, u32, u32), -) -> bool { - signalled || new_size != last_size -} - -/// Watches the terminal size and sends resize events when it changes. -/// -/// The baseline cell size must match what the handshake sent to the server: -/// reading a fresh one here would race the host cell size reply and could -/// swallow the first change. -fn resize_poll_loop( - resize_tx: tokio::sync::mpsc::Sender<ClientLoopEvent>, - initial_cols: u16, - initial_rows: u16, - initial_cell_width: u32, - initial_cell_height: u32, - kitty_graphics_enabled: bool, - reported_cell_size: &AtomicU64, - should_quit: &Arc<AtomicBool>, -) { - crate::platform::watch_terminal_resize_signal(); - let mut last_size = ( - initial_cols, - initial_rows, - initial_cell_width, - initial_cell_height, - ); - while !should_quit.load(Ordering::Acquire) { - std::thread::sleep(Duration::from_millis(100)); - let signalled = crate::platform::take_terminal_resize_signal(); - let new_size = current_terminal_geometry( - kitty_graphics_enabled, - reported_cell_size, - Some((last_size.2, last_size.3)), - ); - if resize_report_required(signalled, new_size, last_size) { - last_size = new_size; - if resize_tx - .blocking_send(ClientLoopEvent::Resize( - new_size.0, new_size.1, new_size.2, new_size.3, - )) - .is_err() - { - break; // Main loop gone. - } - } - } -} - -// --------------------------------------------------------------------------- -// Logging -// --------------------------------------------------------------------------- - -#[cfg(any(not(windows), test))] -fn query_host_terminal_appearance() { - let _ = write_host_terminal_appearance_query(io::stdout()); -} - -#[cfg(any(not(windows), test))] -fn write_host_terminal_appearance_query(mut writer: impl io::Write) -> io::Result<()> { - writer.write_all(crate::terminal_theme::HOST_COLOR_SCHEME_QUERY_SEQUENCE.as_bytes())?; - writer.flush() -} - -/// Initialize logging for the client process. -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<()> { - let query = crate::terminal_theme::host_terminal_theme_query_sequence( - crate::platform::should_query_host_terminal_palette(), - ); - writer.write_all(query.as_bytes())?; - writer.flush() -} - -/// XTWINOPS request for the host terminal cell size in pixels. -const HOST_CELL_SIZE_QUERY: &[u8] = b"\x1b[16t"; - -fn query_host_cell_size() { - let _ = write_host_cell_size_query(io::stdout()); -} - -fn should_query_host_cell_size() -> bool { - !cfg!(windows) -} - -/// Only pane graphics need pixel dimensions, and only when the ioctl cannot -/// provide them. -fn host_cell_size_query_required(kitty_graphics_enabled: bool) -> bool { - kitty_graphics_enabled && should_query_host_cell_size() && ioctl_cell_size().is_none() -} - -fn write_host_cell_size_query(mut writer: impl io::Write) -> io::Result<()> { - writer.write_all(HOST_CELL_SIZE_QUERY)?; - writer.flush() -} - -#[cfg(any(unix, test))] -fn store_reported_cell_size(reported_cell_size: &AtomicU64, width_px: u32, height_px: u32) { - let packed = pack_cell_size(width_px, height_px); - if reported_cell_size.swap(packed, Ordering::AcqRel) != packed { - debug!(width_px, height_px, "host terminal reported cell size"); - } -} - -#[cfg(any(unix, test))] -fn reported_cell_size_from_events( - events: &[crate::raw_input::RawInputEvent], -) -> Option<(u32, u32)> { - events.iter().rev().find_map(|event| match event { - crate::raw_input::RawInputEvent::HostCellSizeReport { - width_px, - height_px, - } => Some((*width_px, *height_px)), - _ => None, - }) + true } fn init_logging() { @@ -2660,991 +1872,4 @@ fn init_logging() { // --------------------------------------------------------------------------- #[cfg(test)] -mod tests { - use super::*; - use std::ffi::OsString; - use std::sync::{Mutex, OnceLock}; - - fn env_lock() -> &'static Mutex<()> { - static LOCK: OnceLock<Mutex<()>> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())) - } - - #[test] - fn resize_signal_reports_even_when_polled_size_is_unchanged() { - let size = (120, 40, 8, 16); - assert!(resize_report_required(true, size, size)); - assert!(!resize_report_required(false, size, size)); - assert!(resize_report_required(false, (120, 41, 8, 16), size)); - assert!(resize_report_required(false, (120, 40, 9, 18), size)); - } - - #[test] - fn approximate_cell_size_never_enables_direct_graphics() { - assert_eq!( - client_launch_mode(false, false, 8, 16), - ClientLaunchMode::App - ); - assert_eq!( - client_launch_mode(true, false, 8, 16), - ClientLaunchMode::TerminalAttach - ); - } - - #[test] - fn direct_graphics_profile_is_narrow_and_transport_safe() { - for (program, term, kitty, expected) in [ - ("ghostty", "", false, true), - ("WezTerm", "", false, true), - ("", "xterm-kitty", false, true), - ("", "xterm-256color", true, true), - ("", "xterm-256color", false, false), - ] { - assert_eq!( - direct_graphics_profile_values(program, term, kitty, false, true), - expected - ); - } - assert!(!direct_graphics_profile_values( - "ghostty", "", false, true, true - )); - assert!(!direct_graphics_profile_values( - "ghostty", "", false, false, false - )); - } - - fn restore_env_var(key: &str, value: Option<OsString>) { - if let Some(value) = value { - std::env::set_var(key, value); - } else { - std::env::remove_var(key); - } - } - - struct EnvVarGuard { - key: &'static str, - previous: Option<OsString>, - } - - impl EnvVarGuard { - fn set(key: &'static str, value: &str) -> Self { - let previous = std::env::var_os(key); - std::env::set_var(key, value); - Self { key, previous } - } - } - - impl Drop for EnvVarGuard { - fn drop(&mut self) { - restore_env_var(self.key, self.previous.clone()); - } - } - - #[test] - fn windows_virtual_terminal_input_mode_sets_only_vti_bit() { - assert_eq!(windows_virtual_terminal_input_mode(0x01f0), 0x03f0); - assert_eq!(windows_virtual_terminal_input_mode(0x03f0), 0x03f0); - } - - struct EnvVarsRemovedGuard { - previous: Vec<(&'static str, Option<OsString>)>, - } - - impl EnvVarsRemovedGuard { - fn new(keys: &[&'static str]) -> Self { - let previous: Vec<_> = keys - .iter() - .map(|key| (*key, std::env::var_os(key))) - .collect(); - for key in keys { - std::env::remove_var(key); - } - Self { previous } - } - } - - impl Drop for EnvVarsRemovedGuard { - fn drop(&mut self) { - for (key, value) in self.previous.clone() { - restore_env_var(key, value); - } - } - } - - #[test] - fn remote_client_uses_extended_handshake_timeout() { - let _guard = env_lock().lock().unwrap(); - let _remote = EnvVarGuard::set(crate::remote::REMOTE_KEYBINDINGS_ENV_VAR, "local"); - - assert_eq!(handshake_read_timeout(), REMOTE_HANDSHAKE_READ_TIMEOUT); - } - - #[test] - fn host_cursor_policy_auto_uses_platform_default() { - assert_eq!( - should_draw_host_cursor(crate::config::HostCursorModeConfig::Auto), - crate::platform::should_draw_host_cursor_by_default() - ); - } - - #[test] - fn host_cursor_policy_native_and_drawn_override_auto_detection() { - let _guard = env_lock().lock().unwrap(); - let _env = EnvVarGuard::set("TERM_PROGRAM", "WezTerm"); - - assert!(!should_draw_host_cursor( - crate::config::HostCursorModeConfig::Native - )); - assert!(should_draw_host_cursor( - crate::config::HostCursorModeConfig::Drawn - )); - } - - #[cfg(unix)] - #[test] - fn clipboard_image_paste_bridge_triggers_on_configured_key_and_empty_paste() { - let ctrl_v = crate::config::parse_key_combo("ctrl+v").unwrap(); - assert!(should_bridge_clipboard_image_paste( - &[0x16], - true, - Some(ctrl_v) - )); - assert!(should_bridge_clipboard_image_paste( - b"\x1b[118;5u", - true, - Some(ctrl_v) - )); - assert!(should_bridge_clipboard_image_paste( - b"\x1b[200~\x1b[201~", - true, - None - )); - assert!(!should_bridge_clipboard_image_paste( - b"\x1b[200~\x1b[201~", - false, - Some(ctrl_v) - )); - assert!(!should_bridge_clipboard_image_paste( - b"\x1b[200~text\x1b[201~", - true, - Some(ctrl_v) - )); - assert!(!should_bridge_clipboard_image_paste(&[0x16], true, None)); - assert!(!should_bridge_clipboard_image_paste( - b"v", - true, - Some(ctrl_v) - )); - } - - struct TempImageFile { - path: std::path::PathBuf, - } - - impl TempImageFile { - fn new(extension: &str, bytes: &[u8]) -> Self { - Self::with_name_fragment("test", extension, bytes) - } - - fn with_name_fragment(name_fragment: &str, extension: &str, bytes: &[u8]) -> Self { - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos(); - let path = std::env::temp_dir().join(format!( - "herdr-client-drop-{name_fragment}-{}-{nanos}.{extension}", - std::process::id() - )); - std::fs::write(&path, bytes).unwrap(); - Self { path } - } - } - - impl Drop for TempImageFile { - fn drop(&mut self) { - let _ = std::fs::remove_file(&self.path); - } - } - - #[test] - fn clipboard_image_event_bridge_matches_remote_key_and_empty_paste() { - let ctrl_v = crate::config::parse_key_combo("ctrl+v").unwrap(); - let key = crate::protocol::ClientInputEvent::Key { - code: crate::protocol::ClientKeyCode::Char('v'), - modifiers: crossterm::event::KeyModifiers::CONTROL.bits(), - kind: crate::protocol::ClientKeyKind::Press, - repeat_count: 1, - generated_text: None, - source: crate::protocol::ClientKeySource::Synthesized, - }; - let empty_paste = crate::protocol::ClientInputEvent::Paste { - text: String::new(), - }; - - assert!(should_bridge_clipboard_image_events( - std::slice::from_ref(&key), - true, - Some(ctrl_v), - )); - assert!(should_bridge_clipboard_image_events( - std::slice::from_ref(&empty_paste), - true, - None, - )); - assert!(!should_bridge_clipboard_image_events( - &[key], - false, - Some(ctrl_v), - )); - assert!(!should_bridge_clipboard_image_events( - &[crate::protocol::ClientInputEvent::Paste { - text: "text".to_string(), - }], - true, - Some(ctrl_v), - )); - } - - #[test] - fn remote_image_file_drop_bridge_reads_semantic_paste_path() { - let file = TempImageFile::new("PNG", b"image-bytes"); - let events = [crate::protocol::ClientInputEvent::Paste { - text: format!("\"{}\"", file.path.display()), - }]; - - let image = read_image_file_from_client_events(&events, true).unwrap(); - - assert_eq!(image.extension, "png"); - assert_eq!(image.bytes, b"image-bytes"); - } - - #[cfg(unix)] - #[test] - fn remote_image_file_drop_bridge_reads_bracketed_absolute_image_path() { - let file = TempImageFile::new("PNG", b"image-bytes"); - let input = format!("\x1b[200~{}\x1b[201~", file.path.display()); - - let image = read_image_file_from_terminal_drop(input.as_bytes(), true).unwrap(); - - assert_eq!(image.extension, "png"); - assert_eq!(image.bytes, b"image-bytes"); - } - - #[cfg(unix)] - #[test] - fn remote_image_file_drop_bridge_reads_plain_quoted_path_with_newline() { - let file = TempImageFile::new("jpeg", b"jpeg-bytes"); - let input = format!("'{}'\n", file.path.display()); - - let image = read_image_file_from_terminal_drop(input.as_bytes(), true).unwrap(); - - assert_eq!(image.extension, "jpg"); - assert_eq!(image.bytes, b"jpeg-bytes"); - } - - #[cfg(unix)] - #[test] - fn remote_image_file_drop_bridge_unescapes_spaces_in_paths() { - let file = TempImageFile::with_name_fragment("space test", "png", b"image-bytes"); - let escaped_path = file.path.display().to_string().replace(' ', "\\ "); - - let image = read_image_file_from_terminal_drop(escaped_path.as_bytes(), true).unwrap(); - - assert_eq!(image.extension, "png"); - assert_eq!(image.bytes, b"image-bytes"); - } - - #[cfg(unix)] - #[test] - fn remote_image_file_drop_bridge_ignores_non_remote_and_non_image_input() { - let file = TempImageFile::new("png", b"image-bytes"); - let path = file.path.display().to_string(); - - assert!(read_image_file_from_terminal_drop(path.as_bytes(), false).is_none()); - assert!(read_image_file_from_terminal_drop(b"relative.png\n", true).is_none()); - assert!(read_image_file_from_terminal_drop(b"/tmp/file.txt\n", true).is_none()); - assert!(read_image_file_from_terminal_drop( - format!("{}\nextra", file.path.display()).as_bytes(), - true - ) - .is_none()); - } - - #[test] - fn graphics_bytes_are_written_inside_synchronized_blit_with_saved_cursor() { - let mut output = Vec::new(); - write_encoded_frame_with_graphics( - &mut output, - b"\x1b[?2026htext\x1b[?2026lcursor", - b"graphics", - ) - .unwrap(); - - assert_eq!( - output, - b"\x1b[?2026htext\x1b7graphics\x1b8\x1b[?2026lcursor" - ); - } - - #[test] - fn empty_graphics_writes_only_blit_frame() { - let mut output = Vec::new(); - write_encoded_frame_with_graphics(&mut output, b"text", b"").unwrap(); - - assert_eq!(output, b"text"); - } - - #[test] - fn terminal_frame_kitty_detection_matches_apc_prefix() { - assert!(contains_kitty_graphics_bytes(b"text\x1b_Ga=p;\x1b\\")); - assert!(!contains_kitty_graphics_bytes(b"text\x1b[?2026h")); - } - - #[test] - fn kitty_graphics_image_id_parser_tracks_herdr_ids_only() { - let ids = kitty_graphics_image_ids( - b"text\x1b_Ga=t,t=d,f=32,s=1,v=1,i=10023,q=2;AAAA\x1b\\\x1b_Ga=p,i=10023,p=7;\x1b\\", - ); - assert_eq!(ids, vec![10023, 10023]); - } - - #[test] - fn kitty_graphics_cleanup_deletes_tracked_images_not_all_images() { - record_received_kitty_graphics(b"\x1b_Ga=t,i=123,q=2;AAAA\x1b\\"); - let mut output = Vec::new(); - clear_received_kitty_graphics(&mut output).unwrap(); - let text = String::from_utf8(output).unwrap(); - assert!(text.contains("a=d,d=I,i=123")); - assert!(!text.contains("d=A")); - } - - #[test] - fn write_host_terminal_appearance_query_emits_mode_2031_query() { - let mut output = Vec::new(); - write_host_terminal_appearance_query(&mut output).unwrap(); - assert_eq!(output, b"\x1b[?996n"); - } - - #[test] - fn write_host_terminal_theme_query_emits_osc_queries() { - let mut output = Vec::new(); - write_host_terminal_theme_query(&mut output).unwrap(); - assert_eq!( - output, - crate::terminal_theme::host_terminal_theme_query_sequence( - crate::platform::should_query_host_terminal_palette(), - ) - .as_bytes() - ); - assert!(!output - .windows(crate::terminal_theme::HOST_COLOR_SCHEME_QUERY_SEQUENCE.len()) - .any(|window| window - == crate::terminal_theme::HOST_COLOR_SCHEME_QUERY_SEQUENCE.as_bytes())); - } - - #[test] - fn write_host_color_scheme_report_mode_emits_mode_sequences() { - let mut output = Vec::new(); - write_host_color_scheme_report_mode(&mut output, true).unwrap(); - write_host_color_scheme_report_mode(&mut output, false).unwrap(); - - let mut expected = Vec::new(); - expected.extend_from_slice( - crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_ENABLE_SEQUENCE.as_bytes(), - ); - expected.extend_from_slice( - crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_DISABLE_SEQUENCE.as_bytes(), - ); - assert_eq!(output, expected); - } - - #[test] - fn color_scheme_change_event_requests_host_theme_query() { - let events = crate::raw_input::parse_raw_input_bytes_sync(b"\x1b[?997;1n"); - - assert!(crate::raw_input::events_require_host_terminal_theme_query( - &events - )); - } - - #[test] - fn host_terminal_theme_query_is_disabled_on_windows() { - assert_eq!(should_query_host_terminal_theme(), !cfg!(windows)); - } - - #[test] - fn write_host_cell_size_query_emits_xtwinops_request() { - let mut output = Vec::new(); - write_host_cell_size_query(&mut output).unwrap(); - - assert_eq!(output, b"\x1b[16t"); - } - - #[test] - fn host_cell_size_query_is_disabled_on_windows() { - assert_eq!(should_query_host_cell_size(), !cfg!(windows)); - } - - #[test] - fn cell_size_fallback_prefers_reported_then_previous_size() { - assert_eq!(cell_size_fallback(0, None), (8, 16)); - assert_eq!(cell_size_fallback(0, Some((11, 22))), (11, 22)); - assert_eq!( - cell_size_fallback(pack_cell_size(10, 21), Some((11, 22))), - (10, 21) - ); - assert_eq!(cell_size_fallback(pack_cell_size(10, 0), None), (8, 16)); - assert_eq!(cell_size_fallback(pack_cell_size(0, 21), None), (8, 16)); - } - - #[test] - fn reported_cell_size_is_taken_from_host_cell_size_events() { - let events = crate::raw_input::parse_raw_input_bytes_sync(b"\x1b[?997;1n"); - assert_eq!(reported_cell_size_from_events(&events), None); - - let events = crate::raw_input::parse_raw_input_bytes_sync(b"\x1b[6;21;10t\x1b[6;18;9t"); - assert_eq!(reported_cell_size_from_events(&events), Some((9, 18))); - } - - #[test] - fn color_scheme_reports_are_enabled_only_for_full_clients() { - assert_eq!( - should_enable_host_color_scheme_reports(true), - !cfg!(windows) - ); - assert!(!should_enable_host_color_scheme_reports(false)); - } - - #[test] - fn terminal_restore_postlude_restores_visible_default_cursor() { - let mut output = Vec::new(); - write_terminal_restore_postlude(&mut output, false).unwrap(); - assert_eq!(output, b"\x1b[?25h\x1b[0 q"); - } - - #[test] - fn terminal_restore_postlude_disables_color_scheme_reports_when_enabled() { - let mut output = Vec::new(); - write_terminal_restore_postlude(&mut output, true).unwrap(); - - let mut expected = Vec::new(); - expected.extend_from_slice( - crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_DISABLE_SEQUENCE.as_bytes(), - ); - expected.extend_from_slice(b"\x1b[?25h\x1b[0 q"); - assert_eq!(output, expected); - } - - #[cfg(unix)] - #[test] - fn attach_escape_detaches_on_prefix_q() { - let mut escape = AttachEscapeState::default(); - assert!(matches!( - escape.filter_input(vec![0x02], 24, 3), - AttachInputAction::None - )); - assert!(matches!( - escape.filter_input(vec![b'q'], 24, 3), - AttachInputAction::Detach - )); - } - - #[cfg(unix)] - #[test] - fn attach_escape_sends_literal_prefix_on_double_prefix() { - let mut escape = AttachEscapeState::default(); - assert!(matches!( - escape.filter_input(vec![0x02], 24, 3), - AttachInputAction::None - )); - match escape.filter_input(vec![0x02], 24, 3) { - AttachInputAction::Forward(bytes) => assert_eq!(bytes, vec![0x02]), - other => panic!("expected forwarded prefix, got {other:?}"), - } - } - - #[cfg(unix)] - #[test] - fn attach_escape_does_not_interpret_bracketed_paste_contents() { - let mut escape = AttachEscapeState::default(); - let paste = b"\x1b[200~one\x02q\ntwo\x1b[201~".to_vec(); - - match escape.filter_input(paste.clone(), 24, 3) { - AttachInputAction::Forward(bytes) => assert_eq!(bytes, paste), - other => panic!("expected opaque paste, got {other:?}"), - } - } - - #[cfg(unix)] - #[test] - fn attach_escape_flushes_pending_prefix_before_bracketed_paste() { - let mut escape = AttachEscapeState::default(); - let paste = b"\x1b[200~one\ntwo\x1b[201~".to_vec(); - assert!(matches!( - escape.filter_input(vec![0x02], 24, 3), - AttachInputAction::None - )); - - assert!(matches!( - escape.filter_input(paste.clone(), 24, 3), - AttachInputAction::ForwardAfterPendingPrefix(bytes) if bytes == paste - )); - assert!(matches!( - escape.filter_input(vec![b'q'], 24, 3), - AttachInputAction::Forward(bytes) if bytes == b"q" - )); - } - - #[cfg(unix)] - #[test] - fn attach_escape_forwards_prefix_before_non_escape_key() { - let mut escape = AttachEscapeState::default(); - assert!(matches!( - escape.filter_input(vec![b'a', 0x02], 24, 3), - AttachInputAction::Forward(bytes) if bytes == b"a" - )); - match escape.filter_input(vec![b'x'], 24, 3) { - AttachInputAction::Forward(bytes) => assert_eq!(bytes, vec![0x02, b'x']), - other => panic!("expected forwarded bytes, got {other:?}"), - } - } - - #[cfg(unix)] - #[test] - fn attach_escape_turns_wheel_into_scroll_action() { - let mut escape = AttachEscapeState::default(); - match escape.filter_input(b"\x1b[<64;11;6M".to_vec(), 24, 7) { - AttachInputAction::Scroll { - source, - direction, - lines, - column, - row, - .. - } => { - assert_eq!(source, AttachScrollSource::Wheel); - assert_eq!(direction, AttachScrollDirection::Up); - assert_eq!(lines, 7); - assert_eq!(column, Some(10)); - assert_eq!(row, Some(5)); - } - other => panic!("expected scroll action, got {other:?}"), - } - } - - #[cfg(unix)] - #[test] - fn attach_escape_swallows_non_wheel_mouse_reports() { - let mut escape = AttachEscapeState::default(); - assert!(matches!( - escape.filter_input(b"\x1b[<0;11;6M".to_vec(), 24, 7), - AttachInputAction::None - )); - } - - #[cfg(unix)] - #[test] - fn attach_escape_turns_plain_page_keys_into_scroll_actions() { - let mut escape = AttachEscapeState::default(); - match escape.filter_input(b"\x1b[5~".to_vec(), 12, 3) { - AttachInputAction::Scroll { - source, - direction, - lines, - .. - } => { - assert_eq!( - source, - AttachScrollSource::PageKey { - input: b"\x1b[5~".to_vec() - } - ); - assert_eq!(direction, AttachScrollDirection::Up); - assert_eq!(lines, 11); - } - other => panic!("expected page-up scroll action, got {other:?}"), - } - - match escape.filter_input(b"\x1b[6~".to_vec(), 12, 3) { - AttachInputAction::Scroll { - source, - direction, - lines, - .. - } => { - assert_eq!( - source, - AttachScrollSource::PageKey { - input: b"\x1b[6~".to_vec() - } - ); - assert_eq!(direction, AttachScrollDirection::Down); - assert_eq!(lines, 11); - } - other => panic!("expected page-down scroll action, got {other:?}"), - } - } - - #[cfg(unix)] - #[test] - fn attach_escape_forwards_modified_page_key() { - let mut escape = AttachEscapeState::default(); - match escape.filter_input(b"\x1b[5;5~".to_vec(), 12, 3) { - AttachInputAction::Forward(bytes) => assert_eq!(bytes, b"\x1b[5;5~"), - other => panic!("expected modified page key to forward, got {other:?}"), - } - } - - #[test] - fn client_error_display_connection_failed() { - let err = ClientError::ConnectionFailed(io::Error::new( - io::ErrorKind::ConnectionRefused, - "connection refused", - )); - let msg = err.to_string(); - assert!( - msg.contains("failed to connect to server"), - "should mention connection failure: {msg}" - ); - assert!( - msg.contains("herdr server"), - "should suggest starting server: {msg}" - ); - } - - #[test] - fn client_error_display_handshake_rejected() { - let err = ClientError::HandshakeRejected { - version: 1, - error: "incompatible".into(), - }; - let msg = err.to_string(); - assert!( - msg.contains("rejected handshake"), - "should mention rejection: {msg}" - ); - assert!(msg.contains("incompatible"), "should include error: {msg}"); - } - - #[test] - fn client_error_display_server_shutdown() { - let err = ClientError::ServerShutdown { - reason: Some("maintenance".into()), - }; - let msg = err.to_string(); - assert!( - msg.contains("server shut down"), - "should mention shutdown: {msg}" - ); - assert!(msg.contains("maintenance"), "should include reason: {msg}"); - } - - #[test] - fn client_error_display_server_shutdown_no_reason() { - let err = ClientError::ServerShutdown { reason: None }; - let msg = err.to_string(); - assert!( - msg.contains("server shut down"), - "should mention shutdown: {msg}" - ); - } - - #[test] - fn client_error_display_detached_default_session_reattach_hint() { - let _guard = env_lock().lock().unwrap(); - let _env = EnvVarsRemovedGuard::new(&[ - crate::remote::REATTACH_COMMAND_ENV_VAR, - crate::session::SESSION_ENV_VAR, - ]); - let err = ClientError::ServerShutdown { - reason: Some("detached".into()), - }; - let msg = err.to_string(); - assert!( - msg.contains("Run `herdr` to reattach"), - "should suggest default reattach command: {msg}" - ); - } - - #[test] - fn client_error_display_detached_named_session_reattach_hint() { - let _guard = env_lock().lock().unwrap(); - let _remote_env = EnvVarsRemovedGuard::new(&[crate::remote::REATTACH_COMMAND_ENV_VAR]); - let _session_env = EnvVarGuard::set(crate::session::SESSION_ENV_VAR, "work"); - let err = ClientError::ServerShutdown { - reason: Some("detached".into()), - }; - let msg = err.to_string(); - assert!( - msg.contains("Run `herdr session attach work` to reattach"), - "should suggest named session reattach command: {msg}" - ); - } - - #[test] - fn client_error_display_detached_remote_reattach_hint_takes_precedence() { - let _guard = env_lock().lock().unwrap(); - let _remote_env = EnvVarGuard::set( - crate::remote::REATTACH_COMMAND_ENV_VAR, - "herdr --remote host --session work", - ); - let _session_env = EnvVarGuard::set(crate::session::SESSION_ENV_VAR, "work"); - let err = ClientError::ServerShutdown { - reason: Some("detached".into()), - }; - let msg = err.to_string(); - assert!( - msg.contains("Run `herdr --remote host --session work` to reattach"), - "should prefer remote reattach command: {msg}" - ); - } - - #[test] - fn client_error_display_connection_lost() { - let _guard = env_lock().lock().unwrap(); - let _env = EnvVarsRemovedGuard::new(&[crate::remote::REATTACH_COMMAND_ENV_VAR]); - let err = - ClientError::ConnectionLost(io::Error::new(io::ErrorKind::BrokenPipe, "broken pipe")); - let msg = err.to_string(); - assert!( - msg.contains("lost connection to server"), - "should mention lost connection: {msg}" - ); - } - - #[test] - fn client_error_display_remote_connection_lost_has_reattach_hint() { - let _guard = env_lock().lock().unwrap(); - let _remote_env = EnvVarGuard::set( - crate::remote::REATTACH_COMMAND_ENV_VAR, - "herdr --remote host --session work", - ); - let err = - ClientError::ConnectionLost(io::Error::new(io::ErrorKind::BrokenPipe, "broken pipe")); - let msg = err.to_string(); - assert!( - msg.contains("lost connection to remote Herdr"), - "should mention remote connection loss: {msg}" - ); - assert!( - msg.contains("panes may still be running"), - "should explain possible persistence: {msg}" - ); - assert!( - msg.contains("Run `herdr --remote host --session work` to reattach"), - "should show remote reattach command: {msg}" - ); - } - - #[test] - fn sound_from_notify_message_maps_done() { - assert_eq!( - sound_from_notify_message("agent done"), - Some(crate::sound::Sound::Done) - ); - } - - #[test] - fn sound_from_notify_message_maps_attention() { - assert_eq!( - sound_from_notify_message("agent attention"), - Some(crate::sound::Sound::Request) - ); - } - - #[test] - fn sound_from_notify_message_rejects_unknown_payloads() { - assert_eq!(sound_from_notify_message("toast"), None); - } - - #[test] - fn reload_local_client_config_refreshes_local_client_presentation_state() { - let _guard = crate::config::test_config_env_lock().lock().unwrap(); - let path = std::env::temp_dir().join(format!( - "herdr-client-config-reload-{}-{}.toml", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - std::fs::write( - &path, - "[ui]\nredraw_on_focus_gained = false\nhost_cursor = \"drawn\"\n", - ) - .unwrap(); - let path_string = path.to_string_lossy().to_string(); - let _env = EnvVarGuard::set(crate::config::CONFIG_PATH_ENV_VAR, &path_string); - let mut sound_config = crate::config::SoundConfig::default(); - let mut redraw_on_focus_gained = true; - let mut draw_host_cursor = false; - let mut remote_image_paste_key = None; - - reload_local_client_config( - &mut sound_config, - &mut redraw_on_focus_gained, - &mut draw_host_cursor, - &mut remote_image_paste_key, - ); - - assert!(!redraw_on_focus_gained); - assert!(draw_host_cursor); - let _ = std::fs::remove_file(path); - } - - #[test] - fn toast_notify_from_server_is_emitted_even_when_attach_config_was_off() { - let sound_config = crate::config::SoundConfig::default(); - let mut emitted = None; - - handle_notify_with_notifiers( - NotifyKind::Toast, - "pi finished", - Some("workspace 1"), - &sound_config, - |title, body| { - emitted = Some((title.to_string(), body.map(str::to_string))); - Ok(true) - }, - |_, _| Ok(false), - ); - - assert_eq!( - emitted, - Some(("pi finished".to_string(), Some("workspace 1".to_string()))) - ); - } - - #[test] - fn system_toast_notify_from_server_uses_system_notifier() { - let sound_config = crate::config::SoundConfig::default(); - let mut emitted = None; - - handle_notify_with_notifiers( - NotifyKind::SystemToast, - "pi finished", - Some("workspace 1"), - &sound_config, - |_, _| Ok(false), - |title, body| { - emitted = Some((title.to_string(), body.map(str::to_string))); - Ok(true) - }, - ); - - assert_eq!( - emitted, - Some(("pi finished".to_string(), Some("workspace 1".to_string()))) - ); - } - - #[test] - fn system_toast_notify_preserves_colon_in_title() { - let sound_config = crate::config::SoundConfig::default(); - let mut emitted = None; - - handle_notify_with_notifiers( - NotifyKind::SystemToast, - "build: failed", - Some("api workspace"), - &sound_config, - |_, _| Ok(false), - |title, body| { - emitted = Some((title.to_string(), body.map(str::to_string))); - Ok(true) - }, - ); - - assert_eq!( - emitted, - Some(( - "build: failed".to_string(), - Some("api workspace".to_string()) - )) - ); - } - - #[test] - fn decode_clipboard_payload_decodes_base64() { - assert_eq!(decode_clipboard_payload("dGVzdA=="), Some(b"test".to_vec())); - } - - #[test] - fn decode_clipboard_payload_rejects_invalid_base64() { - assert_eq!(decode_clipboard_payload("not-base64!!!"), None); - } - - #[test] - fn terminal_control_input_command_accepts_text() { - let action = - terminal_control_command_from_json(r#"{"type":"terminal.input","text":"hello"}"#) - .unwrap(); - let ClientMessage::Input { data } = action else { - panic!("expected input command"); - }; - assert_eq!(data, b"hello"); - } - - #[test] - fn terminal_control_input_command_accepts_base64_bytes() { - let action = - terminal_control_command_from_json(r#"{"type":"terminal.input","bytes":"G1tB"}"#) - .unwrap(); - let ClientMessage::Input { data } = action else { - panic!("expected input command"); - }; - assert_eq!(data, b"\x1b[A"); - } - - #[test] - fn terminal_control_resize_command_maps_to_client_resize() { - let action = terminal_control_command_from_json( - r#"{"type":"terminal.resize","cols":100,"rows":30,"cell_width_px":8,"cell_height_px":16}"#, - ) - .unwrap(); - let ClientMessage::Resize { - cols, - rows, - cell_width_px, - cell_height_px, - } = action - else { - panic!("expected resize command"); - }; - assert_eq!( - (cols, rows, cell_width_px, cell_height_px), - (100, 30, 8, 16) - ); - } - - #[test] - fn terminal_control_scroll_command_maps_to_attach_scroll() { - let action = terminal_control_command_from_json( - r#"{"type":"terminal.scroll","direction":"up","lines":3}"#, - ) - .unwrap(); - let ClientMessage::AttachScroll { - source, - direction, - lines, - .. - } = action - else { - panic!("expected scroll command"); - }; - assert_eq!(source, AttachScrollSource::Wheel); - assert_eq!(direction, AttachScrollDirection::Up); - assert_eq!(lines, 3); - } - - #[test] - fn forward_clipboard_uses_local_clipboard_path() { - unsafe { - std::env::set_var("SSH_CONNECTION", "1 2 3 4"); - } - forward_clipboard("dGVzdA=="); - unsafe { - std::env::remove_var("SSH_CONNECTION"); - } - } -} +mod tests; |
