1mod remote_pane;
2
3pub use remote_pane::RemotePane;
4
5use std::io::{self, IsTerminal, Write, stdout};
6use std::sync::atomic::{AtomicBool, AtomicU16, Ordering};
7use std::sync::{Arc, Mutex};
8use std::time::Duration;
9
10use crossterm::QueueableCommand;
11use crossterm::cursor::{Hide, Show};
12use crossterm::event::{DisableBracketedPaste, EnableBracketedPaste};
13use crossterm::terminal::{
14 EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
15};
16use muxio_rpc_service_endpoint::RpcServiceEndpointInterface;
17use muxio_tokio_mpsc_adapter::ChannelCallerExt;
18use muxio_tokio_rpc_ipc_client::{RpcCallPrebuffered, RpcIpcClient, RpcServiceCallerInterface};
19use portable_pty::PtySize;
20use term_clipboard::{Clipboard, Osc52Extractor};
21use term_session_muxio_service_definitions::{
22 Attach, AttachRequest, OnPtyResized, RpcMethodPrebuffered, STREAM_INPUT_METHOD_ID,
23 SUBSCRIBE_OUTPUT_METHOD_ID, Spawn, SpawnRequest, SpawnResponse, path_wire,
24};
25#[cfg(unix)]
26use term_sys_io::redirect_fd_to_tracing;
27use term_wm_events::{Event, KeyKind, KeyModifiers, MouseEventKind};
28use term_wm_pty_engine::Pane;
29use term_wm_pty_engine::input_encoding::{key_to_bytes, mouse_event_to_bytes};
30use term_wm_pty_engine::signal::install_sigint_handler;
31use term_wm_vt100::{MouseProtocolEncoding, MouseProtocolMode, Parser, Screen};
32
33#[cfg(windows)]
37fn disable_quick_edit() {
38 use windows_sys::Win32::System::Console::{
39 ENABLE_EXTENDED_FLAGS, ENABLE_QUICK_EDIT_MODE, GetConsoleMode, GetStdHandle,
40 STD_INPUT_HANDLE, SetConsoleMode,
41 };
42
43 unsafe {
44 let handle = GetStdHandle(STD_INPUT_HANDLE);
45 let mut mode: u32 = 0;
46 if GetConsoleMode(handle, &mut mode) != 0 {
47 let _ = SetConsoleMode(
48 handle,
49 (mode & !ENABLE_QUICK_EDIT_MODE) | ENABLE_EXTENDED_FLAGS,
50 );
51 }
52 }
53}
54
55#[cfg(target_os = "windows")]
58const INITIAL_WAIT_ITERS: usize = 60;
59#[cfg(not(target_os = "windows"))]
60const INITIAL_WAIT_ITERS: usize = 20;
61
62const PTY_OUTPUT_CHANNEL_CAPACITY: usize = 256;
64const CLIPBOARD_CHANNEL_CAPACITY: usize = 64;
66const INPUT_CHANNEL_CAPACITY: usize = 64;
68
69const PREV_TAIL_LEN: usize = 8;
73
74const INITIAL_WAIT_SLEEP_MS: u64 = 50;
76
77const INPUT_POLL_MS: u64 = 50;
80
81const BACKPRESSURE_SLEEP_MS: u64 = 1;
84
85const BRACKETED_PASTE_OVERHEAD: usize = 12;
88
89const RENDER_BUF_CELL_MULTIPLIER: usize = 3;
91
92const MIN_TERM_COLS: u16 = 2;
96const MIN_TERM_ROWS: u16 = 2;
97
98const FALLBACK_TERM_COLS: u16 = 80;
102const FALLBACK_TERM_ROWS: u16 = 24;
103
104pub fn init_terminal<W: Write>(mut writer: W) -> io::Result<TerminalGuard<W>> {
112 if std::io::stdin().is_terminal() {
113 enable_raw_mode()?;
114 }
115 writer.queue(EnterAlternateScreen)?;
116 writer.queue(Hide)?;
117 writer.queue(EnableBracketedPaste)?;
118 writer.queue(crossterm::event::EnableMouseCapture)?;
119 #[cfg(windows)]
127 term_wm_crossterm_adapter::set_mouse_capture_with(&mut writer, true)?;
128 writer.flush()?;
129 Ok(TerminalGuard {
130 writer: Some(writer),
131 })
132}
133
134pub struct TerminalGuard<W: Write = std::io::Stdout> {
138 writer: Option<W>,
139}
140
141impl<W: Write> Drop for TerminalGuard<W> {
142 fn drop(&mut self) {
143 if let Some(ref mut writer) = self.writer {
144 let _ = writer.queue(crossterm::event::DisableMouseCapture);
145 #[cfg(windows)]
148 let _ = term_wm_crossterm_adapter::set_mouse_capture_with(writer, false);
149 let _ = writer.queue(DisableBracketedPaste);
150 let _ = writer.queue(Show);
151 let _ = writer.queue(LeaveAlternateScreen);
152 if std::io::stdin().is_terminal() {
153 let _ = disable_raw_mode();
154 }
155 let _ = writer.flush();
156 }
157 }
158}
159
160fn convert_crossterm_event(evt: crossterm::event::Event) -> Option<Event> {
162 term_wm_crossterm_adapter::try_translate_event(evt)
163}
164
165fn is_coalescable_mouse(
170 a_kind: &MouseEventKind,
171 a_mod: &KeyModifiers,
172 b_kind: &MouseEventKind,
173 b_mod: &KeyModifiers,
174) -> bool {
175 if a_mod != b_mod {
176 return false;
177 }
178 match (a_kind, b_kind) {
179 (MouseEventKind::Moved, MouseEventKind::Moved) => true,
180 (MouseEventKind::Drag(btn1), MouseEventKind::Drag(btn2)) => btn1 == btn2,
181 _ => false,
182 }
183}
184
185fn client_user() -> String {
190 #[cfg(unix)]
191 {
192 unsafe {
193 let pw = libc::getpwuid(libc::getuid());
194 if !pw.is_null() {
195 let name = std::ffi::CStr::from_ptr((*pw).pw_name);
196 if let Ok(s) = name.to_str()
197 && !s.is_empty()
198 {
199 return s.to_string();
200 }
201 }
202 }
203 std::env::var("USER").unwrap_or_default()
204 }
205 #[cfg(windows)]
206 {
207 if let Ok(u) = std::env::var("USERNAME")
208 && !u.is_empty()
209 {
210 return u;
211 }
212 windows_username().unwrap_or_default()
213 }
214 #[cfg(not(any(unix, windows)))]
215 {
216 String::new()
217 }
218}
219
220#[cfg(windows)]
223fn windows_username() -> Option<String> {
224 use std::os::windows::ffi::OsStringExt;
225 use windows_sys::Win32::System::WindowsProgramming::GetUserNameW;
226 let mut buf = [0u16; 256];
227 let mut len = buf.len() as u32;
228 let ok = unsafe { GetUserNameW(buf.as_mut_ptr(), &mut len) };
229 if ok == 0 {
230 return None;
231 }
232 let s = std::ffi::OsString::from_wide(&buf[..len as usize])
233 .to_string_lossy()
234 .into_owned();
235 if s.is_empty() { None } else { Some(s) }
236}
237
238fn client_version() -> String {
241 env!("CARGO_PKG_VERSION").to_string()
242}
243
244fn client_ssh_ip() -> Option<String> {
248 for var in ["SSH_CLIENT", "SSH_CONNECTION"] {
249 if let Ok(v) = std::env::var(var) {
250 let ip = v.split_whitespace().next()?;
251 if !ip.is_empty() {
252 return Some(ip.to_string());
253 }
254 }
255 }
256 None
257}
258
259pub fn run_session(socket_path: &str, channel: &str, cmd: &[String]) -> io::Result<()> {
270 #[cfg(windows)]
275 disable_quick_edit();
276
277 let rt =
278 tokio::runtime::Runtime::new().map_err(|e| io::Error::other(format!("runtime: {e}")))?;
279
280 let client: Arc<RpcIpcClient> = rt
282 .block_on(RpcIpcClient::new(socket_path))
283 .map_err(|e| io::Error::new(io::ErrorKind::ConnectionRefused, format!("{e:?}")))?;
284
285 let abi_fault = |e: &dyn std::fmt::Display| -> io::Error {
289 io::Error::other(format!(
290 "FATAL: Protocol ABI mismatch. A legacy daemon may be occupying the IPC socket. Manually terminate the daemon process before continuing. (cause: {e})"
291 ))
292 };
293
294 let server_cols = Arc::new(AtomicU16::new(0));
298 let server_rows = Arc::new(AtomicU16::new(0));
299 let resize_pending = Arc::new(AtomicBool::new(false));
300
301 {
302 let cols_ref = Arc::clone(&server_cols);
303 let rows_ref = Arc::clone(&server_rows);
304 let pending_ref = Arc::clone(&resize_pending);
305 rt.block_on(client.get_endpoint().register_prebuffered(
306 OnPtyResized::METHOD_ID,
307 move |payload, _ctx| {
308 let cols_ref = Arc::clone(&cols_ref);
309 let rows_ref = Arc::clone(&rows_ref);
310 let pending_ref = Arc::clone(&pending_ref);
311 async move {
312 let (cols, rows) = OnPtyResized::decode_request(&payload)
313 .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
314 cols_ref.store(cols, Ordering::Relaxed);
315 rows_ref.store(rows, Ordering::Relaxed);
316 pending_ref.store(true, Ordering::Relaxed);
317 OnPtyResized::encode_response(())
318 .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
319 }
320 },
321 ))
322 .map_err(|e| io::Error::other(format!("register OnPtyResized: {e:?}")))?;
323 }
324
325 let (push_tx, push_rx) = crossbeam_channel::bounded::<Vec<u8>>(PTY_OUTPUT_CHANNEL_CAPACITY);
329 let (clip_tx, clip_rx) = crossbeam_channel::bounded::<String>(CLIPBOARD_CHANNEL_CAPACITY);
330
331 let (term_cols, term_rows) = match crossterm::terminal::size() {
338 Ok((c, r)) => (c.max(MIN_TERM_COLS), r.max(MIN_TERM_ROWS)),
339 Err(_) => (FALLBACK_TERM_COLS, FALLBACK_TERM_ROWS),
340 };
341 let hostname = hostname::get()
342 .map(|h| h.to_string_lossy().into_owned())
343 .unwrap_or_else(|_| "unknown".to_string());
344
345 let (actual_cols, actual_rows) = rt.block_on(async {
346 let conn_id = Attach::call(
349 &*client,
350 AttachRequest {
351 channel: channel.to_string(),
352 hostname,
353 pid: std::process::id() as u64,
354 user: client_user(),
355 version: client_version(),
356 ssh_ip: client_ssh_ip(),
357 },
358 )
359 .await
360 .map_err(|e| abi_fault(&e))?;
361 let cmd = if cmd.is_empty() {
363 None
364 } else {
365 Some(cmd.to_vec())
366 };
367 let launch_cwd = std::env::current_dir().ok().map(path_wire::encode_path);
373 let SpawnResponse {
374 id: _session_id,
375 cols: actual_cols,
376 rows: actual_rows,
377 ..
378 } = Spawn::call(
379 &*client,
380 SpawnRequest {
381 cmd,
382 cols: term_cols,
383 rows: term_rows,
384 cwd: launch_cwd,
385 },
386 )
387 .await
388 .map_err(|e| abi_fault(&e))?;
389 let _ = conn_id;
390 Ok::<(u16, u16), io::Error>((actual_cols, actual_rows))
391 })?;
392
393 let writer = rt.block_on(async {
395 let (_, mut reader) = client
398 .open_channel(SUBSCRIBE_OUTPUT_METHOD_ID, 0)
399 .await
400 .map_err(|e| io::Error::other(format!("subscribe: {e:?}")))?;
401
402 rt.spawn(async move {
406 let mut osc52 = Osc52Extractor::new();
407 let mut prev_tail: [u8; PREV_TAIL_LEN] = [0; PREV_TAIL_LEN];
408
409 while let Some(chunk) = reader.recv().await {
410 if let Ok(mut data) = chunk {
411 if let Some(text) = osc52.push(&data, &prev_tail) {
412 let _ = clip_tx.try_send(text);
413 }
414
415 let n = data.len();
416 if n >= PREV_TAIL_LEN {
417 prev_tail.copy_from_slice(&data[n - PREV_TAIL_LEN..n]);
418 } else if n > 0 {
419 prev_tail.rotate_left(n);
420 prev_tail[PREV_TAIL_LEN - n..].copy_from_slice(&data[..n]);
421 }
422
423 while let Err(crossbeam_channel::TrySendError::Full(pending)) =
426 push_tx.try_send(data)
427 {
428 data = pending;
429 tokio::time::sleep(Duration::from_millis(BACKPRESSURE_SLEEP_MS)).await;
430 }
431 } else {
432 break;
433 }
434 }
435 if let Some(text) = osc52.finish() {
438 let _ = clip_tx.try_send(text);
439 }
440 });
441
442 let (writer, _) = client
445 .open_channel(STREAM_INPUT_METHOD_ID, 0)
446 .await
447 .map_err(|e| io::Error::other(format!("stream input: {e:?}")))?;
448
449 Ok::<_, io::Error>(writer)
450 })?;
451
452 let input_writer = Box::new(move |data: &[u8]| -> io::Result<()> {
453 writer
454 .send(data.to_vec())
455 .map_err(|e| io::Error::other(e.to_string()))?;
456 Ok(())
457 });
458
459 let mut pane = RemotePane::new(
460 1u64,
461 Some(client.clone()),
462 rt.handle().clone(),
463 term_cols,
464 term_rows,
465 push_rx.clone(),
466 input_writer,
467 );
468
469 for _ in 0..INITIAL_WAIT_ITERS {
471 pane.drain_pushes();
472 let parser = pane.shared_parser();
473 let parser = parser.lock().unwrap();
474 if !parser.screen().contents_formatted().is_empty() {
475 break;
476 }
477 drop(parser);
478 std::thread::sleep(Duration::from_millis(INITIAL_WAIT_SLEEP_MS));
479 }
480
481 {
483 let parser = pane.shared_parser();
484 let mut parser_lk = parser.lock().unwrap();
485 let (cur_rows, cur_cols) = parser_lk.screen().size();
486 if actual_cols != cur_cols || actual_rows != cur_rows {
487 parser_lk.screen_mut().set_size(actual_rows, actual_cols);
488 }
489 drop(parser_lk);
490 }
491
492 #[cfg(unix)]
503 let _ = redirect_fd_to_tracing(libc::STDERR_FILENO, true);
504 let _guard = init_terminal(stdout())?;
505 let mut out = stdout();
506
507 let mut clipboard = Clipboard::new();
508 let sigint = install_sigint_handler()?;
509
510 let (input_tx, input_rx) = crossbeam_channel::bounded::<Event>(INPUT_CHANNEL_CAPACITY);
512
513 std::thread::Builder::new()
517 .name("crossterm-input".into())
518 .spawn(move || {
519 loop {
520 match crossterm::event::poll(Duration::from_millis(INPUT_POLL_MS)) {
521 Ok(true) => {
522 if let Ok(crossterm_evt) = crossterm::event::read()
523 && let Some(e) = convert_crossterm_event(crossterm_evt)
524 && input_tx.send(e).is_err()
525 {
526 break;
527 }
528 }
529 Ok(false) => continue,
530 Err(_) => break,
531 }
532 }
533 })
534 .map_err(|e| io::Error::other(format!("spawn input thread: {e}")))?;
535
536 {
538 let parser = pane.shared_parser();
539 let parser = parser.lock().unwrap();
540 let screen = parser.screen();
541 let (rows, cols) = screen.size();
542 render_frame(&mut out, screen, rows, cols, false)?;
543 }
544
545 let mut pending_input: Option<Event> = None;
546 loop {
547 let mut force_render = false;
548 let mut clear_display = false;
549
550 let apply_pending_resize = |shared_parser: &Arc<Mutex<Parser>>| -> bool {
553 if resize_pending.swap(false, Ordering::Relaxed) {
554 let cols = server_cols.load(Ordering::Relaxed);
555 let rows = server_rows.load(Ordering::Relaxed);
556 if cols > 0 && rows > 0 {
557 let mut parser_lk = shared_parser.lock().unwrap();
558 let (cur_rows, cur_cols) = parser_lk.screen().size();
559 if cur_cols != cols || cur_rows != rows {
560 parser_lk.screen_mut().set_size(rows, cols);
561 return true;
562 }
563 }
564 }
565 false
566 };
567
568 let resized = apply_pending_resize(&pane.shared_parser());
570 force_render |= resized;
571 clear_display |= resized;
572
573 let input_event = if let Some(evt) = pending_input.take() {
576 Some(evt)
577 } else {
578 crossbeam_channel::select! {
579 recv(input_rx) -> msg => {
580 match msg {
581 Ok(evt) => Some(evt),
582 Err(_) => return Err(io::Error::other("input thread died")),
583 }
584 }
585 recv(push_rx) -> msg => {
586 match msg {
587 Ok(data) => {
588 let resized = apply_pending_resize(&pane.shared_parser());
593 force_render |= resized;
594 clear_display |= resized;
595
596 let parser = pane.shared_parser();
598 let mut parser = parser.lock().unwrap();
599 parser.process(&data);
600 None
601 }
602 Err(_) => {
603 None
606 }
607 }
608 }
609 }
610 };
611
612 let has_new_data = pane.drain_pushes() || input_event.is_none();
614
615 while let Ok(text) = clip_rx.try_recv() {
617 clipboard.set(&text);
618 }
619
620 if sigint.received() {
622 sigint.ack();
623 let _ = pane.write_bytes(&[0x03]);
624 }
625
626 if let Some(mut evt) = input_event {
628 if let Event::Mouse(ref mut mouse) = evt
633 && matches!(mouse.kind, MouseEventKind::Moved | MouseEventKind::Drag(_))
634 {
635 while let Ok(next_evt) = input_rx.try_recv() {
636 match next_evt {
637 Event::Mouse(ref next_mouse)
638 if is_coalescable_mouse(
639 &mouse.kind,
640 &mouse.modifiers,
641 &next_mouse.kind,
642 &next_mouse.modifiers,
643 ) =>
644 {
645 *mouse = *next_mouse;
646 }
647 other => {
648 pending_input = Some(other);
649 break;
650 }
651 }
652 }
653 }
654
655 match evt {
656 Event::Key(ref key)
657 if key.kind == KeyKind::Press || key.kind == KeyKind::Repeat =>
658 {
659 let bytes = key_to_bytes(key, false);
660 if !bytes.is_empty() {
661 let _ = pane.write_bytes(&bytes);
662 }
663 }
664 Event::Mouse(ref mouse) => {
665 let mouse_active = {
666 let parser = pane.shared_parser();
667 let parser = parser.lock().unwrap();
668 parser.screen().mouse_protocol_mode() != MouseProtocolMode::None
669 };
670 if mouse_active {
671 let bytes = mouse_event_to_bytes(mouse, MouseProtocolEncoding::Sgr);
672 if !bytes.is_empty() {
673 let _ = pane.write_bytes(&bytes);
674 }
675 }
676 }
677 Event::Resize(w, h) => {
678 let size = PtySize {
679 rows: h,
680 cols: w,
681 pixel_width: 0,
682 pixel_height: 0,
683 };
684 if let Err(err) = pane.resize(size) {
685 tracing::warn!(error = %err, "resize request failed on PTY pane");
686 }
687 force_render = true;
688 clear_display = true;
689 }
690 Event::Paste(text) => {
691 let mut wrapped = Vec::with_capacity(text.len() + BRACKETED_PASTE_OVERHEAD);
692 wrapped.extend_from_slice(b"\x1b[200~");
693 wrapped.extend_from_slice(text.as_bytes());
694 wrapped.extend_from_slice(b"\x1b[201~");
695 let _ = pane.write_bytes(&wrapped);
696 }
697 _ => {}
698 }
699 }
700
701 if !client.is_connected() {
703 return Err(io::Error::other("connection to session server lost"));
704 }
705
706 if has_new_data || force_render {
708 let parser = pane.shared_parser();
709 let parser = parser.lock().unwrap();
710 let screen = parser.screen();
711 let (rows, cols) = screen.size();
712 render_frame(&mut out, screen, rows, cols, clear_display)?;
713 }
714
715 if pane.has_exited() {
717 return Ok(());
718 }
719 }
720}
721
722#[derive(Default, PartialEq, Clone, Copy)]
723struct CellStyle {
724 fg: term_wm_vt100::Color,
725 bg: term_wm_vt100::Color,
726 bold: bool,
727 dim: bool,
728 italic: bool,
729 underline: bool,
730 inverse: bool,
731}
732
733impl CellStyle {
734 fn from_cell(cell: &term_wm_vt100::Cell) -> Self {
735 Self {
736 fg: cell.fgcolor(),
737 bg: cell.bgcolor(),
738 bold: cell.bold(),
739 dim: cell.dim(),
740 italic: cell.italic(),
741 underline: cell.underline(),
742 inverse: cell.inverse(),
743 }
744 }
745}
746
747fn apply_sgr(out: &mut dyn Write, style: &CellStyle) -> io::Result<()> {
748 write!(out, "\x1b[0m")?;
749 if style.bold {
750 write!(out, "\x1b[1m")?;
751 }
752 if style.dim {
753 write!(out, "\x1b[2m")?;
754 }
755 if style.italic {
756 write!(out, "\x1b[3m")?;
757 }
758 if style.underline {
759 write!(out, "\x1b[4m")?;
760 }
761 if style.inverse {
762 write!(out, "\x1b[7m")?;
763 }
764 match style.fg {
765 term_wm_vt100::Color::Idx(i) => write!(out, "\x1b[38;5;{}m", i)?,
766 term_wm_vt100::Color::Rgb(r, g, b) => write!(out, "\x1b[38;2;{};{};{}m", r, g, b)?,
767 _ => {}
768 }
769 match style.bg {
770 term_wm_vt100::Color::Idx(i) => write!(out, "\x1b[48;5;{}m", i)?,
771 term_wm_vt100::Color::Rgb(r, g, b) => write!(out, "\x1b[48;2;{};{};{}m", r, g, b)?,
772 _ => {}
773 }
774 Ok(())
775}
776
777pub fn render_frame(
778 out: &mut dyn Write,
779 screen: &Screen,
780 rows: u16,
781 cols: u16,
782 clear_display: bool,
783) -> io::Result<()> {
784 let mut buf =
785 Vec::with_capacity((rows as usize) * (cols as usize) * RENDER_BUF_CELL_MULTIPLIER);
786 let mut active_style = CellStyle::default();
787
788 buf.extend_from_slice(b"\x1b[?2026h\x1b[?25l\x1b[0m");
790 if clear_display {
791 buf.extend_from_slice(b"\x1b[2J");
792 }
793 buf.extend_from_slice(b"\x1b[?7l");
794
795 for row in 0..rows {
796 write!(buf, "\x1b[{};1H", row + 1)?;
797
798 let mut col: u16 = 0;
799 while col < cols {
800 let cell_opt = screen.cell(row, col);
804 let contents = cell_opt.map_or("", |c| c.contents());
805 let width = if contents.is_empty() {
806 1
807 } else {
808 unicode_width::UnicodeWidthStr::width(contents).max(1) as u16
809 };
810
811 if col + width >= cols {
815 buf.extend_from_slice(b"\x1b[0m\x1b[K");
816 active_style = CellStyle::default();
817 }
818
819 let style = cell_opt.map(CellStyle::from_cell).unwrap_or_default();
820 if style != active_style {
821 apply_sgr(&mut buf, &style)?;
822 active_style = style;
823 }
824
825 if contents.is_empty() {
826 buf.push(b' ');
827 } else {
828 buf.extend_from_slice(contents.as_bytes());
829 }
830
831 col += width;
832 }
833 }
834
835 buf.extend_from_slice(b"\x1b[?7h");
836 buf.extend_from_slice(b"\x1b[0m");
837 let (cur_row, cur_col) = screen.cursor_position();
838 write!(buf, "\x1b[{};{}H", cur_row + 1, cur_col + 1)?;
839 if screen.hide_cursor() {
840 buf.extend_from_slice(b"\x1b[?25l");
841 } else {
842 buf.extend_from_slice(b"\x1b[?25h");
843 }
844 buf.extend_from_slice(b"\x1b[?2026l");
846
847 out.write_all(&buf)?;
848 out.flush()
849}
850
851#[cfg(test)]
852mod tests {
853 use super::*;
854 use std::sync::{Arc, Mutex};
855
856 use term_wm_events::{KeyCode, KeyEvent, MouseButton, MouseEvent};
857
858 struct TestWriter {
859 buf: Arc<Mutex<Vec<u8>>>,
860 }
861
862 impl TestWriter {
863 fn new() -> (Self, Arc<Mutex<Vec<u8>>>) {
864 let buf = Arc::new(Mutex::new(Vec::new()));
865 (Self { buf: buf.clone() }, buf)
866 }
867 }
868
869 impl Write for TestWriter {
870 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
871 self.buf.lock().unwrap().extend_from_slice(buf);
872 Ok(buf.len())
873 }
874 fn flush(&mut self) -> io::Result<()> {
875 Ok(())
876 }
877 }
878
879 #[test]
884 fn init_terminal_writes_bracketed_paste_enable() {
885 let (writer, buf) = TestWriter::new();
886 let _guard = init_terminal(writer).expect("init_terminal");
887 let bytes = buf.lock().unwrap();
888 assert!(
889 bytes
890 .windows(b"\x1b[?2004h".len())
891 .any(|w| w == b"\x1b[?2004h")
892 );
893 }
894
895 #[test]
900 fn init_terminal_writes_mouse_enable_ansi() {
901 let (writer, buf) = TestWriter::new();
902 let _guard = init_terminal(writer).expect("init_terminal");
903 let bytes = buf.lock().unwrap();
904 assert!(
905 bytes
906 .windows(b"\x1b[?1000h".len())
907 .any(|w| w == b"\x1b[?1000h"),
908 "init_terminal must emit the mouse-enable ANSI so a host routes mouse input"
909 );
910 }
911
912 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
918 static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
919 LOCK.lock().unwrap_or_else(|e| e.into_inner())
920 }
921
922 #[test]
923 fn client_ssh_ip_from_ssh_client() {
924 let _guard = env_lock();
925 unsafe {
926 std::env::set_var("SSH_CLIENT", "192.168.1.50 54321 22");
927 std::env::remove_var("SSH_CONNECTION");
928 }
929 assert_eq!(client_ssh_ip().as_deref(), Some("192.168.1.50"));
930 unsafe {
931 std::env::remove_var("SSH_CLIENT");
932 }
933 }
934
935 #[test]
936 fn client_ssh_ip_from_ssh_connection_fallback() {
937 let _guard = env_lock();
938 unsafe {
939 std::env::remove_var("SSH_CLIENT");
940 std::env::set_var("SSH_CONNECTION", "10.0.0.7 48000 10.0.0.1 22");
941 }
942 assert_eq!(client_ssh_ip().as_deref(), Some("10.0.0.7"));
943 unsafe {
944 std::env::remove_var("SSH_CONNECTION");
945 }
946 }
947
948 #[test]
949 fn client_ssh_ip_ssh_client_wins_over_connection() {
950 let _guard = env_lock();
951 unsafe {
952 std::env::set_var("SSH_CLIENT", "1.2.3.4 1000 22");
953 std::env::set_var("SSH_CONNECTION", "9.9.9.9 2000 1.1.1.1 22");
954 }
955 assert_eq!(client_ssh_ip().as_deref(), Some("1.2.3.4"));
956 unsafe {
957 std::env::remove_var("SSH_CLIENT");
958 std::env::remove_var("SSH_CONNECTION");
959 }
960 }
961
962 #[test]
963 fn client_ssh_ip_none_when_local() {
964 let _guard = env_lock();
965 unsafe {
966 std::env::remove_var("SSH_CLIENT");
967 std::env::remove_var("SSH_CONNECTION");
968 }
969 assert_eq!(client_ssh_ip(), None);
970 }
971
972 #[test]
973 fn client_version_matches_package() {
974 assert_eq!(client_version(), env!("CARGO_PKG_VERSION"));
975 }
976
977 #[test]
978 fn client_user_non_empty() {
979 assert!(!client_user().is_empty(), "client user must resolve");
980 }
981
982 #[test]
983 #[cfg(windows)]
984 fn client_user_prefers_username_env_when_set() {
985 let _guard = env_lock();
986 unsafe {
987 std::env::set_var("USERNAME", "win-test-user");
988 }
989 assert_eq!(client_user(), "win-test-user");
990 unsafe {
991 std::env::remove_var("USERNAME");
992 }
993 }
994
995 #[test]
996 #[cfg(windows)]
997 fn client_user_falls_back_to_getusername_when_env_absent() {
998 let _guard = env_lock();
999 unsafe {
1000 std::env::remove_var("USERNAME");
1001 }
1002 assert!(
1005 !client_user().is_empty(),
1006 "GetUserNameW fallback must resolve a user"
1007 );
1008 }
1009
1010 #[test]
1013 fn terminal_guard_teardown_writes_bracketed_paste_disable() {
1014 let (writer, buf) = TestWriter::new();
1015 {
1016 let _guard = TerminalGuard {
1017 writer: Some(writer),
1018 };
1019 }
1020 let bytes = buf.lock().unwrap();
1021 assert!(
1022 bytes
1023 .windows(b"\x1b[?2004l".len())
1024 .any(|w| w == b"\x1b[?2004l")
1025 );
1026 }
1027
1028 #[test]
1031 fn terminal_guard_teardown_writes_mouse_disable_ansi() {
1032 let (writer, buf) = TestWriter::new();
1033 {
1034 let _guard = TerminalGuard {
1035 writer: Some(writer),
1036 };
1037 }
1038 let bytes = buf.lock().unwrap();
1039 assert!(
1040 bytes
1041 .windows(b"\x1b[?1000l".len())
1042 .any(|w| w == b"\x1b[?1000l"),
1043 "TerminalGuard teardown must emit the mouse-disable ANSI"
1044 );
1045 }
1046
1047 #[test]
1050 fn init_and_teardown_roundtrip_contains_both_sequences() {
1051 let (writer, buf) = TestWriter::new();
1052 let guard = init_terminal(writer).expect("init_terminal");
1053 drop(guard);
1054 let bytes = buf.lock().unwrap();
1055 assert!(
1056 bytes
1057 .windows(b"\x1b[?2004h".len())
1058 .any(|w| w == b"\x1b[?2004h")
1059 );
1060 assert!(
1061 bytes
1062 .windows(b"\x1b[?2004l".len())
1063 .any(|w| w == b"\x1b[?2004l")
1064 );
1065 }
1066
1067 #[test]
1070 fn test_prev_parser_resize_sync_matches_fresh_parser() {
1071 let mut prev_parser = term_wm_vt100::Parser::new(24, 80, 0);
1072 prev_parser.process(b"initial screen content");
1073
1074 let (new_rows, new_cols) = (40, 120);
1076 let new_formatted_content = {
1077 let mut p = term_wm_vt100::Parser::new(new_rows, new_cols, 0);
1078 p.process(b"resized screen content");
1079 p.screen().contents_formatted().to_vec()
1080 };
1081
1082 prev_parser.screen_mut().set_size(new_rows, new_cols);
1084 prev_parser.process(b"\x1bc");
1085 prev_parser.process(&new_formatted_content);
1086
1087 let mut fresh_parser = term_wm_vt100::Parser::new(new_rows, new_cols, 0);
1089 fresh_parser.process(&new_formatted_content);
1090
1091 assert_eq!(
1092 prev_parser.screen().contents_formatted(),
1093 fresh_parser.screen().contents_formatted(),
1094 "Reused parser state after set_size + RIS must match fresh parser"
1095 );
1096 }
1097
1098 #[test]
1099 fn render_frame_outputs_correct_cup_and_sgr() {
1100 let mut parser = term_wm_vt100::Parser::new(4, 8, 0);
1101 parser.process(b"\x1b[31mhello\x1b[0m");
1102 let screen = parser.screen();
1103 let mut buf: Vec<u8> = Vec::new();
1104 let (rows, cols) = screen.size();
1105 render_frame(&mut buf, screen, rows, cols, false).unwrap();
1106 let output = String::from_utf8_lossy(&buf);
1107 assert!(output.contains("\x1b[1;1H"));
1109 assert!(output.contains("\x1b[2;1H"));
1110 assert!(output.contains("\x1b[3;1H"));
1111 assert!(output.contains("\x1b[4;1H"));
1112 assert!(output.contains("hello"));
1114 assert!(
1116 output.contains("\x1b[38;5;1m") || output.contains("\x1b[31m"),
1117 "Expected red foreground SGR in output: {output:?}"
1118 );
1119 assert!(!output.contains("\x1b\x1b"), "no double ESC sequences");
1121 }
1122
1123 #[test]
1126 fn coalesce_moved_with_moved() {
1127 assert!(is_coalescable_mouse(
1128 &MouseEventKind::Moved,
1129 &KeyModifiers::NONE,
1130 &MouseEventKind::Moved,
1131 &KeyModifiers::NONE,
1132 ));
1133 }
1134
1135 #[test]
1136 fn coalesce_drag_same_button() {
1137 assert!(is_coalescable_mouse(
1138 &MouseEventKind::Drag(MouseButton::Left),
1139 &KeyModifiers::NONE,
1140 &MouseEventKind::Drag(MouseButton::Left),
1141 &KeyModifiers::NONE,
1142 ));
1143 assert!(is_coalescable_mouse(
1144 &MouseEventKind::Drag(MouseButton::Right),
1145 &KeyModifiers {
1146 shift: true,
1147 ..KeyModifiers::NONE
1148 },
1149 &MouseEventKind::Drag(MouseButton::Right),
1150 &KeyModifiers {
1151 shift: true,
1152 ..KeyModifiers::NONE
1153 },
1154 ));
1155 }
1156
1157 #[test]
1158 fn reject_drag_different_button() {
1159 assert!(!is_coalescable_mouse(
1160 &MouseEventKind::Drag(MouseButton::Left),
1161 &KeyModifiers::NONE,
1162 &MouseEventKind::Drag(MouseButton::Right),
1163 &KeyModifiers::NONE,
1164 ));
1165 }
1166
1167 #[test]
1168 fn reject_moved_vs_drag() {
1169 assert!(!is_coalescable_mouse(
1170 &MouseEventKind::Moved,
1171 &KeyModifiers::NONE,
1172 &MouseEventKind::Drag(MouseButton::Left),
1173 &KeyModifiers::NONE,
1174 ));
1175 }
1176
1177 #[test]
1178 fn reject_different_modifiers() {
1179 assert!(!is_coalescable_mouse(
1180 &MouseEventKind::Moved,
1181 &KeyModifiers::NONE,
1182 &MouseEventKind::Moved,
1183 &KeyModifiers {
1184 shift: true,
1185 ..KeyModifiers::NONE
1186 },
1187 ));
1188 assert!(!is_coalescable_mouse(
1189 &MouseEventKind::Drag(MouseButton::Left),
1190 &KeyModifiers {
1191 control: true,
1192 ..KeyModifiers::NONE
1193 },
1194 &MouseEventKind::Drag(MouseButton::Left),
1195 &KeyModifiers::NONE,
1196 ));
1197 }
1198
1199 #[test]
1200 fn reject_discrete_events() {
1201 assert!(!is_coalescable_mouse(
1202 &MouseEventKind::Press(MouseButton::Left),
1203 &KeyModifiers::NONE,
1204 &MouseEventKind::Press(MouseButton::Left),
1205 &KeyModifiers::NONE,
1206 ));
1207 assert!(!is_coalescable_mouse(
1208 &MouseEventKind::Release(MouseButton::Left),
1209 &KeyModifiers::NONE,
1210 &MouseEventKind::Moved,
1211 &KeyModifiers::NONE,
1212 ));
1213 assert!(!is_coalescable_mouse(
1214 &MouseEventKind::Moved,
1215 &KeyModifiers::NONE,
1216 &MouseEventKind::ScrollDown,
1217 &KeyModifiers::NONE,
1218 ));
1219 assert!(!is_coalescable_mouse(
1220 &MouseEventKind::ScrollUp,
1221 &KeyModifiers::NONE,
1222 &MouseEventKind::ScrollUp,
1223 &KeyModifiers::NONE,
1224 ));
1225 }
1226
1227 fn coalesce_through(
1232 events: &[Event],
1233 kind: MouseEventKind,
1234 modifiers: KeyModifiers,
1235 ) -> Option<Event> {
1236 let (tx, rx) = crossbeam_channel::bounded::<Event>(events.len());
1237 for e in events.iter().cloned() {
1238 tx.send(e).ok();
1239 }
1240 drop(tx);
1241
1242 let mut result = Event::Mouse(MouseEvent {
1243 kind,
1244 modifiers,
1245 column: 0,
1246 row: 0,
1247 });
1248
1249 if let Event::Mouse(ref mut mouse) = result
1250 && matches!(mouse.kind, MouseEventKind::Moved | MouseEventKind::Drag(_))
1251 {
1252 while let Ok(next) = rx.try_recv() {
1253 match next {
1254 Event::Mouse(ref next_mouse)
1255 if is_coalescable_mouse(
1256 &mouse.kind,
1257 &mouse.modifiers,
1258 &next_mouse.kind,
1259 &next_mouse.modifiers,
1260 ) =>
1261 {
1262 *mouse = *next_mouse;
1263 }
1264 _other => return Some(result),
1265 }
1266 }
1267 }
1268
1269 Some(result)
1270 }
1271
1272 #[test]
1273 fn coalesce_keeps_latest_moved_position() {
1274 let events = vec![
1275 Event::Mouse(MouseEvent {
1276 kind: MouseEventKind::Moved,
1277 modifiers: KeyModifiers::NONE,
1278 column: 5,
1279 row: 5,
1280 }),
1281 Event::Mouse(MouseEvent {
1282 kind: MouseEventKind::Moved,
1283 modifiers: KeyModifiers::NONE,
1284 column: 10,
1285 row: 10,
1286 }),
1287 Event::Mouse(MouseEvent {
1288 kind: MouseEventKind::Moved,
1289 modifiers: KeyModifiers::NONE,
1290 column: 15,
1291 row: 15,
1292 }),
1293 ];
1294 let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
1295 let Event::Mouse(m) = result.unwrap() else {
1296 panic!("expected mouse")
1297 };
1298 assert_eq!((m.column, m.row), (15, 15));
1299 }
1300
1301 #[test]
1302 fn coalesce_keeps_latest_drag_position() {
1303 let events = vec![
1304 Event::Mouse(MouseEvent {
1305 kind: MouseEventKind::Drag(MouseButton::Left),
1306 modifiers: KeyModifiers::NONE,
1307 column: 1,
1308 row: 1,
1309 }),
1310 Event::Mouse(MouseEvent {
1311 kind: MouseEventKind::Drag(MouseButton::Left),
1312 modifiers: KeyModifiers::NONE,
1313 column: 2,
1314 row: 2,
1315 }),
1316 ];
1317 let result = coalesce_through(
1318 &events,
1319 MouseEventKind::Drag(MouseButton::Left),
1320 KeyModifiers::NONE,
1321 );
1322 let Event::Mouse(m) = result.unwrap() else {
1323 panic!("expected mouse")
1324 };
1325 assert_eq!((m.column, m.row), (2, 2));
1326 }
1327
1328 #[test]
1329 fn coalesce_stops_at_modifier_change() {
1330 let events = vec![Event::Mouse(MouseEvent {
1331 kind: MouseEventKind::Moved,
1332 modifiers: KeyModifiers {
1333 shift: true,
1334 ..KeyModifiers::NONE
1335 },
1336 column: 99,
1337 row: 99,
1338 })];
1339 let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
1340 let Event::Mouse(m) = result.unwrap() else {
1341 panic!("expected mouse")
1342 };
1343 assert_eq!((m.column, m.row), (0, 0));
1346 }
1347
1348 #[test]
1349 fn coalesce_stops_at_non_mouse_event() {
1350 let key = Event::Key(KeyEvent {
1351 code: KeyCode::Char('q'),
1352 kind: KeyKind::Press,
1353 modifiers: KeyModifiers::NONE,
1354 });
1355 let events = vec![key.clone()];
1356 let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
1357 let Event::Mouse(m) = result.unwrap() else {
1358 panic!("expected mouse")
1359 };
1360 assert_eq!((m.column, m.row), (0, 0));
1362 }
1363
1364 #[test]
1365 fn coalesce_stops_at_discrete_mouse_event() {
1366 let events = vec![Event::Mouse(MouseEvent {
1367 kind: MouseEventKind::Press(MouseButton::Left),
1368 modifiers: KeyModifiers::NONE,
1369 column: 10,
1370 row: 10,
1371 })];
1372 let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
1373 let Event::Mouse(m) = result.unwrap() else {
1374 panic!("expected mouse")
1375 };
1376 assert_eq!((m.column, m.row), (0, 0));
1378 }
1379}
1380
1381#[cfg(test)]
1385#[allow(clippy::type_complexity)]
1386mod snapshot_tests {
1387 use super::*;
1388
1389 fn render_and_capture(pty_bytes: &[u8], rows: u16, cols: u16, clear_display: bool) -> Vec<u8> {
1392 let rt = tokio::runtime::Builder::new_current_thread()
1393 .build()
1394 .expect("tokio rt");
1395 let (push_tx, push_rx) = crossbeam_channel::bounded(16);
1396 let input_writer: Box<dyn FnMut(&[u8]) -> io::Result<()> + Send> = Box::new(|_| Ok(()));
1397 let mut pane = RemotePane::new(
1398 0,
1399 None,
1400 rt.handle().clone(),
1401 cols,
1402 rows,
1403 push_rx,
1404 input_writer,
1405 );
1406 drop(rt); push_tx.send(pty_bytes.to_vec()).ok();
1409 pane.drain_pushes();
1410
1411 let parser = pane.shared_parser();
1412 let parser = parser.lock().unwrap();
1413 let screen = parser.screen();
1414 let (rows, cols) = screen.size();
1415 let mut out = Vec::new();
1416 render_frame(&mut out, screen, rows, cols, clear_display).unwrap();
1417 out
1418 }
1419
1420 fn escape_ansi(bytes: &[u8]) -> String {
1422 let mut out: Vec<u8> = Vec::with_capacity(bytes.len() * 4);
1423 for &b in bytes {
1424 match b {
1425 b'\x1b' => out.extend_from_slice(b"\\x1b"),
1426 b'\n' => out.extend_from_slice(b"\\n"),
1427 b'\r' => out.extend_from_slice(b"\\r"),
1428 b'\t' => out.extend_from_slice(b"\\t"),
1429 0x20..=0x7e => out.push(b),
1430 _ => {
1431 out.push(b'\\');
1432 out.push(b'x');
1433 out.extend_from_slice(&hex_byte(b));
1434 }
1435 }
1436 }
1437 unsafe { String::from_utf8_unchecked(out) }
1439 }
1440
1441 fn hex_byte(b: u8) -> [u8; 2] {
1442 #[inline]
1443 fn hex_nibble(n: u8) -> u8 {
1444 let digit = n & 0x0f;
1445 if digit < 10 {
1446 b'0' + digit
1447 } else {
1448 b'a' + digit - 10
1449 }
1450 }
1451 [hex_nibble(b >> 4), hex_nibble(b)]
1452 }
1453
1454 #[test]
1457 fn snapshot_empty_grid() {
1458 let out = render_and_capture(b"", 4, 8, false);
1459 insta::assert_snapshot!("empty_grid", escape_ansi(&out));
1460 }
1461
1462 #[test]
1463 fn snapshot_basic_text() {
1464 let out = render_and_capture(b"Hello\nWorld", 4, 8, false);
1465 insta::assert_snapshot!("basic_text", escape_ansi(&out));
1466 }
1467
1468 #[test]
1469 fn snapshot_colored_text() {
1470 let out = render_and_capture(b"\x1b[31mred\x1b[1mbold", 4, 8, false);
1471 insta::assert_snapshot!("colored_text", escape_ansi(&out));
1472 }
1473
1474 #[test]
1475 fn snapshot_normal_char_at_margin() {
1476 let out = render_and_capture(b"ABCD", 1, 4, false);
1479 insta::assert_snapshot!("normal_char_at_margin", escape_ansi(&out));
1480 }
1481
1482 #[test]
1483 fn snapshot_clear_display() {
1484 let out = render_and_capture(b"", 4, 8, true);
1485 insta::assert_snapshot!("clear_display", escape_ansi(&out));
1486 }
1487
1488 #[test]
1489 fn snapshot_hidden_cursor() {
1490 let out = render_and_capture(b"\x1b[?25l", 4, 8, false);
1491 insta::assert_snapshot!("hidden_cursor", escape_ansi(&out));
1492 }
1493
1494 #[test]
1495 fn snapshot_color_across_margin() {
1496 let out = render_and_capture(b"\x1b[41mX", 1, 4, false);
1499 insta::assert_snapshot!("color_across_margin", escape_ansi(&out));
1500 }
1501
1502 #[test]
1503 fn snapshot_multi_row_fill() {
1504 let out = render_and_capture(b"ABCDEFGHIJKL", 3, 4, false);
1507 insta::assert_snapshot!("multi_row_fill", escape_ansi(&out));
1508 }
1509
1510 #[test]
1511 fn snapshot_wide_char_margin() {
1512 let out = render_and_capture(
1515 b"B\xe3\x81\x82", 1,
1517 3,
1518 false,
1519 );
1520 insta::assert_snapshot!("wide_char_margin", escape_ansi(&out));
1521 }
1522
1523 #[test]
1524 fn snapshot_wide_char_middle() {
1525 let out = render_and_capture(
1529 b"A\xe3\x81\x82\xe3\x81\x83", 1,
1531 5,
1532 false,
1533 );
1534 insta::assert_snapshot!("wide_char_middle", escape_ansi(&out));
1535 }
1536}