(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} 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.NexusTerminal.wailsService.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.NexusTerminal.wailsService.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); } } /** * Drops frontend recording state when a session is closed. The backend * finalises the recorder as part of DisconnectSession. * @param {string} sessionId */ cleanup(sessionId) { this.activeSessions.delete(sessionId); this._updateRecordingIndicator(sessionId, false); } /** * Opens the recordings browser modal. */ async openBrowser() { try { const [recordings, storageInfo] = await Promise.all([ window.NexusTerminal.wailsService.ListRecordings(), window.NexusTerminal.wailsService.GetRecordingStorageInfo(), ]); this._showBrowserModal(recordings || [], storageInfo || {}); } 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.NexusTerminal.wailsService.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.NexusTerminal.wailsService.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 * @param {Object} storageInfo * @private */ _showBrowserModal(recordings, storageInfo = {}) { const backdrop = createElement('div', 'modal-backdrop'); const modal = createElement('div', 'modal modal-lg'); let listHTML = ''; if (recordings.length === 0) { listHTML = '

No recordings yet. Use the record button on a terminal tab to capture a session.

'; } else { listHTML = '
'; for (const rec of recordings) { const date = new Date(rec.createdAt).toLocaleString(); const dur = _formatSeconds(rec.duration); const size = _formatBytes(rec.size); listHTML += `
${_esc(rec.title || rec.id)} ${date} · ${dur} · ${size} · ${rec.width}×${rec.height}
`; } listHTML += '
'; } const totalSize = _formatBytes(storageInfo.totalBytes || 0); const count = storageInfo.count ?? recordings.length; modal.innerHTML = ` `; 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); modal.querySelector('.recording-cleanup').addEventListener('click', async () => { const ageDays = parseInt(modal.querySelector('.recording-cleanup-age').value, 10) || 0; const maxBytes = parseInt(modal.querySelector('.recording-cleanup-size').value, 10) || 0; const status = modal.querySelector('.recording-cleanup-status'); const btn = modal.querySelector('.recording-cleanup'); status.textContent = ''; btn.disabled = true; try { const result = await window.NexusTerminal.wailsService.CleanupRecordings(ageDays, maxBytes); status.textContent = `Deleted ${result.deleted || 0}; freed ${_formatBytes(result.freedBytes || 0)}.`; const fresh = await window.NexusTerminal.wailsService.ListRecordings(); close(); this._showBrowserModal(fresh || [], await window.NexusTerminal.wailsService.GetRecordingStorageInfo()); } catch (err) { status.textContent = 'Cleanup failed: ' + err; } finally { btn.disabled = false; } }); // 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 = ` `; 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 => ` `).join(''); modal.innerHTML = ` `; 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.NexusTerminal.wailsService.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 = ` `; 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; const status = modal.querySelector('.import-status'); const confirmBtn = modal.querySelector('.import-confirm'); status.textContent = ''; if (!password) { status.textContent = 'Please enter the decryption password.'; return; } try { confirmBtn.disabled = true; confirmBtn.textContent = 'Importing...'; const count = await window.NexusTerminal.wailsService.ImportProfilesEncrypted(password, strategy); this.app.notifications.success(`Imported ${count} profile(s)`); await this.app.loadProfiles(); close(); } catch (err) { if (String(err).includes('cancelled')) { return; } status.textContent = this._formatImportError(err); this.app.notifications.error('Import failed'); } finally { confirmBtn.disabled = false; confirmBtn.textContent = 'Import'; } }); } _formatImportError(err) { const msg = String(err || 'Unknown import error'); return msg.replace(/\s+/g, ' ').trim(); } } // --------------------------------------------------------------------------- // 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; })();