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