1055 lines
52 KiB
Markdown
1055 lines
52 KiB
Markdown
# xterm 移植跨平台终端核心架构方案(2026-03-01)
|
||
|
||
## 1. 目标与范围
|
||
|
||
本方案构建一个**生产可用的跨平台终端核心库**,将 xterm.js 的 VT 仿真能力移植为独立的、无 DOM 依赖的 TypeScript 包(`packages/terminal-core`),供 Web 端、iOS 端(Capacitor WKWebView)、微信小程序端三个平台共用,各端仅需实现 Transport 与 Renderer 两个平台边界。
|
||
|
||
规范基线:
|
||
|
||
1. 渲染与输入语义以 `xterm.js@5.3.0` 源码为准。
|
||
2. 文档中的换行、回显、光标、粘贴规则均以该版本源码行为校正。
|
||
3. 若未来升级 xterm 版本,需先做"规范差异审计"再修改本方案。
|
||
|
||
目标:
|
||
|
||
1. **核心包独立**:`packages/terminal-core` 无 DOM 依赖,可在任意 JS Runtime(浏览器/WKWebView/微信小程序逻辑层)中运行。
|
||
2. **三端生产可用**:Web(Vue SPA)、iOS(Capacitor WKWebView 加载同一 Web 构建产物)、微信小程序(原生 WXML 组件 + 逻辑层复用 terminal-core)。
|
||
3. **平台边界最薄**:各端只需实现 `TerminalTransport`(网络层)和 `RendererAdapter`(渲染层)两个接口,Session/VT 仿真/输入映射全部共用。
|
||
4. **SSH 协议外置**:SSH 握手与流转发由 `apps/gateway`(WebSocket)或 iOS 原生插件负责,`terminal-core` 只处理 PTY stdout/stderr 字节流。
|
||
5. **构建/运行可复用**:命令约定统一为 `dev/build/test/lint/typecheck`。
|
||
|
||
## 2. 非目标
|
||
|
||
1. 不在本方案中实现 SSH 协议本身(由 `apps/gateway` 或 iOS 原生 Swift 插件负责)。
|
||
2. 不修改 `apps/gateway` 的业务协议和转发逻辑。
|
||
3. 不改 `packages/shared` 的现有模型定义。
|
||
4. 不实现图片/Sixel/Kitty 图像协议渲染。
|
||
|
||
### 2.1 明确不实现的 xterm 能力
|
||
|
||
以下能力在本方案中确认跳过,遇到相关需求时应主动忽略,不可因"部分实现"引入不稳定行为:
|
||
|
||
1. Kitty 键盘协议(`CSI =u / >u / <u / ?u`,`KittyKeyboardFlags`)。
|
||
2. Win32InputMode(Windows 伪终端特有输入模式)。
|
||
3. 鼠标跟踪模式(`?1000 / ?1002 / ?1003` 及 `onBinary` 二进制鼠标上报通道)。
|
||
4. `screenReaderMode` 及 ARIA 无障碍支持。
|
||
5. Markers / Decorations(`registerMarker / registerDecoration`)。
|
||
6. Link Provider(`registerLinkProvider`)。
|
||
7. CharacterJoiner(`registerCharacterJoiner / deregisterCharacterJoiner`,仅 WebGL 渲染器使用)。
|
||
8. LigaturesAddon / ImageAddon(Sixel/Kitty 图片协议)。
|
||
9. SerializeAddon(终端状态序列化,由 `OutputBuffer` 承担类似职责)。
|
||
10. Unicode Grapheme Clusters(`addon-unicode-graphemes`,emoji 字形簇宽度计算)。
|
||
11. DECSCA(字符保护,`CSI "q`)与 DECRQM(模式查询,`CSI $p / ?$p`)。
|
||
12. `macOptionIsMeta`、`altClickMovesCursor`、`rightClickSelectsWord`(桌面端特有选项)。
|
||
13. OSC 52(剪贴板写入)、OSC 133(Shell 集成/语义标记)及其它非标 OSC 扩展。
|
||
14. `attachCustomKeyEventHandler / attachCustomWheelEventHandler` 公开 API(通过 `eventOwnershipRouter` 统一管理,不暴露此 API)。
|
||
|
||
## 3. 独立性与复用规则
|
||
|
||
### 3.1 必须满足
|
||
|
||
1. `packages/terminal-core` 不得 `import` `apps/*` 的业务模块;`apps/*` 可单向依赖 `terminal-core`。
|
||
2. 禁止运行时依赖 `@xterm/*` 包;xterm 仅作为源码语义参考,不作为可执行依赖。
|
||
3. `terminal-core` 构建产物在零 DOM 环境(微信小程序逻辑层)中可直接运行。
|
||
4. 各平台的 Transport / Renderer / InputBridge 实现隔离在各自 `apps/` 或平台目录内,不得混用。
|
||
|
||
### 3.2 允许复用
|
||
|
||
1. 依赖版本策略可复用(例如与根工程保持同主版本)。
|
||
2. 工程工具可复用(TypeScript、Vite、ESLint、Vitest 配置风格)。
|
||
3. 运行方式可复用(统一命令命名,不要求命令实现完全一致)。
|
||
4. 主题 token 可复用现有 Web/小程序的成熟定义(颜色变量、语义色分层、主题切换策略),但实现代码保留在各端目录内。
|
||
|
||
### 3.3 核心包 DOM 隔离约束(强制)
|
||
|
||
1. `packages/terminal-core/src/` 内所有文件**禁止**直接引用 `document / window / HTMLElement / Event / Canvas / CanvasRenderingContext2D` 等任何 DOM/BOM API。
|
||
2. 平台相关的测量(字符宽高、容器尺寸)以 `IMeasureAdapter` 接口注入;事件来源(键盘/IME/触摸)以 `IInputSource` 接口注入;`terminal-core` 只定义接口,不实现。
|
||
3. `sizeCalculator` 在 core 包内**只保留纯算法**(给定 px 宽高 → cols/rows),DOM 测量(`ResizeObserver`)和 wx 测量(`wx.createSelectorQuery`)各自在 `apps/` 中实现并注入。
|
||
4. `imeController` 在 core 包内只保留 IME 状态机逻辑(idle/composing/commit_pending),`compositionstart/end` 等 DOM 事件由 Web 层订阅后调用 core 接口;小程序通过 `bindinput` + `isComposing` 等效调用同一接口。
|
||
5. CI 中增加以下 typecheck 任务保证零 DOM 泄漏:
|
||
```bash
|
||
tsc -p packages/terminal-core/tsconfig.json --lib esnext --noEmit
|
||
```
|
||
|
||
## 4. 目录规划(实施级)
|
||
|
||
```text
|
||
packages/terminal-core/ ← 核心包,零 DOM,三端共用
|
||
package.json ← name: "@remoteconn/terminal-core"
|
||
tsconfig.json ← lib: ["esnext"],无 dom lib
|
||
src/
|
||
session/
|
||
sessionMachine.ts ← 会话状态机(纯状态逻辑)
|
||
sessionTypes.ts
|
||
transport/
|
||
terminalTransport.ts ← TerminalTransport 接口定义
|
||
renderer/
|
||
terminalCore.ts ← VT 仿真内核(无 DOM)
|
||
rendererAdapter.ts ← RendererAdapter 接口定义
|
||
outputBuffer.ts
|
||
input/
|
||
inputBridge.ts ← 键序列映射纯逻辑
|
||
imeController.ts ← IME 状态机(无 DOM 事件)
|
||
IInputSource.ts ← 平台输入源接口
|
||
layout/
|
||
sizeCalculator.ts ← 纯算法:px → cols/rows
|
||
cursorMath.ts
|
||
IMeasureAdapter.ts ← 平台测量接口
|
||
sanitize/
|
||
terminalSanitizer.ts
|
||
|
||
packages/shared/ ← 现有,不改
|
||
|
||
apps/web/ ← Vue SPA(Web + iOS Capacitor WKWebView)
|
||
src/
|
||
transport/
|
||
gatewayTransport.ts ← WebSocket → TerminalTransport
|
||
iosNativeTransport.ts ← Capacitor Bridge → TerminalTransport
|
||
renderer/
|
||
compatRenderer.ts ← Canvas DOM 渲染
|
||
textareaRenderer.ts ← textarea DOM 渲染
|
||
input/
|
||
domInputBridge.ts ← DOM 事件 → IInputSource
|
||
domImeController.ts ← compositionstart/end → ImeController
|
||
layout/
|
||
domMeasureAdapter.ts ← ResizeObserver → IMeasureAdapter
|
||
pages/TerminalPage.vue
|
||
components/
|
||
TerminalToolbar.vue
|
||
TerminalViewport.vue
|
||
TerminalInputBar.vue
|
||
TerminalTouchTools.vue
|
||
stores/useTerminalStore.ts
|
||
styles/terminal.css
|
||
|
||
apps/miniprogram/ ← 微信小程序(原生组件)
|
||
utils/
|
||
wxTransport.js ← wx.connectSocket → TerminalTransport
|
||
wxInputBridge.js ← bindinput/bindconfirm → IInputSource
|
||
wxMeasureAdapter.js ← wx.createSelectorQuery → IMeasureAdapter
|
||
components/
|
||
terminal-core-view/ ← WXML 渲染,消费 TerminalCore.snapshot()
|
||
index.js index.wxml index.wxss
|
||
|
||
ios/plugin/RemoteConnSSHPlugin/ ← Swift SSH 插件(补全骨架后供 Capacitor 调用)
|
||
```
|
||
|
||
## 4.1 微信小程序消费 `terminal-core` 构建产物(工程约束)
|
||
|
||
由于小程序逻辑层**不支持 TypeScript 直接运行**,需要以下构建策略:
|
||
|
||
1. `packages/terminal-core/package.json` 增加 `"miniprogram": "dist-miniprogram/index.js"` 字段,提供预编译纯 JS CommonJS 产物。
|
||
2. 对应构建命令:`tsc -p packages/terminal-core/tsconfig.json --module commonjs --outDir packages/terminal-core/dist-miniprogram`,产物为零 DOM 纯 JS,可直接被小程序 `require()`。
|
||
3. `apps/miniprogram/utils/wxTransport.js` 等文件通过相对路径引用:`require('../../../packages/terminal-core/dist-miniprogram/index')`。
|
||
4. CI 中小程序相关构建步骤之前,必须先完成 `terminal-core` 构建(根 `package.json scripts` 中保证 `workspaces` 构建顺序,或通过 `prebuild` 钩子)。
|
||
5. 源码变更后必须重建 `dist-miniprogram`;**禁止**小程序逻辑层直接 `require` TypeScript 源文件。
|
||
|
||
## 5. 总体架构与数据流
|
||
|
||
```text
|
||
┌────────────── packages/terminal-core(零 DOM,三端共用)──────────────┐
|
||
│ SessionMachine → TerminalTransport(接口) ←→ Gateway/iOS/wx │
|
||
│ stdout/stderr → TerminalSanitizer → OutputBuffer → TerminalCore │
|
||
│ TerminalCore.snapshot() → RendererAdapter(接口) → 平台渲染层 │
|
||
│ IInputSource(接口) → InputBridge(键序列映射) → Transport.send() │
|
||
│ IMeasureAdapter(接口) → sizeCalculator(px→cols/rows) → resize │
|
||
└───────────────────────────────────────────────────────────────────────┘
|
||
↓ 接口注入 ↓ 接口注入
|
||
┌── Web / iOS ────────────────┐ ┌── 微信小程序 ────────────────────┐
|
||
│ GatewayTransport(WebSocket) │ │ WxTransport(wx.connectSocket) │
|
||
│ IosNativeTransport(Capacitor│ │ terminal-core-view(WXML组件) │
|
||
│ compatRenderer(Canvas DOM) │ │ wxInputBridge(bindinput) │
|
||
│ textareaRenderer(textarea) │ │ wxMeasureAdapter(selectorQuery) │
|
||
│ domInputBridge │ └─────────────────────────────────┘
|
||
│ domMeasureAdapter(ResizeObs)│
|
||
└─────────────────────────────┘
|
||
```
|
||
|
||
关键原则:
|
||
|
||
1. 传输层不感知渲染器类型;渲染器不感知传输层。
|
||
2. 输入处理统一走 `InputBridge`(键序列映射逻辑),事件来源由各平台的 `IInputSource` 实现注入。
|
||
3. 渲染器通过统一 `RendererAdapter` 接口实现,支持 compat/textarea 切换和跨端适配。
|
||
4. 输入映射遵循 xterm 规则:`Enter -> CR`,粘贴文本行结束归一到 `CR`。
|
||
5. 输出渲染遵循 xterm 控制字符纪律:`CR` 与 `LF` 分离语义,`convertEol` 默认 `false`。
|
||
6. 原生响应优先:软键盘、选区、滚动由原生通道(Web `textarea` / 小程序 `input`)主导,不得被 terminal-core 接管。
|
||
7. 事件主权清晰:键盘/触摸事件先由应用层做区域过滤与路由,再决定是否进入终端输入链路。
|
||
|
||
## 6. 状态机规划
|
||
|
||
## 6.1 会话状态机(连接生命周期)
|
||
|
||
状态集合(与现有模型对齐):
|
||
|
||
1. `idle`
|
||
2. `connecting`
|
||
3. `auth_pending`
|
||
4. `connected`
|
||
5. `reconnecting`
|
||
6. `disconnected`
|
||
7. `error`
|
||
|
||
合法迁移(必须在 `sessionMachine.ts` 中强校验):
|
||
|
||
1. `idle -> connecting | disconnected`
|
||
2. `connecting -> auth_pending | error | disconnected`
|
||
3. `auth_pending -> connected | error | disconnected`
|
||
4. `connected -> reconnecting | disconnected | error`
|
||
5. `reconnecting -> connected | error | disconnected`
|
||
6. `disconnected -> connecting | idle`
|
||
7. `error -> connecting | disconnected`
|
||
|
||
触发事件:
|
||
|
||
1. `connect_request`
|
||
2. `socket_open`
|
||
3. `connected_frame`
|
||
4. `stdout_first_packet`
|
||
5. `disconnect_frame/ws_close`
|
||
6. `error_frame/ws_error`
|
||
7. `manual_disconnect`
|
||
8. `auto_reconnect_timeout`
|
||
|
||
## 6.2 输入状态机(IME/普通输入)
|
||
|
||
状态:
|
||
|
||
1. `input_idle`
|
||
2. `input_composing`
|
||
3. `input_commit_pending`
|
||
|
||
规则:
|
||
|
||
1. `compositionstart` -> `input_composing`
|
||
2. `compositionend` -> 进入 `input_commit_pending`,提交非 ASCII 候选
|
||
3. `beforeinput/input` 非组合态可触发 ASCII 兜底发送
|
||
4. 超时守卫(如 1.8s)未收到 `compositionend` 时自动恢复到 `input_idle`
|
||
|
||
## 6.3 触摸焦点状态机(移动端)
|
||
|
||
动作集合:
|
||
|
||
1. `BLUR_ONLY`
|
||
2. `FOCUS_KEYBOARD`
|
||
3. `PASS_NATIVE`
|
||
4. `PASS_SCROLL`
|
||
|
||
决策优先级:
|
||
|
||
1. `hasSelectionStart || hasSelectionEnd` -> `PASS_NATIVE`
|
||
2. `scrollLike` -> `PASS_SCROLL`
|
||
3. `moved` -> `PASS_NATIVE`
|
||
4. `!inBand` -> `BLUR_ONLY`
|
||
5. `inBand` -> `FOCUS_KEYBOARD`
|
||
|
||
硬约束(新增):
|
||
|
||
1. 只有 `FOCUS_KEYBOARD` 动作允许触发原生键盘弹出。
|
||
2. `FOCUS_KEYBOARD` 必须满足“光标附近行”条件(`inBand=true`),禁止在非光标邻近区域弹键盘。
|
||
3. `PASS_NATIVE/PASS_SCROLL` 禁止 `preventDefault`,确保原生选区和滚动不被破坏。
|
||
4. 禁止在终端根容器上全局拦截 `touchstart/touchmove/click`;仅允许在明确手势分支下做最小拦截。
|
||
5. 移动端 `compat` 仅承担回显渲染,输入焦点必须由原生 `textarea` 锚点持有。
|
||
6. 禁止在非用户手势路径下调用 `terminal.focus()` 触发软键盘。
|
||
|
||
## 6.4 渲染模式状态机
|
||
|
||
状态:
|
||
|
||
1. `renderer_compat`
|
||
2. `renderer_textarea`
|
||
|
||
切换事件:
|
||
|
||
1. `switch_to_compat`
|
||
2. `switch_to_textarea`
|
||
|
||
切换过程必须原子化:
|
||
|
||
1. `oldRenderer.dispose()`
|
||
2. `newRenderer.mount(container)`
|
||
3. `replay buffer`
|
||
4. `apply size/theme`
|
||
5. `focus restore`(仅桌面自动聚焦)
|
||
|
||
## 6.5 换行模式状态(xterm 对齐)
|
||
|
||
状态:
|
||
|
||
1. `convertEol=false`(默认)
|
||
2. `convertEol=true`(由 `CSI 20 h` 打开)
|
||
|
||
规则:
|
||
|
||
1. `LF/VT/FF` 进入 `lineFeed()`:始终 `y+1`,并在触底时滚屏。
|
||
2. `convertEol=true` 时,`lineFeed()` 额外执行 `x=0`;`false` 时保留列位置。
|
||
3. `CR` 仅执行 `x=0`,不改变 `y`。
|
||
4. `NEL`(`ESC E` / `C1.NEL`)等价 `x=0 + index()`(下移一行并在必要时滚屏)。
|
||
5. 右边界写入是否自动折行由 `DECAWM(wraparound)` 控制。
|
||
6. 自动折行触发条件按 xterm 口径:字符写入将越过末列时触发换行;换行行需标记 `isWrapped=true`。
|
||
7. 显式 `LF` 后目标行应清除 `isWrapped`,避免把硬换行误判为软换行。
|
||
|
||
## 6.6 屏幕缓冲状态(主屏/备用屏,强制)
|
||
|
||
状态:
|
||
|
||
1. `buffer_normal`(主屏)
|
||
2. `buffer_alternate`(备用屏)
|
||
|
||
切换规则(xterm 对齐):
|
||
|
||
1. `CSI ? 47 h` / `CSI ? 1047 h`:切换到备用屏。
|
||
2. `CSI ? 47 l` / `CSI ? 1047 l`:返回主屏。
|
||
3. `CSI ? 1049 h`:保存光标状态并切换到备用屏。
|
||
4. `CSI ? 1049 l`:返回主屏并恢复保存光标状态。
|
||
5. 对齐 `xterm.js@5.3.0` 现实行为:`1049` 不强制清空备用屏历史(按源码注释口径处理)。
|
||
|
||
实现约束:
|
||
|
||
1. `TerminalCore` 必须同时维护 `normalBuffer` 与 `alternateBuffer`,仅一个为 `activeBuffer`。
|
||
2. 两个缓冲区的 `cursor`、`scroll region`、`isWrapped` 状态独立维护,禁止互相污染。
|
||
3. 切屏仅切换活动缓冲区引用,不得重建对象导致历史丢失。
|
||
4. `1049` 路径必须包含 `saveCursor/restoreCursor`,并保证退出全屏后提示符位置正确。
|
||
5. 该能力只影响输出缓冲与光标状态,不改变“原生输入主通道”与事件归属策略。
|
||
|
||
## 7. 组件职责与接口
|
||
|
||
## 7.1 `TerminalPage.vue`
|
||
|
||
职责:
|
||
|
||
1. 页面编排、模式切换、连接/断开、状态展示。
|
||
2. 组装 `store + renderer + inputBridge`。
|
||
|
||
## 7.2 `TerminalViewport.vue`
|
||
|
||
职责:
|
||
|
||
1. 只负责承载渲染容器(不直连 transport)。
|
||
2. 提供挂载点给 `RendererAdapter`。
|
||
|
||
## 7.3 `TerminalInputBar.vue`
|
||
|
||
职责:
|
||
|
||
1. 文本输入框、发送按钮、粘贴、快捷键入口。
|
||
2. 通过 `InputBridge.send()` 发送,不直接访问 transport。
|
||
|
||
## 7.4 `TerminalToolbar.vue`
|
||
|
||
职责:
|
||
|
||
1. 展示连接状态、延迟、渲染模式。
|
||
2. 触发 `connect/disconnect/clear/switchRenderer`。
|
||
|
||
## 7.5 `TerminalTouchTools.vue`
|
||
|
||
职责:
|
||
|
||
1. 方向键、Enter、Ctrl+C、Tab、Paste。
|
||
2. 输出标准控制序列(如 `ESC[A`)。
|
||
|
||
## 8. 传输与协议实现要点
|
||
|
||
## 8.1 四个核心平台接口(TypeScript 签名)
|
||
|
||
以下接口定义于 `packages/terminal-core/src/` 内,各端必须严格实现,**不得扩展签名**。
|
||
|
||
```typescript
|
||
// ── 会话状态(§6.1 状态集对应) ───────────────────────────────────────────
|
||
type SessionState =
|
||
| 'idle' | 'connecting' | 'auth_pending' | 'connected'
|
||
| 'reconnecting' | 'disconnected' | 'error';
|
||
|
||
// ── 连接参数 ──────────────────────────────────────────────────────────────
|
||
interface ConnectParams {
|
||
host: string;
|
||
port: number;
|
||
username: string;
|
||
password?: string;
|
||
privateKey?: string; // PEM 格式私钥字符串
|
||
passphrase?: string;
|
||
}
|
||
|
||
// ── 帧元数据(可选,用于去重/溯源) ──────────────────────────────────────
|
||
interface FrameMeta {
|
||
source?: 'keyboard' | 'assist' | 'paste';
|
||
txnId?: string;
|
||
}
|
||
|
||
// ── Transport 事件(出站帧,见 §8.2) ─────────────────────────────────────
|
||
type TransportEvent =
|
||
| { type: 'stdout'; data: string }
|
||
| { type: 'stderr'; data: string }
|
||
| { type: 'control'; action: 'connected' | 'disconnect' | 'pong'; data?: string }
|
||
| { type: 'error'; code: string; message: string };
|
||
|
||
type TransportEventListener = (event: TransportEvent) => void;
|
||
|
||
// ── TerminalTransport 接口 ────────────────────────────────────────────────
|
||
interface TerminalTransport {
|
||
connect(params: ConnectParams): Promise<void>;
|
||
send(data: string, meta?: FrameMeta): void;
|
||
resize(cols: number, rows: number): void;
|
||
disconnect(reason?: string): void;
|
||
on(listener: TransportEventListener): () => void; // 返回取消订阅函数
|
||
getState(): SessionState;
|
||
}
|
||
|
||
// ── RendererAdapter 接口 ──────────────────────────────────────────────────
|
||
interface RendererAdapter {
|
||
mount(container: unknown): void; // Web: HTMLElement;小程序: WXML 组件自挂载时传 null
|
||
write(data: string): void; // 增量写入 VT 字节流
|
||
resize(cols: number, rows: number): void;
|
||
applySnapshot(snapshot: TerminalSnapshot): void; // 全量重放(切换渲染器时调用)
|
||
dispose(): void;
|
||
}
|
||
|
||
// ── IMeasureAdapter 接口 ──────────────────────────────────────────────────
|
||
interface IMeasureAdapter {
|
||
measureChar(): { widthPx: number; heightPx: number }; // 单字符像素尺寸
|
||
measureContainer(): { widthPx: number; heightPx: number }; // 终端容器内部尺寸
|
||
onResize(cb: () => void): () => void; // 容器尺寸变化订阅
|
||
}
|
||
|
||
// ── IInputSource 接口 ─────────────────────────────────────────────────────
|
||
interface KeyPayload {
|
||
key: string; code: string;
|
||
ctrlKey: boolean; altKey: boolean; shiftKey: boolean; metaKey: boolean;
|
||
}
|
||
interface InputPayload { data: string; isComposing: boolean; }
|
||
interface PastePayload { text: string; }
|
||
interface CompositionPayload { data: string; }
|
||
|
||
interface IInputSource {
|
||
on(event: 'key', cb: (p: KeyPayload) => void): () => void;
|
||
on(event: 'input', cb: (p: InputPayload) => void): () => void;
|
||
on(event: 'paste', cb: (p: PastePayload) => void): () => void;
|
||
on(event: 'compositionstart', cb: (p: CompositionPayload) => void): () => void;
|
||
on(event: 'compositionend', cb: (p: CompositionPayload) => void): () => void;
|
||
}
|
||
```
|
||
|
||
## 8.2 网关帧协议(三端统一,不分叉)
|
||
|
||
帧格式:JSON 序列化后经 WebSocket / Capacitor / wx.connectSocket 传输。
|
||
|
||
```typescript
|
||
// ── 入站帧(客户端 → 网关) ──────────────────────────────────────────────
|
||
type InboundFrame =
|
||
| { type: 'init'; sessionId: string; params: ConnectParams }
|
||
| { type: 'stdin'; data: string; meta?: FrameMeta }
|
||
| { type: 'resize'; cols: number; rows: number }
|
||
| { type: 'control'; action: 'ping' | 'pong' | 'disconnect'; reason?: string };
|
||
|
||
// ── 出站帧(网关 → 客户端) ──────────────────────────────────────────────
|
||
type OutboundFrame =
|
||
| { type: 'stdout'; data: string } // PTY 输出(UTF-8 字符串)
|
||
| { type: 'stderr'; data: string } // PTY 错误输出
|
||
| { type: 'control'; action: 'connected' | 'disconnect' | 'pong'; data?: string }
|
||
| { type: 'error'; code: ErrorCode; message: string };
|
||
|
||
type ErrorCode =
|
||
| 'AUTH_FAILED' // SSH 认证失败
|
||
| 'HOST_UNREACHABLE' // 目标主机不可达
|
||
| 'TIMEOUT' // 连接超时
|
||
| 'SESSION_NOT_FOUND' // sessionId 不存在
|
||
| 'INTERNAL_ERROR'; // 服务端内部错误
|
||
```
|
||
|
||
约束:
|
||
|
||
1. `data` 字段统一为 UTF-8 字符串;二进制内容须 Base64 编码后传输。
|
||
2. `txnId` 为可选幂等键,网关对相同 `txnId` 的 `stdin` 帧去重。
|
||
3. 协议版本通过 `init` 帧扩展字段(如 `version: '1'`)传递,当前默认省略。
|
||
|
||
## 8.3 三端 Transport 实现
|
||
|
||
| 实现 | 位置 | 底层 API | 适用场景 |
|
||
|---|---|---|---|
|
||
| `GatewayTransport` | `apps/web/src/transport/gatewayTransport.ts` | `WebSocket` | Web 浏览器、微信小程序后备 |
|
||
| `IosNativeTransport` | `apps/web/src/transport/iosNativeTransport.ts` | `window.Capacitor.Plugins.RemoteConnSSH` | iOS Capacitor WKWebView |
|
||
| `WxTransport` | `apps/miniprogram/utils/wxTransport.js` | `wx.connectSocket` | 微信小程序原生 |
|
||
|
||
约束:
|
||
|
||
1. 三端帧协议完全一致(`8.2` 定义),Transport 实现只替换网络层 API,不改协议语义。
|
||
2. `IosNativeTransport` 通过 Capacitor Bridge 调用 Swift 插件(`connect/send/resize/disconnect` 四方法 + `addListener` 事件推送);Swift 层须完成真实 SSH 接入(当前为骨架)。
|
||
3. `WxTransport` 的 `wx.connectSocket` 在小程序逻辑层运行,帧收发逻辑与 `GatewayTransport` 对称实现。
|
||
|
||
## 9. 渲染管线设计
|
||
|
||
## 9.1 输出处理链
|
||
|
||
1. 收到 `stdout/stderr`。
|
||
2. 执行 `sanitizeTerminalOutput`(过滤同步更新模式等控制序列噪音)。
|
||
3. 追加到 `OutputBuffer`(条目上限 + 字节上限双阈值)。
|
||
4. `outputRevision++`。
|
||
5. 渲染器增量写入;必要时重放。
|
||
|
||
## 9.2 缓冲策略(必须)
|
||
|
||
1. `maxEntries` 下限保护(建议 >= 200)。
|
||
2. `maxBytes` 下限保护(建议 >= 64KB)。
|
||
3. 先按条目裁剪,再按字节裁剪。
|
||
4. 至少保留最新一条,避免全清空闪烁。
|
||
|
||
## 9.3 兼容渲染规则(自研)
|
||
|
||
1. 使用 `write()` 增量写入,不用 `writeln()` 改写换行语义。
|
||
2. `convertEol` 默认值必须为 `false`(与 xterm 默认一致)。
|
||
3. 显式 `LF` 不等于 `CRLF`:`CR` 和 `LF` 分别处理,禁止业务层自行合并。
|
||
4. `fit` 后同步 `resize(cols, rows)`。
|
||
|
||
## 9.4 textarea 渲染规则
|
||
|
||
1. 采用“只读显示区 + 输入区”或“单 textarea 双模式”方案(二选一,建议前者)。
|
||
2. 输出区按文本流追加,必要时使用 `requestAnimationFrame` 批量刷新。
|
||
3. 默认自动滚到底;用户手动上滚时暂停自动跟随。
|
||
4. 不允许使用“仅按字符串估算”的粗略光标算法作为最终实现。
|
||
5. 必须模拟 xterm 的 C0/C1 换行语义:`CR`、`LF`、`VT`、`FF`、`NEL`。
|
||
6. 必须显式维护软换行标记(等价 `isWrapped`),区分“自动折行”与“显式换行”。
|
||
|
||
## 9.5 精准光标内核(强制)
|
||
|
||
为满足“回显后光标位置必须精准计算”,实验项目必须引入统一终端状态内核(`TerminalCore`),并将其作为两种渲染模式的唯一真相源。
|
||
|
||
实现要求:
|
||
|
||
1. `stdout/stderr` 必须先写入 `TerminalCore`,由内核解析控制序列并更新光标、屏幕缓冲、滚动区域状态。
|
||
2. `compat` 模式与 `textarea` 模式都必须消费同一份 `TerminalCore` 状态快照。
|
||
3. 禁止直接通过 `selectionStart + 文本长度` 推断“终端光标”,该方法仅可用于“输入框本地插入点”,不能代表远端终端光标。
|
||
4. `TerminalCore` 至少要正确处理本项目目标程序涉及的序列族,按优先级分层:
|
||
- **C0/C1 控制字符**:`CR / LF / VT / FF / NEL / BS / HT / BEL`(BEL 须抛事件,不得静默丢弃)。
|
||
- **光标移动**:`CSI A/B/C/D`(上下左右)、`CSI E/F`(行首上下)、`CSI G`(列绝对)、`CSI H/f`(行列绝对)、`CSI I`(前进 Tab)、`CSI Z`(后退 Tab)。
|
||
- **擦除**:`CSI J / ?J`(擦除显示)、`CSI K / ?K`(擦除行)、`CSI X`(擦除字符)。
|
||
- **行/字符插删(TUI 全屏必需)**:`CSI L`(insertLines)、`CSI M`(deleteLines)、`CSI @`(insertChars)、`CSI P`(deleteChars)、`CSI S`(scrollUp)、`CSI T`(scrollDown)。
|
||
- **滚动区域(TUI 全屏必需)**:`CSI r`(DECSTBM,设置滚动上下边界),与 `DECOM(?6h/l)` 联动。
|
||
- **光标保存/恢复**:`CSI s / u`(ANSI save/restore cursor);`ESC 7 / ESC 8`(DECSC/DECRC)。
|
||
- **全终端重置**:`ESC c`(RIS,全量重置到初始状态);`CSI !p`(DECSTR 软重置)。
|
||
- **SGR 属性**:`CSI m`(见 §9.6)。
|
||
- **模式控制**:`SM/RM (Ps=4 insertMode, Ps=20 convertEol)`;`DECSET/DECRST (含 DECAWM ?7, DECTCEM ?25, DECCKM ?1, 备用屏 ?47/?1047/?1049, bracketed paste ?2004)`。
|
||
- **OSC**:`OSC 0/2`(标题,见 §9.9);其余 OSC 须安全解析并丢弃,不得透传到显示区。
|
||
5. `TerminalCore` 必须维护以下状态:`cursorX/cursorY`、`baseY`、`viewportY`、`scrollTop/scrollBottom`、`convertEol`、`wraparound`、`line.isWrapped`。
|
||
6. 每次输出处理后,必须产出 `cursor: { x, y, globalRow }` 与 `viewport` 快照,供触摸激活带与滚动定位复用。
|
||
|
||
## 9.6 颜色渲染规范(xterm 对齐,强制)
|
||
|
||
目标:
|
||
|
||
1. 颜色与文本属性语义必须对齐 `xterm.js@5.3.0` 的 `SGR(CSI ... m)` 行为。
|
||
2. `compat` 模式与 `textarea` 模式必须共享同一套属性状态机,避免模式切换后颜色跳变。
|
||
|
||
实现边界:
|
||
|
||
1. 必须支持基础 SGR 属性:`0/1/2/3/4/7/8/9/21/22/23/24/25/27/28/29/53/55`。
|
||
2. 必须支持前景/背景标准色:`30-37`、`40-47`。
|
||
3. 必须支持高亮色:`90-97`、`100-107`。
|
||
4. 必须支持扩展色:
|
||
- 前景 `38`
|
||
- 背景 `48`
|
||
- 下划线颜色 `58`
|
||
5. 扩展色子模式必须支持:
|
||
- `;5;INDEX`(256 色索引)
|
||
- `;2;R;G;B`(TrueColor)
|
||
6. 必须支持默认色复位:
|
||
- `39` 复位前景
|
||
- `49` 复位背景
|
||
- `59` 复位下划线颜色
|
||
|
||
颜色状态模型(建议):
|
||
|
||
1. `fg`: `{ mode: default | p16 | p256 | rgb, value }`
|
||
2. `bg`: `{ mode: default | p16 | p256 | rgb, value }`
|
||
3. `underlineColor`: `{ mode: default | p256 | rgb, value }`
|
||
4. `flags`: `bold/dim/italic/underline/inverse/invisible/strikethrough/overline`
|
||
5. `underlineStyle`: `none/single/double/curly/dotted/dashed`
|
||
|
||
重置纪律:
|
||
|
||
1. `SGR 0` 必须重置前景、背景、下划线样式与颜色到默认(等价 xterm `_processSGR0`)。
|
||
2. `22` 仅取消 `bold` 与 `dim`,不影响颜色。
|
||
3. `24` 仅取消下划线与下划线样式,不清空其它属性。
|
||
4. `27` 仅取消 `inverse`。
|
||
5. `28` 仅取消 `invisible`。
|
||
6. `29` 仅取消删除线。
|
||
|
||
渲染映射规则:
|
||
|
||
1. `inverse` 采用“渲染时交换 fg/bg”策略,不直接改写底层存储值。
|
||
2. `invisible` 保留背景渲染,前景以透明或背景同色处理(与终端“隐藏文字”语义一致)。
|
||
3. `bold` 只作为属性位,不应强行映射为高亮颜色(除非显式启用“粗体映射亮色”策略)。
|
||
4. `blink` 在 xterm 5.3 源码中为已记录属性但渲染支持有限,实验项目默认可不做动画实现,但要保留属性位兼容。
|
||
|
||
主题与调色板约束:
|
||
|
||
1. 16 色与 256 色索引色应来自统一调色板定义,避免 `xterm` 与 `textarea` 调色板不一致。
|
||
2. TrueColor (`rgb`) 必须按原值渲染,不经过主题二次量化。
|
||
3. 主题切换只影响“默认色与索引色映射”,不应篡改已存在的 TrueColor 单元格。
|
||
|
||
## 9.7 主题复用与轻量化策略(强制)
|
||
|
||
目标:
|
||
|
||
1. 主题实现参考并复用现有 Web/小程序成熟方案,保持视觉一致与维护成本可控。
|
||
2. xterm 移植以“核心稳定 + 轻量高效”为优先级,禁止默认堆叠非必要能力。
|
||
|
||
主题复用规则:
|
||
|
||
1. 统一采用语义 token(如 `terminalFg/terminalBg/cursor/selection/ansi16`),禁止在组件内硬编码颜色。
|
||
2. 默认前景/背景与 `ansi16` 从现有主题配置映射生成,避免与现有产品主题出现明显偏差。
|
||
3. 256 色表采用固定映射(标准 xterm256 表),不随业务主题动态重算。
|
||
4. 各端只维护“主题映射层”,不复制跨端整套业务主题逻辑。
|
||
|
||
轻量化规则:
|
||
|
||
1. 不引入 `@xterm/*` 运行时依赖;实现以 `TerminalCore + 自研渲染器` 为主。
|
||
2. 兼容能力按需实现并按模块拆分,首屏仅加载连接、输入、回显、resize 必需逻辑。
|
||
3. 默认使用原生 DOM/Canvas 路径(Web 端)或 WXML 组件(小程序端),禁止引入重型图形加速依赖作为前置条件。
|
||
4. 非核心能力(复杂链接检测、额外动画)默认关闭,确保输入回显路径最短。
|
||
|
||
性能预算(建议):
|
||
|
||
1. Web 端 JS 增量目标:gzip <= 150KB(超限需说明来源与必要性)。
|
||
2. 终端首屏可输入目标:页面进入后 1 秒内可完成输入与回显(普通开发机基线)。
|
||
3. 持续输出下避免长卡顿:连续主线程阻塞 >100ms 视为性能问题并需定位。
|
||
|
||
降级开关(必须):
|
||
|
||
1. `compatLiteMode`:关闭可选增强,仅保留连接、输入、回显、resize。
|
||
2. `rendererFallback=textarea`:低端设备或异常场景可一键降级。
|
||
3. 主题映射失败时自动回退默认主题,禁止阻塞会话连接与输入流程。
|
||
|
||
## 9.8 主屏/备用屏落地实现(强制)
|
||
|
||
最小实现步骤:
|
||
|
||
1. 在 `TerminalCore` 新增 `bufferSet = { normal, alternate, active }` 与 `savedCursor`。
|
||
2. 解析 `DECSET/DECRST` 时接入 `47/1047/1049` 分支,不允许只做日志忽略。
|
||
3. `switchToAlternate()`:切换 `active=alternate`,同步 `viewport/baseY/cursor`。
|
||
4. `switchToNormal()`:切换 `active=normal`,恢复主屏可视与滚动位置。
|
||
5. `1049h` 先 `saveCursor()` 再切换;`1049l` 先切回主屏再 `restoreCursor()`。
|
||
6. 渲染器读取统一 `activeBuffer` 快照,禁止自行缓存“上一屏”造成穿透渲染。
|
||
|
||
兼容边界:
|
||
|
||
1. 若目标程序未使用备用屏序列,行为与当前方案完全一致。
|
||
2. 若收到未知私有模式,记录日志并安全忽略,不得破坏当前缓冲状态。
|
||
3. 对于不在首期范围的高级 TUI 能力,可延后,但 `47/1047/1049` 不可缺失。
|
||
|
||
## 9.9 OSC / BEL 处理规范(强制)
|
||
|
||
### BEL(`\x07`)
|
||
|
||
1. `TerminalCore` 必须注册 BEL 执行处理器,检测到后向外抛 `onBell` 事件,不得静默丢弃。
|
||
2. Web 层(`TerminalPage`)可选响应:振动(`navigator.vibrate`)、提示音或弹层,默认静音(但回调链路必须存在)。
|
||
3. 小程序层可通过 `wx.vibrateShort()` 响应 `onBell`;iOS 层可通过 `AudioServicesPlaySystemSound` 响应。
|
||
4. BEL 不得在显示区产生任何可见字符。
|
||
|
||
### OSC 0 / OSC 2(终端标题)
|
||
|
||
1. `TerminalCore` 必须解析 `OSC 0 ; text BEL` 与 `OSC 2 ; text BEL`,提取 `text` 后向外抛 `onTitleChange(title: string)` 事件。
|
||
2. Web 层默认将标题更新到 `TerminalToolbar` 展示区;不强制写入 `document.title`。
|
||
3. OSC 1(icon name)与 OSC 0 处理逻辑相同,`text` 同步抛出,忽略图标语义。
|
||
|
||
### OSC 4 / OSC 10 / OSC 11 / OSC 12(调色盘/默认色动态修改)
|
||
|
||
1. 首期:安全解析序列,忽略颜色写入。
|
||
2. 不得将原始 OSC 序列透传到显示区造成乱码。
|
||
3. 可记录调试日志,供后期审计。
|
||
|
||
### OSC 8(超链接)
|
||
|
||
1. 首期:安全解析并忽略,不渲染为链接。
|
||
2. 不得透传到显示区。
|
||
|
||
### 通用 OSC 兜底规则
|
||
|
||
1. 未命中任何已注册 OSC handler 的序列,必须在解析完成后安全丢弃。
|
||
2. 禁止将 OSC 原始字节写入可视 buffer;解析失败时记录错误日志但不崩溃。
|
||
3. OSC 以 `BEL(\x07)` 或 `ST(\x9C / ESC \\)` 结束,两者均须支持。
|
||
|
||
## 9.10 `TerminalSnapshot` 数据结构与 `TerminalCore` 公开 API(强制)
|
||
|
||
所有渲染器(compat、textarea、小程序 WXML `setData`)均通过 `TerminalCore.snapshot()` 获取渲染数据,以下是完整类型定义:
|
||
|
||
```typescript
|
||
// ── 颜色值 ────────────────────────────────────────────────────────────────
|
||
interface ColorValue {
|
||
mode: 'default' | 'p16' | 'p256' | 'rgb';
|
||
value: number; // p16: 0-15;p256: 0-255;rgb: 0xRRGGBB
|
||
}
|
||
|
||
// ── 单元格 ────────────────────────────────────────────────────────────────
|
||
interface TerminalCell {
|
||
char: string; // 显示字符(空单元格为 ' ')
|
||
width: 1 | 2; // 东亚宽字符占 2 列
|
||
fg: ColorValue;
|
||
bg: ColorValue;
|
||
flags: number; // SGR flags bitmask(bold=1,dim=2,italic=4,underline=8,
|
||
// inverse=16,invisible=32,strikethrough=64,overline=128)
|
||
underlineStyle: 'none' | 'single' | 'double' | 'curly' | 'dotted' | 'dashed';
|
||
underlineColor: ColorValue;
|
||
}
|
||
|
||
// ── 行 ───────────────────────────────────────────────────────────────────
|
||
interface TerminalLine {
|
||
cells: TerminalCell[]; // 长度固定为 cols
|
||
isWrapped: boolean; // 此行为前一行的自动折行续行
|
||
}
|
||
|
||
// ── 光标状态 ──────────────────────────────────────────────────────────────
|
||
interface CursorState {
|
||
x: number; // 列(0-based)
|
||
y: number; // 行(相对 baseY,0-based)
|
||
globalRow: number; // baseY + y(全局行号,供触摸激活带复用)
|
||
visible: boolean; // DECTCEM(CSI ?25h/l)控制
|
||
}
|
||
|
||
// ── 快照(渲染器唯一数据源) ─────────────────────────────────────────────
|
||
interface TerminalSnapshot {
|
||
cols: number;
|
||
rows: number;
|
||
cursor: CursorState;
|
||
lines: TerminalLine[]; // 长度 = rows,仅含当前可视区
|
||
title: string; // 最近 OSC 0/2 设置的标题
|
||
revision: number; // 每次 write() 处理后递增,供渲染器脏检测
|
||
isAlternateBuffer: boolean; // 当前是否为备用屏
|
||
}
|
||
```
|
||
|
||
`TerminalCore` 公开 API(必须实现,不可对外暴露细节方法):
|
||
|
||
```typescript
|
||
type TerminalCoreEvent = 'bell' | 'titleChange' | 'resize';
|
||
|
||
interface TerminalCoreEventMap {
|
||
bell: void;
|
||
titleChange: string; // 新标题字符串
|
||
resize: { cols: number; rows: number }; // 内部触发的 resize 通知
|
||
}
|
||
|
||
class TerminalCore {
|
||
// 写入 VT 字节流;内部完成序列解析、状态更新、revision 自增
|
||
write(data: string): void;
|
||
|
||
// 全量重置到初始状态(等价 ESC c / RIS)
|
||
reset(): void;
|
||
|
||
// 产出当前可视区快照;调用成本 O(rows*cols),建议仅在 RAF 回调中调用
|
||
snapshot(): TerminalSnapshot;
|
||
|
||
// 更新终端尺寸(通常由 sizeCalculator 计算后注入)
|
||
resize(cols: number, rows: number): void;
|
||
|
||
// 事件订阅;返回取消订阅函数(调用后立即停止回调)
|
||
on<K extends TerminalCoreEvent>(
|
||
event: K,
|
||
cb: (payload: TerminalCoreEventMap[K]) => void
|
||
): () => void;
|
||
}
|
||
```
|
||
|
||
渲染器实现约束:
|
||
|
||
1. 渲染器**只读** snapshot,禁止回写 `TerminalCore` 内部状态。
|
||
2. 可通过 `revision` 做脏检测,跳过无变化帧的重渲染。
|
||
3. 小程序 `terminal-core-view` 在 `onUpdate` 中调用 `snapshot()` 后执行 `this.setData({ lines, cursor })`,最小化 setData 数据量。
|
||
|
||
## 10. 页面渲染与布局规则
|
||
|
||
## 10.1 基础布局
|
||
|
||
1. 顶部:状态栏(state、latency、mode)。
|
||
2. 中部:终端视口(xterm 或 textarea 输出)。
|
||
3. 底部:输入栏与触摸工具。
|
||
|
||
## 10.2 尺寸与 PTY 计算规则(必须统一)
|
||
|
||
变量:
|
||
|
||
1. `containerInnerWidthPx`
|
||
2. `containerInnerHeightPx`
|
||
3. `charWidthPx`
|
||
4. `lineHeightPx`
|
||
|
||
公式:
|
||
|
||
1. `cols = max(20, floor(containerInnerWidthPx / charWidthPx))`
|
||
2. `rows = max(8, floor(containerInnerHeightPx / lineHeightPx))`
|
||
|
||
约束:
|
||
|
||
1. 变化阈值去抖:`|cols-lastCols| + |rows-lastRows| >= 1` 才发 `resize`。
|
||
2. 初次挂载后至少重试 fit/measure 3~10 次(应对路由切换延迟布局)。
|
||
|
||
## 11. 光标与坐标计算规则(重点)
|
||
|
||
说明:本节区分两类光标,避免语义混淆。
|
||
|
||
1. 终端光标:远端 PTY 回显语义对应的光标(用于激活带判定)。
|
||
2. 输入光标:本地输入框 `selectionStart/selectionEnd`(用于编辑行为)。
|
||
|
||
## 11.1 行高计算
|
||
|
||
优先级:
|
||
|
||
1. 优先取真实渲染行高(DOM 测量)。
|
||
2. 无法测量时回退:`lineHeightPx = fontSize * lineHeight`。
|
||
|
||
## 11.2 触点行号计算
|
||
|
||
1. `localRow = floor((clientY - viewportTop) / rowHeightPx)`
|
||
2. compat 模式:`touchRow = viewportY + localRow`
|
||
3. textarea 模式:`touchRow = viewportTopRow + localRow`
|
||
4. 若 `clientY` 不在视口边界内,判定为无效触点。
|
||
|
||
## 11.3 compat 光标行号
|
||
|
||
1. `cursorLocalRow = core.cursorY`
|
||
2. `cursorBaseRow = core.baseY`
|
||
3. `cursorRow = cursorBaseRow + cursorLocalRow`(全局行号)
|
||
4. 激活带判断:`abs(touchRow - cursorRow) <= activationRadius`(建议 2 行)。
|
||
|
||
## 11.4 textarea 光标位置计算
|
||
|
||
输入(终端光标,来自 `TerminalCore`):
|
||
|
||
1. `coreCursorRow`
|
||
2. `coreCursorCol`
|
||
3. `viewportTopRow`
|
||
4. `rows`
|
||
|
||
规则:
|
||
|
||
1. `cursorRow = clamp(coreCursorRow, 0, +inf)`。
|
||
2. `cursorVisualRow = cursorRow - viewportTopRow`。
|
||
3. 仅当 `0 <= cursorVisualRow < rows` 时认为光标在当前可视窗口内。
|
||
4. 激活带判断统一使用 `cursorRow`(全局行号),禁止改用输入框 `selectionStart`。
|
||
5. `coreCursorRow` 语义需与 xterm 对齐:`globalRow = baseY + cursorY`。
|
||
|
||
输入(本地输入光标,仅编辑用途):
|
||
|
||
1. `selectionStart`(光标字符索引)
|
||
2. `text`(当前显示文本)
|
||
3. `cols`(当前列数)
|
||
|
||
算法(必须实现成纯函数):
|
||
|
||
1. 从 `text[0..selectionStart)` 顺序扫描。
|
||
2. 碰到 `\n`:`row += 1; col = 0`。
|
||
3. 普通字符:`col += charDisplayWidth(ch)`。
|
||
4. 若 `col >= cols`:按自动换行规则折行:`row += floor(col / cols); col = col % cols`。
|
||
|
||
`charDisplayWidth(ch)` 规则:
|
||
|
||
1. ASCII:宽度 1。
|
||
2. CJK/全角:宽度 2(可用简化 east-asian-width 判定)。
|
||
3. 控制字符:宽度 0。
|
||
|
||
## 11.5 坐标到光标索引(可选增强,仅输入框)
|
||
|
||
若需要“点按定位光标”:
|
||
|
||
1. 先由 `(x,y)` 推导目标 `(row,col)`。
|
||
2. 再线性扫描文本映射回最近字符索引。
|
||
3. 时间复杂度 O(n),长文本需缓存行起始索引优化。
|
||
|
||
## 11.6 原生键盘弹出门控(强制)
|
||
|
||
必须按以下判定顺序执行:
|
||
|
||
1. `sessionState === connected`。
|
||
2. 当前手势动作被状态机判定为 `FOCUS_KEYBOARD`。
|
||
3. `inBand === true`(即 `abs(touchRow - cursorRow) <= activationRadius`)。
|
||
4. 不存在活动原生选区(否则走 `PASS_NATIVE`)。
|
||
|
||
执行动作:
|
||
|
||
1. `readOnly=false`。
|
||
2. `blur -> focus` 顺序触发输入锚点激活。
|
||
3. 进入短保护窗口,防止 iOS 瞬时 blur 立刻收回键盘。
|
||
|
||
禁止动作:
|
||
|
||
1. 非 `inBand` 区域触摸时强制 `focus()`。
|
||
2. `PASS_NATIVE/PASS_SCROLL` 分支触发键盘弹出。
|
||
|
||
## 12. 输入处理规则(必须)
|
||
|
||
## 12.1 统一发送入口
|
||
|
||
1. 所有输入只走 `InputBridge.sendRaw()`。
|
||
2. 键盘 `Enter` 必须映射为 `CR (\r)`(与 xterm `Keyboard.ts` 一致)。
|
||
3. 普通文本输入(keypress/input)按字符原样发送,不做全局换行重写。
|
||
|
||
## 12.2 meta 标记
|
||
|
||
1. 常规按键:`meta.source = "keyboard"`。
|
||
2. 语音/候选提交:`meta.source = "assist"`,可携带 `txnId` 去重。
|
||
|
||
## 12.3 粘贴规则
|
||
|
||
1. 粘贴前执行 xterm 对齐的行结束归一:`/\r?\n/g -> '\r'`。
|
||
2. 若开启 bracketed paste,按 `ESC[200~ + text + ESC[201~` 包裹发送。
|
||
3. 粘贴文本直接写入 stdin,不走逐键模拟。
|
||
|
||
## 12.4 快捷键规则
|
||
|
||
1. `Ctrl/Cmd + C`:有选中则复制;无选中则发 `\u0003`。
|
||
2. `Ctrl/Cmd + V`:读剪贴板后发送。
|
||
3. 方向键默认发送:`ESC[A`/`ESC[B`/`ESC[C`/`ESC[D`;应用光标模式下发送:`ESCOA`/`ESCOB`/`ESCOC`/`ESCOD`。
|
||
|
||
## 12.5 反劫持策略(强制)
|
||
|
||
1. 输入主通道固定为原生 `textarea`,禁止把系统软键盘直接绑定到 xterm 隐藏输入节点。
|
||
2. 兼容层键盘监听仅用于桌面对照模式;移动端默认禁用兼容层直连 stdin 的按键采集。
|
||
3. 触摸事件默认透传,只有 `FOCUS_KEYBOARD` 分支可执行最小化 `preventDefault`。
|
||
4. 原生输入法事件(`compositionstart/update/end`、`beforeinput`)必须完整透传到输入状态机,不得被 xterm 监听提前吞掉。
|
||
5. 发生冲突时遵循优先级:系统行为 > 原生输入通道 > xterm 视图行为。
|
||
|
||
## 12.6 区域过滤与事件归属(强制)
|
||
|
||
区域定义(建议使用 `data-zone` 标记):
|
||
|
||
1. `terminal-output-zone`:终端可视回显区(仅显示与文本选区)。
|
||
2. `native-input-zone`:原生输入锚点区(软键盘与 IME 主入口)。
|
||
3. `app-control-zone`:应用工具栏、按钮、菜单区。
|
||
4. `app-overlay-zone`:应用弹层、右键菜单、浮层工具区。
|
||
|
||
归属规则:
|
||
|
||
1. 键盘事件默认归 `native-input-zone` 与应用层快捷键系统;xterm 仅消费“已路由到终端”的标准控制输入。
|
||
2. 鼠标点击在 `app-control-zone/app-overlay-zone` 必须 100% 透传应用层,禁止被 xterm 截获。
|
||
3. `wheel/touchmove` 默认交给最近可滚动容器;仅终端主视口滚动时才进入终端滚动链路。
|
||
4. 文本选择相关事件优先给系统原生选区,xterm 不得覆盖浏览器/系统的选区手柄行为。
|
||
|
||
实现约束:
|
||
|
||
1. 在捕获阶段先执行 `eventOwnershipRouter`,返回 `APP | NATIVE | XTERM`,再分发事件。
|
||
2. 未命中白名单事件时默认 `APP`,禁止“默认给 xterm”。
|
||
3. 仅以下事件允许进入 xterm:`stdin` 字符输入、终端方向键控制序列、明确授权的复制粘贴桥接。
|
||
4. 右键菜单、双击词选、三击行选等交互由应用策略统一控制,不由 xterm 内建策略隐式决定。
|
||
|
||
## 13. 关键风险与强约束
|
||
|
||
1. 风险:textarea 模式无法完整支持 TUI 全屏程序。
|
||
约束:文档中明确为已知限制,不作为首期阻塞项。
|
||
2. 风险:IME 双发或丢字。
|
||
约束:保留 `composition + beforeinput/input + fallback` 三层机制和去重窗口。
|
||
3. 风险:移动端误弹键盘。
|
||
约束:触摸状态机按优先级表执行,禁止随意新增 `preventDefault` 分支。
|
||
4. 风险:模式切换导致历史丢失。
|
||
约束:统一 `OutputBuffer`,切换只换渲染器,不换数据源。
|
||
5. 风险:xterm 事件劫持导致软键盘、选区、滚动异常。
|
||
约束:移动端输入以原生 `textarea` 为唯一焦点源,xterm 不持有输入焦点。
|
||
|
||
## 14. 分阶段实施计划(可直接执行)
|
||
|
||
### 阶段 A:核心包骨架与接口
|
||
|
||
1. 建立 `packages/terminal-core` 目录与模块骨架。
|
||
2. 定义并导出 `TerminalTransport`、`RendererAdapter`、`IMeasureAdapter`、`IInputSource` 四个核心接口。
|
||
3. 完成 `sessionMachine + terminalSanitizer + outputBuffer` 骨架。
|
||
4. `tsconfig.json` 配置 `lib: ["esnext"]`,CI 零 DOM typecheck 通过。
|
||
|
||
### 阶段 B:Web 基线(TerminalCore + compatRenderer)
|
||
|
||
1. 实现 `TerminalCore`(VT 仿真内核,含 §9.5 全部序列族)。
|
||
2. `apps/web` 接入 `terminal-core`,实现 `GatewayTransport + compatRenderer + domInputBridge + domMeasureAdapter`。
|
||
3. 跑通 SSH 会话:输入、回显、resize、快捷键、粘贴。
|
||
4. 完成基线测试用例(Enter/CR、convertEol、DECAWM、isWrapped、备用屏、颜色 SGR)。
|
||
|
||
### 阶段 C:textarea 渲染 + iOS 接入
|
||
|
||
1. 实现 `textareaRenderer`,接入 `TerminalCore.snapshot()`,完成 compat ↔ textarea 切换。
|
||
2. 实现 `iosNativeTransport`(Capacitor Bridge),`apps/web` 同一构建产物在 Capacitor 壳中加载。
|
||
3. 补全 `ios/plugin/RemoteConnSSHPlugin` Swift 真实 SSH 接入(替换骨架占位)。
|
||
4. iOS 设备冒烟:vim、htop 基础操作可用。
|
||
|
||
### 阶段 D:微信小程序原生接入
|
||
|
||
1. 实现 `WxTransport`(`wx.connectSocket`)、`wxInputBridge`、`wxMeasureAdapter`。
|
||
2. 实现 `terminal-core-view` WXML 渲染组件,消费 `TerminalCore.snapshot()`。
|
||
3. 小程序开发者工具 + 真机冒烟:vim、nano 基础操作可用。
|
||
|
||
### 阶段 E:全端验收
|
||
|
||
1. 三端(Web / iOS / 小程序)同时跑 vim / htop 冒烟测试(DECSTBM、insertLines/deleteLines、RIS、OSC 标题)。
|
||
2. 性能验收:各端高频输出不明显卡顿。
|
||
3. 删除演练:可单独删除旧版 `apps/web` 终端实现,`terminal-core` 与小程序不受影响;主工程 `typecheck/lint/test/build` 通过。
|
||
|
||
## 15. 测试与验收清单
|
||
|
||
功能验收:
|
||
|
||
1. 连接状态迁移严格符合状态机。
|
||
2. stdout/stderr 能稳定显示,无额外换行污染。
|
||
3. Enter 语义对齐 xterm:按键 Enter 发送 `CR`,不发送裸 `LF`。
|
||
4. 中文输入(含候选提交)无明显丢字/双发。
|
||
5. 模式切换后输出可重放,连接不中断。
|
||
6. 光标精准性:同一份输出流在 `compat` 与 `textarea` 模式下,终端光标 `(row,col)` 一致。
|
||
7. 光标附近弹键盘:仅在光标邻近行轻触触发原生键盘;非邻近行不弹键盘。
|
||
8. 长按选区场景下,键盘不误弹,原生选区与手柄保持可用。
|
||
9. `convertEol=false` 时,`LF` 仅下移不回列 0;`convertEol=true` 时 `LF` 同时回列 0。
|
||
10. `CR` 只回列 0;`NEL` 执行“回列 0 + 下移一行”。
|
||
11. 粘贴文本行结束归一为 `CR`,并在 bracketed paste 模式下正确包裹。
|
||
12. 自动折行与显式换行可区分:软换行行具备 `isWrapped=true` 语义。
|
||
13. 颜色回显一致性:同一输出流在 `compat` 与 `textarea` 模式下,`SGR` 属性(粗体/下划线/反显/隐藏/删除线)渲染结果一致。
|
||
14. 颜色指令完整性:`30-37/40-47/90-97/100-107/38/48/58` 与 `39/49/59/0/22/24/27/28/29` 复位行为符合预期。
|
||
15. 扩展色准确性:`38;5;INDEX`、`48;5;INDEX`、`38;2;R;G;B`、`48;2;R;G;B`、`58;2;R;G;B` 能精准渲染且模式切换后不漂移。
|
||
16. 主题切换约束:切换主题后默认色与索引色生效,历史 TrueColor 单元格颜色值保持不变。
|
||
17. 主题复用一致性:实验页主题映射与现有 Web/小程序基线一致,同主题下无明显色偏。
|
||
18. 轻量化约束:运行时不依赖 `@xterm/*`,未启用增强能力不进入首屏加载链路。
|
||
19. 降级策略有效:`compatLiteMode` 与 `rendererFallback=textarea` 能生效且核心会话稳定。
|
||
20. 原生响应优先:软键盘弹出/收起、长按选区、滚动手势在移动端与普通 `textarea` 行为一致,无明显被劫持现象。
|
||
21. 焦点纪律正确:移动端会话中 `textarea` 始终为输入焦点主体,xterm 不直接持有键盘焦点。
|
||
22. 区域归属正确:`app-control-zone/app-overlay-zone` 的键鼠事件不被 xterm 捕获,应用可独立演进交互逻辑。
|
||
23. 路由兜底正确:未声明事件默认归应用层处理,不出现“事件默认落到 xterm”导致的不可控行为。
|
||
24. 备用屏切换正确:`?1049h` 进入全屏后不污染主屏历史,`?1049l` 退出后恢复原提示符与光标位置。
|
||
25. `47/1047` 兼容正确:切换主/备用屏时两屏内容与滚动状态互不污染。
|
||
26. TUI 滚动区域正确性:`CSI r` 设定滚动边界后,vim/nano 状态栏固定不随内容区滚动,内容区滚动不越界。
|
||
27. RIS/软重置正确性:`ESC c` 后终端恢复干净初始状态,不残留 TUI 程序的绘图内容;`CSI !p` 后属性/模式复位但缓冲内容保留。
|
||
28. BEL/OSC 无乱码:`\x07` 不在显示区产生可见字符;`OSC 0;title\a` 抛出标题事件且显示区无乱码;未知 OSC 序列安全丢弃。
|
||
29. iOS 一致性:iOS Capacitor 壳加载同一 `apps/web` 构建产物,渲染与输入行为与桌面浏览器一致。
|
||
30. 小程序原生可用性:微信小程序端通过 `terminal-core-view` 组件运行,vim / nano 基础操作可用(DECSTBM + insertLines/deleteLines 验证),无 `<web-view>` 嵌套。
|
||
31. DOM 隔离验证:`packages/terminal-core` 在 `--lib esnext`(无 dom lib)的 TypeScript 编译下零错误。
|
||
|
||
性能验收:
|
||
|
||
1. 高频输出不明显卡顿(基线:普通开发机可持续滚动)。
|
||
2. 缓冲裁剪后不出现白屏或大面积闪烁。
|
||
|
||
回归验收:
|
||
|
||
1. 仅新增小程序入口改动;主页面行为一致。
|
||
2. 删除实验目录后,主项目 `typecheck/lint/test/build` 通过。
|
||
|
||
## 16. xterm 源码对照
|
||
|
||
本方案关键规则对应 `xterm.js@5.3.0` 源码:
|
||
|
||
1. `Enter -> CR`:`src/common/input/Keyboard.ts`。
|
||
2. 粘贴换行归一(`/\r?\n/g -> \r`):`src/browser/Clipboard.ts`。
|
||
3. `LF/VT/FF -> lineFeed`、`CR -> carriageReturn`、`NEL -> nextLine`:`src/common/InputHandler.ts`。
|
||
4. `convertEol` 默认 `false`,且受 `CSI 20 h/l` 控制:`src/common/services/OptionsService.ts` + `InputHandler.ts`。
|
||
5. 自动折行(DECAWM)与 `isWrapped`:`src/common/InputHandler.ts#print`。
|
||
6. 光标全局行号语义(`cursorY` 相对 `baseY`):`typings/xterm.d.ts` 中 `IBuffer` 定义。
|
||
7. `SGR` 属性解析与颜色扩展(含 `38/48/58`、`39/49/59`、`0/22/24/27/28/29`):`src/common/InputHandler.ts#charAttributes`。
|
||
8. 主屏/备用屏切换与 `1049` 光标保存恢复:`src/common/InputHandler.ts#setModePrivate` + `resetModePrivate`。
|
||
9. 缓冲区实现与切屏基础结构:`src/common/buffer/BufferSet.ts`。
|
||
10. `CSI L/M`(insertLines/deleteLines):`src/common/InputHandler.ts#insertLines` + `deleteLines`。
|
||
11. `CSI @/P/X/S/T`(insertChars/deleteChars/eraseChars/scrollUp/scrollDown):`src/common/InputHandler.ts` 对应同名方法。
|
||
12. `CSI r`(DECSTBM 滚动区域):`src/common/InputHandler.ts#setScrollRegion`。
|
||
13. `ESC c`(RIS 全量重置)、`CSI !p`(DECSTR 软重置):`src/common/InputHandler.ts` execute 注册 + `softReset`。
|
||
14. `BEL → bell()`、`OSC 0/1/2 → onTitleChange`:`src/common/InputHandler.ts` execute/OSC handler 注册。
|
||
|
||
## 17. 完成定义(DoD)
|
||
|
||
1. 本文档中的状态机、组件职责、算法规则均已对应到代码模块;`packages/terminal-core` DOM 隔离 CI 检查通过。
|
||
2. 三端(Web / iOS Capacitor / 微信小程序)均可独立开发与验证,且不互相耦合。
|
||
3. 微信小程序端通过原生 WXML 组件接入,不依赖 `<web-view>` 嵌套。
|
||
4. 删除演练通过:可单独删除旧版 `apps/web` 终端实现,`terminal-core`、小程序端、iOS 端均不受影响;主工程 `typecheck/lint/test/build` 通过。
|
||
5. 换行、光标、TUI 必需 CSI 序列(含 DECSTBM/insertLines/deleteLines/RIS)及 OSC/BEL 处理已与 `xterm.js@5.3.0` 源码逐条对齐,并有对应验收用例。
|
||
6. 开发者仅依赖本方案即可按阶段落地实现。
|
||
7. 主屏/备用屏与 `1049` 光标恢复能力已落地并通过验收用例。
|