first commit
This commit is contained in:
14
terminal/apps/web/src/services/transport/factory.ts
Normal file
14
terminal/apps/web/src/services/transport/factory.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { TerminalTransport } from "./terminalTransport";
|
||||
import { GatewayTransport } from "./gatewayTransport";
|
||||
import { IosNativeTransport } from "./iosNativeTransport";
|
||||
|
||||
export function createTransport(
|
||||
mode: "gateway" | "ios-native",
|
||||
options: { gatewayUrl: string; gatewayToken: string }
|
||||
): TerminalTransport {
|
||||
if (mode === "ios-native") {
|
||||
return new IosNativeTransport();
|
||||
}
|
||||
return new GatewayTransport(options.gatewayUrl, options.gatewayToken);
|
||||
}
|
||||
|
||||
555
terminal/apps/web/src/services/transport/gatewayTransport.ts
Normal file
555
terminal/apps/web/src/services/transport/gatewayTransport.ts
Normal file
@@ -0,0 +1,555 @@
|
||||
import type { SessionState } from "@remoteconn/terminal-core";
|
||||
import type { ConnectParams, StdinMeta, TerminalTransport, TransportEvent } from "./terminalTransport";
|
||||
|
||||
type GatewayFrame = {
|
||||
type: string;
|
||||
payload?: Record<string, unknown>;
|
||||
data?: unknown;
|
||||
action?: unknown;
|
||||
code?: unknown;
|
||||
message?: unknown;
|
||||
};
|
||||
|
||||
export class GatewayTransport implements TerminalTransport {
|
||||
private static readonly PREFERRED_ENDPOINT_KEY = "terminal.gateway.preferredEndpoint";
|
||||
private static readonly CONNECT_TIMEOUT_MS = 12000;
|
||||
private socket: WebSocket | null = null;
|
||||
private listeners = new Set<(event: TransportEvent) => void>();
|
||||
private pingAt = 0;
|
||||
private heartbeatTimer: number | null = null;
|
||||
private connectAttemptId = 0;
|
||||
private state: SessionState = "idle";
|
||||
|
||||
public constructor(
|
||||
private readonly gatewayUrl: string,
|
||||
private readonly token: string
|
||||
) {}
|
||||
|
||||
public async connect(params: ConnectParams): Promise<void> {
|
||||
const attemptId = ++this.connectAttemptId;
|
||||
this.log("connect:start", {
|
||||
attemptId,
|
||||
host: params.host,
|
||||
port: params.port,
|
||||
username: params.username,
|
||||
cols: params.cols,
|
||||
rows: params.rows,
|
||||
hasClientSessionKey: Boolean(params.clientSessionKey),
|
||||
hasKnownHostFingerprint: Boolean(params.knownHostFingerprint)
|
||||
});
|
||||
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
||||
this.log("connect:already_open");
|
||||
throw new Error("会话已连接");
|
||||
}
|
||||
|
||||
this.state = "connecting";
|
||||
|
||||
this.socket = await new Promise<WebSocket>((resolve, reject) => {
|
||||
const endpoints = this.buildEndpoints();
|
||||
this.log("connect:endpoints", { endpoints });
|
||||
const reasons: string[] = [];
|
||||
let index = 0;
|
||||
const candidateHint = `候选地址: ${endpoints.join(", ")}`;
|
||||
|
||||
const tryConnect = (): void => {
|
||||
if (attemptId !== this.connectAttemptId) {
|
||||
this.log("connect:attempt_cancelled", { attemptId });
|
||||
reject(new Error("连接已取消"));
|
||||
return;
|
||||
}
|
||||
const endpoint = endpoints[index];
|
||||
if (!endpoint) {
|
||||
this.log("connect:all_failed", { reasons, candidateHint });
|
||||
reject(new Error(`无法连接网关: ${reasons.join(" | ") || "无可用网关地址"} | ${candidateHint}`));
|
||||
return;
|
||||
}
|
||||
let settled = false;
|
||||
let socket: WebSocket;
|
||||
let timeoutTimer: number | null = null;
|
||||
this.log("ws:connecting", { endpoint, attempt: index + 1, total: endpoints.length });
|
||||
|
||||
try {
|
||||
socket = new WebSocket(endpoint);
|
||||
this.socket = socket;
|
||||
} catch {
|
||||
this.log("ws:invalid_endpoint", { endpoint });
|
||||
reasons.push(`地址无效: ${endpoint}`);
|
||||
if (index < endpoints.length - 1) {
|
||||
index += 1;
|
||||
tryConnect();
|
||||
return;
|
||||
}
|
||||
reject(new Error(`无法连接网关: ${reasons.join(" | ")} | ${candidateHint}`));
|
||||
return;
|
||||
}
|
||||
|
||||
timeoutTimer = window.setTimeout(() => {
|
||||
fail(`连接超时>${GatewayTransport.CONNECT_TIMEOUT_MS}ms`);
|
||||
}, GatewayTransport.CONNECT_TIMEOUT_MS);
|
||||
|
||||
const clearTimer = (): void => {
|
||||
if (timeoutTimer !== null) {
|
||||
window.clearTimeout(timeoutTimer);
|
||||
timeoutTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const fail = (reason: string): void => {
|
||||
if (settled) return;
|
||||
if (attemptId !== this.connectAttemptId) {
|
||||
settled = true;
|
||||
clearTimer();
|
||||
reject(new Error("连接已取消"));
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimer();
|
||||
reasons.push(`${reason}: ${endpoint}`);
|
||||
this.log("ws:connect_failed", { endpoint, reason });
|
||||
if (this.getPreferredEndpoint() === endpoint) {
|
||||
this.clearPreferredEndpoint();
|
||||
this.log("connect:preferred_endpoint_cleared", { endpoint, reason });
|
||||
}
|
||||
try {
|
||||
socket.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (index < endpoints.length - 1) {
|
||||
index += 1;
|
||||
tryConnect();
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error(`无法连接网关: ${reasons.join(" | ")} | ${candidateHint}`));
|
||||
};
|
||||
|
||||
socket.onopen = () => {
|
||||
if (settled) return;
|
||||
if (attemptId !== this.connectAttemptId) {
|
||||
settled = true;
|
||||
clearTimer();
|
||||
try {
|
||||
socket.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
reject(new Error("连接已取消"));
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimer();
|
||||
this.log("ws:open", { endpoint });
|
||||
this.setPreferredEndpoint(endpoint);
|
||||
resolve(socket);
|
||||
};
|
||||
socket.onerror = () => fail("网络或协议错误");
|
||||
socket.onclose = (event) => {
|
||||
if (!settled) {
|
||||
fail(`连接关闭 code=${event.code} reason=${event.reason || "none"}`);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
tryConnect();
|
||||
});
|
||||
if (attemptId !== this.connectAttemptId) {
|
||||
this.log("connect:socket_ready_but_cancelled", { attemptId });
|
||||
try {
|
||||
this.socket?.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw new Error("连接已取消");
|
||||
}
|
||||
this.log("connect:socket_ready", { readyState: this.socket.readyState });
|
||||
|
||||
this.socket.onmessage = (event) => {
|
||||
const text = typeof event.data === "string" ? event.data : "";
|
||||
if (!text) return;
|
||||
let frame: GatewayFrame;
|
||||
try {
|
||||
frame = JSON.parse(text) as GatewayFrame;
|
||||
} catch {
|
||||
this.log("ws:message_parse_failed", { sample: text.slice(0, 120) });
|
||||
return;
|
||||
}
|
||||
this.handleFrame(frame);
|
||||
};
|
||||
|
||||
this.socket.onclose = (event) => {
|
||||
console.log(`[GatewayTransport][${new Date().toISOString()}] ws:onclose`, {
|
||||
code: event.code,
|
||||
reason: event.reason || "none",
|
||||
wasClean: event.wasClean
|
||||
});
|
||||
console.error(`[GatewayTransport][${new Date().toISOString()}] ws:onclose`, {
|
||||
code: event.code,
|
||||
reason: event.reason || "none",
|
||||
wasClean: event.wasClean
|
||||
});
|
||||
this.log("ws:onclose", { code: event.code, reason: event.reason || "none", wasClean: event.wasClean });
|
||||
this.stopHeartbeat();
|
||||
this.state = "disconnected";
|
||||
this.emit({
|
||||
type: "disconnect",
|
||||
reason: `ws_closed(code=${event.code},reason=${event.reason || "none"},clean=${event.wasClean})`
|
||||
});
|
||||
};
|
||||
|
||||
this.socket.onerror = () => {
|
||||
console.log(`[GatewayTransport][${new Date().toISOString()}] ws:onerror`);
|
||||
console.error(`[GatewayTransport][${new Date().toISOString()}] ws:onerror`);
|
||||
this.log("ws:onerror");
|
||||
this.stopHeartbeat();
|
||||
this.state = "error";
|
||||
this.emit({ type: "error", code: "WS_ERROR", message: "WebSocket 异常" });
|
||||
};
|
||||
|
||||
this.log("connect:send_init", {
|
||||
host: params.host,
|
||||
port: params.port,
|
||||
username: params.username,
|
||||
cols: params.cols,
|
||||
rows: params.rows,
|
||||
hasKnownHostFingerprint: Boolean(params.knownHostFingerprint)
|
||||
});
|
||||
this.sendRaw({
|
||||
type: "init",
|
||||
payload: {
|
||||
host: params.host,
|
||||
port: params.port,
|
||||
username: params.username,
|
||||
...(params.clientSessionKey ? { clientSessionKey: params.clientSessionKey } : {}),
|
||||
credential: params.credential,
|
||||
...(params.knownHostFingerprint ? { knownHostFingerprint: params.knownHostFingerprint } : {}),
|
||||
pty: { cols: params.cols, rows: params.rows }
|
||||
}
|
||||
});
|
||||
this.startHeartbeat();
|
||||
this.state = "auth_pending";
|
||||
this.log("connect:state_auth_pending");
|
||||
}
|
||||
|
||||
public async send(data: string, meta?: StdinMeta): Promise<void> {
|
||||
this.log("stdin:send", { length: data.length, source: meta?.source ?? "keyboard" });
|
||||
this.sendRaw({
|
||||
type: "stdin",
|
||||
payload: {
|
||||
data,
|
||||
...(meta ? { meta } : {})
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async resize(cols: number, rows: number): Promise<void> {
|
||||
this.log("pty:resize", { cols, rows });
|
||||
this.sendRaw({ type: "resize", payload: { cols, rows } });
|
||||
}
|
||||
|
||||
public async disconnect(reason = "manual"): Promise<void> {
|
||||
this.connectAttemptId += 1;
|
||||
this.log("disconnect:requested", { reason, readyState: this.socket?.readyState ?? null });
|
||||
this.stopHeartbeat();
|
||||
if (this.socket) {
|
||||
if (this.socket.readyState === WebSocket.OPEN) {
|
||||
this.log("disconnect:send_control", { reason });
|
||||
this.sendRaw({ type: "control", payload: { action: "disconnect", reason } });
|
||||
}
|
||||
// Force close regardless of state (e.g. CONNECTING)
|
||||
try {
|
||||
this.socket.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
this.socket = null;
|
||||
this.state = "disconnected";
|
||||
this.log("disconnect:done");
|
||||
}
|
||||
|
||||
public on(listener: (event: TransportEvent) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
public getState(): SessionState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
private sendRaw(frame: Record<string, unknown>): void {
|
||||
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
||||
this.log("ws:send_blocked", {
|
||||
frameType: String(frame.type ?? "unknown"),
|
||||
readyState: this.socket?.readyState ?? null,
|
||||
state: this.state
|
||||
});
|
||||
throw new Error("网关连接未建立");
|
||||
}
|
||||
this.socket.send(JSON.stringify(frame));
|
||||
}
|
||||
|
||||
private handleFrame(frame: GatewayFrame): void {
|
||||
const payload = frame.payload ?? {};
|
||||
const type = String(frame.type ?? "");
|
||||
const action = String((payload.action ?? frame.action ?? "") as string);
|
||||
if (type !== "stdout" && type !== "stderr") {
|
||||
this.log("ws:frame", { type, action: action || undefined });
|
||||
}
|
||||
|
||||
if (type === "stdout") {
|
||||
const data = String(payload.data ?? frame.data ?? "");
|
||||
if (!data) return;
|
||||
if (this.state !== "connected") {
|
||||
this.log("ws:stdout_promote_connected");
|
||||
}
|
||||
this.state = "connected";
|
||||
this.emit({ type: "stdout", data });
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "stderr") {
|
||||
const data = String(payload.data ?? frame.data ?? "");
|
||||
if (!data) return;
|
||||
this.emit({ type: "stderr", data });
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "error") {
|
||||
console.log(`[GatewayTransport][${new Date().toISOString()}] ws:error_frame`, {
|
||||
code: String(payload.code ?? frame.code ?? "INTERNAL_ERROR"),
|
||||
message: String(payload.message ?? frame.message ?? "未知错误")
|
||||
});
|
||||
console.error(`[GatewayTransport][${new Date().toISOString()}] ws:error_frame`, {
|
||||
code: String(payload.code ?? frame.code ?? "INTERNAL_ERROR"),
|
||||
message: String(payload.message ?? frame.message ?? "未知错误")
|
||||
});
|
||||
this.log("ws:error_frame", {
|
||||
code: String(payload.code ?? frame.code ?? "INTERNAL_ERROR"),
|
||||
message: String(payload.message ?? frame.message ?? "未知错误")
|
||||
});
|
||||
this.state = "error";
|
||||
this.emit({
|
||||
type: "error",
|
||||
code: String(payload.code ?? frame.code ?? "INTERNAL_ERROR"),
|
||||
message: String(payload.message ?? frame.message ?? "未知错误")
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "connected") {
|
||||
this.log("ws:connected_frame", { fingerprint: String(payload.fingerprint ?? "") || undefined });
|
||||
this.state = "connected";
|
||||
this.emit({ type: "connected", fingerprint: String(payload.fingerprint ?? "") || undefined });
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "disconnect") {
|
||||
this.log("ws:disconnect_frame", { reason: String(payload.reason ?? "unknown") });
|
||||
this.state = "disconnected";
|
||||
this.stopHeartbeat();
|
||||
this.emit({ type: "disconnect", reason: String(payload.reason ?? "unknown") });
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "control") {
|
||||
if (action === "ping") {
|
||||
this.log("heartbeat:ping_recv");
|
||||
this.sendRaw({ type: "control", payload: { action: "pong" } });
|
||||
return;
|
||||
}
|
||||
if (action === "pong") {
|
||||
if (this.pingAt > 0) {
|
||||
this.log("heartbeat:pong_recv", { latencyMs: Date.now() - this.pingAt });
|
||||
this.emit({ type: "latency", data: Date.now() - this.pingAt });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (action === "connected") {
|
||||
this.log("ws:control_connected", { fingerprint: String(payload.fingerprint ?? "") || undefined });
|
||||
this.state = "connected";
|
||||
this.emit({ type: "connected", fingerprint: String(payload.fingerprint ?? "") || undefined });
|
||||
return;
|
||||
}
|
||||
if (action === "disconnect") {
|
||||
this.log("ws:control_disconnect", { reason: String(payload.reason ?? "unknown") });
|
||||
this.state = "disconnected";
|
||||
this.stopHeartbeat();
|
||||
this.emit({ type: "disconnect", reason: String(payload.reason ?? "unknown") });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private emit(event: TransportEvent): void {
|
||||
for (const listener of this.listeners) {
|
||||
listener(event);
|
||||
}
|
||||
}
|
||||
|
||||
private startHeartbeat(): void {
|
||||
this.stopHeartbeat();
|
||||
this.log("heartbeat:start");
|
||||
this.heartbeatTimer = window.setInterval(() => {
|
||||
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
this.pingAt = Date.now();
|
||||
this.log("heartbeat:ping_send");
|
||||
this.sendRaw({ type: "control", payload: { action: "ping" } });
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
private stopHeartbeat(): void {
|
||||
if (this.heartbeatTimer) {
|
||||
this.log("heartbeat:stop");
|
||||
window.clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private log(_message: string, _detail?: unknown): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
const enabled =
|
||||
window.localStorage.getItem("terminal.debugTransport") === "1" ||
|
||||
window.localStorage.getItem("terminal.debugPaste") === "1";
|
||||
if (!enabled) return;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const prefix = `[GatewayTransport][${new Date().toISOString()}] ${_message}`;
|
||||
if (typeof _detail === "undefined") {
|
||||
console.log(prefix);
|
||||
return;
|
||||
}
|
||||
console.log(prefix, _detail);
|
||||
}
|
||||
|
||||
private buildEndpoints(): string[] {
|
||||
const pageIsHttps = window.location.protocol === "https:";
|
||||
const pageHost = window.location.hostname;
|
||||
const pageHostWithPort = window.location.host;
|
||||
const pageProtocol = pageIsHttps ? "wss:" : "ws:";
|
||||
const pageOrigin = `${pageProtocol}//${pageHostWithPort}`;
|
||||
const rawInput = this.gatewayUrl.trim();
|
||||
const candidates: string[] = [];
|
||||
|
||||
const finalizeEndpoint = (source: URL): string => {
|
||||
const next = new URL(source.toString());
|
||||
const pathname = next.pathname.replace(/\/+$/, "");
|
||||
next.pathname = pathname.endsWith("/ws/terminal") ? pathname : `${pathname}/ws/terminal`.replace(/\/{2,}/g, "/");
|
||||
next.search = `token=${encodeURIComponent(this.token)}`;
|
||||
return next.toString();
|
||||
};
|
||||
|
||||
const pushCandidate = (next: URL): void => {
|
||||
if (pageIsHttps && next.protocol === "ws:") {
|
||||
return;
|
||||
}
|
||||
candidates.push(finalizeEndpoint(next));
|
||||
};
|
||||
|
||||
// 兼容相对路径配置(如 /ws/terminal),并以当前页面源为基准解析。
|
||||
const fallbackUrl = new URL(pageOrigin);
|
||||
let url: URL;
|
||||
try {
|
||||
if (!rawInput) {
|
||||
url = new URL(fallbackUrl.toString());
|
||||
} else if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(rawInput)) {
|
||||
url = new URL(rawInput);
|
||||
} else {
|
||||
url = new URL(rawInput, fallbackUrl.toString());
|
||||
}
|
||||
} catch {
|
||||
url = new URL(fallbackUrl.toString());
|
||||
}
|
||||
|
||||
if (url.protocol === "http:") url.protocol = "ws:";
|
||||
if (url.protocol === "https:") url.protocol = "wss:";
|
||||
if (pageIsHttps && url.protocol === "ws:") {
|
||||
url.protocol = "wss:";
|
||||
}
|
||||
|
||||
const localHosts = new Set(["localhost", "127.0.0.1", "::1"]);
|
||||
const pageIsLocal = localHosts.has(pageHost);
|
||||
let targetIsLocal = localHosts.has(url.hostname);
|
||||
if (!pageIsLocal && targetIsLocal) {
|
||||
// 页面从远程域名访问时,若配置仍是 localhost/127.0.0.1,
|
||||
// 优先尝试同源地址(通常由 Vite/Nginx 代理到本地网关)。
|
||||
const sameOrigin = new URL(url.toString());
|
||||
sameOrigin.protocol = pageProtocol;
|
||||
sameOrigin.host = pageHostWithPort;
|
||||
pushCandidate(sameOrigin);
|
||||
|
||||
const sameOriginNoPort = new URL(sameOrigin.toString());
|
||||
sameOriginNoPort.port = "";
|
||||
pushCandidate(sameOriginNoPort);
|
||||
|
||||
url.hostname = pageHost;
|
||||
targetIsLocal = localHosts.has(url.hostname);
|
||||
}
|
||||
|
||||
pushCandidate(url);
|
||||
|
||||
if (!pageIsHttps && url.protocol === "ws:") {
|
||||
const tlsUrl = new URL(url.toString());
|
||||
tlsUrl.protocol = "wss:";
|
||||
pushCandidate(tlsUrl);
|
||||
} else if (url.protocol === "wss:" && !pageIsHttps) {
|
||||
const plainUrl = new URL(url.toString());
|
||||
plainUrl.protocol = "ws:";
|
||||
pushCandidate(plainUrl);
|
||||
}
|
||||
|
||||
if (!targetIsLocal) {
|
||||
const noPort = new URL(url.toString());
|
||||
noPort.port = "";
|
||||
pushCandidate(noPort);
|
||||
|
||||
if (!pageIsHttps && noPort.protocol === "ws:") {
|
||||
const noPortTls = new URL(noPort.toString());
|
||||
noPortTls.protocol = "wss:";
|
||||
pushCandidate(noPortTls);
|
||||
} else if (noPort.protocol === "wss:" && !pageIsHttps) {
|
||||
const noPortPlain = new URL(noPort.toString());
|
||||
noPortPlain.protocol = "ws:";
|
||||
pushCandidate(noPortPlain);
|
||||
}
|
||||
}
|
||||
|
||||
const ordered = [...new Set(candidates)];
|
||||
if (ordered.length === 0) {
|
||||
return [finalizeEndpoint(fallbackUrl)];
|
||||
}
|
||||
const preferred = this.getPreferredEndpoint();
|
||||
if (preferred && ordered.includes(preferred)) {
|
||||
return [preferred, ...ordered.filter((endpoint) => endpoint !== preferred)];
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
private getPreferredEndpoint(): string | null {
|
||||
try {
|
||||
return window.localStorage.getItem(GatewayTransport.PREFERRED_ENDPOINT_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private setPreferredEndpoint(endpoint: string): void {
|
||||
try {
|
||||
window.localStorage.setItem(GatewayTransport.PREFERRED_ENDPOINT_KEY, endpoint);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
private clearPreferredEndpoint(): void {
|
||||
try {
|
||||
window.localStorage.removeItem(GatewayTransport.PREFERRED_ENDPOINT_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
174
terminal/apps/web/src/services/transport/iosNativeTransport.ts
Normal file
174
terminal/apps/web/src/services/transport/iosNativeTransport.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import type { SessionState } from "@remoteconn/terminal-core";
|
||||
import type { ConnectParams, StdinMeta, TerminalTransport, TransportEvent } from "./terminalTransport";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Capacitor?: {
|
||||
Plugins?: {
|
||||
RemoteConnSSH?: {
|
||||
connect(options: unknown): Promise<void>;
|
||||
send(options: { data: string }): Promise<void>;
|
||||
resize(options: { cols: number; rows: number }): Promise<void>;
|
||||
disconnect(options: { reason?: string }): Promise<void>;
|
||||
addListener(
|
||||
eventName: "stdout" | "stderr" | "disconnect" | "latency" | "error" | "connected",
|
||||
listener: (payload: unknown) => void
|
||||
): Promise<{ remove: () => void }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
type NativeCredentialPayload =
|
||||
| { type: "password"; password: string }
|
||||
| { type: "privateKey"; privateKey: string; passphrase?: string }
|
||||
| { type: "certificate"; privateKey: string; passphrase?: string; certificate: string };
|
||||
|
||||
interface NativeConnectPayload {
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
knownHostFingerprint?: string;
|
||||
cols: number;
|
||||
rows: number;
|
||||
credential: NativeCredentialPayload;
|
||||
}
|
||||
|
||||
function buildNativeConnectPayload(params: ConnectParams): NativeConnectPayload {
|
||||
const base = {
|
||||
host: String(params.host ?? ""),
|
||||
port: Number(params.port ?? 22),
|
||||
username: String(params.username ?? ""),
|
||||
cols: Number(params.cols ?? 80),
|
||||
rows: Number(params.rows ?? 24)
|
||||
};
|
||||
|
||||
const knownHostFingerprint =
|
||||
typeof params.knownHostFingerprint === "string" && params.knownHostFingerprint.trim().length > 0
|
||||
? params.knownHostFingerprint.trim()
|
||||
: undefined;
|
||||
|
||||
if (params.credential.type === "password") {
|
||||
return {
|
||||
...base,
|
||||
...(knownHostFingerprint ? { knownHostFingerprint } : {}),
|
||||
credential: {
|
||||
type: "password",
|
||||
password: String(params.credential.password ?? "")
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (params.credential.type === "privateKey") {
|
||||
return {
|
||||
...base,
|
||||
...(knownHostFingerprint ? { knownHostFingerprint } : {}),
|
||||
credential: {
|
||||
type: "privateKey",
|
||||
privateKey: String(params.credential.privateKey ?? ""),
|
||||
...(params.credential.passphrase ? { passphrase: String(params.credential.passphrase) } : {})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
...(knownHostFingerprint ? { knownHostFingerprint } : {}),
|
||||
credential: {
|
||||
type: "certificate",
|
||||
privateKey: String(params.credential.privateKey ?? ""),
|
||||
certificate: String(params.credential.certificate ?? ""),
|
||||
...(params.credential.passphrase ? { passphrase: String(params.credential.passphrase) } : {})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export class IosNativeTransport implements TerminalTransport {
|
||||
private state: SessionState = "idle";
|
||||
private listeners = new Set<(event: TransportEvent) => void>();
|
||||
private disposers: Array<() => void> = [];
|
||||
|
||||
public async connect(params: ConnectParams): Promise<void> {
|
||||
const plugin = window.Capacitor?.Plugins?.RemoteConnSSH;
|
||||
if (!plugin) {
|
||||
throw new Error("iOS 原生插件不可用");
|
||||
}
|
||||
|
||||
this.state = "connecting";
|
||||
|
||||
const onStdout = await plugin.addListener("stdout", (payload) => {
|
||||
this.state = "connected";
|
||||
this.emit({ type: "stdout", data: String((payload as { data?: string }).data ?? "") });
|
||||
});
|
||||
this.disposers.push(() => onStdout.remove());
|
||||
|
||||
const onStderr = await plugin.addListener("stderr", (payload) => {
|
||||
this.emit({ type: "stderr", data: String((payload as { data?: string }).data ?? "") });
|
||||
});
|
||||
this.disposers.push(() => onStderr.remove());
|
||||
|
||||
const onDisconnect = await plugin.addListener("disconnect", (payload) => {
|
||||
this.state = "disconnected";
|
||||
this.emit({ type: "disconnect", reason: String((payload as { reason?: string }).reason ?? "disconnect") });
|
||||
});
|
||||
this.disposers.push(() => onDisconnect.remove());
|
||||
|
||||
const onLatency = await plugin.addListener("latency", (payload) => {
|
||||
this.emit({ type: "latency", data: Number((payload as { latency?: number }).latency ?? 0) });
|
||||
});
|
||||
this.disposers.push(() => onLatency.remove());
|
||||
|
||||
const onError = await plugin.addListener("error", (payload) => {
|
||||
this.state = "error";
|
||||
const error = payload as { code?: string; message?: string };
|
||||
this.emit({
|
||||
type: "error",
|
||||
code: String(error.code ?? "NATIVE_ERROR"),
|
||||
message: String(error.message ?? "iOS 连接异常")
|
||||
});
|
||||
});
|
||||
this.disposers.push(() => onError.remove());
|
||||
|
||||
const onConnected = await plugin.addListener("connected", (payload) => {
|
||||
this.state = "connected";
|
||||
this.emit({ type: "connected", fingerprint: String((payload as { fingerprint?: string }).fingerprint ?? "") || undefined });
|
||||
});
|
||||
this.disposers.push(() => onConnected.remove());
|
||||
|
||||
await plugin.connect(buildNativeConnectPayload(params));
|
||||
}
|
||||
|
||||
public async send(data: string, _meta?: StdinMeta): Promise<void> {
|
||||
await window.Capacitor?.Plugins?.RemoteConnSSH?.send({ data });
|
||||
}
|
||||
|
||||
public async resize(cols: number, rows: number): Promise<void> {
|
||||
await window.Capacitor?.Plugins?.RemoteConnSSH?.resize({ cols, rows });
|
||||
}
|
||||
|
||||
public async disconnect(reason?: string): Promise<void> {
|
||||
await window.Capacitor?.Plugins?.RemoteConnSSH?.disconnect({ reason });
|
||||
for (const dispose of this.disposers) {
|
||||
dispose();
|
||||
}
|
||||
this.disposers = [];
|
||||
this.state = "disconnected";
|
||||
}
|
||||
|
||||
public on(listener: (event: TransportEvent) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
public getState(): SessionState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
private emit(event: TransportEvent): void {
|
||||
for (const listener of this.listeners) {
|
||||
listener(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ConnectParams as CoreConnectParams, FrameMeta, SessionState } from "@remoteconn/terminal-core";
|
||||
|
||||
export type ConnectParams = CoreConnectParams;
|
||||
export type StdinMeta = FrameMeta;
|
||||
|
||||
export type TransportEvent =
|
||||
| { type: "stdout"; data: string }
|
||||
| { type: "stderr"; data: string }
|
||||
| { type: "latency"; data: number }
|
||||
| { type: "connected"; fingerprint?: string }
|
||||
| { type: "disconnect"; reason: string }
|
||||
| { type: "error"; code: string; message: string };
|
||||
|
||||
export interface TerminalTransport {
|
||||
connect(params: ConnectParams): Promise<void>;
|
||||
send(data: string, meta?: StdinMeta): Promise<void>;
|
||||
resize(cols: number, rows: number): Promise<void>;
|
||||
disconnect(reason?: string): Promise<void>;
|
||||
on(listener: (event: TransportEvent) => void): () => void;
|
||||
getState(): SessionState;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user