Skip to content
Merged
162 changes: 162 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 { resolveHostKeyError } from "lib/sshHostKey";
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,126 @@ 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 onEvent = (event) => {
switch (event?.type) {
case "ready":
this.remoteShellId = event.sessionId;
this.pid = `ssh:${event.sessionId}`;
this.isConnected = true;
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);
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 = async (message) => {
try {
if (!settled && (await resolveHostKeyError(message))) {
openShell();
return;
}
} catch (error) {
this.isConnected = false;
if (!settled) reject(error);
else if (!this.intentionalClose) this.onError?.(error);
return;
}
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 = () => {
if (profile.profileId) {
sftp.openShellUsingProfile(
profile.profileId,
this.terminal.cols,
this.terminal.rows,
onEvent,
onFailure,
);
return;
}

if (profile.keyFile) {
sftp.openShellUsingKeyFile(
profile.hostname,
profile.port,
profile.username,
profile.keyFile,
profile.passPhrase || "",
this.terminal.cols,
this.terminal.rows,
onEvent,
onFailure,
);
return;
}

sftp.openShellUsingPassword(
profile.hostname,
profile.port,
profile.username,
profile.password || "",
this.terminal.cols,
this.terminal.rows,
onEvent,
onFailure,
);
};

openShell();
});
}

/**
* Resize terminal
* @param {number} cols - Number of columns
Expand All @@ -926,6 +1053,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 +1128,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 +1435,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
68 changes: 63 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,43 @@ 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 { username, password, hostname, port, query } = Url.decodeUrl(url);
const profileId = hostname?.startsWith("profile-") ? hostname : null;
if (!profileId && (!hostname || !username)) {
throw new Error("The SFTP storage is missing its host or username");
}

return this.createTerminal({
...options,
name: options.name || `SSH - ${storageName || hostname}`,
serverMode: true,
remoteSsh: profileId
? { profileId, displayName: storageName || "SSH" }
: {
hostname,
port: port || 22,
username,
password,
keyFile: query?.keyFile,
passPhrase: query?.passPhrase,
},
});
}

/**
* Handle keyboard resize events for all terminals
* This is called when the virtual keyboard opens/closes on mobile
Expand Down
Loading