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, SpawnRequest, SpawnResponse, path_wire,
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 let rt =
315 tokio::runtime::Runtime::new().map_err(|e| io::Error::other(format!("runtime: {e}")))?;
316
317 let client: Arc<RpcIpcClient> = rt
319 .block_on(RpcIpcClient::new(socket_path))
320 .map_err(|e| io::Error::new(io::ErrorKind::ConnectionRefused, format!("{e:?}")))?;
321
322 let abi_fault = |e: &dyn std::fmt::Display| -> io::Error {
326 io::Error::other(format!(
327 "FATAL: Protocol ABI mismatch. A legacy daemon may be occupying the IPC socket. Manually terminate the daemon process before continuing. (cause: {e})"
328 ))
329 };
330
331 let server_cols = Arc::new(AtomicU16::new(0));
335 let server_rows = Arc::new(AtomicU16::new(0));
336 let resize_pending = Arc::new(AtomicBool::new(false));
337
338 {
339 let cols_ref = Arc::clone(&server_cols);
340 let rows_ref = Arc::clone(&server_rows);
341 let pending_ref = Arc::clone(&resize_pending);
342 rt.block_on(client.get_endpoint().register_prebuffered(
343 OnPtyResized::METHOD_ID,
344 move |payload, _ctx| {
345 let cols_ref = Arc::clone(&cols_ref);
346 let rows_ref = Arc::clone(&rows_ref);
347 let pending_ref = Arc::clone(&pending_ref);
348 async move {
349 let (cols, rows) = OnPtyResized::decode_request(&payload)
350 .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
351 cols_ref.store(cols, Ordering::Relaxed);
352 rows_ref.store(rows, Ordering::Relaxed);
353 pending_ref.store(true, Ordering::Relaxed);
354 OnPtyResized::encode_response(())
355 .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
356 }
357 },
358 ))
359 .map_err(|e| io::Error::other(format!("register OnPtyResized: {e:?}")))?;
360 }
361
362 let (push_tx, push_rx) = crossbeam_channel::bounded::<Vec<u8>>(PTY_OUTPUT_CHANNEL_CAPACITY);
366 let (clip_tx, clip_rx) = crossbeam_channel::bounded::<String>(CLIPBOARD_CHANNEL_CAPACITY);
367
368 let (term_cols, term_rows) = match crossterm::terminal::size() {
375 Ok((c, r)) => (c.max(MIN_TERM_COLS), r.max(MIN_TERM_ROWS)),
376 Err(_) => (FALLBACK_TERM_COLS, FALLBACK_TERM_ROWS),
377 };
378 let hostname = hostname::get()
379 .map(|h| h.to_string_lossy().into_owned())
380 .unwrap_or_else(|_| "unknown".to_string());
381
382 let (actual_cols, actual_rows) = rt.block_on(async {
383 let conn_id = Attach::call(
386 &*client,
387 AttachRequest {
388 channel: channel.to_string(),
389 hostname,
390 pid: std::process::id() as u64,
391 user: client_user(),
392 version: client_version(),
393 ssh_ip: client_ssh_ip(),
394 },
395 )
396 .await
397 .map_err(|e| abi_fault(&e))?;
398 let cmd = if cmd.is_empty() {
400 None
401 } else {
402 Some(cmd.to_vec())
403 };
404 let launch_cwd = std::env::current_dir().ok().map(path_wire::encode_path);
410 let SpawnResponse {
411 id: _session_id,
412 cols: actual_cols,
413 rows: actual_rows,
414 ..
415 } = Spawn::call(
416 &*client,
417 SpawnRequest {
418 cmd,
419 cols: term_cols,
420 rows: term_rows,
421 cwd: launch_cwd,
422 },
423 )
424 .await
425 .map_err(|e| abi_fault(&e))?;
426 let _ = conn_id;
427 Ok::<(u16, u16), io::Error>((actual_cols, actual_rows))
428 })?;
429
430 let writer = rt.block_on(async {
432 let (_, mut reader) = client
435 .open_channel(SUBSCRIBE_OUTPUT_METHOD_ID, 0)
436 .await
437 .map_err(|e| io::Error::other(format!("subscribe: {e:?}")))?;
438
439 rt.spawn(async move {
443 let mut osc52 = Osc52Extractor::new();
444 let mut prev_tail: [u8; PREV_TAIL_LEN] = [0; PREV_TAIL_LEN];
445
446 while let Some(chunk) = reader.recv().await {
447 if let Ok(mut data) = chunk {
448 if let Some(text) = osc52.push(&data, &prev_tail) {
449 let _ = clip_tx.try_send(text);
450 }
451
452 let n = data.len();
453 if n >= PREV_TAIL_LEN {
454 prev_tail.copy_from_slice(&data[n - PREV_TAIL_LEN..n]);
455 } else if n > 0 {
456 prev_tail.rotate_left(n);
457 prev_tail[PREV_TAIL_LEN - n..].copy_from_slice(&data[..n]);
458 }
459
460 while let Err(crossbeam_channel::TrySendError::Full(pending)) =
463 push_tx.try_send(data)
464 {
465 data = pending;
466 tokio::time::sleep(Duration::from_millis(BACKPRESSURE_SLEEP_MS)).await;
467 }
468 } else {
469 break;
470 }
471 }
472 if let Some(text) = osc52.finish() {
475 let _ = clip_tx.try_send(text);
476 }
477 });
478
479 let (writer, _) = client
482 .open_channel(STREAM_INPUT_METHOD_ID, 0)
483 .await
484 .map_err(|e| io::Error::other(format!("stream input: {e:?}")))?;
485
486 Ok::<_, io::Error>(writer)
487 })?;
488
489 let input_writer = Box::new(move |data: &[u8]| -> io::Result<()> {
490 writer
491 .send(data.to_vec())
492 .map_err(|e| io::Error::other(e.to_string()))?;
493 Ok(())
494 });
495
496 let mut pane = RemotePane::new(
497 1u64,
498 Some(client.clone()),
499 rt.handle().clone(),
500 term_cols,
501 term_rows,
502 push_rx.clone(),
503 input_writer,
504 );
505
506 for _ in 0..INITIAL_WAIT_ITERS {
508 pane.drain_pushes();
509 let parser = pane.shared_parser();
510 let parser = parser.lock().unwrap();
511 if !parser.screen().contents_formatted().is_empty() {
512 break;
513 }
514 drop(parser);
515 std::thread::sleep(Duration::from_millis(INITIAL_WAIT_SLEEP_MS));
516 }
517
518 {
520 let parser = pane.shared_parser();
521 let mut parser_lk = parser.lock().unwrap();
522 let (cur_rows, cur_cols) = parser_lk.screen().size();
523 if actual_cols != cur_cols || actual_rows != cur_rows {
524 parser_lk.screen_mut().set_size(actual_rows, actual_cols);
525 }
526 drop(parser_lk);
527 }
528
529 #[cfg(unix)]
540 let _ = redirect_fd_to_tracing(libc::STDERR_FILENO, true);
541 let _guard = init_terminal(stdout())?;
542 let mut out = stdout();
543
544 let mut clipboard = Clipboard::new();
545 let sigint = install_sigint_handler()?;
546
547 let (input_tx, input_rx) = crossbeam_channel::bounded::<Event>(INPUT_CHANNEL_CAPACITY);
549
550 std::thread::Builder::new()
554 .name("crossterm-input".into())
555 .spawn(move || {
556 loop {
557 match crossterm::event::poll(Duration::from_millis(INPUT_POLL_MS)) {
558 Ok(true) => {
559 if let Ok(crossterm_evt) = crossterm::event::read()
560 && let Some(e) = convert_crossterm_event(crossterm_evt)
561 && input_tx.send(e).is_err()
562 {
563 break;
564 }
565 }
566 Ok(false) => continue,
567 Err(_) => break,
568 }
569 }
570 })
571 .map_err(|e| io::Error::other(format!("spawn input thread: {e}")))?;
572
573 {
575 let parser = pane.shared_parser();
576 let parser = parser.lock().unwrap();
577 let screen = parser.screen();
578 let (rows, cols) = screen.size();
579 render_frame(&mut out, screen, rows, cols, false)?;
580 }
581
582 let mut pending_input: Option<Event> = None;
583 loop {
584 let mut force_render = false;
585 let mut clear_display = false;
586
587 let apply_pending_resize = |shared_parser: &Arc<Mutex<Parser>>| -> bool {
590 if resize_pending.swap(false, Ordering::Relaxed) {
591 let cols = server_cols.load(Ordering::Relaxed);
592 let rows = server_rows.load(Ordering::Relaxed);
593 if cols > 0 && rows > 0 {
594 let mut parser_lk = shared_parser.lock().unwrap();
595 let (cur_rows, cur_cols) = parser_lk.screen().size();
596 if cur_cols != cols || cur_rows != rows {
597 parser_lk.screen_mut().set_size(rows, cols);
598 return true;
599 }
600 }
601 }
602 false
603 };
604
605 let resized = apply_pending_resize(&pane.shared_parser());
607 force_render |= resized;
608 clear_display |= resized;
609
610 let input_event = if let Some(evt) = pending_input.take() {
613 Some(evt)
614 } else {
615 crossbeam_channel::select! {
616 recv(input_rx) -> msg => {
617 match msg {
618 Ok(evt) => Some(evt),
619 Err(_) => return Err(io::Error::other("input thread died")),
620 }
621 }
622 recv(push_rx) -> msg => {
623 match msg {
624 Ok(data) => {
625 let resized = apply_pending_resize(&pane.shared_parser());
630 force_render |= resized;
631 clear_display |= resized;
632
633 let parser = pane.shared_parser();
635 let mut parser = parser.lock().unwrap();
636 parser.process(&data);
637 None
638 }
639 Err(_) => {
640 None
643 }
644 }
645 }
646 }
647 };
648
649 let has_new_data = pane.drain_pushes() || input_event.is_none();
651
652 while let Ok(text) = clip_rx.try_recv() {
654 clipboard.set(&text);
655 }
656
657 if sigint.received() {
659 sigint.ack();
660 let _ = pane.write_bytes(&[0x03]);
661 }
662
663 if let Some(mut evt) = input_event {
665 if let Event::Mouse(ref mut mouse) = evt
670 && matches!(mouse.kind, MouseEventKind::Moved | MouseEventKind::Drag(_))
671 {
672 while let Ok(next_evt) = input_rx.try_recv() {
673 match next_evt {
674 Event::Mouse(ref next_mouse)
675 if is_coalescable_mouse(
676 &mouse.kind,
677 &mouse.modifiers,
678 &next_mouse.kind,
679 &next_mouse.modifiers,
680 ) =>
681 {
682 *mouse = *next_mouse;
683 }
684 other => {
685 pending_input = Some(other);
686 break;
687 }
688 }
689 }
690 }
691
692 match evt {
693 Event::Key(ref key)
694 if key.kind == KeyKind::Press || key.kind == KeyKind::Repeat =>
695 {
696 let bytes = key_to_bytes(key, false);
697 if !bytes.is_empty() {
698 let _ = pane.write_bytes(&bytes);
699 }
700 }
701 Event::Mouse(ref mouse) => {
702 let mouse_active = {
703 let parser = pane.shared_parser();
704 let parser = parser.lock().unwrap();
705 parser.screen().mouse_protocol_mode() != MouseProtocolMode::None
706 };
707 if mouse_active {
708 let bytes = mouse_event_to_bytes(mouse, MouseProtocolEncoding::Sgr);
709 if !bytes.is_empty() {
710 let _ = pane.write_bytes(&bytes);
711 }
712 }
713 }
714 Event::Resize(w, h) => {
715 let size = PtySize {
716 rows: h,
717 cols: w,
718 pixel_width: 0,
719 pixel_height: 0,
720 };
721 if let Err(err) = pane.resize(size) {
722 tracing::warn!(error = %err, "resize request failed on PTY pane");
723 }
724 force_render = true;
725 clear_display = true;
726 }
727 Event::Paste(text) => {
728 let mut wrapped = Vec::with_capacity(text.len() + BRACKETED_PASTE_OVERHEAD);
729 wrapped.extend_from_slice(b"\x1b[200~");
730 wrapped.extend_from_slice(text.as_bytes());
731 wrapped.extend_from_slice(b"\x1b[201~");
732 let _ = pane.write_bytes(&wrapped);
733 }
734 _ => {}
735 }
736 }
737
738 if !client.is_connected() {
740 return Err(io::Error::other("connection to session server lost"));
741 }
742
743 if has_new_data || force_render {
745 let parser = pane.shared_parser();
746 let parser = parser.lock().unwrap();
747 let screen = parser.screen();
748 let (rows, cols) = screen.size();
749 render_frame(&mut out, screen, rows, cols, clear_display)?;
750 }
751
752 if pane.has_exited() {
754 return Ok(());
755 }
756 }
757}
758
759#[derive(Default, PartialEq, Clone, Copy)]
760struct CellStyle {
761 fg: vt100::Color,
762 bg: vt100::Color,
763 bold: bool,
764 dim: bool,
765 italic: bool,
766 underline: bool,
767 inverse: bool,
768}
769
770impl CellStyle {
771 fn from_cell(cell: &vt100::Cell) -> Self {
772 Self {
773 fg: cell.fgcolor(),
774 bg: cell.bgcolor(),
775 bold: cell.bold(),
776 dim: cell.dim(),
777 italic: cell.italic(),
778 underline: cell.underline(),
779 inverse: cell.inverse(),
780 }
781 }
782}
783
784fn apply_sgr(out: &mut dyn Write, style: &CellStyle) -> io::Result<()> {
785 write!(out, "\x1b[0m")?;
786 if style.bold {
787 write!(out, "\x1b[1m")?;
788 }
789 if style.dim {
790 write!(out, "\x1b[2m")?;
791 }
792 if style.italic {
793 write!(out, "\x1b[3m")?;
794 }
795 if style.underline {
796 write!(out, "\x1b[4m")?;
797 }
798 if style.inverse {
799 write!(out, "\x1b[7m")?;
800 }
801 match style.fg {
802 vt100::Color::Idx(i) => write!(out, "\x1b[38;5;{}m", i)?,
803 vt100::Color::Rgb(r, g, b) => write!(out, "\x1b[38;2;{};{};{}m", r, g, b)?,
804 _ => {}
805 }
806 match style.bg {
807 vt100::Color::Idx(i) => write!(out, "\x1b[48;5;{}m", i)?,
808 vt100::Color::Rgb(r, g, b) => write!(out, "\x1b[48;2;{};{};{}m", r, g, b)?,
809 _ => {}
810 }
811 Ok(())
812}
813
814pub fn render_frame(
815 out: &mut dyn Write,
816 screen: &Screen,
817 rows: u16,
818 cols: u16,
819 clear_display: bool,
820) -> io::Result<()> {
821 let mut buf =
822 Vec::with_capacity((rows as usize) * (cols as usize) * RENDER_BUF_CELL_MULTIPLIER);
823 let mut active_style = CellStyle::default();
824
825 buf.extend_from_slice(b"\x1b[?2026h\x1b[?25l\x1b[0m");
827 if clear_display {
828 buf.extend_from_slice(b"\x1b[2J");
829 }
830 buf.extend_from_slice(b"\x1b[?7l");
831
832 for row in 0..rows {
833 write!(buf, "\x1b[{};1H", row + 1)?;
834
835 let mut col: u16 = 0;
836 while col < cols {
837 let cell_opt = screen.cell(row, col);
841 let contents = cell_opt.map_or("", |c| c.contents());
842 let width = if contents.is_empty() {
843 1
844 } else {
845 unicode_width::UnicodeWidthStr::width(contents).max(1) as u16
846 };
847
848 if col + width >= cols {
852 buf.extend_from_slice(b"\x1b[0m\x1b[K");
853 active_style = CellStyle::default();
854 }
855
856 let style = cell_opt.map(CellStyle::from_cell).unwrap_or_default();
857 if style != active_style {
858 apply_sgr(&mut buf, &style)?;
859 active_style = style;
860 }
861
862 if contents.is_empty() {
863 buf.push(b' ');
864 } else {
865 buf.extend_from_slice(contents.as_bytes());
866 }
867
868 col += width;
869 }
870 }
871
872 buf.extend_from_slice(b"\x1b[?7h");
873 buf.extend_from_slice(b"\x1b[0m");
874 let (cur_row, cur_col) = screen.cursor_position();
875 write!(buf, "\x1b[{};{}H", cur_row + 1, cur_col + 1)?;
876 if screen.hide_cursor() {
877 buf.extend_from_slice(b"\x1b[?25l");
878 } else {
879 buf.extend_from_slice(b"\x1b[?25h");
880 }
881 buf.extend_from_slice(b"\x1b[?2026l");
883
884 out.write_all(&buf)?;
885 out.flush()
886}
887
888#[cfg(test)]
889mod tests {
890 use super::*;
891 use std::sync::{Arc, Mutex};
892
893 use term_wm_events::{KeyCode, KeyEvent, MouseButton, MouseEvent};
894
895 struct TestWriter {
896 buf: Arc<Mutex<Vec<u8>>>,
897 }
898
899 impl TestWriter {
900 fn new() -> (Self, Arc<Mutex<Vec<u8>>>) {
901 let buf = Arc::new(Mutex::new(Vec::new()));
902 (Self { buf: buf.clone() }, buf)
903 }
904 }
905
906 impl Write for TestWriter {
907 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
908 self.buf.lock().unwrap().extend_from_slice(buf);
909 Ok(buf.len())
910 }
911 fn flush(&mut self) -> io::Result<()> {
912 Ok(())
913 }
914 }
915
916 #[test]
921 fn init_terminal_writes_bracketed_paste_enable() {
922 let (writer, buf) = TestWriter::new();
923 let _guard = init_terminal(writer).expect("init_terminal");
924 let bytes = buf.lock().unwrap();
925 assert!(
926 bytes
927 .windows(b"\x1b[?2004h".len())
928 .any(|w| w == b"\x1b[?2004h")
929 );
930 }
931
932 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
938 static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
939 LOCK.lock().unwrap_or_else(|e| e.into_inner())
940 }
941
942 #[test]
943 fn client_ssh_ip_from_ssh_client() {
944 let _guard = env_lock();
945 unsafe {
946 std::env::set_var("SSH_CLIENT", "192.168.1.50 54321 22");
947 std::env::remove_var("SSH_CONNECTION");
948 }
949 assert_eq!(client_ssh_ip().as_deref(), Some("192.168.1.50"));
950 unsafe {
951 std::env::remove_var("SSH_CLIENT");
952 }
953 }
954
955 #[test]
956 fn client_ssh_ip_from_ssh_connection_fallback() {
957 let _guard = env_lock();
958 unsafe {
959 std::env::remove_var("SSH_CLIENT");
960 std::env::set_var("SSH_CONNECTION", "10.0.0.7 48000 10.0.0.1 22");
961 }
962 assert_eq!(client_ssh_ip().as_deref(), Some("10.0.0.7"));
963 unsafe {
964 std::env::remove_var("SSH_CONNECTION");
965 }
966 }
967
968 #[test]
969 fn client_ssh_ip_ssh_client_wins_over_connection() {
970 let _guard = env_lock();
971 unsafe {
972 std::env::set_var("SSH_CLIENT", "1.2.3.4 1000 22");
973 std::env::set_var("SSH_CONNECTION", "9.9.9.9 2000 1.1.1.1 22");
974 }
975 assert_eq!(client_ssh_ip().as_deref(), Some("1.2.3.4"));
976 unsafe {
977 std::env::remove_var("SSH_CLIENT");
978 std::env::remove_var("SSH_CONNECTION");
979 }
980 }
981
982 #[test]
983 fn client_ssh_ip_none_when_local() {
984 let _guard = env_lock();
985 unsafe {
986 std::env::remove_var("SSH_CLIENT");
987 std::env::remove_var("SSH_CONNECTION");
988 }
989 assert_eq!(client_ssh_ip(), None);
990 }
991
992 #[test]
993 fn client_version_matches_package() {
994 assert_eq!(client_version(), env!("CARGO_PKG_VERSION"));
995 }
996
997 #[test]
998 fn client_user_non_empty() {
999 assert!(!client_user().is_empty(), "client user must resolve");
1000 }
1001
1002 #[test]
1003 #[cfg(windows)]
1004 fn client_user_prefers_username_env_when_set() {
1005 let _guard = env_lock();
1006 unsafe {
1007 std::env::set_var("USERNAME", "win-test-user");
1008 }
1009 assert_eq!(client_user(), "win-test-user");
1010 unsafe {
1011 std::env::remove_var("USERNAME");
1012 }
1013 }
1014
1015 #[test]
1016 #[cfg(windows)]
1017 fn client_user_falls_back_to_getusername_when_env_absent() {
1018 let _guard = env_lock();
1019 unsafe {
1020 std::env::remove_var("USERNAME");
1021 }
1022 assert!(
1025 !client_user().is_empty(),
1026 "GetUserNameW fallback must resolve a user"
1027 );
1028 }
1029
1030 #[test]
1033 fn terminal_guard_teardown_writes_bracketed_paste_disable() {
1034 let (writer, buf) = TestWriter::new();
1035 {
1036 let _guard = TerminalGuard {
1037 writer: Some(writer),
1038 };
1039 }
1040 let bytes = buf.lock().unwrap();
1041 assert!(
1042 bytes
1043 .windows(b"\x1b[?2004l".len())
1044 .any(|w| w == b"\x1b[?2004l")
1045 );
1046 }
1047
1048 #[test]
1051 fn init_and_teardown_roundtrip_contains_both_sequences() {
1052 let (writer, buf) = TestWriter::new();
1053 let guard = init_terminal(writer).expect("init_terminal");
1054 drop(guard);
1055 let bytes = buf.lock().unwrap();
1056 assert!(
1057 bytes
1058 .windows(b"\x1b[?2004h".len())
1059 .any(|w| w == b"\x1b[?2004h")
1060 );
1061 assert!(
1062 bytes
1063 .windows(b"\x1b[?2004l".len())
1064 .any(|w| w == b"\x1b[?2004l")
1065 );
1066 }
1067
1068 #[test]
1071 fn test_prev_parser_resize_sync_matches_fresh_parser() {
1072 let mut prev_parser = vt100::Parser::new(24, 80, 0);
1073 prev_parser.process(b"initial screen content");
1074
1075 let (new_rows, new_cols) = (40, 120);
1077 let new_formatted_content = {
1078 let mut p = vt100::Parser::new(new_rows, new_cols, 0);
1079 p.process(b"resized screen content");
1080 p.screen().contents_formatted().to_vec()
1081 };
1082
1083 prev_parser.screen_mut().set_size(new_rows, new_cols);
1085 prev_parser.process(b"\x1bc");
1086 prev_parser.process(&new_formatted_content);
1087
1088 let mut fresh_parser = vt100::Parser::new(new_rows, new_cols, 0);
1090 fresh_parser.process(&new_formatted_content);
1091
1092 assert_eq!(
1093 prev_parser.screen().contents_formatted(),
1094 fresh_parser.screen().contents_formatted(),
1095 "Reused parser state after set_size + RIS must match fresh parser"
1096 );
1097 }
1098
1099 #[test]
1100 fn render_frame_outputs_correct_cup_and_sgr() {
1101 let mut parser = vt100::Parser::new(4, 8, 0);
1102 parser.process(b"\x1b[31mhello\x1b[0m");
1103 let screen = parser.screen();
1104 let mut buf: Vec<u8> = Vec::new();
1105 let (rows, cols) = screen.size();
1106 render_frame(&mut buf, screen, rows, cols, false).unwrap();
1107 let output = String::from_utf8_lossy(&buf);
1108 assert!(output.contains("\x1b[1;1H"));
1110 assert!(output.contains("\x1b[2;1H"));
1111 assert!(output.contains("\x1b[3;1H"));
1112 assert!(output.contains("\x1b[4;1H"));
1113 assert!(output.contains("hello"));
1115 assert!(
1117 output.contains("\x1b[38;5;1m") || output.contains("\x1b[31m"),
1118 "Expected red foreground SGR in output: {output:?}"
1119 );
1120 assert!(!output.contains("\x1b\x1b"), "no double ESC sequences");
1122 }
1123
1124 #[test]
1127 fn coalesce_moved_with_moved() {
1128 assert!(is_coalescable_mouse(
1129 &MouseEventKind::Moved,
1130 &KeyModifiers::NONE,
1131 &MouseEventKind::Moved,
1132 &KeyModifiers::NONE,
1133 ));
1134 }
1135
1136 #[test]
1137 fn coalesce_drag_same_button() {
1138 assert!(is_coalescable_mouse(
1139 &MouseEventKind::Drag(MouseButton::Left),
1140 &KeyModifiers::NONE,
1141 &MouseEventKind::Drag(MouseButton::Left),
1142 &KeyModifiers::NONE,
1143 ));
1144 assert!(is_coalescable_mouse(
1145 &MouseEventKind::Drag(MouseButton::Right),
1146 &KeyModifiers {
1147 shift: true,
1148 ..KeyModifiers::NONE
1149 },
1150 &MouseEventKind::Drag(MouseButton::Right),
1151 &KeyModifiers {
1152 shift: true,
1153 ..KeyModifiers::NONE
1154 },
1155 ));
1156 }
1157
1158 #[test]
1159 fn reject_drag_different_button() {
1160 assert!(!is_coalescable_mouse(
1161 &MouseEventKind::Drag(MouseButton::Left),
1162 &KeyModifiers::NONE,
1163 &MouseEventKind::Drag(MouseButton::Right),
1164 &KeyModifiers::NONE,
1165 ));
1166 }
1167
1168 #[test]
1169 fn reject_moved_vs_drag() {
1170 assert!(!is_coalescable_mouse(
1171 &MouseEventKind::Moved,
1172 &KeyModifiers::NONE,
1173 &MouseEventKind::Drag(MouseButton::Left),
1174 &KeyModifiers::NONE,
1175 ));
1176 }
1177
1178 #[test]
1179 fn reject_different_modifiers() {
1180 assert!(!is_coalescable_mouse(
1181 &MouseEventKind::Moved,
1182 &KeyModifiers::NONE,
1183 &MouseEventKind::Moved,
1184 &KeyModifiers {
1185 shift: true,
1186 ..KeyModifiers::NONE
1187 },
1188 ));
1189 assert!(!is_coalescable_mouse(
1190 &MouseEventKind::Drag(MouseButton::Left),
1191 &KeyModifiers {
1192 control: true,
1193 ..KeyModifiers::NONE
1194 },
1195 &MouseEventKind::Drag(MouseButton::Left),
1196 &KeyModifiers::NONE,
1197 ));
1198 }
1199
1200 #[test]
1201 fn reject_discrete_events() {
1202 assert!(!is_coalescable_mouse(
1203 &MouseEventKind::Press(MouseButton::Left),
1204 &KeyModifiers::NONE,
1205 &MouseEventKind::Press(MouseButton::Left),
1206 &KeyModifiers::NONE,
1207 ));
1208 assert!(!is_coalescable_mouse(
1209 &MouseEventKind::Release(MouseButton::Left),
1210 &KeyModifiers::NONE,
1211 &MouseEventKind::Moved,
1212 &KeyModifiers::NONE,
1213 ));
1214 assert!(!is_coalescable_mouse(
1215 &MouseEventKind::Moved,
1216 &KeyModifiers::NONE,
1217 &MouseEventKind::ScrollDown,
1218 &KeyModifiers::NONE,
1219 ));
1220 assert!(!is_coalescable_mouse(
1221 &MouseEventKind::ScrollUp,
1222 &KeyModifiers::NONE,
1223 &MouseEventKind::ScrollUp,
1224 &KeyModifiers::NONE,
1225 ));
1226 }
1227
1228 fn coalesce_through(
1233 events: &[Event],
1234 kind: MouseEventKind,
1235 modifiers: KeyModifiers,
1236 ) -> Option<Event> {
1237 let (tx, rx) = crossbeam_channel::bounded::<Event>(events.len());
1238 for e in events.iter().cloned() {
1239 tx.send(e).ok();
1240 }
1241 drop(tx);
1242
1243 let mut result = Event::Mouse(MouseEvent {
1244 kind,
1245 modifiers,
1246 column: 0,
1247 row: 0,
1248 });
1249
1250 if let Event::Mouse(ref mut mouse) = result
1251 && matches!(mouse.kind, MouseEventKind::Moved | MouseEventKind::Drag(_))
1252 {
1253 while let Ok(next) = rx.try_recv() {
1254 match next {
1255 Event::Mouse(ref next_mouse)
1256 if is_coalescable_mouse(
1257 &mouse.kind,
1258 &mouse.modifiers,
1259 &next_mouse.kind,
1260 &next_mouse.modifiers,
1261 ) =>
1262 {
1263 *mouse = *next_mouse;
1264 }
1265 _other => return Some(result),
1266 }
1267 }
1268 }
1269
1270 Some(result)
1271 }
1272
1273 #[test]
1274 fn coalesce_keeps_latest_moved_position() {
1275 let events = vec![
1276 Event::Mouse(MouseEvent {
1277 kind: MouseEventKind::Moved,
1278 modifiers: KeyModifiers::NONE,
1279 column: 5,
1280 row: 5,
1281 }),
1282 Event::Mouse(MouseEvent {
1283 kind: MouseEventKind::Moved,
1284 modifiers: KeyModifiers::NONE,
1285 column: 10,
1286 row: 10,
1287 }),
1288 Event::Mouse(MouseEvent {
1289 kind: MouseEventKind::Moved,
1290 modifiers: KeyModifiers::NONE,
1291 column: 15,
1292 row: 15,
1293 }),
1294 ];
1295 let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
1296 let Event::Mouse(m) = result.unwrap() else {
1297 panic!("expected mouse")
1298 };
1299 assert_eq!((m.column, m.row), (15, 15));
1300 }
1301
1302 #[test]
1303 fn coalesce_keeps_latest_drag_position() {
1304 let events = vec![
1305 Event::Mouse(MouseEvent {
1306 kind: MouseEventKind::Drag(MouseButton::Left),
1307 modifiers: KeyModifiers::NONE,
1308 column: 1,
1309 row: 1,
1310 }),
1311 Event::Mouse(MouseEvent {
1312 kind: MouseEventKind::Drag(MouseButton::Left),
1313 modifiers: KeyModifiers::NONE,
1314 column: 2,
1315 row: 2,
1316 }),
1317 ];
1318 let result = coalesce_through(
1319 &events,
1320 MouseEventKind::Drag(MouseButton::Left),
1321 KeyModifiers::NONE,
1322 );
1323 let Event::Mouse(m) = result.unwrap() else {
1324 panic!("expected mouse")
1325 };
1326 assert_eq!((m.column, m.row), (2, 2));
1327 }
1328
1329 #[test]
1330 fn coalesce_stops_at_modifier_change() {
1331 let events = vec![Event::Mouse(MouseEvent {
1332 kind: MouseEventKind::Moved,
1333 modifiers: KeyModifiers {
1334 shift: true,
1335 ..KeyModifiers::NONE
1336 },
1337 column: 99,
1338 row: 99,
1339 })];
1340 let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
1341 let Event::Mouse(m) = result.unwrap() else {
1342 panic!("expected mouse")
1343 };
1344 assert_eq!((m.column, m.row), (0, 0));
1347 }
1348
1349 #[test]
1350 fn coalesce_stops_at_non_mouse_event() {
1351 let key = Event::Key(KeyEvent {
1352 code: KeyCode::Char('q'),
1353 kind: KeyKind::Press,
1354 modifiers: KeyModifiers::NONE,
1355 });
1356 let events = vec![key.clone()];
1357 let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
1358 let Event::Mouse(m) = result.unwrap() else {
1359 panic!("expected mouse")
1360 };
1361 assert_eq!((m.column, m.row), (0, 0));
1363 }
1364
1365 #[test]
1366 fn coalesce_stops_at_discrete_mouse_event() {
1367 let events = vec![Event::Mouse(MouseEvent {
1368 kind: MouseEventKind::Press(MouseButton::Left),
1369 modifiers: KeyModifiers::NONE,
1370 column: 10,
1371 row: 10,
1372 })];
1373 let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
1374 let Event::Mouse(m) = result.unwrap() else {
1375 panic!("expected mouse")
1376 };
1377 assert_eq!((m.column, m.row), (0, 0));
1379 }
1380}
1381
1382#[cfg(test)]
1386#[allow(clippy::type_complexity)]
1387mod snapshot_tests {
1388 use super::*;
1389
1390 fn render_and_capture(pty_bytes: &[u8], rows: u16, cols: u16, clear_display: bool) -> Vec<u8> {
1393 let rt = tokio::runtime::Builder::new_current_thread()
1394 .build()
1395 .expect("tokio rt");
1396 let (push_tx, push_rx) = crossbeam_channel::bounded(16);
1397 let input_writer: Box<dyn FnMut(&[u8]) -> io::Result<()> + Send> = Box::new(|_| Ok(()));
1398 let mut pane = RemotePane::new(
1399 0,
1400 None,
1401 rt.handle().clone(),
1402 cols,
1403 rows,
1404 push_rx,
1405 input_writer,
1406 );
1407 drop(rt); push_tx.send(pty_bytes.to_vec()).ok();
1410 pane.drain_pushes();
1411
1412 let parser = pane.shared_parser();
1413 let parser = parser.lock().unwrap();
1414 let screen = parser.screen();
1415 let (rows, cols) = screen.size();
1416 let mut out = Vec::new();
1417 render_frame(&mut out, screen, rows, cols, clear_display).unwrap();
1418 out
1419 }
1420
1421 fn escape_ansi(bytes: &[u8]) -> String {
1423 let mut out: Vec<u8> = Vec::with_capacity(bytes.len() * 4);
1424 for &b in bytes {
1425 match b {
1426 b'\x1b' => out.extend_from_slice(b"\\x1b"),
1427 b'\n' => out.extend_from_slice(b"\\n"),
1428 b'\r' => out.extend_from_slice(b"\\r"),
1429 b'\t' => out.extend_from_slice(b"\\t"),
1430 0x20..=0x7e => out.push(b),
1431 _ => {
1432 out.push(b'\\');
1433 out.push(b'x');
1434 out.extend_from_slice(&hex_byte(b));
1435 }
1436 }
1437 }
1438 unsafe { String::from_utf8_unchecked(out) }
1440 }
1441
1442 fn hex_byte(b: u8) -> [u8; 2] {
1443 #[inline]
1444 fn hex_nibble(n: u8) -> u8 {
1445 let digit = n & 0x0f;
1446 if digit < 10 {
1447 b'0' + digit
1448 } else {
1449 b'a' + digit - 10
1450 }
1451 }
1452 [hex_nibble(b >> 4), hex_nibble(b)]
1453 }
1454
1455 #[test]
1458 fn snapshot_empty_grid() {
1459 let out = render_and_capture(b"", 4, 8, false);
1460 insta::assert_snapshot!("empty_grid", escape_ansi(&out));
1461 }
1462
1463 #[test]
1464 fn snapshot_basic_text() {
1465 let out = render_and_capture(b"Hello\nWorld", 4, 8, false);
1466 insta::assert_snapshot!("basic_text", escape_ansi(&out));
1467 }
1468
1469 #[test]
1470 fn snapshot_colored_text() {
1471 let out = render_and_capture(b"\x1b[31mred\x1b[1mbold", 4, 8, false);
1472 insta::assert_snapshot!("colored_text", escape_ansi(&out));
1473 }
1474
1475 #[test]
1476 fn snapshot_normal_char_at_margin() {
1477 let out = render_and_capture(b"ABCD", 1, 4, false);
1480 insta::assert_snapshot!("normal_char_at_margin", escape_ansi(&out));
1481 }
1482
1483 #[test]
1484 fn snapshot_clear_display() {
1485 let out = render_and_capture(b"", 4, 8, true);
1486 insta::assert_snapshot!("clear_display", escape_ansi(&out));
1487 }
1488
1489 #[test]
1490 fn snapshot_hidden_cursor() {
1491 let out = render_and_capture(b"\x1b[?25l", 4, 8, false);
1492 insta::assert_snapshot!("hidden_cursor", escape_ansi(&out));
1493 }
1494
1495 #[test]
1496 fn snapshot_color_across_margin() {
1497 let out = render_and_capture(b"\x1b[41mX", 1, 4, false);
1500 insta::assert_snapshot!("color_across_margin", escape_ansi(&out));
1501 }
1502
1503 #[test]
1504 fn snapshot_multi_row_fill() {
1505 let out = render_and_capture(b"ABCDEFGHIJKL", 3, 4, false);
1508 insta::assert_snapshot!("multi_row_fill", escape_ansi(&out));
1509 }
1510
1511 #[test]
1512 fn snapshot_wide_char_margin() {
1513 let out = render_and_capture(
1516 b"B\xe3\x81\x82", 1,
1518 3,
1519 false,
1520 );
1521 insta::assert_snapshot!("wide_char_margin", escape_ansi(&out));
1522 }
1523
1524 #[test]
1525 fn snapshot_wide_char_middle() {
1526 let out = render_and_capture(
1530 b"A\xe3\x81\x82\xe3\x81\x83", 1,
1532 5,
1533 false,
1534 );
1535 insta::assert_snapshot!("wide_char_middle", escape_ansi(&out));
1536 }
1537}