diff options
Diffstat (limited to 'src/client/terminal_setup.rs')
| -rw-r--r-- | src/client/terminal_setup.rs | 296 |
1 files changed, 292 insertions, 4 deletions
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(); |
