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:
+22
-1
@@ -17,6 +17,7 @@
|
|||||||
<link rel="stylesheet" href="/src/css/tabs.css">
|
<link rel="stylesheet" href="/src/css/tabs.css">
|
||||||
<link rel="stylesheet" href="/src/css/terminal.css">
|
<link rel="stylesheet" href="/src/css/terminal.css">
|
||||||
<link rel="stylesheet" href="/src/css/splitpane.css">
|
<link rel="stylesheet" href="/src/css/splitpane.css">
|
||||||
|
<link rel="stylesheet" href="/src/css/recordings.css">
|
||||||
<link rel="stylesheet" href="/src/css/forms.css">
|
<link rel="stylesheet" href="/src/css/forms.css">
|
||||||
<link rel="stylesheet" href="/src/css/modals.css">
|
<link rel="stylesheet" href="/src/css/modals.css">
|
||||||
<link rel="stylesheet" href="/src/css/github.css">
|
<link rel="stylesheet" href="/src/css/github.css">
|
||||||
@@ -61,7 +62,7 @@
|
|||||||
<button class="btn btn-ghost btn-sm" id="btn-new-connection" title="New Connection">
|
<button class="btn btn-ghost btn-sm" id="btn-new-connection" title="New Connection">
|
||||||
<span>+ New</span>
|
<span>+ New</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-ghost btn-icon btn-sm" id="btn-import" title="Import">
|
<button class="btn btn-ghost btn-icon btn-sm" id="btn-import" title="Import Profiles">
|
||||||
<!-- Upload icon -->
|
<!-- Upload icon -->
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
|
||||||
fill="none" stroke="currentColor" stroke-width="2"
|
fill="none" stroke="currentColor" stroke-width="2"
|
||||||
@@ -71,6 +72,25 @@
|
|||||||
<line x1="12" y1="3" x2="12" y2="15"></line>
|
<line x1="12" y1="3" x2="12" y2="15"></line>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn-ghost btn-icon btn-sm" id="btn-export" title="Export Profiles">
|
||||||
|
<!-- Download icon -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||||
|
<polyline points="7 10 12 15 17 10"></polyline>
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-ghost btn-icon btn-sm" id="btn-recordings" title="Session Recordings">
|
||||||
|
<!-- Film icon -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
|
<circle cx="12" cy="12" r="3"></circle>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button class="btn btn-ghost btn-icon btn-sm" id="btn-settings" title="Settings">
|
<button class="btn btn-ghost btn-icon btn-sm" id="btn-settings" title="Settings">
|
||||||
<!-- Cog icon -->
|
<!-- Cog icon -->
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
|
||||||
@@ -177,6 +197,7 @@
|
|||||||
<script src="/src/js/settings.js"></script>
|
<script src="/src/js/settings.js"></script>
|
||||||
<script src="/src/js/commands.js"></script>
|
<script src="/src/js/commands.js"></script>
|
||||||
<script src="/src/js/secrets.js"></script>
|
<script src="/src/js/secrets.js"></script>
|
||||||
|
<script src="/src/js/recordings.js"></script>
|
||||||
<script src="/src/js/splitpane.js"></script>
|
<script src="/src/js/splitpane.js"></script>
|
||||||
<script src="/src/js/app.js"></script>
|
<script src="/src/js/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
/* ── Session Recording & Playback ────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* Recording indicator dot on tabs */
|
||||||
|
.recording-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--error, #ef4444);
|
||||||
|
margin-right: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
animation: recording-pulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes recording-pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.3; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Recordings list in browser modal */
|
||||||
|
.recordings-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recording-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--surface-2, #2a2a2e);
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recording-item:hover {
|
||||||
|
background: var(--surface-3, #333338);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recording-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recording-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary, #e4e4e7);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recording-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary, #71717a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recording-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Playback modal — larger to fit a terminal */
|
||||||
|
.modal-playback {
|
||||||
|
width: 90vw;
|
||||||
|
max-width: 900px;
|
||||||
|
max-height: 90vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playback-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playback-terminal {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 300px;
|
||||||
|
background: #1a1a1e;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
margin: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playback-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px 12px;
|
||||||
|
background: var(--surface-1, #1e1e22);
|
||||||
|
border-top: 1px solid var(--border, #3f3f46);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playback-progress {
|
||||||
|
flex: 1;
|
||||||
|
height: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
background: var(--surface-3, #333338);
|
||||||
|
border-radius: 2px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playback-progress::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent, #3b82f6);
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playback-time {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary, #71717a);
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 80px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playback-speed {
|
||||||
|
font-size: 11px;
|
||||||
|
background: var(--surface-2, #2a2a2e);
|
||||||
|
color: var(--text-primary, #e4e4e7);
|
||||||
|
border: 1px solid var(--border, #3f3f46);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Export/import profile list */
|
||||||
|
.export-profile-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
background: var(--surface-2, #2a2a2e);
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border, #3f3f46);
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-profile-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary, #e4e4e7);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-profile-item:hover {
|
||||||
|
background: var(--surface-3, #333338);
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-profile-item input[type="checkbox"] {
|
||||||
|
accent-color: var(--accent, #3b82f6);
|
||||||
|
}
|
||||||
+29
-22
@@ -4,7 +4,7 @@
|
|||||||
* Initialises the application shell, loads profiles, and sets up event listeners.
|
* Initialises the application shell, loads profiles, and sets up event listeners.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { State, Sidebar, TabManager, TerminalManager, ProfileEditor, RawViewer, Notifications, Settings, CommandPalette, SecretPrompt, SplitPaneManager, stateLabel } = window.NexusTerminal;
|
const { State, Sidebar, TabManager, TerminalManager, ProfileEditor, RawViewer, Notifications, Settings, CommandPalette, SecretPrompt, SplitPaneManager, RecordingManager, ExportImportUI, stateLabel } = window.NexusTerminal;
|
||||||
|
|
||||||
class App {
|
class App {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -19,6 +19,8 @@ class App {
|
|||||||
this.commandPalette = new CommandPalette(this);
|
this.commandPalette = new CommandPalette(this);
|
||||||
this.secretPrompt = new SecretPrompt(this);
|
this.secretPrompt = new SecretPrompt(this);
|
||||||
this.splitPanes = new SplitPaneManager(this);
|
this.splitPanes = new SplitPaneManager(this);
|
||||||
|
this.recordings = new RecordingManager(this);
|
||||||
|
this.exportImport = new ExportImportUI(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -196,6 +198,17 @@ class App {
|
|||||||
this.notifications.error(`Forward error: ${payload.error}`);
|
this.notifications.error(`Forward error: ${payload.error}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Recording events
|
||||||
|
window.runtime.EventsOn('session:recording:started', (payload) => {
|
||||||
|
this.recordings.activeSessions.add(payload.sessionID);
|
||||||
|
this.recordings._updateRecordingIndicator(payload.sessionID, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.runtime.EventsOn('session:recording:stopped', (payload) => {
|
||||||
|
this.recordings.activeSessions.delete(payload.sessionID);
|
||||||
|
this.recordings._updateRecordingIndicator(payload.sessionID, false);
|
||||||
|
});
|
||||||
|
|
||||||
// Telnet negotiation debug messages
|
// Telnet negotiation debug messages
|
||||||
window.runtime.EventsOn('session:debug', (payload) => {
|
window.runtime.EventsOn('session:debug', (payload) => {
|
||||||
console.debug('[session:debug]', payload);
|
console.debug('[session:debug]', payload);
|
||||||
@@ -336,6 +349,15 @@ class App {
|
|||||||
this.splitPane('horizontal');
|
this.splitPane('horizontal');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ctrl/Cmd+Shift+R — toggle recording on active session
|
||||||
|
if (mod && e.shiftKey && e.key === 'R') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (this.state.activeSessionId) {
|
||||||
|
this.recordings.toggle(this.state.activeSessionId);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,6 +371,8 @@ class App {
|
|||||||
this._bindClick('btn-new-connection', () => this.profileEditor.openNew());
|
this._bindClick('btn-new-connection', () => this.profileEditor.openNew());
|
||||||
this._bindClick('btn-settings', () => this.settings.open());
|
this._bindClick('btn-settings', () => this.settings.open());
|
||||||
this._bindClick('btn-import', () => this.importProfiles());
|
this._bindClick('btn-import', () => this.importProfiles());
|
||||||
|
this._bindClick('btn-export', () => this.exportProfiles());
|
||||||
|
this._bindClick('btn-recordings', () => this.recordings.openBrowser());
|
||||||
this._bindClick('btn-new-tab', () => this.profileEditor.openNew());
|
this._bindClick('btn-new-tab', () => this.profileEditor.openNew());
|
||||||
this._bindClick('btn-empty-connect', () => this.profileEditor.openNew());
|
this._bindClick('btn-empty-connect', () => this.profileEditor.openNew());
|
||||||
this._bindClick('btn-empty-quick', () => this.quickConnect());
|
this._bindClick('btn-empty-quick', () => this.quickConnect());
|
||||||
@@ -452,34 +476,17 @@ class App {
|
|||||||
// ── Import / Export ──────────────────────────────────────────────
|
// ── Import / Export ──────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Triggers a profile import dialog.
|
* Triggers the encrypted profile import dialog.
|
||||||
*/
|
*/
|
||||||
async importProfiles() {
|
async importProfiles() {
|
||||||
try {
|
this.exportImport.showImportModal();
|
||||||
const path = await window.go.api.Service.SelectFile('Import Profiles', '*.json');
|
|
||||||
if (!path) return;
|
|
||||||
this.notifications.info('Import from file is not yet supported. Selected: ' + path);
|
|
||||||
} catch (err) {
|
|
||||||
if (String(err) !== 'cancelled') {
|
|
||||||
this.notifications.error('Import failed: ' + err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Triggers a profile export flow.
|
* Triggers the encrypted profile export dialog.
|
||||||
*/
|
*/
|
||||||
async exportProfiles() {
|
async exportProfiles() {
|
||||||
try {
|
this.exportImport.showExportModal();
|
||||||
const json = await window.go.api.Service.ExportProfiles(false);
|
|
||||||
const path = await window.go.api.Service.SaveFile('Export Profiles', '*.json');
|
|
||||||
if (!path) return;
|
|
||||||
this.notifications.success('Profiles exported');
|
|
||||||
} catch (err) {
|
|
||||||
if (String(err) !== 'cancelled') {
|
|
||||||
this.notifications.error('Export failed: ' + err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Status bar ───────────────────────────────────────────────────
|
// ── Status bar ───────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,628 @@
|
|||||||
|
(function() {
|
||||||
|
/**
|
||||||
|
* Nexus Terminal — Session Recording & Playback.
|
||||||
|
* Provides controls for starting/stopping recordings on active sessions,
|
||||||
|
* listing saved recordings, and a terminal-based playback viewer with
|
||||||
|
* speed controls and a progress scrubber.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { createElement, formatDuration } = window.NexusTerminal;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Recording Manager
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class RecordingManager {
|
||||||
|
constructor(app) {
|
||||||
|
this.app = app;
|
||||||
|
/** @type {Set<string>} Session IDs currently being recorded. */
|
||||||
|
this.activeSessions = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggles recording for the given session.
|
||||||
|
* @param {string} sessionId
|
||||||
|
*/
|
||||||
|
async toggle(sessionId) {
|
||||||
|
if (this.activeSessions.has(sessionId)) {
|
||||||
|
await this.stop(sessionId);
|
||||||
|
} else {
|
||||||
|
await this.start(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts recording a session.
|
||||||
|
* @param {string} sessionId
|
||||||
|
*/
|
||||||
|
async start(sessionId) {
|
||||||
|
try {
|
||||||
|
const entry = this.app.terminals.terminals.get(sessionId);
|
||||||
|
const cols = entry ? entry.term.cols : 80;
|
||||||
|
const rows = entry ? entry.term.rows : 24;
|
||||||
|
|
||||||
|
await window.go.api.Service.StartRecording(sessionId, cols, rows);
|
||||||
|
this.activeSessions.add(sessionId);
|
||||||
|
this._updateRecordingIndicator(sessionId, true);
|
||||||
|
this.app.notifications.success('Recording started');
|
||||||
|
} catch (err) {
|
||||||
|
this.app.notifications.error('Failed to start recording: ' + err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops recording a session.
|
||||||
|
* @param {string} sessionId
|
||||||
|
*/
|
||||||
|
async stop(sessionId) {
|
||||||
|
try {
|
||||||
|
await window.go.api.Service.StopRecording(sessionId);
|
||||||
|
this.activeSessions.delete(sessionId);
|
||||||
|
this._updateRecordingIndicator(sessionId, false);
|
||||||
|
this.app.notifications.success('Recording saved');
|
||||||
|
} catch (err) {
|
||||||
|
this.app.notifications.error('Failed to stop recording: ' + err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the recordings browser modal.
|
||||||
|
*/
|
||||||
|
async openBrowser() {
|
||||||
|
try {
|
||||||
|
const recordings = await window.go.api.Service.ListRecordings();
|
||||||
|
this._showBrowserModal(recordings || []);
|
||||||
|
} catch (err) {
|
||||||
|
this.app.notifications.error('Failed to list recordings: ' + err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plays back a recording in a modal terminal viewer.
|
||||||
|
* @param {string} id — Recording ID.
|
||||||
|
*/
|
||||||
|
async playback(id) {
|
||||||
|
try {
|
||||||
|
const data = await window.go.api.Service.GetRecording(id);
|
||||||
|
this._showPlaybackModal(data);
|
||||||
|
} catch (err) {
|
||||||
|
this.app.notifications.error('Failed to load recording: ' + err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a recording by ID.
|
||||||
|
* @param {string} id
|
||||||
|
*/
|
||||||
|
async deleteRecording(id) {
|
||||||
|
try {
|
||||||
|
await window.go.api.Service.DeleteRecording(id);
|
||||||
|
this.app.notifications.success('Recording deleted');
|
||||||
|
} catch (err) {
|
||||||
|
this.app.notifications.error('Failed to delete recording: ' + err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Recording indicator on tabs ──────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the recording indicator on a session tab.
|
||||||
|
* @param {string} sessionId
|
||||||
|
* @param {boolean} recording
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_updateRecordingIndicator(sessionId, recording) {
|
||||||
|
const tab = document.querySelector(`.session-tab[data-session-id="${sessionId}"]`);
|
||||||
|
if (!tab) return;
|
||||||
|
|
||||||
|
let dot = tab.querySelector('.recording-dot');
|
||||||
|
if (recording) {
|
||||||
|
if (!dot) {
|
||||||
|
dot = createElement('span', 'recording-dot');
|
||||||
|
dot.title = 'Recording';
|
||||||
|
const label = tab.querySelector('.tab-label');
|
||||||
|
if (label) label.parentNode.insertBefore(dot, label);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (dot) dot.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Recordings browser modal ─────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a modal listing all saved recordings.
|
||||||
|
* @param {Array} recordings
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_showBrowserModal(recordings) {
|
||||||
|
const backdrop = createElement('div', 'modal-backdrop');
|
||||||
|
const modal = createElement('div', 'modal modal-lg');
|
||||||
|
|
||||||
|
let listHTML = '';
|
||||||
|
if (recordings.length === 0) {
|
||||||
|
listHTML = '<p class="text-muted" style="padding:1rem;text-align:center;">No recordings yet. Use the record button on a terminal tab to capture a session.</p>';
|
||||||
|
} else {
|
||||||
|
listHTML = '<div class="recordings-list">';
|
||||||
|
for (const rec of recordings) {
|
||||||
|
const date = new Date(rec.createdAt).toLocaleString();
|
||||||
|
const dur = _formatSeconds(rec.duration);
|
||||||
|
const size = _formatBytes(rec.size);
|
||||||
|
listHTML += `
|
||||||
|
<div class="recording-item" data-id="${rec.id}">
|
||||||
|
<div class="recording-info">
|
||||||
|
<span class="recording-title">${_esc(rec.title || rec.id)}</span>
|
||||||
|
<span class="recording-meta">${date} · ${dur} · ${size} · ${rec.width}×${rec.height}</span>
|
||||||
|
</div>
|
||||||
|
<div class="recording-actions">
|
||||||
|
<button class="btn btn-xs btn-primary rec-play" data-id="${rec.id}" title="Play">▶</button>
|
||||||
|
<button class="btn btn-xs btn-danger rec-delete" data-id="${rec.id}" title="Delete">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
listHTML += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 class="modal-title">Session Recordings</h3>
|
||||||
|
<button class="btn btn-icon btn-xs modal-close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" style="max-height:60vh;overflow-y:auto;">
|
||||||
|
${listHTML}
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-sm modal-close-btn">Close</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
backdrop.appendChild(modal);
|
||||||
|
document.body.appendChild(backdrop);
|
||||||
|
requestAnimationFrame(() => backdrop.classList.add('is-visible'));
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
backdrop.classList.remove('is-visible');
|
||||||
|
setTimeout(() => backdrop.remove(), 200);
|
||||||
|
};
|
||||||
|
|
||||||
|
modal.querySelector('.modal-close').addEventListener('click', close);
|
||||||
|
modal.querySelector('.modal-close-btn').addEventListener('click', close);
|
||||||
|
|
||||||
|
// Play buttons
|
||||||
|
modal.querySelectorAll('.rec-play').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
close();
|
||||||
|
this.playback(btn.dataset.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete buttons
|
||||||
|
modal.querySelectorAll('.rec-delete').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
await this.deleteRecording(btn.dataset.id);
|
||||||
|
const item = btn.closest('.recording-item');
|
||||||
|
if (item) item.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Playback modal with embedded terminal ────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a playback modal with an xterm.js terminal, progress bar, and speed controls.
|
||||||
|
* @param {Object} data — { header, events }
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_showPlaybackModal(data) {
|
||||||
|
const header = data.header;
|
||||||
|
const events = data.events || [];
|
||||||
|
if (events.length === 0) {
|
||||||
|
this.app.notifications.info('Recording is empty — nothing to play back.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalDuration = events[events.length - 1][0] || 1;
|
||||||
|
|
||||||
|
const backdrop = createElement('div', 'modal-backdrop');
|
||||||
|
const modal = createElement('div', 'modal modal-playback');
|
||||||
|
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 class="modal-title">${_esc(header.title || 'Recording Playback')}</h3>
|
||||||
|
<button class="btn btn-icon btn-xs modal-close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body playback-body">
|
||||||
|
<div id="playback-terminal" class="playback-terminal"></div>
|
||||||
|
<div class="playback-controls">
|
||||||
|
<button class="btn btn-xs btn-primary playback-play-pause" title="Play/Pause">▶</button>
|
||||||
|
<button class="btn btn-xs btn-outline playback-restart" title="Restart">⟲</button>
|
||||||
|
<input type="range" class="playback-progress" min="0" max="1000" value="0" />
|
||||||
|
<span class="playback-time">0:00 / ${_formatSeconds(totalDuration)}</span>
|
||||||
|
<select class="playback-speed form-input" style="width:auto;padding:2px 6px;font-size:11px;">
|
||||||
|
<option value="0.5">0.5×</option>
|
||||||
|
<option value="1" selected>1×</option>
|
||||||
|
<option value="2">2×</option>
|
||||||
|
<option value="5">5×</option>
|
||||||
|
<option value="10">10×</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
backdrop.appendChild(modal);
|
||||||
|
document.body.appendChild(backdrop);
|
||||||
|
requestAnimationFrame(() => backdrop.classList.add('is-visible'));
|
||||||
|
|
||||||
|
// Create xterm instance for playback.
|
||||||
|
const termContainer = modal.querySelector('#playback-terminal');
|
||||||
|
const { getTerminalTheme } = window.NexusTerminal;
|
||||||
|
const themeName = (this.app.state.preferences && this.app.state.preferences.terminalTheme) || 'charcoal';
|
||||||
|
const theme = getTerminalTheme ? getTerminalTheme(themeName) : {};
|
||||||
|
|
||||||
|
const term = new window.Terminal({
|
||||||
|
cols: header.width || 80,
|
||||||
|
rows: header.height || 24,
|
||||||
|
theme: theme,
|
||||||
|
fontFamily: '"JetBrains Mono", "Fira Code", "Cascadia Code", "Menlo", monospace',
|
||||||
|
fontSize: 13,
|
||||||
|
cursorBlink: false,
|
||||||
|
disableStdin: true,
|
||||||
|
scrollback: 10000,
|
||||||
|
});
|
||||||
|
term.open(termContainer);
|
||||||
|
|
||||||
|
const fitAddon = new window.FitAddon.FitAddon();
|
||||||
|
term.loadAddon(fitAddon);
|
||||||
|
setTimeout(() => fitAddon.fit(), 50);
|
||||||
|
|
||||||
|
// Playback state.
|
||||||
|
let playing = false;
|
||||||
|
let speed = 1;
|
||||||
|
let eventIndex = 0;
|
||||||
|
let playbackTimer = null;
|
||||||
|
let startRealTime = 0;
|
||||||
|
let pausedAt = 0; // elapsed virtual time when paused
|
||||||
|
|
||||||
|
const playPauseBtn = modal.querySelector('.playback-play-pause');
|
||||||
|
const restartBtn = modal.querySelector('.playback-restart');
|
||||||
|
const progressInput = modal.querySelector('.playback-progress');
|
||||||
|
const timeLabel = modal.querySelector('.playback-time');
|
||||||
|
const speedSelect = modal.querySelector('.playback-speed');
|
||||||
|
|
||||||
|
const updateTimeDisplay = (elapsed) => {
|
||||||
|
timeLabel.textContent = `${_formatSeconds(elapsed)} / ${_formatSeconds(totalDuration)}`;
|
||||||
|
progressInput.value = Math.round((elapsed / totalDuration) * 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleNext = () => {
|
||||||
|
if (!playing || eventIndex >= events.length) {
|
||||||
|
if (eventIndex >= events.length) {
|
||||||
|
playing = false;
|
||||||
|
playPauseBtn.textContent = '▶';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const evt = events[eventIndex];
|
||||||
|
const evtTime = evt[0]; // seconds from start
|
||||||
|
const realElapsed = (Date.now() - startRealTime) / 1000;
|
||||||
|
const virtualElapsed = pausedAt + realElapsed * speed;
|
||||||
|
const delay = Math.max(0, (evtTime - virtualElapsed) / speed * 1000);
|
||||||
|
|
||||||
|
playbackTimer = setTimeout(() => {
|
||||||
|
if (!playing) return;
|
||||||
|
|
||||||
|
// Write output events to the terminal.
|
||||||
|
if (evt[1] === 'o') {
|
||||||
|
term.write(evt[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
eventIndex++;
|
||||||
|
const currentVirtual = pausedAt + ((Date.now() - startRealTime) / 1000) * speed;
|
||||||
|
updateTimeDisplay(Math.min(currentVirtual, totalDuration));
|
||||||
|
scheduleNext();
|
||||||
|
}, delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
const play = () => {
|
||||||
|
if (eventIndex >= events.length) {
|
||||||
|
// Restart from beginning.
|
||||||
|
eventIndex = 0;
|
||||||
|
pausedAt = 0;
|
||||||
|
term.reset();
|
||||||
|
}
|
||||||
|
playing = true;
|
||||||
|
playPauseBtn.textContent = '⏸';
|
||||||
|
startRealTime = Date.now();
|
||||||
|
scheduleNext();
|
||||||
|
};
|
||||||
|
|
||||||
|
const pause = () => {
|
||||||
|
playing = false;
|
||||||
|
playPauseBtn.textContent = '▶';
|
||||||
|
if (playbackTimer) {
|
||||||
|
clearTimeout(playbackTimer);
|
||||||
|
playbackTimer = null;
|
||||||
|
}
|
||||||
|
const realElapsed = (Date.now() - startRealTime) / 1000;
|
||||||
|
pausedAt = pausedAt + realElapsed * speed;
|
||||||
|
};
|
||||||
|
|
||||||
|
const seekTo = (fraction) => {
|
||||||
|
const wasPlaying = playing;
|
||||||
|
if (playing) pause();
|
||||||
|
|
||||||
|
const targetTime = fraction * totalDuration;
|
||||||
|
term.reset();
|
||||||
|
eventIndex = 0;
|
||||||
|
|
||||||
|
// Replay all events up to targetTime.
|
||||||
|
for (let i = 0; i < events.length; i++) {
|
||||||
|
if (events[i][0] > targetTime) break;
|
||||||
|
if (events[i][1] === 'o') {
|
||||||
|
term.write(events[i][2]);
|
||||||
|
}
|
||||||
|
eventIndex = i + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
pausedAt = targetTime;
|
||||||
|
updateTimeDisplay(targetTime);
|
||||||
|
|
||||||
|
if (wasPlaying) play();
|
||||||
|
};
|
||||||
|
|
||||||
|
playPauseBtn.addEventListener('click', () => {
|
||||||
|
if (playing) pause(); else play();
|
||||||
|
});
|
||||||
|
|
||||||
|
restartBtn.addEventListener('click', () => {
|
||||||
|
if (playing) pause();
|
||||||
|
term.reset();
|
||||||
|
eventIndex = 0;
|
||||||
|
pausedAt = 0;
|
||||||
|
updateTimeDisplay(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
progressInput.addEventListener('input', () => {
|
||||||
|
seekTo(parseInt(progressInput.value, 10) / 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
speedSelect.addEventListener('change', () => {
|
||||||
|
const newSpeed = parseFloat(speedSelect.value);
|
||||||
|
if (playing) {
|
||||||
|
const realElapsed = (Date.now() - startRealTime) / 1000;
|
||||||
|
pausedAt = pausedAt + realElapsed * speed;
|
||||||
|
speed = newSpeed;
|
||||||
|
startRealTime = Date.now();
|
||||||
|
if (playbackTimer) clearTimeout(playbackTimer);
|
||||||
|
scheduleNext();
|
||||||
|
} else {
|
||||||
|
speed = newSpeed;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-play on open.
|
||||||
|
setTimeout(play, 200);
|
||||||
|
|
||||||
|
// Close handler.
|
||||||
|
const close = () => {
|
||||||
|
if (playing) pause();
|
||||||
|
term.dispose();
|
||||||
|
backdrop.classList.remove('is-visible');
|
||||||
|
setTimeout(() => backdrop.remove(), 200);
|
||||||
|
};
|
||||||
|
|
||||||
|
modal.querySelector('.modal-close').addEventListener('click', close);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Encrypted Export/Import UI
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class ExportImportUI {
|
||||||
|
constructor(app) {
|
||||||
|
this.app = app;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows the encrypted export modal.
|
||||||
|
*/
|
||||||
|
async showExportModal() {
|
||||||
|
const profiles = this.app.state.profiles || [];
|
||||||
|
if (profiles.length === 0) {
|
||||||
|
this.app.notifications.info('No profiles to export.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const backdrop = createElement('div', 'modal-backdrop');
|
||||||
|
const modal = createElement('div', 'modal modal-md');
|
||||||
|
|
||||||
|
let profileListHTML = profiles.map(p => `
|
||||||
|
<label class="export-profile-item">
|
||||||
|
<input type="checkbox" value="${p.id}" checked />
|
||||||
|
<span>${_esc(p.displayName || p.host)} <small class="text-muted">(${p.protocol})</small></span>
|
||||||
|
</label>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 class="modal-title">Export Profiles (Encrypted)</h3>
|
||||||
|
<button class="btn btn-icon btn-xs modal-close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Select profiles to export</label>
|
||||||
|
<div class="export-profile-list" style="max-height:200px;overflow-y:auto;margin-bottom:12px;">
|
||||||
|
${profileListHTML}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Export password</label>
|
||||||
|
<input type="password" class="form-input export-password" placeholder="Enter a strong password" />
|
||||||
|
<small class="text-muted">This password will be required to import the file.</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Confirm password</label>
|
||||||
|
<input type="password" class="form-input export-password-confirm" placeholder="Confirm password" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-sm export-cancel">Cancel</button>
|
||||||
|
<button class="btn btn-sm btn-primary export-confirm">Export</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
backdrop.appendChild(modal);
|
||||||
|
document.body.appendChild(backdrop);
|
||||||
|
requestAnimationFrame(() => backdrop.classList.add('is-visible'));
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
backdrop.classList.remove('is-visible');
|
||||||
|
setTimeout(() => backdrop.remove(), 200);
|
||||||
|
};
|
||||||
|
|
||||||
|
modal.querySelector('.modal-close').addEventListener('click', close);
|
||||||
|
modal.querySelector('.export-cancel').addEventListener('click', close);
|
||||||
|
|
||||||
|
modal.querySelector('.export-confirm').addEventListener('click', async () => {
|
||||||
|
const password = modal.querySelector('.export-password').value;
|
||||||
|
const confirm = modal.querySelector('.export-password-confirm').value;
|
||||||
|
|
||||||
|
if (!password) {
|
||||||
|
this.app.notifications.error('Please enter a password.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password !== confirm) {
|
||||||
|
this.app.notifications.error('Passwords do not match.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkboxes = modal.querySelectorAll('.export-profile-list input[type="checkbox"]:checked');
|
||||||
|
const selectedIds = Array.from(checkboxes).map(cb => cb.value);
|
||||||
|
|
||||||
|
if (selectedIds.length === 0) {
|
||||||
|
this.app.notifications.error('Please select at least one profile.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const path = await window.go.api.Service.ExportProfilesEncrypted(selectedIds, password);
|
||||||
|
this.app.notifications.success(`Exported ${selectedIds.length} profile(s) to ${path}`);
|
||||||
|
close();
|
||||||
|
} catch (err) {
|
||||||
|
if (String(err).includes('cancelled')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.app.notifications.error('Export failed: ' + err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows the encrypted import modal.
|
||||||
|
*/
|
||||||
|
async showImportModal() {
|
||||||
|
const backdrop = createElement('div', 'modal-backdrop');
|
||||||
|
const modal = createElement('div', 'modal modal-md');
|
||||||
|
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 class="modal-title">Import Profiles (Encrypted)</h3>
|
||||||
|
<button class="btn btn-icon btn-xs modal-close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Decryption password</label>
|
||||||
|
<input type="password" class="form-input import-password" placeholder="Enter the export password" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Conflict strategy</label>
|
||||||
|
<select class="form-input import-strategy">
|
||||||
|
<option value="skip">Skip existing</option>
|
||||||
|
<option value="overwrite">Overwrite existing</option>
|
||||||
|
<option value="duplicate">Create duplicates</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-sm import-cancel">Cancel</button>
|
||||||
|
<button class="btn btn-sm btn-primary import-confirm">Import</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
backdrop.appendChild(modal);
|
||||||
|
document.body.appendChild(backdrop);
|
||||||
|
requestAnimationFrame(() => backdrop.classList.add('is-visible'));
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
backdrop.classList.remove('is-visible');
|
||||||
|
setTimeout(() => backdrop.remove(), 200);
|
||||||
|
};
|
||||||
|
|
||||||
|
modal.querySelector('.modal-close').addEventListener('click', close);
|
||||||
|
modal.querySelector('.import-cancel').addEventListener('click', close);
|
||||||
|
|
||||||
|
modal.querySelector('.import-confirm').addEventListener('click', async () => {
|
||||||
|
const password = modal.querySelector('.import-password').value;
|
||||||
|
const strategy = modal.querySelector('.import-strategy').value;
|
||||||
|
|
||||||
|
if (!password) {
|
||||||
|
this.app.notifications.error('Please enter the decryption password.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const count = await window.go.api.Service.ImportProfilesEncrypted(password, strategy);
|
||||||
|
this.app.notifications.success(`Imported ${count} profile(s)`);
|
||||||
|
await this.app.loadProfiles();
|
||||||
|
close();
|
||||||
|
} catch (err) {
|
||||||
|
if (String(err).includes('cancelled')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.app.notifications.error('Import failed: ' + err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Utility helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function _esc(s) {
|
||||||
|
const el = document.createElement('span');
|
||||||
|
el.textContent = s || '';
|
||||||
|
return el.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _formatSeconds(secs) {
|
||||||
|
if (!secs || secs <= 0) return '0:00';
|
||||||
|
const m = Math.floor(secs / 60);
|
||||||
|
const s = Math.floor(secs % 60);
|
||||||
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _formatBytes(bytes) {
|
||||||
|
if (!bytes || bytes <= 0) return '0 B';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
let i = 0;
|
||||||
|
let val = bytes;
|
||||||
|
while (val >= 1024 && i < units.length - 1) {
|
||||||
|
val /= 1024;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return `${val.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Export to global namespace
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
window.NexusTerminal = window.NexusTerminal || {};
|
||||||
|
window.NexusTerminal.RecordingManager = RecordingManager;
|
||||||
|
window.NexusTerminal.ExportImportUI = ExportImportUI;
|
||||||
|
|
||||||
|
})();
|
||||||
Vendored
+16
@@ -19,24 +19,34 @@ export function DeleteProfile(arg1:string):Promise<void>;
|
|||||||
|
|
||||||
export function DeleteProfileSecrets(arg1:string):Promise<void>;
|
export function DeleteProfileSecrets(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function DeleteRecording(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function DisconnectSession(arg1:string):Promise<void>;
|
export function DisconnectSession(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function DuplicateProfile(arg1:string):Promise<models.Profile>;
|
export function DuplicateProfile(arg1:string):Promise<models.Profile>;
|
||||||
|
|
||||||
export function ExportProfiles(arg1:boolean):Promise<string>;
|
export function ExportProfiles(arg1:boolean):Promise<string>;
|
||||||
|
|
||||||
|
export function ExportProfilesEncrypted(arg1:Array<string>,arg2:string):Promise<string>;
|
||||||
|
|
||||||
export function GetAppInfo():Promise<Record<string, string>>;
|
export function GetAppInfo():Promise<Record<string, string>>;
|
||||||
|
|
||||||
export function GetPreferences():Promise<models.AppPreferences>;
|
export function GetPreferences():Promise<models.AppPreferences>;
|
||||||
|
|
||||||
export function GetProfile(arg1:string):Promise<models.Profile>;
|
export function GetProfile(arg1:string):Promise<models.Profile>;
|
||||||
|
|
||||||
|
export function GetRecording(arg1:string):Promise<Record<string, any>>;
|
||||||
|
|
||||||
export function GetSessionHistory(arg1:string):Promise<Array<Record<string, any>>>;
|
export function GetSessionHistory(arg1:string):Promise<Array<Record<string, any>>>;
|
||||||
|
|
||||||
export function ImportProfiles(arg1:string,arg2:string):Promise<number>;
|
export function ImportProfiles(arg1:string,arg2:string):Promise<number>;
|
||||||
|
|
||||||
|
export function ImportProfilesEncrypted(arg1:string,arg2:string):Promise<number>;
|
||||||
|
|
||||||
export function InitialiseSecretStore(arg1:string):Promise<void>;
|
export function InitialiseSecretStore(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function IsRecording(arg1:string):Promise<boolean>;
|
||||||
|
|
||||||
export function IsSecretStoreInitialised():Promise<boolean>;
|
export function IsSecretStoreInitialised():Promise<boolean>;
|
||||||
|
|
||||||
export function IsSecretStoreUnlocked():Promise<boolean>;
|
export function IsSecretStoreUnlocked():Promise<boolean>;
|
||||||
@@ -57,6 +67,8 @@ export function ListProfilesByProtocol(arg1:string):Promise<Array<models.Profile
|
|||||||
|
|
||||||
export function ListProfilesByTag(arg1:string):Promise<Array<models.Profile>>;
|
export function ListProfilesByTag(arg1:string):Promise<Array<models.Profile>>;
|
||||||
|
|
||||||
|
export function ListRecordings():Promise<Array<Record<string, any>>>;
|
||||||
|
|
||||||
export function ListSessions():Promise<Array<models.SessionInfo>>;
|
export function ListSessions():Promise<Array<models.SessionInfo>>;
|
||||||
|
|
||||||
export function LockSecretStore():Promise<void>;
|
export function LockSecretStore():Promise<void>;
|
||||||
@@ -95,8 +107,12 @@ export function SetProfileSecret(arg1:string,arg2:string,arg3:string):Promise<vo
|
|||||||
|
|
||||||
export function StartPortForward(arg1:string,arg2:models.PortForward):Promise<void>;
|
export function StartPortForward(arg1:string,arg2:models.PortForward):Promise<void>;
|
||||||
|
|
||||||
|
export function StartRecording(arg1:string,arg2:number,arg3:number):Promise<string>;
|
||||||
|
|
||||||
export function StopPortForward(arg1:string,arg2:string):Promise<void>;
|
export function StopPortForward(arg1:string,arg2:string):Promise<void>;
|
||||||
|
|
||||||
|
export function StopRecording(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function TestConnection(arg1:string,arg2:number):Promise<Record<string, any>>;
|
export function TestConnection(arg1:string,arg2:number):Promise<Record<string, any>>;
|
||||||
|
|
||||||
export function UnlockSecretStore(arg1:string):Promise<void>;
|
export function UnlockSecretStore(arg1:string):Promise<void>;
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ export function DeleteProfileSecrets(arg1) {
|
|||||||
return window['go']['api']['Service']['DeleteProfileSecrets'](arg1);
|
return window['go']['api']['Service']['DeleteProfileSecrets'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DeleteRecording(arg1) {
|
||||||
|
return window['go']['api']['Service']['DeleteRecording'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function DisconnectSession(arg1) {
|
export function DisconnectSession(arg1) {
|
||||||
return window['go']['api']['Service']['DisconnectSession'](arg1);
|
return window['go']['api']['Service']['DisconnectSession'](arg1);
|
||||||
}
|
}
|
||||||
@@ -46,6 +50,10 @@ export function ExportProfiles(arg1) {
|
|||||||
return window['go']['api']['Service']['ExportProfiles'](arg1);
|
return window['go']['api']['Service']['ExportProfiles'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ExportProfilesEncrypted(arg1, arg2) {
|
||||||
|
return window['go']['api']['Service']['ExportProfilesEncrypted'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function GetAppInfo() {
|
export function GetAppInfo() {
|
||||||
return window['go']['api']['Service']['GetAppInfo']();
|
return window['go']['api']['Service']['GetAppInfo']();
|
||||||
}
|
}
|
||||||
@@ -58,6 +66,10 @@ export function GetProfile(arg1) {
|
|||||||
return window['go']['api']['Service']['GetProfile'](arg1);
|
return window['go']['api']['Service']['GetProfile'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetRecording(arg1) {
|
||||||
|
return window['go']['api']['Service']['GetRecording'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function GetSessionHistory(arg1) {
|
export function GetSessionHistory(arg1) {
|
||||||
return window['go']['api']['Service']['GetSessionHistory'](arg1);
|
return window['go']['api']['Service']['GetSessionHistory'](arg1);
|
||||||
}
|
}
|
||||||
@@ -66,10 +78,18 @@ export function ImportProfiles(arg1, arg2) {
|
|||||||
return window['go']['api']['Service']['ImportProfiles'](arg1, arg2);
|
return window['go']['api']['Service']['ImportProfiles'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ImportProfilesEncrypted(arg1, arg2) {
|
||||||
|
return window['go']['api']['Service']['ImportProfilesEncrypted'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function InitialiseSecretStore(arg1) {
|
export function InitialiseSecretStore(arg1) {
|
||||||
return window['go']['api']['Service']['InitialiseSecretStore'](arg1);
|
return window['go']['api']['Service']['InitialiseSecretStore'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function IsRecording(arg1) {
|
||||||
|
return window['go']['api']['Service']['IsRecording'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function IsSecretStoreInitialised() {
|
export function IsSecretStoreInitialised() {
|
||||||
return window['go']['api']['Service']['IsSecretStoreInitialised']();
|
return window['go']['api']['Service']['IsSecretStoreInitialised']();
|
||||||
}
|
}
|
||||||
@@ -110,6 +130,10 @@ export function ListProfilesByTag(arg1) {
|
|||||||
return window['go']['api']['Service']['ListProfilesByTag'](arg1);
|
return window['go']['api']['Service']['ListProfilesByTag'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ListRecordings() {
|
||||||
|
return window['go']['api']['Service']['ListRecordings']();
|
||||||
|
}
|
||||||
|
|
||||||
export function ListSessions() {
|
export function ListSessions() {
|
||||||
return window['go']['api']['Service']['ListSessions']();
|
return window['go']['api']['Service']['ListSessions']();
|
||||||
}
|
}
|
||||||
@@ -186,10 +210,18 @@ export function StartPortForward(arg1, arg2) {
|
|||||||
return window['go']['api']['Service']['StartPortForward'](arg1, arg2);
|
return window['go']['api']['Service']['StartPortForward'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function StartRecording(arg1, arg2, arg3) {
|
||||||
|
return window['go']['api']['Service']['StartRecording'](arg1, arg2, arg3);
|
||||||
|
}
|
||||||
|
|
||||||
export function StopPortForward(arg1, arg2) {
|
export function StopPortForward(arg1, arg2) {
|
||||||
return window['go']['api']['Service']['StopPortForward'](arg1, arg2);
|
return window['go']['api']['Service']['StopPortForward'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function StopRecording(arg1) {
|
||||||
|
return window['go']['api']['Service']['StopRecording'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function TestConnection(arg1, arg2) {
|
export function TestConnection(arg1, arg2) {
|
||||||
return window['go']['api']['Service']['TestConnection'](arg1, arg2);
|
return window['go']['api']['Service']['TestConnection'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -15,6 +16,7 @@ import (
|
|||||||
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
|
||||||
"github.com/copilot-ssh-client/app/internal/audit"
|
"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/models"
|
||||||
"github.com/copilot-ssh-client/app/internal/preferences"
|
"github.com/copilot-ssh-client/app/internal/preferences"
|
||||||
"github.com/copilot-ssh-client/app/internal/secrets"
|
"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)
|
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.
|
// TestConnection performs a basic TCP reachability check to the given host and port.
|
||||||
// This verifies the network path without establishing a full protocol session.
|
// This verifies the network path without establishing a full protocol session.
|
||||||
func (s *Service) TestConnection(host string, port int) (map[string]interface{}, error) {
|
func (s *Service) TestConnection(host string, port int) (map[string]interface{}, error) {
|
||||||
|
|||||||
+7
-1
@@ -6,6 +6,7 @@ package app
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
"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/config"
|
||||||
"github.com/copilot-ssh-client/app/internal/knownhosts"
|
"github.com/copilot-ssh-client/app/internal/knownhosts"
|
||||||
"github.com/copilot-ssh-client/app/internal/preferences"
|
"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/secrets"
|
||||||
"github.com/copilot-ssh-client/app/internal/sessions"
|
"github.com/copilot-ssh-client/app/internal/sessions"
|
||||||
"github.com/copilot-ssh-client/app/internal/storage"
|
"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)
|
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.
|
// 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)
|
a.SessionManager.SetContext(ctx)
|
||||||
|
|
||||||
// Wire the API service with all backing stores now that they exist.
|
// Wire the API service with all backing stores now that they exist.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"github.com/copilot-ssh-client/app/internal/githubclient"
|
"github.com/copilot-ssh-client/app/internal/githubclient"
|
||||||
"github.com/copilot-ssh-client/app/internal/models"
|
"github.com/copilot-ssh-client/app/internal/models"
|
||||||
"github.com/copilot-ssh-client/app/internal/rawclient"
|
"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/secrets"
|
||||||
"github.com/copilot-ssh-client/app/internal/sshclient"
|
"github.com/copilot-ssh-client/app/internal/sshclient"
|
||||||
"github.com/copilot-ssh-client/app/internal/storage"
|
"github.com/copilot-ssh-client/app/internal/storage"
|
||||||
@@ -32,6 +33,7 @@ type Session struct {
|
|||||||
githubClient *githubclient.Client
|
githubClient *githubclient.Client
|
||||||
githubCancel context.CancelFunc // stops the auto-refresh goroutine
|
githubCancel context.CancelFunc // stops the auto-refresh goroutine
|
||||||
profile *models.Profile
|
profile *models.Profile
|
||||||
|
recorder *recording.Recorder // active recording, nil if not recording
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manager handles the lifecycle of all active sessions.
|
// Manager handles the lifecycle of all active sessions.
|
||||||
@@ -44,17 +46,19 @@ type Manager struct {
|
|||||||
secretStore *secrets.SecretStore
|
secretStore *secrets.SecretStore
|
||||||
keychainStore *secrets.KeychainStore
|
keychainStore *secrets.KeychainStore
|
||||||
auditLog *audit.Logger
|
auditLog *audit.Logger
|
||||||
|
recordStore *recording.Store
|
||||||
ctx context.Context // Wails app context for event emission
|
ctx context.Context // Wails app context for event emission
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewManager creates a Manager wired to the given backing services.
|
// 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{
|
return &Manager{
|
||||||
sessions: make(map[string]*Session),
|
sessions: make(map[string]*Session),
|
||||||
store: store,
|
store: store,
|
||||||
secretStore: secretStore,
|
secretStore: secretStore,
|
||||||
keychainStore: keychainStore,
|
keychainStore: keychainStore,
|
||||||
auditLog: auditLog,
|
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)
|
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 {
|
switch {
|
||||||
case sess.sshClient != nil:
|
case sess.sshClient != nil:
|
||||||
return sess.sshClient.Write(data)
|
return sess.sshClient.Write(data)
|
||||||
@@ -440,6 +452,13 @@ func (m *Manager) connectSSH(sess *Session, getSecret func(prompt string) (strin
|
|||||||
"sessionID": sess.Info.ID,
|
"sessionID": sess.Info.ID,
|
||||||
"data": base64.StdEncoding.EncodeToString(data),
|
"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.
|
// onError — report connection errors to the frontend.
|
||||||
func(err error) {
|
func(err error) {
|
||||||
@@ -512,6 +531,13 @@ func (m *Manager) connectTelnet(sess *Session) error {
|
|||||||
"sessionID": sess.Info.ID,
|
"sessionID": sess.Info.ID,
|
||||||
"data": base64.StdEncoding.EncodeToString(data),
|
"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
|
// onError
|
||||||
func(err error) {
|
func(err error) {
|
||||||
@@ -695,6 +721,111 @@ func (m *Manager) FetchGitHubRepos(sessionID string) ([]models.GitHubRepo, error
|
|||||||
return repos, nil
|
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
|
// 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.
|
// closeSession shuts down the protocol client for a session.
|
||||||
func (m *Manager) closeSession(sess *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 {
|
if sess.sshClient != nil {
|
||||||
sess.sshClient.Close()
|
sess.sshClient.Close()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
func TestNewManager(t *testing.T) {
|
func TestNewManager(t *testing.T) {
|
||||||
// NewManager requires backing services; pass nil for lightweight tests
|
// NewManager requires backing services; pass nil for lightweight tests
|
||||||
// where no actual connections are made.
|
// where no actual connections are made.
|
||||||
mgr := NewManager(nil, nil, nil, nil)
|
mgr := NewManager(nil, nil, nil, nil, nil)
|
||||||
if mgr == nil {
|
if mgr == nil {
|
||||||
t.Fatal("NewManager() returned nil")
|
t.Fatal("NewManager() returned nil")
|
||||||
}
|
}
|
||||||
@@ -30,7 +30,7 @@ func TestNewManager(t *testing.T) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func TestListSessionsEmpty(t *testing.T) {
|
func TestListSessionsEmpty(t *testing.T) {
|
||||||
mgr := NewManager(nil, nil, nil, nil)
|
mgr := NewManager(nil, nil, nil, nil, nil)
|
||||||
sessions := mgr.ListSessions()
|
sessions := mgr.ListSessions()
|
||||||
if len(sessions) != 0 {
|
if len(sessions) != 0 {
|
||||||
t.Errorf("expected 0 sessions, got %d", len(sessions))
|
t.Errorf("expected 0 sessions, got %d", len(sessions))
|
||||||
@@ -42,7 +42,7 @@ func TestListSessionsEmpty(t *testing.T) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func TestCreateTabName(t *testing.T) {
|
func TestCreateTabName(t *testing.T) {
|
||||||
mgr := NewManager(nil, nil, nil, nil)
|
mgr := NewManager(nil, nil, nil, nil, nil)
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -93,7 +93,7 @@ func TestCreateTabName(t *testing.T) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func TestRenameSessionNotFound(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")
|
err := mgr.RenameSession("nonexistent-id", "New Name")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("expected error when renaming non-existent session")
|
t.Error("expected error when renaming non-existent session")
|
||||||
@@ -105,7 +105,7 @@ func TestRenameSessionNotFound(t *testing.T) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func TestPinSessionNotFound(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)
|
err := mgr.PinSession("nonexistent-id", true)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("expected error when pinning non-existent session")
|
t.Error("expected error when pinning non-existent session")
|
||||||
@@ -117,7 +117,7 @@ func TestPinSessionNotFound(t *testing.T) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func TestGetSessionNotFound(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")
|
_, err := mgr.GetSession("nonexistent-id")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("expected error when getting non-existent session")
|
t.Error("expected error when getting non-existent session")
|
||||||
@@ -129,7 +129,7 @@ func TestGetSessionNotFound(t *testing.T) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func TestDisconnectNotFound(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")
|
err := mgr.Disconnect("nonexistent-id")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("expected error when disconnecting non-existent session")
|
t.Error("expected error when disconnecting non-existent session")
|
||||||
@@ -141,7 +141,7 @@ func TestDisconnectNotFound(t *testing.T) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func TestCloseAllEmpty(t *testing.T) {
|
func TestCloseAllEmpty(t *testing.T) {
|
||||||
mgr := NewManager(nil, nil, nil, nil)
|
mgr := NewManager(nil, nil, nil, nil, nil)
|
||||||
// Should not panic.
|
// Should not panic.
|
||||||
mgr.CloseAll()
|
mgr.CloseAll()
|
||||||
if len(mgr.ListSessions()) != 0 {
|
if len(mgr.ListSessions()) != 0 {
|
||||||
|
|||||||
Reference in New Issue
Block a user