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:
2026-04-16 00:00:00 +01:00
parent da6320c04b
commit 8c9ce67684
14 changed files with 2108 additions and 33 deletions
+209
View File
@@ -8,6 +8,7 @@ import (
"encoding/json"
"fmt"
"net"
"path/filepath"
"runtime"
"sync"
"time"
@@ -15,6 +16,7 @@ import (
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
"github.com/copilot-ssh-client/app/internal/audit"
"github.com/copilot-ssh-client/app/internal/exportimport"
"github.com/copilot-ssh-client/app/internal/models"
"github.com/copilot-ssh-client/app/internal/preferences"
"github.com/copilot-ssh-client/app/internal/secrets"
@@ -643,6 +645,213 @@ func (s *Service) StopPortForward(sessionID string, forwardID string) error {
return s.sessions.StopPortForward(sessionID, forwardID)
}
// ---------------------------------------------------------------------------
// Session recording
// ---------------------------------------------------------------------------
// StartRecording begins recording terminal I/O for a session.
func (s *Service) StartRecording(sessionID string, cols int, rows int) (string, error) {
return s.sessions.StartRecording(sessionID, cols, rows)
}
// StopRecording stops an active recording for a session.
func (s *Service) StopRecording(sessionID string) error {
return s.sessions.StopRecording(sessionID)
}
// IsRecording reports whether a session is currently being recorded.
func (s *Service) IsRecording(sessionID string) bool {
return s.sessions.IsRecording(sessionID)
}
// ListRecordings returns metadata for all saved recordings.
func (s *Service) ListRecordings() ([]map[string]interface{}, error) {
metas, err := s.sessions.ListRecordings()
if err != nil {
return nil, err
}
result := make([]map[string]interface{}, len(metas))
for i, m := range metas {
result[i] = map[string]interface{}{
"id": m.ID,
"sessionId": m.SessionID,
"title": m.Title,
"width": m.Width,
"height": m.Height,
"duration": m.Duration,
"size": m.Size,
"createdAt": m.CreatedAt.Format(time.RFC3339),
"filePath": m.FilePath,
}
}
return result, nil
}
// GetRecording returns the full recording data for playback.
func (s *Service) GetRecording(id string) (map[string]interface{}, error) {
hdr, events, err := s.sessions.GetRecording(id)
if err != nil {
return nil, err
}
evtList := make([][]interface{}, len(events))
for i, e := range events {
evtList[i] = []interface{}{e.Elapsed, string(e.Type), e.Data}
}
return map[string]interface{}{
"header": map[string]interface{}{
"version": hdr.Version,
"width": hdr.Width,
"height": hdr.Height,
"timestamp": hdr.Timestamp,
"title": hdr.Title,
},
"events": evtList,
}, nil
}
// DeleteRecording removes a saved recording.
func (s *Service) DeleteRecording(id string) error {
return s.sessions.DeleteRecording(id)
}
// ---------------------------------------------------------------------------
// Encrypted export / import
// ---------------------------------------------------------------------------
// ExportProfilesEncrypted encrypts and exports selected profiles to a file.
// The profileIDs parameter selects which profiles to include; if empty, all
// profiles are exported. Returns the file path on success.
func (s *Service) ExportProfilesEncrypted(profileIDs []string, password string) (string, error) {
if s.ctx == nil {
return "", fmt.Errorf("application context not set")
}
var profiles []models.Profile
if len(profileIDs) == 0 {
// Export all profiles.
all, err := s.store.ListProfiles()
if err != nil {
return "", fmt.Errorf("failed to list profiles: %w", err)
}
for _, p := range all {
profiles = append(profiles, *p)
}
} else {
for _, id := range profileIDs {
p, err := s.store.GetProfile(id)
if err != nil {
continue // skip missing profiles
}
profiles = append(profiles, *p)
}
}
if len(profiles) == 0 {
return "", fmt.Errorf("no profiles to export")
}
// Prompt for save location.
path, err := wailsRuntime.SaveFileDialog(s.ctx, wailsRuntime.SaveDialogOptions{
Title: "Export Encrypted Profiles",
DefaultFilename: "nexus-profiles.nexus",
Filters: []wailsRuntime.FileFilter{
{DisplayName: "Nexus Export (*.nexus)", Pattern: "*.nexus"},
},
})
if err != nil {
return "", fmt.Errorf("save dialog failed: %w", err)
}
if path == "" {
return "", fmt.Errorf("export cancelled")
}
// Ensure .nexus extension.
if filepath.Ext(path) != ".nexus" {
path += ".nexus"
}
if err := exportimport.EncryptAndExport(profiles, password, path); err != nil {
return "", err
}
s.auditLog.LogExport(len(profiles), false)
return path, nil
}
// ImportProfilesEncrypted decrypts and imports profiles from an encrypted file.
// Returns the number of profiles successfully imported.
func (s *Service) ImportProfilesEncrypted(password string, strategy string) (int, error) {
if s.ctx == nil {
return 0, fmt.Errorf("application context not set")
}
// Prompt for file selection.
path, err := wailsRuntime.OpenFileDialog(s.ctx, wailsRuntime.OpenDialogOptions{
Title: "Import Encrypted Profiles",
Filters: []wailsRuntime.FileFilter{
{DisplayName: "Nexus Export (*.nexus)", Pattern: "*.nexus"},
},
})
if err != nil {
return 0, fmt.Errorf("open dialog failed: %w", err)
}
if path == "" {
return 0, fmt.Errorf("import cancelled")
}
profiles, err := exportimport.DecryptAndImport(path, password)
if err != nil {
return 0, err
}
// Import using the existing strategy logic.
imported := 0
for _, p := range profiles {
switch strategy {
case "skip":
if existing, _ := s.store.GetProfile(p.ID); existing != nil {
continue
}
if err := s.store.CreateProfile(&p); err != nil {
continue
}
imported++
case "overwrite":
p.UpdatedAt = time.Now()
if existing, _ := s.store.GetProfile(p.ID); existing != nil {
if err := s.store.UpdateProfile(&p); err != nil {
continue
}
} else {
if err := s.store.CreateProfile(&p); err != nil {
continue
}
}
imported++
case "duplicate":
p.ID = uuid.New().String()
now := time.Now()
p.CreatedAt = now
p.UpdatedAt = now
if err := s.store.CreateProfile(&p); err != nil {
continue
}
imported++
default:
return 0, fmt.Errorf("unknown import strategy %q", strategy)
}
}
s.auditLog.LogImport(imported)
return imported, nil
}
// TestConnection performs a basic TCP reachability check to the given host and port.
// This verifies the network path without establishing a full protocol session.
func (s *Service) TestConnection(host string, port int) (map[string]interface{}, error) {
+7 -1
View File
@@ -6,6 +6,7 @@ package app
import (
"context"
"log"
"path/filepath"
"sync"
"github.com/wailsapp/wails/v2/pkg/runtime"
@@ -15,6 +16,7 @@ import (
"github.com/copilot-ssh-client/app/internal/config"
"github.com/copilot-ssh-client/app/internal/knownhosts"
"github.com/copilot-ssh-client/app/internal/preferences"
"github.com/copilot-ssh-client/app/internal/recording"
"github.com/copilot-ssh-client/app/internal/secrets"
"github.com/copilot-ssh-client/app/internal/sessions"
"github.com/copilot-ssh-client/app/internal/storage"
@@ -97,8 +99,12 @@ func (a *App) Startup(ctx context.Context) {
log.Printf("warning: failed to load preferences, using defaults: %v", err)
}
// Initialise session recording store.
recordDir := filepath.Join(cfg.DataDir, "recordings")
recordStore := recording.NewStore(recordDir)
// Initialise the session manager.
a.SessionManager = sessions.NewManager(store, a.SecretStore, a.KeychainStore, a.AuditLog)
a.SessionManager = sessions.NewManager(store, a.SecretStore, a.KeychainStore, a.AuditLog, recordStore)
a.SessionManager.SetContext(ctx)
// Wire the API service with all backing stores now that they exist.
+188
View File
@@ -0,0 +1,188 @@
// Package exportimport provides encrypted profile export and import.
// Profiles are serialised to JSON, then encrypted with AES-256-GCM using
// an Argon2id-derived key from a user-supplied password. No hard-coded
// secrets are used. The file format includes a version byte, salt, nonce,
// and ciphertext — everything needed for self-contained decryption.
package exportimport
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"time"
"golang.org/x/crypto/argon2"
"github.com/copilot-ssh-client/app/internal/models"
)
const (
// File format version. Increment if the envelope format changes.
formatVersion byte = 1
// Argon2id parameters for export encryption (same strength as the vault).
exportArgonTime = 3
exportArgonMemory = 128 * 1024 // 128 MiB
exportArgonThreads = 4
exportArgonKeyLen = 32 // AES-256
saltLength = 32
nonceLength = 12 // AES-GCM standard nonce size
)
// ExportBundle is the cleartext payload that gets encrypted.
type ExportBundle struct {
Version int `json:"version"`
ExportedAt string `json:"exportedAt"` // ISO 8601
Profiles []models.Profile `json:"profiles"`
Count int `json:"count"`
}
// EncryptAndExport serialises the given profiles, encrypts the JSON with
// AES-256-GCM (Argon2id-derived key), and writes the result to the file.
//
// File layout: [1 byte version][32 bytes salt][12 bytes nonce][ciphertext]
func EncryptAndExport(profiles []models.Profile, password, filePath string) error {
if password == "" {
return errors.New("exportimport: password cannot be empty")
}
if len(profiles) == 0 {
return errors.New("exportimport: no profiles to export")
}
bundle := ExportBundle{
Version: 1,
ExportedAt: time.Now().UTC().Format(time.RFC3339),
Profiles: profiles,
Count: len(profiles),
}
plaintext, err := json.MarshalIndent(bundle, "", " ")
if err != nil {
return fmt.Errorf("exportimport: failed to serialise profiles: %w", err)
}
// Generate random salt and nonce.
salt := make([]byte, saltLength)
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
return fmt.Errorf("exportimport: failed to generate salt: %w", err)
}
nonce := make([]byte, nonceLength)
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return fmt.Errorf("exportimport: failed to generate nonce: %w", err)
}
// Derive encryption key from password.
key := argon2.IDKey(
[]byte(password),
salt,
exportArgonTime,
exportArgonMemory,
exportArgonThreads,
exportArgonKeyLen,
)
// Encrypt with AES-256-GCM.
block, err := aes.NewCipher(key)
if err != nil {
return fmt.Errorf("exportimport: cipher init failed: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return fmt.Errorf("exportimport: GCM init failed: %w", err)
}
ciphertext := gcm.Seal(nil, nonce, plaintext, nil)
// Write the file: version + salt + nonce + ciphertext.
f, err := os.Create(filePath)
if err != nil {
return fmt.Errorf("exportimport: failed to create file: %w", err)
}
defer f.Close()
if _, err := f.Write([]byte{formatVersion}); err != nil {
return fmt.Errorf("exportimport: write version failed: %w", err)
}
if _, err := f.Write(salt); err != nil {
return fmt.Errorf("exportimport: write salt failed: %w", err)
}
if _, err := f.Write(nonce); err != nil {
return fmt.Errorf("exportimport: write nonce failed: %w", err)
}
if _, err := f.Write(ciphertext); err != nil {
return fmt.Errorf("exportimport: write ciphertext failed: %w", err)
}
return f.Sync()
}
// DecryptAndImport reads an encrypted export file, decrypts it using the
// given password, and returns the contained profiles.
func DecryptAndImport(filePath, password string) ([]models.Profile, error) {
if password == "" {
return nil, errors.New("exportimport: password cannot be empty")
}
data, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("exportimport: failed to read file: %w", err)
}
// Minimum size: version(1) + salt(32) + nonce(12) + at least 1 byte ciphertext + GCM tag(16).
minSize := 1 + saltLength + nonceLength + 1 + 16
if len(data) < minSize {
return nil, errors.New("exportimport: file is too small or corrupt")
}
// Parse envelope.
version := data[0]
if version != formatVersion {
return nil, fmt.Errorf("exportimport: unsupported format version %d", version)
}
salt := data[1 : 1+saltLength]
nonce := data[1+saltLength : 1+saltLength+nonceLength]
ciphertext := data[1+saltLength+nonceLength:]
// Derive decryption key.
key := argon2.IDKey(
[]byte(password),
salt,
exportArgonTime,
exportArgonMemory,
exportArgonThreads,
exportArgonKeyLen,
)
// Decrypt with AES-256-GCM.
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("exportimport: cipher init failed: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("exportimport: GCM init failed: %w", err)
}
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, errors.New("exportimport: decryption failed — wrong password or corrupt file")
}
// Parse the JSON bundle.
var bundle ExportBundle
if err := json.Unmarshal(plaintext, &bundle); err != nil {
return nil, fmt.Errorf("exportimport: invalid bundle JSON: %w", err)
}
return bundle.Profiles, nil
}
+131
View File
@@ -0,0 +1,131 @@
package exportimport
import (
"os"
"path/filepath"
"testing"
"github.com/copilot-ssh-client/app/internal/models"
)
func TestEncryptDecryptRoundTrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test-export.nexus")
profiles := []models.Profile{
{
ID: "test-1",
DisplayName: "Test Server",
Protocol: models.ProtocolSSH,
Host: "example.com",
Port: 22,
Username: "admin",
},
{
ID: "test-2",
DisplayName: "Dev Box",
Protocol: models.ProtocolSSH,
Host: "dev.example.com",
Port: 2222,
Username: "developer",
},
}
password := "strong-test-password-2024!"
// Encrypt and export.
if err := EncryptAndExport(profiles, password, path); err != nil {
t.Fatalf("EncryptAndExport failed: %v", err)
}
// Verify file exists and has content.
info, err := os.Stat(path)
if err != nil {
t.Fatalf("exported file not found: %v", err)
}
if info.Size() == 0 {
t.Fatal("exported file is empty")
}
// Decrypt and import.
imported, err := DecryptAndImport(path, password)
if err != nil {
t.Fatalf("DecryptAndImport failed: %v", err)
}
if len(imported) != len(profiles) {
t.Fatalf("expected %d profiles, got %d", len(profiles), len(imported))
}
for i, p := range imported {
if p.ID != profiles[i].ID {
t.Errorf("profile %d: expected ID %q, got %q", i, profiles[i].ID, p.ID)
}
if p.DisplayName != profiles[i].DisplayName {
t.Errorf("profile %d: expected DisplayName %q, got %q", i, profiles[i].DisplayName, p.DisplayName)
}
if p.Host != profiles[i].Host {
t.Errorf("profile %d: expected Host %q, got %q", i, profiles[i].Host, p.Host)
}
}
}
func TestDecryptWrongPassword(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test-export.nexus")
profiles := []models.Profile{
{
ID: "test-1",
DisplayName: "Test",
Protocol: models.ProtocolSSH,
Host: "example.com",
Port: 22,
},
}
if err := EncryptAndExport(profiles, "correct-password", path); err != nil {
t.Fatalf("EncryptAndExport failed: %v", err)
}
_, err := DecryptAndImport(path, "wrong-password")
if err == nil {
t.Fatal("expected error with wrong password, got nil")
}
}
func TestEncryptEmptyProfiles(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.nexus")
err := EncryptAndExport(nil, "password", path)
if err == nil {
t.Fatal("expected error for empty profiles, got nil")
}
}
func TestEncryptEmptyPassword(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.nexus")
profiles := []models.Profile{{ID: "1"}}
err := EncryptAndExport(profiles, "", path)
if err == nil {
t.Fatal("expected error for empty password, got nil")
}
}
func TestDecryptCorruptFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "corrupt.nexus")
// Write garbage data.
if err := os.WriteFile(path, []byte("corrupt"), 0600); err != nil {
t.Fatal(err)
}
_, err := DecryptAndImport(path, "password")
if err == nil {
t.Fatal("expected error for corrupt file, got nil")
}
}
+368
View File
@@ -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
}
+165
View File
@@ -0,0 +1,165 @@
package recording
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestRecorderWriteAndClose(t *testing.T) {
dir := t.TempDir()
id := "test-session-001"
rec, err := NewRecorder(dir, id, "Test Recording", 80, 24)
if err != nil {
t.Fatalf("NewRecorder failed: %v", err)
}
// Write some output events.
rec.WriteOutput([]byte("Welcome to the server\r\n"))
rec.WriteOutput([]byte("$ "))
// Write some input events.
rec.WriteInput([]byte("ls -la\r\n"))
// Write more output.
rec.WriteOutput([]byte("total 42\r\n"))
if rec.EventCount() != 4 {
t.Errorf("expected 4 events, got %d", rec.EventCount())
}
if err := rec.Close(); err != nil {
t.Fatalf("Close failed: %v", err)
}
// Verify the file exists and has correct format.
path := filepath.Join(dir, id+".cast")
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("failed to read recording: %v", err)
}
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
if len(lines) < 5 { // 1 header + 4 events
t.Fatalf("expected at least 5 lines, got %d", len(lines))
}
// Verify header.
var hdr Header
if err := json.Unmarshal([]byte(lines[0]), &hdr); err != nil {
t.Fatalf("failed to parse header: %v", err)
}
if hdr.Version != 2 {
t.Errorf("expected version 2, got %d", hdr.Version)
}
if hdr.Width != 80 || hdr.Height != 24 {
t.Errorf("expected 80x24, got %dx%d", hdr.Width, hdr.Height)
}
// Verify first event is output.
var evt []json.RawMessage
if err := json.Unmarshal([]byte(lines[1]), &evt); err != nil {
t.Fatalf("failed to parse first event: %v", err)
}
if len(evt) != 3 {
t.Fatalf("expected 3-element array, got %d", len(evt))
}
var evtType string
json.Unmarshal(evt[1], &evtType)
if evtType != "o" {
t.Errorf("expected event type 'o', got %q", evtType)
}
}
func TestStoreListAndGet(t *testing.T) {
dir := t.TempDir()
// Create a recording.
rec, err := NewRecorder(dir, "session-abc", "My Session", 120, 40)
if err != nil {
t.Fatalf("NewRecorder failed: %v", err)
}
rec.WriteOutput([]byte("hello world\r\n"))
time.Sleep(10 * time.Millisecond)
rec.WriteOutput([]byte("done\r\n"))
rec.Close()
// Use the store to list.
store := NewStore(dir)
metas, err := store.List()
if err != nil {
t.Fatalf("List failed: %v", err)
}
if len(metas) != 1 {
t.Fatalf("expected 1 recording, got %d", len(metas))
}
if metas[0].ID != "session-abc" {
t.Errorf("expected ID 'session-abc', got %q", metas[0].ID)
}
if metas[0].Width != 120 || metas[0].Height != 40 {
t.Errorf("unexpected dimensions: %dx%d", metas[0].Width, metas[0].Height)
}
// Read full recording.
hdr, events, err := store.Get("session-abc")
if err != nil {
t.Fatalf("Get failed: %v", err)
}
if hdr.Title != "My Session" {
t.Errorf("expected title 'My Session', got %q", hdr.Title)
}
if len(events) != 2 {
t.Errorf("expected 2 events, got %d", len(events))
}
}
func TestStoreDelete(t *testing.T) {
dir := t.TempDir()
rec, err := NewRecorder(dir, "to-delete", "Delete Me", 80, 24)
if err != nil {
t.Fatalf("NewRecorder failed: %v", err)
}
rec.WriteOutput([]byte("test\n"))
rec.Close()
store := NewStore(dir)
// Verify it exists.
metas, _ := store.List()
if len(metas) != 1 {
t.Fatalf("expected 1 recording, got %d", len(metas))
}
// Delete.
if err := store.Delete("to-delete"); err != nil {
t.Fatalf("Delete failed: %v", err)
}
// Verify it's gone.
metas, _ = store.List()
if len(metas) != 0 {
t.Errorf("expected 0 recordings, got %d", len(metas))
}
}
func TestDoubleClose(t *testing.T) {
dir := t.TempDir()
rec, err := NewRecorder(dir, "double-close", "Test", 80, 24)
if err != nil {
t.Fatalf("NewRecorder failed: %v", err)
}
rec.WriteOutput([]byte("test"))
if err := rec.Close(); err != nil {
t.Fatalf("first Close failed: %v", err)
}
// Second close should be a no-op.
if err := rec.Close(); err != nil {
t.Fatalf("second Close failed: %v", err)
}
}
+138 -1
View File
@@ -17,6 +17,7 @@ import (
"github.com/copilot-ssh-client/app/internal/githubclient"
"github.com/copilot-ssh-client/app/internal/models"
"github.com/copilot-ssh-client/app/internal/rawclient"
"github.com/copilot-ssh-client/app/internal/recording"
"github.com/copilot-ssh-client/app/internal/secrets"
"github.com/copilot-ssh-client/app/internal/sshclient"
"github.com/copilot-ssh-client/app/internal/storage"
@@ -32,6 +33,7 @@ type Session struct {
githubClient *githubclient.Client
githubCancel context.CancelFunc // stops the auto-refresh goroutine
profile *models.Profile
recorder *recording.Recorder // active recording, nil if not recording
}
// Manager handles the lifecycle of all active sessions.
@@ -44,17 +46,19 @@ type Manager struct {
secretStore *secrets.SecretStore
keychainStore *secrets.KeychainStore
auditLog *audit.Logger
recordStore *recording.Store
ctx context.Context // Wails app context for event emission
}
// NewManager creates a Manager wired to the given backing services.
func NewManager(store *storage.Store, secretStore *secrets.SecretStore, keychainStore *secrets.KeychainStore, auditLog *audit.Logger) *Manager {
func NewManager(store *storage.Store, secretStore *secrets.SecretStore, keychainStore *secrets.KeychainStore, auditLog *audit.Logger, recordStore *recording.Store) *Manager {
return &Manager{
sessions: make(map[string]*Session),
store: store,
secretStore: secretStore,
keychainStore: keychainStore,
auditLog: auditLog,
recordStore: recordStore,
}
}
@@ -205,6 +209,14 @@ func (m *Manager) WriteToSession(sessionID string, data []byte) error {
return fmt.Errorf("sessions: session %q not found", sessionID)
}
// Record input if recording is active.
m.mu.RLock()
rec := sess.recorder
m.mu.RUnlock()
if rec != nil {
rec.WriteInput(data)
}
switch {
case sess.sshClient != nil:
return sess.sshClient.Write(data)
@@ -440,6 +452,13 @@ func (m *Manager) connectSSH(sess *Session, getSecret func(prompt string) (strin
"sessionID": sess.Info.ID,
"data": base64.StdEncoding.EncodeToString(data),
})
// Write to active recording if present.
m.mu.RLock()
rec := sess.recorder
m.mu.RUnlock()
if rec != nil {
rec.WriteOutput(data)
}
},
// onError — report connection errors to the frontend.
func(err error) {
@@ -512,6 +531,13 @@ func (m *Manager) connectTelnet(sess *Session) error {
"sessionID": sess.Info.ID,
"data": base64.StdEncoding.EncodeToString(data),
})
// Write to active recording if present.
m.mu.RLock()
rec := sess.recorder
m.mu.RUnlock()
if rec != nil {
rec.WriteOutput(data)
}
},
// onError
func(err error) {
@@ -695,6 +721,111 @@ func (m *Manager) FetchGitHubRepos(sessionID string) ([]models.GitHubRepo, error
return repos, nil
}
// ---------------------------------------------------------------------------
// Session recording
// ---------------------------------------------------------------------------
// StartRecording begins recording terminal I/O for a session.
// Returns the recording ID (same as session ID) on success.
func (m *Manager) StartRecording(sessionID string, cols, rows int) (string, error) {
m.mu.Lock()
sess, ok := m.sessions[sessionID]
if !ok {
m.mu.Unlock()
return "", fmt.Errorf("sessions: session %q not found", sessionID)
}
if sess.recorder != nil {
m.mu.Unlock()
return "", fmt.Errorf("sessions: session %q is already being recorded", sessionID)
}
m.mu.Unlock()
if m.recordStore == nil {
return "", fmt.Errorf("sessions: recording store not configured")
}
title := fmt.Sprintf("%s — %s", sess.Info.ProfileName, sess.Info.Host)
rec, err := recording.NewRecorder(m.recordStore.Dir(), sessionID, title, cols, rows)
if err != nil {
return "", fmt.Errorf("sessions: failed to start recording: %w", err)
}
m.mu.Lock()
sess.recorder = rec
m.mu.Unlock()
m.emitEvent("session:recording:started", map[string]interface{}{
"sessionID": sessionID,
})
return sessionID, nil
}
// StopRecording stops an active recording for a session.
func (m *Manager) StopRecording(sessionID string) error {
m.mu.Lock()
sess, ok := m.sessions[sessionID]
if !ok {
m.mu.Unlock()
return fmt.Errorf("sessions: session %q not found", sessionID)
}
rec := sess.recorder
sess.recorder = nil
m.mu.Unlock()
if rec == nil {
return fmt.Errorf("sessions: session %q is not being recorded", sessionID)
}
if err := rec.Close(); err != nil {
return fmt.Errorf("sessions: failed to stop recording: %w", err)
}
m.emitEvent("session:recording:stopped", map[string]interface{}{
"sessionID": sessionID,
"duration": rec.Duration(),
"events": rec.EventCount(),
})
return nil
}
// IsRecording reports whether a session is currently being recorded.
func (m *Manager) IsRecording(sessionID string) bool {
m.mu.RLock()
defer m.mu.RUnlock()
sess, ok := m.sessions[sessionID]
if !ok {
return false
}
return sess.recorder != nil
}
// ListRecordings returns metadata for all saved recordings.
func (m *Manager) ListRecordings() ([]recording.RecordingMeta, error) {
if m.recordStore == nil {
return nil, fmt.Errorf("sessions: recording store not configured")
}
return m.recordStore.List()
}
// GetRecording returns the full recording data (header + events) for playback.
func (m *Manager) GetRecording(id string) (*recording.Header, []recording.Event, error) {
if m.recordStore == nil {
return nil, nil, fmt.Errorf("sessions: recording store not configured")
}
return m.recordStore.Get(id)
}
// DeleteRecording removes a saved recording.
func (m *Manager) DeleteRecording(id string) error {
if m.recordStore == nil {
return fmt.Errorf("sessions: recording store not configured")
}
return m.recordStore.Delete(id)
}
// ---------------------------------------------------------------------------
// Port-forwarding management
// ---------------------------------------------------------------------------
@@ -855,6 +986,12 @@ func (m *Manager) buildSecretCallback(profileID string, secretType string, mode
// closeSession shuts down the protocol client for a session.
func (m *Manager) closeSession(sess *Session) {
// Stop any active recording.
if sess.recorder != nil {
sess.recorder.Close()
sess.recorder = nil
}
if sess.sshClient != nil {
sess.sshClient.Close()
}
+8 -8
View File
@@ -13,7 +13,7 @@ import (
func TestNewManager(t *testing.T) {
// NewManager requires backing services; pass nil for lightweight tests
// where no actual connections are made.
mgr := NewManager(nil, nil, nil, nil)
mgr := NewManager(nil, nil, nil, nil, nil)
if mgr == nil {
t.Fatal("NewManager() returned nil")
}
@@ -30,7 +30,7 @@ func TestNewManager(t *testing.T) {
// ---------------------------------------------------------------------------
func TestListSessionsEmpty(t *testing.T) {
mgr := NewManager(nil, nil, nil, nil)
mgr := NewManager(nil, nil, nil, nil, nil)
sessions := mgr.ListSessions()
if len(sessions) != 0 {
t.Errorf("expected 0 sessions, got %d", len(sessions))
@@ -42,7 +42,7 @@ func TestListSessionsEmpty(t *testing.T) {
// ---------------------------------------------------------------------------
func TestCreateTabName(t *testing.T) {
mgr := NewManager(nil, nil, nil, nil)
mgr := NewManager(nil, nil, nil, nil, nil)
tests := []struct {
name string
@@ -93,7 +93,7 @@ func TestCreateTabName(t *testing.T) {
// ---------------------------------------------------------------------------
func TestRenameSessionNotFound(t *testing.T) {
mgr := NewManager(nil, nil, nil, nil)
mgr := NewManager(nil, nil, nil, nil, nil)
err := mgr.RenameSession("nonexistent-id", "New Name")
if err == nil {
t.Error("expected error when renaming non-existent session")
@@ -105,7 +105,7 @@ func TestRenameSessionNotFound(t *testing.T) {
// ---------------------------------------------------------------------------
func TestPinSessionNotFound(t *testing.T) {
mgr := NewManager(nil, nil, nil, nil)
mgr := NewManager(nil, nil, nil, nil, nil)
err := mgr.PinSession("nonexistent-id", true)
if err == nil {
t.Error("expected error when pinning non-existent session")
@@ -117,7 +117,7 @@ func TestPinSessionNotFound(t *testing.T) {
// ---------------------------------------------------------------------------
func TestGetSessionNotFound(t *testing.T) {
mgr := NewManager(nil, nil, nil, nil)
mgr := NewManager(nil, nil, nil, nil, nil)
_, err := mgr.GetSession("nonexistent-id")
if err == nil {
t.Error("expected error when getting non-existent session")
@@ -129,7 +129,7 @@ func TestGetSessionNotFound(t *testing.T) {
// ---------------------------------------------------------------------------
func TestDisconnectNotFound(t *testing.T) {
mgr := NewManager(nil, nil, nil, nil)
mgr := NewManager(nil, nil, nil, nil, nil)
err := mgr.Disconnect("nonexistent-id")
if err == nil {
t.Error("expected error when disconnecting non-existent session")
@@ -141,7 +141,7 @@ func TestDisconnectNotFound(t *testing.T) {
// ---------------------------------------------------------------------------
func TestCloseAllEmpty(t *testing.T) {
mgr := NewManager(nil, nil, nil, nil)
mgr := NewManager(nil, nil, nil, nil, nil)
// Should not panic.
mgr.CloseAll()
if len(mgr.ListSessions()) != 0 {