Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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