Skip to content
Open
138 changes: 138 additions & 0 deletions src/components/terminal/terminal.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
import confirm from "dialogs/confirm";
import fonts from "lib/fonts";
import appSettings from "lib/settings";
import { quotePosixShellArg } from "utils/shell";
import LigaturesAddon from "./ligatures";
import {
DEFAULT_TERMINAL_SETTINGS,
Expand Down Expand Up @@ -67,6 +68,9 @@ export default class TerminalComponent {
this.pid = null;
this.isConnected = false;
this.serverMode = options.serverMode !== false; // Default true
this.remoteSsh = options.remoteSsh || null;
this.remoteShellId = null;
this.remoteInputDisposable = null;
this.touchSelection = null;
this.touchScrolling = null;
this.parsedAppKeybindings = [];
Expand Down Expand Up @@ -801,6 +805,9 @@ export default class TerminalComponent {
"Terminal is in local mode, cannot connect to server session",
);
}
if (this.remoteSsh) {
return this.connectToRemoteShell();
}

if (!pid) {
pid = await this.createSession();
Expand Down Expand Up @@ -915,6 +922,102 @@ export default class TerminalComponent {
});
}

/**
* Connect xterm to an interactive Maverick SSH shell.
*/
connectToRemoteShell() {
const profile = this.remoteSsh;
if (!profile) throw new Error("SSH profile is required");

return new Promise((resolve, reject) => {
let settled = false;
const finishConnecting = (event) => {
this.remoteInputDisposable = this.terminal.onData((data) => {
if (!this.isConnected || !this.remoteShellId) return;
sftp.writeShell(
this.remoteShellId,
data,
() => {},
(error) => this.onError?.(error),
);
});
this.terminal.unicode.activeVersion = "11";
this.terminal.focus();
void this.fitAndResizeTerminal(true);
this.onConnect?.();
settled = true;
resolve(event.sessionId);
};
const onEvent = (event) => {
switch (event?.type) {
case "ready":
this.remoteShellId = event.sessionId;
this.pid = `ssh:${event.sessionId}`;
this.isConnected = true;
if (profile.initialDirectory && profile.initialDirectory !== "/") {
try {
sftp.writeShell(
event.sessionId,
`cd ${quotePosixShellArg(profile.initialDirectory)}\n`,
() => finishConnecting(event),
onFailure,
);
} catch (error) {
onFailure(error?.message);
}
break;
}
finishConnecting(event);
break;

case "data": {
const binary = atob(event.data || "");
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
this.terminal.write(bytes);
break;
}

case "exit":
this.isConnected = false;
this.processExited = true;
if (!this.intentionalClose) {
this.onProcessExit?.({ exit_code: event.exitCode });
}
break;

case "error": {
const error = new Error(event.message || "SSH shell error");
this.isConnected = false;
if (!settled) reject(error);
else if (!this.intentionalClose) this.onError?.(error);
break;
}
}
};

const onFailure = (message) => {
const error = new Error(
typeof message === "string" ? message : "Failed to open SSH shell",
);
this.isConnected = false;
if (!settled) reject(error);
else if (!this.intentionalClose) this.onError?.(error);
};

const openShell = () => {
sftp.openShellUsingProfile(
profile.profileId,
this.terminal.cols,
this.terminal.rows,
onEvent,
onFailure,
);
};

openShell();
});
}

