diff --git a/frontend/index.html b/frontend/index.html index bdbe54b..9772dfc 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -61,6 +61,7 @@ class="sidebar-title sidebar-mode-toggle" id="mode-leviathan" type="button" + hidden > Leviathan diff --git a/frontend/scripts/test-regressions.js b/frontend/scripts/test-regressions.js index 0d19032..7de226d 100644 --- a/frontend/scripts/test-regressions.js +++ b/frontend/scripts/test-regressions.js @@ -23,6 +23,17 @@ function assertNotContains(file, needle, message) { } } +function assertBefore(file, first, second, message) { + const text = read(file); + const firstIndex = text.indexOf(first); + const secondIndex = text.indexOf(second); + if (firstIndex === -1 || secondIndex === -1 || firstIndex >= secondIndex) { + throw new Error( + `${message}\nExpected ${JSON.stringify(first)} before ${JSON.stringify(second)} in ${file}`, + ); + } +} + function assertTreeNotContains(relDir, needle, message) { const dir = path.join(repo, relDir); const offenders = []; @@ -74,6 +85,26 @@ assertContains( "cancelScheduledFit", "terminal teardown must cancel pending fit work", ); +assertContains( + "frontend/src/js/terminal.js", + "this._sendTerminalSize(sessionId, term);", + "every completed terminal fit must publish its geometry to the backend PTY", +); +assertContains( + "frontend/src/js/terminal.js", + "[Exited - press enter to restart]", + "exited local shells must show the inline restart prompt", +); +assertContains( + "frontend/src/js/terminal.js", + "RestartLocalSession", + "pressing enter after a local shell exit must restart the existing session", +); +assertContains( + "frontend/src/js/app.js", + "!localExited &&", + "local shell exits must not show the disconnected overlay", +); assertNotContains( "frontend/src/js/terminal.js", "new ResizeObserver(() => {\n try {\n fitAddon.fit();", @@ -114,6 +145,31 @@ assertContains( "mode-leviathan", "Leviathan mode toggle must be present beside Nexus Terminal", ); +assertContains( + "frontend/index.html", + 'id="mode-leviathan"\n type="button"\n hidden', + "Leviathan mode toggle must be hidden before preferences load", +); +assertContains( + "frontend/src/js/app.js", + "prefs.leviathanEnabled === true", + "Leviathan mode must only be exposed by an explicit persisted preference", +); +assertContains( + "frontend/src/js/settings.js", + "KONAMI_SEQUENCE", + "the About window must recognise the Konami sequence", +); +assertContains( + "frontend/src/js/settings.js", + "_toggleLeviathanFromKonami", + "the About-window Konami sequence must toggle Leviathan", +); +assertContains( + "frontend/src/js/settings.js", + "SavePreferences(prefs)", + "the Konami toggle must persist the Leviathan preference immediately", +); assertContains( "frontend/src/css/sidebar.css", "user-select: none", @@ -299,6 +355,17 @@ assertContains( "setMode(\"leviathan\")", "App mode switch must route to Leviathan", ); +assertBefore( + "frontend/src/js/splitpane.js", + "localLabel.textContent = 'Local Machine';", + "profiles.forEach(function(profile)", + "split-pane connection choices must put Local Machine before saved profiles", +); +assertContains( + "frontend/src/js/splitpane.js", + "wailsService.ConnectLocal()", + "the Local Machine split-pane choice must open a real local shell", +); assertContains( "internal/api/leviathan.go", "LeviathanRunTool", diff --git a/frontend/src/css/sidebar.css b/frontend/src/css/sidebar.css index 2253040..0653691 100644 --- a/frontend/src/css/sidebar.css +++ b/frontend/src/css/sidebar.css @@ -66,6 +66,10 @@ text-align: right; } +#mode-leviathan[hidden] { + display: none; +} + .sidebar-mode-toggle.is-active { color: var(--text-primary); } diff --git a/frontend/src/js/app.js b/frontend/src/js/app.js index 03faf58..5da303f 100644 --- a/frontend/src/js/app.js +++ b/frontend/src/js/app.js @@ -191,13 +191,23 @@ ); this.updateStatusBar(); + const session = this.state.getSession(payload.sessionID); + const isLocal = session?.protocol === "local"; + const localExited = + isLocal && payload.state === "disconnected"; const showDisconnectedOverlay = - payload.state === "disconnected" || payload.state === "error"; + !localExited && + (payload.state === "disconnected" || payload.state === "error"); this.terminals.setDisconnectedOverlay( payload.sessionID, showDisconnectedOverlay, payload.error, ); + if (localExited) { + this.terminals.setLocalExited(payload.sessionID, true); + } else if (isLocal && payload.state === "connected") { + this.terminals.setLocalExited(payload.sessionID, false); + } if (payload.state === "error" && payload.error) { this._handleSessionError(payload.sessionID, payload.error); @@ -612,7 +622,10 @@ * @param {"nexus"|"leviathan"} mode */ setMode(mode) { - this.mode = mode === "leviathan" ? "leviathan" : "nexus"; + const leviathanEnabled = + this.state.preferences?.leviathanEnabled === true; + this.mode = + mode === "leviathan" && leviathanEnabled ? "leviathan" : "nexus"; const root = document.getElementById("app"); if (root) { root.classList.toggle("mode-leviathan", this.mode === "leviathan"); @@ -1374,6 +1387,16 @@ prefs.showStatusBar === false ? "none" : "flex"; } + // Leviathan is an opt-in mode, revealed from the About window. + const leviathanEnabled = prefs.leviathanEnabled === true; + const leviathanButton = document.getElementById("mode-leviathan"); + if (leviathanButton) { + leviathanButton.hidden = !leviathanEnabled; + } + if (!leviathanEnabled && this.mode === "leviathan") { + this.setMode("nexus"); + } + // Refit any visible terminal after geometry changes. requestAnimationFrame(() => { this.terminals.fitActive(); diff --git a/frontend/src/js/settings.js b/frontend/src/js/settings.js index 0f126c8..15bbc4b 100644 --- a/frontend/src/js/settings.js +++ b/frontend/src/js/settings.js @@ -18,6 +18,19 @@ { key: "about", label: "About" }, ]; + const KONAMI_SEQUENCE = [ + "ArrowUp", + "ArrowUp", + "ArrowDown", + "ArrowDown", + "ArrowLeft", + "ArrowRight", + "ArrowLeft", + "ArrowRight", + "b", + "a", + ]; + class Settings { /** * @param {App} app @@ -36,6 +49,10 @@ /** @type {Array} Cached known hosts list. */ this._knownHosts = []; + + this._aboutKonamiHandler = null; + this._konamiIndex = 0; + this._konamiTogglePending = false; } // ── Public API ─────────────────────────────────────────────────── @@ -75,6 +92,7 @@ * Closes the settings modal. */ close() { + this._detachAboutKonamiListener(); if (this._overlay) { this._overlay.remove(); this._overlay = null; @@ -198,6 +216,7 @@ * @private */ _renderTabContent(body) { + this._detachAboutKonamiListener(); body.innerHTML = ""; body.classList.remove("help-layout"); switch (this._activeTab) { @@ -1058,10 +1077,74 @@ } }); } + + this._attachAboutKonamiListener(); } // ── Form helpers ───────────────────────────────────────────────── + _attachAboutKonamiListener() { + this._detachAboutKonamiListener(); + this._konamiIndex = 0; + this._aboutKonamiHandler = (event) => { + if (!this._overlay || this._activeTab !== "about") return; + if (event.ctrlKey || event.metaKey || event.altKey) { + this._konamiIndex = 0; + return; + } + + const key = event.key.length === 1 ? event.key.toLowerCase() : event.key; + if (key === KONAMI_SEQUENCE[this._konamiIndex]) { + event.preventDefault(); + this._konamiIndex += 1; + if (this._konamiIndex === KONAMI_SEQUENCE.length) { + this._konamiIndex = 0; + void this._toggleLeviathanFromKonami(); + } + return; + } + + this._konamiIndex = key === KONAMI_SEQUENCE[0] ? 1 : 0; + }; + window.addEventListener("keydown", this._aboutKonamiHandler); + } + + _detachAboutKonamiListener() { + if (this._aboutKonamiHandler) { + window.removeEventListener("keydown", this._aboutKonamiHandler); + this._aboutKonamiHandler = null; + } + this._konamiIndex = 0; + } + + async _toggleLeviathanFromKonami() { + if (this._konamiTogglePending) return; + this._konamiTogglePending = true; + + const enabled = + this.app.state.preferences?.leviathanEnabled !== true; + const prefs = { + ...this.app.state.preferences, + leviathanEnabled: enabled, + }; + + try { + await window.NexusTerminal.wailsService.SavePreferences(prefs); + this._prefs.leviathanEnabled = enabled; + this.app.state.setPreferences(prefs); + this.app._applyPreferences?.(); + this.app.notifications.success( + enabled ? "Leviathan enabled" : "Leviathan disabled", + ); + } catch (err) { + this.app.notifications.error( + "Failed to update Leviathan setting: " + err, + ); + } finally { + this._konamiTogglePending = false; + } + } + /** * Renders a single settings form field. * @param {string} type diff --git a/frontend/src/js/splitpane.js b/frontend/src/js/splitpane.js index 1824b1c..a2a21d8 100644 --- a/frontend/src/js/splitpane.js +++ b/frontend/src/js/splitpane.js @@ -385,35 +385,51 @@ class SplitPaneManager { // Build profile list var profiles = this.app.state.profiles || []; - if (profiles.length > 0) { - var list = document.createElement('div'); - list.className = 'split-pane-profile-list'; + var list = document.createElement('div'); + list.className = 'split-pane-profile-list'; - var self = this; - profiles.forEach(function(profile) { - var btn = document.createElement('button'); - btn.className = 'split-pane-profile-btn'; + var self = this; + var localBtn = document.createElement('button'); + localBtn.className = 'split-pane-profile-btn'; - var badge = document.createElement('span'); - badge.className = 'protocol-badge'; - badge.textContent = (profile.protocol || 'ssh').toUpperCase(); - badge.style.color = self._protocolColor(profile.protocol); + var localBadge = document.createElement('span'); + localBadge.className = 'protocol-badge'; + localBadge.textContent = 'LOCAL'; + localBadge.style.color = self._protocolColor('local'); - var label = document.createElement('span'); - label.textContent = profile.displayName || profile.host || profile.id; + var localLabel = document.createElement('span'); + localLabel.textContent = 'Local Machine'; - btn.appendChild(badge); - btn.appendChild(label); + localBtn.appendChild(localBadge); + localBtn.appendChild(localLabel); + localBtn.addEventListener('click', function() { + self._connectLocalInPane(paneEl, ownerSessionId); + }); + list.appendChild(localBtn); - btn.addEventListener('click', function() { - self._connectInPane(paneEl, profile, ownerSessionId); - }); + profiles.forEach(function(profile) { + var btn = document.createElement('button'); + btn.className = 'split-pane-profile-btn'; - list.appendChild(btn); + var badge = document.createElement('span'); + badge.className = 'protocol-badge'; + badge.textContent = (profile.protocol || 'ssh').toUpperCase(); + badge.style.color = self._protocolColor(profile.protocol); + + var label = document.createElement('span'); + label.textContent = profile.displayName || profile.host || profile.id; + + btn.appendChild(badge); + btn.appendChild(label); + + btn.addEventListener('click', function() { + self._connectInPane(paneEl, profile, ownerSessionId); }); - placeholder.appendChild(list); - } + list.appendChild(btn); + }); + + placeholder.appendChild(list); paneEl.appendChild(placeholder); } @@ -427,7 +443,6 @@ class SplitPaneManager { */ _connectInPane(paneEl, profile, ownerSessionId) { var self = this; - var node = paneEl._splitNode; // Remove placeholder var placeholder = paneEl.querySelector('.split-pane-placeholder'); @@ -435,25 +450,7 @@ class SplitPaneManager { // Connect via backend window.NexusTerminal.wailsService.ConnectSession(profile.id).then(function(sessionInfo) { - self.app.state.addSession(sessionInfo); - - // Create a terminal container inside this pane - var termContainer = document.createElement('div'); - termContainer.className = 'terminal-container'; - termContainer.id = 'split-term-' + sessionInfo.id; - termContainer.style.position = 'relative'; - termContainer.style.flex = '1'; - termContainer.style.width = '100%'; - termContainer.style.height = '100%'; - paneEl.appendChild(termContainer); - - // Track which session is in this pane - if (node) { - node.sessionId = sessionInfo.id; - } - - // Create terminal - self.app.terminals.createTerminal(sessionInfo.id, termContainer.id, profile); + self._attachTerminalToPane(paneEl, sessionInfo, profile); self.app.notifications.info('Connecting to ' + (profile.displayName || profile.host) + '…'); }).catch(function(err) { @@ -463,6 +460,59 @@ class SplitPaneManager { }); } + /** + * Opens a local shell in a specific pane. + * @param {HTMLElement} paneEl + * @param {string} ownerSessionId + * @private + */ + _connectLocalInPane(paneEl, ownerSessionId) { + var self = this; + var placeholder = paneEl.querySelector('.split-pane-placeholder'); + if (placeholder) placeholder.remove(); + + window.NexusTerminal.wailsService.ConnectLocal().then(function(sessionInfo) { + var prefs = self.app.state.preferences || {}; + self._attachTerminalToPane(paneEl, sessionInfo, { + terminalFontFamily: prefs.defaultFontFamily, + terminalFontSize: prefs.defaultFontSize, + terminalScrollback: prefs.defaultScrollback, + terminalCursorStyle: prefs.defaultCursorStyle + }); + self.app.notifications.info('Opening local terminal…'); + }).catch(function(err) { + self.app.notifications.error('Local terminal failed: ' + err); + self._renderPlaceholder(paneEl, ownerSessionId); + }); + } + + /** + * Attaches a connected terminal session to a split pane. + * @param {HTMLElement} paneEl + * @param {Object} sessionInfo + * @param {Object} terminalProfile + * @private + */ + _attachTerminalToPane(paneEl, sessionInfo, terminalProfile) { + this.app.state.addSession(sessionInfo); + + var termContainer = document.createElement('div'); + termContainer.className = 'terminal-container'; + termContainer.id = 'split-term-' + sessionInfo.id; + termContainer.style.position = 'relative'; + termContainer.style.flex = '1'; + termContainer.style.width = '100%'; + termContainer.style.height = '100%'; + paneEl.appendChild(termContainer); + + var node = paneEl._splitNode; + if (node) { + node.sessionId = sessionInfo.id; + } + + this.app.terminals.createTerminal(sessionInfo.id, termContainer.id, terminalProfile); + } + // ── Tree traversal helpers ─────────────────────────────────────── /** @@ -588,7 +638,8 @@ class SplitPaneManager { ssh: '#4ecdc4', telnet: '#ffa726', raw: '#ab47bc', - github: '#7c8aff' + github: '#7c8aff', + local: '#66bb6a' }; return colors[protocol] || '#4ecdc4'; } diff --git a/frontend/src/js/terminal.js b/frontend/src/js/terminal.js index f4b04c1..f58ba5c 100644 --- a/frontend/src/js/terminal.js +++ b/frontend/src/js/terminal.js @@ -224,6 +224,10 @@ try { fitAddon.fit(); + // Forced fits do not emit onResize when xterm's geometry is + // unchanged. Always publish the fitted size so a new local PTY + // cannot remain at its 80x24 fallback dimensions. + this._sendTerminalSize(sessionId, term); } catch (_) { /* noop */ } @@ -249,6 +253,14 @@ // Forward user keystrokes to the backend term.onData((data) => { + const entry = this.terminals.get(sessionId); + if (entry?.localExited) { + if (data === "\r" || data === "\n") { + this._restartLocalTerminal(sessionId, entry); + } + return; + } + try { const b64 = utf8ToBase64(data); window.NexusTerminal.wailsService.WriteSession(sessionId, b64); @@ -330,11 +342,7 @@ // Notify backend when terminal dimensions change term.onResize(({ cols, rows }) => { - try { - window.NexusTerminal.wailsService.ResizeSession(sessionId, cols, rows); - } catch (err) { - console.error("ResizeSession error:", err); - } + this._sendTerminalSize(sessionId, { cols, rows }); }); // Re-fit when the container itself resizes. ResizeObserver can fire @@ -354,6 +362,8 @@ scheduleFit, cancelScheduledFit, refreshTerminal, + localExited: false, + localRestarting: false, decoder: typeof TextDecoder !== "undefined" ? new TextDecoder("utf-8") : null, }); @@ -461,6 +471,24 @@ overlay.classList.toggle("is-visible", isDisconnected); } + /** + * Marks a local shell as exited without obscuring its terminal buffer. + * Enter is intercepted by the terminal input handler and restarts it. + * @param {string} sessionId + * @param {boolean} isExited + */ + setLocalExited(sessionId, isExited) { + const entry = this.terminals.get(sessionId); + if (!entry) return; + + if (isExited && !entry.localExited) { + entry.term.write("\r\n[Exited - press enter to restart]\r\n"); + } + entry.localExited = isExited; + entry.localRestarting = false; + if (isExited) entry.term.focus(); + } + /** * Searches within a terminal using the search addon. * @param {string} sessionId @@ -550,6 +578,34 @@ // ── Private helpers ────────────────────────────────────────────── + _sendTerminalSize(sessionId, dimensions) { + const cols = Number(dimensions?.cols || 0); + const rows = Number(dimensions?.rows || 0); + if (cols <= 0 || rows <= 0) return; + + Promise.resolve( + window.NexusTerminal.wailsService.ResizeSession(sessionId, cols, rows), + ).catch((err) => console.error("ResizeSession error:", err)); + } + + async _restartLocalTerminal(sessionId, entry) { + if (entry.localRestarting) return; + entry.localRestarting = true; + + try { + await window.NexusTerminal.wailsService.RestartLocalSession( + sessionId, + entry.term.cols, + entry.term.rows, + ); + } catch (err) { + entry.localRestarting = false; + entry.term.write( + `\r\n[Restart failed: ${String(err)} - press enter to retry]\r\n`, + ); + } + } + /** * Ensures the disconnect overlay exists for a session panel. * @param {HTMLElement} panel diff --git a/frontend/wailsjs/go/api/Service.d.ts b/frontend/wailsjs/go/api/Service.d.ts index 2d26e34..c761a65 100755 --- a/frontend/wailsjs/go/api/Service.d.ts +++ b/frontend/wailsjs/go/api/Service.d.ts @@ -44,6 +44,8 @@ export function DisconnectSession(arg1:string):Promise; export function DownloadGitHubRepoArchive(arg1:string,arg2:string,arg3:string):Promise; +export function DownloadGitHubRepoArchiveToPath(arg1:string,arg2:string,arg3:string,arg4:string):Promise; + export function DuplicateProfile(arg1:string):Promise; export function ExportProfiles(arg1:boolean):Promise; @@ -124,6 +126,8 @@ export function LeviathanRunTool(arg1:string,arg2:Record):Promise; +export function LeviathanSaveWorkingToPath(arg1:string):Promise; + export function LeviathanSetByte(arg1:number,arg2:string):Promise>; export function LeviathanState():Promise; @@ -174,8 +178,14 @@ export function RenameSession(arg1:string,arg2:string):Promise; export function ResizeSession(arg1:string,arg2:number,arg3:number):Promise; +export function RespondSecret(arg1:string,arg2:string,arg3:boolean):Promise; + +export function RestartLocalSession(arg1:string,arg2:number,arg3:number):Promise; + export function SFTPDownload(arg1:string,arg2:string):Promise; +export function SFTPDownloadToPath(arg1:string,arg2:string,arg3:string):Promise; + export function SFTPList(arg1:string,arg2:string):Promise>>; export function SFTPMkdir(arg1:string,arg2:string):Promise; @@ -198,6 +208,8 @@ export function SFTPUploadData(arg1:string,arg2:string,arg3:string,arg4:string): export function SFTPUploadFinish(arg1:string):Promise; +export function SFTPUploadPath(arg1:string,arg2:string,arg3:string):Promise; + export function SFTPUploadStart(arg1:string,arg2:string,arg3:string,arg4:number):Promise; export function SaveFile(arg1:string,arg2:string):Promise; @@ -214,6 +226,8 @@ export function SendBreak(arg1:string):Promise; export function SetContext(arg1:context.Context):Promise; +export function SetEventSink(arg1:any):Promise; + export function SetFavourite(arg1:string,arg2:boolean):Promise; export function SetProfileSecret(arg1:string,arg2:string,arg3:string):Promise; diff --git a/frontend/wailsjs/go/api/Service.js b/frontend/wailsjs/go/api/Service.js index 1f07c06..f727e9d 100755 --- a/frontend/wailsjs/go/api/Service.js +++ b/frontend/wailsjs/go/api/Service.js @@ -82,6 +82,10 @@ export function DownloadGitHubRepoArchive(arg1, arg2, arg3) { return window['go']['api']['Service']['DownloadGitHubRepoArchive'](arg1, arg2, arg3); } +export function DownloadGitHubRepoArchiveToPath(arg1, arg2, arg3, arg4) { + return window['go']['api']['Service']['DownloadGitHubRepoArchiveToPath'](arg1, arg2, arg3, arg4); +} + export function DuplicateProfile(arg1) { return window['go']['api']['Service']['DuplicateProfile'](arg1); } @@ -242,6 +246,10 @@ export function LeviathanSaveWorking() { return window['go']['api']['Service']['LeviathanSaveWorking'](); } +export function LeviathanSaveWorkingToPath(arg1) { + return window['go']['api']['Service']['LeviathanSaveWorkingToPath'](arg1); +} + export function LeviathanSetByte(arg1, arg2) { return window['go']['api']['Service']['LeviathanSetByte'](arg1, arg2); } @@ -342,10 +350,22 @@ export function ResizeSession(arg1, arg2, arg3) { return window['go']['api']['Service']['ResizeSession'](arg1, arg2, arg3); } +export function RespondSecret(arg1, arg2, arg3) { + return window['go']['api']['Service']['RespondSecret'](arg1, arg2, arg3); +} + +export function RestartLocalSession(arg1, arg2, arg3) { + return window['go']['api']['Service']['RestartLocalSession'](arg1, arg2, arg3); +} + export function SFTPDownload(arg1, arg2) { return window['go']['api']['Service']['SFTPDownload'](arg1, arg2); } +export function SFTPDownloadToPath(arg1, arg2, arg3) { + return window['go']['api']['Service']['SFTPDownloadToPath'](arg1, arg2, arg3); +} + export function SFTPList(arg1, arg2) { return window['go']['api']['Service']['SFTPList'](arg1, arg2); } @@ -390,6 +410,10 @@ export function SFTPUploadFinish(arg1) { return window['go']['api']['Service']['SFTPUploadFinish'](arg1); } +export function SFTPUploadPath(arg1, arg2, arg3) { + return window['go']['api']['Service']['SFTPUploadPath'](arg1, arg2, arg3); +} + export function SFTPUploadStart(arg1, arg2, arg3, arg4) { return window['go']['api']['Service']['SFTPUploadStart'](arg1, arg2, arg3, arg4); } @@ -422,6 +446,10 @@ export function SetContext(arg1) { return window['go']['api']['Service']['SetContext'](arg1); } +export function SetEventSink(arg1) { + return window['go']['api']['Service']['SetEventSink'](arg1); +} + export function SetFavourite(arg1, arg2) { return window['go']['api']['Service']['SetFavourite'](arg1, arg2); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index be095ec..b66a789 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -81,6 +81,7 @@ export namespace models { defaultCursorStyle: string; useNativePaste: boolean; localShell: string; + leviathanEnabled: boolean; static createFrom(source: any = {}) { return new AppPreferences(source); @@ -101,6 +102,7 @@ export namespace models { this.defaultCursorStyle = source["defaultCursorStyle"]; this.useNativePaste = source["useNativePaste"]; this.localShell = source["localShell"]; + this.leviathanEnabled = source["leviathanEnabled"]; } } export class AuditEntry { diff --git a/internal/api/api.go b/internal/api/api.go index 6a63e69..a4095c2 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -430,6 +430,12 @@ func (s *Service) ConnectLocal() (models.SessionInfo, error) { return *info, nil } +// RestartLocalSession starts a new shell in an exited local terminal while +// preserving its tab, scrollback, terminal geometry, and best-effort cwd. +func (s *Service) RestartLocalSession(sessionID string, cols int, rows int) error { + return s.sessions.RestartLocal(sessionID, cols, rows) +} + // DisconnectSession closes a session by ID. func (s *Service) DisconnectSession(sessionID string) error { return s.sessions.Disconnect(sessionID) diff --git a/internal/localshell/localshell.go b/internal/localshell/localshell.go index 53cb84a..383ccf3 100644 --- a/internal/localshell/localshell.go +++ b/internal/localshell/localshell.go @@ -3,11 +3,18 @@ package localshell import ( + "context" + "errors" "fmt" + "io" "os" "os/exec" "runtime" + "strconv" + "strings" "sync" + "syscall" + "time" "github.com/andydixon/nexus/internal/terminalenv" "github.com/creack/pty" @@ -15,26 +22,28 @@ import ( // Client manages a local shell PTY session. type Client struct { - mu sync.Mutex - cmd *exec.Cmd - ptmx *os.File - shell string - onData func([]byte) - onError func(error) - onClose func() - closed bool - cols uint16 - rows uint16 + mu sync.Mutex + cmd *exec.Cmd + ptmx *os.File + shell string + startDir string + onData func([]byte) + onError func(error) + onClose func() + closed bool + cols uint16 + rows uint16 } // Config holds configuration for a local shell session. type Config struct { - Shell string // Path to the shell binary (e.g. /bin/zsh) - Cols uint16 - Rows uint16 - OnData func([]byte) - OnError func(error) - OnClose func() + Shell string // Path to the shell binary (e.g. /bin/zsh) + Cols uint16 + Rows uint16 + StartDir string // Initial working directory; defaults to the user's home directory. + OnData func([]byte) + OnError func(error) + OnClose func() } // DefaultShell returns the system default shell. @@ -67,13 +76,20 @@ func NewClient(cfg Config) *Client { if rows == 0 { rows = 24 } + startDir := cfg.StartDir + if startDir == "" { + if home, err := os.UserHomeDir(); err == nil { + startDir = home + } + } return &Client{ - shell: shell, - cols: cols, - rows: rows, - onData: cfg.OnData, - onError: cfg.OnError, - onClose: cfg.OnClose, + shell: shell, + startDir: startDir, + cols: cols, + rows: rows, + onData: cfg.OnData, + onError: cfg.OnError, + onClose: cfg.OnClose, } } @@ -82,17 +98,16 @@ func (c *Client) Start() error { c.mu.Lock() defer c.mu.Unlock() + if c.closed { + return fmt.Errorf("shell already closed") + } if c.ptmx != nil { return fmt.Errorf("shell already running") } cmd := exec.Command(c.shell) cmd.Env = terminalenv.LocalShellEnv(os.Environ()) - - // Start in the user's home directory. - if home, err := os.UserHomeDir(); err == nil { - cmd.Dir = home - } + cmd.Dir = c.startDir ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{ Cols: c.cols, @@ -106,7 +121,7 @@ func (c *Client) Start() error { c.ptmx = ptmx // Read loop — forward PTY output to the frontend. - go c.readLoop() + go c.readLoop(ptmx) // Wait for process exit. go func() { @@ -114,6 +129,9 @@ func (c *Client) Start() error { c.mu.Lock() wasClosed := c.closed c.closed = true + if c.ptmx == ptmx { + c.ptmx = nil + } c.mu.Unlock() if !wasClosed && c.onClose != nil { @@ -126,6 +144,13 @@ func (c *Client) Start() error { // Write sends data to the shell's stdin (PTY). func (c *Client) Write(data []byte) error { + // Capture the shell's current directory before it handles a command or + // EOF. This lets a replacement shell resume in the same directory after + // an ordinary `exit`, without injecting shell-specific prompt hooks. + if bytesContainCommandBoundary(data) { + c.captureWorkingDirectory() + } + c.mu.Lock() ptmx := c.ptmx c.mu.Unlock() @@ -140,7 +165,15 @@ func (c *Client) Write(data []byte) error { // Resize updates the PTY window size. func (c *Client) Resize(cols, rows uint16) error { c.mu.Lock() + if cols > 0 { + c.cols = cols + } + if rows > 0 { + c.rows = rows + } ptmx := c.ptmx + cols = c.cols + rows = c.rows c.mu.Unlock() if ptmx == nil { @@ -152,26 +185,36 @@ func (c *Client) Resize(cols, rows uint16) error { // Close terminates the shell process and PTY. func (c *Client) Close() { c.mu.Lock() - defer c.mu.Unlock() - if c.closed { + c.mu.Unlock() return } c.closed = true + ptmx := c.ptmx + cmd := c.cmd + c.ptmx = nil + c.mu.Unlock() - if c.ptmx != nil { - c.ptmx.Close() - c.ptmx = nil + if ptmx != nil { + _ = ptmx.Close() } - if c.cmd != nil && c.cmd.Process != nil { - c.cmd.Process.Kill() + if cmd != nil && cmd.Process != nil { + _ = cmd.Process.Kill() } } -func (c *Client) readLoop() { +// WorkingDirectory returns the last directory observed for the shell. +func (c *Client) WorkingDirectory() string { + c.mu.Lock() + defer c.mu.Unlock() + return c.startDir +} + +func (c *Client) readLoop(ptmx *os.File) { + defer ptmx.Close() buf := make([]byte, 32*1024) for { - n, err := c.ptmx.Read(buf) + n, err := ptmx.Read(buf) if n > 0 && c.onData != nil { c.onData(buf[:n]) } @@ -179,10 +222,73 @@ func (c *Client) readLoop() { c.mu.Lock() wasClosed := c.closed c.mu.Unlock() - if !wasClosed && c.onError != nil { + if !wasClosed && !isNormalPTYClosure(err) && c.onError != nil { c.onError(err) } return } } } + +func bytesContainCommandBoundary(data []byte) bool { + for _, b := range data { + if b == '\r' || b == '\n' || b == 0x04 { + return true + } + } + return false +} + +func isNormalPTYClosure(err error) bool { + return errors.Is(err, io.EOF) || errors.Is(err, os.ErrClosed) || errors.Is(err, syscall.EIO) +} + +func (c *Client) captureWorkingDirectory() { + c.mu.Lock() + cmd := c.cmd + c.mu.Unlock() + if cmd == nil || cmd.Process == nil { + return + } + + dir := processWorkingDirectory(cmd.Process.Pid) + if dir == "" || !isDirectory(dir) { + return + } + + c.mu.Lock() + if c.cmd == cmd { + c.startDir = dir + } + c.mu.Unlock() +} + +func processWorkingDirectory(pid int) string { + switch runtime.GOOS { + case "linux": + dir, err := os.Readlink("/proc/" + strconv.Itoa(pid) + "/cwd") + if err == nil { + return dir + } + case "darwin": + // macOS does not expose /proc. lsof is part of the base system and its + // field output gives us the cwd without parsing a human-readable table. + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + out, err := exec.CommandContext(ctx, "/usr/sbin/lsof", "-a", "-p", strconv.Itoa(pid), "-d", "cwd", "-Fn").Output() + if err != nil { + return "" + } + for _, line := range strings.Split(string(out), "\n") { + if strings.HasPrefix(line, "n") { + return strings.TrimPrefix(line, "n") + } + } + } + return "" +} + +func isDirectory(path string) bool { + info, err := os.Stat(path) + return err == nil && info.IsDir() +} diff --git a/internal/localshell/localshell_test.go b/internal/localshell/localshell_test.go new file mode 100644 index 0000000..e8c3f5a --- /dev/null +++ b/internal/localshell/localshell_test.go @@ -0,0 +1,146 @@ +package localshell + +import ( + "bytes" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + "time" +) + +func TestClientUsesConfiguredDirectoryAndPreStartSize(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("local PTY test requires a Unix shell") + } + + dir := t.TempDir() + startDir := filepath.Join(dir, "working") + if err := os.Mkdir(startDir, 0o700); err != nil { + t.Fatal(err) + } + script := filepath.Join(dir, "report-terminal.sh") + if err := os.WriteFile(script, []byte("#!/bin/sh\nstty size\npwd\n"), 0o700); err != nil { + t.Fatal(err) + } + + var output bytes.Buffer + var outputMu sync.Mutex + closed := make(chan struct{}) + errors := make(chan error, 1) + client := NewClient(Config{ + Shell: script, + StartDir: startDir, + Cols: 80, + Rows: 24, + OnData: func(data []byte) { + outputMu.Lock() + _, _ = output.Write(data) + outputMu.Unlock() + }, + OnError: func(err error) { + errors <- err + }, + OnClose: func() { + close(closed) + }, + }) + + // A frontend fit can arrive before the process starts. The stored size + // must become the initial PTY geometry rather than being discarded. + if err := client.Resize(132, 43); err != nil { + t.Fatalf("Resize() before Start() error = %v", err) + } + if err := client.Start(); err != nil { + t.Fatalf("Start() error = %v", err) + } + + select { + case err := <-errors: + t.Fatalf("unexpected PTY read error: %v", err) + case <-closed: + case <-time.After(5 * time.Second): + client.Close() + t.Fatal("timed out waiting for local shell to exit") + } + + outputMu.Lock() + got := strings.ReplaceAll(output.String(), "\r", "") + outputMu.Unlock() + if !strings.Contains(got, "43 132\n") { + t.Fatalf("PTY output %q does not contain configured size 43 132", got) + } + if !strings.Contains(got, startDir+"\n") { + t.Fatalf("PTY output %q does not contain start directory %q", got, startDir) + } +} + +func TestBytesContainCommandBoundary(t *testing.T) { + for _, data := range [][]byte{[]byte("pwd\r"), []byte("pwd\n"), {0x04}} { + if !bytesContainCommandBoundary(data) { + t.Fatalf("bytesContainCommandBoundary(%q) = false, want true", data) + } + } + if bytesContainCommandBoundary([]byte("pwd")) { + t.Fatal("bytesContainCommandBoundary without enter/EOF = true, want false") + } +} + +func TestClientCapturesWorkingDirectoryBeforeExit(t *testing.T) { + if runtime.GOOS != "darwin" && runtime.GOOS != "linux" { + t.Skip("process cwd lookup is implemented on macOS and Linux") + } + + dir := t.TempDir() + startDir := filepath.Join(dir, "start") + nextDir := filepath.Join(startDir, "next") + if err := os.MkdirAll(nextDir, 0o700); err != nil { + t.Fatal(err) + } + expectedDir, err := filepath.EvalSymlinks(nextDir) + if err != nil { + t.Fatal(err) + } + + closed := make(chan struct{}) + client := NewClient(Config{ + Shell: "/bin/sh", + StartDir: startDir, + OnClose: func() { + close(closed) + }, + }) + if err := client.Start(); err != nil { + t.Fatalf("Start() error = %v", err) + } + defer client.Close() + + if err := client.Write([]byte("cd next\r")); err != nil { + t.Fatalf("Write(cd) error = %v", err) + } + deadline := time.Now().Add(5 * time.Second) + for client.WorkingDirectory() != expectedDir && time.Now().Before(deadline) { + time.Sleep(20 * time.Millisecond) + // The empty command boundary snapshots the directory after cd has run. + if err := client.Write([]byte("\r")); err != nil { + t.Fatalf("Write(snapshot) error = %v", err) + } + } + if got := client.WorkingDirectory(); got != expectedDir { + t.Fatalf("WorkingDirectory() = %q, want %q", got, expectedDir) + } + + if err := client.Write([]byte("exit\r")); err != nil { + t.Fatalf("Write(exit) error = %v", err) + } + select { + case <-closed: + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for local shell to exit") + } + if got := client.WorkingDirectory(); got != expectedDir { + t.Fatalf("WorkingDirectory() after exit = %q, want %q", got, expectedDir) + } +} diff --git a/internal/models/defaults.go b/internal/models/defaults.go index 32975a4..d3652d7 100644 --- a/internal/models/defaults.go +++ b/internal/models/defaults.go @@ -54,6 +54,7 @@ func DefaultAppPreferences() AppPreferences { DefaultCursorStyle: DefaultCursorStyle, UseNativePaste: false, LocalShell: "", // empty means use system default + LeviathanEnabled: false, } } diff --git a/internal/models/defaults_test.go b/internal/models/defaults_test.go new file mode 100644 index 0000000..aef6ae2 --- /dev/null +++ b/internal/models/defaults_test.go @@ -0,0 +1,10 @@ +package models + +import "testing" + +func TestDefaultAppPreferencesHideLeviathan(t *testing.T) { + prefs := DefaultAppPreferences() + if prefs.LeviathanEnabled { + t.Fatal("LeviathanEnabled = true, want false by default") + } +} diff --git a/internal/models/models.go b/internal/models/models.go index 72194fb..6047f20 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -346,6 +346,7 @@ type AppPreferences struct { DefaultCursorStyle string `json:"defaultCursorStyle"` UseNativePaste bool `json:"useNativePaste"` LocalShell string `json:"localShell"` // path to shell binary for local terminal + LeviathanEnabled bool `json:"leviathanEnabled"` } // AuditEntry records an auditable action within the application. diff --git a/internal/sessions/manager.go b/internal/sessions/manager.go index d2e2ccf..c3bbb3f 100644 --- a/internal/sessions/manager.go +++ b/internal/sessions/manager.go @@ -46,6 +46,8 @@ type Session struct { vncClient *vncclient.Client // VNC proxy client, nil for non-VNC sessions ircClient *ircclient.Client // IRC client, nil for non-IRC sessions localClient *localshell.Client // local shell PTY, nil for non-local sessions + localShell string // configured shell path, retained for restart + localCwd string // last known local working directory sftpClient *sftpclient.Client // SFTP subsystem, nil until requested profile *models.Profile recorder *recording.Recorder // active recording, nil if not recording @@ -214,7 +216,8 @@ func (m *Manager) ConnectLocal(shell string) (string, error) { } sess := &Session{ - Info: info, + Info: info, + localShell: shell, } m.mu.Lock() @@ -226,43 +229,76 @@ func (m *Manager) ConnectLocal(shell string) (string, error) { "state": string(models.SessionConnecting), }) - go func() { - client := localshell.NewClient(localshell.Config{ - Shell: shell, - Cols: 80, - Rows: 24, - OnData: func(data []byte) { - m.emitEvent("session:data", map[string]interface{}{ - "sessionID": sessionID, - "data": base64.StdEncoding.EncodeToString(data), - }) - // Write to active recording if present. - m.mu.RLock() - rec := sess.recorder - m.mu.RUnlock() - if rec != nil { - rec.WriteOutput(data) - } - }, - OnError: func(err error) { - m.mu.Lock() - sess.Info.State = models.SessionError - sess.Info.Error = err.Error() - m.mu.Unlock() + go m.startLocalSession(sess, 80, 24) - m.emitEvent("session:state", map[string]interface{}{ - "sessionID": sessionID, - "state": string(models.SessionError), - "error": err.Error(), - }) - }, - OnClose: func() { - m.handleSessionClosed(sessionID) - }, - }) + return sessionID, nil +} - if err := client.Start(); err != nil { +// RestartLocal starts a replacement shell inside an exited local session. +// Keeping the session ID lets the frontend retain the tab and terminal buffer. +func (m *Manager) RestartLocal(sessionID string, cols, rows int) error { + m.mu.Lock() + sess, ok := m.sessions[sessionID] + if !ok { + m.mu.Unlock() + return fmt.Errorf("sessions: session %q not found", sessionID) + } + if sess.Info.Protocol != models.ProtocolLocal { + m.mu.Unlock() + return fmt.Errorf("sessions: session %q is not local", sessionID) + } + if sess.localClient != nil || sess.Info.State == models.SessionConnecting || sess.Info.State == models.SessionConnected { + m.mu.Unlock() + return fmt.Errorf("sessions: local session %q is still running", sessionID) + } + if cols <= 0 { + cols = 80 + } + if rows <= 0 { + rows = 24 + } + sess.Info.State = models.SessionConnecting + sess.Info.Error = "" + m.mu.Unlock() + + m.emitEvent("session:state", map[string]interface{}{ + "sessionID": sessionID, + "state": string(models.SessionConnecting), + }) + go m.startLocalSession(sess, cols, rows) + return nil +} + +func (m *Manager) startLocalSession(sess *Session, cols, rows int) { + m.mu.RLock() + sessionID := sess.Info.ID + shell := sess.localShell + startDir := sess.localCwd + m.mu.RUnlock() + var client *localshell.Client + client = localshell.NewClient(localshell.Config{ + Shell: shell, + StartDir: startDir, + Cols: terminalDimension(cols, 80), + Rows: terminalDimension(rows, 24), + OnData: func(data []byte) { + m.emitEvent("session:data", map[string]interface{}{ + "sessionID": sessionID, + "data": base64.StdEncoding.EncodeToString(data), + }) + m.mu.RLock() + rec := sess.recorder + m.mu.RUnlock() + if rec != nil { + rec.WriteOutput(data) + } + }, + OnError: func(err error) { m.mu.Lock() + if sess.localClient != client { + m.mu.Unlock() + return + } sess.Info.State = models.SessionError sess.Info.Error = err.Error() m.mu.Unlock() @@ -272,29 +308,85 @@ func (m *Manager) ConnectLocal(shell string) (string, error) { "state": string(models.SessionError), "error": err.Error(), }) - return + }, + OnClose: func() { + m.handleLocalSessionClosed(sessionID, client) + }, + }) + + // Publish the client before Start so a resize arriving during process + // creation is retained by Client.Resize and used for the initial PTY size. + m.mu.Lock() + if sess.Info.State != models.SessionConnecting { + m.mu.Unlock() + return + } + sess.localClient = client + pendingCols, pendingRows := sess.consumePendingResizeLocked() + m.mu.Unlock() + applyLocalResize(client, pendingCols, pendingRows) + + if err := client.Start(); err != nil { + m.mu.Lock() + emitError := false + if sess.localClient == client { + sess.localClient = nil + sess.Info.State = models.SessionError + sess.Info.Error = err.Error() + emitError = true } - - m.mu.Lock() - sess.localClient = client - cols, rows := sess.consumePendingResizeLocked() - m.mu.Unlock() - applyLocalResize(client, cols, rows) - - m.mu.Lock() - sess.Info.State = models.SessionConnected - sess.Info.ConnectedAt = time.Now() m.mu.Unlock() - m.emitEvent("session:state", map[string]interface{}{ - "sessionID": sessionID, - "state": string(models.SessionConnected), - }) + if emitError { + m.emitEvent("session:state", map[string]interface{}{ + "sessionID": sessionID, + "state": string(models.SessionError), + "error": err.Error(), + }) + } + return + } - m.auditLog.LogConnect("", sessionID, "local", "localhost") - }() + m.mu.Lock() + if sess.localClient != client || sess.Info.State != models.SessionConnecting { + m.mu.Unlock() + return + } + sess.Info.State = models.SessionConnected + sess.Info.ConnectedAt = time.Now() + m.mu.Unlock() - return sessionID, nil + m.emitEvent("session:state", map[string]interface{}{ + "sessionID": sessionID, + "state": string(models.SessionConnected), + }) + m.auditLog.LogConnect("", sessionID, "local", "localhost") +} + +func (m *Manager) handleLocalSessionClosed(sessionID string, client *localshell.Client) { + m.mu.Lock() + sess, ok := m.sessions[sessionID] + if !ok || sess.localClient != client { + m.mu.Unlock() + return + } + sess.localClient = nil + if cwd := client.WorkingDirectory(); cwd != "" { + sess.localCwd = cwd + } + m.mu.Unlock() + + m.handleSessionClosed(sessionID) +} + +func terminalDimension(value int, fallback uint16) uint16 { + if value <= 0 { + return fallback + } + if value > int(^uint16(0)) { + return ^uint16(0) + } + return uint16(value) } // Disconnect closes a specific session. @@ -353,29 +445,31 @@ func (m *Manager) Reconnect(sessionID string, getSecret func(prompt string) (str func (m *Manager) WriteToSession(sessionID string, data []byte) error { m.mu.RLock() sess, ok := m.sessions[sessionID] - m.mu.RUnlock() - if !ok { + m.mu.RUnlock() return fmt.Errorf("sessions: session %q not found", sessionID) } + rec := sess.recorder + sshClient := sess.sshClient + telnetClient := sess.telnetClient + rawClient := sess.rawClient + localClient := sess.localClient + m.mu.RUnlock() // Record input if recording is active. - m.mu.RLock() - rec := sess.recorder - m.mu.RUnlock() if rec != nil { rec.WriteInput(data) } switch { - case sess.sshClient != nil: - return sess.sshClient.Write(data) - case sess.telnetClient != nil: - return sess.telnetClient.Write(data) - case sess.rawClient != nil: - return sess.rawClient.Write(data) - case sess.localClient != nil: - return sess.localClient.Write(data) + case sshClient != nil: + return sshClient.Write(data) + case telnetClient != nil: + return telnetClient.Write(data) + case rawClient != nil: + return rawClient.Write(data) + case localClient != nil: + return localClient.Write(data) } return fmt.Errorf("sessions: no active client for session %q", sessionID) } @@ -405,7 +499,7 @@ func (m *Manager) ResizeSession(sessionID string, cols, rows int) error { case telnetClient != nil: return telnetClient.Resize(cols, rows) case localClient != nil: - return localClient.Resize(uint16(cols), uint16(rows)) + return localClient.Resize(terminalDimension(cols, 80), terminalDimension(rows, 24)) } // RAW sessions have no concept of terminal size. return nil @@ -439,7 +533,7 @@ func applyLocalResize(client *localshell.Client, cols, rows int) { if cols <= 0 || rows <= 0 { return } - _ = client.Resize(uint16(cols), uint16(rows)) + _ = client.Resize(terminalDimension(cols, 80), terminalDimension(rows, 24)) } // GetSessionCwd returns a best-effort working directory for SSH sessions. @@ -2021,9 +2115,12 @@ func (m *Manager) closeSession(sess *Session) { sess.ircClient.Close() sess.ircClient = nil } - if sess.localClient != nil { - sess.localClient.Close() - sess.localClient = nil + m.mu.Lock() + localClient := sess.localClient + sess.localClient = nil + m.mu.Unlock() + if localClient != nil { + localClient.Close() } m.mu.Lock() diff --git a/internal/sessions/manager_test.go b/internal/sessions/manager_test.go index de0116c..5ab12a2 100644 --- a/internal/sessions/manager_test.go +++ b/internal/sessions/manager_test.go @@ -3,9 +3,16 @@ package sessions import ( "crypto/ed25519" "crypto/rand" + "encoding/base64" + "os" "path/filepath" + "runtime" + "strings" + "sync" "testing" + "time" + "github.com/andydixon/nexus/internal/audit" "github.com/andydixon/nexus/internal/models" "github.com/andydixon/nexus/internal/storage" "golang.org/x/crypto/ssh" @@ -154,6 +161,103 @@ func TestCloseAllEmpty(t *testing.T) { } } +func TestTerminalDimension(t *testing.T) { + if got := terminalDimension(0, 80); got != 80 { + t.Fatalf("terminalDimension(0, 80) = %d, want 80", got) + } + if got := terminalDimension(132, 80); got != 132 { + t.Fatalf("terminalDimension(132, 80) = %d, want 132", got) + } + if got := terminalDimension(100000, 80); got != ^uint16(0) { + t.Fatalf("terminalDimension(100000, 80) = %d, want %d", got, ^uint16(0)) + } +} + +func TestRestartLocalReusesSessionAndAppliesRequestedSize(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("local PTY test requires a Unix shell") + } + dir := t.TempDir() + script := filepath.Join(dir, "local-shell.sh") + if err := os.WriteFile(script, []byte("#!/bin/sh\nstty size\n"), 0o700); err != nil { + t.Fatal(err) + } + + auditStore := &sessionAuditStore{} + mgr := NewManager(nil, nil, nil, audit.NewLogger(auditStore), nil) + var outputMu sync.Mutex + var output strings.Builder + disconnected := make(chan string, 2) + mgr.SetEventSink(func(name string, payload interface{}) { + values, ok := payload.(map[string]interface{}) + if !ok { + return + } + if name == "session:data" { + encoded, _ := values["data"].(string) + decoded, _ := base64.StdEncoding.DecodeString(encoded) + outputMu.Lock() + _, _ = output.Write(decoded) + outputMu.Unlock() + } + if name == "session:state" && values["state"] == string(models.SessionDisconnected) { + disconnected <- values["sessionID"].(string) + } + }) + + sessionID, err := mgr.ConnectLocal(script) + if err != nil { + t.Fatalf("ConnectLocal() error = %v", err) + } + waitForDisconnectedSession(t, disconnected, sessionID) + + if err := mgr.RestartLocal(sessionID, 132, 43); err != nil { + t.Fatalf("RestartLocal() error = %v", err) + } + waitForDisconnectedSession(t, disconnected, sessionID) + + info := mgr.GetSessionInfo(sessionID) + if info == nil || info.ID != sessionID || info.State != models.SessionDisconnected { + t.Fatalf("session after restart = %#v, want same disconnected session", info) + } + outputMu.Lock() + got := strings.ReplaceAll(output.String(), "\r", "") + outputMu.Unlock() + if !strings.Contains(got, "43 132\n") { + t.Fatalf("local output %q does not contain restarted size 43 132", got) + } +} + +func waitForDisconnectedSession(t *testing.T, states <-chan string, want string) { + t.Helper() + select { + case got := <-states: + if got != want { + t.Fatalf("disconnected session = %q, want %q", got, want) + } + case <-time.After(5 * time.Second): + t.Fatalf("timed out waiting for session %q to disconnect", want) + } +} + +type sessionAuditStore struct { + mu sync.Mutex + entries []*models.AuditEntry +} + +func (s *sessionAuditStore) AddAuditEntry(entry *models.AuditEntry) error { + s.mu.Lock() + s.entries = append(s.entries, entry) + s.mu.Unlock() + return nil +} + +func (s *sessionAuditStore) ListAuditEntries(limit int) ([]*models.AuditEntry, error) { + s.mu.Lock() + defer s.mu.Unlock() + return append([]*models.AuditEntry(nil), s.entries...), nil +} + func TestHostKeyCallbackAcceptNewStoresHost(t *testing.T) { store := newSessionTestStore(t) mgr := NewManager(store, nil, nil, nil, nil) diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go index 6b6ae19..9d8016e 100644 --- a/internal/storage/storage_test.go +++ b/internal/storage/storage_test.go @@ -306,6 +306,7 @@ func TestSavePreferencesDoesNotError(t *testing.T) { prefs.ConfirmOnClose = true prefs.DefaultFontSize = 16 prefs.Theme = "custom-dark" + prefs.LeviathanEnabled = true if err := store.SavePreferences(&prefs); err != nil { t.Fatalf("SavePreferences() error = %v", err) @@ -327,6 +328,9 @@ func TestSavePreferencesDoesNotError(t *testing.T) { if got.Theme != "custom-dark" { t.Errorf("Theme = %q, want %q", got.Theme, "custom-dark") } + if !got.LeviathanEnabled { + t.Error("LeviathanEnabled = false, want true") + } } // --------------------------------------------------------------------------- diff --git a/release.sh b/release.sh index a814289..9e00d75 100755 --- a/release.sh +++ b/release.sh @@ -295,8 +295,11 @@ main() { log "Building release ${version}..." : > "${out_dir}/.release-files" - local targets - mapfile -t targets < <(release_targets) + local targets=() + local selected_target + while IFS= read -r selected_target; do + [ -n "$selected_target" ] && targets+=("$selected_target") + done < <(release_targets) if [ "${#targets[@]}" -eq 0 ]; then err "No release targets selected." exit 1