diff --git a/.gitignore b/.gitignore index dc761d5..b41f4a4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ /target -/output.cast +output.cast .direnv /logs diff --git a/output.cast b/output.cast deleted file mode 100644 index e69de29..0000000 diff --git a/src/asciicast.rs b/src/asciicast.rs index c2d0e1d..55c4192 100644 --- a/src/asciicast.rs +++ b/src/asciicast.rs @@ -8,17 +8,17 @@ use serde::ser::{Serialize, SerializeSeq, Serializer}; #[derive(Serialize)] pub struct Header { - version: u32, - width: u32, - height: u32, + pub version: u32, + pub width: u32, + pub height: u32, - timestamp: Option, - duration: Option, - idle_time_limit: Option, - command: Option, - title: Option, - env: Option>, - theme: Option, + pub timestamp: Option, + pub duration: Option, + pub idle_time_limit: Option, + pub command: Option, + pub title: Option, + pub env: Option>, + pub theme: Option, } #[derive(Serialize)] diff --git a/src/lib.rs b/src/lib.rs index bc9ccdb..ea35d6d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,6 @@ #[macro_use] +extern crate anyhow; +#[macro_use] extern crate serde_derive; #[macro_use] extern crate tracing; diff --git a/src/main.rs b/src/main.rs index 34cefa3..9e30572 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,12 +1,13 @@ #[macro_use] extern crate tracing; -use std::fs::File; +use std::process::Command; +use std::thread; +use std::{fs::File, io::Write}; use anyhow::Result; use clap::{ArgAction, Parser}; -use liveterm::recorder::Terminal; -use tokio::process::Command; +use liveterm::{asciicast::Header, recorder::Terminal}; use tracing::Level; #[derive(Parser)] @@ -26,12 +27,53 @@ enum Subcommand { } fn record() -> Result<()> { - let _file = File::create("output.cast")?; + let mut file = File::create("output.cast")?; - let mut command = Command::new("/usr/bin/env"); - command.arg("bash"); + let mut command = Command::new("zsh"); + // command.arg("bash"); + command.env("TERM", "xterm-256color"); + + // Write header + // TODO: Clean this up + let header = { + let command_str = format!("{:?}", command); + let env = command + .get_envs() + .into_iter() + .filter_map(|(a, b)| b.map(|b| (a, b))) + .map(|(a, b)| { + ( + a.to_string_lossy().to_string(), + b.to_string_lossy().to_string(), + ) + }) + .collect(); + Header { + version: 2, + width: 30, + height: 30, + + timestamp: None, + duration: None, + idle_time_limit: None, + command: Some(command_str), + title: None, + env: Some(env), + theme: None, + } + }; + + serde_json::to_writer(&file, &header)?; + file.write(b"\n")?; let (pty, rx) = Terminal::setup(command)?; + thread::spawn(move || { + for event in rx.into_iter() { + serde_json::to_writer(&file, &event); + file.write(b"\n"); + } + }); + pty.wait_until_complete()?; Ok(()) diff --git a/src/recorder.rs b/src/recorder.rs index 85273ad..ce0da83 100644 --- a/src/recorder.rs +++ b/src/recorder.rs @@ -1,40 +1,66 @@ //! Setting up a recorder -use std::os::unix::prelude::RawFd; +use std::env; +use std::ffi::CString; +use std::os::unix::prelude::{AsRawFd, OsStrExt, RawFd}; +use std::process::{Command, Stdio}; use std::sync::mpsc::{self, Receiver, Sender}; +use std::time::Instant; -use anyhow::Result; -use libc::{STDIN_FILENO, STDOUT_FILENO}; +use anyhow::{Context, Result}; +use libc::{O_RDWR, STDERR_FILENO, STDIN_FILENO, STDOUT_FILENO}; +use nix::fcntl::{open, OFlag}; use nix::pty::{openpty, OpenptyResult}; use nix::sys::select::{select, FdSet}; -use nix::unistd::{dup, dup2, fsync, read, write}; -use tokio::process::Command; +use nix::sys::stat::Mode; +use nix::unistd::{ + close, dup, dup2, execvp, execvpe, fork, fsync, read, setsid, ttyname, write, + ForkResult, +}; -use crate::asciicast::Event; +use crate::asciicast::{Event, EventKind}; pub struct Terminal { - /// The file descriptor that the parent process' original stdout got duplicated to. - /// - /// This needs to be saved so we can restore it later. - parent_stdout_dup: RawFd, - - pty: OpenptyResult, - event_tx: Sender, + command: Command, } impl Terminal { - pub fn setup(mut command: Command) -> Result<(Self, Receiver)> { - command.env("TERM", "xterm-256color"); - + pub fn setup(command: Command) -> Result<(Self, Receiver)> { // Set up channels let (event_tx, event_rx) = mpsc::channel::(); + let term = Terminal { event_tx, command }; + Ok((term, event_rx)) + } + + pub fn wait_until_complete(self) -> Result<()> { + self.run()?; + + Ok(()) + } + + fn run(self) -> Result<()> { + // References: + // - https://github.com/python/cpython/blob/main/Lib/pty.py + // - https://github.com/asciinema/asciinema/blob/develop/asciinema/pty_.py + // TODO: Investigate cross-platformness + if cfg!(target_os = "windows") { + panic!("Not supported on windows."); + } + + let mut command = self.command; + + // Merge environment + for (key, val) in env::vars() { + command.env(key, val); + } + // Open a pty let pty = openpty(None, None)?; let parent_stdout_dup = dup(STDOUT_FILENO)?; info!(parent_stdout_dup, "Duplicated parent process' stdout."); - dup2(pty.slave, STDOUT_FILENO)?; + // dup2(pty.slave, STDOUT_FILENO)?; info!( redirected_to = pty.slave, "Redirected parent process' stdout to new pty." @@ -43,71 +69,139 @@ impl Terminal { eprintln!("Starting child process..."); // Spawn the child - let child = command.spawn()?; - let _child_pid = child.id(); + let child_pid = match unsafe { fork() } { + Ok(ForkResult::Parent { child }) => { + info!( + child_pid = child.as_raw(), + "Continuing execution in parent process.", + ); - let term = Terminal { - parent_stdout_dup, - pty, - event_tx, + close(pty.slave)?; + + child + } + + Ok(ForkResult::Child) => { + setsid()?; + close(pty.master)?; + + // Duplicate all stdio descriptors to slave + dup2(pty.slave, STDIN_FILENO)?; + dup2(pty.slave, STDOUT_FILENO)?; + dup2(pty.slave, STDERR_FILENO)?; + if pty.slave > STDERR_FILENO { + close(pty.slave)?; + } + + let tty_path = ttyname(STDOUT_FILENO)?; + let tmp_fd = open(&tty_path, OFlag::O_RDWR, Mode::S_IRWXU)?; + close(tmp_fd)?; + + exec_command(command)?; + unreachable!() + } + + Err(err) => bail!("Could not fork: {}", err), }; - Ok((term, event_rx)) - } - pub fn wait_until_complete(self) -> Result<()> { - self.run()?; - - // Restore stdout - fsync(STDOUT_FILENO)?; - dup2(self.parent_stdout_dup, STDOUT_FILENO)?; - - Ok(()) - } - - fn run(&self) -> Result<()> { + // Set up fd sets and buffers for reading let mut read_fd_set = FdSet::new(); let mut buf = [0; 1024]; + // Set up recording function + let start_time = Instant::now(); + let record = |now: Instant, output: bool, data: &[u8]| -> Result<()> { + let elapsed = (now - start_time).as_secs_f64(); + let event_kind = (match output { + true => EventKind::Output, + false => EventKind::Input, + })(data.to_vec()); + let event = Event(elapsed, event_kind); + self.event_tx.send(event)?; + Ok(()) + }; + info!("Starting read loop..."); loop { // Set up fdsets // asciinema does not capture stdin by default read_fd_set.clear(); - read_fd_set.insert(self.pty.master); + read_fd_set.insert(pty.master); read_fd_set.insert(STDIN_FILENO); // TODO: Signal file descriptor - select(None, &mut read_fd_set, None, None, None)?; + select(None, &mut read_fd_set, None, None, None).context("Select")?; // Master is ready for read, which means child process stdout has data // (child process stdout) -> (pty.slave) -> (pty.master) - if read_fd_set.contains(self.pty.master) { - let bytes_read = read(self.pty.master, &mut buf)?; + if read_fd_set.contains(pty.master) { + let bytes_read = + read(pty.master, &mut buf).context("Read from master")?; if bytes_read == 0 { info!("Read 0 bytes, exiting the read loop."); break; } + let now = Instant::now(); let data = &buf[..bytes_read]; debug!(bytes_read, "Read data from master."); // We should take this and rewrite this to the current stdout - write(STDOUT_FILENO, data)?; - } - // Stdin is ready for read, which means input from the user - else if read_fd_set.contains(STDIN_FILENO) { - let bytes_read = read(self.pty.master, &mut buf)?; - let data = &buf[..bytes_read]; - debug!(bytes_read, "Read data from master."); + write(STDOUT_FILENO, data).context("Write to current STDOUT")?; - write(self.pty.master, data)?; + // Also, record this + record(now, true, data).context("Record data")?; + } + + // Stdin is ready for read, which means input from the user + if read_fd_set.contains(STDIN_FILENO) { + let bytes_read = + read(STDIN_FILENO, &mut buf).context("Read from current STDIN")?; + let data = &buf[..bytes_read]; + debug!(bytes_read, "Read data from current STDIN."); + let now = Instant::now(); + + write(pty.master, data).context("Write to master")?; + + // Also, record this + record(now, false, data).context("Record data")?; } } + // Restore stdout + fsync(STDOUT_FILENO)?; + dup2(parent_stdout_dup, STDOUT_FILENO)?; + Ok(()) } } +fn exec_command(command: Command) -> Result<()> { + let program = CString::new(command.get_program().as_bytes())?; + let args = command + .get_args() + .into_iter() + .map(|osstr| CString::new(osstr.as_bytes()).map_err(|e| e.into())) + .collect::>>()?; + let env = command + .get_envs() + .into_iter() + .filter_map(|(key, val)| { + let val = match val { + Some(val) => val, + None => return None, + }; + + let mut key = key.to_os_string(); + let equals = String::from("="); + key.extend([equals.as_ref(), val]); + Some(CString::new(key.as_bytes()).map_err(|e| e.into())) + }) + .collect::>>()?; + execvpe(&program, &args, &env)?; + unreachable!() +} + /* pub fn record(args: &[&str], writer: impl Writer + Send) -> Result<()> { // Setup