Phase 3: Session recording/playback and encrypted profile export/import
Session Recording & Playback: - Add recording package with asciicast v2 format writer and reader - Recorder captures terminal I/O with timestamps from SSH and Telnet sessions - Input and output events recorded via hooks in session manager callbacks - Recording store manages .cast files in ~/Library/Application Support/NexusTerminal/recordings - Recording browser modal lists all saved recordings with metadata - Full playback viewer with embedded xterm.js terminal, progress scrubber, play/pause/restart controls, and 0.5x-10x speed adjustment - Recording indicator (pulsing red dot) shown on active tab during capture - Keyboard shortcut Cmd+Shift+R to toggle recording on active session - Tests for recording, store listing, deletion, and double-close safety Encrypted Profile Export/Import: - Add exportimport package with AES-256-GCM encryption (Argon2id key derivation) - File format: version byte + 32-byte salt + 12-byte nonce + ciphertext - Same Argon2id parameters as vault (128 MiB, 3 iterations, 4 threads) - Export modal with profile selection checkboxes and password entry - Import modal with password entry and conflict strategy (skip/overwrite/duplicate) - Native save/open file dialogs for .nexus files - Tests for round-trip encryption, wrong password rejection, corrupt files - Replaced placeholder import/export with full encrypted implementation UI: - Added export and recordings buttons to sidebar footer - Recording and playback CSS with charcoal design tokens - Wired RecordingManager and ExportImportUI into app.js
This commit is contained in:
@@ -0,0 +1,368 @@
|
||||
// Package recording provides session recording in asciicast v2 format.
|
||||
// Recordings capture terminal I/O with timestamps, enabling playback of
|
||||
// complete sessions for auditing, training, or troubleshooting.
|
||||
//
|
||||
// File format: https://docs.asciinema.org/manual/asciicast/v2/
|
||||
//
|
||||
// The header line is a JSON object with metadata (version, dimensions, etc.)
|
||||
// followed by one JSON array per event: [elapsed_secs, event_type, data].
|
||||
package recording
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// EventType distinguishes input from output events.
|
||||
type EventType string
|
||||
|
||||
const (
|
||||
// EventOutput represents data sent from the remote host to the terminal.
|
||||
EventOutput EventType = "o"
|
||||
// EventInput represents data sent from the user to the remote host.
|
||||
EventInput EventType = "i"
|
||||
)
|
||||
|
||||
// Header is the asciicast v2 file header (first line).
|
||||
type Header struct {
|
||||
Version int `json:"version"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
}
|
||||
|
||||
// Event is a single recorded I/O event: [elapsed_seconds, type, data].
|
||||
type Event struct {
|
||||
Elapsed float64 `json:"-"`
|
||||
Type EventType `json:"-"`
|
||||
Data string `json:"-"`
|
||||
}
|
||||
|
||||
// MarshalJSON serialises an event as the asciicast v2 array format.
|
||||
func (e Event) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal([]interface{}{e.Elapsed, string(e.Type), e.Data})
|
||||
}
|
||||
|
||||
// RecordingMeta describes a saved recording without loading the full data.
|
||||
type RecordingMeta struct {
|
||||
ID string `json:"id"`
|
||||
SessionID string `json:"sessionId"`
|
||||
Title string `json:"title"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Duration float64 `json:"duration"` // seconds
|
||||
Size int64 `json:"size"` // file size in bytes
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
FilePath string `json:"filePath"`
|
||||
}
|
||||
|
||||
// Recorder captures terminal I/O for a single session.
|
||||
type Recorder struct {
|
||||
mu sync.Mutex
|
||||
file *os.File
|
||||
encoder *json.Encoder
|
||||
startTime time.Time
|
||||
header Header
|
||||
eventCount int
|
||||
closed bool
|
||||
filePath string
|
||||
}
|
||||
|
||||
// NewRecorder creates a new recording file and writes the header.
|
||||
func NewRecorder(dir, id, title string, cols, rows int) (*Recorder, error) {
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("recording: failed to create directory: %w", err)
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("%s.cast", id)
|
||||
path := filepath.Join(dir, filename)
|
||||
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("recording: failed to create file: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
hdr := Header{
|
||||
Version: 2,
|
||||
Width: cols,
|
||||
Height: rows,
|
||||
Timestamp: now.Unix(),
|
||||
Title: title,
|
||||
Env: map[string]string{
|
||||
"TERM": "xterm-256color",
|
||||
},
|
||||
}
|
||||
|
||||
enc := json.NewEncoder(f)
|
||||
enc.SetEscapeHTML(false)
|
||||
if err := enc.Encode(hdr); err != nil {
|
||||
f.Close()
|
||||
os.Remove(path)
|
||||
return nil, fmt.Errorf("recording: failed to write header: %w", err)
|
||||
}
|
||||
|
||||
return &Recorder{
|
||||
file: f,
|
||||
encoder: enc,
|
||||
startTime: now,
|
||||
header: hdr,
|
||||
filePath: path,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// WriteOutput records terminal output (data from remote host).
|
||||
func (r *Recorder) WriteOutput(data []byte) {
|
||||
r.writeEvent(EventOutput, data)
|
||||
}
|
||||
|
||||
// WriteInput records terminal input (data from user).
|
||||
func (r *Recorder) WriteInput(data []byte) {
|
||||
r.writeEvent(EventInput, data)
|
||||
}
|
||||
|
||||
// writeEvent appends a timestamped event to the recording file.
|
||||
func (r *Recorder) writeEvent(eventType EventType, data []byte) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if r.closed || r.file == nil {
|
||||
return
|
||||
}
|
||||
|
||||
elapsed := time.Since(r.startTime).Seconds()
|
||||
evt := Event{
|
||||
Elapsed: elapsed,
|
||||
Type: eventType,
|
||||
Data: string(data),
|
||||
}
|
||||
|
||||
// Write as a JSON array line: [elapsed, "o"/"i", "data"]
|
||||
if err := r.encoder.Encode(evt); err != nil {
|
||||
// Non-fatal — continue recording even if one event fails.
|
||||
return
|
||||
}
|
||||
r.eventCount++
|
||||
}
|
||||
|
||||
// UpdateSize records a terminal resize (not part of asciicast v2 events,
|
||||
// but useful for future playback fidelity). Currently a no-op placeholder.
|
||||
func (r *Recorder) UpdateSize(cols, rows int) {
|
||||
// asciicast v2 does not have a standard resize event. Future extension
|
||||
// could use a custom event type like "r" with JSON dimensions.
|
||||
}
|
||||
|
||||
// Close flushes and closes the recording file.
|
||||
func (r *Recorder) Close() error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if r.closed {
|
||||
return nil
|
||||
}
|
||||
r.closed = true
|
||||
|
||||
if r.file != nil {
|
||||
if err := r.file.Sync(); err != nil {
|
||||
r.file.Close()
|
||||
return fmt.Errorf("recording: sync failed: %w", err)
|
||||
}
|
||||
return r.file.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Duration returns the elapsed time since recording started.
|
||||
func (r *Recorder) Duration() float64 {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return time.Since(r.startTime).Seconds()
|
||||
}
|
||||
|
||||
// FilePath returns the absolute path to the recording file.
|
||||
func (r *Recorder) FilePath() string {
|
||||
return r.filePath
|
||||
}
|
||||
|
||||
// EventCount returns the number of events recorded so far.
|
||||
func (r *Recorder) EventCount() int {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.eventCount
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recording store — manages listing, reading, and deleting recordings.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Store manages the recording directory.
|
||||
type Store struct {
|
||||
dir string
|
||||
}
|
||||
|
||||
// NewStore creates a recording store at the given directory.
|
||||
func NewStore(dir string) *Store {
|
||||
return &Store{dir: dir}
|
||||
}
|
||||
|
||||
// Dir returns the recordings directory path.
|
||||
func (s *Store) Dir() string {
|
||||
return s.dir
|
||||
}
|
||||
|
||||
// List returns metadata for all recordings in the store.
|
||||
func (s *Store) List() ([]RecordingMeta, error) {
|
||||
if err := os.MkdirAll(s.dir, 0700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(s.dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("recording: failed to read directory: %w", err)
|
||||
}
|
||||
|
||||
var results []RecordingMeta
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".cast") {
|
||||
continue
|
||||
}
|
||||
|
||||
meta, err := s.readMeta(entry.Name())
|
||||
if err != nil {
|
||||
continue // skip unreadable files
|
||||
}
|
||||
results = append(results, *meta)
|
||||
}
|
||||
|
||||
// Sort by creation time, newest first.
|
||||
sort.Slice(results, func(i, j int) bool {
|
||||
return results[i].CreatedAt.After(results[j].CreatedAt)
|
||||
})
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// Get reads the full recording (header + events) from a file.
|
||||
func (s *Store) Get(id string) (*Header, []Event, error) {
|
||||
path := filepath.Join(s.dir, id+".cast")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("recording: failed to read file: %w", err)
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
||||
if len(lines) < 1 {
|
||||
return nil, nil, fmt.Errorf("recording: empty file")
|
||||
}
|
||||
|
||||
// Parse header (first line).
|
||||
var hdr Header
|
||||
if err := json.Unmarshal([]byte(lines[0]), &hdr); err != nil {
|
||||
return nil, nil, fmt.Errorf("recording: invalid header: %w", err)
|
||||
}
|
||||
|
||||
// Parse events (remaining lines).
|
||||
events := make([]Event, 0, len(lines)-1)
|
||||
for _, line := range lines[1:] {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var raw []json.RawMessage
|
||||
if err := json.Unmarshal([]byte(line), &raw); err != nil {
|
||||
continue
|
||||
}
|
||||
if len(raw) != 3 {
|
||||
continue
|
||||
}
|
||||
|
||||
var elapsed float64
|
||||
var evtType, evtData string
|
||||
if err := json.Unmarshal(raw[0], &elapsed); err != nil {
|
||||
continue
|
||||
}
|
||||
if err := json.Unmarshal(raw[1], &evtType); err != nil {
|
||||
continue
|
||||
}
|
||||
if err := json.Unmarshal(raw[2], &evtData); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
events = append(events, Event{
|
||||
Elapsed: elapsed,
|
||||
Type: EventType(evtType),
|
||||
Data: evtData,
|
||||
})
|
||||
}
|
||||
|
||||
return &hdr, events, nil
|
||||
}
|
||||
|
||||
// Delete removes a recording file.
|
||||
func (s *Store) Delete(id string) error {
|
||||
path := filepath.Join(s.dir, id+".cast")
|
||||
if err := os.Remove(path); err != nil {
|
||||
return fmt.Errorf("recording: failed to delete: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readMeta reads just the header from a .cast file to produce metadata.
|
||||
func (s *Store) readMeta(filename string) (*RecordingMeta, error) {
|
||||
path := filepath.Join(s.dir, filename)
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var hdr Header
|
||||
dec := json.NewDecoder(f)
|
||||
if err := dec.Decode(&hdr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Calculate duration by scanning for last event timestamp.
|
||||
var duration float64
|
||||
for dec.More() {
|
||||
var raw json.RawMessage
|
||||
if err := dec.Decode(&raw); err != nil {
|
||||
break
|
||||
}
|
||||
var arr []json.RawMessage
|
||||
if err := json.Unmarshal(raw, &arr); err != nil || len(arr) < 1 {
|
||||
continue
|
||||
}
|
||||
var elapsed float64
|
||||
if err := json.Unmarshal(arr[0], &elapsed); err == nil {
|
||||
duration = elapsed
|
||||
}
|
||||
}
|
||||
|
||||
id := strings.TrimSuffix(filename, ".cast")
|
||||
|
||||
return &RecordingMeta{
|
||||
ID: id,
|
||||
SessionID: id, // recording ID matches session ID by default
|
||||
Title: hdr.Title,
|
||||
Width: hdr.Width,
|
||||
Height: hdr.Height,
|
||||
Duration: duration,
|
||||
Size: info.Size(),
|
||||
CreatedAt: time.Unix(hdr.Timestamp, 0),
|
||||
FilePath: path,
|
||||
}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user