Files
nexus/frontend/src/js/app.js
T
andy 8c9ce67684 Phase 3: Session recording/playback and encrypted profile export/import
Session Recording & Playback:
- Add recording package with asciicast v2 format writer and reader
- Recorder captures terminal I/O with timestamps from SSH and Telnet sessions
- Input and output events recorded via hooks in session manager callbacks
- Recording store manages .cast files in ~/Library/Application Support/NexusTerminal/recordings
- Recording browser modal lists all saved recordings with metadata
- Full playback viewer with embedded xterm.js terminal, progress scrubber,
  play/pause/restart controls, and 0.5x-10x speed adjustment
- Recording indicator (pulsing red dot) shown on active tab during capture
- Keyboard shortcut Cmd+Shift+R to toggle recording on active session
- Tests for recording, store listing, deletion, and double-close safety

Encrypted Profile Export/Import:
- Add exportimport package with AES-256-GCM encryption (Argon2id key derivation)
- File format: version byte + 32-byte salt + 12-byte nonce + ciphertext
- Same Argon2id parameters as vault (128 MiB, 3 iterations, 4 threads)
- Export modal with profile selection checkboxes and password entry
- Import modal with password entry and conflict strategy (skip/overwrite/duplicate)
- Native save/open file dialogs for .nexus files
- Tests for round-trip encryption, wrong password rejection, corrupt files
- Replaced placeholder import/export with full encrypted implementation

UI:
- Added export and recordings buttons to sidebar footer
- Recording and playback CSS with charcoal design tokens
- Wired RecordingManager and ExportImportUI into app.js
2026-04-16 00:00:00 +01:00

685 lines
26 KiB
JavaScript

