Skip to content

Commit 060aa78

Browse files
committed
feat(ssh): add remote terminal sessions
1 parent aa57a98 commit 060aa78

7 files changed

Lines changed: 639 additions & 14 deletions

File tree

‎src/components/terminal/terminal.js‎

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ export default class TerminalComponent {
6767
this.pid = null;
6868
this.isConnected = false;
6969
this.serverMode = options.serverMode !== false; // Default true
70+
this.remoteSsh = options.remoteSsh || null;
71+
this.remoteShellId = null;
72+
this.remoteInputDisposable = null;
7073
this.touchSelection = null;
7174
this.touchScrolling = null;
7275
this.parsedAppKeybindings = [];
@@ -801,6 +804,9 @@ export default class TerminalComponent {
801804
"Terminal is in local mode, cannot connect to server session",
802805
);
803806
}
807+
if (this.remoteSsh) {
808+
return this.connectToRemoteShell();
809+
}
804810

805811
if (!pid) {
806812
pid = await this.createSession();
@@ -915,6 +921,100 @@ export default class TerminalComponent {
915921
});
916922
}
917923

924+
/**
925+
* Connect xterm to an interactive Maverick SSH shell.
926+
*/
927+
connectToRemoteShell() {
928+
const profile = this.remoteSsh;
929+
if (!profile) throw new Error("SSH profile is required");
930+
931+
return new Promise((resolve, reject) => {
932+
let settled = false;
933+
const onEvent = (event) => {
934+
switch (event?.type) {
935+
case "ready":
936+
this.remoteShellId = event.sessionId;
937+
this.pid = `ssh:${event.sessionId}`;
938+
this.isConnected = true;
939+
this.remoteInputDisposable = this.terminal.onData((data) => {
940+
if (!this.isConnected || !this.remoteShellId) return;
941+
sftp.writeShell(
942+
this.remoteShellId,
943+
data,
944+
() => {},
945+
(error) => this.onError?.(error),
946+
);
947+
});
948+
this.terminal.unicode.activeVersion = "11";
949+
this.terminal.focus();
950+
void this.fitAndResizeTerminal(true);
951+
this.onConnect?.();
952+
settled = true;
953+
resolve(event.sessionId);
954+
break;
955+
956+
case "data": {
957+
const binary = atob(event.data || "");
958+
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
959+
this.terminal.write(bytes);
960+
break;
961+
}
962+
963+
case "exit":
964+
this.isConnected = false;
965+
this.processExited = true;
966+
if (!this.intentionalClose) {
967+
this.onProcessExit?.({ exit_code: event.exitCode });
968+
}
969+
break;
970+
971+
case "error": {
972+
const error = new Error(event.message || "SSH shell error");
973+
this.isConnected = false;
974+
if (!settled) reject(error);
975+
else if (!this.intentionalClose) this.onError?.(error);
976+
break;
977+
}
978+
}
979+
};
980+
981+
const onFailure = (message) => {
982+
const error = new Error(
983+
typeof message === "string" ? message : "Failed to open SSH shell",
984+
);
985+
this.isConnected = false;
986+
if (!settled) reject(error);
987+
else if (!this.intentionalClose) this.onError?.(error);
988+
};
989+
990+
if (profile.keyFile) {
991+
sftp.openShellUsingKeyFile(
992+
profile.hostname,
993+
profile.port,
994+
profile.username,
995+
profile.keyFile,
996+
profile.passPhrase || "",
997+
this.terminal.cols,
998+
this.terminal.rows,
999+
onEvent,
1000+
onFailure,
1001+
);
1002+
return;
1003+
}
1004+
1005+
sftp.openShellUsingPassword(
1006+
profile.hostname,
1007+
profile.port,
1008+
profile.username,
1009+
profile.password || "",
1010+
this.terminal.cols,
1011+
this.terminal.rows,
1012+
onEvent,
1013+
onFailure,
1014+
);
1015+
});
1016+
}
1017+
9181018
/**
9191019
* Resize terminal
9201020
* @param {number} cols - Number of columns
@@ -926,6 +1026,20 @@ export default class TerminalComponent {
9261026
const resizeKey = `${cols}x${rows}`;
9271027
if (!force && this.lastRequestedServerSize === resizeKey) return;
9281028
this.lastRequestedServerSize = resizeKey;
1029+
if (this.remoteSsh) {
1030+
if (!this.remoteShellId) return;
1031+
sftp.resizeShell(
1032+
this.remoteShellId,
1033+
cols,
1034+
rows,
1035+
() => {},
1036+
(error) => {
1037+
this.lastRequestedServerSize = null;
1038+
this.onError?.(error);
1039+
},
1040+
);
1041+
return;
1042+
}
9291043

9301044
try {
9311045
await new Promise((resolve, reject) => {
@@ -987,6 +1101,15 @@ export default class TerminalComponent {
9871101
* @param {string} data - Data to write
9881102
*/
9891103
write(data) {
1104+
if (this.remoteSsh && this.isConnected && this.remoteShellId) {
1105+
sftp.writeShell(
1106+
this.remoteShellId,
1107+
data,
1108+
() => {},
1109+
(error) => this.onError?.(error),
1110+
);
1111+
return;
1112+
}
9901113
if (
9911114
this.serverMode &&
9921115
this.isConnected &&
@@ -1285,6 +1408,18 @@ export default class TerminalComponent {
12851408
*/
12861409
async terminate() {
12871410
this.intentionalClose = true;
1411+
this.remoteInputDisposable?.dispose?.();
1412+
this.remoteInputDisposable = null;
1413+
1414+
if (this.remoteShellId) {
1415+
const shellID = this.remoteShellId;
1416+
this.remoteShellId = null;
1417+
this.isConnected = false;
1418+
await new Promise((resolve) => {
1419+
sftp.closeShell(shellID, resolve, resolve);
1420+
});
1421+
return;
1422+
}
12881423

12891424
if (this.websocket) {
12901425
try {

‎src/components/terminal/terminalManager.js‎

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import openFile from "lib/openFile";
1313
import openFolder from "lib/openFolder";
1414
import appSettings from "lib/settings";
1515
import helpers from "utils/helpers";
16+
import Url from "utils/Url";
1617
import TerminalComponent from "./terminal";
1718
import TerminalTouchSelection from "./terminalTouchSelection";
1819

@@ -276,6 +277,7 @@ class TerminalManager {
276277
const shouldRender = render !== false;
277278
const isServerMode = serverMode !== false;
278279
const isReconnecting = reconnecting === true;
280+
const isRemoteSsh = !!terminalOptions.remoteSsh;
279281

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

291293
// Check if terminal is installed before proceeding
292-
if (isServerMode) {
294+
if (isServerMode && !isRemoteSsh) {
293295
const installationResult = await this.checkAndInstallTerminal();
294296
if (!installationResult.success) {
295297
throw new Error(installationResult.error);
@@ -369,7 +371,11 @@ class TerminalManager {
369371

370372
this.terminals.set(uniqueId, instance);
371373

372-
if (terminalComponent.serverMode && terminalComponent.pid) {
374+
if (
375+
terminalComponent.serverMode &&
376+
!terminalComponent.remoteSsh &&
377+
terminalComponent.pid
378+
) {
373379
await this.persistTerminalSession(
374380
terminalComponent.pid,
375381
terminalName,
@@ -831,7 +837,11 @@ class TerminalManager {
831837
const formattedTitle = `${titlePrefix} - ${title}`;
832838
terminalFile.filename = formattedTitle;
833839

834-
if (terminalComponent.serverMode && terminalComponent.pid) {
840+
if (
841+
terminalComponent.serverMode &&
842+
!terminalComponent.remoteSsh &&
843+
terminalComponent.pid
844+
) {
835845
await this.persistTerminalSession(
836846
terminalComponent.pid,
837847
formattedTitle,
@@ -870,6 +880,9 @@ class TerminalManager {
870880

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

875888
// Convert proot path
@@ -899,6 +912,10 @@ class TerminalManager {
899912

900913
// Set up custom title function for terminal
901914
const getTerminalTitle = () => {
915+
if (terminalComponent.remoteSsh) {
916+
const { username, hostname } = terminalComponent.remoteSsh;
917+
return `${username}@${hostname}`;
918+
}
902919
if (terminalComponent.pid) {
903920
return `PID: ${terminalComponent.pid}`;
904921
}
@@ -924,7 +941,11 @@ class TerminalManager {
924941
terminal.component.intentionalClose = true;
925942
}
926943

927-
if (terminal.component.serverMode && terminal.component.pid) {
944+
if (
945+
terminal.component.serverMode &&
946+
!terminal.component.remoteSsh &&
947+
terminal.component.pid
948+
) {
928949
this.removePersistedSession(terminal.component.pid);
929950
}
930951

@@ -1091,6 +1112,40 @@ class TerminalManager {
10911112
});
10921113
}
10931114

1115+
/**
1116+
* Create an SSH terminal using credentials from an SFTP storage URL.
1117+
* @param {string|{url: string, name?: string}} storage - SFTP storage
1118+
* @param {object} options - Terminal options
1119+
* @returns {Promise<object>} Terminal instance
1120+
*/
1121+
async createRemoteTerminal(storage, options = {}) {
1122+
const url = typeof storage === "string" ? storage : storage?.url;
1123+
const storageName = typeof storage === "object" ? storage?.name : null;
1124+
1125+
if (!url || !/^sftp:/.test(url)) {
1126+
throw new Error("A valid SFTP storage is required");
1127+
}
1128+
1129+
const { username, password, hostname, port, query } = Url.decodeUrl(url);
1130+
if (!hostname || !username) {
1131+
throw new Error("The SFTP storage is missing its host or username");
1132+
}
1133+
1134+
return this.createTerminal({
1135+
...options,
1136+
name: options.name || `SSH - ${storageName || hostname}`,
1137+
serverMode: true,
1138+
remoteSsh: {
1139+
hostname,
1140+
port: port || 22,
1141+
username,
1142+
password,
1143+
keyFile: query?.keyFile,
1144+
passPhrase: query?.passPhrase,
1145+
},
1146+
});
1147+
}
1148+
10941149
/**
10951150
* Handle keyboard resize events for all terminals
10961151
* This is called when the virtual keyboard opens/closes on mobile

‎src/lib/openFolder.js‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,11 @@ async function handleContextmenu(type, url, name, $target) {
335335
strings["install as plugin"] || "Install as Plugin",
336336
"extension",
337337
];
338+
const OPEN_SSH_TERMINAL = [
339+
"open-ssh-terminal",
340+
strings["open ssh terminal"] || "Open SSH Terminal",
341+
"terminal",
342+
];
338343

339344
let options;
340345

@@ -364,6 +369,8 @@ async function handleContextmenu(type, url, name, $target) {
364369
"terminal",
365370
];
366371
options.push(OPEN_IN_TERMINAL);
372+
} else if (/^sftp:/.test(url)) {
373+
options.push(OPEN_SSH_TERMINAL);
367374
}
368375
} else if (type === "root") {
369376
options = [];
@@ -381,6 +388,8 @@ async function handleContextmenu(type, url, name, $target) {
381388
"terminal",
382389
];
383390
options.push(OPEN_IN_TERMINAL);
391+
} else if (/^sftp:/.test(url)) {
392+
options.push(OPEN_SSH_TERMINAL);
384393
}
385394

386395
options.push(CLOSE_FOLDER);
@@ -401,7 +410,7 @@ async function handleContextmenu(type, url, name, $target) {
401410

402411
/**
403412
* @param {"dir"|"file"|"root"} type
404-
* @param {"copy"|"cut"|"delete"|"rename"|"paste"|"new file"|"new folder"|"cancel"|"open-folder"|"install-plugin"} action
413+
* @param {"copy"|"cut"|"delete"|"rename"|"paste"|"new file"|"new folder"|"cancel"|"open-folder"|"install-plugin"|"open-in-terminal"|"open-ssh-terminal"|"copy-relative-path"} action
405414
* @param {string} url target url
406415
* @param {HTMLElement} $target target element
407416
* @param {string} name Name of file or folder
@@ -447,6 +456,9 @@ function execOperation(type, action, url, $target, name) {
447456
case "open-in-terminal":
448457
return openInTerminal();
449458

459+
case "open-ssh-terminal":
460+
return openSshTerminal();
461+
450462
case "copy-relative-path":
451463
return copyRelativePath();
452464
}
@@ -557,6 +569,19 @@ function execOperation(type, action, url, $target, name) {
557569
}
558570
}
559571

572+
async function openSshTerminal() {
573+
try {
574+
const { TerminalManager } = await import(
575+
/* webpackChunkName: "terminal" */ "components/terminal"
576+
);
577+
await TerminalManager.createRemoteTerminal({ url, name });
578+
Sidebar.hide();
579+
} catch (error) {
580+
console.error("Failed to open SSH terminal:", error);
581+
toast(`Failed to open SSH terminal: ${error.message || "Unknown error"}`);
582+
}
583+
}
584+
560585
async function deleteFile() {
561586
const msg = strings["delete entry"].replace("{name}", name);
562587
const confirmation = await confirm(strings.warning, msg);

0 commit comments

Comments
 (0)