Files
nexus/internal/app/app.go
T
andy 8c9ce67684 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
2026-04-16 00:00:00 +01:00

149 lines
4.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package app provides the core application lifecycle for Nexus Terminal.
// It wires together storage, secrets, sessions, preferences, known hosts,
// and the audit log, and exposes Wails lifecycle hooks.
package app
import (
"context"
"log"
"path/filepath"
"sync"
"github.com/wailsapp/wails/v2/pkg/runtime"
"github.com/copilot-ssh-client/app/internal/api"
"github.com/copilot-ssh-client/app/internal/audit"
"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"
)
// App is the central application struct. Wails binds its lifecycle methods
// (Startup, Shutdown, DomReady) and it holds references to every subsystem.
type App struct {
ctx context.Context
mu sync.Mutex
Config *config.AppConfig
Store *storage.Store
SecretStore *secrets.SecretStore
KeychainStore *secrets.KeychainStore
SessionManager *sessions.Manager
Prefs *preferences.Manager
KnownHosts *knownhosts.Store
AuditLog *audit.Logger
Service *api.Service
}
// NewApp creates a new App instance. Subsystems are not initialised until
// Startup is called by the Wails runtime. The API service is pre-created
// so it can be bound by Wails before Startup runs.
func NewApp() *App {
a := &App{}
// The API service is created eagerly so Wails can bind it. Its backing
// stores are nil at this point; they are wired during Startup.
a.Service = &api.Service{}
return a
}
// APIService returns the Wails-bound API service instance. This must be
// called before wails.Run so the binding is registered.
func (a *App) APIService() *api.Service {
return a.Service
}
// Startup is called by Wails when the application starts. It loads
// configuration, ensures required directories exist, and initialises all
// subsystems (storage, secret store, session manager, preferences).
func (a *App) Startup(ctx context.Context) {
a.mu.Lock()
defer a.mu.Unlock()
a.ctx = ctx
// Load platform-specific configuration paths.
cfg, err := config.Load()
if err != nil {
log.Fatalf("failed to load configuration: %v", err)
}
a.Config = cfg
if err := cfg.EnsureDirectories(); err != nil {
log.Fatalf("failed to create application directories: %v", err)
}
// Initialise SQLite storage (migrations run automatically on open).
store, err := storage.NewStore(cfg.DBPath)
if err != nil {
log.Fatalf("failed to open storage: %v", err)
}
a.Store = store
// Initialise secret stores (keychain preferred, encrypted file fallback).
a.SecretStore = secrets.NewSecretStore(cfg.SecretPath)
a.KeychainStore = secrets.NewKeychainStore("NexusTerminal")
// Initialise the audit logger backed by SQLite.
a.AuditLog = audit.NewLogger(store)
// Initialise known hosts store.
a.KnownHosts = knownhosts.NewStore(store)
// Initialise preferences manager.
a.Prefs = preferences.NewManager(store)
if err := a.Prefs.Load(); err != nil {
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, recordStore)
a.SessionManager.SetContext(ctx)
// Wire the API service with all backing stores now that they exist.
*a.Service = *api.NewService(store, a.SecretStore, a.KeychainStore, a.SessionManager, a.AuditLog, a.Prefs)
a.Service.SetContext(ctx)
log.Printf("Nexus Terminal initialised – data directory: %s", cfg.DataDir)
}
// Shutdown is called by Wails when the application is closing. It ensures all
// sessions are cleanly terminated and persistent storage is flushed.
func (a *App) Shutdown(ctx context.Context) {
a.mu.Lock()
defer a.mu.Unlock()
if a.SessionManager != nil {
a.SessionManager.CloseAll()
}
if a.Store != nil {
if err := a.Store.Close(); err != nil {
log.Printf("warning: error closing storage: %v", err)
}
}
// Lock the secret store to zero key material.
if a.SecretStore != nil {
a.SecretStore.Lock()
}
log.Println("Nexus Terminal shut down cleanly")
}
// DomReady is called by Wails once the frontend DOM is fully loaded. It emits
// a "backend:ready" event so the frontend knows the backend is prepared.
func (a *App) DomReady(ctx context.Context) {
runtime.EventsEmit(ctx, "backend:ready", map[string]interface{}{
"message": "Backend is ready",
})
log.Println("DOM ready – backend:ready event emitted")
}