aboutsummaryrefslogtreecommitdiff
path: root/src/client
diff options
context:
space:
mode:
authorakbash <akbash@herdr.dev>2026-09-10 14:02:33 +0300
committerGitHub <noreply@github.com>2026-09-10 14:02:33 +0300
commit7265a2b101c9541a4ee5c800485b074774be26a7 (patch)
tree0b8da341a84d37e5fa8543e4f1206efc6bdd88c7 /src/client
parenta52060315e1c2b694ffa147f869cd856306c46e0 (diff)
fix(windows): preserve legacy mouse reports over ssh (#3742)
* fix(windows): preserve legacy mouse reports over ssh refs #3735 * fix(windows): preserve input after missing mouse release refs #3735 --------- Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com> Co-authored-by: JJ Liebig <jonathan.liebig@gmail.com>
Diffstat (limited to 'src/client')
-rw-r--r--src/client/input/windows_vti.rs520
1 files changed, 506 insertions, 14 deletions
diff --git a/src/client/input/windows_vti.rs b/src/client/input/windows_vti.rs
index fc81cd81..d89a02c7 100644
--- a/src/client/input/windows_vti.rs
+++ b/src/client/input/windows_vti.rs
@@ -283,6 +283,15 @@ struct WindowsInputPump {
framer: crate::raw_input::RawInputFramer,
paste_from_win32_key_records: bool,
pending_physical_escape: Option<(crate::protocol::ClientInputEvent, bool)>,
+ default_mouse_candidate: DefaultMouseCandidate,
+ consumed_default_mouse_keys: Vec<WindowsKeyRecord>,
+}
+
+#[derive(Default)]
+struct DefaultMouseCandidate {
+ active: bool,
+ events: Vec<crate::protocol::ClientInputEvent>,
+ unreleased_keys: Vec<WindowsKeyRecord>,
}
impl Default for WindowsInputPump {
@@ -291,6 +300,8 @@ impl Default for WindowsInputPump {
framer: crate::raw_input::RawInputFramer::for_host_input(),
paste_from_win32_key_records: false,
pending_physical_escape: None,
+ default_mouse_candidate: DefaultMouseCandidate::default(),
+ consumed_default_mouse_keys: Vec::new(),
}
}
}
@@ -303,11 +314,13 @@ enum PlatformInputItem {
paste_bytes: Vec<u8>,
raw_bytes: Vec<u8>,
win32_paste_bytes: Vec<u8>,
+ nested_record: WindowsKeyRecord,
},
PasteAwareKey {
bytes: Vec<u8>,
win32_paste_bytes: Vec<u8>,
events: Vec<crate::protocol::ClientInputEvent>,
+ nested_record: Option<WindowsKeyRecord>,
},
}
@@ -373,29 +386,42 @@ impl WindowsInputPump {
paste_bytes,
raw_bytes,
win32_paste_bytes,
+ nested_record,
} => {
- let pending_paste = self.framer.has_pending_bracketed_paste();
- let decode_win32_record = self.paste_from_win32_key_records || !pending_paste;
- let raw_events = if decode_win32_record {
- if pending_paste {
- self.framer.push(&win32_paste_bytes)
+ if self.consume_default_mouse_key_event(nested_record) {
+ Vec::new()
+ } else {
+ let pending_paste = self.framer.has_pending_bracketed_paste();
+ if !pending_paste && self.framer.has_pending_default_mouse_sequence() {
+ self.stage_default_mouse_record(nested_record, &raw_bytes);
+ }
+ let decode_win32_record = self.paste_from_win32_key_records || !pending_paste;
+ let raw_events = if decode_win32_record {
+ if pending_paste {
+ self.framer.push(&win32_paste_bytes)
+ } else {
+ self.framer.push(&raw_bytes)
+ }
} else {
- self.framer.push(&raw_bytes)
+ self.framer.push(&paste_bytes)
+ };
+ if decode_win32_record && self.framer.has_pending_bracketed_paste() {
+ self.paste_from_win32_key_records = true;
}
- } else {
- self.framer.push(&paste_bytes)
- };
- if decode_win32_record && self.framer.has_pending_bracketed_paste() {
- self.paste_from_win32_key_records = true;
+ self.process_raw_events(raw_events)
}
- self.process_raw_events(raw_events)
}
PlatformInputItem::PasteAwareKey {
bytes,
win32_paste_bytes,
events,
+ nested_record,
} => {
- if self.framer.has_pending_bracketed_paste() {
+ let suppress_consumed_key = nested_record
+ .is_some_and(|record| self.consume_default_mouse_key_event(record));
+ if suppress_consumed_key {
+ Vec::new()
+ } else if self.framer.has_pending_bracketed_paste() {
let bytes = if self.paste_from_win32_key_records {
&win32_paste_bytes
} else {
@@ -403,6 +429,12 @@ impl WindowsInputPump {
};
let raw_events = self.framer.push(bytes);
self.process_raw_events(raw_events)
+ } else if let Some(record) =
+ nested_record.filter(|_| self.framer.has_pending_default_mouse_sequence())
+ {
+ self.stage_default_mouse_key(record, &win32_paste_bytes, events);
+ let raw_events = self.framer.push(&win32_paste_bytes);
+ self.process_raw_events(raw_events)
} else {
let raw_events = self.framer.flush_timeout();
let mut output = self.process_raw_events(raw_events);
@@ -439,7 +471,59 @@ impl WindowsInputPump {
self.paste_from_win32_key_records = false;
}
}
- Self::raw_events_to_client_events(events)
+
+ let accepted_default_mouse = self.default_mouse_candidate.active
+ && events
+ .iter()
+ .any(|event| matches!(event, crate::raw_input::RawInputEvent::Mouse(_)));
+ let mut output = Self::raw_events_to_client_events(events);
+ if self.default_mouse_candidate.active && !self.framer.has_pending_default_mouse_sequence()
+ {
+ self.default_mouse_candidate.active = false;
+ if accepted_default_mouse {
+ self.default_mouse_candidate.events.clear();
+ self.consumed_default_mouse_keys
+ .append(&mut self.default_mouse_candidate.unreleased_keys);
+ } else {
+ let mut staged = std::mem::take(&mut self.default_mouse_candidate.events);
+ self.default_mouse_candidate.unreleased_keys.clear();
+ staged.append(&mut output);
+ output = staged;
+ }
+ }
+ output
+ }
+
+ fn stage_default_mouse_key(
+ &mut self,
+ record: WindowsKeyRecord,
+ raw_bytes: &[u8],
+ events: Vec<crate::protocol::ClientInputEvent>,
+ ) {
+ self.default_mouse_candidate.events.extend(events);
+ self.stage_default_mouse_record(record, raw_bytes);
+ }
+
+ fn stage_default_mouse_record(&mut self, record: WindowsKeyRecord, raw_bytes: &[u8]) {
+ self.default_mouse_candidate.active = true;
+ if record.key_down
+ && !raw_bytes.is_empty()
+ && (record.virtual_key_code != 0 || record.virtual_scan_code != 0)
+ {
+ if matching_key_index(&self.default_mouse_candidate.unreleased_keys, record).is_none() {
+ self.default_mouse_candidate.unreleased_keys.push(record);
+ }
+ } else if !record.key_down {
+ remove_matching_key(&mut self.default_mouse_candidate.unreleased_keys, record);
+ }
+ }
+
+ fn consume_default_mouse_key_event(&mut self, record: WindowsKeyRecord) -> bool {
+ let Some(index) = matching_key_index(&self.consumed_default_mouse_keys, record) else {
+ return false;
+ };
+ self.consumed_default_mouse_keys.remove(index);
+ !record.key_down
}
fn raw_events_to_client_events(
@@ -452,6 +536,21 @@ impl WindowsInputPump {
}
}
+fn matching_key_index(keys: &[WindowsKeyRecord], record: WindowsKeyRecord) -> Option<usize> {
+ keys.iter().position(|candidate| {
+ candidate.virtual_key_code == record.virtual_key_code
+ && candidate.virtual_scan_code == record.virtual_scan_code
+ })
+}
+
+fn remove_matching_key(keys: &mut Vec<WindowsKeyRecord>, record: WindowsKeyRecord) -> bool {
+ let Some(index) = matching_key_index(keys, record) else {
+ return false;
+ };
+ keys.remove(index);
+ true
+}
+
impl PlatformInputItem {
fn raw_bytes(&self) -> Option<&[u8]> {
match self {
@@ -591,6 +690,7 @@ impl WindowsInputMapper {
win32_paste_bytes: bytes.clone(),
bytes,
events: vec![event],
+ nested_record: None,
});
} else if let Some(bytes) = self.synthetic_utf16_unit_to_bytes(key.unicode) {
items.extend(self.translate_win32_input_mode_bytes(&bytes));
@@ -609,6 +709,7 @@ impl WindowsInputMapper {
win32_paste_bytes: bytes.clone(),
bytes,
events,
+ nested_record: None,
}]
} else {
events
@@ -655,6 +756,7 @@ impl WindowsInputMapper {
paste_bytes: bytes,
raw_bytes,
win32_paste_bytes,
+ nested_record: record,
});
} else {
let oem_char = resolve_ctrl_oem_char(record);
@@ -662,6 +764,7 @@ impl WindowsInputMapper {
bytes,
win32_paste_bytes,
events: self.translate_semantic_key_events(record, oem_char),
+ nested_record: Some(record),
})
}
}
@@ -2916,6 +3019,395 @@ mod tests {
}
#[test]
+ fn vti_win32_input_mode_encoded_default_mouse_sequence() {
+ // Exact inner Win32-input-mode records captured from Ghostty through
+ // Windows OpenSSH while moving the mouse, reported in issue #3735.
+ let records = concat!(
+ "\x1b[0;0;27;1;0;1_",
+ "\x1b[0;0;91;1;0;1_",
+ "\x1b[0;0;77;1;0;1_",
+ "\x1b[16;42;0;1;16;1_",
+ "\x1b[67;46;67;1;16;1_",
+ "\x1b[67;46;67;0;16;1_",
+ "\x1b[16;42;0;0;0;1_",
+ "\x1b[16;42;0;1;16;1_",
+ "\x1b[75;37;75;1;16;1_",
+ "\x1b[75;37;75;0;16;1_",
+ "\x1b[16;42;0;0;0;1_",
+ "\x1b[16;42;0;1;16;1_",
+ "\x1b[49;2;33;1;16;1_",
+ "\x1b[49;2;33;0;16;1_",
+ "\x1b[16;42;0;0;0;1_",
+ )
+ .chars()
+ .map(key_char);
+
+ let mut translator = WindowsInputTranslator::default();
+ let events = records
+ .flat_map(|record| translator.translate(record))
+ .collect::<Vec<_>>();
+ assert_eq!(
+ events,
+ vec![crate::protocol::ClientInputEvent::Mouse {
+ kind: crate::protocol::ClientMouseKind::Moved,
+ column: 42,
+ row: 0,
+ modifiers: 0,
+ }]
+ );
+ assert!(translator.idle().is_empty());
+ assert_eq!(
+ semantic_only(translator.translate(key_vk_with_unicode(0x58, 'x', 0))),
+ vec![crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Char('x'),
+ modifiers: 0,
+ kind: crate::protocol::ClientKeyKind::Press,
+ repeat_count: 1,
+ generated_text: None,
+ source: crate::protocol::ClientKeySource::Synthesized,
+ }]
+ );
+ }
+
+ #[test]
+ fn vti_default_mouse_prefix_does_not_capture_native_shifted_key() {
+ let native_press = key_vk_with_scan_unicode(0x43, 0x2e, 'C', 0x0010);
+ let native_release = key_vk_up_with_scan_unicode(0x43, 0x2e, 'C', 0x0010);
+ let records = win32_input_mode_encoded_raw_bytes(b"\x1b[M")
+ .into_iter()
+ .chain([native_press, native_release]);
+
+ assert_eq!(
+ translate(records),
+ vec![
+ crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Char('C'),
+ modifiers: crossterm::event::KeyModifiers::SHIFT.bits(),
+ kind: crate::protocol::ClientKeyKind::Press,
+ repeat_count: 1,
+ generated_text: Some("C".into()),
+ source: crate::protocol::ClientKeySource::Synthesized,
+ },
+ crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Char('C'),
+ modifiers: crossterm::event::KeyModifiers::SHIFT.bits(),
+ kind: crate::protocol::ClientKeyKind::Release,
+ repeat_count: 1,
+ generated_text: None,
+ source: crate::protocol::ClientKeySource::Synthesized,
+ },
+ ]
+ );
+ }
+
+ #[test]
+ fn vti_incomplete_default_mouse_restores_nested_key_on_idle() {
+ let shift_down = WindowsKeyRecord {
+ key_down: true,
+ repeat_count: 1,
+ virtual_key_code: 0x10,
+ virtual_scan_code: 0x2a,
+ unicode: 0,
+ control_key_state: 0x0010,
+ };
+ let c_down = WindowsKeyRecord {
+ key_down: true,
+ repeat_count: 1,
+ virtual_key_code: 0x43,
+ virtual_scan_code: 0x2e,
+ unicode: 'C' as u16,
+ control_key_state: 0x0010,
+ };
+ let mut records = win32_input_mode_encoded_raw_bytes(b"\x1b[M");
+ for record in [
+ shift_down,
+ c_down,
+ WindowsKeyRecord {
+ key_down: false,
+ ..c_down
+ },
+ ] {
+ records.extend(win32_input_mode_encoded_record(record));
+ }
+
+ let mut translator = WindowsInputTranslator::default();
+ assert!(records
+ .into_iter()
+ .flat_map(|record| translator.translate(record))
+ .collect::<Vec<_>>()
+ .is_empty());
+ assert_eq!(
+ semantic_only(translator.idle()),
+ vec![
+ crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Char('C'),
+ modifiers: crossterm::event::KeyModifiers::SHIFT.bits(),
+ kind: crate::protocol::ClientKeyKind::Press,
+ repeat_count: 1,
+ generated_text: Some("C".into()),
+ source: crate::protocol::ClientKeySource::Synthesized,
+ },
+ crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Char('C'),
+ modifiers: crossterm::event::KeyModifiers::SHIFT.bits(),
+ kind: crate::protocol::ClientKeyKind::Release,
+ repeat_count: 1,
+ generated_text: None,
+ source: crate::protocol::ClientKeySource::Synthesized,
+ },
+ ]
+ );
+ }
+
+ #[test]
+ fn vti_invalid_default_mouse_restores_nested_key() {
+ let space_down = WindowsKeyRecord {
+ key_down: true,
+ repeat_count: 1,
+ virtual_key_code: 0x20,
+ virtual_scan_code: 0x39,
+ unicode: ' ' as u16,
+ control_key_state: 0,
+ };
+ let mut records = win32_input_mode_encoded_raw_bytes(b"\x1b[MCK");
+ for record in [
+ space_down,
+ WindowsKeyRecord {
+ key_down: false,
+ ..space_down
+ },
+ ] {
+ records.extend(win32_input_mode_encoded_record(record));
+ }
+
+ assert_eq!(
+ translate(records),
+ vec![
+ crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Char(' '),
+ modifiers: 0,
+ kind: crate::protocol::ClientKeyKind::Press,
+ repeat_count: 1,
+ generated_text: None,
+ source: crate::protocol::ClientKeySource::Synthesized,
+ },
+ crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Char(' '),
+ modifiers: 0,
+ kind: crate::protocol::ClientKeyKind::Release,
+ repeat_count: 1,
+ generated_text: None,
+ source: crate::protocol::ClientKeySource::Synthesized,
+ },
+ ]
+ );
+ }
+
+ #[test]
+ fn vti_all_zero_default_mouse_records_do_not_retain_key_identity() {
+ let records = win32_input_mode_encoded_raw_bytes(b"\x1b[MCK1\x1b[MCL1");
+
+ assert_eq!(
+ translate(records),
+ vec![
+ crate::protocol::ClientInputEvent::Mouse {
+ kind: crate::protocol::ClientMouseKind::Moved,
+ column: 42,
+ row: 16,
+ modifiers: 0,
+ },
+ crate::protocol::ClientInputEvent::Mouse {
+ kind: crate::protocol::ClientMouseKind::Moved,
+ column: 43,
+ row: 16,
+ modifiers: 0,
+ },
+ ]
+ );
+ }
+
+ #[test]
+ fn vti_scan_zero_default_mouse_payload_suppresses_nested_release() {
+ let payload_down = WindowsKeyRecord {
+ key_down: true,
+ repeat_count: 1,
+ virtual_key_code: 0x31,
+ virtual_scan_code: 0,
+ unicode: '1' as u16,
+ control_key_state: 0,
+ };
+ let mut records = win32_input_mode_encoded_raw_bytes(b"\x1b[MCK");
+ for record in [
+ payload_down,
+ WindowsKeyRecord {
+ key_down: false,
+ ..payload_down
+ },
+ ] {
+ records.extend(win32_input_mode_encoded_record(record));
+ }
+
+ assert_eq!(
+ translate(records),
+ vec![crate::protocol::ClientInputEvent::Mouse {
+ kind: crate::protocol::ClientMouseKind::Moved,
+ column: 42,
+ row: 16,
+ modifiers: 0,
+ }]
+ );
+ }
+
+ #[test]
+ fn vti_missing_mouse_payload_release_does_not_consume_next_press() {
+ let payload_down = WindowsKeyRecord {
+ key_down: true,
+ repeat_count: 1,
+ virtual_key_code: 0x31,
+ virtual_scan_code: 0x02,
+ unicode: '!' as u16,
+ control_key_state: 0x0010,
+ };
+ let mut records = win32_input_mode_encoded_raw_bytes(b"\x1b[MCK");
+ for record in [
+ payload_down,
+ payload_down,
+ WindowsKeyRecord {
+ key_down: false,
+ ..payload_down
+ },
+ ] {
+ records.extend(win32_input_mode_encoded_record(record));
+ }
+
+ assert_eq!(
+ translate(records),
+ vec![
+ crate::protocol::ClientInputEvent::Mouse {
+ kind: crate::protocol::ClientMouseKind::Moved,
+ column: 42,
+ row: 0,
+ modifiers: 0,
+ },
+ crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Char('!'),
+ modifiers: crossterm::event::KeyModifiers::SHIFT.bits(),
+ kind: crate::protocol::ClientKeyKind::Press,
+ repeat_count: 1,
+ generated_text: Some("!".into()),
+ source: crate::protocol::ClientKeySource::Synthesized,
+ },
+ crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Char('!'),
+ modifiers: crossterm::event::KeyModifiers::SHIFT.bits(),
+ kind: crate::protocol::ClientKeyKind::Release,
+ repeat_count: 1,
+ generated_text: None,
+ source: crate::protocol::ClientKeySource::Synthesized,
+ },
+ ]
+ );
+ }
+
+ #[test]
+ fn vti_repeated_default_mouse_payload_tracks_one_release() {
+ let payload_down = WindowsKeyRecord {
+ key_down: true,
+ repeat_count: 1,
+ virtual_key_code: 0x43,
+ virtual_scan_code: 0x2e,
+ unicode: 'C' as u16,
+ control_key_state: 0x0010,
+ };
+ let payload_up = WindowsKeyRecord {
+ key_down: false,
+ ..payload_down
+ };
+ let mut records = win32_input_mode_encoded_raw_bytes(b"\x1b[M");
+ for record in [
+ payload_down,
+ payload_down,
+ payload_down,
+ payload_up,
+ payload_down,
+ payload_up,
+ ] {
+ records.extend(win32_input_mode_encoded_record(record));
+ }
+
+ assert_eq!(
+ translate(records),
+ vec![
+ crate::protocol::ClientInputEvent::Mouse {
+ kind: crate::protocol::ClientMouseKind::Moved,
+ column: 34,
+ row: 34,
+ modifiers: 0,
+ },
+ crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Char('C'),
+ modifiers: crossterm::event::KeyModifiers::SHIFT.bits(),
+ kind: crate::protocol::ClientKeyKind::Press,
+ repeat_count: 1,
+ generated_text: Some("C".into()),
+ source: crate::protocol::ClientKeySource::Synthesized,
+ },
+ crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Char('C'),
+ modifiers: crossterm::event::KeyModifiers::SHIFT.bits(),
+ kind: crate::protocol::ClientKeyKind::Release,
+ repeat_count: 1,
+ generated_text: None,
+ source: crate::protocol::ClientKeySource::Synthesized,
+ },
+ ]
+ );
+ }
+
+ #[test]
+ fn vti_default_mouse_release_survives_modifier_interleaving() {
+ let payload_down = WindowsKeyRecord {
+ key_down: true,
+ repeat_count: 1,
+ virtual_key_code: 0x31,
+ virtual_scan_code: 0x02,
+ unicode: '!' as u16,
+ control_key_state: 0x0010,
+ };
+ let shift_up = WindowsKeyRecord {
+ key_down: false,
+ repeat_count: 1,
+ virtual_key_code: 0x10,
+ virtual_scan_code: 0x2a,
+ unicode: 0,
+ control_key_state: 0,
+ };
+ let mut records = win32_input_mode_encoded_raw_bytes(b"\x1b[MCK");
+ for record in [
+ payload_down,
+ shift_up,
+ WindowsKeyRecord {
+ key_down: false,
+ unicode: '1' as u16,
+ control_key_state: 0,
+ ..payload_down
+ },
+ ] {
+ records.extend(win32_input_mode_encoded_record(record));
+ }
+
+ assert_eq!(
+ translate(records),
+ vec![crate::protocol::ClientInputEvent::Mouse {
+ kind: crate::protocol::ClientMouseKind::Moved,
+ column: 42,
+ row: 0,
+ modifiers: 0,
+ }]
+ );
+ }
+
+ #[test]
fn vti_escape_arrow_sequence_becomes_arrow_key() {
let records = "\x1b[A".chars().map(key_char);
assert_eq!(