(function() { /** * Nexus Terminal — connection profile editor. * Renders a slide-out drawer for creating and editing connection profiles. * Dynamically switches form sections based on the selected protocol. */ const { createElement, escapeHtml, generateId, $ } = window.NexusTerminal; /** * Default values mirroring the Go backend defaults. */ const DEFAULTS = { ssh: { port: 22, protocol: 'ssh', ssh: { authMethods: ['publickey', 'password', 'keyboard-interactive'], secretMode: 'saved', keySecretMode: 'saved', hostKeyPolicy: 'ask', terminalType: 'xterm-256color', keepaliveInterval: 30, keepaliveCountMax: 3, compression: false, jumpHosts: [], portForwards: [], startupCommand: '', envVars: {}, loginScripts: [], }, }, telnet: { port: 23, protocol: 'telnet', telnet: { localEcho: false, lineEnding: 'crlf', terminalType: 'xterm-256color', encoding: 'utf-8', negotiationDebug: false, lineMode: false, loginScripts: [], }, }, raw: { port: 23, protocol: 'raw', raw: { autoReconnect: false, lineEnding: 'crlf', encoding: 'utf-8', showTimestamps: false, showDirection: false, payloadPresets: [], }, }, github: { port: 443, protocol: 'github', host: 'github.com', github: { baseURL: 'https://api.github.com', secretMode: 'saved', refreshIntervalSecs: 300, }, }, }; class ProfileEditor { /** * @param {App} app */ constructor(app) { this.app = app; /** @type {HTMLElement|null} */ this.drawer = null; /** @type {Object|null} The profile currently being edited. */ this._profile = null; /** @type {boolean} */ this._isNew = false; } // ── Public API ─────────────────────────────────────────────────── /** * Opens the editor with a blank profile of the given protocol. * @param {string} [protocol='ssh'] */ openNew(protocol = 'ssh') { const profile = this._defaultProfile(protocol); this._isNew = true; this._profile = profile; this.render(profile, true); } /** * Opens the editor pre-populated with an existing profile. * @param {string} profileId */ openEdit(profileId) { const profile = this.app.state.getProfile(profileId); if (!profile) { this.app.notifications.error('Profile not found'); return; } this._isNew = false; this._profile = JSON.parse(JSON.stringify(profile)); // deep clone this.render(this._profile, false); } /** * Renders (or re-renders) the editor drawer. * @param {Object} profile * @param {boolean} isNew */ render(profile, isNew) { this.close(); const overlay = createElement('div', 'drawer-backdrop'); const drawer = createElement('div', 'drawer'); // ── Header ────────────────────────────────────────────── const header = createElement('div', 'drawer-header'); header.innerHTML = `

${isNew ? 'New Connection' : 'Edit Connection'}

`; drawer.appendChild(header); // ── Protocol selector tabs ────────────────────────────── const protocols = ['ssh', 'telnet', 'raw', 'github']; const tabBar = createElement('div', 'drawer-protocol-tabs'); for (const proto of protocols) { const label = proto === 'github' ? 'GitHub' : proto.toUpperCase(); const btn = createElement('button', `drawer-proto-tab ${profile.protocol === proto ? 'active' : ''}`, { textContent: label, dataset: { proto }, }); btn.addEventListener('click', () => this.handleProtocolChange(proto)); tabBar.appendChild(btn); } drawer.appendChild(tabBar); // ── Scrollable body ───────────────────────────────────── const body = createElement('div', 'drawer-body'); // General section (common to all protocols) body.appendChild(this._renderGeneralSection(profile)); // Protocol-specific sections switch (profile.protocol) { case 'ssh': this._renderSSHSections(profile).forEach((s) => body.appendChild(s)); break; case 'telnet': this._renderTelnetSections(profile).forEach((s) => body.appendChild(s)); break; case 'raw': this._renderRAWSections(profile).forEach((s) => body.appendChild(s)); break; case 'github': this._renderGitHubSections(profile).forEach((s) => body.appendChild(s)); break; } // Terminal section (SSH and Telnet only) if (profile.protocol !== 'raw' && profile.protocol !== 'github') { body.appendChild(this._renderTerminalSection(profile)); } // Notes & Tags section (common) body.appendChild(this._renderNotesSection(profile)); drawer.appendChild(body); // ── Footer ────────────────────────────────────────────── const footer = createElement('div', 'drawer-footer'); const cancelBtn = createElement('button', 'btn btn-secondary', { textContent: 'Cancel' }); const testBtn = createElement('button', 'btn btn-outline', { textContent: 'Test' }); const saveBtn = createElement('button', 'btn btn-primary', { textContent: isNew ? 'Create' : 'Save' }); const connectBtn = createElement('button', 'btn btn-accent', { textContent: isNew ? 'Create & Connect' : 'Save & Connect' }); const deleteBtn = !isNew ? createElement('button', 'btn btn-danger', { textContent: 'Delete' }) : null; cancelBtn.addEventListener('click', () => this.close()); testBtn.addEventListener('click', () => this._testConnection()); saveBtn.addEventListener('click', () => this._handleSave(false)); connectBtn.addEventListener('click', () => this._handleSave(true)); if (deleteBtn) { deleteBtn.addEventListener('click', () => this._handleDelete()); } if (deleteBtn) footer.appendChild(deleteBtn); footer.appendChild(cancelBtn); footer.appendChild(testBtn); footer.appendChild(saveBtn); footer.appendChild(connectBtn); drawer.appendChild(footer); // ── Events ────────────────────────────────────────────── header.querySelector('.drawer-close').addEventListener('click', () => this.close()); overlay.addEventListener('click', () => this.close()); overlay.appendChild(drawer); document.body.appendChild(overlay); this.drawer = overlay; // Trigger visibility after append so CSS transitions animate requestAnimationFrame(() => { overlay.classList.add('is-visible'); drawer.classList.add('is-visible'); }); // Prevent overlay click from reaching through the drawer drawer.addEventListener('click', (e) => e.stopPropagation()); // Focus the first input const firstInput = drawer.querySelector('input, select, textarea'); if (firstInput) firstInput.focus(); } /** * Closes the editor drawer. */ close() { if (this.drawer) { this.drawer.remove(); this.drawer = null; } } /** * Handles protocol change from the tab bar. * @param {string} protocol */ handleProtocolChange(protocol) { if (this._profile.protocol === protocol) return; // Preserve common fields, reset protocol-specific ones this._collectFormValues(); const base = this._defaultProfile(protocol); base.id = this._profile.id; base.displayName = this._profile.displayName; base.description = this._profile.description; base.host = this._profile.host; base.username = this._profile.username; base.tags = this._profile.tags; base.notes = this._profile.notes; base.favourite = this._profile.favourite; this._profile = base; this.render(this._profile, this._isNew); } // ── Section renderers ──────────────────────────────────────────── /** @private */ _renderGeneralSection(profile) { const fields = [ this._field('text', 'displayName', 'Display Name', profile.displayName, { placeholder: 'My Server' }), this._field('text', 'host', 'Host', profile.host, { placeholder: profile.protocol === 'github' ? 'github.com' : '192.168.1.1 or example.com', required: true, }), ]; // Port and username not relevant for GitHub connections if (profile.protocol !== 'github') { fields.push(this._field('number', 'port', 'Port', profile.port, { min: 1, max: 65535, required: true })); fields.push(this._field('text', 'username', 'Username', profile.username, { placeholder: 'root' })); } fields.push(this._renderGroupField(profile)); fields.push(this._field('text', 'description', 'Description', profile.description, { placeholder: 'Optional description' })); return this._renderSection('General', fields); } /** * Renders a combo-box for the Group field — a text input backed by a * datalist of existing group names. Typing a new name creates a group. * @param {Object} profile * @returns {HTMLElement} * @private */ _renderGroupField(profile) { const group = createElement('div', 'form-group'); const label = createElement('label', 'form-label', { textContent: 'Group', for: 'field-folder', }); group.appendChild(label); // Collect distinct group names from all profiles const existingGroups = [...new Set( this.app.state.profiles .map((p) => (p.folder || '').trim()) .filter(Boolean) )].sort((a, b) => a.localeCompare(b)); // Create the datalist so the browser offers autocomplete const listId = 'folder-suggestions'; const datalist = document.createElement('datalist'); datalist.id = listId; for (const g of existingGroups) { const opt = document.createElement('option'); opt.value = g; datalist.appendChild(opt); } group.appendChild(datalist); const input = createElement('input', 'form-input', { type: 'text', id: 'field-folder', name: 'folder', value: profile.folder || '', placeholder: 'e.g. Production, Staging, Home Lab', }); input.setAttribute('list', listId); input.setAttribute('autocomplete', 'off'); group.appendChild(input); const hint = createElement('span', 'form-hint', { textContent: 'Choose an existing group or type a new name to create one', }); group.appendChild(hint); return group; } /** @private */ _renderSSHSections(profile) { const ssh = profile.ssh || {}; const sections = []; // Authentication sections.push(this._renderSection('Authentication', [ this._field('select', 'ssh.secretMode', 'Password Mode', ssh.secretMode, { options: [ { value: 'saved', label: 'Saved in vault' }, { value: 'session-only', label: 'Session only' }, { value: 'ask-every-time', label: 'Ask every time' }, ], }), this._field('tags', 'ssh.authMethods', 'Auth Methods', (ssh.authMethods || []).join(', '), { help: 'Comma-separated: publickey, password, keyboard-interactive', }), ])); // Key & Passphrase sections.push(this._renderSection('Key & Passphrase', [ this._field('text', 'ssh.privateKeyPath', 'Private Key Path', ssh.privateKeyPath, { placeholder: '~/.ssh/id_ed25519', }), this._field('select', 'ssh.keySecretMode', 'Passphrase Mode', ssh.keySecretMode, { options: [ { value: 'saved', label: 'Saved in vault' }, { value: 'session-only', label: 'Session only' }, { value: 'ask-every-time', label: 'Ask every time' }, ], }), ])); // Host Verification sections.push(this._renderSection('Host Verification', [ this._field('select', 'ssh.hostKeyPolicy', 'Host Key Policy', ssh.hostKeyPolicy, { options: [ { value: 'strict', label: 'Strict (reject unknown)' }, { value: 'ask', label: 'Ask (prompt on unknown)' }, { value: 'accept-new', label: 'Accept new keys' }, { value: 'insecure', label: 'Insecure (accept all)' }, ], }), ])); // Jump Hosts sections.push(this._renderSection('Jump Hosts', [ this._renderJumpHostsList(ssh.jumpHosts || []), ], true)); // Port Forwarding sections.push(this._renderSection('Port Forwarding', [ this._renderPortForwardsList(ssh.portForwards || []), ], true)); // Startup sections.push(this._renderSection('Startup', [ this._field('text', 'ssh.startupCommand', 'Startup Command', ssh.startupCommand, { placeholder: 'Optional command to run after connecting', }), this._field('textarea', 'ssh.envVarsText', 'Environment Variables', this._envVarsToText(ssh.envVars), { placeholder: 'KEY=VALUE (one per line)', rows: 3, }), ])); // Login Scripts (expect-style) sections.push(this._renderLoginScriptSection('ssh', ssh.loginScripts || [])); // SSH Agent sections.push(this._renderSection('SSH Agent', [ this._field('toggle', 'ssh.agentAuth', 'Use SSH Agent for Authentication', ssh.agentAuth, { help: 'Authenticate using keys from the local SSH agent (ssh-agent)', }), this._field('toggle', 'ssh.agentForwarding', 'Forward Agent to Remote', ssh.agentForwarding, { help: 'Allow the remote host to use your local SSH agent keys (equivalent to ssh -A)', }), ], true)); // TOTP / 2FA sections.push(this._renderTOTPSection(profile)); // Proxy sections.push(this._renderSection('Proxy', [ this._field('select', 'ssh.proxyType', 'Proxy Type', ssh.proxyType || 'none', { options: [ { value: 'none', label: 'None (direct connection)' }, { value: 'socks5', label: 'SOCKS5' }, { value: 'http', label: 'HTTP CONNECT' }, ], }), this._field('text', 'ssh.proxyHost', 'Proxy Host', ssh.proxyHost || '', { placeholder: 'proxy.example.com', }), this._field('number', 'ssh.proxyPort', 'Proxy Port', ssh.proxyPort || 1080, { min: 1, max: 65535 }), this._field('text', 'ssh.proxyUsername', 'Proxy Username', ssh.proxyUsername || '', { placeholder: 'Optional', }), this._field('text', 'ssh.proxyPassword', 'Proxy Password', ssh.proxyPassword || '', { placeholder: 'Optional', }), ], true)); // Advanced sections.push(this._renderSection('Advanced', [ this._field('text', 'ssh.terminalType', 'Terminal Type', ssh.terminalType, { placeholder: 'xterm-256color' }), this._field('number', 'ssh.keepaliveInterval', 'Keepalive Interval (s)', ssh.keepaliveInterval, { min: 0 }), this._field('number', 'ssh.keepaliveCountMax', 'Max Missed Keepalives', ssh.keepaliveCountMax, { min: 0 }), this._field('toggle', 'ssh.compression', 'Compression', ssh.compression), this._field('toggle', 'ssh.autoReconnect', 'Auto-Reconnect', ssh.autoReconnect, { help: 'Automatically reconnect with exponential backoff when the connection drops', }), this._field('number', 'ssh.maxReconnectTries', 'Max Reconnect Attempts', ssh.maxReconnectTries || 5, { min: 1, max: 20 }), ], true)); return sections; } /** @private */ _renderTelnetSections(profile) { const tel = profile.telnet || {}; const sections = []; sections.push(this._renderSection('Login', [ this._field('toggle', 'telnet.localEcho', 'Local Echo', tel.localEcho), this._field('toggle', 'telnet.lineMode', 'Line Mode', tel.lineMode), ])); sections.push(this._renderSection('Terminal & Encoding', [ this._field('text', 'telnet.terminalType', 'Terminal Type', tel.terminalType, { placeholder: 'xterm-256color' }), this._field('select', 'telnet.encoding', 'Encoding', tel.encoding, { options: [ { value: 'utf-8', label: 'UTF-8' }, { value: 'ascii', label: 'ASCII' }, { value: 'iso-8859-1', label: 'ISO 8859-1' }, ], }), ])); sections.push(this._renderSection('Echo & Line Endings', [ this._field('select', 'telnet.lineEnding', 'Line Ending', tel.lineEnding, { options: [ { value: 'crlf', label: 'CR+LF (\\r\\n)' }, { value: 'lf', label: 'LF (\\n)' }, { value: 'cr', label: 'CR (\\r)' }, { value: 'none', label: 'None' }, ], }), ])); sections.push(this._renderSection('Negotiation', [ this._field('toggle', 'telnet.negotiationDebug', 'Debug Negotiation', tel.negotiationDebug), ], true)); // Login Scripts (expect-style) sections.push(this._renderLoginScriptSection('telnet', tel.loginScripts || [])); sections.push(this._renderSection('Reconnection', [ this._field('toggle', 'telnet.autoReconnect', 'Auto-Reconnect', tel.autoReconnect, { help: 'Automatically reconnect with exponential backoff when the connection drops', }), this._field('number', 'telnet.maxReconnectTries', 'Max Reconnect Attempts', tel.maxReconnectTries || 5, { min: 1, max: 20 }), ], true)); return sections; } /** @private */ _renderRAWSections(profile) { const raw = profile.raw || {}; const sections = []; sections.push(this._renderSection('Encoding & Framing', [ this._field('select', 'raw.encoding', 'Encoding', raw.encoding, { options: [ { value: 'utf-8', label: 'UTF-8' }, { value: 'ascii', label: 'ASCII' }, { value: 'hex', label: 'Hex' }, ], }), this._field('select', 'raw.lineEnding', 'Line Ending', raw.lineEnding, { options: [ { value: 'crlf', label: 'CR+LF (\\r\\n)' }, { value: 'lf', label: 'LF (\\n)' }, { value: 'cr', label: 'CR (\\r)' }, { value: 'none', label: 'None' }, ], }), ])); sections.push(this._renderSection('Display', [ this._field('toggle', 'raw.showTimestamps', 'Show Timestamps', raw.showTimestamps), this._field('toggle', 'raw.showDirection', 'Show Direction', raw.showDirection), ])); sections.push(this._renderSection('Payload Presets', [ this._renderPayloadPresetsList(raw.payloadPresets || []), ], true)); sections.push(this._renderSection('Timeouts & Reconnect', [ this._field('number', 'connectTimeout', 'Connect Timeout (s)', profile.connectTimeout, { min: 0 }), this._field('number', 'readTimeout', 'Read Timeout (s)', profile.readTimeout, { min: 0 }), this._field('toggle', 'raw.autoReconnect', 'Auto-reconnect', raw.autoReconnect), ])); return sections; } /** @private */ _renderGitHubSections(profile) { const sections = []; const gh = profile.github || DEFAULTS.github.github; sections.push(this._renderSection('API', [ this._field('text', 'github.baseURL', 'API Base URL', gh.baseURL, { placeholder: 'https://api.github.com', }), this._field('select', 'github.secretMode', 'Token Storage', gh.secretMode, { options: [ { value: 'saved', label: 'Save to keychain' }, { value: 'session-only', label: 'Remember for session' }, { value: 'ask-every-time', label: 'Ask every time' }, ], }), ])); sections.push(this._renderSection('Refresh', [ this._field('number', 'github.refreshIntervalSecs', 'Refresh Interval (seconds)', gh.refreshIntervalSecs, { min: 60, max: 3600, }), ])); return sections; } /** @private */ _renderTerminalSection(profile) { return this._renderSection('Terminal', [ this._field('text', 'terminalFontFamily', 'Font Family', profile.terminalFontFamily, { placeholder: 'JetBrains Mono, Menlo, Monaco, Consolas, monospace', }), this._field('number', 'terminalFontSize', 'Font Size', profile.terminalFontSize, { min: 8, max: 36 }), this._field('number', 'terminalScrollback', 'Scrollback Lines', profile.terminalScrollback, { min: 0, max: 100000 }), this._field('select', 'terminalCursorStyle', 'Cursor Style', profile.terminalCursorStyle, { options: [ { value: 'block', label: 'Block' }, { value: 'underline', label: 'Underline' }, { value: 'bar', label: 'Bar' }, ], }), ]); } /** @private */ _renderNotesSection(profile) { return this._renderSection('Notes & Tags', [ this._field('tags', 'tags', 'Tags', (profile.tags || []).join(', '), { placeholder: 'production, london, vpn', help: 'Comma-separated tags for filtering', }), this._field('textarea', 'notes', 'Notes', profile.notes, { placeholder: 'Free-form notes…', rows: 4, }), ]); } // ── Form helpers ───────────────────────────────────────────────── /** * Renders a collapsible form section. * @param {string} title * @param {HTMLElement[]} fields * @param {boolean} [startCollapsed=false] * @returns {HTMLElement} * @private */ /** * Renders the TOTP/2FA section with secret management and test button. * @param {Object} profile * @returns {HTMLElement} * @private */ _renderTOTPSection(profile) { const section = createElement('div', 'form-section'); const header = createElement('div', 'form-section-header'); let collapsed = true; header.innerHTML = ` ▸ TOTP / 2FA `; const content = createElement('div', 'form-section-content'); content.style.display = 'none'; const helpText = createElement('div', 'form-help', { textContent: 'Configure a TOTP secret to auto-fill 2FA codes during SSH keyboard-interactive authentication. The secret is stored encrypted.', }); content.appendChild(helpText); // TOTP secret input const group = createElement('div', 'form-group'); const label = createElement('label', 'form-label', { textContent: 'TOTP Secret' }); group.appendChild(label); const row = createElement('div', 'totp-input-row'); const secretInput = createElement('input', 'form-input', { type: 'password', placeholder: 'Base32-encoded secret (e.g. from authenticator app setup)', id: 'field-ssh.totpSecret', dataset: { field: 'ssh.totpSecret' }, autocapitalize: 'off', autocorrect: 'off', spellcheck: false, }); // Show/hide toggle const toggleBtn = createElement('button', 'btn btn-small btn-secondary totp-toggle', { textContent: '👁', title: 'Show/hide secret', type: 'button', }); toggleBtn.addEventListener('mousedown', e => e.preventDefault()); toggleBtn.addEventListener('click', () => { secretInput.type = secretInput.type === 'password' ? 'text' : 'password'; }); // Test button — generates a live code const testBtn = createElement('button', 'btn btn-small btn-accent totp-test', { textContent: 'Test', title: 'Generate a code with this secret', type: 'button', }); testBtn.addEventListener('mousedown', e => e.preventDefault()); testBtn.addEventListener('click', async () => { const profileId = profile.id; if (profileId) { try { const code = await window.go.api.Service.GetTOTPCode(profileId); testBtn.textContent = code; setTimeout(() => { testBtn.textContent = 'Test'; }, 5000); } catch (err) { // If no saved secret yet, try computing locally using the input value testBtn.textContent = 'Save first'; setTimeout(() => { testBtn.textContent = 'Test'; }, 2000); } } }); // Save button const saveBtn = createElement('button', 'btn btn-small btn-primary totp-save', { textContent: 'Save', title: 'Encrypt and save the TOTP secret', type: 'button', }); saveBtn.addEventListener('mousedown', e => e.preventDefault()); saveBtn.addEventListener('click', async () => { const profileId = profile.id; const secret = secretInput.value.trim(); if (!profileId) { NexusTerminal.Notifications.show('Save the profile first, then set the TOTP secret.', 'warning'); return; } if (!secret) { // Delete existing secret try { await window.go.api.Service.DeleteTOTPSecret(profileId); NexusTerminal.Notifications.show('TOTP secret removed.', 'info'); } catch (err) { NexusTerminal.Notifications.show('Failed to remove TOTP secret.', 'error'); } return; } try { await window.go.api.Service.SetTOTPSecret(profileId, secret); NexusTerminal.Notifications.show('TOTP secret saved securely.', 'success'); secretInput.value = ''; secretInput.placeholder = '••••••••• (secret saved)'; } catch (err) { NexusTerminal.Notifications.show('Failed to save TOTP secret: ' + err, 'error'); } }); // Delete button const deleteBtn = createElement('button', 'btn btn-small btn-danger totp-delete', { textContent: 'Delete', title: 'Remove the TOTP secret', type: 'button', }); deleteBtn.addEventListener('mousedown', e => e.preventDefault()); deleteBtn.addEventListener('click', async () => { const profileId = profile.id; if (!profileId) return; try { await window.go.api.Service.DeleteTOTPSecret(profileId); NexusTerminal.Notifications.show('TOTP secret removed.', 'info'); secretInput.placeholder = 'Base32-encoded secret (e.g. from authenticator app setup)'; } catch (err) { NexusTerminal.Notifications.show('Failed to remove TOTP secret.', 'error'); } }); row.appendChild(secretInput); row.appendChild(toggleBtn); row.appendChild(testBtn); row.appendChild(saveBtn); row.appendChild(deleteBtn); group.appendChild(row); content.appendChild(group); // Check if secret is already saved and show placeholder if (profile.id) { (async () => { try { const has = await window.go.api.Service.HasTOTPSecret(profile.id); if (has) { secretInput.placeholder = '••••••••• (secret saved)'; } } catch (_) { // Ignore — might not be unlocked. } })(); } header.addEventListener('mousedown', (e) => e.preventDefault()); header.addEventListener('click', () => { collapsed = !collapsed; content.style.display = collapsed ? 'none' : 'block'; header.querySelector('.form-section-chevron').textContent = collapsed ? '▸' : '▾'; }); section.appendChild(header); section.appendChild(content); return section; } /** * Renders the login script section (expect-style send/wait steps). * @param {string} prefix - 'ssh' or 'telnet' * @param {Array} steps - Array of {type, value, timeout} * @returns {HTMLElement} * @private */ _renderLoginScriptSection(prefix, steps) { const section = createElement('div', 'form-section'); const header = createElement('div', 'form-section-header'); let collapsed = true; header.innerHTML = ` ▸ Login Script `; const content = createElement('div', 'form-section-content'); content.style.display = 'none'; const helpText = createElement('div', 'form-help', { textContent: 'Automate login sequences: wait for text from the server, then send a response. Steps execute in order after connection.', }); content.appendChild(helpText); // Hidden input to hold JSON const hiddenInput = createElement('input', '', { type: 'hidden', id: `field-${prefix}.loginScripts`, dataset: { field: `${prefix}.loginScripts` }, }); content.appendChild(hiddenInput); // List container const listContainer = createElement('div', 'login-script-list'); content.appendChild(listContainer); // Working copy let currentSteps = JSON.parse(JSON.stringify(steps || [])); const renderSteps = () => { listContainer.innerHTML = ''; hiddenInput.value = JSON.stringify(currentSteps); if (currentSteps.length === 0) { const empty = createElement('div', 'login-script-empty', { textContent: 'No login script steps configured.', }); listContainer.appendChild(empty); } currentSteps.forEach((step, idx) => { const row = createElement('div', 'login-script-row'); const typeSelect = createElement('select', 'form-input login-script-type'); ['wait', 'send'].forEach(t => { const opt = createElement('option', '', { value: t, textContent: t === 'wait' ? 'Wait for' : 'Send' }); if (t === step.type) opt.selected = true; typeSelect.appendChild(opt); }); typeSelect.addEventListener('change', () => { currentSteps[idx].type = typeSelect.value; hiddenInput.value = JSON.stringify(currentSteps); renderSteps(); }); const valueInput = createElement('input', 'form-input login-script-value', { type: 'text', value: step.value || '', placeholder: step.type === 'wait' ? 'Text to match...' : 'Text to send...', autocapitalize: 'off', autocorrect: 'off', spellcheck: false, }); valueInput.addEventListener('input', () => { currentSteps[idx].value = valueInput.value; hiddenInput.value = JSON.stringify(currentSteps); }); row.appendChild(typeSelect); row.appendChild(valueInput); // Timeout field for wait steps if (step.type === 'wait') { const timeoutInput = createElement('input', 'form-input login-script-timeout', { type: 'number', value: step.timeout || 10, min: 1, max: 120, title: 'Timeout (seconds)', }); timeoutInput.style.width = '60px'; timeoutInput.addEventListener('input', () => { currentSteps[idx].timeout = parseInt(timeoutInput.value, 10) || 10; hiddenInput.value = JSON.stringify(currentSteps); }); const timeoutLabel = createElement('span', 'login-script-timeout-label', { textContent: 's' }); row.appendChild(timeoutInput); row.appendChild(timeoutLabel); } // Move up/down/remove buttons const actions = createElement('div', 'login-script-actions'); if (idx > 0) { const upBtn = createElement('button', 'login-script-btn', { textContent: '▲', title: 'Move up', type: 'button' }); upBtn.addEventListener('mousedown', e => e.preventDefault()); upBtn.addEventListener('click', () => { [currentSteps[idx - 1], currentSteps[idx]] = [currentSteps[idx], currentSteps[idx - 1]]; renderSteps(); }); actions.appendChild(upBtn); } if (idx < currentSteps.length - 1) { const downBtn = createElement('button', 'login-script-btn', { textContent: '▼', title: 'Move down', type: 'button' }); downBtn.addEventListener('mousedown', e => e.preventDefault()); downBtn.addEventListener('click', () => { [currentSteps[idx], currentSteps[idx + 1]] = [currentSteps[idx + 1], currentSteps[idx]]; renderSteps(); }); actions.appendChild(downBtn); } const removeBtn = createElement('button', 'login-script-btn login-script-btn-remove', { textContent: '✕', title: 'Remove', type: 'button' }); removeBtn.addEventListener('mousedown', e => e.preventDefault()); removeBtn.addEventListener('click', () => { currentSteps.splice(idx, 1); renderSteps(); }); actions.appendChild(removeBtn); row.appendChild(actions); listContainer.appendChild(row); }); }; renderSteps(); // Add step buttons const addRow = createElement('div', 'login-script-add-row'); const addWaitBtn = createElement('button', 'btn btn-small btn-secondary', { textContent: '+ Wait', type: 'button' }); addWaitBtn.addEventListener('mousedown', e => e.preventDefault()); addWaitBtn.addEventListener('click', () => { currentSteps.push({ type: 'wait', value: '', timeout: 10 }); renderSteps(); }); const addSendBtn = createElement('button', 'btn btn-small btn-secondary', { textContent: '+ Send', type: 'button' }); addSendBtn.addEventListener('mousedown', e => e.preventDefault()); addSendBtn.addEventListener('click', () => { currentSteps.push({ type: 'send', value: '', timeout: 0 }); renderSteps(); }); addRow.appendChild(addWaitBtn); addRow.appendChild(addSendBtn); content.appendChild(addRow); header.addEventListener('mousedown', (e) => e.preventDefault()); header.addEventListener('click', () => { collapsed = !collapsed; content.style.display = collapsed ? 'none' : 'block'; header.querySelector('.form-section-chevron').textContent = collapsed ? '▸' : '▾'; }); section.appendChild(header); section.appendChild(content); return section; } _renderSection(title, fields, startCollapsed = false) { const section = createElement('div', 'form-section'); const header = createElement('div', 'form-section-header'); let collapsed = startCollapsed; header.innerHTML = ` ${collapsed ? '▸' : '▾'} ${escapeHtml(title)} `; const content = createElement('div', 'form-section-content'); if (collapsed) content.style.display = 'none'; for (const field of fields) { if (field instanceof HTMLElement) { content.appendChild(field); } } // Prevent mousedown from starting a text selection gesture in WKWebView, // which would swallow the first click and require a double-click. header.addEventListener('mousedown', (e) => e.preventDefault()); header.addEventListener('click', () => { collapsed = !collapsed; content.style.display = collapsed ? 'none' : 'block'; header.querySelector('.form-section-chevron').textContent = collapsed ? '▸' : '▾'; }); section.appendChild(header); section.appendChild(content); return section; } /** * Renders a single form field. * @param {string} type - text | number | select | toggle | textarea | tags | file * @param {string} name - Dot-notated field name, e.g. "ssh.secretMode". * @param {string} label * @param {*} value * @param {Object} [opts] * @returns {HTMLElement} * @private */ _field(type, name, label, value, opts = {}) { const group = createElement('div', 'form-group'); // Label const labelEl = createElement('label', 'form-label', { textContent: label, for: `field-${name}`, }); group.appendChild(labelEl); // Input element based on type let input; switch (type) { case 'text': case 'file': input = createElement('input', 'form-input', { type: type === 'file' ? 'text' : 'text', id: `field-${name}`, name, value: value || '', placeholder: opts.placeholder || '', }); if (opts.required) input.required = true; break; case 'number': input = createElement('input', 'form-input', { type: 'number', id: `field-${name}`, name, value: value != null ? String(value) : '', }); if (opts.min != null) input.min = opts.min; if (opts.max != null) input.max = opts.max; if (opts.required) input.required = true; break; case 'select': { input = createElement('select', 'form-select', { id: `field-${name}`, name, }); for (const opt of (opts.options || [])) { const option = createElement('option', '', { value: opt.value, textContent: opt.label, }); if (opt.value === value) option.selected = true; input.appendChild(option); } break; } case 'toggle': { const wrapper = createElement('div', 'form-toggle-wrapper'); input = createElement('input', 'form-toggle', { type: 'checkbox', id: `field-${name}`, name, }); input.checked = !!value; const slider = createElement('label', 'form-toggle-label', { for: `field-${name}` }); wrapper.appendChild(input); wrapper.appendChild(slider); group.appendChild(wrapper); // Help text if (opts.help) { group.appendChild(createElement('span', 'form-hint', { textContent: opts.help })); } return group; } case 'textarea': input = createElement('textarea', 'form-textarea', { id: `field-${name}`, name, placeholder: opts.placeholder || '', }); input.rows = opts.rows || 3; input.textContent = value || ''; break; case 'tags': input = createElement('input', 'form-input', { type: 'text', id: `field-${name}`, name, value: value || '', placeholder: opts.placeholder || '', }); break; default: input = createElement('input', 'form-input', { type: 'text', id: `field-${name}`, name, value: value || '', }); } group.appendChild(input); // Help text if (opts.help) { group.appendChild(createElement('span', 'form-hint', { textContent: opts.help })); } return group; } /** * Renders the jump-hosts list editor. * @param {Array} jumpHosts * @returns {HTMLElement} * @private */ _renderJumpHostsList(jumpHosts) { const wrapper = createElement('div', 'jump-hosts-list'); for (let i = 0; i < jumpHosts.length; i++) { const jh = jumpHosts[i]; const row = createElement('div', 'jump-host-row'); row.innerHTML = `
`; row.querySelector('.jump-host-remove').addEventListener('click', () => { jumpHosts.splice(i, 1); this.render(this._profile, this._isNew); }); wrapper.appendChild(row); } const addBtn = createElement('button', 'btn btn-sm btn-outline', { textContent: '+ Add Jump Host' }); addBtn.addEventListener('click', () => { jumpHosts.push({ host: '', port: 22, username: '', authMethod: 'publickey', secretMode: 'ask-every-time', privateKeyPath: '' }); this.render(this._profile, this._isNew); }); wrapper.appendChild(addBtn); return wrapper; } /** * Renders the port-forwarding rules list editor. * @param {Array} portForwards * @returns {HTMLElement} * @private */ _renderPortForwardsList(portForwards) { const wrapper = createElement('div', 'port-forwards-list'); for (let i = 0; i < portForwards.length; i++) { const pf = portForwards[i]; const row = createElement('div', 'port-forward-row'); row.innerHTML = `
⟶
`; row.querySelector('.port-forward-remove').addEventListener('click', () => { portForwards.splice(i, 1); this.render(this._profile, this._isNew); }); wrapper.appendChild(row); } const addBtn = createElement('button', 'btn btn-sm btn-outline', { textContent: '+ Add Port Forward' }); addBtn.addEventListener('click', () => { portForwards.push({ id: generateId(), direction: 'local', bindHost: '127.0.0.1', bindPort: 0, destHost: '', destPort: 0, enabled: true, label: '', }); this.render(this._profile, this._isNew); }); wrapper.appendChild(addBtn); return wrapper; } /** * Renders the payload-presets list editor. * @param {Array} presets * @returns {HTMLElement} * @private */ _renderPayloadPresetsList(presets) { const wrapper = createElement('div', 'payload-presets-list'); for (let i = 0; i < presets.length; i++) { const ps = presets[i]; const row = createElement('div', 'preset-row'); row.innerHTML = ` `; row.querySelector('.preset-remove').addEventListener('click', () => { presets.splice(i, 1); this.render(this._profile, this._isNew); }); wrapper.appendChild(row); } const addBtn = createElement('button', 'btn btn-sm btn-outline', { textContent: '+ Add Preset' }); addBtn.addEventListener('click', () => { presets.push({ id: generateId(), name: '', payload: '', format: 'text' }); this.render(this._profile, this._isNew); }); wrapper.appendChild(addBtn); return wrapper; } // ── Form data collection ───────────────────────────────────────── /** * Reads current form values into this._profile. * @private */ _collectFormValues() { if (!this.drawer) return; const val = (name) => { const el = this.drawer.querySelector(`[name="${name}"]`); if (!el) return undefined; if (el.type === 'checkbox') return el.checked; if (el.type === 'number') return el.value !== '' ? Number(el.value) : 0; return el.value; }; const p = this._profile; // General p.displayName = val('displayName') || ''; p.host = val('host') || ''; p.port = val('port') || p.port; p.username = val('username') || ''; p.description = val('description') || ''; p.folder = (val('folder') || '').trim(); p.terminalFontFamily = val('terminalFontFamily') || ''; p.terminalFontSize = val('terminalFontSize') || 14; p.terminalScrollback = val('terminalScrollback') || 10000; p.terminalCursorStyle = val('terminalCursorStyle') || 'block'; p.connectTimeout = val('connectTimeout') || 30; p.readTimeout = val('readTimeout') || 0; // Tags const tagsStr = val('tags'); if (tagsStr !== undefined) { p.tags = tagsStr.split(',').map((t) => t.trim()).filter(Boolean); } // Notes const notes = val('notes'); if (notes !== undefined) p.notes = notes; // SSH-specific if (p.protocol === 'ssh' && p.ssh) { p.ssh.secretMode = val('ssh.secretMode') || p.ssh.secretMode; p.ssh.privateKeyPath = val('ssh.privateKeyPath') || ''; p.ssh.keySecretMode = val('ssh.keySecretMode') || p.ssh.keySecretMode; p.ssh.hostKeyPolicy = val('ssh.hostKeyPolicy') || p.ssh.hostKeyPolicy; p.ssh.terminalType = val('ssh.terminalType') || 'xterm-256color'; p.ssh.keepaliveInterval = val('ssh.keepaliveInterval') ?? 30; p.ssh.keepaliveCountMax = val('ssh.keepaliveCountMax') ?? 3; p.ssh.compression = val('ssh.compression') || false; p.ssh.agentAuth = val('ssh.agentAuth') || false; p.ssh.agentForwarding = val('ssh.agentForwarding') || false; p.ssh.autoReconnect = val('ssh.autoReconnect') || false; p.ssh.maxReconnectTries = val('ssh.maxReconnectTries') ?? 5; p.ssh.startupCommand = val('ssh.startupCommand') || ''; // Proxy settings p.ssh.proxyType = val('ssh.proxyType') || 'none'; p.ssh.proxyHost = val('ssh.proxyHost') || ''; p.ssh.proxyPort = val('ssh.proxyPort') || 1080; p.ssh.proxyUsername = val('ssh.proxyUsername') || ''; p.ssh.proxyPassword = val('ssh.proxyPassword') || ''; const authStr = val('ssh.authMethods'); if (authStr !== undefined) { p.ssh.authMethods = authStr.split(',').map((s) => s.trim()).filter(Boolean); } // Env vars from textarea const envText = val('ssh.envVarsText'); if (envText !== undefined) { p.ssh.envVars = {}; envText.split('\n').forEach((line) => { const eq = line.indexOf('='); if (eq > 0) { p.ssh.envVars[line.slice(0, eq).trim()] = line.slice(eq + 1).trim(); } }); } // Jump hosts const jh = []; let ji = 0; while (this.drawer.querySelector(`[name="jumpHost-${ji}-host"]`)) { jh.push({ host: val(`jumpHost-${ji}-host`) || '', port: val(`jumpHost-${ji}-port`) || 22, username: val(`jumpHost-${ji}-username`) || '', authMethod: 'publickey', secretMode: 'ask-every-time', privateKeyPath: val(`jumpHost-${ji}-privateKeyPath`) || '', }); ji++; } p.ssh.jumpHosts = jh; // Port forwards const pfs = []; let fi = 0; while (this.drawer.querySelector(`[name="portFwd-${fi}-direction"]`)) { pfs.push({ id: (p.ssh.portForwards && p.ssh.portForwards[fi]?.id) || generateId(), direction: val(`portFwd-${fi}-direction`) || 'local', bindHost: val(`portFwd-${fi}-bindHost`) || '127.0.0.1', bindPort: val(`portFwd-${fi}-bindPort`) || 0, destHost: val(`portFwd-${fi}-destHost`) || '', destPort: val(`portFwd-${fi}-destPort`) || 0, enabled: val(`portFwd-${fi}-enabled`) !== false, label: val(`portFwd-${fi}-label`) || '', }); fi++; } p.ssh.portForwards = pfs; // Login scripts const loginScriptsRaw = val('ssh.loginScripts'); if (loginScriptsRaw) { try { p.ssh.loginScripts = JSON.parse(loginScriptsRaw); } catch (_) { p.ssh.loginScripts = []; } } else { p.ssh.loginScripts = []; } } // Telnet-specific if (p.protocol === 'telnet' && p.telnet) { p.telnet.localEcho = val('telnet.localEcho') || false; p.telnet.lineMode = val('telnet.lineMode') || false; p.telnet.terminalType = val('telnet.terminalType') || 'xterm-256color'; p.telnet.encoding = val('telnet.encoding') || 'utf-8'; p.telnet.lineEnding = val('telnet.lineEnding') || 'crlf'; p.telnet.negotiationDebug = val('telnet.negotiationDebug') || false; p.telnet.autoReconnect = val('telnet.autoReconnect') || false; p.telnet.maxReconnectTries = val('telnet.maxReconnectTries') ?? 5; // Login scripts const telLoginScriptsRaw = val('telnet.loginScripts'); if (telLoginScriptsRaw) { try { p.telnet.loginScripts = JSON.parse(telLoginScriptsRaw); } catch (_) { p.telnet.loginScripts = []; } } else { p.telnet.loginScripts = []; } } // RAW-specific if (p.protocol === 'raw' && p.raw) { p.raw.encoding = val('raw.encoding') || 'utf-8'; p.raw.lineEnding = val('raw.lineEnding') || 'crlf'; p.raw.showTimestamps = val('raw.showTimestamps') || false; p.raw.showDirection = val('raw.showDirection') || false; p.raw.autoReconnect = val('raw.autoReconnect') || false; // Presets const presets = []; let pi = 0; while (this.drawer.querySelector(`[name="preset-${pi}-name"]`)) { presets.push({ id: (p.raw.payloadPresets && p.raw.payloadPresets[pi]?.id) || generateId(), name: val(`preset-${pi}-name`) || '', payload: val(`preset-${pi}-payload`) || '', format: val(`preset-${pi}-format`) || 'text', }); pi++; } p.raw.payloadPresets = presets; } // GitHub-specific if (p.protocol === 'github') { if (!p.github) p.github = {}; p.github.baseURL = val('github.baseURL') || 'https://api.github.com'; p.github.secretMode = val('github.secretMode') || 'saved'; p.github.refreshIntervalSecs = val('github.refreshIntervalSecs') || 300; } } // ── Validation ─────────────────────────────────────────────────── /** * Validates the collected profile. Returns an array of error strings (empty = valid). * @param {Object} profile * @returns {string[]} * @private */ _validate(profile) { const errors = []; if (!profile.host || !profile.host.trim()) { errors.push('Host is required.'); } if (!profile.port || profile.port < 1 || profile.port > 65535) { errors.push('Port must be between 1 and 65535.'); } return errors; } // ── Actions ────────────────────────────────────────────────────── /** @private */ async _handleSave(andConnect = false) { this._collectFormValues(); const errors = this._validate(this._profile); if (errors.length > 0) { this.app.notifications.warning(errors.join(' ')); return; } try { let saved; if (this._isNew) { saved = await window.go.api.Service.CreateProfile(this._profile); this.app.state.addProfile(saved); this.app.notifications.success('Profile created'); } else { saved = await window.go.api.Service.UpdateProfile(this._profile); this.app.state.updateProfile(saved); this.app.notifications.success('Profile updated'); } this.app.sidebar.render(); this.close(); if (andConnect && saved && saved.id) { this.app.connectProfile(saved.id); } } catch (err) { this.app.notifications.error('Save failed: ' + err); } } /** @private */ async _handleDelete() { const ok = await this._confirmDelete(); if (!ok) return; try { await window.go.api.Service.DeleteProfile(this._profile.id); this.app.state.removeProfile(this._profile.id); this.app.sidebar.render(); this.app.notifications.success('Profile deleted'); this.close(); } catch (err) { this.app.notifications.error('Failed to delete profile: ' + err); } } /** @private */ async _testConnection() { this._collectFormValues(); const errors = this._validate(this._profile); if (errors.length > 0) { this.app.notifications.warning(errors.join(' ')); return; } const host = this._profile.host; const port = this._profile.port; this.app.notifications.info(`Testing connection to ${host}:${port}…`); try { const result = await window.go.api.Service.TestConnection(host, port); this.app.notifications.success(`Reachable — ${result.latencyMs}ms latency`); } catch (err) { this.app.notifications.error('Test failed: ' + err); } } /** * Shows a confirmation modal for profile deletion. * @returns {Promise} * @private */ _confirmDelete() { return new Promise((resolve) => { const backdrop = createElement('div', 'modal-backdrop'); const modal = createElement('div', 'modal modal-sm'); const label = escapeHtml(this._profile.displayName || this._profile.host || 'Untitled'); modal.innerHTML = ` `; backdrop.appendChild(modal); document.body.appendChild(backdrop); requestAnimationFrame(() => { backdrop.classList.add('is-visible'); modal.classList.add('is-visible'); }); const close = (val) => { backdrop.classList.remove('is-visible'); modal.classList.remove('is-visible'); setTimeout(() => backdrop.remove(), 200); resolve(val); }; modal.querySelector('.modal-close').addEventListener('click', () => close(false)); modal.querySelector('.confirm-cancel').addEventListener('click', () => close(false)); modal.querySelector('.confirm-ok').addEventListener('click', () => close(true)); backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close(false); }); }); } // ── Utilities ──────────────────────────────────────────────────── /** * Returns a default profile object for the given protocol. * @param {string} protocol * @returns {Object} * @private */ _defaultProfile(protocol) { const base = { id: generateId(), displayName: '', description: '', protocol, host: '', port: 0, username: '', tags: [], folder: '', environment: '', notes: '', favourite: false, lastConnected: null, connectTimeout: 30, readTimeout: 0, terminalFontFamily: 'JetBrains Mono, Menlo, Monaco, Consolas, monospace', terminalFontSize: 14, terminalScrollback: 10000, terminalCursorStyle: 'block', ssh: null, telnet: null, raw: null, }; const defaults = DEFAULTS[protocol] || {}; return { ...base, ...defaults }; } /** * Converts an envVars map into "KEY=VALUE\n" text. * @param {Object} envVars * @returns {string} * @private */ _envVarsToText(envVars) { if (!envVars) return ''; return Object.entries(envVars).map(([k, v]) => `${k}=${v}`).join('\n'); } } window.NexusTerminal = window.NexusTerminal || {}; window.NexusTerminal.ProfileEditor = ProfileEditor; })();