first commit

This commit is contained in:
douboer@gmail.com
2026-03-03 13:23:14 +08:00
commit 3b7c1d558a
161 changed files with 28120 additions and 0 deletions

View 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
View 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;
}

View 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");

View 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);
}

View 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
}
}
}

View 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);
}
}
}

View File

@@ -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;
}

View 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
};
});

View 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
};
});

View 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
};
});

View 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;
}

View 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>

View File

@@ -0,0 +1,229 @@
<script setup lang="ts">
/**
* TerminalInputBar — 原生 textarea 输入锚点 + 工具栏输入行。
*
* 职责:
* - 承载软键盘弹出的原生 textareaIME 主通道)
* - 通过 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>

View 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>

View 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>

View File

@@ -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>

View 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();
}
}

View 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);
}
}

View 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("功能键应返回 trueEnter", () => {
expect(shouldHandleKeydownDirectly(makePayload({ key: "Enter" }))).toBe(true);
});
it("方向键应返回 trueArrowUp", () => {
expect(shouldHandleKeydownDirectly(makePayload({ key: "ArrowUp" }))).toBe(true);
});
it("组合键应返回 trueCtrl+C", () => {
expect(shouldHandleKeydownDirectly(makePayload({ key: "c", ctrlKey: true }))).toBe(true);
});
});

View 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);
}

View 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=falsescroll 事件可能在时间窗口内把它改回 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) {
// 在底部:恢复 autoFollow700ms 内保护远端 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;
}
}

View 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);
}
}

View 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 === "&" ? "&amp;" : char === "<" ? "&lt;" : char === ">" ? "&gt;" : char;
if (!styles.length) return escChar;
return `<span style="${styles.join(";")}">${escChar}</span>`;
}

View 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 "&amp;";
if (ch === "<") return "&lt;";
if (ch === ">") return "&gt;";
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);
}
}

View 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,
};
});

View 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}`;
}

View 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;
}