diff --git a/CHANGELOG.md b/CHANGELOG.md index a8ebe8257..fcd3f6051 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # asciinema changelog +## 3.0.0 (wip) + +* Full rewrite in Rust +* rec: `--append` can be used with `--raw` now +* rec: use of `--append` and `--overwrite` together returns error now +* rec: fixed saving of custom rec command in asciicast header +* Improved error message when non-UTF-8 locale is detected + ## 2.4.0 (2023-10-23) * When recording without file arg we now ask whether to save, upload or discard the recording (#576) diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 000000000..ddfb84979 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,405 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "anstream" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab91ebe16eb252986481c5b62f6098f3b698a45e34b5b98200cf20dd2484a44" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7079075b41f533b8c61d2a4d073c4676e1f8b249ff94a393b0595db304e0dd87" + +[[package]] +name = "anstyle-parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317b9a89c1868f5ea6ff1d9539a69f45dffc21ce321ac1fd1160dfa48c8e2140" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca11d4be1bab0c8bc8734a9aa7bf4ee8316d462a08c6ac5052f888fef5b494b" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0699d10d2f4d628a98ee7b57b289abbc98ff3bad977cb3152709d4bf2330628" +dependencies = [ + "anstyle", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6" + +[[package]] +name = "asciinema" +version = "3.0.0-alpha.1" +dependencies = [ + "anyhow", + "clap", + "mio", + "nix", + "serde", + "serde_json", + "signal-hook", + "signal-hook-mio", + "termion", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "327762f6e5a765692301e5bb513e0d9fef63be86bbc14528052b1cd3e6f03e07" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "clap" +version = "4.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac495e00dcec98c83465d5ad66c5c4fabd652fd6686e7c6269b117e729a6f17b" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77ed9a32a62e6ca27175d00d29d05ca32e396ea1eb5fb01d8256b669cec7663" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf9804afaaf59a91e75b022a30fb7229a7901f60c755489cc61c9b423b836442" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "702fc72eb24e5a1e48ce58027a675bc24edd52096d5397d4aea7c6dd9eca0bd1" + +[[package]] +name = "colorchoice" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "itoa" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38" + +[[package]] +name = "libc" +version = "0.2.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a08173bc88b7955d1b3145aa561539096c421ac8debde8cbc3612ec635fee29b" + +[[package]] +name = "log" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" + +[[package]] +name = "mio" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "927a765cd3fc26206e66b296465fa9d3e5ab003e651c1b3c060e7956d96b19d2" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys", +] + +[[package]] +name = "nix" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" +dependencies = [ + "bitflags 2.4.1", + "cfg-if", + "libc", +] + +[[package]] +name = "numtoa" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8f8bdf33df195859076e54ab11ee78a1b208382d3a26ec40d142ffc1ecc49ef" + +[[package]] +name = "proc-macro2" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "134c189feb4956b20f6f547d2cf727d4c0fe06722b20a0eec87ed445a97f92da" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_termios" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8440d8acb4fd3d277125b4bd01a6f38aee8d814b3b5fc09b3f2b825d37d3fe8f" +dependencies = [ + "redox_syscall", +] + +[[package]] +name = "ryu" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741" + +[[package]] +name = "serde" +version = "1.0.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e422a44e74ad4001bdc8eede9a4570ab52f71190e9c076d14369f38b9200537" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e48d1f918009ce3145511378cf68d613e3b3d9137d67272562080d68a2b32d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b420ce6e3d8bd882e9b243c6eed35dbc9a6110c9769e74b584e0d68d1f20c65" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "signal-hook" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ad2e15f37ec9a6cc544097b78a1ec90001e9f71b81338ca39f430adaca99af" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" +dependencies = [ + "libc", +] + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + +[[package]] +name = "syn" +version = "2.0.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e96b79aaa137db8f61e26363a0c9b47d8b4ec75da28b7d1d614c2303e232408b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "termion" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "659c1f379f3408c7e5e84c7d0da6d93404e3800b6b9d063ba24436419302ec90" +dependencies = [ + "libc", + "numtoa", + "redox_syscall", + "redox_termios", +] + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "utf8parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 000000000..b652b470b --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "asciinema" +version = "3.0.0-alpha.1" +edition = "2021" +authors = ["Marcin Kulik "] +homepage = "https://asciinema.org" +repository = "https://github.com/asciinema/asciinema" +description = "Terminal session recorder" +license-file = "LICENSE" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +anyhow = "1.0.75" +nix = { version = "0.27", features = [ "fs", "term", "process", "signal" ] } +mio = { version ="0.8", features = ["os-poll", "os-ext"] } +termion = "2.0.1" +serde = { version = "1.0.189", features = ["derive"] } +serde_json = "1.0.107" +clap = { version = "4.4.7", features = ["derive"] } +signal-hook-mio = { version = "0.2.3", features = ["support-v0_8"] } +signal-hook = "0.3.17" diff --git a/doc/asciicast-v2.md b/doc/asciicast-v2.md index 8f1b60ab4..ae9ab4098 100644 --- a/doc/asciicast-v2.md +++ b/doc/asciicast-v2.md @@ -72,10 +72,7 @@ Map of captured environment variables. Object (String -> String). Example env: ```json -"env": { - "SHELL": "/bin/bash", - "TERM": "xterm-256color" -} +"env": { "SHELL": "/bin/bash", "TERM": "xterm-256color" } ``` > Official asciinema recorder captures only `SHELL` and `TERM` by default. All diff --git a/src/format.rs b/src/format.rs new file mode 100644 index 000000000..4c4fddf4e --- /dev/null +++ b/src/format.rs @@ -0,0 +1,20 @@ +pub mod asciicast; +pub mod raw; +use std::{collections::HashMap, io}; + +pub trait Writer { + fn header(&mut self, header: &Header) -> io::Result<()>; + fn output(&mut self, time: f64, data: &[u8]) -> io::Result<()>; + fn input(&mut self, time: f64, data: &[u8]) -> io::Result<()>; + fn resize(&mut self, time: f64, size: (u16, u16)) -> io::Result<()>; +} + +pub struct Header { + pub cols: u16, + pub rows: u16, + pub timestamp: u64, + pub idle_time_limit: Option, + pub command: Option, + pub title: Option, + pub env: HashMap, +} diff --git a/src/format/asciicast.rs b/src/format/asciicast.rs new file mode 100644 index 000000000..d688cf748 --- /dev/null +++ b/src/format/asciicast.rs @@ -0,0 +1,396 @@ +use anyhow::bail; +use serde::Deserialize; +use std::collections::HashMap; +use std::fmt::{self, Display}; +use std::fs; +use std::io::BufRead; +use std::io::{self, Write}; +use std::path::Path; + +pub struct Writer { + writer: W, + time_offset: f64, +} + +#[derive(Deserialize)] +pub struct Header { + width: u16, + height: u16, + timestamp: u64, + idle_time_limit: Option, + command: Option, + title: Option, + env: HashMap, +} + +pub struct Event { + pub time: f64, + pub code: EventCode, + pub data: String, +} + +#[derive(PartialEq, Eq, Debug)] +pub enum EventCode { + Output, + Input, + Resize, + Marker, + Other(char), +} + +impl Writer +where + W: Write, +{ + pub fn new(writer: W, time_offset: f64) -> Self { + Self { + writer, + time_offset, + } + } + + pub fn write_header(&mut self, header: &Header) -> io::Result<()> { + writeln!(self.writer, "{}", serde_json::to_string(&header)?) + } + + pub fn write_event(&mut self, mut event: Event) -> io::Result<()> { + event.time += self.time_offset; + + writeln!(self.writer, "{}", serde_json::to_string(&event)?) + } +} + +impl super::Writer for Writer +where + W: Write, +{ + fn header(&mut self, header: &super::Header) -> io::Result<()> { + self.write_header(&header.into()) + } + + fn output(&mut self, time: f64, data: &[u8]) -> io::Result<()> { + self.write_event(Event::output(time, data)) + } + + fn input(&mut self, time: f64, data: &[u8]) -> io::Result<()> { + self.write_event(Event::input(time, data)) + } + + fn resize(&mut self, time: f64, size: (u16, u16)) -> io::Result<()> { + self.write_event(Event::resize(time, size)) + } +} + +pub fn open( + reader: R, +) -> anyhow::Result<(super::Header, impl Iterator>)> { + let mut lines = reader.lines(); + let first_line = lines.next().ok_or(anyhow::anyhow!("empty"))??; + let header: Header = serde_json::from_str(&first_line)?; + let header: super::Header = (&header).into(); + + let events = lines + .filter(|l| l.as_ref().map_or(true, |l| !l.is_empty())) + .enumerate() + .map(|(i, l)| l.map(|l| parse_event(l, i + 2))?); + + Ok((header, events)) +} + +fn parse_event(line: String, i: usize) -> anyhow::Result { + use EventCode::*; + + let value: serde_json::Value = serde_json::from_str(&line)?; + + let time = value[0] + .as_f64() + .ok_or(anyhow::anyhow!("line {}: invalid event time", i))?; + + let code = match value[1].as_str() { + Some("o") => Output, + Some("i") => Input, + Some("r") => Resize, + Some("m") => Marker, + Some(s) if !s.is_empty() => Other(s.chars().next().unwrap()), + Some(_) => bail!("line {}: missing event code", i), + None => bail!("line {}: event code must be a string", i), + }; + + let data = match value[2].as_str() { + Some(data) => data.to_owned(), + None => bail!("line {}: event data must be a string", i), + }; + + Ok(Event { time, code, data }) +} + +pub fn get_duration>(path: S) -> anyhow::Result { + let file = fs::File::open(path)?; + let reader = io::BufReader::new(file); + let (_header, events) = open(reader)?; + let time = events.last().map_or(Ok(0.0), |e| e.map(|e| e.time))?; + + Ok(time) +} + +impl Event { + pub fn output(time: f64, data: &[u8]) -> Self { + Event { + time, + code: EventCode::Output, + data: String::from_utf8_lossy(data).to_string(), + } + } + + pub fn input(time: f64, data: &[u8]) -> Self { + Event { + time, + code: EventCode::Input, + data: String::from_utf8_lossy(data).to_string(), + } + } + + pub fn resize(time: f64, size: (u16, u16)) -> Self { + Event { + time, + code: EventCode::Resize, + data: format!("{}x{}", size.0, size.1), + } + } +} + +impl Display for EventCode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> fmt::Result { + use EventCode::*; + + match self { + Output => f.write_str("o"), + Input => f.write_str("i"), + Resize => f.write_str("r"), + Marker => f.write_str("m"), + Other(t) => f.write_str(&t.to_string()), + } + } +} + +impl serde::Serialize for Header { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + use serde::ser::SerializeMap; + + let mut len = 4; + + 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.is_empty() { + len += 1; + } + + let mut map = serializer.serialize_map(Some(len))?; + map.serialize_entry("version", &2)?; + map.serialize_entry("width", &self.width)?; + map.serialize_entry("height", &self.height)?; + map.serialize_entry("timestamp", &self.timestamp)?; + + 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 !self.env.is_empty() { + map.serialize_entry("env", &self.env)?; + } + + map.end() + } +} + +impl serde::Serialize for Event { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + use serde::ser::SerializeTuple; + let mut tup = serializer.serialize_tuple(3)?; + tup.serialize_element(&self.time)?; + tup.serialize_element(&self.code.to_string())?; + tup.serialize_element(&self.data)?; + tup.end() + } +} + +impl From<&Header> for super::Header { + fn from(header: &Header) -> Self { + Self { + cols: header.width, + rows: header.height, + timestamp: header.timestamp, + idle_time_limit: header.idle_time_limit, + command: header.command.clone(), + title: header.title.clone(), + env: header.env.clone(), + } + } +} + +impl From<&super::Header> for Header { + fn from(header: &super::Header) -> Self { + Self { + width: header.cols, + height: header.rows, + timestamp: header.timestamp, + idle_time_limit: header.idle_time_limit, + command: header.command.clone(), + title: header.title.clone(), + env: header.env.clone(), + } + } +} + +#[cfg(test)] +mod tests { + use super::{Event, EventCode, Header, Writer}; + use std::collections::HashMap; + use std::fs::File; + use std::io; + + #[test] + fn open() { + let file = File::open("tests/demo.cast").unwrap(); + let (header, events) = super::open(io::BufReader::new(file)).unwrap(); + + let events = events + .take(7) + .collect::>>() + .unwrap(); + + assert_eq!((header.cols, header.rows), (75, 18)); + + assert_eq!(events[1].time, 0.100989); + assert_eq!(events[1].code, EventCode::Output); + assert_eq!(events[1].data, "\u{1b}[?2004h"); + + assert_eq!(events[5].time, 1.511526); + assert_eq!(events[5].code, EventCode::Input); + assert_eq!(events[5].data, "v"); + + assert_eq!(events[6].time, 1.511937); + assert_eq!(events[6].code, EventCode::Output); + assert_eq!(events[6].data, "v"); + } + + #[test] + fn writer() { + let mut data = Vec::new(); + + let cursor = io::Cursor::new(&mut data); + let mut fw = Writer::new(cursor, 0.0); + + let header = Header { + width: 80, + height: 24, + timestamp: 1, + idle_time_limit: None, + command: None, + title: None, + env: Default::default(), + }; + + fw.write_header(&header).unwrap(); + fw.write_event(Event::output(1.0, "hello\r\n".as_bytes())) + .unwrap(); + + let data_len = data.len() as u64; + let mut cursor = io::Cursor::new(&mut data); + cursor.set_position(data_len); + let mut fw = Writer::new(cursor, 1.0); + + fw.write_event(Event::output(1.0, "world".as_bytes())) + .unwrap(); + fw.write_event(Event::input(2.0, " ".as_bytes())).unwrap(); + fw.write_event(Event::resize(3.0, (100, 40))).unwrap(); + + let lines = parse(data); + + assert_eq!(lines[0]["version"], 2); + assert_eq!(lines[0]["width"], 80); + assert_eq!(lines[0]["height"], 24); + assert_eq!(lines[0]["timestamp"], 1); + assert_eq!(lines[1][0], 1.0); + assert_eq!(lines[1][1], "o"); + assert_eq!(lines[1][2], "hello\r\n"); + assert_eq!(lines[2][0], 2.0); + assert_eq!(lines[2][1], "o"); + assert_eq!(lines[2][2], "world"); + assert_eq!(lines[3][0], 3.0); + assert_eq!(lines[3][1], "i"); + assert_eq!(lines[3][2], " "); + assert_eq!(lines[4][0], 4.0); + assert_eq!(lines[4][1], "r"); + assert_eq!(lines[4][2], "100x40"); + } + + #[test] + fn write_header() { + let mut data = Vec::new(); + let mut fw = Writer::new(io::Cursor::new(&mut data), 0.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()); + + let header = Header { + width: 80, + height: 24, + timestamp: 1, + idle_time_limit: Some(1.5), + command: Some("/bin/bash".to_owned()), + title: Some("Demo".to_owned()), + env, + }; + + fw.write_header(&header).unwrap(); + + let lines = parse(data); + + assert_eq!(lines[0]["version"], 2); + assert_eq!(lines[0]["width"], 80); + assert_eq!(lines[0]["height"], 24); + assert_eq!(lines[0]["timestamp"], 1); + assert_eq!(lines[0]["idle_time_limit"], 1.5); + assert_eq!(lines[0]["command"], "/bin/bash"); + assert_eq!(lines[0]["title"], "Demo"); + assert_eq!(lines[0]["env"].as_object().unwrap().len(), 2); + assert_eq!(lines[0]["env"]["SHELL"], "/usr/bin/fish"); + assert_eq!(lines[0]["env"]["TERM"], "xterm256-color"); + } + + fn parse(json: Vec) -> Vec { + String::from_utf8(json) + .unwrap() + .split('\n') + .filter(|s| !s.is_empty()) + .map(serde_json::from_str::) + .collect::>>() + .unwrap() + } +} diff --git a/src/format/raw.rs b/src/format/raw.rs new file mode 100644 index 000000000..345fcb20f --- /dev/null +++ b/src/format/raw.rs @@ -0,0 +1,29 @@ +use std::io::{self, Write}; + +pub struct Writer { + writer: W, +} + +impl Writer { + pub fn new(writer: W) -> Self { + Writer { writer } + } +} + +impl super::Writer for Writer { + fn header(&mut self, header: &super::Header) -> io::Result<()> { + write!(self.writer, "\x1b[8;{};{}t", header.rows, header.cols) + } + + fn output(&mut self, _time: f64, data: &[u8]) -> io::Result<()> { + self.writer.write_all(data) + } + + fn input(&mut self, _time: f64, _data: &[u8]) -> io::Result<()> { + Ok(()) + } + + fn resize(&mut self, _time: f64, _size: (u16, u16)) -> io::Result<()> { + Ok(()) + } +} diff --git a/src/locale.rs b/src/locale.rs new file mode 100644 index 000000000..e14537558 --- /dev/null +++ b/src/locale.rs @@ -0,0 +1,42 @@ +use nix::libc::{self, CODESET, LC_ALL}; +use std::env; +use std::ffi::CStr; + +pub fn check_utf8_locale() -> anyhow::Result<()> { + initialize_from_env(); + + let encoding = get_encoding(); + + if ["US-ASCII", "UTF-8"].contains(&encoding.as_str()) { + Ok(()) + } else { + let env = env::var("LC_ALL") + .map(|v| format!("LC_ALL={}", v)) + .or(env::var("LC_CTYPE").map(|v| format!("LC_CTYPE={}", v))) + .or(env::var("LANG").map(|v| format!("LANG={}", v))) + .unwrap_or("".to_string()); + + Err(anyhow::anyhow!("asciinema requires ASCII or UTF-8 character encoding. The environment ({}) specifies the character set \"{}\". Check the output of `locale` command.", env, encoding)) + } +} + +pub fn initialize_from_env() { + unsafe { + libc::setlocale(LC_ALL, b"\0".as_ptr() as *const libc::c_char); + }; +} + +fn get_encoding() -> String { + let codeset = unsafe { CStr::from_ptr(libc::nl_langinfo(CODESET)) }; + + let mut encoding = codeset + .to_str() + .expect("Locale codeset name is not a valid UTF-8 string") + .to_owned(); + + if encoding == "ANSI_X3.4-1968" { + encoding = "US-ASCII".to_owned(); + } + + encoding +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 000000000..a8ce79d3a --- /dev/null +++ b/src/main.rs @@ -0,0 +1,235 @@ +mod format; +mod locale; +mod pty; +mod recorder; +use anyhow::Result; +use clap::{Parser, Subcommand}; +use format::{asciicast, raw}; +use std::collections::{HashMap, HashSet}; +use std::env; +use std::ffi::{CString, OsString}; +use std::fs; +use std::io; +use std::os::unix::ffi::OsStringExt; +use std::path::Path; + +#[derive(Debug, Parser)] +#[clap(author, version, about)] +#[command(name = "asciinema")] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Debug, Subcommand)] +enum Commands { + /// Record terminal session + #[command(name = "rec")] + Record { + filename: String, + + /// Enable input recording + #[arg(long)] + stdin: bool, + + /// Append to existing asciicast file + #[arg(long)] + append: bool, + + /// Save raw output only + #[arg(long)] + raw: bool, + + /// Overwrite target file if it already exists + #[arg(long, conflicts_with = "append")] + overwrite: bool, + + /// Command to record [default: $SHELL] + #[arg(short, long)] + command: Option, + + /// List of env vars to save + #[arg(short, long, default_value_t = String::from("SHELL,TERM"))] + env: String, + + /// Title of the recording + #[arg(short, long)] + title: Option, + + /// Limit idle time to given number of seconds + #[arg(short, long, value_name = "SECS")] + idle_time_limit: Option, + + /// Override terminal width (columns) for recorded command + #[arg(long)] + cols: Option, + + /// Override terminal height (rows) for recorded command + #[arg(long)] + rows: Option, + + /// Quiet mode - suppress all notices/warnings + #[arg(short, long)] + quiet: bool, + }, + + /// Play terminal session + Play { + filename: String, + + /// Limit idle time to given number of seconds + #[arg(short, long, value_name = "SECS")] + idle_time_limit: Option, + + /// Set playback speed + #[arg(short, long)] + speed: Option, + + /// Loop loop loop loop + #[arg(short, long, name = "loop")] + loop_: bool, + + /// Automatically pause on markers + #[arg(short = 'm', long)] + pause_on_markers: bool, + }, + + /// Print full output of terminal sessions + Cat { + #[arg(required = true)] + filename: Vec, + }, + + /// Upload recording to asciinema.org + Upload { + /// Filename/path of asciicast to upload + filename: String, + }, + + /// Link this system to asciinema.org account + Auth, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + + match cli.command { + Commands::Record { + filename, + stdin, + mut append, + raw, + mut overwrite, + command, + env, + title, + idle_time_limit, + cols, + rows, + quiet, + } => { + locale::check_utf8_locale()?; + + let path = Path::new(&filename); + + if path.exists() { + let metadata = fs::metadata(path)?; + + if metadata.len() == 0 { + overwrite = true; + append = false; + } + // TODO if !append && !overwrite - error message + } else { + append = false; + } + + let mut opts = fs::OpenOptions::new(); + + opts.write(true) + .append(append) + .create(overwrite) + .create_new(!overwrite && !append) + .truncate(overwrite); + + let writer: Box = if raw { + let file = opts.open(&filename)?; + + Box::new(raw::Writer::new(file)) + } else { + let time_offset = if append { + asciicast::get_duration(&filename)? + } else { + 0.0 + }; + + let file = io::LineWriter::new(opts.open(&filename)?); + let writer = asciicast::Writer::new(file, time_offset); + + Box::new(writer) + }; + + let mut recorder = recorder::Recorder::new( + writer, + append, + stdin, + idle_time_limit, + command.clone(), + title, + capture_env(&env), + ); + + let exec_args = build_exec_args(command); + let exec_env = build_exec_env(); + + pty::exec(&exec_args, &exec_env, (cols, rows), &mut recorder)?; + } + + Commands::Play { + filename, + idle_time_limit, + speed, + loop_, + pause_on_markers, + } => todo!(), + + Commands::Cat { filename } => todo!(), + + Commands::Upload { filename } => todo!(), + + Commands::Auth => todo!(), + } + + Ok(()) +} + +fn capture_env(vars: &str) -> HashMap { + let vars = vars.split(',').collect::>(); + + env::vars() + .filter(|(k, _v)| vars.contains(&k.as_str())) + .collect::>() +} + +fn build_exec_args(command: Option) -> Vec { + let command = command + .or(env::var("SHELL").ok()) + .unwrap_or("/bin/sh".to_owned()); + + vec!["/bin/sh".to_owned(), "-c".to_owned(), command] +} + +fn build_exec_env() -> Vec { + env::vars_os() + .map(format_env_var) + .chain(std::iter::once(CString::new("ASCIINEMA_REC=1").unwrap())) + .collect() +} + +fn format_env_var((key, value): (OsString, OsString)) -> CString { + let mut key_value = key.into_vec(); + key_value.push(b'='); + key_value.extend(value.into_vec()); + + CString::new(key_value).unwrap() +} diff --git a/src/pty.rs b/src/pty.rs new file mode 100644 index 000000000..a04ab756b --- /dev/null +++ b/src/pty.rs @@ -0,0 +1,380 @@ +use anyhow::bail; +use mio::unix::SourceFd; +use nix::{fcntl, libc, pty, sys::signal, sys::wait, unistd, unistd::ForkResult}; +use signal_hook::consts::signal::*; +use signal_hook_mio::v0_8::Signals; +use std::ffi::{CString, NulError}; +use std::fs; +use std::io::{self, Read, Write}; +use std::ops::Deref; +use std::os::fd::RawFd; +use std::os::unix::io::{AsRawFd, FromRawFd}; +use termion::raw::IntoRawMode; + +pub trait Recorder { + fn start(&mut self, size: (u16, u16)) -> io::Result<()>; + fn output(&mut self, data: &[u8]); + fn input(&mut self, data: &[u8]); + fn resize(&mut self, size: (u16, u16)); +} + +pub fn exec, R: Recorder>( + args: &[S], + env: &[CString], + winsize_override: (Option, Option), + recorder: &mut R, +) -> anyhow::Result { + let tty = open_tty()?; + let winsize = get_tty_size(tty.as_raw_fd(), winsize_override); + recorder.start((winsize.ws_col, winsize.ws_row))?; + let result = unsafe { pty::forkpty(Some(&winsize), None) }?; + + match result.fork_result { + ForkResult::Parent { child } => handle_parent( + result.master.as_raw_fd(), + tty, + child, + winsize_override, + recorder, + ), + + ForkResult::Child => { + handle_child(args, env)?; + unreachable!(); + } + } +} + +fn handle_parent( + master_fd: RawFd, + tty: fs::File, + child: unistd::Pid, + winsize_override: (Option, Option), + recorder: &mut R, +) -> anyhow::Result { + let copy_result = copy(master_fd, tty, child, winsize_override, recorder); + let wait_result = wait::waitpid(child, None); + copy_result?; + + match wait_result { + Ok(wait::WaitStatus::Exited(_pid, status)) => Ok(status), + Ok(wait::WaitStatus::Signaled(_pid, signal, ..)) => Ok(128 + signal as i32), + Ok(_) => Ok(1), + Err(e) => Err(anyhow::anyhow!(e)), + } +} + +const MASTER: mio::Token = mio::Token(0); +const TTY: mio::Token = mio::Token(1); +const SIGNAL: mio::Token = mio::Token(2); +const BUF_SIZE: usize = 128 * 1024; + +fn copy( + master_fd: RawFd, + tty: fs::File, + child: unistd::Pid, + winsize_override: (Option, Option), + recorder: &mut R, +) -> anyhow::Result<()> { + let mut master = unsafe { fs::File::from_raw_fd(master_fd) }; + let mut poll = mio::Poll::new()?; + let mut events = mio::Events::with_capacity(128); + let mut master_source = SourceFd(&master_fd); + let mut tty = tty.into_raw_mode()?; + let tty_fd = tty.as_raw_fd(); + let mut tty_source = SourceFd(&tty_fd); + let mut signals = Signals::new([SIGWINCH, SIGINT, SIGTERM, SIGQUIT, SIGHUP])?; + let mut buf = [0u8; BUF_SIZE]; + let mut input: Vec = Vec::with_capacity(BUF_SIZE); + let mut output: Vec = Vec::with_capacity(BUF_SIZE); + let mut flush = false; + + set_non_blocking(&master_fd)?; + set_non_blocking(&tty_fd)?; + + poll.registry() + .register(&mut master_source, MASTER, mio::Interest::READABLE)?; + + poll.registry() + .register(&mut tty_source, TTY, mio::Interest::READABLE)?; + + poll.registry() + .register(&mut signals, SIGNAL, mio::Interest::READABLE)?; + + loop { + if let Err(e) = poll.poll(&mut events, None) { + if e.kind() == io::ErrorKind::Interrupted { + continue; + } else { + bail!(e); + } + } + + for event in events.iter() { + match event.token() { + MASTER => { + if event.is_readable() { + let offset = output.len(); + let read = read_all(&mut master, &mut buf, &mut output)?; + + if read > 0 { + recorder.output(&output[offset..]); + + poll.registry().reregister( + &mut tty_source, + TTY, + mio::Interest::READABLE | mio::Interest::WRITABLE, + )?; + } + } + + if event.is_writable() { + let left = write_all(&mut master, &mut input)?; + + if left == 0 { + poll.registry().reregister( + &mut master_source, + MASTER, + mio::Interest::READABLE, + )?; + } + } + + if event.is_read_closed() { + poll.registry().deregister(&mut master_source)?; + + if !output.is_empty() { + flush = true; + } else { + return Ok(()); + } + } + } + + TTY => { + if event.is_writable() { + let left = write_all(&mut tty, &mut output)?; + + if left == 0 { + if flush { + return Ok(()); + } else { + poll.registry().reregister( + &mut tty_source, + TTY, + mio::Interest::READABLE, + )?; + } + } + } + + if event.is_readable() { + let offset = input.len(); + let read = read_all(&mut tty.deref(), &mut buf, &mut input)?; + + if read > 0 { + recorder.input(&input[offset..]); + + poll.registry().reregister( + &mut master_source, + MASTER, + mio::Interest::READABLE | mio::Interest::WRITABLE, + )?; + } + } + + if event.is_read_closed() { + poll.registry().deregister(&mut tty_source).unwrap(); + return Ok(()); + } + } + + SIGNAL => { + for signal in signals.pending() { + match signal { + SIGWINCH => { + let winsize = get_tty_size(tty_fd, winsize_override); + set_pty_size(master_fd, &winsize); + recorder.resize((winsize.ws_col, winsize.ws_row)); + } + + SIGINT => (), + + SIGTERM | SIGQUIT | SIGHUP => { + unsafe { libc::kill(child.as_raw(), SIGTERM) }; + return Ok(()); + } + + _ => (), + } + } + } + + _ => (), + } + } + } +} + +fn handle_child>(args: &[S], env: &[CString]) -> anyhow::Result<()> { + use signal::{SigHandler, Signal}; + + let args = args + .iter() + .map(|s| CString::new(s.as_ref())) + .collect::, NulError>>()?; + + unsafe { signal::signal(Signal::SIGPIPE, SigHandler::SigDfl) }?; + unistd::execvpe(&args[0], &args, env)?; + unsafe { libc::_exit(1) } +} + +fn open_tty() -> io::Result { + fs::OpenOptions::new() + .read(true) + .write(true) + .open("/dev/tty") +} + +fn get_tty_size(tty_fd: i32, winsize_override: (Option, Option)) -> pty::Winsize { + let mut winsize = pty::Winsize { + ws_row: 24, + ws_col: 80, + ws_xpixel: 0, + ws_ypixel: 0, + }; + + unsafe { libc::ioctl(tty_fd, libc::TIOCGWINSZ, &mut winsize) }; + + if let Some(cols) = winsize_override.0 { + winsize.ws_col = cols; + } + + if let Some(rows) = winsize_override.1 { + winsize.ws_row = rows; + } + + winsize +} + +fn set_pty_size(pty_fd: i32, winsize: &pty::Winsize) { + unsafe { libc::ioctl(pty_fd, libc::TIOCSWINSZ, winsize) }; +} + +fn set_non_blocking(fd: &RawFd) -> Result<(), io::Error> { + use fcntl::{fcntl, FcntlArg::*, OFlag}; + + let flags = fcntl(*fd, F_GETFL)?; + let mut oflags = OFlag::from_bits_truncate(flags); + oflags |= OFlag::O_NONBLOCK; + fcntl(*fd, F_SETFL(oflags))?; + + Ok(()) +} + +fn read_all(source: &mut R, buf: &mut [u8], out: &mut Vec) -> io::Result { + let mut read = 0; + + loop { + match source.read(buf) { + Ok(0) => (), + + Ok(n) => { + out.extend_from_slice(&buf[0..n]); + read += n; + } + + Err(_) => { + break; + } + } + } + + Ok(read) +} + +fn write_all(sink: &mut W, data: &mut Vec) -> io::Result { + let mut buf: &[u8] = data.as_ref(); + + loop { + match sink.write(buf) { + Ok(0) => (), + + Ok(n) => { + buf = &buf[n..]; + + if buf.is_empty() { + break; + } + } + + Err(_) => { + break; + } + } + } + + let left = buf.len(); + + if left == 0 { + data.clear(); + } else { + let rot = data.len() - left; + data.rotate_left(rot); + data.truncate(left); + } + + Ok(left) +} + +#[cfg(test)] +mod tests { + #[derive(Default)] + struct TestRecorder { + size: Option<(u16, u16)>, + output: Vec>, + } + + impl super::Recorder for TestRecorder { + fn start(&mut self, size: (u16, u16)) -> std::io::Result<()> { + self.size = Some(size); + Ok(()) + } + + fn output(&mut self, data: &[u8]) { + self.output.push(data.into()); + } + + fn input(&mut self, _data: &[u8]) {} + fn resize(&mut self, _size: (u16, u16)) {} + } + + impl TestRecorder { + fn output(&self) -> Vec { + self.output + .iter() + .map(|x| String::from_utf8_lossy(x).to_string()) + .collect::>() + } + } + + #[test] + fn exec() { + let mut recorder = TestRecorder::default(); + + let code = r#" +import sys; +import time; +sys.stdout.write('foo'); +sys.stdout.flush(); +time.sleep(0.01); +sys.stdout.write('bar'); +"#; + + let result = super::exec(&["python3", "-c", code], &[], (None, None), &mut recorder); + + assert!(result.is_ok()); + assert!(recorder.size.is_some()); + assert_eq!(recorder.output(), vec!["foo", "bar"]); + } +} diff --git a/src/recorder.rs b/src/recorder.rs new file mode 100644 index 000000000..baf278609 --- /dev/null +++ b/src/recorder.rs @@ -0,0 +1,136 @@ +use crate::format; +use crate::pty; +use std::collections::HashMap; +use std::io; +use std::sync::mpsc; +use std::thread; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; + +pub struct Recorder { + writer: Option>, + start_time: Instant, + append: bool, + record_input: bool, + idle_time_limit: Option, + command: Option, + title: Option, + env: HashMap, + sender: mpsc::Sender, + receiver: Option>, + handle: Option, +} + +enum Message { + Output(f64, Vec), + Input(f64, Vec), + Resize(f64, (u16, u16)), +} + +struct JoinHandle(Option>); + +impl Recorder { + pub fn new( + writer: Box, + append: bool, + record_input: bool, + idle_time_limit: Option, + command: Option, + title: Option, + env: HashMap, + ) -> Self { + let (sender, receiver) = mpsc::channel(); + + Recorder { + writer: Some(writer), + start_time: Instant::now(), + append, + record_input, + idle_time_limit, + command, + title, + env, + sender, + receiver: Some(receiver), + handle: None, + } + } + + fn elapsed_time(&self) -> f64 { + self.start_time.elapsed().as_secs_f64() + } +} + +impl pty::Recorder for Recorder { + fn start(&mut self, size: (u16, u16)) -> io::Result<()> { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + let mut writer = self.writer.take().unwrap(); + let receiver = self.receiver.take().unwrap(); + + if !self.append { + let header = format::Header { + cols: size.0, + rows: size.1, + timestamp, + idle_time_limit: self.idle_time_limit, + command: self.command.clone(), + title: self.title.clone(), + env: self.env.clone(), + }; + + writer.header(&header)?; + } + + let handle = thread::spawn(move || { + for msg in receiver { + match msg { + Message::Output(time, data) => { + let _ = writer.output(time, &data); + } + + Message::Input(time, data) => { + let _ = writer.input(time, &data); + } + + Message::Resize(time, size) => { + let _ = writer.resize(time, size); + } + } + } + }); + + self.handle = Some(JoinHandle(Some(handle))); + self.start_time = Instant::now(); + + Ok(()) + } + + fn output(&mut self, data: &[u8]) { + let msg = Message::Output(self.elapsed_time(), data.into()); + let _ = self.sender.send(msg); + // TODO use notifier for error reporting + } + + fn input(&mut self, data: &[u8]) { + if self.record_input { + let msg = Message::Input(self.elapsed_time(), data.into()); + let _ = self.sender.send(msg); + // TODO use notifier for error reporting + } + } + + fn resize(&mut self, size: (u16, u16)) { + let msg = Message::Resize(self.elapsed_time(), size); + let _ = self.sender.send(msg); + // TODO use notifier for error reporting + } +} + +impl Drop for JoinHandle { + fn drop(&mut self) { + self.0.take().unwrap().join().expect("Thread panicked"); + } +}