aboutsummaryrefslogtreecommitdiff
path: root/src/client/terminal_setup.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/client/terminal_setup.rs')
-rw-r--r--src/client/terminal_setup.rs296
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();