mirror of
https://github.com/nyakang/nyaterm.git
synced 2026-09-22 16:01:31 +00:00
- Added new functions `resolveFileEditorOpenTarget` and `resolveInternalEditorDisplay` to determine the appropriate editor mode based on user settings. - Introduced a test suite for the file editor open mode to validate the new resolution logic. - Updated the `FileExplorer` component to utilize the new editor resolution functions, enhancing file handling capabilities. - Modified the `TransferTab` and context providers to include `internal_editor_display` settings, defaulting to "workspace". - Enhanced type definitions to support the new editor settings and ensure type safety across components.
497 lines
13 KiB
TypeScript
497 lines
13 KiB
TypeScript
import type { SavedConnection } from "@/types/global";
|
|
import {
|
|
parseSshRuntimeMode,
|
|
parseTemporaryLink,
|
|
type SshRuntimeMode,
|
|
type TemporaryLinkConfig,
|
|
type TemporarySshLinkConfig,
|
|
type TemporaryTelnetLinkConfig,
|
|
} from "./temporaryLink";
|
|
|
|
export type ExternalNetworkOpenProtocol = "ssh" | "telnet";
|
|
export type ExternalOpenProtocol = ExternalNetworkOpenProtocol | "local";
|
|
|
|
export interface ExternalNetworkOpenIntent {
|
|
protocol: ExternalNetworkOpenProtocol;
|
|
host: string;
|
|
port: number;
|
|
username: string | null;
|
|
usernameSpecified: boolean;
|
|
passwordSpecified: boolean;
|
|
runtimeMode?: SshRuntimeMode;
|
|
runtimeModeSpecified: boolean;
|
|
temporary: TemporaryLinkConfig;
|
|
}
|
|
|
|
export interface ExternalLocalOpenIntent {
|
|
protocol: "local";
|
|
workingDir: string | null;
|
|
}
|
|
|
|
export type ExternalOpenIntent =
|
|
| ExternalNetworkOpenIntent
|
|
| ExternalLocalOpenIntent;
|
|
|
|
export type ExternalOpenParseResult =
|
|
| { ok: true; intent: ExternalOpenIntent }
|
|
| { ok: false; errorKey: string; errorType: string; scheme?: string };
|
|
|
|
export type ExternalConnectionResolution =
|
|
| {
|
|
kind: "saved";
|
|
connection: SavedConnection;
|
|
runtimeModeOverride?: SshRuntimeMode;
|
|
}
|
|
| {
|
|
kind: "ambiguous";
|
|
connections: SavedConnection[];
|
|
temporary: TemporaryLinkConfig;
|
|
runtimeModeOverride?: SshRuntimeMode;
|
|
}
|
|
| { kind: "temporary"; config: TemporaryLinkConfig }
|
|
| { kind: "invalid"; errorKey: string; errorType: string };
|
|
|
|
const DEFAULT_SSH_PORT = 22;
|
|
const DEFAULT_TELNET_PORT = 23;
|
|
const DEFAULT_USERNAME = "root";
|
|
const UNSUPPORTED_NYATERM_PARAMS = new Set([
|
|
"command",
|
|
"postlogin",
|
|
"post_login",
|
|
"identityfile",
|
|
"identity_file",
|
|
"proxy",
|
|
"proxyjump",
|
|
"proxy_jump",
|
|
"localforward",
|
|
"local_forward",
|
|
"remoteforward",
|
|
"remote_forward",
|
|
"dynamicforward",
|
|
"dynamic_forward",
|
|
"l",
|
|
"r",
|
|
"d",
|
|
]);
|
|
|
|
export function parseExternalOpenUrl(rawUrl: string): ExternalOpenParseResult {
|
|
const trimmed = rawUrl.trim();
|
|
if (!trimmed) return invalid("externalOpen.invalidUrl", "empty");
|
|
|
|
const scheme = schemeOf(trimmed);
|
|
if (!scheme) return invalid("externalOpen.invalidUrl", "invalid_url");
|
|
|
|
if (scheme === "ssh" || scheme === "telnet") {
|
|
return parseExternalTemporaryUrl(trimmed, scheme);
|
|
}
|
|
|
|
if (scheme === "nyaterm") {
|
|
return parseNyatermUrl(trimmed);
|
|
}
|
|
|
|
return invalid(
|
|
"externalOpen.unsupportedProtocol",
|
|
"unsupported_scheme",
|
|
scheme,
|
|
);
|
|
}
|
|
|
|
export function findExternalConnectionMatches(
|
|
savedConnections: SavedConnection[],
|
|
intent: ExternalNetworkOpenIntent,
|
|
): ExternalConnectionResolution {
|
|
if (intent.protocol === "ssh" && intent.passwordSpecified) {
|
|
return { kind: "temporary", config: intent.temporary };
|
|
}
|
|
|
|
const matches = savedConnections.filter((connection) => {
|
|
if (connection.type !== intent.protocol) return false;
|
|
if (normalizeHostForMatch(connection.host ?? "") !== intent.host)
|
|
return false;
|
|
if (
|
|
normalizePortForProtocol(connection.port, intent.protocol) !== intent.port
|
|
)
|
|
return false;
|
|
if (intent.protocol === "ssh" && intent.usernameSpecified) {
|
|
return (connection.username ?? "") === intent.username;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
const runtimeModeOverride =
|
|
intent.protocol === "ssh" && intent.runtimeModeSpecified
|
|
? intent.runtimeMode
|
|
: undefined;
|
|
|
|
if (matches.length === 1) {
|
|
return { kind: "saved", connection: matches[0], runtimeModeOverride };
|
|
}
|
|
if (matches.length > 1) {
|
|
return {
|
|
kind: "ambiguous",
|
|
connections: matches,
|
|
temporary: intent.temporary,
|
|
runtimeModeOverride,
|
|
};
|
|
}
|
|
return { kind: "temporary", config: intent.temporary };
|
|
}
|
|
|
|
function parseExternalTemporaryUrl(
|
|
rawUrl: string,
|
|
protocol: ExternalNetworkOpenProtocol,
|
|
): ExternalOpenParseResult {
|
|
const parsed = parseTemporaryLink(protocol, rawUrl);
|
|
if (!parsed.ok) {
|
|
if (hasInvalidExplicitUrlPort(rawUrl)) {
|
|
return invalid("externalOpen.invalidPort", "invalid_port", protocol);
|
|
}
|
|
return invalid(
|
|
mapTemporaryErrorKey(parsed.errorKey),
|
|
temporaryErrorType(parsed.errorKey),
|
|
protocol,
|
|
);
|
|
}
|
|
if (parsed.config.protocol !== protocol) {
|
|
return invalid("externalOpen.invalidUrl", "invalid_protocol", protocol);
|
|
}
|
|
|
|
const url = new URL(rawUrl);
|
|
const host = normalizeHostForMatch(parsed.config.host);
|
|
const port = normalizePortForProtocol(parsed.config.port, protocol);
|
|
|
|
if (protocol === "ssh") {
|
|
const sshConfig = parsed.config as TemporarySshLinkConfig;
|
|
const usernameSpecified = url.username.length > 0;
|
|
const passwordSpecified = temporarySshPasswordSpecified(sshConfig);
|
|
const runtimeModeSpecified = url.searchParams.has("mode");
|
|
return {
|
|
ok: true,
|
|
intent: {
|
|
protocol,
|
|
host,
|
|
port,
|
|
username: usernameSpecified ? sshConfig.username : null,
|
|
usernameSpecified,
|
|
passwordSpecified,
|
|
runtimeMode: sshConfig.runtime_mode,
|
|
runtimeModeSpecified,
|
|
temporary: normalizeTemporaryConfig(sshConfig, host, port),
|
|
},
|
|
};
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
intent: {
|
|
protocol,
|
|
host,
|
|
port,
|
|
username: null,
|
|
usernameSpecified: false,
|
|
passwordSpecified: false,
|
|
runtimeModeSpecified: false,
|
|
temporary: normalizeTemporaryConfig(parsed.config, host, port),
|
|
},
|
|
};
|
|
}
|
|
|
|
function parseNyatermUrl(rawUrl: string): ExternalOpenParseResult {
|
|
let url: URL;
|
|
try {
|
|
url = new URL(rawUrl);
|
|
} catch {
|
|
return invalid("externalOpen.invalidUrl", "invalid_url", "nyaterm");
|
|
}
|
|
|
|
if (url.hostname.toLowerCase() !== "connect") {
|
|
return invalid("externalOpen.invalidUrl", "invalid_path", "nyaterm");
|
|
}
|
|
|
|
const protocol = url.pathname.replace(/^\/+/, "").toLowerCase();
|
|
if (protocol !== "ssh" && protocol !== "telnet" && protocol !== "local") {
|
|
return invalid(
|
|
"externalOpen.unsupportedProtocol",
|
|
"unsupported_scheme",
|
|
"nyaterm",
|
|
);
|
|
}
|
|
|
|
const params = url.searchParams;
|
|
if (protocol === "local") {
|
|
return parseNyatermLocalUrl(params);
|
|
}
|
|
|
|
for (const key of params.keys()) {
|
|
const normalizedKey = key.toLowerCase();
|
|
if (normalizedKey === "password") {
|
|
return invalid(
|
|
"externalOpen.inlinePassword",
|
|
"inline_password",
|
|
"nyaterm",
|
|
);
|
|
}
|
|
if (protocol !== "ssh" && normalizedKey === "mode") {
|
|
return invalid(
|
|
"externalOpen.unsupportedParameter",
|
|
"unsupported_parameter",
|
|
"nyaterm",
|
|
);
|
|
}
|
|
if (UNSUPPORTED_NYATERM_PARAMS.has(normalizedKey)) {
|
|
return invalid(
|
|
"externalOpen.unsupportedParameter",
|
|
"unsupported_parameter",
|
|
"nyaterm",
|
|
);
|
|
}
|
|
}
|
|
|
|
const host = normalizeHostForMatch(params.get("host") ?? "");
|
|
if (!host)
|
|
return invalid("externalOpen.missingHost", "missing_host", "nyaterm");
|
|
|
|
const port = parsePort(params.get("port"), protocol);
|
|
if (port === null)
|
|
return invalid("externalOpen.invalidPort", "invalid_port", "nyaterm");
|
|
|
|
if (protocol === "ssh") {
|
|
const username = (params.get("username") ?? DEFAULT_USERNAME).trim();
|
|
if (!username)
|
|
return invalid("externalOpen.invalidUrl", "missing_username", "nyaterm");
|
|
const runtimeMode = parseSshRuntimeMode(params.get("mode"));
|
|
if (!runtimeMode)
|
|
return invalid("externalOpen.invalidMode", "invalid_mode", "nyaterm");
|
|
return {
|
|
ok: true,
|
|
intent: {
|
|
protocol,
|
|
host,
|
|
port,
|
|
username,
|
|
usernameSpecified: params.has("username"),
|
|
passwordSpecified: false,
|
|
runtimeMode,
|
|
runtimeModeSpecified: params.has("mode"),
|
|
temporary: createTemporarySshConfig(
|
|
host,
|
|
port,
|
|
username,
|
|
null,
|
|
runtimeMode,
|
|
),
|
|
},
|
|
};
|
|
}
|
|
|
|
if (params.has("username")) {
|
|
return invalid(
|
|
"externalOpen.invalidUrl",
|
|
"unsupported_username",
|
|
"nyaterm",
|
|
);
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
intent: {
|
|
protocol,
|
|
host,
|
|
port,
|
|
username: null,
|
|
usernameSpecified: false,
|
|
passwordSpecified: false,
|
|
runtimeModeSpecified: false,
|
|
temporary: createTemporaryTelnetConfig(host, port),
|
|
},
|
|
};
|
|
}
|
|
|
|
function normalizeTemporaryConfig(
|
|
config: TemporaryLinkConfig,
|
|
host: string,
|
|
port: number,
|
|
): TemporaryLinkConfig {
|
|
if (config.protocol === "ssh") {
|
|
return createTemporarySshConfig(
|
|
host,
|
|
port,
|
|
config.username,
|
|
temporarySshPassword(config),
|
|
config.runtime_mode,
|
|
);
|
|
}
|
|
if (config.protocol === "telnet") {
|
|
return createTemporaryTelnetConfig(host, port);
|
|
}
|
|
return config;
|
|
}
|
|
|
|
function createTemporarySshConfig(
|
|
host: string,
|
|
port: number,
|
|
username: string,
|
|
password: string | null = null,
|
|
runtimeMode: SshRuntimeMode = "standard",
|
|
): TemporarySshLinkConfig {
|
|
return {
|
|
protocol: "ssh",
|
|
runtime_mode: runtimeMode,
|
|
name: `${username}@${host}:${port}`,
|
|
host,
|
|
port,
|
|
username,
|
|
auth: { type: "password", password },
|
|
backspace_mode: "del",
|
|
x11_forwarding: false,
|
|
x11_display: "",
|
|
proxy: null,
|
|
proxy_jump: null,
|
|
post_login: null,
|
|
};
|
|
}
|
|
|
|
function temporarySshPassword(config: TemporarySshLinkConfig) {
|
|
return config.auth.type === "password" && config.auth.password
|
|
? config.auth.password
|
|
: null;
|
|
}
|
|
|
|
function temporarySshPasswordSpecified(config: TemporaryLinkConfig) {
|
|
return config.protocol === "ssh" && temporarySshPassword(config) !== null;
|
|
}
|
|
|
|
function createTemporaryTelnetConfig(
|
|
host: string,
|
|
port: number,
|
|
): TemporaryTelnetLinkConfig {
|
|
return {
|
|
protocol: "telnet",
|
|
name: `telnet://${host}:${port}`,
|
|
host,
|
|
port,
|
|
};
|
|
}
|
|
|
|
function normalizeHostForMatch(host: string): string {
|
|
const trimmed = host.trim().replace(/^\[(.*)\]$/, "$1");
|
|
if (!trimmed) return "";
|
|
return trimmed.includes(":") ? trimmed : trimmed.toLowerCase();
|
|
}
|
|
|
|
function normalizePortForProtocol(
|
|
port: number | undefined,
|
|
protocol: ExternalNetworkOpenProtocol,
|
|
) {
|
|
return port && Number.isInteger(port)
|
|
? port
|
|
: protocol === "ssh"
|
|
? DEFAULT_SSH_PORT
|
|
: DEFAULT_TELNET_PORT;
|
|
}
|
|
|
|
function parsePort(value: string | null, protocol: ExternalNetworkOpenProtocol) {
|
|
if (!value)
|
|
return protocol === "ssh" ? DEFAULT_SSH_PORT : DEFAULT_TELNET_PORT;
|
|
if (!/^\d+$/.test(value)) return null;
|
|
const port = Number(value);
|
|
return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : null;
|
|
}
|
|
|
|
function parseNyatermLocalUrl(params: URLSearchParams): ExternalOpenParseResult {
|
|
for (const key of params.keys()) {
|
|
const normalizedKey = key.toLowerCase();
|
|
if (normalizedKey === "password") {
|
|
return invalid(
|
|
"externalOpen.inlinePassword",
|
|
"inline_password",
|
|
"nyaterm",
|
|
);
|
|
}
|
|
if (normalizedKey !== "cwd") {
|
|
return invalid(
|
|
"externalOpen.unsupportedParameter",
|
|
"unsupported_parameter",
|
|
"nyaterm",
|
|
);
|
|
}
|
|
}
|
|
|
|
const workingDir = params.get("cwd");
|
|
if (workingDir !== null && workingDir.trim() === "") {
|
|
return invalid("externalOpen.invalidUrl", "empty_cwd", "nyaterm");
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
intent: {
|
|
protocol: "local",
|
|
workingDir,
|
|
},
|
|
};
|
|
}
|
|
|
|
function mapTemporaryErrorKey(errorKey: string) {
|
|
if (errorKey === "temporarySsh.inlinePassword")
|
|
return "externalOpen.inlinePassword";
|
|
if (errorKey === "temporarySsh.unsupportedOption")
|
|
return "externalOpen.unsupportedParameter";
|
|
if (errorKey === "temporarySsh.invalidPort")
|
|
return "externalOpen.invalidPort";
|
|
if (errorKey === "temporarySsh.invalidMode")
|
|
return "externalOpen.invalidMode";
|
|
if (errorKey === "temporarySsh.missingHost")
|
|
return "externalOpen.missingHost";
|
|
return "externalOpen.invalidUrl";
|
|
}
|
|
|
|
function temporaryErrorType(errorKey: string) {
|
|
if (errorKey === "temporarySsh.inlinePassword") return "inline_password";
|
|
if (errorKey === "temporarySsh.unsupportedOption")
|
|
return "unsupported_parameter";
|
|
if (errorKey === "temporarySsh.invalidPort") return "invalid_port";
|
|
if (errorKey === "temporarySsh.invalidMode") return "invalid_mode";
|
|
if (errorKey === "temporarySsh.missingHost") return "missing_host";
|
|
return "invalid_url";
|
|
}
|
|
|
|
function invalid(
|
|
errorKey: string,
|
|
errorType: string,
|
|
scheme?: string,
|
|
): ExternalOpenParseResult {
|
|
return { ok: false, errorKey, errorType, scheme };
|
|
}
|
|
|
|
function schemeOf(value: string) {
|
|
const index = value.indexOf(":");
|
|
if (index <= 0) return null;
|
|
const scheme = value.slice(0, index);
|
|
if (!/^[a-z][a-z0-9+.-]*$/i.test(scheme)) return null;
|
|
return scheme.toLowerCase();
|
|
}
|
|
|
|
function hasInvalidExplicitUrlPort(value: string) {
|
|
const authority = value.match(/^[a-z][a-z0-9+.-]*:\/\/([^/?#]*)/i)?.[1];
|
|
if (!authority) return false;
|
|
const hostPort = authority.slice(authority.lastIndexOf("@") + 1);
|
|
let portText: string | null = null;
|
|
|
|
if (hostPort.startsWith("[")) {
|
|
const end = hostPort.indexOf("]");
|
|
if (end >= 0 && hostPort[end + 1] === ":") {
|
|
portText = hostPort.slice(end + 2);
|
|
}
|
|
} else {
|
|
const colonCount = (hostPort.match(/:/g) ?? []).length;
|
|
if (colonCount === 1) {
|
|
portText = hostPort.slice(hostPort.indexOf(":") + 1);
|
|
}
|
|
}
|
|
|
|
if (portText === null) return false;
|
|
if (!/^\d+$/.test(portText)) return true;
|
|
const port = Number(portText);
|
|
return !Number.isInteger(port) || port < 1 || port > 65535;
|
|
}
|