(function() {
/**
* Nexus Terminal — main application entry point.
* Initialises the application shell, loads profiles, and sets up event listeners.
*/
const { State, Sidebar, TabManager, TerminalManager, ProfileEditor, RawViewer, Notifications, Settings, CommandPalette, SecretPrompt, SplitPaneManager, RecordingManager, ExportImportUI, stateLabel } = window.NexusTerminal;
class App {
constructor() {
this.state = new State();
this.notifications = new Notifications();
this.sidebar = new Sidebar(this);
this.tabs = new TabManager(this);
this.terminals = new TerminalManager(this);
this.profileEditor = new ProfileEditor(this);
this.rawViewer = new RawViewer(this);
this.settings = new Settings(this);
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);
}
/**
* Bootstraps the application after the DOM is ready.
* UI event binding happens synchronously so buttons always work.
* Backend data loading waits for the Wails runtime to be available.
*/
async init() {
// Phase 1 — bind all UI event handlers immediately (DOM-only, no backend).
this.setupUI();
this.setupKeyboard();
this.updateStatusBar();
// Phase 2 — wait for the Wails runtime, then load backend data and bind events.
await this._waitForWailsRuntime();
this.setupEvents();
await this.loadProfiles();
await this.loadPreferences();
await this.checkSecretStore();
this.updateStatusBar();
}
/**
* Polls for `window.go` and `window.runtime` to become available.
* Wails injects these into the webview; on fast machines they are
* present before DOMContentLoaded, but on slower starts there may
* be a brief delay.
* @returns {Promise<void>}
* @private
*/
_waitForWailsRuntime() {
return new Promise((resolve) => {
const maxWait = 5000;
const start = Date.now();
const check = () => {
if (window.go && window.runtime) {
resolve();
} else if (Date.now() - start < maxWait) {
setTimeout(check, 50);
} else {
console.warn('Wails runtime not detected after ' + maxWait + 'ms — proceeding anyway');
resolve();
}
};
check();
});
}
// ── Data loading ─────────────────────────────────────────────────
/**
* Fetches all connection profiles from the backend and renders the sidebar.
*/
async loadProfiles() {
try {
const profiles = await window.go.api.Service.ListProfiles();
this.state.setProfiles(profiles || []);
this.sidebar.render();
} catch (err) {
this.notifications.error('Failed to load profiles: ' + err);
}
}
/**
* Loads user preferences from the backend.
*/
async loadPreferences() {
try {
const prefs = await window.go.api.Service.GetPreferences();
this.state.setPreferences(prefs || {});
this._applyPreferences();
} catch (err) {
// Non-fatal — fall back to defaults
console.warn('Failed to load preferences:', err);
}
}
/**
* Queries the secret store status (initialised / unlocked).
*/
async checkSecretStore() {
try {
const initialised = await window.go.api.Service.IsSecretStoreInitialised();
this.state.setSecretStoreInitialised(!!initialised);
if (initialised) {
const unlocked = await window.go.api.Service.IsSecretStoreUnlocked();
this.state.setSecretStoreState(!!unlocked);
} else {
this.state.setSecretStoreState(false);
}
} catch (_) {
// Secret store may not be available yet
}
}
// ── Wails event listeners ────────────────────────────────────────
/**
* Registers listeners for backend events emitted via the Wails runtime.
* Guarded: silently skips if the runtime is unavailable.
*/
setupEvents() {
if (!window.runtime || typeof window.runtime.EventsOn !== 'function') {
console.warn('window.runtime not available — Wails event listeners not bound');
return;
}
// Incoming session data (terminal output)
window.runtime.EventsOn('session:data', (payload) => {
this.terminals.handleData(payload.sessionID, payload.data);
});
// Session state transitions (connecting → connected → disconnected)
window.runtime.EventsOn('session:state', (payload) => {
this.tabs.updateSessionState(payload.sessionID, payload.state, payload.error);
this.updateStatusBar();
// Once connected, ensure the backend PTY has the correct dimensions.
// The initial fit happens before the SSH handshake completes, so the
// onResize callback fires when the backend isn't ready. By the time
// the connection is established, xterm already has the right cols/rows
// and fitAddon.fit() won't trigger a new resize event. We must
// explicitly send the current terminal size to the backend.
if (payload.state === 'connected') {
const entry = this.terminals.terminals.get(payload.sessionID);
if (entry) {
const { cols, rows } = entry.term;
try {
window.go.api.Service.ResizeSession(payload.sessionID, cols, rows);
} catch (_) { /* noop */ }
}
// Also re-fit to catch any layout changes that occurred while connecting.
const fit = () => this.terminals.fitSession(payload.sessionID);
setTimeout(fit, 50);
setTimeout(fit, 300);
}
});
// Session closed by backend
window.runtime.EventsOn('session:closed', (payload) => {
this.tabs.handleSessionClosed(payload.sessionID);
this.updateStatusBar();
});
// RAW TCP data (separate channel for the raw viewer)
window.runtime.EventsOn('session:raw:data', (payload) => {
this.rawViewer.handleData(payload);
});
// GitHub repository data
window.runtime.EventsOn('session:github:repos', (payload) => {
window.NexusTerminal.GitHubViewer.updateRepos(
payload.sessionID, payload.login, payload.repos
);
});
// GitHub refresh errors
window.runtime.EventsOn('session:github:error', (payload) => {
window.NexusTerminal.GitHubViewer.showError(
payload.sessionID, payload.error
);
});
// Port-forwarding events
window.runtime.EventsOn('session:forward:started', (payload) => {
const fwd = payload.forward || {};
const label = fwd.label || `${fwd.bindHost || ''}:${fwd.bindPort} → ${fwd.destHost}:${fwd.destPort}`;
this.notifications.success(`Forward started: ${label}`);
});
window.runtime.EventsOn('session:forward:stopped', (payload) => {
this.notifications.info(`Forward stopped: ${payload.forwardID}`);
});
window.runtime.EventsOn('session:forward:error', (payload) => {
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);
});
// Backend ready event (emitted from DomReady)
window.runtime.EventsOn('backend:ready', () => {
console.log('Backend is ready');
});
// Host key verification requests
window.runtime.EventsOn('session:hostkey:verify', async (payload) => {
try {
const accepted = await this.secretPrompt.promptHostKeyVerification(
payload.host,
payload.keyType,
payload.fingerprint,
);
window.runtime.EventsEmit('session:hostkey:response', {
sessionID: payload.sessionID,
accepted,
});
} catch (_) {
window.runtime.EventsEmit('session:hostkey:response', {
sessionID: payload.sessionID,
accepted: false,
});
}
});
// Password/passphrase prompt requests from the backend
window.runtime.EventsOn('session:secret:prompt', async (payload) => {
try {
const value = await this.secretPrompt.prompt(
payload.title || 'Credentials Required',
payload.message || 'Enter your password or passphrase.',
);
window.runtime.EventsEmit('session:secret:response', {
requestID: payload.requestID,
value,
});
} catch (_) {
window.runtime.EventsEmit('session:secret:response', {
requestID: payload.requestID,
value: '',
cancelled: true,
});
}
});
}
// ── Keyboard shortcuts ───────────────────────────────────────────
/**
* Registers global keyboard shortcuts.
*/
setupKeyboard() {
document.addEventListener('keydown', (e) => {
const mod = e.ctrlKey || e.metaKey;
// Ctrl/Cmd+K — command palette
if (mod && e.key === 'k') {
e.preventDefault();
this.commandPalette.toggle();
return;
}
// Ctrl/Cmd+N — new connection
if (mod && e.key === 'n') {
e.preventDefault();
this.profileEditor.openNew();
return;
}
// Ctrl/Cmd+W — close active tab
if (mod && e.key === 'w') {
e.preventDefault();
this.tabs.closeActive();
return;
}
// Ctrl/Cmd+Tab — next tab
if (mod && e.key === 'Tab' && !e.shiftKey) {
e.preventDefault();
const next = this.tabs.getNextTab();
if (next) this.tabs.setActive(next);
return;
}
// Ctrl/Cmd+Shift+Tab — previous tab
if (mod && e.key === 'Tab' && e.shiftKey) {
e.preventDefault();
const prev = this.tabs.getPrevTab();
if (prev) this.tabs.setActive(prev);
return;
}
// Ctrl/Cmd+1-9 — switch to tab by index
if (mod && e.key >= '1' && e.key <= '9') {
e.preventDefault();
const idx = parseInt(e.key, 10) - 1;
const order = this.tabs.getTabOrder();
if (idx < order.length) {
this.tabs.setActive(order[idx]);
}
return;
}
// Ctrl/Cmd+, — settings
if (mod && e.key === ',') {
e.preventDefault();
this.settings.open();
return;
}
// Ctrl/Cmd+F — find in terminal
if (mod && e.key === 'f') {
e.preventDefault();
this._openTerminalSearch();
return;
}
// Escape — close any open overlays
if (e.key === 'Escape') {
this.commandPalette.close();
}
// Ctrl/Cmd+Shift+D — split pane vertically (left/right)
if (mod && e.shiftKey && e.key === 'D') {
e.preventDefault();
this.splitPane('vertical');
return;
}
// Ctrl/Cmd+Shift+E — split pane horizontally (top/bottom)
if (mod && e.shiftKey && e.key === 'E') {
e.preventDefault();
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;
}
});
}
// ── UI event wiring ──────────────────────────────────────────────
/**
* Binds click handlers to static UI elements.
*/
setupUI() {
var btnEl = document.getElementById('btn-new-connection');
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());
const searchInput = document.getElementById('profile-search');
if (searchInput) {
searchInput.addEventListener('input', (e) => this.sidebar.search(e.target.value));
}
// Window resize → re-fit active terminal (debounced)
const { debounce } = window.NexusTerminal;
window.addEventListener('resize', debounce(() => {
this.terminals.fitActive();
this.splitPanes.refitAll();
}, 100));
}
// ── Split pane ─────────────────────────────────────────────────
/**
* Splits the active session panel in the given direction.
* @param {'horizontal'|'vertical'} direction
*/
splitPane(direction) {
if (!this.state.activeSessionId) return;
if (direction === 'horizontal') {
this.splitPanes.splitHorizontal();
} else {
this.splitPanes.splitVertical();
}
}
// ── Connection lifecycle ─────────────────────────────────────────
/**
* Opens a new session for the given profile ID.
* @param {string} profileId
*/
async connectProfile(profileId) {
const profile = this.state.getProfile(profileId);
if (!profile) {
this.notifications.error('Profile not found');
return;
}
try {
const sessionInfo = await window.go.api.Service.ConnectSession(profileId);
this.state.addSession(sessionInfo);
this.tabs.addTab(sessionInfo);
// Create the appropriate viewer
const panelId = `panel-${sessionInfo.id}`;
if (profile.protocol === 'raw') {
this.rawViewer.createViewer(sessionInfo.id, panelId, profile);
} else if (profile.protocol === 'github') {
const container = document.getElementById(panelId);
if (container) {
window.NexusTerminal.GitHubViewer.createViewer(
sessionInfo.id, container, profile.displayName || profile.host
);
}
} else {
this.terminals.createTerminal(sessionInfo.id, panelId, profile);
}
this.notifications.info(`Connecting to ${profile.displayName || profile.host}…`);
} catch (err) {
this.notifications.error('Connection failed: ' + err);
}
}
/**
* Prompts the user for a host/port and opens a quick connection.
*/
async quickConnect() {
const input = await this._inputPrompt('Quick Connect', 'Enter host[:port]', 'e.g. 192.168.1.1:22');
if (!input || !input.trim()) return;
const parts = input.trim().split(':');
const host = parts[0];
const port = parts[1] ? parseInt(parts[1], 10) : 22;
const protocol = this.state.preferences.defaultProtocol || 'ssh';
try {
const profile = { protocol, host, port, displayName: host, tags: [] };
const sessionInfo = await window.go.api.Service.ConnectQuick(profile);
this.state.addSession(sessionInfo);
this.tabs.addTab(sessionInfo);
const panelId = `panel-${sessionInfo.id}`;
if (protocol === 'raw') {
this.rawViewer.createViewer(sessionInfo.id, panelId, { protocol, raw: {} });
} else {
this.terminals.createTerminal(sessionInfo.id, panelId, { protocol });
}
} catch (err) {
this.notifications.error('Quick connect failed: ' + err);
}
}
// ── Import / Export ──────────────────────────────────────────────
/**
* Triggers the encrypted profile import dialog.
*/
async importProfiles() {
this.exportImport.showImportModal();
}
/**
* Triggers the encrypted profile export dialog.
*/
async exportProfiles() {
this.exportImport.showExportModal();
}
// ── Status bar ───────────────────────────────────────────────────
/**
* Updates the status bar with current session info.
*/
updateStatusBar() {
const connEl = document.getElementById('status-connection');
const countEl = document.getElementById('status-session-count');
const vaultEl = document.getElementById('status-vault');
if (!connEl) return;
const sessions = this.state.getAllSessions();
const connected = sessions.filter((s) => s.state === 'connected').length;
const total = sessions.length;
const activeSession = this.state.activeSessionId
? this.state.getSession(this.state.activeSessionId)
: null;
if (activeSession) {
connEl.innerHTML = `<span class="status-dot is-${activeSession.state}"></span> ${stateLabel(activeSession.state)}`;
} else {
connEl.innerHTML = '<span class="status-dot is-disconnected"></span> No active session';
}
if (countEl) countEl.textContent = `${connected}/${total} sessions`;
if (vaultEl) {
vaultEl.textContent = this.state.secretStoreUnlocked ? '🔓 Unlocked' : '🔒 Locked';
}
}
// ── Internal helpers ─────────────────────────────────────────────
/**
* Applies loaded preferences to the UI.
* @private
*/
_applyPreferences() {
const prefs = this.state.preferences;
// Sidebar width
const sidebar = document.getElementById('sidebar');
if (sidebar && prefs.sidebarWidth) {
sidebar.style.width = `${prefs.sidebarWidth}px`;
}
// Status bar visibility
const statusBar = document.getElementById('status-bar');
if (statusBar) {
statusBar.style.display = prefs.showStatusBar === false ? 'none' : 'flex';
}
}
/**
* Opens an inline search bar for the active terminal.
* @private
*/
_openTerminalSearch() {
const sessionId = this.state.activeSessionId;
if (!sessionId) return;
// Check if search bar already exists
const panel = document.getElementById(`panel-${sessionId}`);
if (!panel || panel.querySelector('.terminal-search-bar')) return;
const bar = document.createElement('div');
bar.className = 'terminal-search-bar';
bar.innerHTML = `
<input type="text" class="form-input terminal-search-input" placeholder="Find…" />
<button class="btn btn-xs btn-outline terminal-search-prev">▲</button>
<button class="btn btn-xs btn-outline terminal-search-next">▼</button>
<button class="btn btn-xs btn-outline terminal-search-close">✕</button>
`;
const input = bar.querySelector('.terminal-search-input');
const closeBtn = bar.querySelector('.terminal-search-close');
const prevBtn = bar.querySelector('.terminal-search-prev');
const nextBtn = bar.querySelector('.terminal-search-next');
const doSearch = () => this.terminals.search(sessionId, input.value);
const doPrev = () => this.terminals.searchPrevious(sessionId, input.value);
input.addEventListener('input', doSearch);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.shiftKey ? doPrev() : doSearch();
}
if (e.key === 'Escape') {
bar.remove();
this.terminals.focusTerminal(sessionId);
}
});
nextBtn.addEventListener('click', doSearch);
prevBtn.addEventListener('click', doPrev);
closeBtn.addEventListener('click', () => {
bar.remove();
this.terminals.focusTerminal(sessionId);
});
panel.prepend(bar);
input.focus();
}
/**
* Safely binds a click handler to an element by ID (no-op if the element does not exist).
* @param {string} id - Element ID.
* @param {Function} handler
* @private
*/
_bindClick(id, handler) {
const el = document.getElementById(id);
if (el) {
el.addEventListener('click', (e) => {
try {
handler(e);
} catch (err) {
console.error('Click handler error for', id, err);
}
});
}
}
/**
* Shows a simple text-input prompt modal (replaces window.prompt which
* is unavailable in WKWebView).
* @param {string} title - Modal heading.
* @param {string} label - Label above the input.
* @param {string} [placeholder] - Placeholder text.
* @returns {Promise<string|null>} Resolves with the value or null if cancelled.
* @private
*/
_inputPrompt(title, label, placeholder) {
const createElement = window.NexusTerminal.createElement;
return new Promise((resolve) => {
const backdrop = createElement('div', 'modal-backdrop');
const modal = createElement('div', 'modal modal-sm');
modal.innerHTML = `
<div class="modal-header">
<h3 class="modal-title">${title}</h3>
<button class="btn btn-icon btn-xs modal-close">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">${label}</label>
<input class="form-input" type="text" placeholder="${placeholder || ''}" autofocus />
</div>
</div>
<div class="modal-footer">
<button class="btn btn-sm prompt-cancel">Cancel</button>
<button class="btn btn-sm btn-primary prompt-ok">Connect</button>
</div>
`;
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
requestAnimationFrame(() => backdrop.classList.add('is-visible'));
const input = modal.querySelector('input');
const close = (value) => {
backdrop.classList.remove('is-visible');
setTimeout(() => backdrop.remove(), 200);
resolve(value);
};
modal.querySelector('.modal-close').addEventListener('click', () => close(null));
modal.querySelector('.prompt-cancel').addEventListener('click', () => close(null));
modal.querySelector('.prompt-ok').addEventListener('click', () => close(input.value));
backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close(null); });
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') close(input.value);
if (e.key === 'Escape') close(null);
});
setTimeout(() => input.focus(), 50);
});
}
}
// ── Bootstrap ────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
try {
const app = new App();
app.init().catch((err) => {
console.error('App initialisation failed:', err);
});
} catch (err) {
console.error('App constructor failed:', err);
}
});
})();