diff options
Diffstat (limited to 'src/client')
| -rw-r--r-- | src/client/input.rs | 85 | ||||
| -rw-r--r-- | src/client/loop_config.rs | 2 | ||||
| -rw-r--r-- | src/client/mod.rs | 16 | ||||
| -rw-r--r-- | src/client/terminal_setup.rs | 296 |
4 files changed, 367 insertions, 32 deletions
diff --git a/src/client/input.rs b/src/client/input.rs index 41850f96..45656f84 100644 --- a/src/client/input.rs +++ b/src/client/input.rs @@ -42,6 +42,8 @@ pub fn stdin_reader_loop( host_cell_size_query_sent: bool, host_mouse_capture_active: Arc<AtomicBool>, host_sgr_pixels_active: Arc<AtomicBool>, + host_escape_disambiguation_active: bool, + initial_host_input: Vec<u8>, #[cfg(unix)] direct_response: Arc<std::sync::Mutex<super::direct_graphics::ResponseMatcher>>, #[cfg(unix)] direct_response_active: Arc<AtomicBool>, ) { @@ -53,6 +55,7 @@ pub fn stdin_reader_loop( host_mouse_capture_active, host_sgr_pixels_active, ); + let _ = (host_escape_disambiguation_active, initial_host_input); windows_stdin_reader_loop(event_tx, should_quit); } @@ -64,6 +67,8 @@ pub fn stdin_reader_loop( host_cell_size_query_sent, host_mouse_capture_active, host_sgr_pixels_active, + host_escape_disambiguation_active, + initial_host_input, direct_response, direct_response_active, ); @@ -77,6 +82,8 @@ fn unix_stdin_reader_loop( host_cell_size_query_sent: bool, host_mouse_capture_active: Arc<AtomicBool>, host_sgr_pixels_active: Arc<AtomicBool>, + host_escape_disambiguation_active: bool, + initial_host_input: Vec<u8>, direct_response: Arc<std::sync::Mutex<super::direct_graphics::ResponseMatcher>>, direct_response_active: Arc<AtomicBool>, ) { @@ -84,6 +91,7 @@ fn unix_stdin_reader_loop( let mut reader = stdin.lock(); let mut scratch = [0u8; 4096]; let mut framer = crate::raw_input::RawInputByteFramer::for_host_input(); + framer.set_host_escape_disambiguation_active(host_escape_disambiguation_active); if host_color_query_sent { framer.host_color_query_sent(); framer.enable_host_color_scheme_change_tracking(); @@ -97,6 +105,57 @@ fn unix_stdin_reader_loop( let mut last_geometry = None; let mut direct_filter = super::direct_graphics::InputFilter::default(); + if !initial_host_input.is_empty() { + let sgr_pixels = host_sgr_pixels_active.load(Ordering::Acquire); + if sgr_pixels { + last_geometry = crate::input::mouse::HostGeometry::current(); + } + let chunks = framer.push(&initial_host_input); + if !send_unix_input_chunks( + chunks, + &event_tx, + &mut pending_palette, + sgr_pixels, + last_geometry, + ) { + return; + } + if (framer.has_pending_input() || !pending_palette.is_empty()) + && stdin_read_ready( + &reader, + idle_flush_timeout_ms(&framer, host_mouse_capture_active.load(Ordering::Acquire)), + ) == Some(false) + { + let had_pending = framer.has_pending_input(); + let chunks = framer.flush_timeout(); + let held_escape = had_pending && chunks.is_empty(); + if !send_unix_input_chunks( + chunks, + &event_tx, + &mut pending_palette, + sgr_pixels, + last_geometry, + ) || !flush_unix_palette_input(&event_tx, &mut pending_palette) + { + return; + } + if held_escape + && stdin_read_ready(&reader, crate::raw_input::RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS) + == Some(false) + && !send_unix_input_chunks( + framer.flush_timeout(), + &event_tx, + &mut pending_palette, + sgr_pixels, + last_geometry, + ) + { + return; + } + } + pending_mode = framer.has_pending_input().then_some(sgr_pixels); + } + while !should_quit.load(Ordering::Acquire) { if direct_filter.has_pending() && stdin_read_ready(&reader, crate::raw_input::RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS) @@ -593,31 +652,7 @@ fn stdin_read_ready<R: AsRawFd>(reader: &R, timeout_ms: i32) -> Option<bool> { #[cfg(unix)] fn poll_read_ready(fd: i32, timeout_ms: i32) -> Option<bool> { - #[repr(C)] - struct PollFd { - fd: i32, - events: i16, - revents: i16, - } - - unsafe extern "C" { - fn poll(fds: *mut PollFd, nfds: usize, timeout: i32) -> i32; - } - - const POLLIN: i16 = 0x0001; - - let mut pfd = PollFd { - fd, - events: POLLIN, - revents: 0, - }; - - let result = unsafe { poll(&mut pfd as *mut PollFd, 1, timeout_ms) }; - if result < 0 { - None - } else { - Some(result > 0) - } + crate::platform::poll_fd_readable(fd, timeout_ms).ok() } // --------------------------------------------------------------------------- diff --git a/src/client/loop_config.rs b/src/client/loop_config.rs index 53ef9f38..97979bbf 100644 --- a/src/client/loop_config.rs +++ b/src/client/loop_config.rs @@ -9,6 +9,8 @@ pub(super) struct ClientLoopConfig { pub(super) pixel_geometry_enabled: bool, pub(super) pixel_geometry_fallback: bool, pub(super) mouse_capture_active: bool, + pub(super) host_escape_disambiguation_active: bool, + pub(super) initial_host_input: Vec<u8>, pub(super) endpoint_keybindings: bool, pub(super) remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, diff --git a/src/client/mod.rs b/src/client/mod.rs index 1cef87a7..a5094397 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -79,6 +79,7 @@ fn refresh_host_mouse_capture(enabled: bool, sgr_pixels: bool) { warn!(err = %err, "failed to re-assert host mouse capture"); } } + #[cfg(windows)] use terminal_setup::{is_ssh_session, windows_vti_input_backend_enabled}; #[cfg(test)] @@ -179,7 +180,7 @@ fn run_client_with_mode( let endpoint_keybindings = shell_config .as_ref() .is_some_and(shell::ClientShellConfig::uses_endpoint_keybindings); - let loop_config = ClientLoopConfig { + let mut loop_config = ClientLoopConfig { sound_config: loaded_config.config.ui.sound, mouse_scroll_lines, redraw_on_focus_gained, @@ -188,6 +189,8 @@ fn run_client_with_mode( pixel_geometry_enabled, pixel_geometry_fallback: kitty_graphics_enabled, mouse_capture_active: mouse_capture, + host_escape_disambiguation_active: false, + initial_host_input: Vec::new(), endpoint_keybindings, remote_image_paste_key, shell_config, @@ -278,7 +281,7 @@ fn run_client_with_mode( // The federated shell can show connection notices without any server snapshot. let direct_attach = attach_escape.is_some(); - let terminal_guard = if direct_attach { + let mut terminal_guard = if direct_attach { setup_direct_attach_terminal(mouse_capture) } else { setup_terminal(mouse_capture) @@ -287,6 +290,9 @@ fn run_client_with_mode( eprintln!("herdr: failed to set up terminal: {err}"); err })?; + loop_config.host_escape_disambiguation_active = + terminal_guard.host_escape_disambiguation_active(); + loop_config.initial_host_input = terminal_guard.take_buffered_host_input(); // Install a panic hook so the foreground client always restores its terminal. let panic_restore = terminal_guard.panic_restore(); @@ -374,7 +380,7 @@ async fn run_client_loop( initial_cell_height_px: u32, initial_pixel_geometry_exact: bool, should_quit: Arc<AtomicBool>, - config: ClientLoopConfig, + mut config: ClientLoopConfig, attach_escape: Option<AttachEscapeState>, _terminal_guard: &TerminalGuard, ) -> Result<(), ClientError> { @@ -475,6 +481,8 @@ async fn run_client_loop( let stdin_quit = should_quit.clone(); let stdin_mouse_capture_active = host_mouse_capture_active.clone(); let stdin_sgr_pixels_active = host_sgr_pixels_active.clone(); + let stdin_escape_disambiguation_active = config.host_escape_disambiguation_active; + let stdin_initial_host_input = std::mem::take(&mut config.initial_host_input); #[cfg(unix)] let stdin_direct_response = state.direct_graphics_response.clone(); #[cfg(unix)] @@ -490,6 +498,8 @@ async fn run_client_loop( will_query_host_cell_size, stdin_mouse_capture_active, stdin_sgr_pixels_active, + stdin_escape_disambiguation_active, + stdin_initial_host_input, #[cfg(unix)] stdin_direct_response, #[cfg(unix)] diff --git a/src/client/terminal_setup.rs b/src/client/terminal_setup.rs index 3bb5c281..9c84985a 100644 --- a/src/client/terminal_setup.rs +++ b/src/client/terminal_setup.rs @@ -1,10 +1,14 @@ //! Terminal setup and restoration for the rendered client. use std::io::{self, Write as _}; +#[cfg(not(windows))] +use std::os::fd::AsRawFd as _; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; #[cfg(windows)] use std::sync::{Mutex, MutexGuard}; +#[cfg(not(windows))] +use std::time::{Duration, Instant}; use crossterm::event::{ DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste, @@ -44,6 +48,8 @@ pub(super) fn setup_terminal_with_capabilities( ) -> io::Result<TerminalGuard> { ratatui::init(); let mut terminal_guard = TerminalGuard { + host_escape_disambiguation_active: false, + buffered_host_input: Vec::new(), reset_keyboard_enhancements: false, reset_modify_other_keys: false, reset_host_color_scheme_reports: false, @@ -68,22 +74,25 @@ pub(super) fn setup_terminal_with_capabilities( WindowsVirtualTerminalInputSetup::default() }; - if enable_client_protocols { + let (host_escape_disambiguation_active, buffered_host_input) = if enable_client_protocols { + terminal_guard.reset_keyboard_enhancements = true; + push_keyboard_enhancement_flags()?; + let (active, buffered_input) = query_host_escape_disambiguation(); set_mouse_capture(mouse_capture, false)?; execute!(io::stdout(), EnableBracketedPaste, EnableFocusChange)?; if host_color_scheme_reports { terminal_guard.reset_host_color_scheme_reports = true; write_host_color_scheme_report_mode(&mut io::stdout(), true)?; } - terminal_guard.reset_keyboard_enhancements = true; - push_keyboard_enhancement_flags()?; + (active, buffered_input) } 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)?; - } + (false, Vec::new()) + }; #[cfg(windows)] if enable_client_protocols && windows_vti_input_backend_enabled() && !windows_ssh_session { @@ -113,6 +122,8 @@ pub(super) fn setup_terminal_with_capabilities( execute!(io::stdout(), DisableLineWrap)?; + terminal_guard.host_escape_disambiguation_active = host_escape_disambiguation_active; + terminal_guard.buffered_host_input = buffered_host_input; Ok(terminal_guard) } @@ -122,6 +133,8 @@ pub(super) fn should_enable_host_color_scheme_reports(enable_client_protocols: b /// Guard that restores the terminal when dropped. pub(super) struct TerminalGuard { + host_escape_disambiguation_active: bool, + buffered_host_input: Vec<u8>, reset_keyboard_enhancements: bool, reset_modify_other_keys: bool, reset_host_color_scheme_reports: bool, @@ -131,6 +144,189 @@ pub(super) struct TerminalGuard { restore_windows_input_mode: Arc<WindowsInputModeRestore>, } +#[cfg(not(windows))] +const HOST_KEYBOARD_QUERY_TIMEOUT: Duration = Duration::from_millis(250); +#[cfg(not(windows))] +const MAX_BUFFERED_HOST_INPUT: usize = 64 * 1024; + +#[cfg(not(windows))] +#[derive(Default)] +struct HostKeyboardProbeResponses { + flags: Option<u16>, + primary_device_attributes: bool, +} + +#[cfg(not(windows))] +fn query_host_escape_disambiguation() -> (bool, Vec<u8>) { + const QUERY: &[u8] = b"\x1b[?u\x1b[c"; + + let mut buffered_input = Vec::new(); + if let Err(err) = io::stdout() + .write_all(QUERY) + .and_then(|()| io::stdout().flush()) + { + tracing::debug!(%err, "host keyboard enhancement query unavailable"); + return (false, buffered_input); + } + + // Bypass StdinLock's shared buffer so poll and read observe the same bytes. + let stdin = io::stdin(); + let stdin_fd = stdin.as_raw_fd(); + let deadline = Instant::now() + HOST_KEYBOARD_QUERY_TIMEOUT; + let mut responses = HostKeyboardProbeResponses::default(); + while !responses.primary_device_attributes && buffered_input.len() < MAX_BUFFERED_HOST_INPUT { + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + break; + }; + let timeout_ms = remaining.as_millis().clamp(1, i32::MAX as u128) as i32; + match crate::platform::poll_fd_readable(stdin_fd, timeout_ms) { + Ok(true) => {} + Ok(false) => break, + Err(err) if err.kind() == io::ErrorKind::Interrupted => continue, + Err(err) => { + tracing::debug!(%err, "host keyboard enhancement query read unavailable"); + break; + } + } + + let mut scratch = [0u8; 4096]; + let capacity = MAX_BUFFERED_HOST_INPUT - buffered_input.len(); + let read_limit = capacity.min(scratch.len()); + match crate::platform::read_fd(stdin_fd, &mut scratch[..read_limit]) { + Ok(0) => break, + Ok(read) => { + buffered_input.extend_from_slice(&scratch[..read]); + consume_host_keyboard_probe_responses(&mut buffered_input, &mut responses); + } + Err(err) if err.kind() == io::ErrorKind::Interrupted => continue, + Err(err) => { + tracing::debug!(%err, "host keyboard enhancement query read failed"); + break; + } + } + } + + ( + host_escape_disambiguation_confirmed(&responses), + buffered_input, + ) +} + +#[cfg(not(windows))] +fn host_escape_disambiguation_confirmed(responses: &HostKeyboardProbeResponses) -> bool { + responses.primary_device_attributes + && responses + .flags + .is_some_and(|flags| flags & 0b0000_0001 != 0) +} + +#[cfg(not(windows))] +fn consume_host_keyboard_probe_responses( + buffered_input: &mut Vec<u8>, + responses: &mut HostKeyboardProbeResponses, +) { + const PASTE_START: &[u8] = b"\x1b[200~"; + const PASTE_END: &[u8] = b"\x1b[201~"; + + let mut offset = 0; + while offset < buffered_input.len() { + if buffered_input[offset..].starts_with(PASTE_START) { + let payload_start = offset + PASTE_START.len(); + let Some(relative_end) = buffered_input[payload_start..] + .windows(PASTE_END.len()) + .position(|bytes| bytes == PASTE_END) + else { + break; + }; + offset = payload_start + relative_end + PASTE_END.len(); + continue; + } + if let Some(control_string_end) = host_control_string_end(&buffered_input[offset..]) { + let Some(control_string_end) = control_string_end else { + break; + }; + offset += control_string_end; + continue; + } + if !buffered_input[offset..].starts_with(b"\x1b[?") { + offset += 1; + continue; + } + + let start = offset; + let mut end = start + 3; + while end < buffered_input.len() + && (buffered_input[end].is_ascii_digit() || buffered_input[end] == b';') + { + end += 1; + } + if end == buffered_input.len() { + break; + } + let body = &buffered_input[start + 3..end]; + let recognized = match buffered_input[end] { + b'u' if !body.is_empty() && body.iter().all(u8::is_ascii_digit) => { + std::str::from_utf8(body) + .ok() + .and_then(|flags| flags.parse().ok()) + .map(|flags| { + if !responses.primary_device_attributes { + responses.flags = Some(flags); + } + }) + .is_some() + } + b'c' if !body.is_empty() + && body + .iter() + .all(|byte| byte.is_ascii_digit() || *byte == b';') => + { + responses.primary_device_attributes = true; + true + } + _ => false, + }; + if recognized { + buffered_input.drain(start..=end); + } else { + offset += 1; + } + } +} + +#[cfg(not(windows))] +fn host_control_string_end(bytes: &[u8]) -> Option<Option<usize>> { + if bytes.first() != Some(&0x1b) { + return None; + } + + let allow_bel = if bytes.starts_with(b"\x1b]") { + true + } else if bytes + .get(1) + .is_some_and(|byte| matches!(*byte, b'P' | b'_' | b'^' | b'X')) + { + false + } else { + return None; + }; + + for offset in 2..bytes.len() { + if allow_bel && bytes[offset] == 0x07 { + return Some(Some(offset + 1)); + } + if bytes[offset..].starts_with(b"\x1b\\") { + return Some(Some(offset + 2)); + } + } + Some(None) +} + +#[cfg(windows)] +fn query_host_escape_disambiguation() -> (bool, Vec<u8>) { + (false, Vec::new()) +} + pub(super) fn write_host_color_scheme_report_mode( writer: &mut impl io::Write, enabled: bool, @@ -521,6 +717,14 @@ fn disable_windows_win32_input_mode(writer: &mut impl std::io::Write) -> io::Res } impl TerminalGuard { + pub(super) fn host_escape_disambiguation_active(&self) -> bool { + self.host_escape_disambiguation_active + } + + pub(super) fn take_buffered_host_input(&mut self) -> Vec<u8> { + std::mem::take(&mut self.buffered_host_input) + } + #[cfg(windows)] pub(super) fn recover_windows_virtual_terminal_input(&self) -> io::Result<()> { let active = enable_windows_virtual_terminal_input( @@ -645,6 +849,90 @@ mod tests { } } + #[cfg(not(windows))] + #[test] + fn host_keyboard_probe_consumes_fragmented_responses_and_preserves_input() { + let stream = b"before\x1b[?7u-middle-\x1b[?1;2cafter"; + + for split in 1..stream.len() { + let mut buffered = Vec::new(); + let mut responses = HostKeyboardProbeResponses::default(); + buffered.extend_from_slice(&stream[..split]); + consume_host_keyboard_probe_responses(&mut buffered, &mut responses); + buffered.extend_from_slice(&stream[split..]); + consume_host_keyboard_probe_responses(&mut buffered, &mut responses); + + assert_eq!(responses.flags, Some(7), "split {split}"); + assert!(responses.primary_device_attributes, "split {split}"); + assert_eq!(buffered, b"before-middle-after", "split {split}"); + } + } + + #[cfg(not(windows))] + #[test] + fn host_keyboard_probe_preserves_typed_input_before_responses() { + let mut buffered = b"aPtyped\x1b[?7u\x1b[?1;2c".to_vec(); + let mut responses = HostKeyboardProbeResponses::default(); + + consume_host_keyboard_probe_responses(&mut buffered, &mut responses); + + assert!(host_escape_disambiguation_confirmed(&responses)); + assert_eq!(buffered, b"aPtyped"); + } + + #[cfg(not(windows))] + #[test] + fn host_keyboard_probe_requires_disambiguation_bit_and_device_attributes() { + for (flags, expected) in [(0, false), (2, false), (7, true)] { + let mut buffered = format!("\x1b[?{flags}u\x1b[?1;2c").into_bytes(); + let mut responses = HostKeyboardProbeResponses::default(); + + consume_host_keyboard_probe_responses(&mut buffered, &mut responses); + + assert_eq!(host_escape_disambiguation_confirmed(&responses), expected); + assert!(buffered.is_empty()); + } + } + + #[cfg(not(windows))] + #[test] + fn host_keyboard_probe_requires_flags_before_device_attributes() { + let mut buffered = b"\x1b[?1;2c\x1b[?7uinput".to_vec(); + let mut responses = HostKeyboardProbeResponses::default(); + + consume_host_keyboard_probe_responses(&mut buffered, &mut responses); + + assert_eq!(responses.flags, None); + assert!(responses.primary_device_attributes); + assert_eq!(buffered, b"input"); + } + + #[cfg(not(windows))] + #[test] + fn host_keyboard_probe_preserves_response_shaped_payloads() { + let opaque = b"\x1b[200~paste \x1b[?1u \x1b[?1;2c\x1b[201~-\x1bPdata \x1b[?7u\x1b\\"; + let mut buffered = [opaque.as_slice(), b"\x1b[?7u\x1b[?1;2c"].concat(); + let mut responses = HostKeyboardProbeResponses::default(); + + consume_host_keyboard_probe_responses(&mut buffered, &mut responses); + + assert!(host_escape_disambiguation_confirmed(&responses)); + assert_eq!(buffered, opaque); + } + + #[cfg(not(windows))] + #[test] + fn host_keyboard_probe_preserves_malformed_responses() { + let mut buffered = b"a\x1b[?7;1ub\x1b[?65536uc".to_vec(); + let mut responses = HostKeyboardProbeResponses::default(); + + consume_host_keyboard_probe_responses(&mut buffered, &mut responses); + + assert_eq!(responses.flags, None); + assert!(!responses.primary_device_attributes); + assert_eq!(buffered, b"a\x1b[?7;1ub\x1b[?65536uc"); + } + #[test] fn windows_native_mouse_capture_reasserts_final_encoding_after_native_enable() { let mut output = SharedOutput::default(); |
