feat: improve local terminal workflows
This commit is contained in:
@@ -61,6 +61,7 @@
|
|||||||
class="sidebar-title sidebar-mode-toggle"
|
class="sidebar-title sidebar-mode-toggle"
|
||||||
id="mode-leviathan"
|
id="mode-leviathan"
|
||||||
type="button"
|
type="button"
|
||||||
|
hidden
|
||||||
>
|
>
|
||||||
Leviathan
|
Leviathan
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -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) {
|
function assertTreeNotContains(relDir, needle, message) {
|
||||||
const dir = path.join(repo, relDir);
|
const dir = path.join(repo, relDir);
|
||||||
const offenders = [];
|
const offenders = [];
|
||||||
@@ -74,6 +85,26 @@ assertContains(
|
|||||||
"cancelScheduledFit",
|
"cancelScheduledFit",
|
||||||
"terminal teardown must cancel pending fit work",
|
"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(
|
assertNotContains(
|
||||||
"frontend/src/js/terminal.js",
|
"frontend/src/js/terminal.js",
|
||||||
"new ResizeObserver(() => {\n try {\n fitAddon.fit();",
|
"new ResizeObserver(() => {\n try {\n fitAddon.fit();",
|
||||||
@@ -114,6 +145,31 @@ assertContains(
|
|||||||
"mode-leviathan",
|
"mode-leviathan",
|
||||||
"Leviathan mode toggle must be present beside Nexus Terminal",
|
"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(
|
assertContains(
|
||||||
"frontend/src/css/sidebar.css",
|
"frontend/src/css/sidebar.css",
|
||||||
"user-select: none",
|
"user-select: none",
|
||||||
@@ -299,6 +355,17 @@ assertContains(
|
|||||||
"setMode(\"leviathan\")",
|
"setMode(\"leviathan\")",
|
||||||
"App mode switch must route to 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(
|
assertContains(
|
||||||
"internal/api/leviathan.go",
|
"internal/api/leviathan.go",
|
||||||
"LeviathanRunTool",
|
"LeviathanRunTool",
|
||||||
|
|||||||
@@ -66,6 +66,10 @@
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#mode-leviathan[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-mode-toggle.is-active {
|
.sidebar-mode-toggle.is-active {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-2
@@ -191,13 +191,23 @@
|
|||||||
);
|
);
|
||||||
this.updateStatusBar();
|
this.updateStatusBar();
|
||||||
|
|
||||||
|
const session = this.state.getSession(payload.sessionID);
|
||||||
|
const isLocal = session?.protocol === "local";
|
||||||
|
const localExited =
|
||||||
|
isLocal && payload.state === "disconnected";
|
||||||
const showDisconnectedOverlay =
|
const showDisconnectedOverlay =
|
||||||
payload.state === "disconnected" || payload.state === "error";
|
!localExited &&
|
||||||
|
(payload.state === "disconnected" || payload.state === "error");
|
||||||
this.terminals.setDisconnectedOverlay(
|
this.terminals.setDisconnectedOverlay(
|
||||||
payload.sessionID,
|
payload.sessionID,
|
||||||
showDisconnectedOverlay,
|
showDisconnectedOverlay,
|
||||||
payload.error,
|
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) {
|
if (payload.state === "error" && payload.error) {
|
||||||
this._handleSessionError(payload.sessionID, payload.error);
|
this._handleSessionError(payload.sessionID, payload.error);
|
||||||
@@ -612,7 +622,10 @@
|
|||||||
* @param {"nexus"|"leviathan"} mode
|
* @param {"nexus"|"leviathan"} mode
|
||||||
*/
|
*/
|
||||||
setMode(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");
|
const root = document.getElementById("app");
|
||||||
if (root) {
|
if (root) {
|
||||||
root.classList.toggle("mode-leviathan", this.mode === "leviathan");
|
root.classList.toggle("mode-leviathan", this.mode === "leviathan");
|
||||||
@@ -1374,6 +1387,16 @@
|
|||||||
prefs.showStatusBar === false ? "none" : "flex";
|
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.
|
// Refit any visible terminal after geometry changes.
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
this.terminals.fitActive();
|
this.terminals.fitActive();
|
||||||
|
|||||||
@@ -18,6 +18,19 @@
|
|||||||
{ key: "about", label: "About" },
|
{ key: "about", label: "About" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const KONAMI_SEQUENCE = [
|
||||||
|
"ArrowUp",
|
||||||
|
"ArrowUp",
|
||||||
|
"ArrowDown",
|
||||||
|
"ArrowDown",
|
||||||
|
"ArrowLeft",
|
||||||
|
"ArrowRight",
|
||||||
|
"ArrowLeft",
|
||||||
|
"ArrowRight",
|
||||||
|
"b",
|
||||||
|
"a",
|
||||||
|
];
|
||||||
|
|
||||||
class Settings {
|
class Settings {
|
||||||
/**
|
/**
|
||||||
* @param {App} app
|
* @param {App} app
|
||||||
@@ -36,6 +49,10 @@
|
|||||||
|
|
||||||
/** @type {Array<Object>} Cached known hosts list. */
|
/** @type {Array<Object>} Cached known hosts list. */
|
||||||
this._knownHosts = [];
|
this._knownHosts = [];
|
||||||
|
|
||||||
|
this._aboutKonamiHandler = null;
|
||||||
|
this._konamiIndex = 0;
|
||||||
|
this._konamiTogglePending = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Public API ───────────────────────────────────────────────────
|
// ── Public API ───────────────────────────────────────────────────
|
||||||
@@ -75,6 +92,7 @@
|
|||||||
* Closes the settings modal.
|
* Closes the settings modal.
|
||||||
*/
|
*/
|
||||||
close() {
|
close() {
|
||||||
|
this._detachAboutKonamiListener();
|
||||||
if (this._overlay) {
|
if (this._overlay) {
|
||||||
this._overlay.remove();
|
this._overlay.remove();
|
||||||
this._overlay = null;
|
this._overlay = null;
|
||||||
@@ -198,6 +216,7 @@
|
|||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
_renderTabContent(body) {
|
_renderTabContent(body) {
|
||||||
|
this._detachAboutKonamiListener();
|
||||||
body.innerHTML = "";
|
body.innerHTML = "";
|
||||||
body.classList.remove("help-layout");
|
body.classList.remove("help-layout");
|
||||||
switch (this._activeTab) {
|
switch (this._activeTab) {
|
||||||
@@ -1058,10 +1077,74 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this._attachAboutKonamiListener();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Form helpers ─────────────────────────────────────────────────
|
// ── 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.
|
* Renders a single settings form field.
|
||||||
* @param {string} type
|
* @param {string} type
|
||||||
|
|||||||
@@ -385,11 +385,28 @@ class SplitPaneManager {
|
|||||||
|
|
||||||
// Build profile list
|
// Build profile list
|
||||||
var profiles = this.app.state.profiles || [];
|
var profiles = this.app.state.profiles || [];
|
||||||
if (profiles.length > 0) {
|
|
||||||
var list = document.createElement('div');
|
var list = document.createElement('div');
|
||||||
list.className = 'split-pane-profile-list';
|
list.className = 'split-pane-profile-list';
|
||||||
|
|
||||||
var self = this;
|
var self = this;
|
||||||
|
var localBtn = document.createElement('button');
|
||||||
|
localBtn.className = 'split-pane-profile-btn';
|
||||||
|
|
||||||
|
var localBadge = document.createElement('span');
|
||||||
|
localBadge.className = 'protocol-badge';
|
||||||
|
localBadge.textContent = 'LOCAL';
|
||||||
|
localBadge.style.color = self._protocolColor('local');
|
||||||
|
|
||||||
|
var localLabel = document.createElement('span');
|
||||||
|
localLabel.textContent = 'Local Machine';
|
||||||
|
|
||||||
|
localBtn.appendChild(localBadge);
|
||||||
|
localBtn.appendChild(localLabel);
|
||||||
|
localBtn.addEventListener('click', function() {
|
||||||
|
self._connectLocalInPane(paneEl, ownerSessionId);
|
||||||
|
});
|
||||||
|
list.appendChild(localBtn);
|
||||||
|
|
||||||
profiles.forEach(function(profile) {
|
profiles.forEach(function(profile) {
|
||||||
var btn = document.createElement('button');
|
var btn = document.createElement('button');
|
||||||
btn.className = 'split-pane-profile-btn';
|
btn.className = 'split-pane-profile-btn';
|
||||||
@@ -413,7 +430,6 @@ class SplitPaneManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
placeholder.appendChild(list);
|
placeholder.appendChild(list);
|
||||||
}
|
|
||||||
|
|
||||||
paneEl.appendChild(placeholder);
|
paneEl.appendChild(placeholder);
|
||||||
}
|
}
|
||||||
@@ -427,7 +443,6 @@ class SplitPaneManager {
|
|||||||
*/
|
*/
|
||||||
_connectInPane(paneEl, profile, ownerSessionId) {
|
_connectInPane(paneEl, profile, ownerSessionId) {
|
||||||
var self = this;
|
var self = this;
|
||||||
var node = paneEl._splitNode;
|
|
||||||
|
|
||||||
// Remove placeholder
|
// Remove placeholder
|
||||||
var placeholder = paneEl.querySelector('.split-pane-placeholder');
|
var placeholder = paneEl.querySelector('.split-pane-placeholder');
|
||||||
@@ -435,9 +450,52 @@ class SplitPaneManager {
|
|||||||
|
|
||||||
// Connect via backend
|
// Connect via backend
|
||||||
window.NexusTerminal.wailsService.ConnectSession(profile.id).then(function(sessionInfo) {
|
window.NexusTerminal.wailsService.ConnectSession(profile.id).then(function(sessionInfo) {
|
||||||
self.app.state.addSession(sessionInfo);
|
self._attachTerminalToPane(paneEl, sessionInfo, profile);
|
||||||
|
|
||||||
|
self.app.notifications.info('Connecting to ' + (profile.displayName || profile.host) + '…');
|
||||||
|
}).catch(function(err) {
|
||||||
|
self.app.notifications.error('Connection failed: ' + err);
|
||||||
|
// Re-render placeholder on failure
|
||||||
|
self._renderPlaceholder(paneEl, ownerSessionId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
|
||||||
// Create a terminal container inside this pane
|
|
||||||
var termContainer = document.createElement('div');
|
var termContainer = document.createElement('div');
|
||||||
termContainer.className = 'terminal-container';
|
termContainer.className = 'terminal-container';
|
||||||
termContainer.id = 'split-term-' + sessionInfo.id;
|
termContainer.id = 'split-term-' + sessionInfo.id;
|
||||||
@@ -447,20 +505,12 @@ class SplitPaneManager {
|
|||||||
termContainer.style.height = '100%';
|
termContainer.style.height = '100%';
|
||||||
paneEl.appendChild(termContainer);
|
paneEl.appendChild(termContainer);
|
||||||
|
|
||||||
// Track which session is in this pane
|
var node = paneEl._splitNode;
|
||||||
if (node) {
|
if (node) {
|
||||||
node.sessionId = sessionInfo.id;
|
node.sessionId = sessionInfo.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create terminal
|
this.app.terminals.createTerminal(sessionInfo.id, termContainer.id, terminalProfile);
|
||||||
self.app.terminals.createTerminal(sessionInfo.id, termContainer.id, profile);
|
|
||||||
|
|
||||||
self.app.notifications.info('Connecting to ' + (profile.displayName || profile.host) + '…');
|
|
||||||
}).catch(function(err) {
|
|
||||||
self.app.notifications.error('Connection failed: ' + err);
|
|
||||||
// Re-render placeholder on failure
|
|
||||||
self._renderPlaceholder(paneEl, ownerSessionId);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tree traversal helpers ───────────────────────────────────────
|
// ── Tree traversal helpers ───────────────────────────────────────
|
||||||
@@ -588,7 +638,8 @@ class SplitPaneManager {
|
|||||||
ssh: '#4ecdc4',
|
ssh: '#4ecdc4',
|
||||||
telnet: '#ffa726',
|
telnet: '#ffa726',
|
||||||
raw: '#ab47bc',
|
raw: '#ab47bc',
|
||||||
github: '#7c8aff'
|
github: '#7c8aff',
|
||||||
|
local: '#66bb6a'
|
||||||
};
|
};
|
||||||
return colors[protocol] || '#4ecdc4';
|
return colors[protocol] || '#4ecdc4';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -224,6 +224,10 @@
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
fitAddon.fit();
|
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 (_) {
|
} catch (_) {
|
||||||
/* noop */
|
/* noop */
|
||||||
}
|
}
|
||||||
@@ -249,6 +253,14 @@
|
|||||||
|
|
||||||
// Forward user keystrokes to the backend
|
// Forward user keystrokes to the backend
|
||||||
term.onData((data) => {
|
term.onData((data) => {
|
||||||
|
const entry = this.terminals.get(sessionId);
|
||||||
|
if (entry?.localExited) {
|
||||||
|
if (data === "\r" || data === "\n") {
|
||||||
|
this._restartLocalTerminal(sessionId, entry);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const b64 = utf8ToBase64(data);
|
const b64 = utf8ToBase64(data);
|
||||||
window.NexusTerminal.wailsService.WriteSession(sessionId, b64);
|
window.NexusTerminal.wailsService.WriteSession(sessionId, b64);
|
||||||
@@ -330,11 +342,7 @@
|
|||||||
|
|
||||||
// Notify backend when terminal dimensions change
|
// Notify backend when terminal dimensions change
|
||||||
term.onResize(({ cols, rows }) => {
|
term.onResize(({ cols, rows }) => {
|
||||||
try {
|
this._sendTerminalSize(sessionId, { cols, rows });
|
||||||
window.NexusTerminal.wailsService.ResizeSession(sessionId, cols, rows);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("ResizeSession error:", err);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Re-fit when the container itself resizes. ResizeObserver can fire
|
// Re-fit when the container itself resizes. ResizeObserver can fire
|
||||||
@@ -354,6 +362,8 @@
|
|||||||
scheduleFit,
|
scheduleFit,
|
||||||
cancelScheduledFit,
|
cancelScheduledFit,
|
||||||
refreshTerminal,
|
refreshTerminal,
|
||||||
|
localExited: false,
|
||||||
|
localRestarting: false,
|
||||||
decoder:
|
decoder:
|
||||||
typeof TextDecoder !== "undefined" ? new TextDecoder("utf-8") : null,
|
typeof TextDecoder !== "undefined" ? new TextDecoder("utf-8") : null,
|
||||||
});
|
});
|
||||||
@@ -461,6 +471,24 @@
|
|||||||
overlay.classList.toggle("is-visible", isDisconnected);
|
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.
|
* Searches within a terminal using the search addon.
|
||||||
* @param {string} sessionId
|
* @param {string} sessionId
|
||||||
@@ -550,6 +578,34 @@
|
|||||||
|
|
||||||
// ── Private helpers ──────────────────────────────────────────────
|
// ── 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.
|
* Ensures the disconnect overlay exists for a session panel.
|
||||||
* @param {HTMLElement} panel
|
* @param {HTMLElement} panel
|
||||||
|
|||||||
Vendored
+14
@@ -44,6 +44,8 @@ export function DisconnectSession(arg1:string):Promise<void>;
|
|||||||
|
|
||||||
export function DownloadGitHubRepoArchive(arg1:string,arg2:string,arg3:string):Promise<string>;
|
export function DownloadGitHubRepoArchive(arg1:string,arg2:string,arg3:string):Promise<string>;
|
||||||
|
|
||||||
|
export function DownloadGitHubRepoArchiveToPath(arg1:string,arg2:string,arg3:string,arg4:string):Promise<string>;
|
||||||
|
|
||||||
export function DuplicateProfile(arg1:string):Promise<models.Profile>;
|
export function DuplicateProfile(arg1:string):Promise<models.Profile>;
|
||||||
|
|
||||||
export function ExportProfiles(arg1:boolean):Promise<string>;
|
export function ExportProfiles(arg1:boolean):Promise<string>;
|
||||||
@@ -124,6 +126,8 @@ export function LeviathanRunTool(arg1:string,arg2:Record<string, any>):Promise<R
|
|||||||
|
|
||||||
export function LeviathanSaveWorking():Promise<string>;
|
export function LeviathanSaveWorking():Promise<string>;
|
||||||
|
|
||||||
|
export function LeviathanSaveWorkingToPath(arg1:string):Promise<string>;
|
||||||
|
|
||||||
export function LeviathanSetByte(arg1:number,arg2:string):Promise<Record<string, any>>;
|
export function LeviathanSetByte(arg1:number,arg2:string):Promise<Record<string, any>>;
|
||||||
|
|
||||||
export function LeviathanState():Promise<api.leviathanDocState>;
|
export function LeviathanState():Promise<api.leviathanDocState>;
|
||||||
@@ -174,8 +178,14 @@ export function RenameSession(arg1:string,arg2:string):Promise<void>;
|
|||||||
|
|
||||||
export function ResizeSession(arg1:string,arg2:number,arg3:number):Promise<void>;
|
export function ResizeSession(arg1:string,arg2:number,arg3:number):Promise<void>;
|
||||||
|
|
||||||
|
export function RespondSecret(arg1:string,arg2:string,arg3:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function RestartLocalSession(arg1:string,arg2:number,arg3:number):Promise<void>;
|
||||||
|
|
||||||
export function SFTPDownload(arg1:string,arg2:string):Promise<void>;
|
export function SFTPDownload(arg1:string,arg2:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SFTPDownloadToPath(arg1:string,arg2:string,arg3:string):Promise<void>;
|
||||||
|
|
||||||
export function SFTPList(arg1:string,arg2:string):Promise<Array<Record<string, any>>>;
|
export function SFTPList(arg1:string,arg2:string):Promise<Array<Record<string, any>>>;
|
||||||
|
|
||||||
export function SFTPMkdir(arg1:string,arg2:string):Promise<void>;
|
export function SFTPMkdir(arg1:string,arg2:string):Promise<void>;
|
||||||
@@ -198,6 +208,8 @@ export function SFTPUploadData(arg1:string,arg2:string,arg3:string,arg4:string):
|
|||||||
|
|
||||||
export function SFTPUploadFinish(arg1:string):Promise<void>;
|
export function SFTPUploadFinish(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SFTPUploadPath(arg1:string,arg2:string,arg3:string):Promise<void>;
|
||||||
|
|
||||||
export function SFTPUploadStart(arg1:string,arg2:string,arg3:string,arg4:number):Promise<string>;
|
export function SFTPUploadStart(arg1:string,arg2:string,arg3:string,arg4:number):Promise<string>;
|
||||||
|
|
||||||
export function SaveFile(arg1:string,arg2:string):Promise<string>;
|
export function SaveFile(arg1:string,arg2:string):Promise<string>;
|
||||||
@@ -214,6 +226,8 @@ export function SendBreak(arg1:string):Promise<void>;
|
|||||||
|
|
||||||
export function SetContext(arg1:context.Context):Promise<void>;
|
export function SetContext(arg1:context.Context):Promise<void>;
|
||||||
|
|
||||||
|
export function SetEventSink(arg1:any):Promise<void>;
|
||||||
|
|
||||||
export function SetFavourite(arg1:string,arg2:boolean):Promise<void>;
|
export function SetFavourite(arg1:string,arg2:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetProfileSecret(arg1:string,arg2:string,arg3:string):Promise<void>;
|
export function SetProfileSecret(arg1:string,arg2:string,arg3:string):Promise<void>;
|
||||||
|
|||||||
@@ -82,6 +82,10 @@ export function DownloadGitHubRepoArchive(arg1, arg2, arg3) {
|
|||||||
return window['go']['api']['Service']['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) {
|
export function DuplicateProfile(arg1) {
|
||||||
return window['go']['api']['Service']['DuplicateProfile'](arg1);
|
return window['go']['api']['Service']['DuplicateProfile'](arg1);
|
||||||
}
|
}
|
||||||
@@ -242,6 +246,10 @@ export function LeviathanSaveWorking() {
|
|||||||
return window['go']['api']['Service']['LeviathanSaveWorking']();
|
return window['go']['api']['Service']['LeviathanSaveWorking']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function LeviathanSaveWorkingToPath(arg1) {
|
||||||
|
return window['go']['api']['Service']['LeviathanSaveWorkingToPath'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function LeviathanSetByte(arg1, arg2) {
|
export function LeviathanSetByte(arg1, arg2) {
|
||||||
return window['go']['api']['Service']['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);
|
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) {
|
export function SFTPDownload(arg1, arg2) {
|
||||||
return window['go']['api']['Service']['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) {
|
export function SFTPList(arg1, arg2) {
|
||||||
return window['go']['api']['Service']['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);
|
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) {
|
export function SFTPUploadStart(arg1, arg2, arg3, arg4) {
|
||||||
return window['go']['api']['Service']['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);
|
return window['go']['api']['Service']['SetContext'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetEventSink(arg1) {
|
||||||
|
return window['go']['api']['Service']['SetEventSink'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetFavourite(arg1, arg2) {
|
export function SetFavourite(arg1, arg2) {
|
||||||
return window['go']['api']['Service']['SetFavourite'](arg1, arg2);
|
return window['go']['api']['Service']['SetFavourite'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ export namespace models {
|
|||||||
defaultCursorStyle: string;
|
defaultCursorStyle: string;
|
||||||
useNativePaste: boolean;
|
useNativePaste: boolean;
|
||||||
localShell: string;
|
localShell: string;
|
||||||
|
leviathanEnabled: boolean;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new AppPreferences(source);
|
return new AppPreferences(source);
|
||||||
@@ -101,6 +102,7 @@ export namespace models {
|
|||||||
this.defaultCursorStyle = source["defaultCursorStyle"];
|
this.defaultCursorStyle = source["defaultCursorStyle"];
|
||||||
this.useNativePaste = source["useNativePaste"];
|
this.useNativePaste = source["useNativePaste"];
|
||||||
this.localShell = source["localShell"];
|
this.localShell = source["localShell"];
|
||||||
|
this.leviathanEnabled = source["leviathanEnabled"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class AuditEntry {
|
export class AuditEntry {
|
||||||
|
|||||||
@@ -430,6 +430,12 @@ func (s *Service) ConnectLocal() (models.SessionInfo, error) {
|
|||||||
return *info, nil
|
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.
|
// DisconnectSession closes a session by ID.
|
||||||
func (s *Service) DisconnectSession(sessionID string) error {
|
func (s *Service) DisconnectSession(sessionID string) error {
|
||||||
return s.sessions.Disconnect(sessionID)
|
return s.sessions.Disconnect(sessionID)
|
||||||
|
|||||||
@@ -3,11 +3,18 @@
|
|||||||
package localshell
|
package localshell
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/andydixon/nexus/internal/terminalenv"
|
"github.com/andydixon/nexus/internal/terminalenv"
|
||||||
"github.com/creack/pty"
|
"github.com/creack/pty"
|
||||||
@@ -19,6 +26,7 @@ type Client struct {
|
|||||||
cmd *exec.Cmd
|
cmd *exec.Cmd
|
||||||
ptmx *os.File
|
ptmx *os.File
|
||||||
shell string
|
shell string
|
||||||
|
startDir string
|
||||||
onData func([]byte)
|
onData func([]byte)
|
||||||
onError func(error)
|
onError func(error)
|
||||||
onClose func()
|
onClose func()
|
||||||
@@ -32,6 +40,7 @@ type Config struct {
|
|||||||
Shell string // Path to the shell binary (e.g. /bin/zsh)
|
Shell string // Path to the shell binary (e.g. /bin/zsh)
|
||||||
Cols uint16
|
Cols uint16
|
||||||
Rows uint16
|
Rows uint16
|
||||||
|
StartDir string // Initial working directory; defaults to the user's home directory.
|
||||||
OnData func([]byte)
|
OnData func([]byte)
|
||||||
OnError func(error)
|
OnError func(error)
|
||||||
OnClose func()
|
OnClose func()
|
||||||
@@ -67,8 +76,15 @@ func NewClient(cfg Config) *Client {
|
|||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
rows = 24
|
rows = 24
|
||||||
}
|
}
|
||||||
|
startDir := cfg.StartDir
|
||||||
|
if startDir == "" {
|
||||||
|
if home, err := os.UserHomeDir(); err == nil {
|
||||||
|
startDir = home
|
||||||
|
}
|
||||||
|
}
|
||||||
return &Client{
|
return &Client{
|
||||||
shell: shell,
|
shell: shell,
|
||||||
|
startDir: startDir,
|
||||||
cols: cols,
|
cols: cols,
|
||||||
rows: rows,
|
rows: rows,
|
||||||
onData: cfg.OnData,
|
onData: cfg.OnData,
|
||||||
@@ -82,17 +98,16 @@ func (c *Client) Start() error {
|
|||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
if c.closed {
|
||||||
|
return fmt.Errorf("shell already closed")
|
||||||
|
}
|
||||||
if c.ptmx != nil {
|
if c.ptmx != nil {
|
||||||
return fmt.Errorf("shell already running")
|
return fmt.Errorf("shell already running")
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.Command(c.shell)
|
cmd := exec.Command(c.shell)
|
||||||
cmd.Env = terminalenv.LocalShellEnv(os.Environ())
|
cmd.Env = terminalenv.LocalShellEnv(os.Environ())
|
||||||
|
cmd.Dir = c.startDir
|
||||||
// Start in the user's home directory.
|
|
||||||
if home, err := os.UserHomeDir(); err == nil {
|
|
||||||
cmd.Dir = home
|
|
||||||
}
|
|
||||||
|
|
||||||
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{
|
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{
|
||||||
Cols: c.cols,
|
Cols: c.cols,
|
||||||
@@ -106,7 +121,7 @@ func (c *Client) Start() error {
|
|||||||
c.ptmx = ptmx
|
c.ptmx = ptmx
|
||||||
|
|
||||||
// Read loop — forward PTY output to the frontend.
|
// Read loop — forward PTY output to the frontend.
|
||||||
go c.readLoop()
|
go c.readLoop(ptmx)
|
||||||
|
|
||||||
// Wait for process exit.
|
// Wait for process exit.
|
||||||
go func() {
|
go func() {
|
||||||
@@ -114,6 +129,9 @@ func (c *Client) Start() error {
|
|||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
wasClosed := c.closed
|
wasClosed := c.closed
|
||||||
c.closed = true
|
c.closed = true
|
||||||
|
if c.ptmx == ptmx {
|
||||||
|
c.ptmx = nil
|
||||||
|
}
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
|
|
||||||
if !wasClosed && c.onClose != nil {
|
if !wasClosed && c.onClose != nil {
|
||||||
@@ -126,6 +144,13 @@ func (c *Client) Start() error {
|
|||||||
|
|
||||||
// Write sends data to the shell's stdin (PTY).
|
// Write sends data to the shell's stdin (PTY).
|
||||||
func (c *Client) Write(data []byte) error {
|
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()
|
c.mu.Lock()
|
||||||
ptmx := c.ptmx
|
ptmx := c.ptmx
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
@@ -140,7 +165,15 @@ func (c *Client) Write(data []byte) error {
|
|||||||
// Resize updates the PTY window size.
|
// Resize updates the PTY window size.
|
||||||
func (c *Client) Resize(cols, rows uint16) error {
|
func (c *Client) Resize(cols, rows uint16) error {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
|
if cols > 0 {
|
||||||
|
c.cols = cols
|
||||||
|
}
|
||||||
|
if rows > 0 {
|
||||||
|
c.rows = rows
|
||||||
|
}
|
||||||
ptmx := c.ptmx
|
ptmx := c.ptmx
|
||||||
|
cols = c.cols
|
||||||
|
rows = c.rows
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
|
|
||||||
if ptmx == nil {
|
if ptmx == nil {
|
||||||
@@ -152,26 +185,36 @@ func (c *Client) Resize(cols, rows uint16) error {
|
|||||||
// Close terminates the shell process and PTY.
|
// Close terminates the shell process and PTY.
|
||||||
func (c *Client) Close() {
|
func (c *Client) Close() {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
|
||||||
|
|
||||||
if c.closed {
|
if c.closed {
|
||||||
|
c.mu.Unlock()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.closed = true
|
c.closed = true
|
||||||
|
ptmx := c.ptmx
|
||||||
if c.ptmx != nil {
|
cmd := c.cmd
|
||||||
c.ptmx.Close()
|
|
||||||
c.ptmx = nil
|
c.ptmx = nil
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
if ptmx != nil {
|
||||||
|
_ = ptmx.Close()
|
||||||
}
|
}
|
||||||
if c.cmd != nil && c.cmd.Process != nil {
|
if cmd != nil && cmd.Process != nil {
|
||||||
c.cmd.Process.Kill()
|
_ = 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)
|
buf := make([]byte, 32*1024)
|
||||||
for {
|
for {
|
||||||
n, err := c.ptmx.Read(buf)
|
n, err := ptmx.Read(buf)
|
||||||
if n > 0 && c.onData != nil {
|
if n > 0 && c.onData != nil {
|
||||||
c.onData(buf[:n])
|
c.onData(buf[:n])
|
||||||
}
|
}
|
||||||
@@ -179,10 +222,73 @@ func (c *Client) readLoop() {
|
|||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
wasClosed := c.closed
|
wasClosed := c.closed
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
if !wasClosed && c.onError != nil {
|
if !wasClosed && !isNormalPTYClosure(err) && c.onError != nil {
|
||||||
c.onError(err)
|
c.onError(err)
|
||||||
}
|
}
|
||||||
return
|
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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,6 +54,7 @@ func DefaultAppPreferences() AppPreferences {
|
|||||||
DefaultCursorStyle: DefaultCursorStyle,
|
DefaultCursorStyle: DefaultCursorStyle,
|
||||||
UseNativePaste: false,
|
UseNativePaste: false,
|
||||||
LocalShell: "", // empty means use system default
|
LocalShell: "", // empty means use system default
|
||||||
|
LeviathanEnabled: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -346,6 +346,7 @@ type AppPreferences struct {
|
|||||||
DefaultCursorStyle string `json:"defaultCursorStyle"`
|
DefaultCursorStyle string `json:"defaultCursorStyle"`
|
||||||
UseNativePaste bool `json:"useNativePaste"`
|
UseNativePaste bool `json:"useNativePaste"`
|
||||||
LocalShell string `json:"localShell"` // path to shell binary for local terminal
|
LocalShell string `json:"localShell"` // path to shell binary for local terminal
|
||||||
|
LeviathanEnabled bool `json:"leviathanEnabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuditEntry records an auditable action within the application.
|
// AuditEntry records an auditable action within the application.
|
||||||
|
|||||||
+128
-31
@@ -46,6 +46,8 @@ type Session struct {
|
|||||||
vncClient *vncclient.Client // VNC proxy client, nil for non-VNC sessions
|
vncClient *vncclient.Client // VNC proxy client, nil for non-VNC sessions
|
||||||
ircClient *ircclient.Client // IRC client, nil for non-IRC sessions
|
ircClient *ircclient.Client // IRC client, nil for non-IRC sessions
|
||||||
localClient *localshell.Client // local shell PTY, nil for non-local 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
|
sftpClient *sftpclient.Client // SFTP subsystem, nil until requested
|
||||||
profile *models.Profile
|
profile *models.Profile
|
||||||
recorder *recording.Recorder // active recording, nil if not recording
|
recorder *recording.Recorder // active recording, nil if not recording
|
||||||
@@ -215,6 +217,7 @@ func (m *Manager) ConnectLocal(shell string) (string, error) {
|
|||||||
|
|
||||||
sess := &Session{
|
sess := &Session{
|
||||||
Info: info,
|
Info: info,
|
||||||
|
localShell: shell,
|
||||||
}
|
}
|
||||||
|
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
@@ -226,17 +229,63 @@ func (m *Manager) ConnectLocal(shell string) (string, error) {
|
|||||||
"state": string(models.SessionConnecting),
|
"state": string(models.SessionConnecting),
|
||||||
})
|
})
|
||||||
|
|
||||||
go func() {
|
go m.startLocalSession(sess, 80, 24)
|
||||||
client := localshell.NewClient(localshell.Config{
|
|
||||||
|
return sessionID, 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,
|
Shell: shell,
|
||||||
Cols: 80,
|
StartDir: startDir,
|
||||||
Rows: 24,
|
Cols: terminalDimension(cols, 80),
|
||||||
|
Rows: terminalDimension(rows, 24),
|
||||||
OnData: func(data []byte) {
|
OnData: func(data []byte) {
|
||||||
m.emitEvent("session:data", map[string]interface{}{
|
m.emitEvent("session:data", map[string]interface{}{
|
||||||
"sessionID": sessionID,
|
"sessionID": sessionID,
|
||||||
"data": base64.StdEncoding.EncodeToString(data),
|
"data": base64.StdEncoding.EncodeToString(data),
|
||||||
})
|
})
|
||||||
// Write to active recording if present.
|
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
rec := sess.recorder
|
rec := sess.recorder
|
||||||
m.mu.RUnlock()
|
m.mu.RUnlock()
|
||||||
@@ -246,6 +295,10 @@ func (m *Manager) ConnectLocal(shell string) (string, error) {
|
|||||||
},
|
},
|
||||||
OnError: func(err error) {
|
OnError: func(err error) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
|
if sess.localClient != client {
|
||||||
|
m.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
sess.Info.State = models.SessionError
|
sess.Info.State = models.SessionError
|
||||||
sess.Info.Error = err.Error()
|
sess.Info.Error = err.Error()
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
@@ -257,31 +310,48 @@ func (m *Manager) ConnectLocal(shell string) (string, error) {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
OnClose: func() {
|
OnClose: func() {
|
||||||
m.handleSessionClosed(sessionID)
|
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 {
|
if err := client.Start(); err != nil {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
|
emitError := false
|
||||||
|
if sess.localClient == client {
|
||||||
|
sess.localClient = nil
|
||||||
sess.Info.State = models.SessionError
|
sess.Info.State = models.SessionError
|
||||||
sess.Info.Error = err.Error()
|
sess.Info.Error = err.Error()
|
||||||
|
emitError = true
|
||||||
|
}
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
if emitError {
|
||||||
m.emitEvent("session:state", map[string]interface{}{
|
m.emitEvent("session:state", map[string]interface{}{
|
||||||
"sessionID": sessionID,
|
"sessionID": sessionID,
|
||||||
"state": string(models.SessionError),
|
"state": string(models.SessionError),
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
sess.localClient = client
|
if sess.localClient != client || sess.Info.State != models.SessionConnecting {
|
||||||
cols, rows := sess.consumePendingResizeLocked()
|
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
applyLocalResize(client, cols, rows)
|
return
|
||||||
|
}
|
||||||
m.mu.Lock()
|
|
||||||
sess.Info.State = models.SessionConnected
|
sess.Info.State = models.SessionConnected
|
||||||
sess.Info.ConnectedAt = time.Now()
|
sess.Info.ConnectedAt = time.Now()
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
@@ -290,11 +360,33 @@ func (m *Manager) ConnectLocal(shell string) (string, error) {
|
|||||||
"sessionID": sessionID,
|
"sessionID": sessionID,
|
||||||
"state": string(models.SessionConnected),
|
"state": string(models.SessionConnected),
|
||||||
})
|
})
|
||||||
|
|
||||||
m.auditLog.LogConnect("", sessionID, "local", "localhost")
|
m.auditLog.LogConnect("", sessionID, "local", "localhost")
|
||||||
}()
|
}
|
||||||
|
|
||||||
return sessionID, nil
|
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.
|
// 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 {
|
func (m *Manager) WriteToSession(sessionID string, data []byte) error {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
sess, ok := m.sessions[sessionID]
|
sess, ok := m.sessions[sessionID]
|
||||||
m.mu.RUnlock()
|
|
||||||
|
|
||||||
if !ok {
|
if !ok {
|
||||||
|
m.mu.RUnlock()
|
||||||
return fmt.Errorf("sessions: session %q not found", sessionID)
|
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.
|
// Record input if recording is active.
|
||||||
m.mu.RLock()
|
|
||||||
rec := sess.recorder
|
|
||||||
m.mu.RUnlock()
|
|
||||||
if rec != nil {
|
if rec != nil {
|
||||||
rec.WriteInput(data)
|
rec.WriteInput(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case sess.sshClient != nil:
|
case sshClient != nil:
|
||||||
return sess.sshClient.Write(data)
|
return sshClient.Write(data)
|
||||||
case sess.telnetClient != nil:
|
case telnetClient != nil:
|
||||||
return sess.telnetClient.Write(data)
|
return telnetClient.Write(data)
|
||||||
case sess.rawClient != nil:
|
case rawClient != nil:
|
||||||
return sess.rawClient.Write(data)
|
return rawClient.Write(data)
|
||||||
case sess.localClient != nil:
|
case localClient != nil:
|
||||||
return sess.localClient.Write(data)
|
return localClient.Write(data)
|
||||||
}
|
}
|
||||||
return fmt.Errorf("sessions: no active client for session %q", sessionID)
|
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:
|
case telnetClient != nil:
|
||||||
return telnetClient.Resize(cols, rows)
|
return telnetClient.Resize(cols, rows)
|
||||||
case localClient != nil:
|
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.
|
// RAW sessions have no concept of terminal size.
|
||||||
return nil
|
return nil
|
||||||
@@ -439,7 +533,7 @@ func applyLocalResize(client *localshell.Client, cols, rows int) {
|
|||||||
if cols <= 0 || rows <= 0 {
|
if cols <= 0 || rows <= 0 {
|
||||||
return
|
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.
|
// 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.Close()
|
||||||
sess.ircClient = nil
|
sess.ircClient = nil
|
||||||
}
|
}
|
||||||
if sess.localClient != nil {
|
m.mu.Lock()
|
||||||
sess.localClient.Close()
|
localClient := sess.localClient
|
||||||
sess.localClient = nil
|
sess.localClient = nil
|
||||||
|
m.mu.Unlock()
|
||||||
|
if localClient != nil {
|
||||||
|
localClient.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
|
|||||||
@@ -3,9 +3,16 @@ package sessions
|
|||||||
import (
|
import (
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/andydixon/nexus/internal/audit"
|
||||||
"github.com/andydixon/nexus/internal/models"
|
"github.com/andydixon/nexus/internal/models"
|
||||||
"github.com/andydixon/nexus/internal/storage"
|
"github.com/andydixon/nexus/internal/storage"
|
||||||
"golang.org/x/crypto/ssh"
|
"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) {
|
func TestHostKeyCallbackAcceptNewStoresHost(t *testing.T) {
|
||||||
store := newSessionTestStore(t)
|
store := newSessionTestStore(t)
|
||||||
mgr := NewManager(store, nil, nil, nil, nil)
|
mgr := NewManager(store, nil, nil, nil, nil)
|
||||||
|
|||||||
@@ -306,6 +306,7 @@ func TestSavePreferencesDoesNotError(t *testing.T) {
|
|||||||
prefs.ConfirmOnClose = true
|
prefs.ConfirmOnClose = true
|
||||||
prefs.DefaultFontSize = 16
|
prefs.DefaultFontSize = 16
|
||||||
prefs.Theme = "custom-dark"
|
prefs.Theme = "custom-dark"
|
||||||
|
prefs.LeviathanEnabled = true
|
||||||
|
|
||||||
if err := store.SavePreferences(&prefs); err != nil {
|
if err := store.SavePreferences(&prefs); err != nil {
|
||||||
t.Fatalf("SavePreferences() error = %v", err)
|
t.Fatalf("SavePreferences() error = %v", err)
|
||||||
@@ -327,6 +328,9 @@ func TestSavePreferencesDoesNotError(t *testing.T) {
|
|||||||
if got.Theme != "custom-dark" {
|
if got.Theme != "custom-dark" {
|
||||||
t.Errorf("Theme = %q, want %q", got.Theme, "custom-dark")
|
t.Errorf("Theme = %q, want %q", got.Theme, "custom-dark")
|
||||||
}
|
}
|
||||||
|
if !got.LeviathanEnabled {
|
||||||
|
t.Error("LeviathanEnabled = false, want true")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
+5
-2
@@ -295,8 +295,11 @@ main() {
|
|||||||
log "Building release ${version}..."
|
log "Building release ${version}..."
|
||||||
: > "${out_dir}/.release-files"
|
: > "${out_dir}/.release-files"
|
||||||
|
|
||||||
local targets
|
local targets=()
|
||||||
mapfile -t targets < <(release_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
|
if [ "${#targets[@]}" -eq 0 ]; then
|
||||||
err "No release targets selected."
|
err "No release targets selected."
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
Reference in New Issue
Block a user