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/terminal.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/modals.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">
|
||||
<span>+ New</span>
|
||||
</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 -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2"
|
||||
@@ -71,6 +72,25 @@
|
||||
<line x1="12" y1="3" x2="12" y2="15"></line>
|
||||
</svg>
|
||||
</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">
|
||||
<!-- Cog icon -->
|
||||
<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/commands.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/app.js"></script>
|
||||
</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.
|
||||
*/
|
||||
|
||||
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 {
|
||||
constructor() {
|
||||
@@ -19,6 +19,8 @@ class App {
|
||||
this.commandPalette = new CommandPalette(this);
|
||||
this.secretPrompt = new SecretPrompt(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}`);
|
||||
});
|
||||
|
||||
// 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
|
||||
window.runtime.EventsOn('session:debug', (payload) => {
|
||||
console.debug('[session:debug]', payload);
|
||||
@@ -336,6 +349,15 @@ class App {
|
||||
this.splitPane('horizontal');
|
||||
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-settings', () => this.settings.open());
|
||||
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-empty-connect', () => this.profileEditor.openNew());
|
||||
this._bindClick('btn-empty-quick', () => this.quickConnect());
|
||||
@@ -452,34 +476,17 @@ class App {
|
||||
// ── Import / Export ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Triggers a profile import dialog.
|
||||
* Triggers the encrypted profile import dialog.
|
||||
*/
|
||||
async importProfiles() {
|
||||
try {
|
||||
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);
|
||||
}
|
||||
}
|
||||
this.exportImport.showImportModal();
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers a profile export flow.
|
||||
* Triggers the encrypted profile export dialog.
|
||||
*/
|
||||
async exportProfiles() {
|
||||
try {
|
||||
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);
|
||||
}
|
||||
}
|
||||
this.exportImport.showExportModal();
|
||||
}
|
||||
|
||||
// ── 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 DeleteRecording(arg1:string):Promise<void>;
|
||||
|
||||
export function DisconnectSession(arg1:string):Promise<void>;
|
||||
|
||||
export function DuplicateProfile(arg1:string):Promise<models.Profile>;
|
||||
|
||||
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 GetPreferences():Promise<models.AppPreferences>;
|
||||
|
||||
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 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 IsRecording(arg1:string):Promise<boolean>;
|
||||
|
||||
export function IsSecretStoreInitialised():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 ListRecordings():Promise<Array<Record<string, any>>>;
|
||||
|
||||
export function ListSessions():Promise<Array<models.SessionInfo>>;
|
||||
|
||||
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 StartRecording(arg1:string,arg2:number,arg3:number):Promise<string>;
|
||||
|
||||
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 UnlockSecretStore(arg1:string):Promise<void>;
|
||||
|
||||
@@ -34,6 +34,10 @@ export function DeleteProfileSecrets(arg1) {
|
||||
return window['go']['api']['Service']['DeleteProfileSecrets'](arg1);
|
||||
}
|
||||
|
||||
export function DeleteRecording(arg1) {
|
||||
return window['go']['api']['Service']['DeleteRecording'](arg1);
|
||||
}
|
||||
|
||||
export function DisconnectSession(arg1) {
|
||||
return window['go']['api']['Service']['DisconnectSession'](arg1);
|
||||
}
|
||||
@@ -46,6 +50,10 @@ export function 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() {
|
||||
return window['go']['api']['Service']['GetAppInfo']();
|
||||
}
|
||||
@@ -58,6 +66,10 @@ export function GetProfile(arg1) {
|
||||
return window['go']['api']['Service']['GetProfile'](arg1);
|
||||
}
|
||||
|
||||
export function GetRecording(arg1) {
|
||||
return window['go']['api']['Service']['GetRecording'](arg1);
|
||||
}
|
||||
|
||||
export function 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);
|
||||
}
|
||||
|
||||
export function ImportProfilesEncrypted(arg1, arg2) {
|
||||
return window['go']['api']['Service']['ImportProfilesEncrypted'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function InitialiseSecretStore(arg1) {
|
||||
return window['go']['api']['Service']['InitialiseSecretStore'](arg1);
|
||||
}
|
||||
|
||||
export function IsRecording(arg1) {
|
||||
return window['go']['api']['Service']['IsRecording'](arg1);
|
||||
}
|
||||
|
||||
export function IsSecretStoreInitialised() {
|
||||
return window['go']['api']['Service']['IsSecretStoreInitialised']();
|
||||
}
|
||||
@@ -110,6 +130,10 @@ export function ListProfilesByTag(arg1) {
|
||||
return window['go']['api']['Service']['ListProfilesByTag'](arg1);
|
||||
}
|
||||
|
||||
export function ListRecordings() {
|
||||
return window['go']['api']['Service']['ListRecordings']();
|
||||
}
|
||||
|
||||
export function ListSessions() {
|
||||
return window['go']['api']['Service']['ListSessions']();
|
||||
}
|
||||
@@ -186,10 +210,18 @@ export function 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) {
|
||||
return window['go']['api']['Service']['StopPortForward'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function StopRecording(arg1) {
|
||||
return window['go']['api']['Service']['StopRecording'](arg1);
|
||||
}
|
||||
|
||||
export function TestConnection(arg1, arg2) {
|
||||
return window['go']['api']['Service']['TestConnection'](arg1, arg2);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user