213 lines
5.7 KiB
Go
213 lines
5.7 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"os"
|
|
"os/exec"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/vhs/token"
|
|
"github.com/creack/pty"
|
|
"github.com/spf13/cobra"
|
|
"golang.org/x/term"
|
|
)
|
|
|
|
// sleepThreshold is the time at which if there has been no activity in the
|
|
// tape file we insert a Sleep command.
|
|
const sleepThreshold = 500 * time.Millisecond
|
|
|
|
// EscapeSequences is a map of escape sequences to their VHS commands.
|
|
var EscapeSequences = map[string]string{
|
|
"\x1b[A": token.UP,
|
|
"\x1b[B": token.DOWN,
|
|
"\x1b[C": token.RIGHT,
|
|
"\x1b[D": token.LEFT,
|
|
"\x1b[1~": token.HOME,
|
|
"\x1b[2~": token.INSERT,
|
|
"\x1b[3~": token.DELETE,
|
|
"\x1b[4~": token.END,
|
|
"\x1b[5~": token.PAGE_UP,
|
|
"\x1b[6~": token.PAGE_DOWN,
|
|
"\x01": token.CTRL + "+A",
|
|
"\x02": token.CTRL + "+B",
|
|
"\x03": token.CTRL + "+C",
|
|
"\x04": token.CTRL + "+D",
|
|
"\x05": token.CTRL + "+E",
|
|
"\x06": token.CTRL + "+F",
|
|
"\x07": token.CTRL + "+G",
|
|
"\x08": token.BACKSPACE,
|
|
"\x09": token.TAB,
|
|
"\x0b": token.CTRL + "+K",
|
|
"\x0c": token.CTRL + "+L",
|
|
"\x0d": token.ENTER,
|
|
"\x0e": token.CTRL + "+N",
|
|
"\x0f": token.CTRL + "+O",
|
|
"\x10": token.CTRL + "+P",
|
|
"\x11": token.CTRL + "+Q",
|
|
"\x12": token.CTRL + "+R",
|
|
"\x13": token.CTRL + "+S",
|
|
"\x14": token.CTRL + "+T",
|
|
"\x15": token.CTRL + "+U",
|
|
"\x16": token.CTRL + "+V",
|
|
"\x17": token.CTRL + "+W",
|
|
"\x18": token.CTRL + "+X",
|
|
"\x19": token.CTRL + "+Y",
|
|
"\x1a": token.CTRL + "+Z",
|
|
"\x1b": token.ESCAPE,
|
|
"\x7f": token.BACKSPACE,
|
|
}
|
|
|
|
// Record is a command that starts a pseudo-terminal for the user to begin
|
|
// writing to, it records all the key presses on stdin and uses them to write
|
|
// Tape commands.
|
|
//
|
|
// vhs record > file.tape
|
|
//
|
|
//nolint:wrapcheck
|
|
func Record(_ *cobra.Command, _ []string) error {
|
|
command := exec.Command(shell)
|
|
|
|
command.Env = append(os.Environ(), "VHS_RECORD=true")
|
|
|
|
terminal, err := pty.Start(command)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := pty.InheritSize(os.Stdin, terminal); err != nil {
|
|
log.Printf("error resizing pty: %s", err)
|
|
}
|
|
|
|
prevState, err := term.MakeRaw(int(os.Stdin.Fd()))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// We'll need to display the stdin on the screen but we'll also need a copy to
|
|
// analyze later and create a tape file.
|
|
tape := &bytes.Buffer{}
|
|
in := io.MultiWriter(tape, terminal)
|
|
|
|
if shell != defaultShell {
|
|
_, _ = fmt.Fprintf(tape, "%s Shell %s\n", token.SET, shell)
|
|
}
|
|
|
|
go func() {
|
|
var length int
|
|
for {
|
|
length = tape.Len()
|
|
time.Sleep(sleepThreshold)
|
|
if length == tape.Len() {
|
|
// Tape has not changed in a while, write a Sleep command.
|
|
_, _ = fmt.Fprintf(tape, "\n%s\n", token.SLEEP)
|
|
}
|
|
}
|
|
}()
|
|
|
|
// Write to the buffer and PTY's stdin and stderr so that stdout is reserved
|
|
// for the output tape file.
|
|
go func() { _, _ = io.Copy(in, os.Stdin) }()
|
|
_, _ = io.Copy(os.Stderr, terminal)
|
|
|
|
// PTY cleanup and restore terminal
|
|
_ = terminal.Close()
|
|
_ = term.Restore(int(os.Stdin.Fd()), prevState)
|
|
|
|
fmt.Println(inputToTape(tape.String()))
|
|
return nil
|
|
}
|
|
|
|
var (
|
|
cursorResponse = regexp.MustCompile(`\x1b\[\d+;\d+R`)
|
|
oscResponse = regexp.MustCompile(`\x1b\]\d+;rgb:....\/....\/....(\x07|\x1b\\)`)
|
|
)
|
|
|
|
// inputToTape takes input from a PTY stdin and converts it into a tape file.
|
|
func inputToTape(input string) string {
|
|
// If the user exited the shell by typing exit don't record this in the
|
|
// command.
|
|
//
|
|
// NOTE: this is not very robust as if someone types exii<BS>t it will not work
|
|
// correctly and the exit will show up. In this case, the user should edit the
|
|
// tape file.
|
|
s := strings.TrimSuffix(strings.TrimSpace(input), "exit")
|
|
|
|
// Remove cursor / osc responses
|
|
s = cursorResponse.ReplaceAllString(s, "")
|
|
s = oscResponse.ReplaceAllString(s, "")
|
|
|
|
// Substitute escape sequences for commands
|
|
for sequence, command := range EscapeSequences {
|
|
s = strings.ReplaceAll(s, sequence, "\n"+command+"\n")
|
|
}
|
|
|
|
s = strings.ReplaceAll(s, "\n\n", "\n")
|
|
|
|
var sanitized strings.Builder
|
|
lines := strings.Split(s, "\n")
|
|
|
|
for i := 0; i < len(lines)-1; i++ {
|
|
// Group repeated commands to compress file and make it more readable.
|
|
repeat := 1
|
|
for lines[i] == lines[i+repeat] {
|
|
repeat++
|
|
if i+repeat == len(lines) {
|
|
break
|
|
}
|
|
}
|
|
i += repeat - 1
|
|
|
|
// We've encountered some non-command, assume that we need to type these
|
|
// characters.
|
|
if token.Type(lines[i]) == token.SLEEP { //nolint:nestif
|
|
sleep := sleepThreshold * time.Duration(repeat)
|
|
if sleep >= time.Minute {
|
|
_, _ = fmt.Fprintf(&sanitized, "%s %gs", token.Type(token.SLEEP), sleep.Seconds())
|
|
} else {
|
|
_, _ = fmt.Fprintf(&sanitized, "%s %s", token.Type(token.SLEEP), sleep)
|
|
}
|
|
} else if strings.HasPrefix(lines[i], token.CTRL) {
|
|
for j := 0; j < repeat; j++ {
|
|
sanitized.WriteString("Ctrl" + strings.TrimPrefix(lines[i], token.CTRL) + "\n")
|
|
}
|
|
continue
|
|
} else if strings.HasPrefix(lines[i], token.ALT) {
|
|
for j := 0; j < repeat; j++ {
|
|
sanitized.WriteString("Alt" + strings.TrimPrefix(lines[i], token.ALT) + "\n")
|
|
}
|
|
continue
|
|
} else if strings.HasPrefix(lines[i], token.SET) {
|
|
sanitized.WriteString("Set" + strings.TrimPrefix(lines[i], token.SET))
|
|
} else if token.IsCommand(token.Type(lines[i])) {
|
|
_, _ = fmt.Fprint(&sanitized, token.Type(lines[i]))
|
|
if repeat > 1 {
|
|
_, _ = fmt.Fprint(&sanitized, " ", repeat)
|
|
}
|
|
} else {
|
|
if lines[i] != "" {
|
|
_, _ = fmt.Fprintln(&sanitized, token.Type(token.TYPE), quote(lines[i]))
|
|
}
|
|
continue
|
|
}
|
|
sanitized.WriteRune('\n')
|
|
}
|
|
|
|
return sanitized.String()
|
|
}
|
|
|
|
// quote wraps a string in (single or double) quotes.
|
|
func quote(s string) string {
|
|
if strings.ContainsRune(s, '"') && strings.ContainsRune(s, '\'') {
|
|
return fmt.Sprintf("`%s`", s)
|
|
}
|
|
if strings.ContainsRune(s, '"') {
|
|
return fmt.Sprintf(`'%s'`, s)
|
|
}
|
|
return fmt.Sprintf(`"%s"`, s)
|
|
}
|