use crate::terminal::{clamp_dims, TerminalScreen, MAXIMUM_CLIPBOARD_SIZE, MAX_TITLE_LEN};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Progress {
pub state: u8,
pub percent: u8,
}
pub const UNHANDLED_OSC_RING: usize = 16;
pub const UNHANDLED_OSC_MAX_LEN: usize = 256;
fn parse_progress(params: &[&[u8]]) -> Option<Progress> {
if params.len() < 3 || params.first().copied() != Some(b"9".as_slice()) {
return None;
}
if params.get(1).copied() != Some(b"4".as_slice()) {
return None;
}
let num = |i: usize| -> Option<u8> {
let p = params.get(i).copied()?;
let s = std::str::from_utf8(p).ok()?;
s.parse::<u8>().ok()
};
let state = num(2)?;
if state > 4 {
return None;
}
let percent = if state == 0 { 0 } else { num(3)? };
if percent > 100 {
return None;
}
Some(Progress { state, percent })
}
fn title_from(bytes: &[u8]) -> String {
String::from_utf8_lossy(bytes)
.chars()
.take(MAX_TITLE_LEN)
.collect()
}
#[derive(Default)]
struct Callbacks {
title: String,
icon: String,
clipboard: String,
bell_count: u64,
host_replies: Vec<u8>,
progress: Option<Progress>,
unhandled_oscs: std::collections::VecDeque<Vec<u8>>,
}
impl vt100::Callbacks for Callbacks {
fn set_window_title(&mut self, _: &mut vt100::Screen, t: &[u8]) {
self.title = title_from(t);
}
fn set_window_icon_name(&mut self, _: &mut vt100::Screen, n: &[u8]) {
self.icon = title_from(n);
}
fn audible_bell(&mut self, _: &mut vt100::Screen) {
self.bell_count += 1;
}
fn copy_to_clipboard(&mut self, _: &mut vt100::Screen, _ty: &[u8], data: &[u8]) {
if data.len() <= MAXIMUM_CLIPBOARD_SIZE {
self.clipboard = String::from_utf8_lossy(data).into_owned();
}
}
fn unhandled_osc(&mut self, _: &mut vt100::Screen, params: &[&[u8]]) {
match parse_progress(params) {
Some(p) if p.state == 0 => self.progress = None,
Some(p) => self.progress = Some(p),
None => {}
}
let mut raw = params.join(&b';');
raw.truncate(UNHANDLED_OSC_MAX_LEN);
if self.unhandled_oscs.len() >= UNHANDLED_OSC_RING {
self.unhandled_oscs.pop_front();
}
self.unhandled_oscs.push_back(raw);
}
fn unhandled_csi(
&mut self,
screen: &mut vt100::Screen,
i1: Option<u8>,
i2: Option<u8>,
params: &[&[u16]],
c: char,
) {
let p0 = params.first().and_then(|p| p.first()).copied().unwrap_or(0);
match (i1, i2, c) {
(None, _, 'n') => match p0 {
6 => {
let (row, col) = screen.cursor_position();
self.host_replies
.extend_from_slice(format!("\x1b[{};{}R", row + 1, col + 1).as_bytes());
}
5 => self.host_replies.extend_from_slice(b"\x1b[0n"), _ => {}
},
(Some(b'?'), _, 'n') if p0 == 6 => {
let (row, col) = screen.cursor_position();
self.host_replies
.extend_from_slice(format!("\x1b[?{};{}R", row + 1, col + 1).as_bytes());
}
(None, _, 'c') => self.host_replies.extend_from_slice(b"\x1b[?62;1;6c"),
(Some(b'>'), _, 'c') => self.host_replies.extend_from_slice(b"\x1b[>1;10;0c"),
(Some(b'?'), Some(b'$'), 'p') => {
let status = match p0 {
2004 => {
if screen.bracketed_paste() {
1
} else {
2
}
}
_ => 0u16,
};
self.host_replies
.extend_from_slice(format!("\x1b[?{p0};{status}$y").as_bytes());
}
_ => {}
}
}
}
pub struct ServerTerminal {
parser: vt100::Parser<Callbacks>,
exit_code: Option<u32>,
}
impl ServerTerminal {
pub fn new(rows: u16, cols: u16, scrollback: usize) -> Self {
Self {
parser: vt100::Parser::new_with_callbacks(rows, cols, scrollback, Callbacks::default()),
exit_code: None,
}
}
pub fn set_exit_code(&mut self, code: u32) {
self.exit_code = Some(code);
}
pub fn process(&mut self, bytes: &[u8]) {
if !crate::terminal::process_contained(&mut self.parser, bytes) {
tracing::error!(
"vt100 panicked on shell output; dropped the chunk (backtrace in logs)"
);
}
}
pub fn take_host_replies(&mut self) -> Vec<u8> {
std::mem::take(&mut self.parser.callbacks_mut().host_replies)
}
pub fn progress(&self) -> Option<Progress> {
self.parser.callbacks().progress
}
pub fn take_unhandled_oscs(&mut self) -> Vec<Vec<u8>> {
std::mem::take(&mut self.parser.callbacks_mut().unhandled_oscs).into()
}
pub fn resize(&mut self, rows: u16, cols: u16) {
let (rows, cols) = clamp_dims(rows, cols);
self.parser.screen_mut().set_size(rows, cols);
}
#[cfg(test)]
pub fn size(&self) -> (u16, u16) {
self.parser.screen().size()
}
#[cfg(test)]
pub fn title(&self) -> &str {
&self.parser.callbacks().title
}
#[cfg(test)]
pub fn icon_name(&self) -> &str {
&self.parser.callbacks().icon
}
#[cfg(test)]
pub fn bell_count(&self) -> u64 {
self.parser.callbacks().bell_count
}
pub fn application_cursor(&self) -> bool {
self.parser.screen().application_cursor()
}
pub fn snapshot(&self) -> TerminalScreen {
TerminalScreen {
screen: self.parser.screen().clone(),
echo_ack: 0,
title: self.parser.callbacks().title.clone(),
icon: self.parser.callbacks().icon.clone(),
clipboard: self.parser.callbacks().clipboard.clone(),
bell_count: self.parser.callbacks().bell_count,
exit_code: self.exit_code,
parser: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn answers_cursor_position_report() {
let mut t = ServerTerminal::new(24, 80, 0);
t.process(b"\x1b[5;3H"); t.process(b"\x1b[6n"); assert_eq!(t.take_host_replies(), b"\x1b[5;3R"); assert!(t.take_host_replies().is_empty());
}
#[test]
fn answers_device_attributes() {
let mut t = ServerTerminal::new(24, 80, 0);
t.process(b"\x1b[c"); assert_eq!(t.take_host_replies(), b"\x1b[?62;1;6c");
t.process(b"\x1b[>c"); assert_eq!(t.take_host_replies(), b"\x1b[>1;10;0c");
}
#[test]
fn title_icon_bell_clipboard_captured() {
let mut t = ServerTerminal::new(24, 80, 0);
t.process(b"\x1b]2;my-title\x07\x07\x1b]1;my-icon\x07\x1b]52;c;aGVsbG8=\x07");
assert_eq!(t.title(), "my-title");
assert_eq!(t.icon_name(), "my-icon");
assert_eq!(t.bell_count(), 1);
let snap = t.snapshot();
assert_eq!(snap.title(), "my-title");
assert_eq!(snap.icon(), "my-icon", "snapshot carries the icon name");
assert_eq!(
snap.clipboard(),
"aGVsbG8=",
"snapshot carries the OSC-52 clipboard"
);
assert_eq!(snap.bell_count(), 1, "snapshot carries the bell count");
}
#[test]
fn server_resize_clamps_oom_and_zero() {
use crate::terminal::{MAX_DIM, MIN_DIM};
let mut t = ServerTerminal::new(24, 80, 0);
t.resize(65000, 65000); assert_eq!(
t.size(),
(MAX_DIM, MAX_DIM),
"giant resize clamped to MAX_DIM"
);
t.resize(0, 0); assert_eq!(
t.size(),
(MIN_DIM, MIN_DIM),
"zero resize clamped to MIN_DIM"
);
t.process("AAAA日本🦀\r\nBBBB\r\n".repeat(8).as_bytes());
let _ = t.snapshot();
t.resize(40, 120);
assert_eq!(t.size(), (40, 120));
let _ = t.snapshot();
}
#[test]
fn oversized_clipboard_is_dropped() {
let mut t = ServerTerminal::new(24, 80, 0);
let big = "A".repeat(MAXIMUM_CLIPBOARD_SIZE + 1);
t.process(format!("\x1b]52;c;{big}\x07").as_bytes());
assert_eq!(t.snapshot().clipboard(), "", "oversized clipboard dropped");
}
#[test]
fn oversized_title_is_clamped() {
let mut t = ServerTerminal::new(24, 80, 0);
let huge = "x".repeat(MAX_TITLE_LEN + 500);
t.process(format!("\x1b]2;{huge}\x07").as_bytes());
assert_eq!(
t.title().chars().count(),
MAX_TITLE_LEN,
"title clamped to the cap"
);
t.process(b"\x1b]2;short\x07");
assert_eq!(t.title(), "short");
}
#[test]
fn osc_9_4_progress_is_parsed_and_cleared() {
let mut t = ServerTerminal::new(24, 80, 0);
t.process(b"\x1b]9;4;1;50\x1b\\");
assert_eq!(
t.progress(),
Some(Progress {
state: 1,
percent: 50
})
);
t.process(b"\x1b]9;4;3;0\x07"); assert_eq!(
t.progress(),
Some(Progress {
state: 3,
percent: 0
})
);
t.process(b"\x1b]9;4;0\x1b\\"); assert_eq!(t.progress(), None);
}
#[test]
fn malformed_osc_9_4_yields_none_and_never_panics() {
for bad in [
&b"\x1b]9;4;1;150\x1b\\"[..], b"\x1b]9;4;x\x1b\\", b"\x1b]9;4\x1b\\", b"\x1b]9;4;7;10\x1b\\", b"\x1b]\x1b\\", ] {
let mut t = ServerTerminal::new(24, 80, 0);
t.process(bad);
assert_eq!(t.progress(), None, "{bad:?}");
}
let mut t = ServerTerminal::new(24, 80, 0);
let mut big = b"\x1b]9;4;1;".to_vec();
big.extend(std::iter::repeat_n(b'9', 4096));
big.extend_from_slice(b"\x1b\\");
t.process(&big);
assert_eq!(t.progress(), None);
let ring = t.take_unhandled_oscs();
assert_eq!(ring.len(), 1);
assert!(ring[0].len() <= UNHANDLED_OSC_MAX_LEN);
}
#[test]
fn unhandled_osc_ring_keeps_the_last_sixteen_and_drains() {
let mut t = ServerTerminal::new(24, 80, 0);
for i in 0..20u8 {
t.process(format!("\x1b]777;item{i}\x1b\\").as_bytes());
}
let ring = t.take_unhandled_oscs();
assert_eq!(ring.len(), UNHANDLED_OSC_RING);
assert_eq!(ring[0], b"777;item4".to_vec(), "oldest retained is the 5th");
assert_eq!(ring[15], b"777;item19".to_vec());
assert!(t.take_unhandled_oscs().is_empty(), "take drains");
t.process(b"\x1b]2;a title\x1b\\");
assert!(t.take_unhandled_oscs().is_empty());
assert_eq!(t.title(), "a title");
}
}