/**
* Resize terminal
* @param {number} cols - Number of columns
Expand All @@ -926,6 +1029,20 @@ export default class TerminalComponent {
const resizeKey = `${cols}x${rows}`;
if (!force && this.lastRequestedServerSize === resizeKey) return;
this.lastRequestedServerSize = resizeKey;
if (this.remoteSsh) {
if (!this.remoteShellId) return;
sftp.resizeShell(
this.remoteShellId,
cols,
rows,
() => {},
(error) => {
this.lastRequestedServerSize = null;
this.onError?.(error);
},
);
return;
}

try {
await new Promise((resolve, reject) => {
Expand Down Expand Up @@ -987,6 +1104,15 @@ export default class TerminalComponent {
* @param {string} data - Data to write
*/
write(data) {
if (this.remoteSsh && this.isConnected && this.remoteShellId) {
sftp.writeShell(
this.remoteShellId,
data,
() => {},
(error) => this.onError?.(error),
);
return;
}
if (
this.serverMode &&
this.isConnected &&
Expand Down Expand Up @@ -1285,6 +1411,18 @@ export default class TerminalComponent {
*/
async terminate() {
this.intentionalClose = true;
this.remoteInputDisposable?.dispose?.();
this.remoteInputDisposable = null;

if (this.remoteShellId) {
const shellID = this.remoteShellId;
this.remoteShellId = null;
this.isConnected = false;
await new Promise((resolve) => {
sftp.closeShell(shellID, resolve, resolve);
});
return;
}

if (this.websocket) {
try {
Expand Down
65 changes: 60 additions & 5 deletions src/components/terminal/terminalManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import openFile from "lib/openFile";
import openFolder from "lib/openFolder";
import appSettings from "lib/settings";
import helpers from "utils/helpers";
import Url from "utils/Url";
import TerminalComponent from "./terminal";
import TerminalTouchSelection from "./terminalTouchSelection";

Expand Down Expand Up @@ -276,6 +277,7 @@ class TerminalManager {
const shouldRender = render !== false;
const isServerMode = serverMode !== false;
const isReconnecting = reconnecting === true;
const isRemoteSsh = !!terminalOptions.remoteSsh;

const terminalId = `terminal_${++this.terminalCounter}`;
const providedName =
Expand All @@ -289,7 +291,7 @@ class TerminalManager {
: terminalName;

// Check if terminal is installed before proceeding
if (isServerMode) {
if (isServerMode && !isRemoteSsh) {
const installationResult = await this.checkAndInstallTerminal();
if (!installationResult.success) {
throw new Error(installationResult.error);
Expand Down Expand Up @@ -369,7 +371,11 @@ class TerminalManager {

this.terminals.set(uniqueId, instance);

if (terminalComponent.serverMode && terminalComponent.pid) {
if (
terminalComponent.serverMode &&
!terminalComponent.remoteSsh &&
terminalComponent.pid
) {
await this.persistTerminalSession(
terminalComponent.pid,
terminalName,
Expand Down Expand Up @@ -399,7 +405,7 @@ class TerminalManager {
}

// Show alert for terminal creation failure
if (!isReconnecting) {
if (!isReconnecting && !error?.reported) {
const errorMessage = error?.message || "Unknown error";
alert(
strings["error"],
Expand Down Expand Up @@ -831,7 +837,11 @@ class TerminalManager {
const formattedTitle = `${titlePrefix} - ${title}`;
terminalFile.filename = formattedTitle;

if (terminalComponent.serverMode && terminalComponent.pid) {
if (
terminalComponent.serverMode &&
!terminalComponent.remoteSsh &&
terminalComponent.pid
) {
await this.persistTerminalSession(
terminalComponent.pid,
formattedTitle,
Expand Down Expand Up @@ -870,6 +880,9 @@ class TerminalManager {

// Handle acode CLI open commands (OSC 7777)
terminalComponent.onOscOpen = async (type, path) => {
// OSC 7777 is an Acode-local CLI protocol. Remote hosts must not use it
// to request access to paths on the Android device.
if (terminalComponent.remoteSsh) return;
if (!path) return;

// Convert proot path
Expand Down Expand Up @@ -899,6 +912,10 @@ class TerminalManager {

// Set up custom title function for terminal
const getTerminalTitle = () => {
if (terminalComponent.remoteSsh) {
const { username, hostname, displayName } = terminalComponent.remoteSsh;
return displayName || `${username}@${hostname}`;
}
if (terminalComponent.pid) {
return `PID: ${terminalComponent.pid}`;
}
Expand All @@ -924,7 +941,11 @@ class TerminalManager {
terminal.component.intentionalClose = true;
}

if (terminal.component.serverMode && terminal.component.pid) {
if (
terminal.component.serverMode &&
!terminal.component.remoteSsh &&
terminal.component.pid
) {
this.removePersistedSession(terminal.component.pid);
}

Expand Down Expand Up @@ -1091,6 +1112,40 @@ class TerminalManager {
});
}

/**
* Create an SSH terminal using credentials from an SFTP storage URL.
* @param {string|{url: string, name?: string}} storage - SFTP storage
* @param {object} options - Terminal options
* @returns {Promise<object>} Terminal instance
*/
async createRemoteTerminal(storage, options = {}) {
const url = typeof storage === "string" ? storage : storage?.url;
const storageName = typeof storage === "object" ? storage?.name : null;

if (!url || !/^sftp:/.test(url)) {
throw new Error("A valid SFTP storage is required");
}

const { hostname, pathname } = Url.decodeUrl(url);
const profileId = hostname?.startsWith("profile-") ? hostname : null;
if (!profileId) {
throw new Error(
"The SFTP storage has not been migrated to a native profile",
);
}

return this.createTerminal({
...options,
name: options.name || `SSH - ${storageName || hostname}`,
serverMode: true,
remoteSsh: {
profileId,
displayName: storageName || "SSH",
initialDirectory: pathname || "/",
},
});
}

/**
* Handle keyboard resize events for all terminals
* This is called when the virtual keyboard opens/closes on mobile
Expand Down
22 changes: 20 additions & 2 deletions src/dialogs/multiPrompt.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import alert from "./alert";
* @property {boolean} [readOnly] Is read only
* @property {boolean} [autofocus] Is autofocus
* @property {boolean} [hidden] Is hidden
* @property {boolean} [sensitive] Clear the input when the prompt closes
*/

/**
Expand Down Expand Up @@ -66,8 +67,9 @@ export default function multiPrompt(message, inputs, help) {
return;
}
}
const values = getValue();
hide();
resolve(getValue());
resolve(values);
},
});
const cancelBtn = tag("button", {
Expand All @@ -88,7 +90,9 @@ export default function multiPrompt(message, inputs, help) {
onsubmit: (e) => {
e.preventDefault();
if (!okBtn.disabled) {
resolve(getValue());
const values = getValue();
hide();
resolve(values);
}
},
children: [
Expand Down Expand Up @@ -136,6 +140,7 @@ export default function multiPrompt(message, inputs, help) {
if ($focusEl) $focusEl.focus();

function hidePrompt() {
clearSensitiveInputs();
$promptDiv.classList.add("hide");
restoreTheme();
setTimeout(() => {
Expand All @@ -158,10 +163,19 @@ export default function multiPrompt(message, inputs, help) {
values[$input.id] = $input.checked;
else values[$input.id] = $input.value;
});
clearSensitiveInputs(inputAr);

return values;
}

function clearSensitiveInputs(inputs = [...$body.getAll("input")]) {
for (const $input of inputs) {
if ($input.type === "password" || $input.isSensitive) {
$input.value = "";
}
}
}

/**
* Creates a group of inputs
* @param {Array<Input>} inputs Array of inputs
Expand Down Expand Up @@ -211,6 +225,7 @@ export default function multiPrompt(message, inputs, help) {
readOnly,
autofocus,
hidden,
sensitive,
} = input;

const inputType = type === "textarea" ? "textarea" : "input";
Expand Down Expand Up @@ -277,6 +292,9 @@ export default function multiPrompt(message, inputs, help) {
Object.defineProperty($input, "prompt", {
value: { $body, hide },
});
Object.defineProperty($input, "isSensitive", {
value: Boolean(sensitive),
});

Object.defineProperty($input, "setError", {
value(message) {
Expand Down
Loading