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
629 lines
23 KiB
JavaScript
629 lines
23 KiB
JavaScript
(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;
|
||
|
||
})();
|