first commit
This commit is contained in:
12
terminal/apps/web/index.html
Normal file
12
terminal/apps/web/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
|
||||
<title>Terminal Lab</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
25
terminal/apps/web/package.json
Normal file
25
terminal/apps/web/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@remoteconn/web-terminal-lab",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0 --port 5173",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"typecheck": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@remoteconn/terminal-core": "1.0.0",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.5.22"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^5.4.10",
|
||||
"vitest": "^4.0.18",
|
||||
"vue-tsc": "^2.2.4"
|
||||
}
|
||||
}
|
||||
19
terminal/apps/web/public/terminal.config.json
Normal file
19
terminal/apps/web/public/terminal.config.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"gatewayUrl": "/ws/terminal",
|
||||
"gatewayToken": "remoteconn-dev-token",
|
||||
"selectedServerId": "dev-server",
|
||||
"servers": [
|
||||
{
|
||||
"id": "dev-server",
|
||||
"name": "Dev Server",
|
||||
"host": "mac.biboer.cn",
|
||||
"port": 22,
|
||||
"username": "gavin",
|
||||
"transportMode": "gateway",
|
||||
"authType": "password",
|
||||
"password": "Gavin123",
|
||||
"cols": 80,
|
||||
"rows": 24
|
||||
}
|
||||
]
|
||||
}
|
||||
72
terminal/apps/web/src/App.vue
Normal file
72
terminal/apps/web/src/App.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import TerminalPage from "@/terminal/TerminalPage.vue";
|
||||
|
||||
const appStore = useAppStore();
|
||||
const { toasts } = storeToRefs(appStore);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="app-root">
|
||||
<TerminalPage />
|
||||
<section class="toast-list" aria-live="polite">
|
||||
<article
|
||||
v-for="item in toasts"
|
||||
:key="item.id"
|
||||
class="toast-item"
|
||||
:class="`toast-${item.level}`"
|
||||
>
|
||||
{{ item.message }}
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-root {
|
||||
/*
|
||||
* 使用稳定后的可视视口高度控制根容器。
|
||||
* 注意:不跟随 --app-vtop 做整体位移,避免键盘动画期间整页被平移到不可见区。
|
||||
*/
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: var(--app-vh, 100dvh);
|
||||
overflow: hidden;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
.toast-list {
|
||||
position: fixed;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
.toast-item {
|
||||
max-width: 360px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.toast-info {
|
||||
background: #2d7ff9;
|
||||
}
|
||||
|
||||
.toast-warn {
|
||||
background: #b88400;
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
background: #c0392b;
|
||||
}
|
||||
</style>
|
||||
7
terminal/apps/web/src/env.d.ts
vendored
Normal file
7
terminal/apps/web/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>;
|
||||
export default component;
|
||||
}
|
||||
119
terminal/apps/web/src/main.ts
Normal file
119
terminal/apps/web/src/main.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { createPinia } from "pinia";
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import "./style.css";
|
||||
|
||||
function disablePageZoom(): void {
|
||||
if (typeof document === "undefined") return;
|
||||
const prevent = (e: Event) => e.preventDefault();
|
||||
let lastTapAt = 0;
|
||||
let lastTapX = 0;
|
||||
let lastTapY = 0;
|
||||
const DOUBLE_TAP_GUARD_MS = 320;
|
||||
const DOUBLE_TAP_DISTANCE_PX = 32;
|
||||
|
||||
document.addEventListener("gesturestart", prevent, { passive: false });
|
||||
document.addEventListener("gesturechange", prevent, { passive: false });
|
||||
document.addEventListener("gestureend", prevent, { passive: false });
|
||||
document.addEventListener("touchmove", (e: TouchEvent) => {
|
||||
if (e.touches.length > 1) e.preventDefault();
|
||||
}, { passive: false });
|
||||
document.addEventListener("touchend", (e: TouchEvent) => {
|
||||
const touch = e.changedTouches[0];
|
||||
if (!touch) return;
|
||||
|
||||
const now = Date.now();
|
||||
const dt = now - lastTapAt;
|
||||
const dx = Math.abs(touch.clientX - lastTapX);
|
||||
const dy = Math.abs(touch.clientY - lastTapY);
|
||||
lastTapAt = now;
|
||||
lastTapX = touch.clientX;
|
||||
lastTapY = touch.clientY;
|
||||
|
||||
// 全局双击缩放拦截:在整个 Web 页面内统一禁止 double-tap zoom。
|
||||
if (dt > 0 && dt <= DOUBLE_TAP_GUARD_MS && dx <= DOUBLE_TAP_DISTANCE_PX && dy <= DOUBLE_TAP_DISTANCE_PX) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}, { passive: false, capture: true });
|
||||
}
|
||||
|
||||
function setupMobileViewportCompensation(): void {
|
||||
if (typeof window === "undefined" || typeof document === "undefined") return;
|
||||
const root = document.documentElement;
|
||||
const MIN_VIEWPORT_RATIO = 0.35;
|
||||
const MIN_VIEWPORT_PX = 240;
|
||||
const BIG_JUMP_PX = 64;
|
||||
const BIG_JUMP_DEBOUNCE_MS = 100;
|
||||
|
||||
// 记录“可信高度”基线:用于过滤键盘动画期间偶发的极小中间值(例如瞬时 98px)。
|
||||
let baselineViewportHeight = Math.max(window.innerHeight, window.visualViewport?.height ?? 0);
|
||||
let committedViewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
||||
let pendingViewportHeight = committedViewportHeight;
|
||||
let pendingViewportTop = window.visualViewport?.offsetTop ?? 0;
|
||||
let pendingTimer: number | null = null;
|
||||
|
||||
const commitViewport = (viewportHeight: number, viewportTop: number) => {
|
||||
root.style.setProperty("--app-vh", `${Math.round(viewportHeight)}px`);
|
||||
root.style.setProperty("--app-vtop", `${Math.round(viewportTop)}px`);
|
||||
};
|
||||
|
||||
const flushPending = () => {
|
||||
pendingTimer = null;
|
||||
committedViewportHeight = pendingViewportHeight;
|
||||
commitViewport(pendingViewportHeight, pendingViewportTop);
|
||||
};
|
||||
|
||||
const scheduleViewport = (viewportHeight: number, viewportTop: number) => {
|
||||
if (viewportHeight <= 0) return;
|
||||
|
||||
if (viewportHeight > baselineViewportHeight) {
|
||||
baselineViewportHeight = viewportHeight;
|
||||
}
|
||||
|
||||
// 过滤动画毛刺:异常小高度直接丢弃,避免主容器瞬时塌缩导致工具条闪位。
|
||||
const minReasonable = Math.max(MIN_VIEWPORT_PX, baselineViewportHeight * MIN_VIEWPORT_RATIO);
|
||||
if (viewportHeight < minReasonable) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingViewportHeight = viewportHeight;
|
||||
pendingViewportTop = viewportTop;
|
||||
const jump = Math.abs(viewportHeight - committedViewportHeight);
|
||||
|
||||
// 大跳变(键盘开关动画期)走短防抖,只提交最终稳定值。
|
||||
if (jump >= BIG_JUMP_PX) {
|
||||
if (pendingTimer !== null) {
|
||||
window.clearTimeout(pendingTimer);
|
||||
}
|
||||
pendingTimer = window.setTimeout(flushPending, BIG_JUMP_DEBOUNCE_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingTimer !== null) {
|
||||
window.clearTimeout(pendingTimer);
|
||||
pendingTimer = null;
|
||||
}
|
||||
committedViewportHeight = viewportHeight;
|
||||
commitViewport(viewportHeight, viewportTop);
|
||||
};
|
||||
|
||||
const applyViewport = () => {
|
||||
const vv = window.visualViewport;
|
||||
const viewportHeight = vv?.height ?? window.innerHeight;
|
||||
const viewportTop = vv?.offsetTop ?? 0;
|
||||
scheduleViewport(viewportHeight, viewportTop);
|
||||
};
|
||||
|
||||
applyViewport();
|
||||
window.addEventListener("resize", applyViewport);
|
||||
window.addEventListener("orientationchange", () => window.setTimeout(applyViewport, 120));
|
||||
window.visualViewport?.addEventListener("resize", applyViewport);
|
||||
window.visualViewport?.addEventListener("scroll", applyViewport);
|
||||
}
|
||||
|
||||
disablePageZoom();
|
||||
setupMobileViewportCompensation();
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(createPinia());
|
||||
app.mount("#app");
|
||||
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;
|
||||
}
|
||||
|
||||
32
terminal/apps/web/src/stores/appStore.ts
Normal file
32
terminal/apps/web/src/stores/appStore.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
|
||||
interface AppToast {
|
||||
id: string;
|
||||
level: "info" | "warn" | "error";
|
||||
message: string;
|
||||
}
|
||||
|
||||
export const useAppStore = defineStore("app", () => {
|
||||
const toasts = ref<AppToast[]>([]);
|
||||
|
||||
function notify(level: AppToast["level"], message: string): void {
|
||||
const item: AppToast = {
|
||||
id: typeof crypto !== "undefined" && "randomUUID" in crypto
|
||||
? crypto.randomUUID()
|
||||
: `toast-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
level,
|
||||
message
|
||||
};
|
||||
toasts.value.push(item);
|
||||
window.setTimeout(() => {
|
||||
toasts.value = toasts.value.filter((x) => x.id !== item.id);
|
||||
}, level === "error" ? 5000 : 3000);
|
||||
}
|
||||
|
||||
return {
|
||||
toasts,
|
||||
notify
|
||||
};
|
||||
});
|
||||
|
||||
231
terminal/apps/web/src/stores/serverStore.ts
Normal file
231
terminal/apps/web/src/stores/serverStore.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { computed, ref } from "vue";
|
||||
import type { TerminalCredential } from "@remoteconn/terminal-core";
|
||||
import { loadRuntimeConfig } from "@/utils/runtimeConfig";
|
||||
|
||||
export interface ServerProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
transportMode: "gateway" | "ios-native";
|
||||
knownHostFingerprint?: string;
|
||||
authType: "password" | "privateKey" | "certificate";
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
passphrase?: string;
|
||||
certificate?: string;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "remoteconn:web:servers:v1";
|
||||
const SELECTED_SERVER_KEY = "remoteconn:web:selected-server-id:v1";
|
||||
|
||||
function loadServers(): ServerProfile[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
return normalizeServers(parsed);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function toFiniteNumber(value: unknown, fallback: number): number {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
function normalizeServers(input: unknown): ServerProfile[] {
|
||||
if (!Array.isArray(input)) return [];
|
||||
return input
|
||||
.filter((item): item is Record<string, unknown> => !!item && typeof item === "object" && typeof (item as { id?: unknown }).id === "string")
|
||||
.map((item) => {
|
||||
const authType = item.authType === "privateKey" || item.authType === "certificate" ? item.authType : "password";
|
||||
return {
|
||||
id: String(item.id),
|
||||
name: typeof item.name === "string" ? item.name : "未命名服务器",
|
||||
host: typeof item.host === "string" ? item.host : "",
|
||||
port: toFiniteNumber(item.port, 22),
|
||||
username: typeof item.username === "string" ? item.username : "root",
|
||||
transportMode: item.transportMode === "ios-native" ? "ios-native" : "gateway",
|
||||
authType,
|
||||
...(typeof item.knownHostFingerprint === "string" && item.knownHostFingerprint ? { knownHostFingerprint: item.knownHostFingerprint } : {}),
|
||||
...(typeof item.password === "string" ? { password: item.password } : {}),
|
||||
...(typeof item.privateKey === "string" ? { privateKey: item.privateKey } : {}),
|
||||
...(typeof item.passphrase === "string" ? { passphrase: item.passphrase } : {}),
|
||||
...(typeof item.certificate === "string" ? { certificate: item.certificate } : {}),
|
||||
cols: toFiniteNumber(item.cols, 80),
|
||||
rows: toFiniteNumber(item.rows, 24)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function persistServers(servers: ServerProfile[]): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(servers));
|
||||
} catch {
|
||||
// ignore storage unavailability in embedded/private contexts
|
||||
}
|
||||
}
|
||||
|
||||
function loadSelectedServerId(): string {
|
||||
if (typeof window === "undefined") return "";
|
||||
try {
|
||||
return window.localStorage.getItem(SELECTED_SERVER_KEY) ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function persistSelectedServerId(id: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(SELECTED_SERVER_KEY, id);
|
||||
} catch {
|
||||
// ignore storage unavailability in embedded/private contexts
|
||||
}
|
||||
}
|
||||
|
||||
function buildDefaultServer(): ServerProfile {
|
||||
return {
|
||||
id: `srv-${typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : Date.now()}`,
|
||||
name: "新服务器",
|
||||
host: "",
|
||||
port: 22,
|
||||
username: "root",
|
||||
transportMode: "gateway",
|
||||
authType: "password",
|
||||
password: "",
|
||||
cols: 80,
|
||||
rows: 24
|
||||
};
|
||||
}
|
||||
|
||||
export const useServerStore = defineStore("server", () => {
|
||||
const servers = ref<ServerProfile[]>([]);
|
||||
const selectedServerId = ref<string>("");
|
||||
const loaded = ref(false);
|
||||
let bootstrapPromise: Promise<void> | null = null;
|
||||
|
||||
const selectedServer = computed(() => servers.value.find((item) => item.id === selectedServerId.value));
|
||||
|
||||
async function ensureBootstrapped(): Promise<void> {
|
||||
if (loaded.value) return;
|
||||
if (bootstrapPromise) {
|
||||
await bootstrapPromise;
|
||||
return;
|
||||
}
|
||||
bootstrapPromise = (async () => {
|
||||
const runtimeConfig = await loadRuntimeConfig();
|
||||
const configured = normalizeServers(runtimeConfig?.servers);
|
||||
const stored = loadServers();
|
||||
if (configured.length > 0) {
|
||||
servers.value = configured;
|
||||
} else if (stored.length > 0) {
|
||||
servers.value = stored;
|
||||
} else {
|
||||
const sample = buildDefaultServer();
|
||||
servers.value = [sample];
|
||||
persistServers(servers.value);
|
||||
}
|
||||
|
||||
const preferred = runtimeConfig?.selectedServerId ?? loadSelectedServerId();
|
||||
selectedServerId.value = servers.value.some((item) => item.id === preferred)
|
||||
? preferred
|
||||
: (servers.value[0]?.id ?? "");
|
||||
persistSelectedServerId(selectedServerId.value);
|
||||
loaded.value = true;
|
||||
})();
|
||||
try {
|
||||
await bootstrapPromise;
|
||||
} finally {
|
||||
bootstrapPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
await ensureBootstrapped();
|
||||
}
|
||||
|
||||
function setSelectedServer(serverId: string): void {
|
||||
selectedServerId.value = serverId;
|
||||
persistSelectedServerId(serverId);
|
||||
}
|
||||
|
||||
async function saveServer(server: ServerProfile): Promise<void> {
|
||||
const index = servers.value.findIndex((item) => item.id === server.id);
|
||||
if (index >= 0) {
|
||||
servers.value[index] = server;
|
||||
} else {
|
||||
servers.value.unshift(server);
|
||||
}
|
||||
persistServers(servers.value);
|
||||
if (!selectedServerId.value) {
|
||||
setSelectedServer(server.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function createServer(): Promise<ServerProfile> {
|
||||
const sample = buildDefaultServer();
|
||||
servers.value.unshift(sample);
|
||||
persistServers(servers.value);
|
||||
setSelectedServer(sample.id);
|
||||
return sample;
|
||||
}
|
||||
|
||||
async function deleteServer(serverId: string): Promise<void> {
|
||||
servers.value = servers.value.filter((item) => item.id !== serverId);
|
||||
persistServers(servers.value);
|
||||
if (selectedServerId.value === serverId) {
|
||||
setSelectedServer(servers.value[0]?.id ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveCredential(serverId: string): Promise<TerminalCredential> {
|
||||
const server = servers.value.find((item) => item.id === serverId);
|
||||
if (!server) {
|
||||
throw new Error("目标服务器不存在");
|
||||
}
|
||||
|
||||
if (server.authType === "password") {
|
||||
return {
|
||||
type: "password",
|
||||
password: server.password ?? ""
|
||||
};
|
||||
}
|
||||
|
||||
if (server.authType === "privateKey") {
|
||||
return {
|
||||
type: "privateKey",
|
||||
privateKey: server.privateKey ?? "",
|
||||
...(server.passphrase ? { passphrase: server.passphrase } : {})
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "certificate",
|
||||
privateKey: server.privateKey ?? "",
|
||||
certificate: server.certificate ?? "",
|
||||
...(server.passphrase ? { passphrase: server.passphrase } : {})
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
servers,
|
||||
selectedServerId,
|
||||
selectedServer,
|
||||
ensureBootstrapped,
|
||||
bootstrap,
|
||||
setSelectedServer,
|
||||
saveServer,
|
||||
createServer,
|
||||
deleteServer,
|
||||
resolveCredential
|
||||
};
|
||||
});
|
||||
110
terminal/apps/web/src/stores/settingsStore.ts
Normal file
110
terminal/apps/web/src/stores/settingsStore.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { computed, ref } from "vue";
|
||||
import { loadRuntimeConfig } from "@/utils/runtimeConfig";
|
||||
|
||||
interface RuntimeSettings {
|
||||
gatewayUrl: string;
|
||||
gatewayToken: string;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "remoteconn:web:settings:v1";
|
||||
|
||||
function resolveDefaultGatewayUrl(): string {
|
||||
const env = (import.meta as ImportMeta & { env?: Record<string, string | undefined> }).env;
|
||||
const envUrl = env?.VITE_GATEWAY_URL?.trim();
|
||||
if (envUrl) return envUrl;
|
||||
|
||||
if (typeof window === "undefined") return "ws://127.0.0.1:8787/ws/terminal";
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${protocol}//${window.location.host}/ws/terminal`;
|
||||
}
|
||||
|
||||
function resolveDefaultGatewayToken(): string {
|
||||
const env = (import.meta as ImportMeta & { env?: Record<string, string | undefined> }).env;
|
||||
const envToken = env?.VITE_GATEWAY_TOKEN?.trim();
|
||||
if (envToken) return envToken;
|
||||
return "remoteconn-dev-token";
|
||||
}
|
||||
|
||||
function loadSettings(): RuntimeSettings {
|
||||
if (typeof window === "undefined") {
|
||||
return { gatewayUrl: resolveDefaultGatewayUrl(), gatewayToken: resolveDefaultGatewayToken() };
|
||||
}
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return { gatewayUrl: resolveDefaultGatewayUrl(), gatewayToken: resolveDefaultGatewayToken() };
|
||||
}
|
||||
const parsed = JSON.parse(raw) as Partial<RuntimeSettings>;
|
||||
return {
|
||||
gatewayUrl: String(parsed.gatewayUrl ?? resolveDefaultGatewayUrl()),
|
||||
gatewayToken: String(parsed.gatewayToken ?? resolveDefaultGatewayToken())
|
||||
};
|
||||
} catch {
|
||||
return { gatewayUrl: resolveDefaultGatewayUrl(), gatewayToken: resolveDefaultGatewayToken() };
|
||||
}
|
||||
}
|
||||
|
||||
function persistSettings(settings: RuntimeSettings): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||
} catch {
|
||||
// ignore storage unavailability in embedded/private contexts
|
||||
}
|
||||
}
|
||||
|
||||
export const useSettingsStore = defineStore("settings", () => {
|
||||
const settings = ref<RuntimeSettings>({
|
||||
gatewayUrl: resolveDefaultGatewayUrl(),
|
||||
gatewayToken: resolveDefaultGatewayToken()
|
||||
});
|
||||
const loaded = ref(false);
|
||||
let bootstrapPromise: Promise<void> | null = null;
|
||||
|
||||
async function ensureBootstrapped(): Promise<void> {
|
||||
if (loaded.value) return;
|
||||
if (bootstrapPromise) {
|
||||
await bootstrapPromise;
|
||||
return;
|
||||
}
|
||||
bootstrapPromise = (async () => {
|
||||
const runtimeConfig = await loadRuntimeConfig();
|
||||
const localSettings = loadSettings();
|
||||
settings.value = {
|
||||
gatewayUrl: String(runtimeConfig?.gatewayUrl ?? localSettings.gatewayUrl ?? resolveDefaultGatewayUrl()),
|
||||
gatewayToken: String(runtimeConfig?.gatewayToken ?? localSettings.gatewayToken ?? resolveDefaultGatewayToken())
|
||||
};
|
||||
loaded.value = true;
|
||||
})();
|
||||
try {
|
||||
await bootstrapPromise;
|
||||
} finally {
|
||||
bootstrapPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
await ensureBootstrapped();
|
||||
}
|
||||
|
||||
async function save(next: Partial<RuntimeSettings>): Promise<void> {
|
||||
settings.value = {
|
||||
gatewayUrl: String(next.gatewayUrl ?? settings.value.gatewayUrl ?? resolveDefaultGatewayUrl()),
|
||||
gatewayToken: String(next.gatewayToken ?? settings.value.gatewayToken ?? resolveDefaultGatewayToken())
|
||||
};
|
||||
persistSettings(settings.value);
|
||||
}
|
||||
|
||||
const gatewayUrl = computed(() => settings.value.gatewayUrl || resolveDefaultGatewayUrl());
|
||||
const gatewayToken = computed(() => settings.value.gatewayToken || resolveDefaultGatewayToken());
|
||||
|
||||
return {
|
||||
settings,
|
||||
gatewayUrl,
|
||||
gatewayToken,
|
||||
ensureBootstrapped,
|
||||
bootstrap,
|
||||
save
|
||||
};
|
||||
});
|
||||
21
terminal/apps/web/src/style.css
Normal file
21
terminal/apps/web/src/style.css
Normal file
@@ -0,0 +1,21 @@
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
background: #1a1a1a;
|
||||
overscroll-behavior-y: none;
|
||||
}
|
||||
|
||||
body {
|
||||
overflow: hidden;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
484
terminal/apps/web/src/terminal/TerminalPage.vue
Normal file
484
terminal/apps/web/src/terminal/TerminalPage.vue
Normal file
@@ -0,0 +1,484 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* TerminalPage — 新 terminal-core 架构的实验终端页面。
|
||||
*
|
||||
* 职责:
|
||||
* - 编排 TerminalToolbar / TerminalViewport / TerminalInputBar / TerminalTouchTools
|
||||
* - 处理连接/断开流程(从 serverStore 取服务器与凭据)
|
||||
* - BEL → navigator.vibrate / toast
|
||||
* - 标题 OSC → toolbar 展示
|
||||
* - 触控模式检测(viewport 点击 → focus input anchor)
|
||||
* - 软键盘弹出/收回:提示符行置于屏幕距顶 1/4 处,收回时恢复滚动
|
||||
*/
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from "vue";
|
||||
import { InputBridge } from "@remoteconn/terminal-core";
|
||||
import { useTerminalStore } from "@/terminal/stores/useTerminalStore";
|
||||
import { useServerStore } from "@/stores/serverStore";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import TerminalToolbar from "./components/TerminalToolbar.vue";
|
||||
import TerminalViewport from "./components/TerminalViewport.vue";
|
||||
import TerminalInputBar from "./components/TerminalInputBar.vue";
|
||||
import TerminalTouchTools from "./components/TerminalTouchTools.vue";
|
||||
import { KeyboardAdjustController } from "@/terminal/input/keyboardAdjustController";
|
||||
import { formatActionError } from "@/utils/feedback";
|
||||
|
||||
const store = useTerminalStore();
|
||||
const serverStore = useServerStore();
|
||||
const appStore = useAppStore();
|
||||
const pasteBridge = new InputBridge({ cursorKeyMode: "normal", bracketedPaste: true });
|
||||
|
||||
const toolbarWrapRef = ref<HTMLElement | null>(null);
|
||||
const viewportWrapRef = ref<HTMLElement | null>(null);
|
||||
const inputBarRef = ref<InstanceType<typeof TerminalInputBar> | null>(null);
|
||||
|
||||
const AUTO_RECONNECT_KEY = "remoteconn:web:auto-reconnect:v1";
|
||||
const CLIENT_SESSION_KEY_PREFIX = "remoteconn:web:client-session-key:v1:";
|
||||
|
||||
let pageUnloading = false;
|
||||
|
||||
// ── 触控状态 ────────────────────────────────────────────────────────────────
|
||||
let touchStartX = 0;
|
||||
let touchStartY = 0;
|
||||
let touchMoved = false;
|
||||
let touchStartAt = 0;
|
||||
let touchStartScrollTop = 0;
|
||||
let touchDidScroll = false;
|
||||
let lastZoomGuardTapAt = 0;
|
||||
let keyboardLikelyVisible = false;
|
||||
|
||||
const DOUBLE_TAP_ZOOM_GUARD_MS = 320;
|
||||
const TAP_MOVE_THRESHOLD_PX = 12;
|
||||
const TAP_MAX_DURATION_MS = 260;
|
||||
|
||||
// ── 软键盘调整控制器 ─────────────────────────────────────────────────────────
|
||||
let kbAdjust: KeyboardAdjustController | null = null;
|
||||
|
||||
function isDebugEnabled(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
return (
|
||||
window.localStorage.getItem("terminal.debugKeyboardAdjust") === "1" ||
|
||||
window.localStorage.getItem("terminal.debugTouch") === "1"
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function debugTouch(message: string, detail?: unknown): void {
|
||||
if (!isDebugEnabled()) return;
|
||||
const prefix = `[TerminalPage][Touch][${new Date().toISOString()}] ${message}`;
|
||||
detail !== undefined ? console.log(prefix, detail) : console.log(prefix);
|
||||
}
|
||||
|
||||
function isPasteDebugEnabled(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
return (
|
||||
window.localStorage.getItem("terminal.debugPaste") === "1" ||
|
||||
window.localStorage.getItem("terminal.debugTransport") === "1"
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function debugPaste(message: string, detail?: unknown): void {
|
||||
if (!isPasteDebugEnabled()) return;
|
||||
const prefix = `[TerminalPage][Paste][${new Date().toISOString()}] ${message}`;
|
||||
detail !== undefined ? console.log(prefix, detail) : console.log(prefix);
|
||||
}
|
||||
|
||||
function isTouchDevice(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
return window.matchMedia?.("(pointer: coarse)")?.matches ?? ("ontouchstart" in window);
|
||||
}
|
||||
|
||||
// ── 应用光标键模式(从 TerminalCore 读取,随 DECCKM 状态变化) ───────────────
|
||||
const appCursorKeys = ref(false);
|
||||
|
||||
// ── BEL / Title 事件订阅 ──────────────────────────────────────────────────────
|
||||
let offBell: (() => void) | null = null;
|
||||
let offTitle: (() => void) | null = null;
|
||||
|
||||
function subscribeEvents() {
|
||||
const core = store.getCore();
|
||||
if (!core) return;
|
||||
offBell = core.on("bell", () => {
|
||||
if (typeof navigator !== "undefined" && "vibrate" in navigator) {
|
||||
navigator.vibrate(50);
|
||||
}
|
||||
});
|
||||
offTitle = core.on("titleChange", (newTitle: string) => {
|
||||
store.title = newTitle;
|
||||
});
|
||||
}
|
||||
|
||||
function setAutoReconnect(enabled: boolean): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try { window.localStorage.setItem(AUTO_RECONNECT_KEY, enabled ? "1" : "0"); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function shouldAutoReconnect(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
try { return window.localStorage.getItem(AUTO_RECONNECT_KEY) === "1"; } catch { return false; }
|
||||
}
|
||||
|
||||
function getOrCreateClientSessionKey(serverId: string): string {
|
||||
if (typeof window === "undefined") return `session-${Date.now()}`;
|
||||
const storageKey = `${CLIENT_SESSION_KEY_PREFIX}${serverId}`;
|
||||
try {
|
||||
const existing = window.localStorage.getItem(storageKey);
|
||||
if (existing && existing.trim().length > 0) return existing;
|
||||
const next = (typeof crypto !== "undefined" && "randomUUID" in crypto)
|
||||
? crypto.randomUUID()
|
||||
: `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
window.localStorage.setItem(storageKey, next);
|
||||
return next;
|
||||
} catch {
|
||||
return (typeof crypto !== "undefined" && "randomUUID" in crypto)
|
||||
? crypto.randomUUID()
|
||||
: `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 连接 ──────────────────────────────────────────────────────────────────────
|
||||
async function handleConnect(options: { silent?: boolean } = {}): Promise<void> {
|
||||
const { silent = false } = options;
|
||||
const server = serverStore.selectedServer;
|
||||
if (!server) {
|
||||
if (!silent) appStore.notify("warn", "请先在服务器列表中选择目标服务器");
|
||||
return;
|
||||
}
|
||||
|
||||
const coreSnap = store.getCore()?.snapshot();
|
||||
const cols = server.cols ?? coreSnap?.cols ?? 80;
|
||||
const rows = server.rows ?? coreSnap?.rows ?? 24;
|
||||
|
||||
let credential: Awaited<ReturnType<typeof serverStore.resolveCredential>>;
|
||||
try {
|
||||
credential = await serverStore.resolveCredential(server.id);
|
||||
} catch (err) {
|
||||
appStore.notify("error", formatActionError("凭据解析失败", err));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const clientSessionKey = getOrCreateClientSessionKey(server.id);
|
||||
store.bindOutputCache(`${server.id}:${clientSessionKey}`);
|
||||
await store.connect({
|
||||
host: server.host,
|
||||
port: server.port,
|
||||
username: server.username,
|
||||
credential,
|
||||
clientSessionKey,
|
||||
...(server.knownHostFingerprint ? { knownHostFingerprint: server.knownHostFingerprint } : {}),
|
||||
cols,
|
||||
rows
|
||||
});
|
||||
setAutoReconnect(true);
|
||||
|
||||
// 连接后订阅 core 事件
|
||||
subscribeEvents();
|
||||
await nextTick();
|
||||
inputBarRef.value?.focus();
|
||||
} catch (err) {
|
||||
if (!silent) appStore.notify("error", formatActionError("连接失败", err));
|
||||
}
|
||||
}
|
||||
|
||||
function handleDisconnect(): void {
|
||||
setAutoReconnect(false);
|
||||
store.disconnect("manual");
|
||||
}
|
||||
|
||||
// ── 粘贴 ──────────────────────────────────────────────────────────────────────
|
||||
async function handlePaste(): Promise<void> {
|
||||
if (store.state !== "connected") return;
|
||||
debugPaste("paste:triggered");
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
debugPaste("paste:clipboard_read", { length: text.length });
|
||||
if (text) {
|
||||
const seq = pasteBridge.mapPaste(text);
|
||||
store.sendInput(seq, "paste");
|
||||
debugPaste("paste:dispatched", { normalizedLength: seq.length });
|
||||
}
|
||||
} catch {
|
||||
debugPaste("paste:clipboard_read_failed");
|
||||
appStore.notify("warn", "无法读取剪贴板,请手动粘贴");
|
||||
}
|
||||
}
|
||||
|
||||
// ── 输出区滚动元素 ────────────────────────────────────────────────────────────
|
||||
function getOutputScrollEl(): HTMLElement | null {
|
||||
return store.getOutputEl()
|
||||
?? (viewportWrapRef.value?.querySelector(".tc-output") as HTMLElement | null ?? null);
|
||||
}
|
||||
|
||||
// ── 视口点击 → 聚焦输入锚点 ─────────────────────────────────────────────────
|
||||
function focusInputAnchor(options?: { source?: "tap" | "click" }) {
|
||||
if (store.state !== "connected") return;
|
||||
const selection = typeof window !== "undefined" ? window.getSelection() : null;
|
||||
if (selection && !selection.isCollapsed && selection.toString().length > 0) return;
|
||||
|
||||
const activeEl = document.activeElement as HTMLElement | null;
|
||||
const alreadyAnchorFocused =
|
||||
activeEl?.tagName === "TEXTAREA" && activeEl.classList.contains("tc-input-anchor");
|
||||
|
||||
debugTouch("focus:request", {
|
||||
source: options?.source ?? "unknown",
|
||||
alreadyAnchorFocused,
|
||||
keyboardLikelyVisible,
|
||||
state: store.state,
|
||||
});
|
||||
|
||||
// 已聚焦且软键盘可见时不重复 focus(避免键盘闪退)
|
||||
if (alreadyAnchorFocused && keyboardLikelyVisible) return;
|
||||
inputBarRef.value?.focus();
|
||||
}
|
||||
|
||||
// ── 点击处理(桌面) ─────────────────────────────────────────────────────────
|
||||
function handleViewportClick(e: MouseEvent) {
|
||||
if (isTouchDevice()) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
focusInputAnchor({ source: "click" });
|
||||
}
|
||||
|
||||
// ── 触控处理 ─────────────────────────────────────────────────────────────────
|
||||
function handleViewportTouchStart(e: TouchEvent) {
|
||||
const touch = e.touches[0] ?? e.changedTouches[0];
|
||||
if (!touch) return;
|
||||
touchStartX = touch.clientX;
|
||||
touchStartY = touch.clientY;
|
||||
touchStartAt = Date.now();
|
||||
touchMoved = false;
|
||||
touchDidScroll = false;
|
||||
const scrollEl = getOutputScrollEl();
|
||||
touchStartScrollTop = scrollEl?.scrollTop ?? 0;
|
||||
debugTouch("event:touchstart", { x: touchStartX, y: touchStartY, touchStartScrollTop });
|
||||
}
|
||||
|
||||
function handleViewportTouchMove(e: TouchEvent) {
|
||||
const touch = e.touches[0] ?? e.changedTouches[0];
|
||||
if (!touch) return;
|
||||
const dx = Math.abs(touch.clientX - touchStartX);
|
||||
const dy = Math.abs(touch.clientY - touchStartY);
|
||||
if (dx > TAP_MOVE_THRESHOLD_PX || dy > TAP_MOVE_THRESHOLD_PX) {
|
||||
touchMoved = true;
|
||||
}
|
||||
|
||||
// 兜底滚动:部分机型/输入法场景原生滚动不稳定,手动驱动输出区纵向滚动
|
||||
const scrollEl = getOutputScrollEl();
|
||||
if (!scrollEl) return;
|
||||
const maxTop = Math.max(0, scrollEl.scrollHeight - scrollEl.clientHeight);
|
||||
if (maxTop <= 0) return;
|
||||
|
||||
const deltaY = touch.clientY - touchStartY;
|
||||
const nextTop = Math.max(0, Math.min(maxTop, touchStartScrollTop - deltaY));
|
||||
if (Math.abs(nextTop - scrollEl.scrollTop) < 1) return;
|
||||
|
||||
scrollEl.scrollTop = nextTop;
|
||||
touchDidScroll = true;
|
||||
e.preventDefault();
|
||||
debugTouch("event:touchmove_scroll", { deltaY, nextTop, maxTop });
|
||||
}
|
||||
|
||||
function handleViewportTouchEnd(e: TouchEvent) {
|
||||
const duration = Date.now() - touchStartAt;
|
||||
debugTouch("event:touchend", { touchMoved, touchDidScroll, duration, keyboardLikelyVisible });
|
||||
|
||||
if (touchDidScroll) return;
|
||||
if (touchMoved) return;
|
||||
if (duration > TAP_MAX_DURATION_MS) return;
|
||||
|
||||
// 轻触:阻止合成 click,无延迟聚焦以保持用户手势上下文(移动端弹键盘必须在用户手势内)
|
||||
e.preventDefault();
|
||||
debugTouch("event:tap_focus");
|
||||
focusInputAnchor({ source: "tap" });
|
||||
}
|
||||
|
||||
// ── 防双击缩放(工具栏/输入区) ───────────────────────────────────────────────
|
||||
function handlePageTouchEndCapture(e: TouchEvent) {
|
||||
const touch = e.changedTouches[0] ?? e.touches[0];
|
||||
if (!touch) return;
|
||||
const target = e.target as HTMLElement | null;
|
||||
|
||||
const toolbarBottom = toolbarWrapRef.value?.getBoundingClientRect().bottom ?? 0;
|
||||
const inToolbarBand = touch.clientY <= toolbarBottom + 8;
|
||||
const inInputZone = Boolean(target?.closest('[data-zone="native-input-zone"]'));
|
||||
if (!inToolbarBand && !inInputZone) return;
|
||||
|
||||
const now = Date.now();
|
||||
const dt = now - lastZoomGuardTapAt;
|
||||
lastZoomGuardTapAt = now;
|
||||
if (dt > DOUBLE_TAP_ZOOM_GUARD_MS) return;
|
||||
|
||||
e.preventDefault();
|
||||
debugTouch("event:double_tap_zoom_guard", { dt });
|
||||
}
|
||||
|
||||
// ── 软键盘状态同步(供 focusInputAnchor 判断) ────────────────────────────────
|
||||
// KeyboardAdjustController 通过 visualViewport resize 驱动实际 DOM 滚动。
|
||||
// 此处仅维护 keyboardLikelyVisible 标志供聚焦逻辑使用。
|
||||
const KEYBOARD_OPEN_THRESHOLD_PX = 120;
|
||||
const KEYBOARD_CLOSE_HYSTERESIS_PX = 40;
|
||||
let baselineVpHeight = 0;
|
||||
|
||||
function onVisualViewportResize() {
|
||||
const vv = window.visualViewport;
|
||||
const h = vv ? vv.height : window.innerHeight;
|
||||
if (h <= 0) return;
|
||||
|
||||
if (!keyboardLikelyVisible && h > baselineVpHeight) {
|
||||
baselineVpHeight = h;
|
||||
}
|
||||
|
||||
const shrink = baselineVpHeight - h;
|
||||
|
||||
if (!keyboardLikelyVisible && shrink > KEYBOARD_OPEN_THRESHOLD_PX) {
|
||||
keyboardLikelyVisible = true;
|
||||
debugTouch("keyboard:open_flag_set", { baseline: baselineVpHeight, current: h, shrink });
|
||||
return;
|
||||
}
|
||||
|
||||
if (keyboardLikelyVisible && h >= baselineVpHeight - KEYBOARD_CLOSE_HYSTERESIS_PX) {
|
||||
keyboardLikelyVisible = false;
|
||||
debugTouch("keyboard:close_flag_cleared", { baseline: baselineVpHeight, current: h });
|
||||
}
|
||||
}
|
||||
|
||||
const onPageUnload = () => { pageUnloading = true; };
|
||||
|
||||
// ── 生命周期 ──────────────────────────────────────────────────────────────────
|
||||
onMounted(async () => {
|
||||
await serverStore.ensureBootstrapped();
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("pagehide", onPageUnload);
|
||||
window.addEventListener("beforeunload", onPageUnload);
|
||||
|
||||
const vv = window.visualViewport;
|
||||
baselineVpHeight = vv ? vv.height : window.innerHeight;
|
||||
|
||||
// 软键盘调整控制器
|
||||
kbAdjust = new KeyboardAdjustController({
|
||||
getOutputEl: () => store.getOutputEl(),
|
||||
getCursorOffsetPx: () => store.getCursorOffsetPx(),
|
||||
setAutoFollow: (enabled) => store.setAutoFollow(enabled),
|
||||
setScrollLocked: (locked) => store.setScrollLocked(locked),
|
||||
suppressAutoFollow: (ms) => store.suppressAutoFollow(ms),
|
||||
debug: isDebugEnabled(),
|
||||
});
|
||||
kbAdjust.mount();
|
||||
|
||||
// 同步 keyboardLikelyVisible 标志
|
||||
if (vv) {
|
||||
vv.addEventListener("resize", onVisualViewportResize);
|
||||
} else {
|
||||
window.addEventListener("resize", onVisualViewportResize);
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldAutoReconnect() && serverStore.selectedServer) {
|
||||
await handleConnect({ silent: true });
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => store.state, async (state) => {
|
||||
if (state === "connected") {
|
||||
await nextTick();
|
||||
inputBarRef.value?.focus();
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => store.rendererMode, async () => {
|
||||
if (store.state === "connected") {
|
||||
await nextTick();
|
||||
inputBarRef.value?.focus();
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
offBell?.();
|
||||
offTitle?.();
|
||||
offBell = null;
|
||||
offTitle = null;
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.removeEventListener("pagehide", onPageUnload);
|
||||
window.removeEventListener("beforeunload", onPageUnload);
|
||||
|
||||
const vv = window.visualViewport;
|
||||
if (vv) {
|
||||
vv.removeEventListener("resize", onVisualViewportResize);
|
||||
} else {
|
||||
window.removeEventListener("resize", onVisualViewportResize);
|
||||
}
|
||||
}
|
||||
|
||||
kbAdjust?.forceReset();
|
||||
kbAdjust = null;
|
||||
|
||||
if (!pageUnloading) {
|
||||
store.disconnect("leave_terminal_page");
|
||||
}
|
||||
});
|
||||
|
||||
// ── 计算属性 ──────────────────────────────────────────────────────────────────
|
||||
const isMobile = computed(() =>
|
||||
typeof navigator !== "undefined" &&
|
||||
/Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent)
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="tc-page" @touchend.capture="handlePageTouchEndCapture">
|
||||
<!-- 工具栏 -->
|
||||
<div ref="toolbarWrapRef">
|
||||
<TerminalToolbar @connect="handleConnect" @disconnect="handleDisconnect" />
|
||||
</div>
|
||||
|
||||
<!-- 终端视口 -->
|
||||
<div
|
||||
ref="viewportWrapRef"
|
||||
class="tc-viewport-wrap"
|
||||
@click.stop="handleViewportClick"
|
||||
@touchstart.passive="handleViewportTouchStart"
|
||||
@touchmove="handleViewportTouchMove"
|
||||
@touchend.stop="handleViewportTouchEnd"
|
||||
>
|
||||
<TerminalViewport />
|
||||
</div>
|
||||
|
||||
<!-- 触控辅助按键(移动端显示) -->
|
||||
<TerminalTouchTools
|
||||
v-if="isMobile"
|
||||
:application-cursor-keys="appCursorKeys"
|
||||
@paste="handlePaste"
|
||||
/>
|
||||
|
||||
<!-- 输入栏 -->
|
||||
<TerminalInputBar ref="inputBarRef" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tc-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--tc-bg, #1a1a1a);
|
||||
}
|
||||
|
||||
.tc-viewport-wrap {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
cursor: text;
|
||||
}
|
||||
</style>
|
||||
229
terminal/apps/web/src/terminal/components/TerminalInputBar.vue
Normal file
229
terminal/apps/web/src/terminal/components/TerminalInputBar.vue
Normal file
@@ -0,0 +1,229 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* TerminalInputBar — 原生 textarea 输入锚点 + 工具栏输入行。
|
||||
*
|
||||
* 职责:
|
||||
* - 承载软键盘弹出的原生 textarea(IME 主通道)
|
||||
* - 通过 DomInputBridge + InputBridge 将键盘事件映射为 VT 字节流
|
||||
* - 提供手动发送文本按钮(移动端用途)
|
||||
*/
|
||||
import { ref, onMounted, onUnmounted } from "vue";
|
||||
import { InputBridge } from "@remoteconn/terminal-core";
|
||||
import { DomInputBridge } from "@/terminal/input/domInputBridge";
|
||||
import { DomImeController } from "@/terminal/input/domImeController";
|
||||
import { shouldHandleKeydownDirectly } from "@/terminal/input/inputPolicy";
|
||||
import { useTerminalStore } from "@/terminal/stores/useTerminalStore";
|
||||
|
||||
const store = useTerminalStore();
|
||||
const inputRef = ref<HTMLTextAreaElement | null>(null);
|
||||
const emit = defineEmits<{
|
||||
(e: "anchor-focus"): void;
|
||||
(e: "anchor-blur"): void;
|
||||
}>();
|
||||
|
||||
let domBridge: DomInputBridge | null = null;
|
||||
let imeCtrl: DomImeController | null = null;
|
||||
// 多行粘贴统一走 bracketed paste,避免换行被 shell 解释为直接执行。
|
||||
const inputBridge = new InputBridge({ cursorKeyMode: "normal", bracketedPaste: true });
|
||||
const anchorCleanups: Array<() => void> = [];
|
||||
|
||||
function isInputDebugEnabled(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
return window.localStorage.getItem("terminal.debugInputAnchor") === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function debugInput(message: string, detail?: unknown): void {
|
||||
if (!isInputDebugEnabled()) return;
|
||||
const prefix = `[TerminalInputBar][Anchor][${new Date().toISOString()}] ${message}`;
|
||||
if (typeof detail === "undefined") {
|
||||
console.log(prefix);
|
||||
return;
|
||||
}
|
||||
console.log(prefix, detail);
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
for (const fn of anchorCleanups) fn();
|
||||
anchorCleanups.length = 0;
|
||||
imeCtrl?.dispose();
|
||||
domBridge?.dispose();
|
||||
imeCtrl = null;
|
||||
domBridge = null;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const el = inputRef.value;
|
||||
if (!el) return;
|
||||
|
||||
const onFocus = () => {
|
||||
emit("anchor-focus");
|
||||
debugInput("event:focus", {
|
||||
readonly: el.readOnly,
|
||||
disabled: el.disabled,
|
||||
valueLen: el.value.length,
|
||||
activeTag: (document.activeElement as HTMLElement | null)?.tagName ?? "null",
|
||||
activeClass: (document.activeElement as HTMLElement | null)?.className ?? "",
|
||||
});
|
||||
};
|
||||
const onBlur = () => {
|
||||
emit("anchor-blur");
|
||||
debugInput("event:blur", {
|
||||
readonly: el.readOnly,
|
||||
disabled: el.disabled,
|
||||
valueLen: el.value.length,
|
||||
activeTag: (document.activeElement as HTMLElement | null)?.tagName ?? "null",
|
||||
activeClass: (document.activeElement as HTMLElement | null)?.className ?? "",
|
||||
});
|
||||
};
|
||||
el.addEventListener("focus", onFocus);
|
||||
el.addEventListener("blur", onBlur);
|
||||
anchorCleanups.push(() => {
|
||||
el.removeEventListener("focus", onFocus);
|
||||
el.removeEventListener("blur", onBlur);
|
||||
});
|
||||
|
||||
domBridge = new DomInputBridge();
|
||||
domBridge.mount(el);
|
||||
|
||||
imeCtrl = new DomImeController();
|
||||
imeCtrl.connect(domBridge);
|
||||
|
||||
// ── 键盘事件处理 ────────────────────────────────────────────────────────
|
||||
domBridge.on("key", (p) => {
|
||||
if (store.state !== "connected") return;
|
||||
|
||||
// 文本输入(含第三方输入法 key="测试"/"jk")统一走 input,避免双发。
|
||||
if (!shouldHandleKeydownDirectly(p)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+C 无选区时发 ETX
|
||||
if ((p.ctrlKey || p.metaKey) && p.key === "c") {
|
||||
const sel = window.getSelection()?.toString() ?? "";
|
||||
if (!sel) {
|
||||
store.sendInput("\u0003", "keyboard");
|
||||
} else {
|
||||
document.execCommand("copy");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+V / Cmd+V 粘贴
|
||||
if ((p.ctrlKey || p.metaKey) && p.key === "v") {
|
||||
// 由 paste 事件处理,此处阻止
|
||||
return;
|
||||
}
|
||||
|
||||
const seq = inputBridge.mapKey(p.key, p.code, p.ctrlKey, p.altKey, p.shiftKey, p.metaKey);
|
||||
if (seq !== null) {
|
||||
store.sendInput(seq, "keyboard");
|
||||
}
|
||||
});
|
||||
|
||||
// ── input 事件(处理未被 keydown 拦截的 ASCII + IME 非组合落笔) ────────
|
||||
domBridge.on("input", (p) => {
|
||||
if (p.isComposing) return; // 组合中由 compositionend 处理
|
||||
if (imeCtrl?.core.shouldConsumeInputEvent()) return;
|
||||
if (store.state !== "connected") return;
|
||||
|
||||
if (p.data) {
|
||||
store.sendInput(p.data, "keyboard");
|
||||
}
|
||||
|
||||
// 清空 textarea 防止回显积累
|
||||
const el = inputRef.value;
|
||||
if (el) el.value = "";
|
||||
});
|
||||
|
||||
// ── 粘贴事件 ──────────────────────────────────────────────────────────────
|
||||
domBridge.on("paste", (p) => {
|
||||
if (store.state !== "connected") return;
|
||||
const seq = inputBridge.mapPaste(p.text);
|
||||
store.sendInput(seq, "paste");
|
||||
});
|
||||
|
||||
// ── IME compositionend → 提交候选词 ──────────────────────────────────────
|
||||
domBridge.on("compositionend", (p) => {
|
||||
if (store.state !== "connected") return;
|
||||
if (p.data) {
|
||||
store.sendInput(p.data, "assist");
|
||||
}
|
||||
const el = inputRef.value;
|
||||
if (el) el.value = "";
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
dispose();
|
||||
});
|
||||
|
||||
/** 外部调用:将输入锚点聚焦以弹出软键盘 */
|
||||
function focus() {
|
||||
debugInput("api:focus");
|
||||
inputRef.value?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
defineExpose({ focus });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tc-input-bar" data-zone="native-input-zone">
|
||||
<!-- 原生输入锚点(VT 键盘主通道,对用户不可见) -->
|
||||
<textarea
|
||||
ref="inputRef"
|
||||
class="tc-input-anchor"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
aria-label="终端键盘输入"
|
||||
:readonly="store.state !== 'connected'"
|
||||
rows="1"
|
||||
></textarea>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tc-input-bar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: visible;
|
||||
opacity: 0.01;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* 隐藏的 VT 键盘锚点 */
|
||||
.tc-input-anchor {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
overflow: hidden;
|
||||
opacity: 0.01;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
resize: none;
|
||||
background: transparent;
|
||||
color: transparent;
|
||||
caret-color: transparent;
|
||||
-webkit-appearance: none;
|
||||
/*
|
||||
* 将焦点锚点放在顶部,避免部分移动端浏览器在软键盘弹出时
|
||||
* 为“底部焦点元素可见”而触发布局上推,导致工具条/光标瞬时跳位。
|
||||
*/
|
||||
transform: translateZ(0);
|
||||
}
|
||||
</style>
|
||||
148
terminal/apps/web/src/terminal/components/TerminalToolbar.vue
Normal file
148
terminal/apps/web/src/terminal/components/TerminalToolbar.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useTerminalStore } from "@/terminal/stores/useTerminalStore";
|
||||
|
||||
const store = useTerminalStore();
|
||||
|
||||
const isConnected = computed(() => store.state === "connected");
|
||||
const isConnecting = computed(() => store.state === "connecting" || store.state === "auth_pending" || store.state === "reconnecting");
|
||||
const canReconnect = computed(() => store.state === "disconnected" || store.state === "error");
|
||||
const canDisconnect = computed(() => isConnected.value || isConnecting.value);
|
||||
|
||||
const connectionLabel = computed(() => {
|
||||
if (canReconnect.value) return "重连";
|
||||
if (canDisconnect.value) return "断开";
|
||||
return "连接";
|
||||
});
|
||||
|
||||
const connectionDisabled = computed(() => false);
|
||||
|
||||
function handleClear() {
|
||||
store.clearTerminal();
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "connect"): void;
|
||||
(e: "disconnect"): void;
|
||||
}>();
|
||||
|
||||
function handleConnectionAction() {
|
||||
if (canDisconnect.value) {
|
||||
emit("disconnect");
|
||||
} else {
|
||||
emit("connect");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-toolbar terminal-toolbar tc-toolbar" @dblclick.prevent>
|
||||
<div class="toolbar-left">
|
||||
<button
|
||||
class="icon-btn"
|
||||
type="button"
|
||||
title="清屏"
|
||||
aria-label="清屏"
|
||||
@click="handleClear"
|
||||
>
|
||||
<span aria-hidden="true">⌫</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-spacer"></div>
|
||||
|
||||
<div class="terminal-toolbar-actions">
|
||||
<!-- 标题 -->
|
||||
<h2 class="page-title terminal-title" :title="store.title">
|
||||
{{ store.title || "Terminal" }}
|
||||
</h2>
|
||||
<!-- 状态 -->
|
||||
<span class="state-chip" :class="`state-${store.state}`">{{ store.state }}</span>
|
||||
<!-- 延迟 -->
|
||||
<span v-if="store.latencyMs > 0" class="state-chip">{{ store.latencyMs }}ms</span>
|
||||
<span class="terminal-toolbar-divider" aria-hidden="true"></span>
|
||||
<!-- 连接/断开 -->
|
||||
<button
|
||||
class="terminal-connection-switch"
|
||||
:class="canReconnect ? 'is-reconnect' : 'is-disconnect'"
|
||||
:disabled="connectionDisabled"
|
||||
:aria-label="connectionLabel"
|
||||
@click="handleConnectionAction"
|
||||
>
|
||||
<span class="terminal-connection-switch-label">{{ connectionLabel }}</span>
|
||||
<span class="terminal-connection-switch-knob" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tc-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid #333;
|
||||
background: #1f1f1f;
|
||||
color: #e6e6e6;
|
||||
flex-shrink: 0;
|
||||
/* 禁止连续轻点触发浏览器双击放大 */
|
||||
touch-action: manipulation;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.toolbar-left,
|
||||
.terminal-toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar-spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.icon-btn,
|
||||
.terminal-connection-switch {
|
||||
border: 1px solid #4a4a4a;
|
||||
background: #2a2a2a;
|
||||
color: #e6e6e6;
|
||||
border-radius: 6px;
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.icon-btn:disabled,
|
||||
.terminal-connection-switch:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #e6e6e6;
|
||||
}
|
||||
|
||||
.state-chip {
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
color: #d5d5d5;
|
||||
background: #2b2b2b;
|
||||
}
|
||||
|
||||
.terminal-toolbar-divider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: #444;
|
||||
}
|
||||
</style>
|
||||
191
terminal/apps/web/src/terminal/components/TerminalTouchTools.vue
Normal file
191
terminal/apps/web/src/terminal/components/TerminalTouchTools.vue
Normal file
@@ -0,0 +1,191 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* TerminalTouchTools — 移动端触控辅助按键区。
|
||||
*
|
||||
* 职责:
|
||||
* - 提供方向键、Enter、Ctrl+C、Tab、Paste 等常用控制键
|
||||
* - 输出标准 VT 控制序列,不直接访问 Transport
|
||||
* - 通过 store.sendInput() 发送
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
import { useTerminalStore } from "@/terminal/stores/useTerminalStore";
|
||||
|
||||
const props = defineProps<{
|
||||
/** 是否使用应用光标键模式(DECCKM 激活时为 true) */
|
||||
applicationCursorKeys?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "paste"): void;
|
||||
}>();
|
||||
|
||||
const store = useTerminalStore();
|
||||
const disabled = computed(() => store.state !== "connected");
|
||||
|
||||
/** 发送 VT 控制序列;source 固定为 keyboard,以复用 meta 标记 */
|
||||
function send(seq: string) {
|
||||
if (disabled.value) return;
|
||||
store.sendInput(seq, "keyboard");
|
||||
}
|
||||
|
||||
const arrowUp = computed(() => props.applicationCursorKeys ? "\x1bOA" : "\x1b[A");
|
||||
const arrowDown = computed(() => props.applicationCursorKeys ? "\x1bOB" : "\x1b[B");
|
||||
const arrowRight = computed(() => props.applicationCursorKeys ? "\x1bOC" : "\x1b[C");
|
||||
const arrowLeft = computed(() => props.applicationCursorKeys ? "\x1bOD" : "\x1b[D");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tc-touch-tools" role="toolbar" aria-label="终端触控辅助工具栏">
|
||||
<!-- 方向键 -->
|
||||
<button
|
||||
class="tc-key-btn"
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
aria-label="上箭头"
|
||||
@click="send(arrowUp)"
|
||||
>▲</button>
|
||||
<button
|
||||
class="tc-key-btn"
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
aria-label="下箭头"
|
||||
@click="send(arrowDown)"
|
||||
>▼</button>
|
||||
<button
|
||||
class="tc-key-btn"
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
aria-label="左箭头"
|
||||
@click="send(arrowLeft)"
|
||||
>◀</button>
|
||||
<button
|
||||
class="tc-key-btn"
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
aria-label="右箭头"
|
||||
@click="send(arrowRight)"
|
||||
>▶</button>
|
||||
|
||||
<div class="tc-key-separator" aria-hidden="true"></div>
|
||||
|
||||
<!-- Tab -->
|
||||
<button
|
||||
class="tc-key-btn"
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
aria-label="Tab"
|
||||
@click="send('\t')"
|
||||
>Tab</button>
|
||||
|
||||
<!-- Enter (CR) -->
|
||||
<button
|
||||
class="tc-key-btn tc-key-enter"
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
aria-label="Enter"
|
||||
@click="send('\r')"
|
||||
>↵</button>
|
||||
|
||||
<!-- Ctrl+C -->
|
||||
<button
|
||||
class="tc-key-btn"
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
aria-label="Ctrl+C 中断"
|
||||
@click="send('\u0003')"
|
||||
>^C</button>
|
||||
|
||||
<!-- Ctrl+D -->
|
||||
<button
|
||||
class="tc-key-btn"
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
aria-label="Ctrl+D EOF"
|
||||
@click="send('\u0004')"
|
||||
>^D</button>
|
||||
|
||||
<!-- Esc -->
|
||||
<button
|
||||
class="tc-key-btn"
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
aria-label="Esc"
|
||||
@click="send('\x1b')"
|
||||
>Esc</button>
|
||||
|
||||
<div class="tc-key-separator" aria-hidden="true"></div>
|
||||
|
||||
<!-- 粘贴(调用外部 paste 逻辑) -->
|
||||
<button
|
||||
class="tc-key-btn"
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
aria-label="粘贴"
|
||||
@click="emit('paste')"
|
||||
>粘贴</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tc-touch-tools {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
background: var(--color-surface-1, #1e1e1e);
|
||||
border-top: 1px solid var(--color-border, #333);
|
||||
}
|
||||
|
||||
.tc-touch-tools::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tc-key-btn {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 40px;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--color-border, #444);
|
||||
border-radius: 5px;
|
||||
background: var(--color-surface-2, #2a2a2a);
|
||||
color: var(--color-text, #d4d4d4);
|
||||
font-size: 13px;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.tc-key-btn:active {
|
||||
background: var(--color-surface-3, #3a3a3a);
|
||||
}
|
||||
|
||||
.tc-key-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.tc-key-enter {
|
||||
min-width: 48px;
|
||||
background: var(--color-accent-dim, #1e3a5f);
|
||||
border-color: var(--color-accent, #4a9eff);
|
||||
color: var(--color-accent, #4a9eff);
|
||||
}
|
||||
|
||||
.tc-key-separator {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: var(--color-border, #444);
|
||||
flex-shrink: 0;
|
||||
margin: 0 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from "vue";
|
||||
import { calcSize } from "@remoteconn/terminal-core";
|
||||
import { DomMeasureAdapter } from "@/terminal/layout/domMeasureAdapter";
|
||||
import { useTerminalStore } from "@/terminal/stores/useTerminalStore";
|
||||
|
||||
const store = useTerminalStore();
|
||||
const viewportRef = ref<HTMLElement | null>(null);
|
||||
|
||||
let measure: DomMeasureAdapter | null = null;
|
||||
let offResize: (() => void) | null = null;
|
||||
/** 初次 fit 重试次数上限(应对路由切换延迟布局) */
|
||||
const MAX_FIT_RETRIES = 8;
|
||||
let fitRetryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let fitRetryCount = 0;
|
||||
|
||||
function doFit() {
|
||||
if (!measure) return;
|
||||
|
||||
const { widthPx: charW, heightPx: lineH } = measure.measureChar();
|
||||
const { widthPx: contW, heightPx: contH } = measure.measureContainer();
|
||||
|
||||
if (contW < 10 || contH < 10) {
|
||||
// 容器未就绪,重试
|
||||
if (fitRetryCount < MAX_FIT_RETRIES) {
|
||||
fitRetryCount++;
|
||||
fitRetryTimer = setTimeout(doFit, 120);
|
||||
}
|
||||
return;
|
||||
}
|
||||
fitRetryCount = 0;
|
||||
|
||||
const { cols, rows } = calcSize(contW, contH, charW, lineH);
|
||||
store.resizeTerminal(cols, Math.max(rows, 12));
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const el = viewportRef.value;
|
||||
if (!el) return;
|
||||
|
||||
measure = new DomMeasureAdapter(el, { fontFamily: "monospace", fontSize: "14px" });
|
||||
measure.mount();
|
||||
|
||||
// 挂载渲染器
|
||||
store.mountRenderer(el);
|
||||
|
||||
// 订阅 resize
|
||||
offResize = measure.onResize(() => {
|
||||
doFit();
|
||||
});
|
||||
|
||||
// 首次 fit(多次重试保障)
|
||||
doFit();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
offResize?.();
|
||||
offResize = null;
|
||||
if (fitRetryTimer !== null) {
|
||||
clearTimeout(fitRetryTimer);
|
||||
fitRetryTimer = null;
|
||||
}
|
||||
measure?.dispose();
|
||||
measure = null;
|
||||
// 渲染器 dispose 由 store 管理;此处仅退出测量
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="viewportRef"
|
||||
class="tc-viewport-root"
|
||||
data-zone="terminal-output-zone"
|
||||
aria-label="终端视口"
|
||||
role="region"
|
||||
></div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tc-viewport-root {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: var(--tc-bg, #1a1a1a);
|
||||
color: var(--tc-fg, #d4d4d4);
|
||||
font-family: "JetBrains Mono", "Cascadia Code", "Fira Mono", "Menlo", monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.2;
|
||||
cursor: text;
|
||||
}
|
||||
</style>
|
||||
37
terminal/apps/web/src/terminal/input/domImeController.ts
Normal file
37
terminal/apps/web/src/terminal/input/domImeController.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { ImeController } from "@remoteconn/terminal-core";
|
||||
import type { DomInputBridge } from "./domInputBridge";
|
||||
|
||||
/**
|
||||
* DomImeController — 将 DOM compositionstart/end 事件桥接到 ImeController。
|
||||
* 必须在 DomInputBridge.mount() 之后调用 connect()。
|
||||
*/
|
||||
export class DomImeController {
|
||||
public readonly core: ImeController;
|
||||
private cleanup: (() => void)[] = [];
|
||||
|
||||
constructor() {
|
||||
this.core = new ImeController(
|
||||
(fn, ms) => setTimeout(fn, ms),
|
||||
(id) => clearTimeout(id)
|
||||
);
|
||||
}
|
||||
|
||||
/** 将 inputBridge 的 composition 事件接入 ImeController */
|
||||
connect(bridge: DomInputBridge): void {
|
||||
this.cleanup.push(
|
||||
bridge.on("compositionstart", ({ data }) => {
|
||||
this.core.onCompositionStart(data);
|
||||
}),
|
||||
bridge.on("compositionend", ({ data }) => {
|
||||
this.core.onCompositionEnd(data);
|
||||
// compositionend 后的 input 事件由 ImeController.shouldConsumeInputEvent 拦截
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const fn of this.cleanup) fn();
|
||||
this.cleanup = [];
|
||||
this.core.reset();
|
||||
}
|
||||
}
|
||||
84
terminal/apps/web/src/terminal/input/domInputBridge.ts
Normal file
84
terminal/apps/web/src/terminal/input/domInputBridge.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { IInputSource, InputEventMap } from "@remoteconn/terminal-core";
|
||||
|
||||
type ListenerMap = { [K in keyof InputEventMap]?: Set<(payload: InputEventMap[K]) => void> };
|
||||
|
||||
/**
|
||||
* DomInputBridge — 将 DOM 键盘/input/paste/composition 事件适配为 IInputSource。
|
||||
* 挂载到 textarea 输入锚点元素上。
|
||||
*/
|
||||
export class DomInputBridge implements IInputSource {
|
||||
private el: HTMLElement | null = null;
|
||||
private listeners: ListenerMap = {};
|
||||
private domCleanups: (() => void)[] = [];
|
||||
|
||||
mount(element: HTMLElement): void {
|
||||
this.el = element;
|
||||
const add = <K extends keyof HTMLElementEventMap>(
|
||||
type: K,
|
||||
handler: (e: HTMLElementEventMap[K]) => void,
|
||||
opts?: AddEventListenerOptions
|
||||
) => {
|
||||
element.addEventListener(type, handler as EventListener, opts);
|
||||
this.domCleanups.push(() => element.removeEventListener(type, handler as EventListener, opts));
|
||||
};
|
||||
|
||||
add("keydown", (e) => {
|
||||
if (e.isComposing) return;
|
||||
this.emit("key", {
|
||||
key: e.key,
|
||||
code: e.code,
|
||||
ctrlKey: e.ctrlKey,
|
||||
altKey: e.altKey,
|
||||
shiftKey: e.shiftKey,
|
||||
metaKey: e.metaKey,
|
||||
isComposing: e.isComposing,
|
||||
});
|
||||
});
|
||||
|
||||
add("input", (e) => {
|
||||
const ie = e as InputEvent;
|
||||
this.emit("input", {
|
||||
data: ie.data ?? "",
|
||||
isComposing: ie.isComposing,
|
||||
});
|
||||
});
|
||||
|
||||
add("paste", (e) => {
|
||||
const pe = e as ClipboardEvent;
|
||||
const text = pe.clipboardData?.getData("text") ?? "";
|
||||
if (text) {
|
||||
pe.preventDefault();
|
||||
this.emit("paste", { text });
|
||||
}
|
||||
});
|
||||
|
||||
add("compositionstart", (e) => {
|
||||
this.emit("compositionstart", { data: (e as CompositionEvent).data ?? "" });
|
||||
});
|
||||
|
||||
add("compositionend", (e) => {
|
||||
this.emit("compositionend", { data: (e as CompositionEvent).data ?? "" });
|
||||
});
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const cleanup of this.domCleanups) cleanup();
|
||||
this.domCleanups = [];
|
||||
this.el = null;
|
||||
}
|
||||
|
||||
on<K extends keyof InputEventMap>(event: K, cb: (payload: InputEventMap[K]) => void): () => void {
|
||||
if (!this.listeners[event]) {
|
||||
(this.listeners as Record<string, Set<unknown>>)[event] = new Set();
|
||||
}
|
||||
const set = this.listeners[event] as Set<(p: InputEventMap[K]) => void>;
|
||||
set.add(cb);
|
||||
return () => set.delete(cb);
|
||||
}
|
||||
|
||||
private emit<K extends keyof InputEventMap>(event: K, payload: InputEventMap[K]): void {
|
||||
const set = this.listeners[event] as Set<(p: InputEventMap[K]) => void> | undefined;
|
||||
if (!set) return;
|
||||
for (const fn of set) fn(payload);
|
||||
}
|
||||
}
|
||||
39
terminal/apps/web/src/terminal/input/inputPolicy.test.ts
Normal file
39
terminal/apps/web/src/terminal/input/inputPolicy.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldHandleKeydownDirectly } from "./inputPolicy";
|
||||
|
||||
function makePayload(overrides: Partial<{ key: string; ctrlKey: boolean; altKey: boolean; metaKey: boolean }> = {}) {
|
||||
return {
|
||||
key: "",
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
metaKey: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("shouldHandleKeydownDirectly", () => {
|
||||
it("文本按键应返回 false(普通英文)", () => {
|
||||
expect(shouldHandleKeydownDirectly(makePayload({ key: "a" }))).toBe(false);
|
||||
});
|
||||
|
||||
it("第三方输入法在 keydown 给出整段文本时应返回 false(英文串)", () => {
|
||||
expect(shouldHandleKeydownDirectly(makePayload({ key: "jk" }))).toBe(false);
|
||||
});
|
||||
|
||||
it("第三方输入法在 keydown 给出整段文本时应返回 false(中文)", () => {
|
||||
expect(shouldHandleKeydownDirectly(makePayload({ key: "测试" }))).toBe(false);
|
||||
});
|
||||
|
||||
it("功能键应返回 true(Enter)", () => {
|
||||
expect(shouldHandleKeydownDirectly(makePayload({ key: "Enter" }))).toBe(true);
|
||||
});
|
||||
|
||||
it("方向键应返回 true(ArrowUp)", () => {
|
||||
expect(shouldHandleKeydownDirectly(makePayload({ key: "ArrowUp" }))).toBe(true);
|
||||
});
|
||||
|
||||
it("组合键应返回 true(Ctrl+C)", () => {
|
||||
expect(shouldHandleKeydownDirectly(makePayload({ key: "c", ctrlKey: true }))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
28
terminal/apps/web/src/terminal/input/inputPolicy.ts
Normal file
28
terminal/apps/web/src/terminal/input/inputPolicy.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { KeyPayload } from "@remoteconn/terminal-core";
|
||||
|
||||
/**
|
||||
* 仅这些按键允许在 keydown 阶段直接发送到终端。
|
||||
* 其余文本输入(包括第三方输入法在 keydown 给出的整段文本)必须走 input 事件。
|
||||
*/
|
||||
const KEYDOWN_DIRECT_KEYS = new Set([
|
||||
"Enter", "Backspace", "Tab", "Escape",
|
||||
"Delete", "Insert", "Home", "End",
|
||||
"PageUp", "PageDown",
|
||||
"ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight",
|
||||
"F1", "F2", "F3", "F4", "F5", "F6",
|
||||
"F7", "F8", "F9", "F10", "F11", "F12",
|
||||
]);
|
||||
|
||||
/**
|
||||
* 判断某个 keydown 事件是否应直接进入 VT 发送链路。
|
||||
* 规则:
|
||||
* 1) 带 ctrl/alt/meta 的组合键一律允许(如 Ctrl+C、Alt+X)。
|
||||
* 2) 无组合键时,只允许功能键白名单;文本键一律禁止。
|
||||
*/
|
||||
export function shouldHandleKeydownDirectly(payload: Pick<KeyPayload, "key" | "ctrlKey" | "altKey" | "metaKey">): boolean {
|
||||
if (payload.ctrlKey || payload.altKey || payload.metaKey) {
|
||||
return true;
|
||||
}
|
||||
return KEYDOWN_DIRECT_KEYS.has(payload.key);
|
||||
}
|
||||
|
||||
444
terminal/apps/web/src/terminal/input/keyboardAdjustController.ts
Normal file
444
terminal/apps/web/src/terminal/input/keyboardAdjustController.ts
Normal file
@@ -0,0 +1,444 @@
|
||||
/**
|
||||
* KeyboardAdjustController — 软键盘弹出/收回时的视口滚动控制器。
|
||||
*
|
||||
* 职责:
|
||||
* - 监听 visualViewport resize 判断软键盘状态
|
||||
* - 键盘弹出时:将光标行(提示符行)滚动到屏幕距顶 1/4 处
|
||||
* - 键盘收回时:恢复弹出前的滚动位置
|
||||
*
|
||||
* 设计原则:
|
||||
* - 只使用 visualViewport.resize(不混用 window.resize),避免误触
|
||||
* - 通过回调注入 DOM 获取逻辑,不直接依赖 Vue/Pinia,便于测试
|
||||
* - 所有状态集中在此类,TerminalPage.vue 只负责初始化/销毁
|
||||
*/
|
||||
|
||||
export interface KeyboardAdjustOptions {
|
||||
/**
|
||||
* 获取输出区滚动容器(即渲染器创建的 .tc-output 元素)。
|
||||
* 若返回 null 则跳过本次调整。
|
||||
*/
|
||||
getOutputEl: () => HTMLElement | null;
|
||||
|
||||
/**
|
||||
* 获取光标行距输出区滚动顶部的像素偏移。
|
||||
* 若返回 null 则回退为滚动到底部。
|
||||
*/
|
||||
getCursorOffsetPx: () => number | null;
|
||||
|
||||
/**
|
||||
* 设置渲染器的 autoFollow 状态。
|
||||
* 键盘弹出期间需关闭 autoFollow,防止新输出覆盖定位。
|
||||
* 键盘收回后恢复 autoFollow。
|
||||
*/
|
||||
setAutoFollow: (enabled: boolean) => void;
|
||||
|
||||
/**
|
||||
* 冻结 scroll 事件对 autoFollow 的影响,持续 ms 毫秒。
|
||||
* iOS WebKit 的 scroll 事件是异步的,必须用时间窗口而非同步锁。
|
||||
* ms=0 立即解冻。
|
||||
* 可选:未提供时静默跳过(兼容未注入此回调的调用方)。
|
||||
*/
|
||||
setScrollLocked?: (ms: number) => void;
|
||||
|
||||
/**
|
||||
* 在指定毫秒内抑制 store 的 followBottom 调用。
|
||||
* 用于防止键盘收回后远端 resize 回刷覆盖恢复的滚动位置。
|
||||
*/
|
||||
suppressAutoFollow: (ms: number) => void;
|
||||
|
||||
/** 调试开关(可选,默认关闭)— 保留但现在关键路径已用无条件日志 */
|
||||
debug?: boolean;
|
||||
}
|
||||
|
||||
/** 键盘弹出前保存的视口快照 */
|
||||
interface KeyboardSnapshot {
|
||||
/** 弹出前输出区的 scrollTop */
|
||||
scrollTop: number;
|
||||
/** 弹出前输出区的 clientHeight */
|
||||
clientHeight: number;
|
||||
/** 弹出前输出区的 scrollHeight */
|
||||
scrollHeight: number;
|
||||
/** 弹出前 visualViewport 高度 */
|
||||
viewportHeight: number;
|
||||
/** 用户在键盘可见期间是否手动滚动过 */
|
||||
userScrolled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 软键盘收缩阈值:visualViewport 高度减少超过此值则判定为键盘弹出。
|
||||
* 实测 iOS/Android 软键盘通常 200px+,120px 可覆盖大多数情况。
|
||||
*/
|
||||
const KEYBOARD_OPEN_THRESHOLD_PX = 120;
|
||||
|
||||
/**
|
||||
* 软键盘关闭迟滞:高度恢复至 baseline - HYSTERESIS 以内即判定为收起。
|
||||
* 避免动画过程中反复触发。
|
||||
*/
|
||||
const KEYBOARD_CLOSE_HYSTERESIS_PX = 40;
|
||||
|
||||
/**
|
||||
* 光标行在屏幕中的目标位置比例(0=顶, 1=底)。
|
||||
* 0.25 = 距顶 1/4 处。
|
||||
*/
|
||||
const CURSOR_TARGET_RATIO = 0.25;
|
||||
|
||||
/**
|
||||
* 键盘弹出后等待 DOM 稳定再执行滚动的延迟(ms)。
|
||||
* iOS 软键盘动画约 250ms,此处稍微保守取 280ms。
|
||||
*/
|
||||
const SCROLL_SETTLE_DELAY_MS = 280;
|
||||
|
||||
export class KeyboardAdjustController {
|
||||
private opts: KeyboardAdjustOptions & { debug: boolean };
|
||||
private baselineHeight = 0;
|
||||
private keyboardVisible = false;
|
||||
private snapshot: KeyboardSnapshot | null = null;
|
||||
private settleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// visualViewport resize 处理器引用,用于清理
|
||||
private readonly vpResizeHandler: () => void;
|
||||
// 用户手动滚动检测
|
||||
private readonly scrollHandler: () => void;
|
||||
private scrollListenerAttached = false;
|
||||
/**
|
||||
* 时间戳:直到此时刻前,scrollHandler 不将 snapshot.userScrolled 设为 true。
|
||||
* iOS WebKit scroll 事件是异步的,必须用时间窗口而非布尔标志。
|
||||
*/
|
||||
private programmaticScrollUntil = 0;
|
||||
|
||||
constructor(options: KeyboardAdjustOptions) {
|
||||
this.opts = { debug: false, ...options };
|
||||
this.vpResizeHandler = () => this.onViewportResize();
|
||||
this.scrollHandler = () => this.onUserScroll();
|
||||
}
|
||||
|
||||
private log(msg: string, detail?: unknown): void {
|
||||
// 关键路径使用无条件 console.log,便于线上问题诊断
|
||||
const prefix = `[KeyboardAdjust][${new Date().toISOString()}] ${msg}`;
|
||||
detail !== undefined ? console.log(prefix, detail) : console.log(prefix);
|
||||
}
|
||||
|
||||
/** 初始化:记录基线高度,开始监听 visualViewport */
|
||||
mount(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
const vv = window.visualViewport;
|
||||
if (!vv) {
|
||||
this.log("mount — visualViewport NOT supported, fallback to window.resize");
|
||||
window.addEventListener("resize", this.vpResizeHandler);
|
||||
} else {
|
||||
vv.addEventListener("resize", this.vpResizeHandler);
|
||||
}
|
||||
this.baselineHeight = vv ? vv.height : window.innerHeight;
|
||||
this.log("mount — OK", {
|
||||
baseline: this.baselineHeight,
|
||||
hasVisualViewport: !!vv,
|
||||
innerHeight: window.innerHeight,
|
||||
});
|
||||
}
|
||||
|
||||
/** 销毁:清理监听器和定时器 */
|
||||
dispose(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
const vv = window.visualViewport;
|
||||
if (!vv) {
|
||||
window.removeEventListener("resize", this.vpResizeHandler);
|
||||
} else {
|
||||
vv.removeEventListener("resize", this.vpResizeHandler);
|
||||
}
|
||||
this.detachScrollListener();
|
||||
if (this.settleTimer !== null) {
|
||||
clearTimeout(this.settleTimer);
|
||||
this.settleTimer = null;
|
||||
}
|
||||
// 确保 autoFollow 已恢复
|
||||
this.opts.setAutoFollow(true);
|
||||
this.snapshot = null;
|
||||
this.keyboardVisible = false;
|
||||
this.log("disposed");
|
||||
}
|
||||
|
||||
/** 强制重置(页面卸载时调用) */
|
||||
forceReset(): void {
|
||||
if (this.snapshot) {
|
||||
this.restoreScroll("unmount");
|
||||
}
|
||||
this.dispose();
|
||||
}
|
||||
|
||||
// ── 核心逻辑 ─────────────────────────────────────────────────────────────
|
||||
|
||||
private onViewportResize(): void {
|
||||
const vv = window.visualViewport;
|
||||
const currentH = vv ? vv.height : window.innerHeight;
|
||||
if (currentH <= 0) return;
|
||||
|
||||
// 基线更新:仅在没有键盘时,且高度比当前基线更大才更新
|
||||
if (!this.keyboardVisible && currentH > this.baselineHeight) {
|
||||
this.log("baseline:updated", { old: this.baselineHeight, new: currentH });
|
||||
this.baselineHeight = currentH;
|
||||
}
|
||||
|
||||
const shrink = this.baselineHeight - currentH;
|
||||
|
||||
this.log("viewport:resize", {
|
||||
currentH,
|
||||
baseline: this.baselineHeight,
|
||||
shrink,
|
||||
keyboardVisible: this.keyboardVisible,
|
||||
OPEN_THRESHOLD: KEYBOARD_OPEN_THRESHOLD_PX,
|
||||
CLOSE_HYSTERESIS: KEYBOARD_CLOSE_HYSTERESIS_PX,
|
||||
});
|
||||
|
||||
if (!this.keyboardVisible && shrink > KEYBOARD_OPEN_THRESHOLD_PX) {
|
||||
// 键盘弹出
|
||||
this.keyboardVisible = true;
|
||||
this.onKeyboardOpen(currentH);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.keyboardVisible && currentH >= this.baselineHeight - KEYBOARD_CLOSE_HYSTERESIS_PX) {
|
||||
// 键盘收回
|
||||
this.keyboardVisible = false;
|
||||
this.onKeyboardClose();
|
||||
}
|
||||
}
|
||||
|
||||
private onKeyboardOpen(currentViewportH: number): void {
|
||||
const outputEl = this.opts.getOutputEl();
|
||||
if (!outputEl) {
|
||||
this.log("keyboard:OPEN — no outputEl, skipped");
|
||||
return;
|
||||
}
|
||||
|
||||
// 保存弹出前的状态
|
||||
this.snapshot = {
|
||||
scrollTop: outputEl.scrollTop,
|
||||
clientHeight: outputEl.clientHeight,
|
||||
scrollHeight: outputEl.scrollHeight,
|
||||
viewportHeight: currentViewportH,
|
||||
userScrolled: false,
|
||||
};
|
||||
|
||||
this.log("keyboard:OPEN — snapshot saved", {
|
||||
snapshot: this.snapshot,
|
||||
baseline: this.baselineHeight,
|
||||
currentViewportH,
|
||||
});
|
||||
|
||||
// 关闭 autoFollow,防止新输出把光标滚走
|
||||
this.opts.setAutoFollow(false);
|
||||
|
||||
// 注册用户滚动检测
|
||||
this.attachScrollListener(outputEl);
|
||||
|
||||
// 等键盘动画完成后执行定位
|
||||
if (this.settleTimer !== null) clearTimeout(this.settleTimer);
|
||||
this.log(`keyboard:OPEN — will position in ${SCROLL_SETTLE_DELAY_MS}ms`);
|
||||
this.settleTimer = setTimeout(() => {
|
||||
this.settleTimer = null;
|
||||
this.positionCursorAtQuarter(outputEl);
|
||||
}, SCROLL_SETTLE_DELAY_MS);
|
||||
}
|
||||
|
||||
private onKeyboardClose(): void {
|
||||
this.log("keyboard:CLOSE — detaching scroll listener, cancelling settle timer");
|
||||
this.detachScrollListener();
|
||||
if (this.settleTimer !== null) {
|
||||
clearTimeout(this.settleTimer);
|
||||
this.settleTimer = null;
|
||||
}
|
||||
// iOS 键盘收回时,outputEl.clientHeight 的恢复比 visualViewport.height 更晚,
|
||||
// 固定延迟不可靠。改用轮询:每 32ms 检查一次 clientHeight 是否已恢复到 baseline,
|
||||
// 最多等待 600ms,超时则强制执行。
|
||||
const expectedClientH = this.snapshot
|
||||
? this.snapshot.clientHeight // 弹出前保存的 clientHeight
|
||||
: 0;
|
||||
const startedAt = Date.now();
|
||||
const MAX_WAIT_MS = 600;
|
||||
const POLL_MS = 32;
|
||||
|
||||
// 在整个 poll 等待期间保持 scroll 冻结,防止 iOS 键盘收起动画触发的
|
||||
// scroll 事件把 autoFollow 改回 true,干扰后续 restoreScroll 的逻辑。
|
||||
this.opts.setScrollLocked?.(MAX_WAIT_MS + POLL_MS);
|
||||
|
||||
const poll = () => {
|
||||
const outputEl = this.opts.getOutputEl();
|
||||
const elapsed = Date.now() - startedAt;
|
||||
const currentH = outputEl?.clientHeight ?? 0;
|
||||
const recovered = currentH >= expectedClientH - 4; // 允许 4px 误差
|
||||
|
||||
this.log(`keyboard:CLOSE — poll clientHeight=${currentH} expected=${expectedClientH} recovered=${recovered} elapsed=${elapsed}ms`);
|
||||
|
||||
if (recovered || elapsed >= MAX_WAIT_MS) {
|
||||
this.settleTimer = null;
|
||||
this.restoreScroll("keyboard_close");
|
||||
return;
|
||||
}
|
||||
this.settleTimer = setTimeout(poll, POLL_MS);
|
||||
};
|
||||
|
||||
this.settleTimer = setTimeout(poll, POLL_MS);
|
||||
}
|
||||
|
||||
private onUserScroll(): void {
|
||||
const now = Date.now();
|
||||
if (now < this.programmaticScrollUntil) {
|
||||
this.log(`scroll:handler — programmatic window(${this.programmaticScrollUntil - now}ms), skip userScrolled`);
|
||||
return;
|
||||
}
|
||||
if (this.snapshot) {
|
||||
this.snapshot.userScrolled = true;
|
||||
this.log("scroll:handler — USER scroll detected, userScrolled=true");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将光标行定位到当前可见区域距顶 1/4 处。
|
||||
*
|
||||
* 计算公式:
|
||||
* 目标 scrollTop = cursorOffsetPx - visibleHeight * CURSOR_TARGET_RATIO
|
||||
*
|
||||
* 其中 visibleHeight 是键盘弹出后 outputEl 的实际可见高度。
|
||||
*/
|
||||
private positionCursorAtQuarter(outputEl: HTMLElement): void {
|
||||
const cursorOffsetPx = this.opts.getCursorOffsetPx();
|
||||
|
||||
// 键盘弹出后输出区的实际可见高度(clientHeight 已被键盘压缩)
|
||||
const visibleH = outputEl.clientHeight;
|
||||
|
||||
let targetScrollTop: number;
|
||||
if (cursorOffsetPx !== null) {
|
||||
targetScrollTop = cursorOffsetPx - visibleH * CURSOR_TARGET_RATIO;
|
||||
} else {
|
||||
// 回退:滚到底部
|
||||
targetScrollTop = outputEl.scrollHeight - visibleH;
|
||||
}
|
||||
|
||||
const maxTop = Math.max(0, outputEl.scrollHeight - visibleH);
|
||||
targetScrollTop = Math.max(0, Math.min(maxTop, targetScrollTop));
|
||||
|
||||
this.log("keyboard:POSITION_CURSOR", {
|
||||
cursorOffsetPx,
|
||||
visibleH,
|
||||
CURSOR_TARGET_RATIO,
|
||||
targetScrollTop,
|
||||
currentScrollTop: outputEl.scrollTop,
|
||||
scrollHeight: outputEl.scrollHeight,
|
||||
maxTop,
|
||||
});
|
||||
|
||||
// iOS WebKit 的 scroll 事件是异步的(下一个 task),用时间窗口屏蔽。
|
||||
// 200ms 足够覆盖任何平台的异步 scroll 回调。
|
||||
const FREEZE_MS = 200;
|
||||
this.programmaticScrollUntil = Date.now() + FREEZE_MS;
|
||||
this.opts.setScrollLocked?.(FREEZE_MS); // 同步冻结 renderer scroll → autoFollow
|
||||
outputEl.scrollTop = targetScrollTop;
|
||||
|
||||
// 定位完成后必须强制 autoFollow=false(scroll 事件可能在时间窗口内把它改回 true)
|
||||
this.opts.setAutoFollow(false);
|
||||
// 抑制 store 层 followBottom,整个键盘可见期间屏蔽远端 stdout 的滚动干扰
|
||||
this.opts.suppressAutoFollow(60_000);
|
||||
this.log("keyboard:POSITION_CURSOR — done, final scrollTop=" + outputEl.scrollTop);
|
||||
}
|
||||
|
||||
private restoreScroll(reason: string): void {
|
||||
const snap = this.snapshot;
|
||||
this.snapshot = null;
|
||||
|
||||
// 清除键盘弹出时设置的长期 scroll 冻结(先清,后面可能重新设短窗口)
|
||||
this.opts.setScrollLocked?.(0);
|
||||
|
||||
if (!snap) {
|
||||
this.opts.setAutoFollow(true);
|
||||
this.log(`keyboard:RESTORE — no snapshot (${reason})`);
|
||||
return;
|
||||
}
|
||||
|
||||
const outputEl = this.opts.getOutputEl();
|
||||
if (!outputEl) {
|
||||
this.opts.setAutoFollow(true);
|
||||
this.log(`keyboard:RESTORE — SKIP (no outputEl)`);
|
||||
this.opts.suppressAutoFollow(0);
|
||||
this.opts.suppressAutoFollow(500);
|
||||
return;
|
||||
}
|
||||
|
||||
const before = {
|
||||
scrollTop: outputEl.scrollTop,
|
||||
clientHeight: outputEl.clientHeight,
|
||||
scrollHeight: outputEl.scrollHeight,
|
||||
};
|
||||
|
||||
const contentChanged = Math.abs(before.scrollHeight - snap.scrollHeight) > 4;
|
||||
|
||||
this.log(`keyboard:RESTORE (${reason})`, {
|
||||
userScrolled: snap.userScrolled,
|
||||
contentChanged,
|
||||
snap,
|
||||
before,
|
||||
});
|
||||
|
||||
// 计算目标 scrollTop
|
||||
let targetScrollTop: number;
|
||||
|
||||
if (snap.userScrolled) {
|
||||
// 用户键盘可见期间主动上滚过:恢复弹出前的滚动位置(尊重用户意图)
|
||||
const maxTop = Math.max(0, before.scrollHeight - before.clientHeight);
|
||||
targetScrollTop = Math.max(0, Math.min(maxTop, snap.scrollTop));
|
||||
this.log(`keyboard:RESTORE — userScrolled, target=${targetScrollTop}`);
|
||||
} else {
|
||||
// 用户未主动滚动:直接恢复弹出前保存的 scrollTop。
|
||||
// snap.scrollTop 就是用户弹出键盘前看到的位置,这是最自然的"恢复"语义。
|
||||
// 内容有增长时用 maxTop 兜底(防止超出范围)。
|
||||
const maxTop = Math.max(0, before.scrollHeight - before.clientHeight);
|
||||
targetScrollTop = Math.max(0, Math.min(maxTop, snap.scrollTop));
|
||||
this.log(`keyboard:RESTORE — !userScrolled, restoring snap.scrollTop`, {
|
||||
snapScrollTop: snap.scrollTop, target: targetScrollTop, maxTop,
|
||||
contentChanged,
|
||||
});
|
||||
}
|
||||
|
||||
// 判断目标位置是否在底部(4px 容差)
|
||||
const maxTop = Math.max(0, before.scrollHeight - before.clientHeight);
|
||||
const isAtBottom = Math.abs(targetScrollTop - maxTop) < 4;
|
||||
|
||||
// 关键:先设 autoFollow=false,再设 scrollTop,再按需恢复 autoFollow。
|
||||
// 如果先 setAutoFollow(true),renderSnapshot 的下一帧会立刻把 scrollTop 覆盖成 scrollHeight。
|
||||
this.opts.setAutoFollow(false);
|
||||
|
||||
const FREEZE_MS = 200;
|
||||
this.programmaticScrollUntil = Date.now() + FREEZE_MS;
|
||||
this.opts.setScrollLocked?.(FREEZE_MS);
|
||||
outputEl.scrollTop = targetScrollTop;
|
||||
|
||||
this.log(`keyboard:RESTORE — done, final scrollTop=${outputEl.scrollTop} isAtBottom=${isAtBottom}`);
|
||||
|
||||
if (isAtBottom) {
|
||||
// 在底部:恢复 autoFollow,700ms 内保护远端 resize 回刷
|
||||
this.opts.setAutoFollow(true);
|
||||
this.opts.suppressAutoFollow(0); // 先清除60s长期抑制
|
||||
this.opts.suppressAutoFollow(700); // 再设700ms短保护
|
||||
} else {
|
||||
// 不在底部(光标后有空行):保持 autoFollow=false。
|
||||
// 先清除60s长期抑制,再设700ms短保护。
|
||||
// 顺序必须是:先清(0),再设(700)——反序会导致0把700覆盖。
|
||||
this.opts.suppressAutoFollow(0); // 清除60s长期抑制
|
||||
this.opts.suppressAutoFollow(700); // 设700ms短保护,屏蔽立刻到来的stdout心跳
|
||||
}
|
||||
}
|
||||
|
||||
// ── 辅助 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private attachScrollListener(el: HTMLElement): void {
|
||||
if (this.scrollListenerAttached) return;
|
||||
el.addEventListener("scroll", this.scrollHandler, { passive: true });
|
||||
this.scrollListenerAttached = true;
|
||||
}
|
||||
|
||||
private detachScrollListener(): void {
|
||||
if (!this.scrollListenerAttached) return;
|
||||
const outputEl = this.opts.getOutputEl();
|
||||
outputEl?.removeEventListener("scroll", this.scrollHandler);
|
||||
this.scrollListenerAttached = false;
|
||||
}
|
||||
}
|
||||
81
terminal/apps/web/src/terminal/layout/domMeasureAdapter.ts
Normal file
81
terminal/apps/web/src/terminal/layout/domMeasureAdapter.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { IMeasureAdapter } from "@remoteconn/terminal-core";
|
||||
|
||||
/**
|
||||
* DomMeasureAdapter — 使用 DOM API 实现 IMeasureAdapter。
|
||||
* 通过隐藏测量元素(单字符 span)和 ResizeObserver 获取容器/字符尺寸。
|
||||
*/
|
||||
export class DomMeasureAdapter implements IMeasureAdapter {
|
||||
private charWidthPx = 0;
|
||||
private charHeightPx = 0;
|
||||
private containerW = 0;
|
||||
private containerH = 0;
|
||||
private measureEl: HTMLElement | null = null;
|
||||
private charSpan: HTMLElement | null = null;
|
||||
private observer: ResizeObserver | null = null;
|
||||
private callbacks = new Set<() => void>();
|
||||
|
||||
constructor(
|
||||
private readonly container: HTMLElement,
|
||||
/** 参考字体样式(fontSize, fontFamily 等,会复制到测量元素上) */
|
||||
private readonly fontStyle: Partial<CSSStyleDeclaration> = {}
|
||||
) {}
|
||||
|
||||
mount(): void {
|
||||
this.measureEl = document.createElement("div");
|
||||
Object.assign(this.measureEl.style, {
|
||||
position: "absolute",
|
||||
visibility: "hidden",
|
||||
pointerEvents: "none",
|
||||
top: "0",
|
||||
left: "0",
|
||||
whiteSpace: "pre",
|
||||
...this.fontStyle,
|
||||
});
|
||||
this.charSpan = document.createElement("span");
|
||||
this.charSpan.textContent = "M"; // 等宽字符样本
|
||||
this.measureEl.appendChild(this.charSpan);
|
||||
document.body.appendChild(this.measureEl);
|
||||
|
||||
this.observer = new ResizeObserver(() => {
|
||||
this.refresh();
|
||||
for (const cb of this.callbacks) cb();
|
||||
});
|
||||
this.observer.observe(this.container);
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.observer?.disconnect();
|
||||
this.measureEl?.parentNode?.removeChild(this.measureEl);
|
||||
this.measureEl = null;
|
||||
this.charSpan = null;
|
||||
}
|
||||
|
||||
measureChar(): { widthPx: number; heightPx: number } {
|
||||
if (!this.charWidthPx) this.refresh();
|
||||
return { widthPx: this.charWidthPx || 8, heightPx: this.charHeightPx || 16 };
|
||||
}
|
||||
|
||||
measureContainer(): { widthPx: number; heightPx: number } {
|
||||
if (!this.containerW) this.refresh();
|
||||
return { widthPx: this.containerW || 320, heightPx: this.containerH || 200 };
|
||||
}
|
||||
|
||||
onResize(cb: () => void): () => void {
|
||||
this.callbacks.add(cb);
|
||||
return () => this.callbacks.delete(cb);
|
||||
}
|
||||
|
||||
private refresh(): void {
|
||||
if (this.charSpan) {
|
||||
const rect = this.charSpan.getBoundingClientRect();
|
||||
this.charWidthPx = rect.width || 8;
|
||||
this.charHeightPx = rect.height || 16;
|
||||
}
|
||||
const cRect = this.container.getBoundingClientRect();
|
||||
const style = getComputedStyle(this.container);
|
||||
const px = (s: string) => parseFloat(s) || 0;
|
||||
this.containerW = cRect.width - px(style.paddingLeft) - px(style.paddingRight);
|
||||
this.containerH = cRect.height - px(style.paddingTop) - px(style.paddingBottom);
|
||||
}
|
||||
}
|
||||
208
terminal/apps/web/src/terminal/renderer/compatRenderer.ts
Normal file
208
terminal/apps/web/src/terminal/renderer/compatRenderer.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { TerminalCore, sanitizeTerminalOutput } from "@remoteconn/terminal-core";
|
||||
import type { RendererAdapter, TerminalSnapshot, TerminalLine, TerminalCell, ColorValue } from "@remoteconn/terminal-core";
|
||||
import { FLAG_BOLD, FLAG_DIM, FLAG_ITALIC, FLAG_UNDERLINE, FLAG_INVERSE, FLAG_INVISIBLE, FLAG_STRIKETHROUGH, FLAG_OVERLINE } from "@remoteconn/terminal-core";
|
||||
|
||||
/** 16 色标准 xterm256 调色板(前 16 色) */
|
||||
const ANSI16: string[] = [
|
||||
"#000000","#cc0000","#00aa00","#aaaa00",
|
||||
"#0000ee","#cc00cc","#00aaaa","#aaaaaa",
|
||||
"#555555","#ff5555","#55ff55","#ffff55",
|
||||
"#5555ff","#ff55ff","#55ffff","#ffffff",
|
||||
];
|
||||
|
||||
function colorToCss(c: ColorValue, isFg: boolean): string {
|
||||
switch (c.mode) {
|
||||
case "default": return isFg ? "inherit" : "transparent";
|
||||
case "p16": return ANSI16[c.value] ?? "inherit";
|
||||
case "p256": return p256ToCss(c.value);
|
||||
case "rgb": return `#${(c.value & 0xffffff).toString(16).padStart(6,"0")}`;
|
||||
}
|
||||
}
|
||||
|
||||
function p256ToCss(idx: number): string {
|
||||
if (idx < 16) return ANSI16[idx] ?? "#000";
|
||||
if (idx >= 232) {
|
||||
const v = 8 + (idx - 232) * 10;
|
||||
return `rgb(${v},${v},${v})`;
|
||||
}
|
||||
idx -= 16;
|
||||
const b = idx % 6, g = Math.floor(idx / 6) % 6, r = Math.floor(idx / 36);
|
||||
const ch = (x: number) => x === 0 ? 0 : 55 + x * 40;
|
||||
return `rgb(${ch(r)},${ch(g)},${ch(b)})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* CompatRenderer — DOM div/span 实现的渲染器。
|
||||
* 每次 RAF 消费一次 TerminalCore.snapshot() 进行差量更新。
|
||||
*/
|
||||
export class CompatRenderer implements RendererAdapter {
|
||||
private core: TerminalCore;
|
||||
private container: HTMLElement | null = null;
|
||||
private viewport: HTMLElement | null = null;
|
||||
private cursorEl: HTMLElement | null = null;
|
||||
private rowEls: HTMLElement[] = [];
|
||||
private rafId: number = 0;
|
||||
private lastRevision = -1;
|
||||
|
||||
constructor(cols = 80, rows = 24) {
|
||||
this.core = new TerminalCore(cols, rows);
|
||||
}
|
||||
|
||||
getCore(): TerminalCore { return this.core; }
|
||||
|
||||
mount(container: unknown): void {
|
||||
this.container = container as HTMLElement;
|
||||
this.container.classList.add("tc-compat-root");
|
||||
Object.assign(this.container.style, {
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
fontFamily: "monospace",
|
||||
lineHeight: "1.2em",
|
||||
});
|
||||
|
||||
this.viewport = document.createElement("div");
|
||||
this.viewport.className = "tc-viewport";
|
||||
Object.assign(this.viewport.style, {
|
||||
position: "absolute",
|
||||
top: "0", left: "0",
|
||||
whiteSpace: "pre",
|
||||
});
|
||||
this.container.appendChild(this.viewport);
|
||||
|
||||
this.cursorEl = document.createElement("div");
|
||||
this.cursorEl.className = "tc-cursor";
|
||||
Object.assign(this.cursorEl.style, {
|
||||
position: "absolute",
|
||||
width: "0.6em",
|
||||
height: "1.2em",
|
||||
background:"rgba(255,255,255,0.8)",
|
||||
pointerEvents: "none",
|
||||
zIndex: "1",
|
||||
});
|
||||
this.container.appendChild(this.cursorEl);
|
||||
|
||||
this.rebuildRows(this.core.snapshot().rows);
|
||||
this.scheduleRender();
|
||||
}
|
||||
|
||||
write(data: string): void {
|
||||
const sanitized = sanitizeTerminalOutput(data);
|
||||
if (sanitized) {
|
||||
this.core.write(sanitized);
|
||||
this.scheduleRender();
|
||||
}
|
||||
}
|
||||
|
||||
resize(cols: number, rows: number): void {
|
||||
this.core.resize(cols, rows);
|
||||
this.rebuildRows(rows);
|
||||
this.scheduleRender();
|
||||
}
|
||||
|
||||
applySnapshot(snapshot: TerminalSnapshot): void {
|
||||
// Re-create a clean core from the snapshot is not straightforward;
|
||||
// the caller should pass the same TerminalCore reference instead.
|
||||
// For "mode switch" replays, we just re-render the current state.
|
||||
this.renderSnapshot(snapshot);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.rafId) cancelAnimationFrame(this.rafId);
|
||||
this.container?.classList.remove("tc-compat-root");
|
||||
this.rowEls = [];
|
||||
this.viewport?.parentNode?.removeChild(this.viewport);
|
||||
this.cursorEl?.parentNode?.removeChild(this.cursorEl);
|
||||
this.viewport = null;
|
||||
this.cursorEl = null;
|
||||
this.container = null;
|
||||
}
|
||||
|
||||
// ── Private ────────────────────────────────────────────────────────────
|
||||
|
||||
private scheduleRender(): void {
|
||||
if (this.rafId) return;
|
||||
this.rafId = requestAnimationFrame(() => {
|
||||
this.rafId = 0;
|
||||
const snap = this.core.snapshot();
|
||||
if (snap.revision === this.lastRevision) return;
|
||||
this.lastRevision = snap.revision;
|
||||
this.renderSnapshot(snap);
|
||||
});
|
||||
}
|
||||
|
||||
private rebuildRows(rows: number): void {
|
||||
if (!this.viewport) return;
|
||||
this.viewport.innerHTML = "";
|
||||
this.rowEls = [];
|
||||
for (let r = 0; r < rows; r++) {
|
||||
const div = document.createElement("div");
|
||||
div.className = "tc-row";
|
||||
this.viewport.appendChild(div);
|
||||
this.rowEls.push(div);
|
||||
}
|
||||
}
|
||||
|
||||
private renderSnapshot(snap: TerminalSnapshot): void {
|
||||
if (!this.viewport || !this.cursorEl) return;
|
||||
|
||||
// Ensure row count matches
|
||||
if (this.rowEls.length !== snap.rows) {
|
||||
this.rebuildRows(snap.rows);
|
||||
}
|
||||
|
||||
for (let r = 0; r < snap.rows; r++) {
|
||||
const rowEl = this.rowEls[r];
|
||||
if (!rowEl) continue;
|
||||
const line = snap.lines[r];
|
||||
if (line) {
|
||||
rowEl.innerHTML = lineToHtml(line);
|
||||
}
|
||||
}
|
||||
|
||||
// Update cursor
|
||||
const charEm = 0.6; // em per character (monospace approx)
|
||||
const lineEm = 1.2;
|
||||
Object.assign(this.cursorEl.style, {
|
||||
display: snap.cursor.visible ? "block" : "none",
|
||||
left: `${snap.cursor.x * charEm}em`,
|
||||
top: `${snap.cursor.y * lineEm}em`,
|
||||
width: `${charEm}em`,
|
||||
height: `${lineEm}em`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function lineToHtml(line: TerminalLine): string {
|
||||
let html = "";
|
||||
for (const cell of line.cells) {
|
||||
html += cellToHtml(cell);
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function cellToHtml(cell: TerminalCell): string {
|
||||
const { flags } = cell;
|
||||
let fg = colorToCss(cell.fg, true);
|
||||
let bg = colorToCss(cell.bg, false);
|
||||
|
||||
if (flags & FLAG_INVERSE) { [fg, bg] = [bg === "transparent" ? "#ffffff" : bg, fg === "inherit" ? "#000000" : fg]; }
|
||||
|
||||
const invisible = flags & FLAG_INVISIBLE;
|
||||
const char = invisible ? " " : (cell.char || " ");
|
||||
|
||||
const styles: string[] = [];
|
||||
if (fg !== "inherit") styles.push(`color:${fg}`);
|
||||
if (bg !== "transparent") styles.push(`background:${bg}`);
|
||||
if (flags & FLAG_BOLD) styles.push("font-weight:bold");
|
||||
if (flags & FLAG_DIM) styles.push("opacity:0.5");
|
||||
if (flags & FLAG_ITALIC) styles.push("font-style:italic");
|
||||
const tdecs: string[] = [];
|
||||
if (flags & FLAG_UNDERLINE) tdecs.push("underline");
|
||||
if (flags & FLAG_STRIKETHROUGH) tdecs.push("line-through");
|
||||
if (flags & FLAG_OVERLINE) tdecs.push("overline");
|
||||
if (tdecs.length) styles.push(`text-decoration:${tdecs.join(" ")}`);
|
||||
|
||||
const escChar = char === "&" ? "&" : char === "<" ? "<" : char === ">" ? ">" : char;
|
||||
if (!styles.length) return escChar;
|
||||
return `<span style="${styles.join(";")}">${escChar}</span>`;
|
||||
}
|
||||
381
terminal/apps/web/src/terminal/renderer/textareaRenderer.ts
Normal file
381
terminal/apps/web/src/terminal/renderer/textareaRenderer.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
import { TerminalCore, sanitizeTerminalOutput } from "@remoteconn/terminal-core";
|
||||
import type { RendererAdapter, TerminalSnapshot, TerminalCell, ColorValue } from "@remoteconn/terminal-core";
|
||||
import { FLAG_BOLD, FLAG_DIM, FLAG_ITALIC, FLAG_UNDERLINE, FLAG_INVERSE, FLAG_INVISIBLE, FLAG_STRIKETHROUGH, FLAG_OVERLINE } from "@remoteconn/terminal-core";
|
||||
|
||||
/**
|
||||
* TextareaRenderer — 基于 <pre>/<textarea> 的轻量渲染器。
|
||||
* 适合低端设备和降级场景(rendererFallback=textarea)。
|
||||
* 输出区使用 <pre> + span 显示(支持颜色属性)。
|
||||
*/
|
||||
const ANSI16: string[] = [
|
||||
"#000000","#cc0000","#00aa00","#aaaa00",
|
||||
"#0000ee","#cc00cc","#00aaaa","#aaaaaa",
|
||||
"#555555","#ff5555","#55ff55","#ffff55",
|
||||
"#5555ff","#ff55ff","#55ffff","#ffffff",
|
||||
];
|
||||
|
||||
function colorToCss(c: ColorValue, isFg: boolean): string {
|
||||
switch (c.mode) {
|
||||
case "default": return isFg ? "inherit" : "transparent";
|
||||
case "p16": return ANSI16[c.value] ?? "inherit";
|
||||
case "p256": return p256ToCss(c.value);
|
||||
case "rgb": return `#${(c.value & 0xffffff).toString(16).padStart(6, "0")}`;
|
||||
}
|
||||
}
|
||||
|
||||
function p256ToCss(idx: number): string {
|
||||
if (idx < 16) return ANSI16[idx] ?? "#000";
|
||||
if (idx >= 232) {
|
||||
const v = 8 + (idx - 232) * 10;
|
||||
return `rgb(${v},${v},${v})`;
|
||||
}
|
||||
idx -= 16;
|
||||
const b = idx % 6;
|
||||
const g = Math.floor(idx / 6) % 6;
|
||||
const r = Math.floor(idx / 36);
|
||||
const ch = (x: number) => x === 0 ? 0 : 55 + x * 40;
|
||||
return `rgb(${ch(r)},${ch(g)},${ch(b)})`;
|
||||
}
|
||||
|
||||
function escapeHtml(ch: string): string {
|
||||
if (ch === "&") return "&";
|
||||
if (ch === "<") return "<";
|
||||
if (ch === ">") return ">";
|
||||
return ch;
|
||||
}
|
||||
|
||||
function isTouchDebugEnabled(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
return window.localStorage.getItem("terminal.debugTextareaRenderer") === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function debugRenderer(message: string, detail?: unknown): void {
|
||||
if (!isTouchDebugEnabled()) return;
|
||||
const prefix = `[TextareaRenderer][Touch][${new Date().toISOString()}] ${message}`;
|
||||
if (typeof detail === "undefined") {
|
||||
console.log(prefix);
|
||||
return;
|
||||
}
|
||||
console.log(prefix, detail);
|
||||
}
|
||||
|
||||
export class TextareaRenderer implements RendererAdapter {
|
||||
private core: TerminalCore;
|
||||
private container: HTMLElement | null = null;
|
||||
private outputEl: HTMLPreElement | null = null;
|
||||
private rafId: number = 0;
|
||||
private lastRevision = -1;
|
||||
/** 是否自动跟随滚动到底部 */
|
||||
private autoFollow = true;
|
||||
private blinkVisible = true;
|
||||
private blinkTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private lastSnapshot: TerminalSnapshot | null = null;
|
||||
/**
|
||||
* 时间戳:直到此时刻前,scroll 事件不更新 autoFollow。
|
||||
* iOS WebKit 的 scroll 事件是异步的(下一个 task),不能用同步锁,只能用时间窗口。
|
||||
*/
|
||||
private scrollFrozenUntil = 0;
|
||||
|
||||
constructor(cols = 80, rows = 24) {
|
||||
this.core = new TerminalCore(cols, rows);
|
||||
}
|
||||
|
||||
getCore(): TerminalCore { return this.core; }
|
||||
|
||||
mount(container: unknown): void {
|
||||
this.container = container as HTMLElement;
|
||||
this.container.classList.add("tc-textarea-root");
|
||||
Object.assign(this.container.style, {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
minHeight: "0"
|
||||
});
|
||||
|
||||
// 输出区
|
||||
this.outputEl = document.createElement("pre");
|
||||
this.outputEl.className = "tc-output";
|
||||
Object.assign(this.outputEl.style, {
|
||||
margin: "0",
|
||||
overflow: "auto",
|
||||
fontFamily: "monospace",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-all",
|
||||
flex: "1",
|
||||
minHeight: "0",
|
||||
cursor: "text",
|
||||
userSelect: "text",
|
||||
WebkitUserSelect: "text",
|
||||
WebkitTouchCallout: "default",
|
||||
WebkitOverflowScrolling: "touch",
|
||||
overscrollBehavior: "contain",
|
||||
touchAction: "pan-y",
|
||||
position: "relative",
|
||||
});
|
||||
// 用户手动上滚时禁止自动跟随。
|
||||
// iOS WebKit scroll 事件是异步的(下一个 task),不能用同步锁。
|
||||
// 用时间窗口:scrollFrozenUntil 内的 scroll 事件跳过 autoFollow 更新。
|
||||
this.outputEl.addEventListener("scroll", () => {
|
||||
const now = Date.now();
|
||||
if (now < this.scrollFrozenUntil) {
|
||||
console.log(`[TextareaRenderer][scroll] FROZEN(${this.scrollFrozenUntil - now}ms) — skip autoFollow, scrollTop=${this.outputEl?.scrollTop}`);
|
||||
return;
|
||||
}
|
||||
const el = this.outputEl!;
|
||||
const atBottom = Math.abs(el.scrollTop + el.clientHeight - el.scrollHeight) < 4;
|
||||
const prevAutoFollow = this.autoFollow;
|
||||
this.autoFollow = atBottom;
|
||||
console.log(`[TextareaRenderer][scroll] scrollTop=${el.scrollTop} clientH=${el.clientHeight} scrollH=${el.scrollHeight} atBottom=${atBottom} autoFollow: ${prevAutoFollow} → ${this.autoFollow}`);
|
||||
});
|
||||
this.outputEl.addEventListener("touchstart", (e: TouchEvent) => {
|
||||
const t = e.changedTouches[0];
|
||||
debugRenderer("event:touchstart", t ? { x: t.clientX, y: t.clientY } : undefined);
|
||||
}, { passive: true });
|
||||
this.outputEl.addEventListener("touchmove", (e: TouchEvent) => {
|
||||
const t = e.changedTouches[0];
|
||||
debugRenderer("event:touchmove", t ? { x: t.clientX, y: t.clientY } : undefined);
|
||||
}, { passive: true });
|
||||
this.outputEl.addEventListener("touchend", (e: TouchEvent) => {
|
||||
const t = e.changedTouches[0];
|
||||
debugRenderer("event:touchend", t ? { x: t.clientX, y: t.clientY } : undefined);
|
||||
}, { passive: true });
|
||||
this.container.appendChild(this.outputEl);
|
||||
|
||||
this.startBlink();
|
||||
this.scheduleRender();
|
||||
}
|
||||
|
||||
write(data: string): void {
|
||||
const s = sanitizeTerminalOutput(data);
|
||||
if (s) {
|
||||
this.core.write(s);
|
||||
this.scheduleRender();
|
||||
}
|
||||
}
|
||||
|
||||
resize(cols: number, rows: number): void {
|
||||
this.core.resize(cols, rows);
|
||||
this.scheduleRender();
|
||||
}
|
||||
|
||||
applySnapshot(snapshot: TerminalSnapshot): void {
|
||||
this.renderSnapshot(snapshot);
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制恢复"跟随到底部"行为。
|
||||
* 用于用户执行命令(Enter)后,确保新输出与提示符回到可见底部。
|
||||
*/
|
||||
followBottom(): void {
|
||||
if (!this.outputEl || !this.lastSnapshot) return;
|
||||
if (!this.autoFollow) return;
|
||||
|
||||
// 获取真实的光标DOM进行精准滚动
|
||||
const cursorSpan = this.outputEl.querySelector('.tc-cursor-cell') as HTMLElement;
|
||||
if (cursorSpan) {
|
||||
const cursorBottom = cursorSpan.offsetTop + cursorSpan.offsetHeight;
|
||||
const clientH = this.outputEl.clientHeight;
|
||||
const maxScroll = Math.max(0, this.outputEl.scrollHeight - clientH);
|
||||
// 尝试把光标置于视口底部附近
|
||||
let targetScrollTop = cursorBottom - clientH + 20; // 留一点余量
|
||||
targetScrollTop = Math.max(0, Math.min(maxScroll, targetScrollTop));
|
||||
this.outputEl.scrollTop = targetScrollTop;
|
||||
} else {
|
||||
this.outputEl.scrollTop = this.outputEl.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回光标行在输出滚动容器中距顶部的精准像素偏移量。
|
||||
* 用于软键盘弹出时将提示符行置于屏幕特定位置。
|
||||
* 若渲染器未就绪则返回 null。
|
||||
*
|
||||
* 计算方法:优先使用 getComputedStyle lineHeight 获得精确行高,
|
||||
* 回退使用 scrollHeight/lineCount 估算。
|
||||
*/
|
||||
getCursorOffsetPx(): number | null {
|
||||
if (!this.outputEl || !this.lastSnapshot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 精准获取光标DOM元素的 offsetTop
|
||||
const cursorSpan = this.outputEl.querySelector('.tc-cursor-cell') as HTMLElement;
|
||||
if (cursorSpan) {
|
||||
return cursorSpan.offsetTop;
|
||||
}
|
||||
|
||||
// fallback: 如果没有可见光标,根据 cursor.y 估算
|
||||
const snap = this.lastSnapshot;
|
||||
const totalLines = snap.lines.length;
|
||||
const cursorY = Math.max(0, Math.min(totalLines - 1, snap.cursor.y));
|
||||
const scrollH = this.outputEl.scrollHeight;
|
||||
const lineHeight = scrollH / Math.max(1, totalLines);
|
||||
return cursorY * lineHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取输出滚动容器元素。
|
||||
*/
|
||||
getOutputEl(): HTMLElement | null {
|
||||
return this.outputEl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 冻结 scroll 事件对 autoFollow 的影响,持续 ms 毫秒。
|
||||
* 用于程序主动设置 scrollTop 后,屏蔽 iOS 异步 scroll 事件的干扰。
|
||||
* ms=0 立即解冻。
|
||||
*/
|
||||
setScrollLocked(ms: number): void {
|
||||
this.scrollFrozenUntil = ms > 0 ? Date.now() + ms : 0;
|
||||
console.log(`[TextareaRenderer][setScrollFrozen] ms=${ms} frozenUntil=${ms > 0 ? new Date(this.scrollFrozenUntil).toISOString() : "CLEARED"}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 临时禁止 autoFollow(键盘弹出/收回动画期间保护滚动位置)。
|
||||
*/
|
||||
setAutoFollow(enabled: boolean): void {
|
||||
console.log(`[TextareaRenderer][setAutoFollow] ${this.autoFollow} → ${enabled}`);
|
||||
this.autoFollow = enabled;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.rafId) cancelAnimationFrame(this.rafId);
|
||||
if (this.blinkTimer) {
|
||||
clearInterval(this.blinkTimer);
|
||||
this.blinkTimer = null;
|
||||
}
|
||||
this.container?.classList.remove("tc-textarea-root");
|
||||
this.outputEl?.parentNode?.removeChild(this.outputEl);
|
||||
this.outputEl = null;
|
||||
this.container = null;
|
||||
this.lastSnapshot = null;
|
||||
}
|
||||
|
||||
// ── Private ──────────────────────────────────────────────────────────────
|
||||
|
||||
private scheduleRender(): void {
|
||||
if (this.rafId) return;
|
||||
this.rafId = requestAnimationFrame(() => {
|
||||
this.rafId = 0;
|
||||
const snap = this.core.snapshotWithScrollback();
|
||||
if (snap.revision === this.lastRevision) return;
|
||||
this.lastRevision = snap.revision;
|
||||
this.blinkVisible = true;
|
||||
this.renderSnapshot(snap);
|
||||
});
|
||||
}
|
||||
|
||||
private startBlink(): void {
|
||||
if (this.blinkTimer) clearInterval(this.blinkTimer);
|
||||
this.blinkTimer = setInterval(() => {
|
||||
if (!this.lastSnapshot || !this.lastSnapshot.cursor.visible) return;
|
||||
if (this.hasActiveSelectionInOutput()) return;
|
||||
this.blinkVisible = !this.blinkVisible;
|
||||
this.renderSnapshot(this.lastSnapshot, true);
|
||||
}, 550);
|
||||
}
|
||||
|
||||
private renderSnapshot(snap: TerminalSnapshot, fromBlink = false): void {
|
||||
if (!this.outputEl) return;
|
||||
this.lastSnapshot = snap;
|
||||
if (this.hasActiveSelectionInOutput()) return;
|
||||
|
||||
const htmlRows: string[] = [];
|
||||
const cursorY = Math.max(0, Math.min(snap.lines.length - 1, snap.cursor.y));
|
||||
for (let rowIndex = 0; rowIndex < snap.lines.length; rowIndex++) {
|
||||
const line = snap.lines[rowIndex];
|
||||
if (!line) {
|
||||
htmlRows.push("");
|
||||
continue;
|
||||
}
|
||||
const cells = line.cells;
|
||||
let rowHtml = "";
|
||||
for (let col = 0; col < cells.length; col++) {
|
||||
const cell = cells[col];
|
||||
if (!cell) continue;
|
||||
const cursorLogicalHere = snap.cursor.visible && rowIndex === cursorY && col === snap.cursor.x;
|
||||
const cursorPaintHere = cursorLogicalHere && this.blinkVisible;
|
||||
rowHtml += this.cellToHtml(cell, cursorPaintHere, cursorLogicalHere);
|
||||
}
|
||||
htmlRows.push(rowHtml.replace(/\s+$/g, ""));
|
||||
}
|
||||
|
||||
// iOS Safari 在 innerHTML 赋值后会不稳定地改变 scrollTop。
|
||||
// autoFollow=false(键盘弹出期间)时,必须手动保存和恢复 scrollTop。
|
||||
const savedScrollTop = this.autoFollow ? -1 : this.outputEl.scrollTop;
|
||||
|
||||
this.outputEl.innerHTML = htmlRows.join("\n");
|
||||
|
||||
if (this.autoFollow && !fromBlink) {
|
||||
const clientH = this.outputEl.clientHeight;
|
||||
const maxScroll = Math.max(0, this.outputEl.scrollHeight - clientH);
|
||||
|
||||
const cursorSpan = this.outputEl.querySelector('.tc-cursor-cell') as HTMLElement;
|
||||
if (cursorSpan) {
|
||||
const cursorBottom = cursorSpan.offsetTop + cursorSpan.offsetHeight;
|
||||
let targetScrollTop = cursorBottom - clientH + 20;
|
||||
targetScrollTop = Math.max(0, Math.min(maxScroll, targetScrollTop));
|
||||
this.outputEl.scrollTop = targetScrollTop;
|
||||
} else {
|
||||
this.outputEl.scrollTop = this.outputEl.scrollHeight;
|
||||
}
|
||||
} else if (savedScrollTop >= 0) {
|
||||
// 恢复之前的 scrollTop,防止 iOS 在 innerHTML 赋值后将其重置
|
||||
const maxTop = Math.max(0, this.outputEl.scrollHeight - this.outputEl.clientHeight);
|
||||
const restoredTop = Math.min(savedScrollTop, maxTop);
|
||||
if (Math.abs(restoredTop - this.outputEl.scrollTop) > 1) {
|
||||
console.log(`[TextareaRenderer][renderSnapshot] scrollTop drifted: was=${savedScrollTop} got=${this.outputEl.scrollTop} restoring=${restoredTop} maxTop=${maxTop}`);
|
||||
this.outputEl.scrollTop = restoredTop;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private cellToHtml(cell: TerminalCell, cursorPaintHere: boolean, cursorLogicalHere: boolean): string {
|
||||
const { flags } = cell;
|
||||
let fg = colorToCss(cell.fg, true);
|
||||
let bg = colorToCss(cell.bg, false);
|
||||
if (flags & FLAG_INVERSE) {
|
||||
[fg, bg] = [bg === "transparent" ? "#ffffff" : bg, fg === "inherit" ? "#000000" : fg];
|
||||
}
|
||||
if (cursorPaintHere) {
|
||||
const cursorBg = fg === "inherit" ? "#d9d9d9" : fg;
|
||||
const cursorFg = bg === "transparent" ? "#101010" : bg;
|
||||
fg = cursorFg;
|
||||
bg = cursorBg;
|
||||
}
|
||||
|
||||
const char = (flags & FLAG_INVISIBLE) ? " " : (cell.char || " ");
|
||||
const styles: string[] = [];
|
||||
if (fg !== "inherit") styles.push(`color:${fg}`);
|
||||
if (bg !== "transparent") styles.push(`background:${bg}`);
|
||||
if (flags & FLAG_BOLD) styles.push("font-weight:bold");
|
||||
if (flags & FLAG_DIM) styles.push("opacity:0.5");
|
||||
if (flags & FLAG_ITALIC) styles.push("font-style:italic");
|
||||
const tdecs: string[] = [];
|
||||
if (flags & FLAG_UNDERLINE) tdecs.push("underline");
|
||||
if (flags & FLAG_STRIKETHROUGH) tdecs.push("line-through");
|
||||
if (flags & FLAG_OVERLINE) tdecs.push("overline");
|
||||
if (tdecs.length) styles.push(`text-decoration:${tdecs.join(" ")}`);
|
||||
|
||||
const escaped = escapeHtml(char);
|
||||
const classAttr = cursorLogicalHere ? ` class="tc-cursor-cell"` : "";
|
||||
if (!styles.length) {
|
||||
if (!cursorLogicalHere) return escaped;
|
||||
return `<span${classAttr}>${escaped}</span>`;
|
||||
}
|
||||
return `<span${classAttr} style="${styles.join(";")}">${escaped}</span>`;
|
||||
}
|
||||
|
||||
private hasActiveSelectionInOutput(): boolean {
|
||||
if (!this.outputEl || typeof window === "undefined") return false;
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return false;
|
||||
const range = selection.getRangeAt(0);
|
||||
const node = range.commonAncestorContainer;
|
||||
return this.outputEl.contains(node.nodeType === Node.TEXT_NODE ? node.parentNode : node);
|
||||
}
|
||||
}
|
||||
539
terminal/apps/web/src/terminal/stores/useTerminalStore.ts
Normal file
539
terminal/apps/web/src/terminal/stores/useTerminalStore.ts
Normal file
@@ -0,0 +1,539 @@
|
||||
/**
|
||||
* useTerminalStore — 新 terminal-core 架构的独立 Pinia store。
|
||||
*
|
||||
* 职责:
|
||||
* - 管理 transport / renderer / outputBuffer 生命周期
|
||||
* - 对外暴露 state / title / latencyMs / rendererMode 响应式状态
|
||||
* - 提供 connect / disconnect / sendInput / clearTerminal / switchRenderer / mountRenderer
|
||||
*
|
||||
* 刻意与 sessionStore 分离,保持实验性单一职责。
|
||||
*/
|
||||
import { defineStore } from "pinia";
|
||||
import { ref, shallowRef } from "vue";
|
||||
import { OutputBuffer, SessionMachine, sanitizeTerminalOutput } from "@remoteconn/terminal-core";
|
||||
import type { ConnectParams, SessionState } from "@remoteconn/terminal-core";
|
||||
import type { TerminalTransport } from "@/services/transport/terminalTransport";
|
||||
import { createTransport } from "@/services/transport/factory";
|
||||
import { TextareaRenderer } from "@/terminal/renderer/textareaRenderer";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
|
||||
export type RendererMode = "textarea";
|
||||
|
||||
function logStore(_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 = `[TerminalStore][${new Date().toISOString()}] ${_message}`;
|
||||
if (typeof _detail === "undefined") {
|
||||
console.log(prefix);
|
||||
return;
|
||||
}
|
||||
console.log(prefix, _detail);
|
||||
}
|
||||
|
||||
export const useTerminalStore = defineStore("terminal-core", () => {
|
||||
const OUTPUT_CACHE_PREFIX = "remoteconn:web:output-cache:v1:";
|
||||
const PASTE_CHUNK_SIZE = 256;
|
||||
const DEFAULT_CHUNK_SIZE = 2048;
|
||||
|
||||
// ── 持久配置 ─────────────────────────────────────────────────────────────
|
||||
const rendererMode = ref<RendererMode>("textarea");
|
||||
|
||||
// ── 响应式状态 ───────────────────────────────────────────────────────────
|
||||
const state = ref<SessionState>("idle");
|
||||
const title = ref<string>("");
|
||||
const latencyMs = ref<number>(0);
|
||||
|
||||
// ── 非响应式内部对象 ──────────────────────────────────────────────────────
|
||||
const machine = new SessionMachine();
|
||||
const buffer = new OutputBuffer({ maxEntries: 500, maxBytes: 256 * 1024 });
|
||||
|
||||
const renderer = shallowRef<TextareaRenderer | null>(null);
|
||||
let transport : TerminalTransport | null = null;
|
||||
let offTransport: (() => void) | null = null;
|
||||
let resizeTimer: number | null = null;
|
||||
let pendingResize: { cols: number; rows: number } | null = null;
|
||||
let latestLocalSize: { cols: number; rows: number } | null = null;
|
||||
let lastRemoteSize: { cols: number; rows: number } | null = null;
|
||||
let remoteResizePaused = false;
|
||||
let suppressAutoFollowUntil = 0;
|
||||
const RESIZE_DEBOUNCE_MS = 140;
|
||||
|
||||
let mountedContainer: HTMLElement | null = null;
|
||||
let outputCacheKey: string | null = null;
|
||||
|
||||
function persistOutputCache(): void {
|
||||
if (!outputCacheKey || typeof window === "undefined") return;
|
||||
try {
|
||||
window.sessionStorage.setItem(outputCacheKey, JSON.stringify(buffer.getAll()));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定会话输出缓存。
|
||||
* - scope 建议使用 serverId + clientSessionKey,避免串线;
|
||||
* - 仅在当前 buffer 为空时回放缓存,防止运行中重复叠加。
|
||||
*/
|
||||
function bindOutputCache(scope: string): void {
|
||||
if (!scope || typeof window === "undefined") return;
|
||||
const nextKey = `${OUTPUT_CACHE_PREFIX}${scope}`;
|
||||
if (outputCacheKey === nextKey) return;
|
||||
outputCacheKey = nextKey;
|
||||
|
||||
if (buffer.size > 0) return;
|
||||
|
||||
let restoredAny = false;
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(nextKey);
|
||||
if (!raw) return;
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) return;
|
||||
for (const item of parsed) {
|
||||
if (typeof item !== "string") continue;
|
||||
const sanitized = sanitizeTerminalOutput(item);
|
||||
if (!sanitized) continue;
|
||||
buffer.push(sanitized);
|
||||
restoredAny = true;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (restoredAny && renderer.value) {
|
||||
for (const chunk of buffer.getAll()) {
|
||||
renderer.value.write(chunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearBoundOutputCache(): void {
|
||||
if (!outputCacheKey || typeof window === "undefined") return;
|
||||
try {
|
||||
window.sessionStorage.removeItem(outputCacheKey);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// ── 渲染器挂载点 ─────────────────────────────────────────────────────────
|
||||
/**
|
||||
* 在 TerminalViewport.vue 的 onMounted 后调用,将渲染器挂载到 DOM 容器。
|
||||
* 切换渲染器时也需重新调用(已由 switchRenderer 内部处理)。
|
||||
*/
|
||||
function mountRenderer(container: HTMLElement): void {
|
||||
mountedContainer = container;
|
||||
|
||||
if (!renderer.value) {
|
||||
renderer.value = new TextareaRenderer();
|
||||
}
|
||||
|
||||
renderer.value.mount(container);
|
||||
|
||||
// 重放当前 buffer(初次挂载 / 切换后恢复历史)
|
||||
for (const chunk of buffer.getAll()) {
|
||||
renderer.value.write(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 原子切换渲染模式:dispose → 新建 → 重放 buffer → re-mount。
|
||||
*/
|
||||
function switchRenderer(mode: RendererMode): void {
|
||||
if (rendererMode.value === mode) return;
|
||||
rendererMode.value = "textarea";
|
||||
}
|
||||
|
||||
// ── Transport 事件处理 ───────────────────────────────────────────────────
|
||||
function bindTransport(t: TerminalTransport): void {
|
||||
logStore("transport:bind");
|
||||
offTransport?.();
|
||||
offTransport = t.on((event) => {
|
||||
if (event.type === "stdout" || event.type === "stderr") {
|
||||
logStore("transport:event", { type: event.type, length: event.data.length, machineState: machine.state, state: state.value });
|
||||
} else {
|
||||
logStore("transport:event", { type: event.type, machineState: machine.state, state: state.value, event });
|
||||
}
|
||||
if (event.type === "stdout" || event.type === "stderr") {
|
||||
if (state.value !== "connected" && machine.state === "connecting") {
|
||||
machine.tryTransition("auth_pending");
|
||||
}
|
||||
if (state.value !== "connected" && machine.tryTransition("connected")) {
|
||||
state.value = machine.state;
|
||||
}
|
||||
const sanitized = sanitizeTerminalOutput(event.data);
|
||||
if (sanitized) {
|
||||
// 统一行为:有新输出刷新时保持底部可见(提示符/最新输出不离开视口)。
|
||||
// 例外:键盘收起恢复窗口内,临时抑制 auto-follow,避免覆盖刚恢复的滚动位置。
|
||||
const now = Date.now();
|
||||
if (now >= suppressAutoFollowUntil) {
|
||||
console.log(`[TerminalStore][stdout] followBottom() called, len=${sanitized.length}`);
|
||||
renderer.value?.followBottom();
|
||||
} else {
|
||||
console.log(`[TerminalStore][stdout] followBottom SUPPRESSED, remainMs=${suppressAutoFollowUntil - now}, len=${sanitized.length}`);
|
||||
logStore("follow:skipped_suppressed", { remainMs: suppressAutoFollowUntil - now });
|
||||
}
|
||||
buffer.push(sanitized);
|
||||
renderer.value?.write(sanitized);
|
||||
persistOutputCache();
|
||||
}
|
||||
} else if (event.type === "connected") {
|
||||
if (machine.state === "connecting") {
|
||||
machine.tryTransition("auth_pending");
|
||||
}
|
||||
machine.tryTransition("connected");
|
||||
state.value = machine.state;
|
||||
} else if (event.type === "disconnect") {
|
||||
console.log(`[TerminalStore][${new Date().toISOString()}] transport:disconnect`, { reason: event.reason });
|
||||
console.error(`[TerminalStore][${new Date().toISOString()}] transport:disconnect`, { reason: event.reason });
|
||||
logStore("transport:disconnect", { reason: event.reason });
|
||||
machine.tryTransition("disconnected");
|
||||
state.value = machine.state;
|
||||
} else if (event.type === "latency") {
|
||||
latencyMs.value = event.data;
|
||||
} else if (event.type === "error") {
|
||||
console.log(`[TerminalStore][${new Date().toISOString()}] transport:error`, { code: event.code, message: event.message });
|
||||
console.error(`[TerminalStore][${new Date().toISOString()}] transport:error`, { code: event.code, message: event.message });
|
||||
logStore("transport:error", { code: event.code, message: event.message });
|
||||
machine.tryTransition("error");
|
||||
state.value = machine.state;
|
||||
} else if ((event as unknown as { type?: string; action?: string }).type === "control") {
|
||||
const action = (event as unknown as { action?: string }).action;
|
||||
if (action === "connected") {
|
||||
machine.tryTransition("connected");
|
||||
state.value = machine.state;
|
||||
} else if (action === "disconnect") {
|
||||
machine.tryTransition("disconnected");
|
||||
state.value = machine.state;
|
||||
} else if (action === "pong") {
|
||||
latencyMs.value = 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── 公开 API ──────────────────────────────────────────────────────────────
|
||||
async function connect(params: ConnectParams): Promise<void> {
|
||||
logStore("connect:requested", { state: state.value, host: params.host, port: params.port, username: params.username, cols: params.cols, rows: params.rows });
|
||||
if (state.value === "connected" || state.value === "connecting") {
|
||||
logStore("connect:skip", { reason: "already_connected_or_connecting", state: state.value });
|
||||
return;
|
||||
}
|
||||
|
||||
machine.tryTransition("connecting");
|
||||
state.value = machine.state;
|
||||
logStore("state:update", { machineState: machine.state, state: state.value });
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
await settingsStore.ensureBootstrapped();
|
||||
const gatewayUrl = settingsStore.gatewayUrl as string;
|
||||
const gatewayToken = settingsStore.gatewayToken as string;
|
||||
const isIos = !!(window as unknown as { Capacitor?: { isNativePlatform?: () => boolean } })
|
||||
.Capacitor?.isNativePlatform?.();
|
||||
logStore("connect:settings_ready", {
|
||||
gatewayUrl,
|
||||
hasGatewayToken: Boolean(gatewayToken),
|
||||
transportMode: isIos ? "ios-native" : "gateway"
|
||||
});
|
||||
|
||||
transport = createTransport(
|
||||
isIos ? "ios-native" : "gateway",
|
||||
{ gatewayUrl, gatewayToken }
|
||||
);
|
||||
logStore("transport:created", { transportMode: isIos ? "ios-native" : "gateway" });
|
||||
bindTransport(transport);
|
||||
|
||||
const currentTransport = transport;
|
||||
|
||||
try {
|
||||
logStore("connect:transport_connect_begin");
|
||||
await currentTransport.connect(params);
|
||||
logStore("connect:transport_connect_resolved");
|
||||
|
||||
// If disconnected or transport changed during await
|
||||
if (state.value === "disconnected" || transport !== currentTransport) {
|
||||
logStore("connect:stale_result_ignored", { state: state.value, transportChanged: transport !== currentTransport });
|
||||
return;
|
||||
}
|
||||
|
||||
machine.tryTransition("auth_pending");
|
||||
state.value = machine.state;
|
||||
// init 帧会携带首个 pty 尺寸,这里记录为“远端当前尺寸”基线,避免后续重复下发。
|
||||
lastRemoteSize = { cols: params.cols, rows: params.rows };
|
||||
logStore("state:update", { machineState: machine.state, state: state.value });
|
||||
} catch (err) {
|
||||
if (state.value === "disconnected" || transport !== currentTransport) {
|
||||
logStore("connect:error_ignored_after_disconnect", { state: state.value, transportChanged: transport !== currentTransport, err });
|
||||
return;
|
||||
}
|
||||
machine.tryTransition("error");
|
||||
state.value = machine.state;
|
||||
logStore("state:update", { machineState: machine.state, state: state.value, err });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function disconnect(reason = "manual"): void {
|
||||
logStore("disconnect:requested", { reason, state: state.value });
|
||||
if (transport) {
|
||||
void transport.disconnect(reason).catch((err) => {
|
||||
logStore("disconnect:transport_failed", { reason, err: String(err) });
|
||||
});
|
||||
}
|
||||
offTransport?.();
|
||||
offTransport = null;
|
||||
transport = null;
|
||||
if (resizeTimer !== null) {
|
||||
window.clearTimeout(resizeTimer);
|
||||
resizeTimer = null;
|
||||
}
|
||||
pendingResize = null;
|
||||
latestLocalSize = null;
|
||||
lastRemoteSize = null;
|
||||
remoteResizePaused = false;
|
||||
if (reason === "manual") {
|
||||
clearBoundOutputCache();
|
||||
}
|
||||
machine.tryTransition("disconnected");
|
||||
state.value = machine.state;
|
||||
logStore("state:update", { machineState: machine.state, state: state.value });
|
||||
}
|
||||
|
||||
function sendInput(data: string, source: "keyboard" | "assist" | "paste" = "keyboard"): void {
|
||||
if (!transport || state.value !== "connected") {
|
||||
console.log(`[TerminalStore][${new Date().toISOString()}] stdin:dropped`, {
|
||||
source,
|
||||
length: data.length,
|
||||
hasTransport: Boolean(transport),
|
||||
state: state.value
|
||||
});
|
||||
return;
|
||||
}
|
||||
const currentTransport = transport;
|
||||
// 统一行为:回车/换行、Ctrl+C、Ctrl+D、粘贴/辅助输入都强制回到底部。
|
||||
const shouldForceFollow =
|
||||
data.includes("\r") ||
|
||||
data.includes("\n") ||
|
||||
data.includes("\u0003") ||
|
||||
data.includes("\u0004") ||
|
||||
source !== "keyboard";
|
||||
if (shouldForceFollow) {
|
||||
renderer.value?.followBottom();
|
||||
}
|
||||
// 网关现网 schema 在 stdin 的 union 校验上存在差异:
|
||||
// - 部分环境不接受 meta.source = "paste";
|
||||
// - 部分环境对 paste 分支携带 meta 也会触发 invalid_union。
|
||||
// 因此 paste 统一走“无 meta”发送,keyboard/assist 保持原语义。
|
||||
const wireSource: "keyboard" | "assist" = source === "paste" ? "keyboard" : source;
|
||||
const wireMeta = source === "paste" ? undefined : { source: wireSource };
|
||||
// 大文本(尤其粘贴)分片发送,避免单帧过大触发网关/WS 断连。
|
||||
const chunkSize = source === "paste" ? PASTE_CHUNK_SIZE : DEFAULT_CHUNK_SIZE;
|
||||
const totalChunks = Math.ceil(data.length / chunkSize);
|
||||
if (source === "paste") {
|
||||
console.log(`[TerminalStore][${new Date().toISOString()}] stdin:dispatch`, {
|
||||
source,
|
||||
wireSource,
|
||||
wireMetaSource: wireMeta?.source ?? "none",
|
||||
length: data.length,
|
||||
chunkSize,
|
||||
totalChunks
|
||||
});
|
||||
}
|
||||
logStore("stdin:dispatch", { source, length: data.length, chunkSize, totalChunks });
|
||||
if (data.length <= chunkSize) {
|
||||
void currentTransport.send(data, wireMeta).catch((err) => {
|
||||
console.error(`[TerminalStore][${new Date().toISOString()}] stdin:send_failed`, { source, index: 1, totalChunks: 1, length: data.length, err: String(err) });
|
||||
logStore("stdin:send_failed", { source, index: 1, totalChunks: 1, length: data.length, err: String(err) });
|
||||
});
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < data.length; i += chunkSize) {
|
||||
const chunk = data.slice(i, i + chunkSize);
|
||||
const index = i / chunkSize + 1;
|
||||
if (source === "paste") {
|
||||
console.log(`[TerminalStore][${new Date().toISOString()}] stdin:chunk`, { source, index, totalChunks, length: chunk.length });
|
||||
}
|
||||
logStore("stdin:chunk", { source, index, totalChunks, length: chunk.length });
|
||||
void currentTransport.send(chunk, wireMeta).catch((err) => {
|
||||
console.error(`[TerminalStore][${new Date().toISOString()}] stdin:send_failed`, { source, index, totalChunks, length: chunk.length, err: String(err) });
|
||||
logStore("stdin:send_failed", { source, index, totalChunks, length: chunk.length, err: String(err) });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function clearTerminal(): void {
|
||||
if (transport && state.value === "connected") {
|
||||
// 连接态使用 Ctrl+L,让远端 shell 清屏并重绘提示符,避免提示符消失。
|
||||
sendInput("\x0c", "keyboard");
|
||||
return;
|
||||
}
|
||||
|
||||
// 未连接时执行本地硬清屏。
|
||||
buffer.clear();
|
||||
renderer.value?.write("\x1b[2J\x1b[H");
|
||||
persistOutputCache();
|
||||
}
|
||||
|
||||
function isSameSize(
|
||||
a: { cols: number; rows: number } | null,
|
||||
b: { cols: number; rows: number } | null
|
||||
): boolean {
|
||||
return Boolean(a && b && a.cols === b.cols && a.rows === b.rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* 安排一次“向远端 PTY 下发尺寸”的去抖任务。
|
||||
* - 仅连接态生效;
|
||||
* - 若尺寸与最近成功下发的一致则跳过,避免无效回刷;
|
||||
* - 任务触发时若处于 paused 状态则直接丢弃,由恢复流程统一补发最终尺寸。
|
||||
*/
|
||||
function scheduleRemoteResize(cols: number, rows: number): void {
|
||||
pendingResize = { cols, rows };
|
||||
if (resizeTimer !== null) {
|
||||
window.clearTimeout(resizeTimer);
|
||||
resizeTimer = null;
|
||||
}
|
||||
resizeTimer = window.setTimeout(() => {
|
||||
resizeTimer = null;
|
||||
if (remoteResizePaused) return;
|
||||
if (!pendingResize) return;
|
||||
const next = pendingResize;
|
||||
pendingResize = null;
|
||||
if (!transport || state.value !== "connected") return;
|
||||
if (isSameSize(lastRemoteSize, next)) {
|
||||
logStore("pty:resize_skipped_same", { cols: next.cols, rows: next.rows });
|
||||
return;
|
||||
}
|
||||
void transport.resize(next.cols, next.rows)
|
||||
.then(() => {
|
||||
lastRemoteSize = next;
|
||||
})
|
||||
.catch((err) => {
|
||||
logStore("pty:resize_failed", { cols: next.cols, rows: next.rows, state: state.value, err: String(err) });
|
||||
});
|
||||
}, RESIZE_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 控制“远端 PTY resize”是否暂停。
|
||||
* 典型用途:软键盘动画期间只做本地 resize,避免远端收到中间尺寸后回刷覆盖滚动恢复。
|
||||
*/
|
||||
function setRemoteResizePaused(paused: boolean): void {
|
||||
if (remoteResizePaused === paused) return;
|
||||
remoteResizePaused = paused;
|
||||
logStore("pty:resize_pause_changed", { paused });
|
||||
|
||||
if (paused) {
|
||||
// 进入暂停态时清空未发送任务,防止中间尺寸被发到远端。
|
||||
if (resizeTimer !== null) {
|
||||
window.clearTimeout(resizeTimer);
|
||||
resizeTimer = null;
|
||||
}
|
||||
pendingResize = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// 退出暂停态后补发当前最新本地尺寸(若与远端一致会被 schedule 自动跳过)。
|
||||
if (!transport || state.value !== "connected") return;
|
||||
if (!latestLocalSize) return;
|
||||
scheduleRemoteResize(latestLocalSize.cols, latestLocalSize.rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在指定时间窗口内临时抑制 “stdout/stderr 触发的 followBottom”。
|
||||
* 用于软键盘收起后的滚动恢复保护,避免远端 resize 回刷覆盖恢复位置。
|
||||
*/
|
||||
function suppressAutoFollow(ms: number): void {
|
||||
const nextUntil = Date.now() + Math.max(0, ms);
|
||||
// ms=0 时强制重置(键盘关闭时清除之前设置的长窗口)
|
||||
if (ms > 0 && nextUntil <= suppressAutoFollowUntil) return;
|
||||
suppressAutoFollowUntil = nextUntil;
|
||||
console.log(`[TerminalStore][suppressAutoFollow] ms=${ms} until=${ms === 0 ? "CLEARED" : new Date(suppressAutoFollowUntil).toISOString()}`);
|
||||
logStore("follow:suppressed", { ms, until: suppressAutoFollowUntil });
|
||||
}
|
||||
|
||||
function resizeTerminal(cols: number, rows: number): void {
|
||||
renderer.value?.resize(cols, rows);
|
||||
latestLocalSize = { cols, rows };
|
||||
if (!transport) return;
|
||||
// 仅连接态下向网关发送 resize,避免连接未建立时出现未处理 Promise 拒绝。
|
||||
if (state.value !== "connected") return;
|
||||
if (remoteResizePaused) {
|
||||
logStore("pty:resize_paused_skip", { cols, rows });
|
||||
return;
|
||||
}
|
||||
|
||||
// 仅对“发给远端 PTY”的 resize 去抖:
|
||||
// - 软键盘动画/视口抖动期间会短时间出现多次尺寸跳变(例如 19 -> 12 -> 19);
|
||||
// - 直接逐次下发会触发远端多次重排,导致提示符位置抖动;
|
||||
// - 本地渲染仍即时 resize,保证交互跟手;远端仅发送稳定后的最终尺寸。
|
||||
scheduleRemoteResize(cols, rows);
|
||||
}
|
||||
|
||||
/** 获取当前活跃 TerminalCore(用于 cursorMath、测量等)。 */
|
||||
function getCore() {
|
||||
return renderer.value?.getCore() ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取光标行距输出区顶部的像素偏移。
|
||||
* 用于软键盘弹出时定位提示符行。
|
||||
*/
|
||||
function getCursorOffsetPx(): number | null {
|
||||
return renderer.value?.getCursorOffsetPx() ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取输出滚动容器 DOM 元素。
|
||||
*/
|
||||
function getOutputEl(): HTMLElement | null {
|
||||
return renderer.value?.getOutputEl() ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置输出区是否自动跟随滚动到底部。
|
||||
*/
|
||||
function setAutoFollow(enabled: boolean): void {
|
||||
renderer.value?.setAutoFollow(enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* 冻结 scroll 事件对 autoFollow 的影响,持续 ms 毫秒(iOS scroll 事件是异步的)。
|
||||
* ms=0 立即解冻。
|
||||
*/
|
||||
function setScrollLocked(ms: number): void {
|
||||
renderer.value?.setScrollLocked(ms);
|
||||
}
|
||||
|
||||
return {
|
||||
// 响应式
|
||||
state,
|
||||
title,
|
||||
latencyMs,
|
||||
rendererMode,
|
||||
renderer,
|
||||
// 方法
|
||||
mountRenderer,
|
||||
switchRenderer,
|
||||
bindOutputCache,
|
||||
clearBoundOutputCache,
|
||||
connect,
|
||||
disconnect,
|
||||
sendInput,
|
||||
clearTerminal,
|
||||
resizeTerminal,
|
||||
setRemoteResizePaused,
|
||||
suppressAutoFollow,
|
||||
getCore,
|
||||
getCursorOffsetPx,
|
||||
getOutputEl,
|
||||
setAutoFollow,
|
||||
setScrollLocked,
|
||||
};
|
||||
});
|
||||
81
terminal/apps/web/src/utils/feedback.ts
Normal file
81
terminal/apps/web/src/utils/feedback.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
function asMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return String(error.message || "");
|
||||
}
|
||||
if (typeof error === "string") {
|
||||
return error;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(error);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeText(input: string): string {
|
||||
return input.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function toFriendlyConnectionError(error: unknown): string {
|
||||
const message = asMessage(error);
|
||||
const lower = normalizeText(message);
|
||||
|
||||
if (lower.includes("rate_limit") || message.includes("连接过于频繁")) {
|
||||
return "连接过于频繁,请稍后重试。";
|
||||
}
|
||||
|
||||
if (lower.includes("auth_failed") || message.includes("token 无效")) {
|
||||
return "网关鉴权失败,请联系管理员检查网关令牌。";
|
||||
}
|
||||
|
||||
if (message.includes("SSH 认证失败")) {
|
||||
return "SSH 认证失败。请检查账号/凭据。";
|
||||
}
|
||||
|
||||
if (message.includes("Timed out while waiting for handshake") || message.includes("连接超时") || lower.includes("timeout")) {
|
||||
return "连接超时。请检查服务器地址、端口和网络连通性。";
|
||||
}
|
||||
|
||||
if (message.includes("无法连接网关") || lower.includes("ws_closed") || lower.includes("websocket")) {
|
||||
return "无法连接网关,请检查网关地址、服务状态与网络策略。";
|
||||
}
|
||||
|
||||
if (!message) {
|
||||
return "连接失败,请稍后重试。";
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
export function toFriendlyError(error: unknown): string {
|
||||
const message = asMessage(error);
|
||||
const lower = normalizeText(message);
|
||||
|
||||
if (!message) {
|
||||
return "操作失败,请稍后重试。";
|
||||
}
|
||||
|
||||
if (
|
||||
lower.includes("ws_") ||
|
||||
lower.includes("websocket") ||
|
||||
lower.includes("auth_failed") ||
|
||||
lower.includes("rate_limit") ||
|
||||
message.includes("连接") ||
|
||||
message.includes("网关") ||
|
||||
message.includes("SSH")
|
||||
) {
|
||||
return toFriendlyConnectionError(message);
|
||||
}
|
||||
|
||||
if (message.includes("会话未连接")) {
|
||||
return "当前会话未连接,请先建立连接。";
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
export function formatActionError(action: string, error: unknown): string {
|
||||
const detail = toFriendlyError(error);
|
||||
return `${action}:${detail}`;
|
||||
}
|
||||
|
||||
31
terminal/apps/web/src/utils/runtimeConfig.ts
Normal file
31
terminal/apps/web/src/utils/runtimeConfig.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export interface RuntimeConfig {
|
||||
gatewayUrl?: string;
|
||||
gatewayToken?: string;
|
||||
selectedServerId?: string;
|
||||
servers?: unknown;
|
||||
}
|
||||
|
||||
let runtimeConfigPromise: Promise<RuntimeConfig | null> | null = null;
|
||||
|
||||
export async function loadRuntimeConfig(): Promise<RuntimeConfig | null> {
|
||||
if (runtimeConfigPromise) return runtimeConfigPromise;
|
||||
runtimeConfigPromise = (async () => {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const response = await fetch("/terminal.config.json", { cache: "no-store" });
|
||||
if (!response.ok) return null;
|
||||
const raw = await response.json() as unknown;
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const obj = raw as Record<string, unknown>;
|
||||
return {
|
||||
gatewayUrl: typeof obj.gatewayUrl === "string" ? obj.gatewayUrl : undefined,
|
||||
gatewayToken: typeof obj.gatewayToken === "string" ? obj.gatewayToken : undefined,
|
||||
selectedServerId: typeof obj.selectedServerId === "string" ? obj.selectedServerId : undefined,
|
||||
servers: obj.servers
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
return runtimeConfigPromise;
|
||||
}
|
||||
19
terminal/apps/web/tsconfig.json
Normal file
19
terminal/apps/web/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"types": ["vite/client"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue", "vitest.config.ts", "vite.config.ts"]
|
||||
}
|
||||
53
terminal/apps/web/vite.config.ts
Normal file
53
terminal/apps/web/vite.config.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
import fs from "node:fs";
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
|
||||
const DEV_HOST = "0.0.0.0";
|
||||
const DEV_PORT = 5173;
|
||||
const DEV_PUBLIC_HOST = "shell.biboer.cn";
|
||||
const DEV_CERT_PATH = "/Users/gavin/.acme.sh/shell.biboer.cn_ecc/fullchain.cer";
|
||||
const DEV_KEY_PATH = "/Users/gavin/.acme.sh/shell.biboer.cn_ecc/shell.biboer.cn.key";
|
||||
|
||||
function resolveDevHttpsConfig() {
|
||||
// 优先复用 acme.sh 证书,确保本地开发服务直接以 HTTPS 暴露。
|
||||
if (!fs.existsSync(DEV_CERT_PATH) || !fs.existsSync(DEV_KEY_PATH)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
cert: fs.readFileSync(DEV_CERT_PATH),
|
||||
key: fs.readFileSync(DEV_KEY_PATH)
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
host: DEV_HOST,
|
||||
port: DEV_PORT,
|
||||
strictPort: true,
|
||||
https: resolveDevHttpsConfig(),
|
||||
// 允许通过外网域名访问 dev server(含 HMR websocket 握手)。
|
||||
allowedHosts: [DEV_PUBLIC_HOST],
|
||||
// 明确 HMR 走外网域名,避免客户端回退到 localhost 导致连接拒绝。
|
||||
hmr: {
|
||||
protocol: "wss",
|
||||
host: DEV_PUBLIC_HOST,
|
||||
clientPort: DEV_PORT,
|
||||
port: DEV_PORT
|
||||
},
|
||||
proxy: {
|
||||
"/ws/terminal": {
|
||||
target: "ws://127.0.0.1:8787",
|
||||
ws: true,
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": fileURLToPath(new URL("./src", import.meta.url))
|
||||
}
|
||||
}
|
||||
});
|
||||
11
terminal/apps/web/vitest.config.ts
Normal file
11
terminal/apps/web/vitest.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig, mergeConfig } from "vitest/config";
|
||||
import viteConfig from "./vite.config";
|
||||
|
||||
export default mergeConfig(
|
||||
viteConfig,
|
||||
defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
})
|
||||
);
|
||||
Reference in New Issue
Block a user