From c2b3936d1a187efc20b5402bf16f8cfe8519e52c Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Thu, 24 Apr 2025 11:00:14 +0200 Subject: [PATCH] Add asciicast v3 support, record and convert to v3 by default --- src/asciicast.rs | 66 +++--- src/asciicast/v1.rs | 14 +- src/asciicast/v2.rs | 31 +-- src/asciicast/v3.rs | 461 +++++++++++++++++++++++++++++++++++++++ src/cli.rs | 9 +- src/cmd/cat.rs | 2 +- src/cmd/convert.rs | 9 +- src/cmd/session.rs | 44 +++- src/encoder/asciicast.rs | 43 +++- src/encoder/mod.rs | 2 +- src/encoder/raw.rs | 6 +- src/encoder/txt.rs | 6 +- src/file_writer.rs | 9 +- 13 files changed, 616 insertions(+), 86 deletions(-) create mode 100644 src/asciicast/v3.rs diff --git a/src/asciicast.rs b/src/asciicast.rs index b5e494f..ff3e1e3 100644 --- a/src/asciicast.rs +++ b/src/asciicast.rs @@ -1,6 +1,7 @@ mod util; mod v1; mod v2; +mod v3; use std::collections::HashMap; use std::fs; @@ -10,7 +11,8 @@ use std::path::Path; use anyhow::{anyhow, Result}; use crate::tty::TtyTheme; -pub use v2::Encoder; +pub use v2::V2Encoder; +pub use v3::V3Encoder; pub struct Asciicast<'a> { pub header: Header, @@ -18,14 +20,16 @@ pub struct Asciicast<'a> { } pub struct Header { - pub cols: u16, - pub rows: u16, + pub term_cols: u16, + pub term_rows: u16, + pub term_type: Option, + pub term_version: Option, + pub term_theme: Option, pub timestamp: Option, pub idle_time_limit: Option, pub command: Option, pub title: Option, pub env: Option>, - pub theme: Option, } pub struct Event { @@ -44,14 +48,16 @@ pub enum EventData { impl Default for Header { fn default() -> Self { Self { - cols: 80, - rows: 24, + term_cols: 80, + term_rows: 24, + term_type: None, + term_version: None, + term_theme: None, timestamp: None, idle_time_limit: None, command: None, title: None, env: None, - theme: None, } } } @@ -68,14 +74,16 @@ pub fn open<'a, R: BufRead + 'a>(reader: R) -> Result> { let mut lines = reader.lines(); let first_line = lines.next().ok_or(anyhow!("empty file"))??; - if let Ok(parser) = v2::open(&first_line) { + if let Ok(parser) = v3::open(&first_line) { + Ok(parser.parse(lines)) + } else if let Ok(parser) = v2::open(&first_line) { Ok(parser.parse(lines)) } else { let json = std::iter::once(Ok(first_line)) .chain(lines) .collect::>()?; - v1::load(json) + v1::load(json).map_err(|_| anyhow!("not a v1, v2, v3 asciicast file")) } } @@ -155,7 +163,7 @@ pub fn accelerate( #[cfg(test)] mod tests { - use super::{Asciicast, Encoder, Event, EventData, Header}; + use super::{Asciicast, Event, EventData, Header, V2Encoder}; use crate::tty::TtyTheme; use anyhow::Result; use rgb::RGB8; @@ -168,8 +176,8 @@ mod tests { let events = events.collect::>>().unwrap(); - assert_eq!((header.cols, header.rows), (100, 50)); - assert!(header.theme.is_none()); + assert_eq!((header.term_cols, header.term_rows), (100, 50)); + assert!(header.term_theme.is_none()); assert_eq!(events[0].time, 1230000); assert!(matches!(events[0].data, EventData::Output(ref s) if s == "hello")); @@ -180,7 +188,7 @@ mod tests { let Asciicast { header, events } = super::open_from_path("tests/casts/full.json").unwrap(); let events = events.collect::>>().unwrap(); - assert_eq!((header.cols, header.rows), (100, 50)); + assert_eq!((header.term_cols, header.term_rows), (100, 50)); assert_eq!(events[0].time, 1); assert!(matches!(events[0].data, EventData::Output(ref s) if s == "ż")); @@ -198,8 +206,8 @@ mod tests { super::open_from_path("tests/casts/minimal.cast").unwrap(); let events = events.collect::>>().unwrap(); - assert_eq!((header.cols, header.rows), (100, 50)); - assert!(header.theme.is_none()); + assert_eq!((header.term_cols, header.term_rows), (100, 50)); + assert!(header.term_theme.is_none()); assert_eq!(events[0].time, 1230000); assert!(matches!(events[0].data, EventData::Output(ref s) if s == "hello")); @@ -209,9 +217,9 @@ mod tests { fn open_v2_full() { let Asciicast { header, events } = super::open_from_path("tests/casts/full.cast").unwrap(); let events = events.take(5).collect::>>().unwrap(); - let theme = header.theme.unwrap(); + let theme = header.term_theme.unwrap(); - assert_eq!((header.cols, header.rows), (100, 50)); + assert_eq!((header.term_cols, header.term_rows), (100, 50)); assert_eq!(theme.fg, RGB8::new(0, 0, 0)); assert_eq!(theme.bg, RGB8::new(0xff, 0xff, 0xff)); assert_eq!(theme.palette[0], RGB8::new(0x24, 0x1f, 0x31)); @@ -237,23 +245,12 @@ mod tests { #[test] fn encoder() { let mut data = Vec::new(); - - let header = Header { - cols: 80, - rows: 24, - timestamp: None, - idle_time_limit: None, - command: None, - title: None, - env: Default::default(), - theme: None, - }; - - let mut enc = Encoder::new(0); + let header = Header::default(); + let mut enc = V2Encoder::new(0); data.extend(enc.header(&header)); data.extend(enc.event(&Event::output(1000000, "hello\r\n".to_owned()))); - let mut enc = Encoder::new(1000001); + let mut enc = V2Encoder::new(1000001); data.extend(enc.event(&Event::output(1000001, "world".to_owned()))); data.extend(enc.event(&Event::input(2000002, " ".to_owned()))); data.extend(enc.event(&Event::resize(3000003, (100, 40)))); @@ -284,7 +281,7 @@ mod tests { #[test] fn header_encoding() { - let mut enc = Encoder::new(0); + let mut enc = V2Encoder::new(0); let mut env = HashMap::new(); env.insert("SHELL".to_owned(), "/usr/bin/fish".to_owned()); env.insert("TERM".to_owned(), "xterm256-color".to_owned()); @@ -313,14 +310,13 @@ mod tests { }; let header = Header { - cols: 80, - rows: 24, timestamp: Some(1704719152), idle_time_limit: Some(1.5), command: Some("/bin/bash".to_owned()), title: Some("Demo".to_owned()), env: Some(env), - theme: Some(theme), + term_theme: Some(theme), + ..Default::default() }; let data = enc.header(&header); diff --git a/src/asciicast/v1.rs b/src/asciicast/v1.rs index 38bf1a2..37f1c8d 100644 --- a/src/asciicast/v1.rs +++ b/src/asciicast/v1.rs @@ -31,15 +31,23 @@ pub fn load(json: String) -> Result> { bail!("unsupported asciicast version") } + let term_type = asciicast + .env + .as_ref() + .and_then(|env| env.get("TERM")) + .cloned(); + let header = Header { - cols: asciicast.width, - rows: asciicast.height, + term_cols: asciicast.width, + term_rows: asciicast.height, + term_type, + term_version: None, + term_theme: None, timestamp: None, idle_time_limit: None, command: asciicast.command.clone(), title: asciicast.title.clone(), env: asciicast.env.clone(), - theme: None, }; let events = Box::new( diff --git a/src/asciicast/v2.rs b/src/asciicast/v2.rs index 0fcca28..909fc35 100644 --- a/src/asciicast/v2.rs +++ b/src/asciicast/v2.rs @@ -61,26 +61,28 @@ pub fn open(header_line: &str) -> Result { let header = serde_json::from_str::(header_line)?; if header.version != 2 { - bail!("unsupported asciicast version") + bail!("not an asciicast v2 file") } Ok(Parser(header)) } impl Parser { - pub fn parse<'a, I: Iterator> + 'a>( - &self, - lines: I, - ) -> Asciicast<'a> { + pub fn parse<'a, I: Iterator> + 'a>(self, lines: I) -> Asciicast<'a> { + let term_type = self.0.env.as_ref().and_then(|env| env.get("TERM").cloned()); + let term_theme = self.0.theme.as_ref().map(|t| t.into()); + let header = Header { - cols: self.0.width, - rows: self.0.height, + term_cols: self.0.width, + term_rows: self.0.height, + term_type, + term_version: None, + term_theme, timestamp: self.0.timestamp, idle_time_limit: self.0.idle_time_limit, command: self.0.command.clone(), title: self.0.title.clone(), env: self.0.env.clone(), - theme: self.0.theme.as_ref().map(|t| t.into()), }; let events = Box::new(lines.filter_map(parse_line)); @@ -104,7 +106,8 @@ fn parse_line(line: io::Result) -> Option> { } fn parse_event(line: String) -> Result { - let event = serde_json::from_str::(&line)?; + let event = serde_json::from_str::(&line) + .map_err(|e| anyhow!("asciicast v2 parse error: {e}"))?; let data = match event.code { V2EventCode::Output => EventData::Output(event.data), @@ -157,11 +160,11 @@ where } } -pub struct Encoder { +pub struct V2Encoder { time_offset: u64, } -impl Encoder { +impl V2Encoder { pub fn new(time_offset: u64) -> Self { Self { time_offset } } @@ -356,14 +359,14 @@ impl From<&Header> for V2Header { fn from(header: &Header) -> Self { V2Header { version: 2, - width: header.cols, - height: header.rows, + width: header.term_cols, + height: header.term_rows, timestamp: header.timestamp, idle_time_limit: header.idle_time_limit, command: header.command.clone(), title: header.title.clone(), env: header.env.clone(), - theme: header.theme.as_ref().map(|t| t.into()), + theme: header.term_theme.as_ref().map(|t| t.into()), } } } diff --git a/src/asciicast/v3.rs b/src/asciicast/v3.rs new file mode 100644 index 0000000..86340e2 --- /dev/null +++ b/src/asciicast/v3.rs @@ -0,0 +1,461 @@ +use std::collections::HashMap; +use std::fmt; +use std::io; + +use anyhow::{anyhow, bail, Result}; +use serde::{Deserialize, Deserializer, Serialize}; + +use super::{util, Asciicast, Event, EventData, Header}; +use crate::tty::TtyTheme; + +#[derive(Deserialize)] +struct V3Header { + version: u8, + term: V3Term, + timestamp: Option, + idle_time_limit: Option, + command: Option, + title: Option, + env: Option>, +} + +#[derive(Deserialize)] +struct V3Term { + cols: u16, + rows: u16, + #[serde(rename = "type")] + type_: Option, + version: Option, + theme: Option, +} + +#[derive(Deserialize, Serialize, Clone)] +struct V3Theme { + #[serde(deserialize_with = "deserialize_color")] + fg: RGB8, + #[serde(deserialize_with = "deserialize_color")] + bg: RGB8, + #[serde(deserialize_with = "deserialize_palette")] + palette: V3Palette, +} + +#[derive(Clone)] +struct RGB8(rgb::RGB8); + +#[derive(Clone)] +struct V3Palette(Vec); + +#[derive(Debug, Deserialize)] +struct V3Event { + #[serde(deserialize_with = "util::deserialize_time")] + time: u64, + #[serde(deserialize_with = "deserialize_code")] + code: V3EventCode, + data: String, +} + +#[derive(PartialEq, Debug)] +enum V3EventCode { + Output, + Input, + Resize, + Marker, + Other(char), +} + +pub struct Parser { + header: V3Header, + prev_time: u64, +} + +pub fn open(header_line: &str) -> Result { + let header = serde_json::from_str::(header_line)?; + + if header.version != 3 { + bail!("not an asciicast v3 file") + } + + Ok(Parser { + header, + prev_time: 0, + }) +} + +impl Parser { + pub fn parse<'a, I: Iterator> + 'a>( + mut self, + lines: I, + ) -> Asciicast<'a> { + let term_theme = self.header.term.theme.as_ref().map(|t| t.into()); + + let header = Header { + term_cols: self.header.term.cols, + term_rows: self.header.term.rows, + term_type: self.header.term.type_.clone(), + term_version: self.header.term.version.clone(), + term_theme, + timestamp: self.header.timestamp, + idle_time_limit: self.header.idle_time_limit, + command: self.header.command.clone(), + title: self.header.title.clone(), + env: self.header.env.clone(), + }; + + let events = Box::new(lines.filter_map(move |line| self.parse_line(line))); + + Asciicast { header, events } + } + + fn parse_line(&mut self, line: io::Result) -> Option> { + match line { + Ok(line) => { + if line.is_empty() || line.starts_with("#") { + None + } else { + Some(self.parse_event(line)) + } + } + + Err(e) => Some(Err(e.into())), + } + } + + fn parse_event(&mut self, line: String) -> Result { + let event = serde_json::from_str::(&line) + .map_err(|e| anyhow!("asciicast v3 parse error: {e}"))?; + + let data = match event.code { + V3EventCode::Output => EventData::Output(event.data), + V3EventCode::Input => EventData::Input(event.data), + + V3EventCode::Resize => match event.data.split_once('x') { + Some((cols, rows)) => { + let cols: u16 = cols + .parse() + .map_err(|e| anyhow!("invalid cols value in resize event: {e}"))?; + + let rows: u16 = rows + .parse() + .map_err(|e| anyhow!("invalid rows value in resize event: {e}"))?; + + EventData::Resize(cols, rows) + } + + None => { + bail!("invalid size value in resize event"); + } + }, + + V3EventCode::Marker => EventData::Marker(event.data), + V3EventCode::Other(c) => EventData::Other(c, event.data), + }; + + let time = self.prev_time + event.time; + self.prev_time = time; + + Ok(Event { time, data }) + } +} + +fn deserialize_code<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + use serde::de::Error; + use V3EventCode::*; + + let value: &str = Deserialize::deserialize(deserializer)?; + + match value { + "o" => Ok(Output), + "i" => Ok(Input), + "r" => Ok(Resize), + "m" => Ok(Marker), + "" => Err(Error::custom("missing event code")), + s => Ok(Other(s.chars().next().unwrap())), + } +} + +pub struct V3Encoder { + prev_time: u64, +} + +impl V3Encoder { + pub fn new() -> Self { + Self { prev_time: 0 } + } + + pub fn header(&mut self, header: &Header) -> Vec { + let header: V3Header = header.into(); + let mut data = serde_json::to_string(&header).unwrap().into_bytes(); + data.push(b'\n'); + + data + } + + pub fn event(&mut self, event: &Event) -> Vec { + let mut data = self.serialize_event(event).unwrap().into_bytes(); + data.push(b'\n'); + + data + } + + fn serialize_event(&mut self, event: &Event) -> Result { + use EventData::*; + + let (code, data) = match &event.data { + Output(data) => ('o', serde_json::to_string(data)?), + Input(data) => ('i', serde_json::to_string(data)?), + Resize(cols, rows) => ('r', serde_json::to_string(&format!("{cols}x{rows}"))?), + Marker(data) => ('m', serde_json::to_string(data)?), + Other(code, data) => (*code, serde_json::to_string(data)?), + }; + + let time = event.time - self.prev_time; + self.prev_time = event.time; + + Ok(format!( + "[{}, {}, {}]", + format_time(time), + serde_json::to_string(&code)?, + data, + )) + } +} + +fn format_time(time: u64) -> String { + let mut formatted_time = format!("{}.{:0>6}", time / 1_000_000, time % 1_000_000); + let dot_idx = formatted_time.find('.').unwrap(); + + for idx in (dot_idx + 2..=formatted_time.len() - 1).rev() { + if formatted_time.as_bytes()[idx] != b'0' { + break; + } + + formatted_time.truncate(idx); + } + + formatted_time +} + +impl serde::Serialize for V3Header { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + use serde::ser::SerializeMap; + + let mut len = 2; + + if self.timestamp.is_some() { + len += 1; + } + + if self.idle_time_limit.is_some() { + len += 1; + } + + if self.command.is_some() { + len += 1; + } + + if self.title.is_some() { + len += 1; + } + + if self.env.as_ref().is_some_and(|env| !env.is_empty()) { + len += 1; + } + + let mut map = serializer.serialize_map(Some(len))?; + map.serialize_entry("version", &3)?; + map.serialize_entry("term", &self.term)?; + + if let Some(timestamp) = self.timestamp { + map.serialize_entry("timestamp", ×tamp)?; + } + + if let Some(limit) = self.idle_time_limit { + map.serialize_entry("idle_time_limit", &limit)?; + } + + if let Some(command) = &self.command { + map.serialize_entry("command", &command)?; + } + + if let Some(title) = &self.title { + map.serialize_entry("title", &title)?; + } + + if let Some(env) = &self.env { + if !env.is_empty() { + map.serialize_entry("env", &env)?; + } + } + map.end() + } +} + +impl serde::Serialize for V3Term { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + use serde::ser::SerializeMap; + + let mut len = 2; + + if self.type_.is_some() { + len += 1; + } + + if self.version.is_some() { + len += 1; + } + + if self.theme.is_some() { + len += 1; + } + + let mut map = serializer.serialize_map(Some(len))?; + map.serialize_entry("cols", &self.cols)?; + map.serialize_entry("rows", &self.rows)?; + + if let Some(type_) = &self.type_ { + map.serialize_entry("type", &type_)?; + } + + if let Some(version) = &self.version { + map.serialize_entry("version", &version)?; + } + + if let Some(theme) = &self.theme { + map.serialize_entry("theme", &theme)?; + } + + map.end() + } +} + +fn deserialize_color<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let value: &str = Deserialize::deserialize(deserializer)?; + parse_hex_color(value).ok_or(serde::de::Error::custom("invalid hex triplet")) +} + +fn parse_hex_color(rgb: &str) -> Option { + if rgb.len() != 7 { + return None; + } + + let r = u8::from_str_radix(&rgb[1..3], 16).ok()?; + let g = u8::from_str_radix(&rgb[3..5], 16).ok()?; + let b = u8::from_str_radix(&rgb[5..7], 16).ok()?; + + Some(RGB8(rgb::RGB8::new(r, g, b))) +} + +fn deserialize_palette<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let value: &str = Deserialize::deserialize(deserializer)?; + let mut colors: Vec = value.split(':').filter_map(parse_hex_color).collect(); + let len = colors.len(); + + if len == 8 { + colors.extend_from_within(..); + } else if len != 16 { + return Err(serde::de::Error::custom("expected 8 or 16 hex triplets")); + } + + Ok(V3Palette(colors)) +} + +impl serde::Serialize for RGB8 { + fn serialize(&self, serializer: S) -> std::prelude::v1::Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +impl fmt::Display for RGB8 { + fn fmt(&self, f: &mut std::fmt::Formatter) -> fmt::Result { + write!(f, "#{:0>2x}{:0>2x}{:0>2x}", self.0.r, self.0.g, self.0.b) + } +} + +impl serde::Serialize for V3Palette { + fn serialize(&self, serializer: S) -> std::prelude::v1::Result + where + S: serde::Serializer, + { + let palette = self + .0 + .iter() + .map(|c| c.to_string()) + .collect::>() + .join(":"); + + serializer.serialize_str(&palette) + } +} + +impl From<&Header> for V3Header { + fn from(header: &Header) -> Self { + V3Header { + version: 3, + term: V3Term { + cols: header.term_cols, + rows: header.term_rows, + type_: header.term_type.clone(), + version: header.term_version.clone(), + theme: header.term_theme.as_ref().map(|t| t.into()), + }, + timestamp: header.timestamp, + idle_time_limit: header.idle_time_limit, + command: header.command.clone(), + title: header.title.clone(), + env: header.env.clone(), + } + } +} + +impl From<&TtyTheme> for V3Theme { + fn from(theme: &TtyTheme) -> Self { + let palette = theme.palette.iter().copied().map(RGB8).collect(); + + V3Theme { + fg: RGB8(theme.fg), + bg: RGB8(theme.bg), + palette: V3Palette(palette), + } + } +} + +impl From<&V3Theme> for TtyTheme { + fn from(theme: &V3Theme) -> Self { + let palette = theme.palette.0.iter().map(|c| c.0).collect(); + + TtyTheme { + fg: theme.fg.0, + bg: theme.bg.0, + palette, + } + } +} + +#[cfg(test)] +mod tests { + #[test] + fn format_time() { + assert_eq!(super::format_time(0), "0.0"); + assert_eq!(super::format_time(1000001), "1.000001"); + assert_eq!(super::format_time(12300000), "12.3"); + assert_eq!(super::format_time(12000003), "12.000003"); + } +} diff --git a/src/cli.rs b/src/cli.rs index 2ea1d2f..0f96290 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -62,7 +62,7 @@ pub struct Record { #[arg(short, long)] pub append: bool, - /// Recording file format [default: asciicast] + /// Recording file format [default: asciicast-v3] #[arg(short, long, value_enum)] pub format: Option, @@ -179,7 +179,7 @@ pub struct Session { #[arg(short, long)] pub append: bool, - /// Recording file format [default: asciicast] + /// Recording file format [default: asciicast-v3] #[arg(short, long, value_enum)] pub format: Option, @@ -241,7 +241,7 @@ pub struct Convert { pub output_filename: String, - /// Output file format [default: asciicast] + /// Output file format [default: asciicast-v3] #[arg(short, long, value_enum)] pub format: Option, @@ -261,7 +261,8 @@ pub struct Auth {} #[derive(Clone, Copy, Debug, PartialEq, ValueEnum)] pub enum Format { - Asciicast, + AsciicastV3, + AsciicastV2, Raw, Txt, } diff --git a/src/cmd/cat.rs b/src/cmd/cat.rs index c3d6416..de4e886 100644 --- a/src/cmd/cat.rs +++ b/src/cmd/cat.rs @@ -9,7 +9,7 @@ use crate::config::Config; impl cli::Cat { pub fn run(self, _config: &Config) -> Result<()> { - let mut encoder = asciicast::Encoder::new(0); + let mut encoder = asciicast::V2Encoder::new(0); let mut stdout = io::stdout(); let mut time_offset: u64 = 0; let mut first = true; diff --git a/src/cmd/convert.rs b/src/cmd/convert.rs index eb0a827..9d19f70 100644 --- a/src/cmd/convert.rs +++ b/src/cmd/convert.rs @@ -6,7 +6,9 @@ use anyhow::{bail, Result}; use crate::asciicast; use crate::cli::{self, Format}; use crate::config::Config; -use crate::encoder::{self, AsciicastEncoder, EncoderExt, RawEncoder, TextEncoder}; +use crate::encoder::{ + self, AsciicastV2Encoder, AsciicastV3Encoder, EncoderExt, RawEncoder, TextEncoder, +}; use crate::util; impl cli::Convert { @@ -24,12 +26,13 @@ impl cli::Convert { if self.output_filename.to_lowercase().ends_with(".txt") { Format::Txt } else { - Format::Asciicast + Format::AsciicastV3 } }); match format { - Format::Asciicast => Box::new(AsciicastEncoder::new(false, 0)), + Format::AsciicastV3 => Box::new(AsciicastV3Encoder::new(false)), + Format::AsciicastV2 => Box::new(AsciicastV2Encoder::new(false, 0)), Format::Raw => Box::new(RawEncoder::new(false)), Format::Txt => Box::new(TextEncoder::new()), } diff --git a/src/cmd/session.rs b/src/cmd/session.rs index 65638ec..a020bc6 100644 --- a/src/cmd/session.rs +++ b/src/cmd/session.rs @@ -21,7 +21,7 @@ use crate::api; use crate::asciicast; use crate::cli::{self, Format, RelayTarget}; use crate::config::{self, Config}; -use crate::encoder::{AsciicastEncoder, RawEncoder, TextEncoder}; +use crate::encoder::{AsciicastV2Encoder, AsciicastV3Encoder, RawEncoder, TextEncoder}; use crate::file_writer::{FileWriterStarter, Metadata}; use crate::forwarder; use crate::locale; @@ -43,6 +43,7 @@ impl cli::Session { let keys = get_key_bindings(cmd_config)?; let notifier = notifier::threaded(get_notifier(config)); let record_input = self.input || cmd_config.input; + let term_type = self.get_term_type(); let term_version = self.get_term_version()?; let env = capture_env(self.env.clone(), cmd_config); @@ -58,6 +59,7 @@ impl cli::Session { self.get_file_writer( path, cmd_config, + term_type.clone(), term_version.clone(), &env, notifier.clone(), @@ -75,7 +77,7 @@ impl cli::Session { let mut relay = self .relay .take() - .map(|target| get_relay(target, config, term_version, &env)) + .map(|target| get_relay(target, config, term_type, term_version, &env)) .transpose()?; let relay_id = relay.as_ref().map(|r| r.id()); @@ -225,6 +227,7 @@ impl cli::Session { &self, path: &str, config: &config::Session, + term_type: Option, term_version: Option, env: &HashMap, notifier: N, @@ -233,7 +236,7 @@ impl cli::Session { if path.to_lowercase().ends_with(".txt") { Format::Txt } else { - Format::Asciicast + Format::AsciicastV3 } }); @@ -264,19 +267,31 @@ impl cli::Session { .truncate(overwrite) .open(path)?; - let time_offset = if append && format == Format::Asciicast { + let time_offset = if append && format == Format::AsciicastV2 { asciicast::get_duration(path)? } else { 0 }; - let metadata = self.build_asciicast_metadata(term_version, env, config); + let metadata = self.build_asciicast_metadata(term_type, term_version, env, config); let notifier = Box::new(notifier); let writer = match format { - Format::Asciicast => { + Format::AsciicastV3 => { let writer = Box::new(LineWriter::new(file)); - let encoder = Box::new(AsciicastEncoder::new(append, time_offset)); + let encoder = Box::new(AsciicastV3Encoder::new(append)); + + FileWriterStarter { + writer, + encoder, + metadata, + notifier, + } + } + + Format::AsciicastV2 => { + let writer = Box::new(LineWriter::new(file)); + let encoder = Box::new(AsciicastV2Encoder::new(append, time_offset)); FileWriterStarter { writer, @@ -314,6 +329,10 @@ impl cli::Session { Ok(writer) } + fn get_term_type(&self) -> Option { + env::var("TERM").ok() + } + fn get_term_version(&self) -> Result> { self.get_tty(false).map(|tty| tty.get_version()) } @@ -324,6 +343,7 @@ impl cli::Session { fn build_asciicast_metadata( &self, + term_type: Option, term_version: Option, env: &HashMap, config: &config::Session, @@ -332,6 +352,7 @@ impl cli::Session { let command = self.get_command(config); Metadata { + term_type, term_version, idle_time_limit, command, @@ -396,13 +417,15 @@ impl Relay { fn get_relay( target: RelayTarget, config: &Config, + term_type: Option, term_version: Option, env: &HashMap, ) -> Result { match target { RelayTarget::StreamId(id) => { let stream = api::create_user_stream(id, config)?; - let ws_producer_url = build_producer_url(&stream.ws_producer_url, term_version, env)?; + let ws_producer_url = + build_producer_url(&stream.ws_producer_url, term_type, term_version, env)?; Ok(Relay { ws_producer_url, @@ -419,14 +442,15 @@ fn get_relay( fn build_producer_url( url: &str, + term_type: Option, term_version: Option, env: &HashMap, ) -> Result { let mut url: Url = url.parse()?; let mut params = Vec::new(); - if let Ok(term_type) = env::var("TERM") { - params.push(("term[type]".to_string(), term_type)); + if let Some(type_) = term_type { + params.push(("term[type]".to_string(), type_)); } if let Some(version) = term_version { diff --git a/src/encoder/asciicast.rs b/src/encoder/asciicast.rs index 672d1c1..034bd45 100644 --- a/src/encoder/asciicast.rs +++ b/src/encoder/asciicast.rs @@ -1,19 +1,50 @@ -use crate::asciicast::{Encoder, Event, Header}; +use crate::asciicast::{Event, Header, V2Encoder, V3Encoder}; -pub struct AsciicastEncoder { - inner: Encoder, +pub struct AsciicastV2Encoder { + inner: V2Encoder, append: bool, } -impl AsciicastEncoder { +impl AsciicastV2Encoder { pub fn new(append: bool, time_offset: u64) -> Self { - let inner = Encoder::new(time_offset); + let inner = V2Encoder::new(time_offset); Self { inner, append } } } -impl super::Encoder for AsciicastEncoder { +impl super::Encoder for AsciicastV2Encoder { + fn header(&mut self, header: &Header) -> Vec { + if self.append { + Vec::new() + } else { + self.inner.header(header) + } + } + + fn event(&mut self, event: Event) -> Vec { + self.inner.event(&event) + } + + fn flush(&mut self) -> Vec { + Vec::new() + } +} + +pub struct AsciicastV3Encoder { + inner: V3Encoder, + append: bool, +} + +impl AsciicastV3Encoder { + pub fn new(append: bool) -> Self { + let inner = V3Encoder::new(); + + Self { inner, append } + } +} + +impl super::Encoder for AsciicastV3Encoder { fn header(&mut self, header: &Header) -> Vec { if self.append { Vec::new() diff --git a/src/encoder/mod.rs b/src/encoder/mod.rs index 0cf6e68..5a2dcd5 100644 --- a/src/encoder/mod.rs +++ b/src/encoder/mod.rs @@ -9,7 +9,7 @@ use anyhow::Result; use crate::asciicast::Event; use crate::asciicast::Header; -pub use asciicast::AsciicastEncoder; +pub use asciicast::{AsciicastV2Encoder, AsciicastV3Encoder}; pub use raw::RawEncoder; pub use txt::TextEncoder; diff --git a/src/encoder/raw.rs b/src/encoder/raw.rs index ce76106..f42ade3 100644 --- a/src/encoder/raw.rs +++ b/src/encoder/raw.rs @@ -15,7 +15,7 @@ impl super::Encoder for RawEncoder { if self.append { Vec::new() } else { - format!("\x1b[8;{};{}t", header.rows, header.cols).into_bytes() + format!("\x1b[8;{};{}t", header.term_rows, header.term_cols).into_bytes() } } @@ -43,8 +43,8 @@ mod tests { let mut enc = RawEncoder::new(false); let header = Header { - cols: 100, - rows: 50, + term_cols: 100, + term_rows: 50, ..Default::default() }; diff --git a/src/encoder/txt.rs b/src/encoder/txt.rs index fdb7be0..5c7b568 100644 --- a/src/encoder/txt.rs +++ b/src/encoder/txt.rs @@ -15,7 +15,7 @@ impl TextEncoder { impl super::Encoder for TextEncoder { fn header(&mut self, header: &Header) -> Vec { let vt = avt::Vt::builder() - .size(header.cols as usize, header.rows as usize) + .size(header.term_cols as usize, header.term_rows as usize) .scrollback_limit(100) .build(); @@ -63,8 +63,8 @@ mod tests { let mut enc = TextEncoder::new(); let header = Header { - cols: 3, - rows: 1, + term_cols: 3, + term_rows: 1, ..Default::default() }; diff --git a/src/file_writer.rs b/src/file_writer.rs index 1f61c5c..b0613fa 100644 --- a/src/file_writer.rs +++ b/src/file_writer.rs @@ -22,6 +22,7 @@ pub struct FileWriter { } pub struct Metadata { + pub term_type: Option, pub term_version: Option, pub idle_time_limit: Option, pub command: Option, @@ -39,10 +40,12 @@ impl session::OutputStarter for FileWriterStarter { let timestamp = time.duration_since(UNIX_EPOCH).unwrap().as_secs(); let header = asciicast::Header { - cols: tty_size.0, - rows: tty_size.1, + term_cols: tty_size.0, + term_rows: tty_size.1, + term_type: self.metadata.term_type, + term_version: self.metadata.term_version, + term_theme: theme, timestamp: Some(timestamp), - theme, idle_time_limit: self.metadata.idle_time_limit, command: self.metadata.command.as_ref().cloned(), title: self.metadata.title.as_ref().cloned(),