feat: complete Nexus Terminal — premium SSH/Telnet/RAW TCP desktop client

Production-minded Wails v2 desktop application with:
- Full SSH client (password, key, keyboard-interactive, jump hosts, keepalive)
- Telnet client (RFC 854/855 negotiation, NAWS, TTYPE, encoding)
- RAW TCP client (binary-safe, text/hex/split views, payload presets)
- SQLite storage with migrations and repository abstraction
- Encrypted secret storage (AES-256-GCM + Argon2id) with keychain fallback
- Known hosts verification (strict/ask/accept-new/insecure)
- Session manager with multi-session lifecycle and Wails events
- Dark charcoal Material-inspired design system (CSS custom properties)
- Tabbed session management with rename, reorder, pin, context menu
- Profile editor with protocol-aware form sections
- Command palette, notifications, settings modal
- JSON import/export, audit logging, preferences
- 50+ tests across validation, secrets, storage, encoding, sessions
- Makefile, build.sh, .gitignore, comprehensive README
This commit is contained in:
2026-04-15 00:00:00 +01:00
parent c767c1640e
commit 5bff1d9f3e
17 changed files with 5644 additions and 45 deletions
+487
View File
@@ -0,0 +1,487 @@
/**
* Nexus Terminal — main application entry point.
* Initialises the application shell, loads profiles, and sets up event listeners.
*/
import { State } from './state.js';
import { Sidebar } from './sidebar.js';
import { TabManager } from './tabs.js';
import { TerminalManager } from './terminal.js';
import { ProfileEditor } from './profiles.js';
import { RawViewer } from './raw.js';
import { Notifications } from './notifications.js';
import { Settings } from './settings.js';
import { CommandPalette } from './commands.js';
import { SecretPrompt } from './secrets.js';
import { stateLabel } from './utils.js';
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);
}
/**
* Bootstraps the application after the DOM is ready.
*/
async init() {
await this.loadProfiles();
await this.loadPreferences();
await this.checkSecretStore();
this.setupEvents();
this.setupKeyboard();
this.setupUI();
this.updateStatusBar();
}
// ── 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 status = await window.go.api.Service.SecretStoreStatus();
this.state.setSecretStoreInitialised(status?.initialised || false);
this.state.setSecretStoreState(status?.unlocked || false);
} catch (_) {
// Secret store may not be available yet
}
}
// ── Wails event listeners ────────────────────────────────────────
/**
* Registers listeners for backend events emitted via the Wails runtime.
*/
setupEvents() {
// 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();
});
// 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);
});
// 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', {
sessionID: payload.sessionID,
value,
});
} catch (_) {
window.runtime.EventsEmit('session:secret:response', {
sessionID: payload.sessionID,
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();
}
});
}
// ── UI event wiring ──────────────────────────────────────────────
/**
* Binds click handlers to static UI elements.
*/
setupUI() {
this._bindClick('btn-new-connection', () => this.profileEditor.openNew());
this._bindClick('btn-settings', () => this.settings.open());
this._bindClick('btn-import', () => this.importProfiles());
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
window.addEventListener('resize', () => this.terminals.fitActive());
}
// ── 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.Connect(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 {
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 = prompt('Quick connect (host[:port]):');
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 sessionInfo = await window.go.api.Service.QuickConnect(protocol, host, port);
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 a profile import dialog.
*/
async importProfiles() {
try {
const result = await window.go.api.Service.ImportProfilesDialog();
if (result && result.count > 0) {
await this.loadProfiles();
this.notifications.success(`Imported ${result.count} profile(s)`);
}
} catch (err) {
if (String(err) !== 'cancelled') {
this.notifications.error('Import failed: ' + err);
}
}
}
/**
* Triggers a profile export dialog.
*/
async exportProfiles() {
try {
await window.go.api.Service.ExportProfilesDialog();
this.notifications.success('Profiles exported');
} catch (err) {
if (String(err) !== 'cancelled') {
this.notifications.error('Export failed: ' + err);
}
}
}
// ── Status bar ───────────────────────────────────────────────────
/**
* Updates the status bar with current session info.
*/
updateStatusBar() {
const bar = document.getElementById('status-bar');
if (!bar) 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;
// Left: active session info
const left = bar.querySelector('.status-left') || bar;
if (activeSession) {
const profile = this.state.getProfile(activeSession.profileId);
left.textContent = [
activeSession.protocol?.toUpperCase(),
profile ? `${profile.host}:${profile.port}` : '',
stateLabel(activeSession.state),
].filter(Boolean).join(' — ');
} else {
left.textContent = 'No active session';
}
// Right: session count
const right = bar.querySelector('.status-right');
if (right) {
right.textContent = `${connected}/${total} connected`;
}
}
// ── 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', handler);
}
}
// ── Bootstrap ────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
const app = new App();
app.init().catch((err) => console.error('App initialisation failed:', err));
});