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