first commit
This commit is contained in:
1
terminal/.npmrc
Normal file
1
terminal/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
cache=.npm-cache
|
||||
95
terminal/README.md
Normal file
95
terminal/README.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# terminal
|
||||
|
||||
这个仓库用于承接从 `remoteconn` 迁出的 xterm/terminal-core 迁移代码,采用 npm workspaces 管理。
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Node.js 20+(建议使用当前 LTS)
|
||||
- npm 10+
|
||||
|
||||
## 快速启动(Web)
|
||||
|
||||
在仓库根目录执行:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
默认会启动 `apps/web`,监听地址为:
|
||||
|
||||
- 本机访问:`http://localhost:5173`
|
||||
- 局域网访问:`http://<你的IP>:5173`
|
||||
|
||||
## 常用命令
|
||||
|
||||
根目录命令:
|
||||
|
||||
```bash
|
||||
npm run dev # 启动 Web 实验页(apps/web)
|
||||
npm run build # 先构建 terminal-core,再构建 web
|
||||
npm run test # 运行 terminal-core 测试
|
||||
npm run typecheck # 运行 terminal-core + web 类型检查
|
||||
```
|
||||
|
||||
按 workspace 执行命令:
|
||||
|
||||
```bash
|
||||
npm run test -w apps/web
|
||||
npm run test -w packages/terminal-core
|
||||
npm run lint -w packages/terminal-core
|
||||
npm run build:miniprogram -w packages/terminal-core
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 目前根目录未提供统一 `lint` 与 `deploy` 脚本。
|
||||
- `.npmrc` 已将 npm 缓存指向仓库内 `.npm-cache`,减少 `~/.npm` 权限冲突问题。
|
||||
|
||||
## Web 配置文件
|
||||
|
||||
可通过 `apps/web/public/terminal.config.json` 配置网关与服务器,无需在控制台手动写 `localStorage`。
|
||||
|
||||
- `gatewayUrl`:网关 WebSocket 地址。推荐同源路径(如 `/ws/terminal`),HTTPS 下会自动匹配 `wss`。
|
||||
- `gatewayToken`:网关令牌。
|
||||
- `selectedServerId`:默认选中的服务器 ID。
|
||||
- `servers`:服务器列表(`host/port/username/authType/password/privateKey/...`)。
|
||||
|
||||
修改配置文件后刷新页面即可生效;若同时存在本地缓存,配置文件优先。
|
||||
|
||||
## 目录结构(核心)
|
||||
|
||||
```text
|
||||
.
|
||||
├── apps/
|
||||
│ ├── web/
|
||||
│ └── miniprogram/
|
||||
├── packages/
|
||||
│ └── terminal-core/
|
||||
├── docs/
|
||||
├── README.md
|
||||
├── package.json
|
||||
├── package-lock.json
|
||||
├── .npmrc
|
||||
└── xterm-standalone-lab-plan-2026-03-01.md
|
||||
```
|
||||
|
||||
## 目录说明
|
||||
|
||||
- `apps/web/`:Web 端实验应用(Vite + Vue + Pinia),承载 terminal 实验页与页面级状态。
|
||||
- `apps/miniprogram/`:小程序侧适配代码与组件(输入桥接、测量适配、传输适配等)。
|
||||
- `packages/terminal-core/`:跨端复用的终端核心库(状态机、输入、渲染内核、布局与清洗逻辑)。
|
||||
- `docs/`:迁移与实验相关文档。
|
||||
|
||||
## 已迁入内容
|
||||
|
||||
- `apps/web/src/terminal/`:Web 端新 terminal-core 实验页与组件。
|
||||
- `apps/web/vitest.config.ts`。
|
||||
- `packages/terminal-core/`:跨平台终端核心包。
|
||||
- `apps/miniprogram/components/terminal-core-view/`。
|
||||
- `apps/miniprogram/utils/wxInputBridge.js`。
|
||||
- `apps/miniprogram/utils/wxMeasureAdapter.js`。
|
||||
- `apps/miniprogram/utils/wxTransport.js`。
|
||||
- `docs/xterm-standalone-lab-plan-2026-03-01.md`。
|
||||
|
||||
当前 Web 实验页已收敛为 `textarea` 渲染模式(不再提供 compat 切换)。
|
||||
369
terminal/apps/miniprogram/components/terminal-core-view/index.js
Normal file
369
terminal/apps/miniprogram/components/terminal-core-view/index.js
Normal file
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* terminal-core-view/index.js
|
||||
*
|
||||
* 微信小程序终端渲染组件。
|
||||
* 消费 @remoteconn/terminal-core 构建产物(dist-miniprogram/index.js),
|
||||
* 通过 setData({rows, cursor}) 驱动 rich-text 渲染。
|
||||
*
|
||||
* 外部属性(properties):
|
||||
* - gatewayUrl {string} 网关 WebSocket 地址
|
||||
* - gatewayToken {string} 网关鉴权 Token
|
||||
*
|
||||
* 外部方法(通过组件引用调用):
|
||||
* - connect(params) 开始连接,params: { host, port, username, password? }
|
||||
* - disconnect(reason?) 断开连接
|
||||
* - sendInput(data) 发送输入
|
||||
*/
|
||||
|
||||
const path = '../../../../packages/terminal-core/dist-miniprogram/index';
|
||||
let TerminalCore, InputBridge, OutputBuffer, SessionMachine, sanitizeTerminalOutput, calcSize;
|
||||
try {
|
||||
const core = require(path);
|
||||
TerminalCore = core.TerminalCore;
|
||||
InputBridge = core.InputBridge;
|
||||
OutputBuffer = core.OutputBuffer;
|
||||
SessionMachine = core.SessionMachine;
|
||||
sanitizeTerminalOutput = core.sanitizeTerminalOutput;
|
||||
calcSize = core.calcSize;
|
||||
} catch(e) {
|
||||
console.error('[terminal-core-view] 无法加载 terminal-core,请先执行 build:miniprogram', e);
|
||||
}
|
||||
|
||||
const { WxTransport } = require('../../utils/wxTransport');
|
||||
const { createWxInputBridge } = require('../../utils/wxInputBridge');
|
||||
const { createWxMeasureAdapter } = require('../../utils/wxMeasureAdapter');
|
||||
|
||||
// ── 颜色渲染 ──────────────────────────────────────────────────────────────────
|
||||
const ANSI16 = [
|
||||
'#000000','#cc0000','#00aa00','#aaaa00',
|
||||
'#0000ee','#cc00cc','#00aaaa','#aaaaaa',
|
||||
'#555555','#ff5555','#55ff55','#ffff55',
|
||||
'#5555ff','#ff55ff','#55ffff','#ffffff',
|
||||
];
|
||||
|
||||
function p256ToCss(idx) {
|
||||
if (idx < 16) return ANSI16[idx] || '#000';
|
||||
if (idx >= 232) {
|
||||
const v = 8 + (idx - 232) * 10;
|
||||
return 'rgb(' + v + ',' + v + ',' + v + ')';
|
||||
}
|
||||
const i = idx - 16;
|
||||
const b = i % 6, g = Math.floor(i / 6) % 6, r = Math.floor(i / 36);
|
||||
const ch = function(x) { return x === 0 ? 0 : 55 + x * 40; };
|
||||
return 'rgb(' + ch(r) + ',' + ch(g) + ',' + ch(b) + ')';
|
||||
}
|
||||
|
||||
function colorToCss(c, isFg) {
|
||||
if (!c) return isFg ? 'inherit' : 'transparent';
|
||||
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) | 0x1000000).toString(16).slice(1);
|
||||
default: return 'inherit';
|
||||
}
|
||||
}
|
||||
|
||||
const FLAG_BOLD = 1;
|
||||
const FLAG_DIM = 2;
|
||||
const FLAG_ITALIC = 4;
|
||||
const FLAG_UNDERLINE = 8;
|
||||
const FLAG_INVERSE = 16;
|
||||
const FLAG_INVISIBLE = 32;
|
||||
const FLAG_STRIKETHROUGH = 64;
|
||||
const FLAG_OVERLINE = 128;
|
||||
|
||||
/** 将一行 cells 转换为 rich-text 节点数组 */
|
||||
function lineToNodes(cells) {
|
||||
const nodes = [];
|
||||
let i = 0;
|
||||
while (i < cells.length) {
|
||||
const cell = cells[i];
|
||||
if (!cell) { i++; continue; }
|
||||
|
||||
const flags = cell.flags || 0;
|
||||
const inverse = !!(flags & FLAG_INVERSE);
|
||||
|
||||
let fg = cell.fg, bg = cell.bg;
|
||||
if (inverse) { const tmp = fg; fg = bg; bg = tmp; }
|
||||
|
||||
let style = '';
|
||||
const fgCss = colorToCss(fg, true);
|
||||
const bgCss = colorToCss(bg, false);
|
||||
if (fgCss !== 'inherit') style += 'color:' + fgCss + ';';
|
||||
if (bgCss !== 'transparent') style += 'background:' + bgCss + ';';
|
||||
if (flags & FLAG_BOLD) style += 'font-weight:bold;';
|
||||
if (flags & FLAG_DIM) style += 'opacity:0.6;';
|
||||
if (flags & FLAG_ITALIC) style += 'font-style:italic;';
|
||||
if (flags & FLAG_UNDERLINE) style += 'text-decoration:underline;';
|
||||
if (flags & FLAG_STRIKETHROUGH) style += 'text-decoration:line-through;';
|
||||
if (flags & FLAG_INVISIBLE) style += 'color:transparent;';
|
||||
|
||||
// 合并相邻相同 style 的 cells
|
||||
let text = cell.char || ' ';
|
||||
i++;
|
||||
while (i < cells.length) {
|
||||
const next = cells[i];
|
||||
if (!next) break;
|
||||
const nFlags = next.flags || 0;
|
||||
// 简单比较:只有标志和颜色模式全相同才合并
|
||||
if (nFlags !== flags ||
|
||||
JSON.stringify(next.fg) !== JSON.stringify(fg) ||
|
||||
JSON.stringify(next.bg) !== JSON.stringify(bg)) break;
|
||||
text += next.char || ' ';
|
||||
i++;
|
||||
}
|
||||
|
||||
nodes.push({
|
||||
type: 'node',
|
||||
name: 'span',
|
||||
attrs: style ? { style: style } : {},
|
||||
children: [{ type: 'text', text: text }],
|
||||
});
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
// ── 组件定义 ──────────────────────────────────────────────────────────────────
|
||||
Component({
|
||||
properties: {
|
||||
gatewayUrl: { type: String, value: '' },
|
||||
gatewayToken: { type: String, value: '' },
|
||||
},
|
||||
|
||||
data: {
|
||||
rows: [], // Array<{ html: Array }>
|
||||
cursor: { row: -1, left: 0, visible: false },
|
||||
scrollTop: 9999999, // 滚到底
|
||||
state: 'idle',
|
||||
latencyMs: 0,
|
||||
title: '',
|
||||
inputValue: '',
|
||||
},
|
||||
|
||||
lifetimes: {
|
||||
attached: function() {
|
||||
this._init();
|
||||
},
|
||||
detached: function() {
|
||||
this._dispose();
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
// ── 初始化 ───────────────────────────────────────────────────────────────
|
||||
_init: function() {
|
||||
const self = this;
|
||||
|
||||
// Core
|
||||
self._core = TerminalCore ? new TerminalCore(80, 24) : null;
|
||||
self._buffer = OutputBuffer ? new OutputBuffer({ maxEntries: 500, maxBytes: 256 * 1024 }) : null;
|
||||
self._machine = SessionMachine ? new SessionMachine() : null;
|
||||
self._inputBridge = InputBridge ? new InputBridge() : null;
|
||||
self._wxBridge = createWxInputBridge();
|
||||
self._transport = null;
|
||||
self._offTransport = null;
|
||||
self._lastRevision = -1;
|
||||
self._charW = 7.8; // 默认字符宽度 px (monospace 13px 近似)
|
||||
self._lineH = 16.25; // 行高 px (13px * 1.25)
|
||||
|
||||
// 订阅 core 事件
|
||||
if (self._core) {
|
||||
self._core.on('bell', function() { wx.vibrateShort({ type: 'light' }); });
|
||||
self._core.on('titleChange', function(t) {
|
||||
self.setData({ title: t });
|
||||
});
|
||||
}
|
||||
|
||||
// 测量适配器
|
||||
self._measure = createWxMeasureAdapter(self, '#tc-viewport', self._charW, self._lineH);
|
||||
self._measure.onResize(function() {
|
||||
self._doFit();
|
||||
});
|
||||
|
||||
// 输入桥接事件订阅
|
||||
self._wxBridge.on('key', function(p) {
|
||||
if (!self._inputBridge) return;
|
||||
const seq = self._inputBridge.mapKey(p.key, p.code, p.ctrlKey, p.altKey, p.shiftKey, p.metaKey);
|
||||
if (seq !== null) self.sendInput(seq);
|
||||
});
|
||||
self._wxBridge.on('input', function(p) {
|
||||
if (p.isComposing) return;
|
||||
if (p.data) self.sendInput(p.data);
|
||||
self.setData({ inputValue: '' });
|
||||
});
|
||||
self._wxBridge.on('paste', function(p) {
|
||||
if (!self._inputBridge) return;
|
||||
const seq = self._inputBridge.mapPaste(p.text);
|
||||
self.sendInput(seq);
|
||||
});
|
||||
self._wxBridge.on('compositionend', function(p) {
|
||||
if (p.data) self.sendInput(p.data);
|
||||
self.setData({ inputValue: '' });
|
||||
});
|
||||
|
||||
// 启动渲染循环(50ms ≈ 20fps,setData 有限流)
|
||||
self._renderTimer = setInterval(function() { self._renderFrame(); }, 50);
|
||||
|
||||
// 启动测量轮询
|
||||
self._measure.startPoll(600);
|
||||
},
|
||||
|
||||
_dispose: function() {
|
||||
if (this._renderTimer) { clearInterval(this._renderTimer); this._renderTimer = null; }
|
||||
this._measure && this._measure.stopPoll();
|
||||
this._offTransport && this._offTransport();
|
||||
this._transport && this._transport._cleanup && this._transport._cleanup();
|
||||
},
|
||||
|
||||
// ── 尺寸适配 ─────────────────────────────────────────────────────────────
|
||||
_doFit: function() {
|
||||
if (!calcSize || !this._core) return;
|
||||
const { widthPx: contW, heightPx: contH } = this._measure.measureContainer();
|
||||
if (contW < 10 || contH < 10) return;
|
||||
const { widthPx: charW, heightPx: lineH } = this._measure.measureChar();
|
||||
const { cols, rows } = calcSize(contW, contH, charW, lineH);
|
||||
this._core.resize(cols, rows);
|
||||
this._charW = charW;
|
||||
if (this._transport && this.data.state === 'connected') {
|
||||
this._transport.resize(cols, rows);
|
||||
}
|
||||
},
|
||||
|
||||
// ── 渲染帧 ───────────────────────────────────────────────────────────────
|
||||
_renderFrame: function() {
|
||||
if (!this._core) return;
|
||||
const snap = this._core.snapshot();
|
||||
if (snap.revision === this._lastRevision) return;
|
||||
this._lastRevision = snap.revision;
|
||||
|
||||
const rows = snap.lines.map(function(line) {
|
||||
return { html: lineToNodes(line.cells) };
|
||||
});
|
||||
|
||||
// 光标位置(像素)
|
||||
const cursorLeft = snap.cursor.x * this._charW;
|
||||
const cursorRow = snap.cursor.y; // 相对可视区行号
|
||||
|
||||
this.setData({
|
||||
rows: rows,
|
||||
cursor: { row: cursorRow, left: cursorLeft, visible: snap.cursor.visible },
|
||||
title: snap.title || this.data.title,
|
||||
});
|
||||
},
|
||||
|
||||
// ── 公开 API ──────────────────────────────────────────────────────────────
|
||||
connect: function(params) {
|
||||
const self = this;
|
||||
if (!WxTransport) { console.error('[tc-view] WxTransport 未加载'); return; }
|
||||
|
||||
self._transport = new WxTransport({
|
||||
gatewayUrl: self.properties.gatewayUrl,
|
||||
gatewayToken: self.properties.gatewayToken,
|
||||
});
|
||||
|
||||
self._offTransport = self._transport.on(function(event) {
|
||||
if (event.type === 'stdout' || event.type === 'stderr') {
|
||||
const sanitized = sanitizeTerminalOutput ? sanitizeTerminalOutput(event.data) : event.data;
|
||||
if (sanitized) {
|
||||
self._buffer && self._buffer.push(sanitized);
|
||||
self._core && self._core.write(sanitized);
|
||||
}
|
||||
} else if (event.type === 'control') {
|
||||
if (event.action === 'connected') {
|
||||
self._machine && self._machine.tryTransition('connected');
|
||||
self.setData({ state: 'connected', latencyMs: 0 });
|
||||
self._doFit();
|
||||
} else if (event.action === 'pong') {
|
||||
// latency 由 WxTransport 内部 _pingAt 计算
|
||||
} else if (event.action === 'disconnect') {
|
||||
self._machine && self._machine.tryTransition('disconnected');
|
||||
self.setData({ state: 'disconnected' });
|
||||
}
|
||||
} else if (event.type === 'error') {
|
||||
self._machine && self._machine.tryTransition('error');
|
||||
self.setData({ state: 'error' });
|
||||
}
|
||||
});
|
||||
|
||||
self._machine && self._machine.tryTransition('connecting');
|
||||
self.setData({ state: 'connecting' });
|
||||
|
||||
self._transport.connect(params).catch(function(err) {
|
||||
console.error('[tc-view] connect error', err);
|
||||
self.setData({ state: 'error' });
|
||||
});
|
||||
},
|
||||
|
||||
disconnect: function(reason) {
|
||||
if (this._transport) {
|
||||
this._transport.disconnect(reason || 'manual');
|
||||
}
|
||||
this._machine && this._machine.tryTransition('disconnected');
|
||||
this.setData({ state: 'disconnected' });
|
||||
},
|
||||
|
||||
sendInput: function(data) {
|
||||
if (this.data.state !== 'connected' || !this._transport) return;
|
||||
this._transport.send(data);
|
||||
},
|
||||
|
||||
// ── WXML 事件处理 ─────────────────────────────────────────────────────────
|
||||
onInput: function(e) {
|
||||
const value = e.detail.value || '';
|
||||
const prev = this._prevInputValue || '';
|
||||
// 计算增量字符
|
||||
if (value.length > prev.length) {
|
||||
const delta = value.slice(prev.length);
|
||||
this._wxBridge.triggerInput(delta, e.detail.isComposing);
|
||||
}
|
||||
this._prevInputValue = value;
|
||||
},
|
||||
|
||||
onConfirm: function() {
|
||||
this._prevInputValue = '';
|
||||
this._wxBridge.triggerConfirm();
|
||||
},
|
||||
|
||||
onSend: function() {
|
||||
const val = this.data.inputValue;
|
||||
if (val) {
|
||||
this._wxBridge.triggerInput(val, false);
|
||||
this._wxBridge.triggerConfirm();
|
||||
this.setData({ inputValue: '' });
|
||||
this._prevInputValue = '';
|
||||
}
|
||||
},
|
||||
|
||||
onInputBlur: function() { /* no-op */ },
|
||||
onKeyboardChange: function() { /* no-op */ },
|
||||
|
||||
// ── 滚动事件 ──────────────────────────────────────────────────────────────
|
||||
onScrollToLower: function() {
|
||||
this._autoScroll = true;
|
||||
},
|
||||
onScrollToUpper: function() {
|
||||
this._autoScroll = false;
|
||||
},
|
||||
onScroll: function() { /* handled by upper/lower events */ },
|
||||
|
||||
// ── 触控工具栏 ────────────────────────────────────────────────────────────
|
||||
onTapUp: function() { this._wxBridge.triggerKey('ArrowUp', 'ArrowUp'); },
|
||||
onTapDown: function() { this._wxBridge.triggerKey('ArrowDown', 'ArrowDown'); },
|
||||
onTapLeft: function() { this._wxBridge.triggerKey('ArrowLeft', 'ArrowLeft'); },
|
||||
onTapRight: function() { this._wxBridge.triggerKey('ArrowRight', 'ArrowRight'); },
|
||||
onTapTab: function() { this._wxBridge.triggerKey('Tab', 'Tab'); },
|
||||
onTapEnter: function() { this._wxBridge.triggerConfirm(); },
|
||||
onTapCtrlC: function() { this.sendInput('\u0003'); },
|
||||
onTapCtrlD: function() { this.sendInput('\u0004'); },
|
||||
onTapEsc: function() { this.sendInput('\x1b'); },
|
||||
onTapPaste: function() {
|
||||
const self = this;
|
||||
wx.getClipboardData({
|
||||
success: function(res) {
|
||||
if (res.data) self._wxBridge.triggerPaste(res.data);
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<!--
|
||||
terminal-core-view — 微信小程序终端渲染组件。
|
||||
消费 TerminalCore.snapshot() 通过 setData 驱动 rich-text 渲染。
|
||||
-->
|
||||
<view class="tc-root" id="tc-root">
|
||||
<!-- 终端输出区 -->
|
||||
<scroll-view
|
||||
class="tc-viewport"
|
||||
id="tc-viewport"
|
||||
scroll-y
|
||||
scroll-top="{{scrollTop}}"
|
||||
bindscrolltoupper="onScrollToUpper"
|
||||
bindscrolltolower="onScrollToLower"
|
||||
bindscroll="onScroll"
|
||||
>
|
||||
<view class="tc-lines">
|
||||
<block wx:for="{{rows}}" wx:key="index">
|
||||
<view class="tc-row" style="position: relative;">
|
||||
<!-- 光标行叠加 -->
|
||||
<view
|
||||
wx:if="{{cursor.visible && cursor.row === index}}"
|
||||
class="tc-cursor"
|
||||
style="left: {{cursor.left}}px;"
|
||||
></view>
|
||||
<rich-text class="tc-rich-row" nodes="{{item.html}}"></rich-text>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 触控辅助工具栏 -->
|
||||
<view class="tc-touch-bar">
|
||||
<button class="tc-key" bindtap="onTapUp" size="mini">▲</button>
|
||||
<button class="tc-key" bindtap="onTapDown" size="mini">▼</button>
|
||||
<button class="tc-key" bindtap="onTapLeft" size="mini">◀</button>
|
||||
<button class="tc-key" bindtap="onTapRight" size="mini">▶</button>
|
||||
<button class="tc-key" bindtap="onTapTab" size="mini">Tab</button>
|
||||
<button class="tc-key tc-key-enter" bindtap="onTapEnter" size="mini">↵</button>
|
||||
<button class="tc-key" bindtap="onTapCtrlC" size="mini">^C</button>
|
||||
<button class="tc-key" bindtap="onTapCtrlD" size="mini">^D</button>
|
||||
<button class="tc-key" bindtap="onTapEsc" size="mini">Esc</button>
|
||||
<button class="tc-key" bindtap="onTapPaste" size="mini">粘贴</button>
|
||||
</view>
|
||||
|
||||
<!-- 输入栏 -->
|
||||
<view class="tc-input-bar">
|
||||
<input
|
||||
class="tc-input"
|
||||
type="text"
|
||||
confirm-type="send"
|
||||
placeholder="输入命令…"
|
||||
value="{{inputValue}}"
|
||||
bindinput="onInput"
|
||||
bindconfirm="onConfirm"
|
||||
bindblur="onInputBlur"
|
||||
bindkeyboardheightchange="onKeyboardChange"
|
||||
cursor-spacing="20"
|
||||
adjust-position
|
||||
/>
|
||||
<button class="tc-send-btn" size="mini" bindtap="onSend">发</button>
|
||||
</view>
|
||||
|
||||
<!-- 状态栏 -->
|
||||
<view class="tc-status-bar">
|
||||
<text class="tc-state-chip tc-state-{{state}}">{{state}}</text>
|
||||
<text wx:if="{{latencyMs > 0}}" class="tc-state-chip">{{latencyMs}}ms</text>
|
||||
<text wx:if="{{title}}" class="tc-title">{{title}}</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,154 @@
|
||||
/* terminal-core-view/index.wxss */
|
||||
|
||||
.tc-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #1a1a1a;
|
||||
color: #d4d4d4;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 终端输出区 */
|
||||
.tc-viewport {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tc-lines {
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.tc-row {
|
||||
position: relative;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.tc-rich-row {
|
||||
display: block;
|
||||
font-family: "Courier New", "Menlo", monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.25;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
/* 光标 */
|
||||
.tc-cursor {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 8px;
|
||||
height: 1.25em;
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* 触控工具栏 */
|
||||
.tc-touch-bar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: scroll;
|
||||
padding: 4px 6px;
|
||||
background: #252525;
|
||||
border-top: 1rpx solid #333;
|
||||
gap: 4px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.tc-key {
|
||||
flex-shrink: 0;
|
||||
min-width: 44px;
|
||||
font-size: 12px;
|
||||
font-family: "Courier New", monospace;
|
||||
background: #2e2e2e;
|
||||
color: #ccc;
|
||||
border: 1rpx solid #444;
|
||||
border-radius: 4px;
|
||||
padding: 0 8px;
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.tc-key-enter {
|
||||
background: #1a3a5c;
|
||||
border-color: #4a9eff;
|
||||
color: #4a9eff;
|
||||
}
|
||||
|
||||
/* 输入栏 */
|
||||
.tc-input-bar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 8px;
|
||||
background: #1e1e1e;
|
||||
border-top: 1rpx solid #333;
|
||||
}
|
||||
|
||||
.tc-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
border: 1rpx solid #444;
|
||||
background: #2a2a2a;
|
||||
color: #d4d4d4;
|
||||
font-size: 13px;
|
||||
font-family: "Courier New", monospace;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.tc-send-btn {
|
||||
flex-shrink: 0;
|
||||
width: 44px;
|
||||
height: 36px;
|
||||
background: #4a9eff;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
line-height: 36px;
|
||||
text-align: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* 状态栏 */
|
||||
.tc-status-bar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 8px;
|
||||
background: #141414;
|
||||
border-top: 1rpx solid #2a2a2a;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.tc-state-chip {
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
background: #2a2a2a;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.tc-state-chip.tc-state-connected { background: #1a3a1a; color: #4caf50; }
|
||||
.tc-state-chip.tc-state-connecting { background: #2a2a00; color: #ffc107; }
|
||||
.tc-state-chip.tc-state-auth_pending { background: #2a2a00; color: #ffc107; }
|
||||
.tc-state-chip.tc-state-error { background: #3a1a1a; color: #f44336; }
|
||||
.tc-state-chip.tc-state-disconnected { background: #2a2a2a; color: #666; }
|
||||
|
||||
.tc-title {
|
||||
flex: 1;
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
123
terminal/apps/miniprogram/utils/wxInputBridge.js
Normal file
123
terminal/apps/miniprogram/utils/wxInputBridge.js
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* WxInputBridge — 微信小程序 bindinput/bindconfirm 适配为 IInputSource。
|
||||
*
|
||||
* 使用方式(在 terminal-core-view/index.js 中):
|
||||
* const bridge = createWxInputBridge();
|
||||
* // 绑定到 InputBridge
|
||||
* const core_bridge = new InputBridge();
|
||||
* bridge.on('key', p => { const seq = core_bridge.mapKey(...); transport.send(seq); });
|
||||
* bridge.on('input', p => { if (!p.isComposing && p.data) transport.send(p.data); });
|
||||
* bridge.on('paste', p => { transport.send(core_bridge.mapPaste(p.text)); });
|
||||
*
|
||||
* 组件调用(WXML 事件 → 桥接):
|
||||
* Page/Component: {
|
||||
* onInput(e) { bridge.triggerInput(e.detail.value, e.detail.isComposing); },
|
||||
* onConfirm() { bridge.triggerConfirm(); },
|
||||
* onPaste(text){ bridge.triggerPaste(text); },
|
||||
* onFocus() { bridge.triggerFocus(); },
|
||||
* onBlur() { bridge.triggerBlur(); },
|
||||
* }
|
||||
*/
|
||||
|
||||
function createWxInputBridge() {
|
||||
const _listeners = Object.create(null);
|
||||
|
||||
function on(event, cb) {
|
||||
if (!_listeners[event]) _listeners[event] = [];
|
||||
_listeners[event].push(cb);
|
||||
return function off() {
|
||||
const arr = _listeners[event];
|
||||
if (!arr) return;
|
||||
const idx = arr.indexOf(cb);
|
||||
if (idx >= 0) arr.splice(idx, 1);
|
||||
};
|
||||
}
|
||||
|
||||
function _emit(event, payload) {
|
||||
const arr = _listeners[event];
|
||||
if (!arr) return;
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
try { arr[i](payload); } catch(e) { /* isolation */ }
|
||||
}
|
||||
}
|
||||
|
||||
// ── WXML 事件驱动 API ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 由 WXML bindinput 调用。
|
||||
* 注意:小程序 bindinput 的 value 是整体输入框值,不是增量;
|
||||
* 调用方应提取增量(新增字符)后传入 data 参数。
|
||||
*/
|
||||
function triggerInput(data, isComposing) {
|
||||
_emit('input', {
|
||||
data: data != null ? String(data) : '',
|
||||
isComposing: !!isComposing,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 由 WXML bindconfirm 调用(用户点击软键盘"完成/回车")。
|
||||
*/
|
||||
function triggerConfirm() {
|
||||
_emit('key', {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
metaKey: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 由触控工具栏按键调用(箭头键、Tab、Ctrl+C 等)。
|
||||
* @param {string} key KeyboardEvent.key 等价值(如 "ArrowUp"、"c")
|
||||
* @param {string} code KeyboardEvent.code 等价值(可省略,默认与 key 相同)
|
||||
* @param {{ ctrlKey, altKey, shiftKey, metaKey }} mods 修饰键状态
|
||||
*/
|
||||
function triggerKey(key, code, mods) {
|
||||
const m = mods || {};
|
||||
_emit('key', {
|
||||
key: key,
|
||||
code: code || key,
|
||||
ctrlKey: !!m.ctrlKey,
|
||||
altKey: !!m.altKey,
|
||||
shiftKey: !!m.shiftKey,
|
||||
metaKey: !!m.metaKey,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 由粘贴操作触发(通过 wx.getClipboardData 读取后调用)。
|
||||
*/
|
||||
function triggerPaste(text) {
|
||||
_emit('paste', { text: String(text || '') });
|
||||
}
|
||||
|
||||
/**
|
||||
* IME 组合开始(候选词输入开始)。
|
||||
*/
|
||||
function triggerCompositionStart(data) {
|
||||
_emit('compositionstart', { data: String(data || '') });
|
||||
}
|
||||
|
||||
/**
|
||||
* IME 组合结束(候选词确认)。
|
||||
*/
|
||||
function triggerCompositionEnd(data) {
|
||||
_emit('compositionend', { data: String(data || '') });
|
||||
}
|
||||
|
||||
return {
|
||||
on: on,
|
||||
// WXML 事件驱动方法
|
||||
triggerInput: triggerInput,
|
||||
triggerConfirm: triggerConfirm,
|
||||
triggerKey: triggerKey,
|
||||
triggerPaste: triggerPaste,
|
||||
triggerCompositionStart: triggerCompositionStart,
|
||||
triggerCompositionEnd: triggerCompositionEnd,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createWxInputBridge: createWxInputBridge };
|
||||
112
terminal/apps/miniprogram/utils/wxMeasureAdapter.js
Normal file
112
terminal/apps/miniprogram/utils/wxMeasureAdapter.js
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* WxMeasureAdapter — 微信小程序 wx.createSelectorQuery 实现 IMeasureAdapter。
|
||||
*
|
||||
* 由于小程序不支持 ResizeObserver,使用 BoundingClientRect 查询容器尺寸,
|
||||
* 并提供 `poll(intervalMs)` 方法做周期性尺寸检测(模拟 resize 通知)。
|
||||
*
|
||||
* 使用方式:
|
||||
* const m = createWxMeasureAdapter(component, '#tc-viewport', 14, 16.8);
|
||||
* m.onResize(() => {
|
||||
* const { widthPx, heightPx } = m.measureContainer();
|
||||
* const { widthPx: cw, heightPx: ch } = m.measureChar();
|
||||
* const cols = Math.max(20, Math.floor(widthPx / cw));
|
||||
* const rows = Math.max(8, Math.floor(heightPx / ch));
|
||||
* // ... resize terminal
|
||||
* });
|
||||
* m.startPoll(500);
|
||||
* // 在 component detached / onUnload 中:
|
||||
* m.stopPoll();
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {object} ctx - 小程序 Component 实例(this),用于 createSelectorQuery 作用域
|
||||
* @param {string} selector - CSS 选择器,如 '#tc-viewport'
|
||||
* @param {number} charWidthPx - 等宽字符宽度像素(由组件在初始化时测量或硬编码)
|
||||
* @param {number} charHeightPx - 行高像素
|
||||
*/
|
||||
function createWxMeasureAdapter(ctx, selector, charWidthPx, charHeightPx) {
|
||||
let _containerW = 0;
|
||||
let _containerH = 0;
|
||||
let _pollTimer = null;
|
||||
const _callbacks = [];
|
||||
|
||||
/** 实现 IMeasureAdapter.measureChar */
|
||||
function measureChar() {
|
||||
return {
|
||||
widthPx: charWidthPx || 8,
|
||||
heightPx: charHeightPx || 16,
|
||||
};
|
||||
}
|
||||
|
||||
/** 实现 IMeasureAdapter.measureContainer */
|
||||
function measureContainer() {
|
||||
return {
|
||||
widthPx: _containerW,
|
||||
heightPx: _containerH,
|
||||
};
|
||||
}
|
||||
|
||||
/** 实现 IMeasureAdapter.onResize */
|
||||
function onResize(cb) {
|
||||
_callbacks.push(cb);
|
||||
return function off() {
|
||||
const idx = _callbacks.indexOf(cb);
|
||||
if (idx >= 0) _callbacks.splice(idx, 1);
|
||||
};
|
||||
}
|
||||
|
||||
/** 主动刷新容器尺寸(异步) */
|
||||
function refresh(onDone) {
|
||||
const query = ctx.createSelectorQuery();
|
||||
query.select(selector).boundingClientRect(function(rect) {
|
||||
if (!rect) return;
|
||||
const newW = rect.width || 0;
|
||||
const newH = rect.height || 0;
|
||||
if (newW !== _containerW || newH !== _containerH) {
|
||||
_containerW = newW;
|
||||
_containerH = newH;
|
||||
for (let i = 0; i < _callbacks.length; i++) {
|
||||
try { _callbacks[i](); } catch(e) { /* isolation */ }
|
||||
}
|
||||
}
|
||||
if (typeof onDone === 'function') onDone({ widthPx: _containerW, heightPx: _containerH });
|
||||
}).exec();
|
||||
}
|
||||
|
||||
/** 启动周期性轮询(模拟 ResizeObserver)。intervalMs 建议 300~1000ms */
|
||||
function startPoll(intervalMs) {
|
||||
stopPoll();
|
||||
const ms = intervalMs || 500;
|
||||
// 立刻执行一次
|
||||
refresh();
|
||||
_pollTimer = setInterval(function() { refresh(); }, ms);
|
||||
}
|
||||
|
||||
/** 停止轮询 */
|
||||
function stopPoll() {
|
||||
if (_pollTimer !== null) {
|
||||
clearInterval(_pollTimer);
|
||||
_pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次性测量(供初始化使用)。
|
||||
* @param {function} cb - (result: { widthPx, heightPx }) => void
|
||||
*/
|
||||
function measureOnce(cb) {
|
||||
refresh(cb);
|
||||
}
|
||||
|
||||
return {
|
||||
measureChar: measureChar,
|
||||
measureContainer: measureContainer,
|
||||
onResize: onResize,
|
||||
refresh: refresh,
|
||||
startPoll: startPoll,
|
||||
stopPoll: stopPoll,
|
||||
measureOnce: measureOnce,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createWxMeasureAdapter: createWxMeasureAdapter };
|
||||
201
terminal/apps/miniprogram/utils/wxTransport.js
Normal file
201
terminal/apps/miniprogram/utils/wxTransport.js
Normal file
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* WxTransport — 微信小程序 wx.connectSocket 实现的 TerminalTransport。
|
||||
*
|
||||
* 帧协议与 Web 端 GatewayTransport 完全一致(§8.2):
|
||||
* 入站:{ type: 'init'|'stdin'|'resize'|'control', ... }
|
||||
* 出站:{ type: 'stdout'|'stderr'|'control'|'error', ... }
|
||||
*
|
||||
* 使用方式:
|
||||
* const t = new WxTransport({ gatewayUrl, gatewayToken });
|
||||
* const off = t.on(event => { ... });
|
||||
* await t.connect({ host, port, username, password });
|
||||
* t.send("ls -la\r");
|
||||
* t.resize(80, 24);
|
||||
* t.disconnect();
|
||||
*/
|
||||
|
||||
const { sanitizeTerminalOutput } = require('../../../packages/terminal-core/dist-miniprogram/index');
|
||||
|
||||
function buildWsUrl(rawUrl, token) {
|
||||
let base = String(rawUrl || '').trim();
|
||||
if (!base) throw new Error('WxTransport: 网关地址为空');
|
||||
if (base.startsWith('http://')) base = 'ws://' + base.slice(7);
|
||||
if (base.startsWith('https://')) base = 'wss://' + base.slice(8);
|
||||
if (!base.startsWith('ws://') && !base.startsWith('wss://')) {
|
||||
base = 'wss://' + base;
|
||||
}
|
||||
base = base.replace(/\/+$/, '');
|
||||
return base + '/ws/terminal?token=' + encodeURIComponent(String(token || ''));
|
||||
}
|
||||
|
||||
function WxTransport(options) {
|
||||
const cfg = options || {};
|
||||
this._gatewayUrl = cfg.gatewayUrl || '';
|
||||
this._gatewayToken = cfg.gatewayToken || '';
|
||||
|
||||
this._state = 'idle'; // SessionState
|
||||
this._sessionId = '';
|
||||
this._socket = null; // wx.SocketTask
|
||||
this._listeners = []; // Array<(event) => void>
|
||||
this._pingTimer = null;
|
||||
this._pingAt = 0;
|
||||
}
|
||||
|
||||
// ── 接口实现 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 连接网关并发送 init 帧。
|
||||
* 返回 Promise,在 control.connected 帧到达后 resolve,错误时 reject。
|
||||
*/
|
||||
WxTransport.prototype.connect = function(params) {
|
||||
const self = this;
|
||||
return new Promise(function(resolve, reject) {
|
||||
if (self._state === 'connecting' || self._state === 'connected') {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
self._state = 'connecting';
|
||||
const sessionId = 'wx_' + Date.now() + '_' + Math.random().toString(36).slice(2);
|
||||
self._sessionId = sessionId;
|
||||
const url = buildWsUrl(self._gatewayUrl, self._gatewayToken);
|
||||
|
||||
const task = wx.connectSocket({
|
||||
url: url,
|
||||
protocols: ['v1.terminal'],
|
||||
success: function() { /* socket created */ },
|
||||
fail: function(err) {
|
||||
self._state = 'error';
|
||||
self._emit({ type: 'error', code: 'HOST_UNREACHABLE', message: JSON.stringify(err) });
|
||||
reject(new Error('wx.connectSocket fail: ' + JSON.stringify(err)));
|
||||
}
|
||||
});
|
||||
|
||||
self._socket = task;
|
||||
let resolved = false;
|
||||
|
||||
task.onOpen(function() {
|
||||
// 发送 init 帧
|
||||
self._sendFrame({ type: 'init', sessionId: sessionId, params: params });
|
||||
// 启动心跳
|
||||
self._startPing();
|
||||
});
|
||||
|
||||
task.onMessage(function(res) {
|
||||
let frame;
|
||||
try {
|
||||
frame = JSON.parse(res.data);
|
||||
} catch(e) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.type === 'stdout' || frame.type === 'stderr') {
|
||||
const raw = typeof frame.data === 'string' ? frame.data : '';
|
||||
const sanitized = sanitizeTerminalOutput(raw);
|
||||
if (sanitized) self._emit({ type: frame.type, data: sanitized });
|
||||
} else if (frame.type === 'control') {
|
||||
if (frame.action === 'connected') {
|
||||
self._state = 'connected';
|
||||
self._emit({ type: 'control', action: 'connected', data: frame.data });
|
||||
if (!resolved) { resolved = true; resolve(); }
|
||||
} else if (frame.action === 'disconnect') {
|
||||
self._cleanup();
|
||||
self._state = 'disconnected';
|
||||
self._emit({ type: 'control', action: 'disconnect' });
|
||||
} else if (frame.action === 'pong') {
|
||||
self._emit({ type: 'control', action: 'pong' });
|
||||
}
|
||||
} else if (frame.type === 'error') {
|
||||
self._state = 'error';
|
||||
self._emit({ type: 'error', code: frame.code || 'INTERNAL_ERROR', message: frame.message || '' });
|
||||
if (!resolved) { resolved = true; reject(new Error(frame.message)); }
|
||||
}
|
||||
});
|
||||
|
||||
task.onError(function(err) {
|
||||
self._cleanup();
|
||||
self._state = 'error';
|
||||
self._emit({ type: 'error', code: 'HOST_UNREACHABLE', message: JSON.stringify(err) });
|
||||
if (!resolved) { resolved = true; reject(new Error(JSON.stringify(err))); }
|
||||
});
|
||||
|
||||
task.onClose(function() {
|
||||
self._cleanup();
|
||||
if (self._state !== 'disconnected' && self._state !== 'error') {
|
||||
self._state = 'disconnected';
|
||||
self._emit({ type: 'control', action: 'disconnect' });
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
WxTransport.prototype.send = function(data, meta) {
|
||||
if (this._state !== 'connected') return;
|
||||
this._sendFrame({ type: 'stdin', data: data, meta: meta });
|
||||
};
|
||||
|
||||
WxTransport.prototype.resize = function(cols, rows) {
|
||||
if (this._state !== 'connected') return;
|
||||
this._sendFrame({ type: 'resize', cols: cols, rows: rows });
|
||||
};
|
||||
|
||||
WxTransport.prototype.disconnect = function(reason) {
|
||||
this._sendFrame({ type: 'control', action: 'disconnect', reason: reason || 'manual' });
|
||||
this._cleanup();
|
||||
this._state = 'disconnected';
|
||||
};
|
||||
|
||||
WxTransport.prototype.on = function(listener) {
|
||||
const self = this;
|
||||
self._listeners.push(listener);
|
||||
return function() {
|
||||
const idx = self._listeners.indexOf(listener);
|
||||
if (idx >= 0) self._listeners.splice(idx, 1);
|
||||
};
|
||||
};
|
||||
|
||||
WxTransport.prototype.getState = function() {
|
||||
return this._state;
|
||||
};
|
||||
|
||||
// ── 私有方法 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
WxTransport.prototype._emit = function(event) {
|
||||
for (let i = 0; i < this._listeners.length; i++) {
|
||||
try { this._listeners[i](event); } catch(e) { /* 单个监听器异常不影响其他 */ }
|
||||
}
|
||||
};
|
||||
|
||||
WxTransport.prototype._sendFrame = function(frame) {
|
||||
if (!this._socket) return;
|
||||
try {
|
||||
this._socket.send({ data: JSON.stringify(frame) });
|
||||
} catch(e) { /* 忽略发送时 socket 已关闭 */ }
|
||||
};
|
||||
|
||||
WxTransport.prototype._startPing = function() {
|
||||
const self = this;
|
||||
self._clearPing();
|
||||
self._pingTimer = setInterval(function() {
|
||||
if (self._state !== 'connected') return;
|
||||
self._pingAt = Date.now();
|
||||
self._sendFrame({ type: 'control', action: 'ping' });
|
||||
}, 10000);
|
||||
};
|
||||
|
||||
WxTransport.prototype._clearPing = function() {
|
||||
if (this._pingTimer !== null) {
|
||||
clearInterval(this._pingTimer);
|
||||
this._pingTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
WxTransport.prototype._cleanup = function() {
|
||||
this._clearPing();
|
||||
if (this._socket) {
|
||||
try { this._socket.close(); } catch(e) { /* ignore */ }
|
||||
this._socket = null;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { WxTransport: WxTransport };
|
||||
12
terminal/apps/web/index.html
Normal file
12
terminal/apps/web/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
|
||||
<title>Terminal Lab</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
25
terminal/apps/web/package.json
Normal file
25
terminal/apps/web/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@remoteconn/web-terminal-lab",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0 --port 5173",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"typecheck": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@remoteconn/terminal-core": "1.0.0",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.5.22"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^5.4.10",
|
||||
"vitest": "^4.0.18",
|
||||
"vue-tsc": "^2.2.4"
|
||||
}
|
||||
}
|
||||
19
terminal/apps/web/public/terminal.config.json
Normal file
19
terminal/apps/web/public/terminal.config.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"gatewayUrl": "/ws/terminal",
|
||||
"gatewayToken": "remoteconn-dev-token",
|
||||
"selectedServerId": "dev-server",
|
||||
"servers": [
|
||||
{
|
||||
"id": "dev-server",
|
||||
"name": "Dev Server",
|
||||
"host": "mac.biboer.cn",
|
||||
"port": 22,
|
||||
"username": "gavin",
|
||||
"transportMode": "gateway",
|
||||
"authType": "password",
|
||||
"password": "Gavin123",
|
||||
"cols": 80,
|
||||
"rows": 24
|
||||
}
|
||||
]
|
||||
}
|
||||
72
terminal/apps/web/src/App.vue
Normal file
72
terminal/apps/web/src/App.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import TerminalPage from "@/terminal/TerminalPage.vue";
|
||||
|
||||
const appStore = useAppStore();
|
||||
const { toasts } = storeToRefs(appStore);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="app-root">
|
||||
<TerminalPage />
|
||||
<section class="toast-list" aria-live="polite">
|
||||
<article
|
||||
v-for="item in toasts"
|
||||
:key="item.id"
|
||||
class="toast-item"
|
||||
:class="`toast-${item.level}`"
|
||||
>
|
||||
{{ item.message }}
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-root {
|
||||
/*
|
||||
* 使用稳定后的可视视口高度控制根容器。
|
||||
* 注意:不跟随 --app-vtop 做整体位移,避免键盘动画期间整页被平移到不可见区。
|
||||
*/
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: var(--app-vh, 100dvh);
|
||||
overflow: hidden;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
.toast-list {
|
||||
position: fixed;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
.toast-item {
|
||||
max-width: 360px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.toast-info {
|
||||
background: #2d7ff9;
|
||||
}
|
||||
|
||||
.toast-warn {
|
||||
background: #b88400;
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
background: #c0392b;
|
||||
}
|
||||
</style>
|
||||
7
terminal/apps/web/src/env.d.ts
vendored
Normal file
7
terminal/apps/web/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>;
|
||||
export default component;
|
||||
}
|
||||
119
terminal/apps/web/src/main.ts
Normal file
119
terminal/apps/web/src/main.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { createPinia } from "pinia";
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import "./style.css";
|
||||
|
||||
function disablePageZoom(): void {
|
||||
if (typeof document === "undefined") return;
|
||||
const prevent = (e: Event) => e.preventDefault();
|
||||
let lastTapAt = 0;
|
||||
let lastTapX = 0;
|
||||
let lastTapY = 0;
|
||||
const DOUBLE_TAP_GUARD_MS = 320;
|
||||
const DOUBLE_TAP_DISTANCE_PX = 32;
|
||||
|
||||
document.addEventListener("gesturestart", prevent, { passive: false });
|
||||
document.addEventListener("gesturechange", prevent, { passive: false });
|
||||
document.addEventListener("gestureend", prevent, { passive: false });
|
||||
document.addEventListener("touchmove", (e: TouchEvent) => {
|
||||
if (e.touches.length > 1) e.preventDefault();
|
||||
}, { passive: false });
|
||||
document.addEventListener("touchend", (e: TouchEvent) => {
|
||||
const touch = e.changedTouches[0];
|
||||
if (!touch) return;
|
||||
|
||||
const now = Date.now();
|
||||
const dt = now - lastTapAt;
|
||||
const dx = Math.abs(touch.clientX - lastTapX);
|
||||
const dy = Math.abs(touch.clientY - lastTapY);
|
||||
lastTapAt = now;
|
||||
lastTapX = touch.clientX;
|
||||
lastTapY = touch.clientY;
|
||||
|
||||
// 全局双击缩放拦截:在整个 Web 页面内统一禁止 double-tap zoom。
|
||||
if (dt > 0 && dt <= DOUBLE_TAP_GUARD_MS && dx <= DOUBLE_TAP_DISTANCE_PX && dy <= DOUBLE_TAP_DISTANCE_PX) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}, { passive: false, capture: true });
|
||||
}
|
||||
|
||||
function setupMobileViewportCompensation(): void {
|
||||
if (typeof window === "undefined" || typeof document === "undefined") return;
|
||||
const root = document.documentElement;
|
||||
const MIN_VIEWPORT_RATIO = 0.35;
|
||||
const MIN_VIEWPORT_PX = 240;
|
||||
const BIG_JUMP_PX = 64;
|
||||
const BIG_JUMP_DEBOUNCE_MS = 100;
|
||||
|
||||
// 记录“可信高度”基线:用于过滤键盘动画期间偶发的极小中间值(例如瞬时 98px)。
|
||||
let baselineViewportHeight = Math.max(window.innerHeight, window.visualViewport?.height ?? 0);
|
||||
let committedViewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
||||
let pendingViewportHeight = committedViewportHeight;
|
||||
let pendingViewportTop = window.visualViewport?.offsetTop ?? 0;
|
||||
let pendingTimer: number | null = null;
|
||||
|
||||
const commitViewport = (viewportHeight: number, viewportTop: number) => {
|
||||
root.style.setProperty("--app-vh", `${Math.round(viewportHeight)}px`);
|
||||
root.style.setProperty("--app-vtop", `${Math.round(viewportTop)}px`);
|
||||
};
|
||||
|
||||
const flushPending = () => {
|
||||
pendingTimer = null;
|
||||
committedViewportHeight = pendingViewportHeight;
|
||||
commitViewport(pendingViewportHeight, pendingViewportTop);
|
||||
};
|
||||
|
||||
const scheduleViewport = (viewportHeight: number, viewportTop: number) => {
|
||||
if (viewportHeight <= 0) return;
|
||||
|
||||
if (viewportHeight > baselineViewportHeight) {
|
||||
baselineViewportHeight = viewportHeight;
|
||||
}
|
||||
|
||||
// 过滤动画毛刺:异常小高度直接丢弃,避免主容器瞬时塌缩导致工具条闪位。
|
||||
const minReasonable = Math.max(MIN_VIEWPORT_PX, baselineViewportHeight * MIN_VIEWPORT_RATIO);
|
||||
if (viewportHeight < minReasonable) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingViewportHeight = viewportHeight;
|
||||
pendingViewportTop = viewportTop;
|
||||
const jump = Math.abs(viewportHeight - committedViewportHeight);
|
||||
|
||||
// 大跳变(键盘开关动画期)走短防抖,只提交最终稳定值。
|
||||
if (jump >= BIG_JUMP_PX) {
|
||||
if (pendingTimer !== null) {
|
||||
window.clearTimeout(pendingTimer);
|
||||
}
|
||||
pendingTimer = window.setTimeout(flushPending, BIG_JUMP_DEBOUNCE_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingTimer !== null) {
|
||||
window.clearTimeout(pendingTimer);
|
||||
pendingTimer = null;
|
||||
}
|
||||
committedViewportHeight = viewportHeight;
|
||||
commitViewport(viewportHeight, viewportTop);
|
||||
};
|
||||
|
||||
const applyViewport = () => {
|
||||
const vv = window.visualViewport;
|
||||
const viewportHeight = vv?.height ?? window.innerHeight;
|
||||
const viewportTop = vv?.offsetTop ?? 0;
|
||||
scheduleViewport(viewportHeight, viewportTop);
|
||||
};
|
||||
|
||||
applyViewport();
|
||||
window.addEventListener("resize", applyViewport);
|
||||
window.addEventListener("orientationchange", () => window.setTimeout(applyViewport, 120));
|
||||
window.visualViewport?.addEventListener("resize", applyViewport);
|
||||
window.visualViewport?.addEventListener("scroll", applyViewport);
|
||||
}
|
||||
|
||||
disablePageZoom();
|
||||
setupMobileViewportCompensation();
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(createPinia());
|
||||
app.mount("#app");
|
||||
14
terminal/apps/web/src/services/transport/factory.ts
Normal file
14
terminal/apps/web/src/services/transport/factory.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { TerminalTransport } from "./terminalTransport";
|
||||
import { GatewayTransport } from "./gatewayTransport";
|
||||
import { IosNativeTransport } from "./iosNativeTransport";
|
||||
|
||||
export function createTransport(
|
||||
mode: "gateway" | "ios-native",
|
||||
options: { gatewayUrl: string; gatewayToken: string }
|
||||
): TerminalTransport {
|
||||
if (mode === "ios-native") {
|
||||
return new IosNativeTransport();
|
||||
}
|
||||
return new GatewayTransport(options.gatewayUrl, options.gatewayToken);
|
||||
}
|
||||
|
||||
555
terminal/apps/web/src/services/transport/gatewayTransport.ts
Normal file
555
terminal/apps/web/src/services/transport/gatewayTransport.ts
Normal file
@@ -0,0 +1,555 @@
|
||||
import type { SessionState } from "@remoteconn/terminal-core";
|
||||
import type { ConnectParams, StdinMeta, TerminalTransport, TransportEvent } from "./terminalTransport";
|
||||
|
||||
type GatewayFrame = {
|
||||
type: string;
|
||||
payload?: Record<string, unknown>;
|
||||
data?: unknown;
|
||||
action?: unknown;
|
||||
code?: unknown;
|
||||
message?: unknown;
|
||||
};
|
||||
|
||||
export class GatewayTransport implements TerminalTransport {
|
||||
private static readonly PREFERRED_ENDPOINT_KEY = "terminal.gateway.preferredEndpoint";
|
||||
private static readonly CONNECT_TIMEOUT_MS = 12000;
|
||||
private socket: WebSocket | null = null;
|
||||
private listeners = new Set<(event: TransportEvent) => void>();
|
||||
private pingAt = 0;
|
||||
private heartbeatTimer: number | null = null;
|
||||
private connectAttemptId = 0;
|
||||
private state: SessionState = "idle";
|
||||
|
||||
public constructor(
|
||||
private readonly gatewayUrl: string,
|
||||
private readonly token: string
|
||||
) {}
|
||||
|
||||
public async connect(params: ConnectParams): Promise<void> {
|
||||
const attemptId = ++this.connectAttemptId;
|
||||
this.log("connect:start", {
|
||||
attemptId,
|
||||
host: params.host,
|
||||
port: params.port,
|
||||
username: params.username,
|
||||
cols: params.cols,
|
||||
rows: params.rows,
|
||||
hasClientSessionKey: Boolean(params.clientSessionKey),
|
||||
hasKnownHostFingerprint: Boolean(params.knownHostFingerprint)
|
||||
});
|
||||
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
||||
this.log("connect:already_open");
|
||||
throw new Error("会话已连接");
|
||||
}
|
||||
|
||||
this.state = "connecting";
|
||||
|
||||
this.socket = await new Promise<WebSocket>((resolve, reject) => {
|
||||
const endpoints = this.buildEndpoints();
|
||||
this.log("connect:endpoints", { endpoints });
|
||||
const reasons: string[] = [];
|
||||
let index = 0;
|
||||
const candidateHint = `候选地址: ${endpoints.join(", ")}`;
|
||||
|
||||
const tryConnect = (): void => {
|
||||
if (attemptId !== this.connectAttemptId) {
|
||||
this.log("connect:attempt_cancelled", { attemptId });
|
||||
reject(new Error("连接已取消"));
|
||||
return;
|
||||
}
|
||||
const endpoint = endpoints[index];
|
||||
if (!endpoint) {
|
||||
this.log("connect:all_failed", { reasons, candidateHint });
|
||||
reject(new Error(`无法连接网关: ${reasons.join(" | ") || "无可用网关地址"} | ${candidateHint}`));
|
||||
return;
|
||||
}
|
||||
let settled = false;
|
||||
let socket: WebSocket;
|
||||
let timeoutTimer: number | null = null;
|
||||
this.log("ws:connecting", { endpoint, attempt: index + 1, total: endpoints.length });
|
||||
|
||||
try {
|
||||
socket = new WebSocket(endpoint);
|
||||
this.socket = socket;
|
||||
} catch {
|
||||
this.log("ws:invalid_endpoint", { endpoint });
|
||||
reasons.push(`地址无效: ${endpoint}`);
|
||||
if (index < endpoints.length - 1) {
|
||||
index += 1;
|
||||
tryConnect();
|
||||
return;
|
||||
}
|
||||
reject(new Error(`无法连接网关: ${reasons.join(" | ")} | ${candidateHint}`));
|
||||
return;
|
||||
}
|
||||
|
||||
timeoutTimer = window.setTimeout(() => {
|
||||
fail(`连接超时>${GatewayTransport.CONNECT_TIMEOUT_MS}ms`);
|
||||
}, GatewayTransport.CONNECT_TIMEOUT_MS);
|
||||
|
||||
const clearTimer = (): void => {
|
||||
if (timeoutTimer !== null) {
|
||||
window.clearTimeout(timeoutTimer);
|
||||
timeoutTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const fail = (reason: string): void => {
|
||||
if (settled) return;
|
||||
if (attemptId !== this.connectAttemptId) {
|
||||
settled = true;
|
||||
clearTimer();
|
||||
reject(new Error("连接已取消"));
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimer();
|
||||
reasons.push(`${reason}: ${endpoint}`);
|
||||
this.log("ws:connect_failed", { endpoint, reason });
|
||||
if (this.getPreferredEndpoint() === endpoint) {
|
||||
this.clearPreferredEndpoint();
|
||||
this.log("connect:preferred_endpoint_cleared", { endpoint, reason });
|
||||
}
|
||||
try {
|
||||
socket.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (index < endpoints.length - 1) {
|
||||
index += 1;
|
||||
tryConnect();
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error(`无法连接网关: ${reasons.join(" | ")} | ${candidateHint}`));
|
||||
};
|
||||
|
||||
socket.onopen = () => {
|
||||
if (settled) return;
|
||||
if (attemptId !== this.connectAttemptId) {
|
||||
settled = true;
|
||||
clearTimer();
|
||||
try {
|
||||
socket.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
reject(new Error("连接已取消"));
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimer();
|
||||
this.log("ws:open", { endpoint });
|
||||
this.setPreferredEndpoint(endpoint);
|
||||
resolve(socket);
|
||||
};
|
||||
socket.onerror = () => fail("网络或协议错误");
|
||||
socket.onclose = (event) => {
|
||||
if (!settled) {
|
||||
fail(`连接关闭 code=${event.code} reason=${event.reason || "none"}`);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
tryConnect();
|
||||
});
|
||||
if (attemptId !== this.connectAttemptId) {
|
||||
this.log("connect:socket_ready_but_cancelled", { attemptId });
|
||||
try {
|
||||
this.socket?.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw new Error("连接已取消");
|
||||
}
|
||||
this.log("connect:socket_ready", { readyState: this.socket.readyState });
|
||||
|
||||
this.socket.onmessage = (event) => {
|
||||
const text = typeof event.data === "string" ? event.data : "";
|
||||
if (!text) return;
|
||||
let frame: GatewayFrame;
|
||||
try {
|
||||
frame = JSON.parse(text) as GatewayFrame;
|
||||
} catch {
|
||||
this.log("ws:message_parse_failed", { sample: text.slice(0, 120) });
|
||||
return;
|
||||
}
|
||||
this.handleFrame(frame);
|
||||
};
|
||||
|
||||
this.socket.onclose = (event) => {
|
||||
console.log(`[GatewayTransport][${new Date().toISOString()}] ws:onclose`, {
|
||||
code: event.code,
|
||||
reason: event.reason || "none",
|
||||
wasClean: event.wasClean
|
||||
});
|
||||
console.error(`[GatewayTransport][${new Date().toISOString()}] ws:onclose`, {
|
||||
code: event.code,
|
||||
reason: event.reason || "none",
|
||||
wasClean: event.wasClean
|
||||
});
|
||||
this.log("ws:onclose", { code: event.code, reason: event.reason || "none", wasClean: event.wasClean });
|
||||
this.stopHeartbeat();
|
||||
this.state = "disconnected";
|
||||
this.emit({
|
||||
type: "disconnect",
|
||||
reason: `ws_closed(code=${event.code},reason=${event.reason || "none"},clean=${event.wasClean})`
|
||||
});
|
||||
};
|
||||
|
||||
this.socket.onerror = () => {
|
||||
console.log(`[GatewayTransport][${new Date().toISOString()}] ws:onerror`);
|
||||
console.error(`[GatewayTransport][${new Date().toISOString()}] ws:onerror`);
|
||||
this.log("ws:onerror");
|
||||
this.stopHeartbeat();
|
||||
this.state = "error";
|
||||
this.emit({ type: "error", code: "WS_ERROR", message: "WebSocket 异常" });
|
||||
};
|
||||
|
||||
this.log("connect:send_init", {
|
||||
host: params.host,
|
||||
port: params.port,
|
||||
username: params.username,
|
||||
cols: params.cols,
|
||||
rows: params.rows,
|
||||
hasKnownHostFingerprint: Boolean(params.knownHostFingerprint)
|
||||
});
|
||||
this.sendRaw({
|
||||
type: "init",
|
||||
payload: {
|
||||
host: params.host,
|
||||
port: params.port,
|
||||
username: params.username,
|
||||
...(params.clientSessionKey ? { clientSessionKey: params.clientSessionKey } : {}),
|
||||
credential: params.credential,
|
||||
...(params.knownHostFingerprint ? { knownHostFingerprint: params.knownHostFingerprint } : {}),
|
||||
pty: { cols: params.cols, rows: params.rows }
|
||||
}
|
||||
});
|
||||
this.startHeartbeat();
|
||||
this.state = "auth_pending";
|
||||
this.log("connect:state_auth_pending");
|
||||
}
|
||||
|
||||
public async send(data: string, meta?: StdinMeta): Promise<void> {
|
||||
this.log("stdin:send", { length: data.length, source: meta?.source ?? "keyboard" });
|
||||
this.sendRaw({
|
||||
type: "stdin",
|
||||
payload: {
|
||||
data,
|
||||
...(meta ? { meta } : {})
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async resize(cols: number, rows: number): Promise<void> {
|
||||
this.log("pty:resize", { cols, rows });
|
||||
this.sendRaw({ type: "resize", payload: { cols, rows } });
|
||||
}
|
||||
|
||||
public async disconnect(reason = "manual"): Promise<void> {
|
||||
this.connectAttemptId += 1;
|
||||
this.log("disconnect:requested", { reason, readyState: this.socket?.readyState ?? null });
|
||||
this.stopHeartbeat();
|
||||
if (this.socket) {
|
||||
if (this.socket.readyState === WebSocket.OPEN) {
|
||||
this.log("disconnect:send_control", { reason });
|
||||
this.sendRaw({ type: "control", payload: { action: "disconnect", reason } });
|
||||
}
|
||||
// Force close regardless of state (e.g. CONNECTING)
|
||||
try {
|
||||
this.socket.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
this.socket = null;
|
||||
this.state = "disconnected";
|
||||
this.log("disconnect:done");
|
||||
}
|
||||
|
||||
public on(listener: (event: TransportEvent) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
public getState(): SessionState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
private sendRaw(frame: Record<string, unknown>): void {
|
||||
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
||||
this.log("ws:send_blocked", {
|
||||
frameType: String(frame.type ?? "unknown"),
|
||||
readyState: this.socket?.readyState ?? null,
|
||||
state: this.state
|
||||
});
|
||||
throw new Error("网关连接未建立");
|
||||
}
|
||||
this.socket.send(JSON.stringify(frame));
|
||||
}
|
||||
|
||||
private handleFrame(frame: GatewayFrame): void {
|
||||
const payload = frame.payload ?? {};
|
||||
const type = String(frame.type ?? "");
|
||||
const action = String((payload.action ?? frame.action ?? "") as string);
|
||||
if (type !== "stdout" && type !== "stderr") {
|
||||
this.log("ws:frame", { type, action: action || undefined });
|
||||
}
|
||||
|
||||
if (type === "stdout") {
|
||||
const data = String(payload.data ?? frame.data ?? "");
|
||||
if (!data) return;
|
||||
if (this.state !== "connected") {
|
||||
this.log("ws:stdout_promote_connected");
|
||||
}
|
||||
this.state = "connected";
|
||||
this.emit({ type: "stdout", data });
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "stderr") {
|
||||
const data = String(payload.data ?? frame.data ?? "");
|
||||
if (!data) return;
|
||||
this.emit({ type: "stderr", data });
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "error") {
|
||||
console.log(`[GatewayTransport][${new Date().toISOString()}] ws:error_frame`, {
|
||||
code: String(payload.code ?? frame.code ?? "INTERNAL_ERROR"),
|
||||
message: String(payload.message ?? frame.message ?? "未知错误")
|
||||
});
|
||||
console.error(`[GatewayTransport][${new Date().toISOString()}] ws:error_frame`, {
|
||||
code: String(payload.code ?? frame.code ?? "INTERNAL_ERROR"),
|
||||
message: String(payload.message ?? frame.message ?? "未知错误")
|
||||
});
|
||||
this.log("ws:error_frame", {
|
||||
code: String(payload.code ?? frame.code ?? "INTERNAL_ERROR"),
|
||||
message: String(payload.message ?? frame.message ?? "未知错误")
|
||||
});
|
||||
this.state = "error";
|
||||
this.emit({
|
||||
type: "error",
|
||||
code: String(payload.code ?? frame.code ?? "INTERNAL_ERROR"),
|
||||
message: String(payload.message ?? frame.message ?? "未知错误")
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "connected") {
|
||||
this.log("ws:connected_frame", { fingerprint: String(payload.fingerprint ?? "") || undefined });
|
||||
this.state = "connected";
|
||||
this.emit({ type: "connected", fingerprint: String(payload.fingerprint ?? "") || undefined });
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "disconnect") {
|
||||
this.log("ws:disconnect_frame", { reason: String(payload.reason ?? "unknown") });
|
||||
this.state = "disconnected";
|
||||
this.stopHeartbeat();
|
||||
this.emit({ type: "disconnect", reason: String(payload.reason ?? "unknown") });
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "control") {
|
||||
if (action === "ping") {
|
||||
this.log("heartbeat:ping_recv");
|
||||
this.sendRaw({ type: "control", payload: { action: "pong" } });
|
||||
return;
|
||||
}
|
||||
if (action === "pong") {
|
||||
if (this.pingAt > 0) {
|
||||
this.log("heartbeat:pong_recv", { latencyMs: Date.now() - this.pingAt });
|
||||
this.emit({ type: "latency", data: Date.now() - this.pingAt });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (action === "connected") {
|
||||
this.log("ws:control_connected", { fingerprint: String(payload.fingerprint ?? "") || undefined });
|
||||
this.state = "connected";
|
||||
this.emit({ type: "connected", fingerprint: String(payload.fingerprint ?? "") || undefined });
|
||||
return;
|
||||
}
|
||||
if (action === "disconnect") {
|
||||
this.log("ws:control_disconnect", { reason: String(payload.reason ?? "unknown") });
|
||||
this.state = "disconnected";
|
||||
this.stopHeartbeat();
|
||||
this.emit({ type: "disconnect", reason: String(payload.reason ?? "unknown") });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private emit(event: TransportEvent): void {
|
||||
for (const listener of this.listeners) {
|
||||
listener(event);
|
||||
}
|
||||
}
|
||||
|
||||
private startHeartbeat(): void {
|
||||
this.stopHeartbeat();
|
||||
this.log("heartbeat:start");
|
||||
this.heartbeatTimer = window.setInterval(() => {
|
||||
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
this.pingAt = Date.now();
|
||||
this.log("heartbeat:ping_send");
|
||||
this.sendRaw({ type: "control", payload: { action: "ping" } });
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
private stopHeartbeat(): void {
|
||||
if (this.heartbeatTimer) {
|
||||
this.log("heartbeat:stop");
|
||||
window.clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private log(_message: string, _detail?: unknown): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
const enabled =
|
||||
window.localStorage.getItem("terminal.debugTransport") === "1" ||
|
||||
window.localStorage.getItem("terminal.debugPaste") === "1";
|
||||
if (!enabled) return;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const prefix = `[GatewayTransport][${new Date().toISOString()}] ${_message}`;
|
||||
if (typeof _detail === "undefined") {
|
||||
console.log(prefix);
|
||||
return;
|
||||
}
|
||||
console.log(prefix, _detail);
|
||||
}
|
||||
|
||||
private buildEndpoints(): string[] {
|
||||
const pageIsHttps = window.location.protocol === "https:";
|
||||
const pageHost = window.location.hostname;
|
||||
const pageHostWithPort = window.location.host;
|
||||
const pageProtocol = pageIsHttps ? "wss:" : "ws:";
|
||||
const pageOrigin = `${pageProtocol}//${pageHostWithPort}`;
|
||||
const rawInput = this.gatewayUrl.trim();
|
||||
const candidates: string[] = [];
|
||||
|
||||
const finalizeEndpoint = (source: URL): string => {
|
||||
const next = new URL(source.toString());
|
||||
const pathname = next.pathname.replace(/\/+$/, "");
|
||||
next.pathname = pathname.endsWith("/ws/terminal") ? pathname : `${pathname}/ws/terminal`.replace(/\/{2,}/g, "/");
|
||||
next.search = `token=${encodeURIComponent(this.token)}`;
|
||||
return next.toString();
|
||||
};
|
||||
|
||||
const pushCandidate = (next: URL): void => {
|
||||
if (pageIsHttps && next.protocol === "ws:") {
|
||||
return;
|
||||
}
|
||||
candidates.push(finalizeEndpoint(next));
|
||||
};
|
||||
|
||||
// 兼容相对路径配置(如 /ws/terminal),并以当前页面源为基准解析。
|
||||
const fallbackUrl = new URL(pageOrigin);
|
||||
let url: URL;
|
||||
try {
|
||||
if (!rawInput) {
|
||||
url = new URL(fallbackUrl.toString());
|
||||
} else if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(rawInput)) {
|
||||
url = new URL(rawInput);
|
||||
} else {
|
||||
url = new URL(rawInput, fallbackUrl.toString());
|
||||
}
|
||||
} catch {
|
||||
url = new URL(fallbackUrl.toString());
|
||||
}
|
||||
|
||||
if (url.protocol === "http:") url.protocol = "ws:";
|
||||
if (url.protocol === "https:") url.protocol = "wss:";
|
||||
if (pageIsHttps && url.protocol === "ws:") {
|
||||
url.protocol = "wss:";
|
||||
}
|
||||
|
||||
const localHosts = new Set(["localhost", "127.0.0.1", "::1"]);
|
||||
const pageIsLocal = localHosts.has(pageHost);
|
||||
let targetIsLocal = localHosts.has(url.hostname);
|
||||
if (!pageIsLocal && targetIsLocal) {
|
||||
// 页面从远程域名访问时,若配置仍是 localhost/127.0.0.1,
|
||||
// 优先尝试同源地址(通常由 Vite/Nginx 代理到本地网关)。
|
||||
const sameOrigin = new URL(url.toString());
|
||||
sameOrigin.protocol = pageProtocol;
|
||||
sameOrigin.host = pageHostWithPort;
|
||||
pushCandidate(sameOrigin);
|
||||
|
||||
const sameOriginNoPort = new URL(sameOrigin.toString());
|
||||
sameOriginNoPort.port = "";
|
||||
pushCandidate(sameOriginNoPort);
|
||||
|
||||
url.hostname = pageHost;
|
||||
targetIsLocal = localHosts.has(url.hostname);
|
||||
}
|
||||
|
||||
pushCandidate(url);
|
||||
|
||||
if (!pageIsHttps && url.protocol === "ws:") {
|
||||
const tlsUrl = new URL(url.toString());
|
||||
tlsUrl.protocol = "wss:";
|
||||
pushCandidate(tlsUrl);
|
||||
} else if (url.protocol === "wss:" && !pageIsHttps) {
|
||||
const plainUrl = new URL(url.toString());
|
||||
plainUrl.protocol = "ws:";
|
||||
pushCandidate(plainUrl);
|
||||
}
|
||||
|
||||
if (!targetIsLocal) {
|
||||
const noPort = new URL(url.toString());
|
||||
noPort.port = "";
|
||||
pushCandidate(noPort);
|
||||
|
||||
if (!pageIsHttps && noPort.protocol === "ws:") {
|
||||
const noPortTls = new URL(noPort.toString());
|
||||
noPortTls.protocol = "wss:";
|
||||
pushCandidate(noPortTls);
|
||||
} else if (noPort.protocol === "wss:" && !pageIsHttps) {
|
||||
const noPortPlain = new URL(noPort.toString());
|
||||
noPortPlain.protocol = "ws:";
|
||||
pushCandidate(noPortPlain);
|
||||
}
|
||||
}
|
||||
|
||||
const ordered = [...new Set(candidates)];
|
||||
if (ordered.length === 0) {
|
||||
return [finalizeEndpoint(fallbackUrl)];
|
||||
}
|
||||
const preferred = this.getPreferredEndpoint();
|
||||
if (preferred && ordered.includes(preferred)) {
|
||||
return [preferred, ...ordered.filter((endpoint) => endpoint !== preferred)];
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
private getPreferredEndpoint(): string | null {
|
||||
try {
|
||||
return window.localStorage.getItem(GatewayTransport.PREFERRED_ENDPOINT_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private setPreferredEndpoint(endpoint: string): void {
|
||||
try {
|
||||
window.localStorage.setItem(GatewayTransport.PREFERRED_ENDPOINT_KEY, endpoint);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
private clearPreferredEndpoint(): void {
|
||||
try {
|
||||
window.localStorage.removeItem(GatewayTransport.PREFERRED_ENDPOINT_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
174
terminal/apps/web/src/services/transport/iosNativeTransport.ts
Normal file
174
terminal/apps/web/src/services/transport/iosNativeTransport.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import type { SessionState } from "@remoteconn/terminal-core";
|
||||
import type { ConnectParams, StdinMeta, TerminalTransport, TransportEvent } from "./terminalTransport";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Capacitor?: {
|
||||
Plugins?: {
|
||||
RemoteConnSSH?: {
|
||||
connect(options: unknown): Promise<void>;
|
||||
send(options: { data: string }): Promise<void>;
|
||||
resize(options: { cols: number; rows: number }): Promise<void>;
|
||||
disconnect(options: { reason?: string }): Promise<void>;
|
||||
addListener(
|
||||
eventName: "stdout" | "stderr" | "disconnect" | "latency" | "error" | "connected",
|
||||
listener: (payload: unknown) => void
|
||||
): Promise<{ remove: () => void }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
type NativeCredentialPayload =
|
||||
| { type: "password"; password: string }
|
||||
| { type: "privateKey"; privateKey: string; passphrase?: string }
|
||||
| { type: "certificate"; privateKey: string; passphrase?: string; certificate: string };
|
||||
|
||||
interface NativeConnectPayload {
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
knownHostFingerprint?: string;
|
||||
cols: number;
|
||||
rows: number;
|
||||
credential: NativeCredentialPayload;
|
||||
}
|
||||
|
||||
function buildNativeConnectPayload(params: ConnectParams): NativeConnectPayload {
|
||||
const base = {
|
||||
host: String(params.host ?? ""),
|
||||
port: Number(params.port ?? 22),
|
||||
username: String(params.username ?? ""),
|
||||
cols: Number(params.cols ?? 80),
|
||||
rows: Number(params.rows ?? 24)
|
||||
};
|
||||
|
||||
const knownHostFingerprint =
|
||||
typeof params.knownHostFingerprint === "string" && params.knownHostFingerprint.trim().length > 0
|
||||
? params.knownHostFingerprint.trim()
|
||||
: undefined;
|
||||
|
||||
if (params.credential.type === "password") {
|
||||
return {
|
||||
...base,
|
||||
...(knownHostFingerprint ? { knownHostFingerprint } : {}),
|
||||
credential: {
|
||||
type: "password",
|
||||
password: String(params.credential.password ?? "")
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (params.credential.type === "privateKey") {
|
||||
return {
|
||||
...base,
|
||||
...(knownHostFingerprint ? { knownHostFingerprint } : {}),
|
||||
credential: {
|
||||
type: "privateKey",
|
||||
privateKey: String(params.credential.privateKey ?? ""),
|
||||
...(params.credential.passphrase ? { passphrase: String(params.credential.passphrase) } : {})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
...(knownHostFingerprint ? { knownHostFingerprint } : {}),
|
||||
credential: {
|
||||
type: "certificate",
|
||||
privateKey: String(params.credential.privateKey ?? ""),
|
||||
certificate: String(params.credential.certificate ?? ""),
|
||||
...(params.credential.passphrase ? { passphrase: String(params.credential.passphrase) } : {})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export class IosNativeTransport implements TerminalTransport {
|
||||
private state: SessionState = "idle";
|
||||
private listeners = new Set<(event: TransportEvent) => void>();
|
||||
private disposers: Array<() => void> = [];
|
||||
|
||||
public async connect(params: ConnectParams): Promise<void> {
|
||||
const plugin = window.Capacitor?.Plugins?.RemoteConnSSH;
|
||||
if (!plugin) {
|
||||
throw new Error("iOS 原生插件不可用");
|
||||
}
|
||||
|
||||
this.state = "connecting";
|
||||
|
||||
const onStdout = await plugin.addListener("stdout", (payload) => {
|
||||
this.state = "connected";
|
||||
this.emit({ type: "stdout", data: String((payload as { data?: string }).data ?? "") });
|
||||
});
|
||||
this.disposers.push(() => onStdout.remove());
|
||||
|
||||
const onStderr = await plugin.addListener("stderr", (payload) => {
|
||||
this.emit({ type: "stderr", data: String((payload as { data?: string }).data ?? "") });
|
||||
});
|
||||
this.disposers.push(() => onStderr.remove());
|
||||
|
||||
const onDisconnect = await plugin.addListener("disconnect", (payload) => {
|
||||
this.state = "disconnected";
|
||||
this.emit({ type: "disconnect", reason: String((payload as { reason?: string }).reason ?? "disconnect") });
|
||||
});
|
||||
this.disposers.push(() => onDisconnect.remove());
|
||||
|
||||
const onLatency = await plugin.addListener("latency", (payload) => {
|
||||
this.emit({ type: "latency", data: Number((payload as { latency?: number }).latency ?? 0) });
|
||||
});
|
||||
this.disposers.push(() => onLatency.remove());
|
||||
|
||||
const onError = await plugin.addListener("error", (payload) => {
|
||||
this.state = "error";
|
||||
const error = payload as { code?: string; message?: string };
|
||||
this.emit({
|
||||
type: "error",
|
||||
code: String(error.code ?? "NATIVE_ERROR"),
|
||||
message: String(error.message ?? "iOS 连接异常")
|
||||
});
|
||||
});
|
||||
this.disposers.push(() => onError.remove());
|
||||
|
||||
const onConnected = await plugin.addListener("connected", (payload) => {
|
||||
this.state = "connected";
|
||||
this.emit({ type: "connected", fingerprint: String((payload as { fingerprint?: string }).fingerprint ?? "") || undefined });
|
||||
});
|
||||
this.disposers.push(() => onConnected.remove());
|
||||
|
||||
await plugin.connect(buildNativeConnectPayload(params));
|
||||
}
|
||||
|
||||
public async send(data: string, _meta?: StdinMeta): Promise<void> {
|
||||
await window.Capacitor?.Plugins?.RemoteConnSSH?.send({ data });
|
||||
}
|
||||
|
||||
public async resize(cols: number, rows: number): Promise<void> {
|
||||
await window.Capacitor?.Plugins?.RemoteConnSSH?.resize({ cols, rows });
|
||||
}
|
||||
|
||||
public async disconnect(reason?: string): Promise<void> {
|
||||
await window.Capacitor?.Plugins?.RemoteConnSSH?.disconnect({ reason });
|
||||
for (const dispose of this.disposers) {
|
||||
dispose();
|
||||
}
|
||||
this.disposers = [];
|
||||
this.state = "disconnected";
|
||||
}
|
||||
|
||||
public on(listener: (event: TransportEvent) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
public getState(): SessionState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
private emit(event: TransportEvent): void {
|
||||
for (const listener of this.listeners) {
|
||||
listener(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ConnectParams as CoreConnectParams, FrameMeta, SessionState } from "@remoteconn/terminal-core";
|
||||
|
||||
export type ConnectParams = CoreConnectParams;
|
||||
export type StdinMeta = FrameMeta;
|
||||
|
||||
export type TransportEvent =
|
||||
| { type: "stdout"; data: string }
|
||||
| { type: "stderr"; data: string }
|
||||
| { type: "latency"; data: number }
|
||||
| { type: "connected"; fingerprint?: string }
|
||||
| { type: "disconnect"; reason: string }
|
||||
| { type: "error"; code: string; message: string };
|
||||
|
||||
export interface TerminalTransport {
|
||||
connect(params: ConnectParams): Promise<void>;
|
||||
send(data: string, meta?: StdinMeta): Promise<void>;
|
||||
resize(cols: number, rows: number): Promise<void>;
|
||||
disconnect(reason?: string): Promise<void>;
|
||||
on(listener: (event: TransportEvent) => void): () => void;
|
||||
getState(): SessionState;
|
||||
}
|
||||
|
||||
32
terminal/apps/web/src/stores/appStore.ts
Normal file
32
terminal/apps/web/src/stores/appStore.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
|
||||
interface AppToast {
|
||||
id: string;
|
||||
level: "info" | "warn" | "error";
|
||||
message: string;
|
||||
}
|
||||
|
||||
export const useAppStore = defineStore("app", () => {
|
||||
const toasts = ref<AppToast[]>([]);
|
||||
|
||||
function notify(level: AppToast["level"], message: string): void {
|
||||
const item: AppToast = {
|
||||
id: typeof crypto !== "undefined" && "randomUUID" in crypto
|
||||
? crypto.randomUUID()
|
||||
: `toast-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
level,
|
||||
message
|
||||
};
|
||||
toasts.value.push(item);
|
||||
window.setTimeout(() => {
|
||||
toasts.value = toasts.value.filter((x) => x.id !== item.id);
|
||||
}, level === "error" ? 5000 : 3000);
|
||||
}
|
||||
|
||||
return {
|
||||
toasts,
|
||||
notify
|
||||
};
|
||||
});
|
||||
|
||||
231
terminal/apps/web/src/stores/serverStore.ts
Normal file
231
terminal/apps/web/src/stores/serverStore.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { computed, ref } from "vue";
|
||||
import type { TerminalCredential } from "@remoteconn/terminal-core";
|
||||
import { loadRuntimeConfig } from "@/utils/runtimeConfig";
|
||||
|
||||
export interface ServerProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
transportMode: "gateway" | "ios-native";
|
||||
knownHostFingerprint?: string;
|
||||
authType: "password" | "privateKey" | "certificate";
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
passphrase?: string;
|
||||
certificate?: string;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "remoteconn:web:servers:v1";
|
||||
const SELECTED_SERVER_KEY = "remoteconn:web:selected-server-id:v1";
|
||||
|
||||
function loadServers(): ServerProfile[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
return normalizeServers(parsed);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function toFiniteNumber(value: unknown, fallback: number): number {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
function normalizeServers(input: unknown): ServerProfile[] {
|
||||
if (!Array.isArray(input)) return [];
|
||||
return input
|
||||
.filter((item): item is Record<string, unknown> => !!item && typeof item === "object" && typeof (item as { id?: unknown }).id === "string")
|
||||
.map((item) => {
|
||||
const authType = item.authType === "privateKey" || item.authType === "certificate" ? item.authType : "password";
|
||||
return {
|
||||
id: String(item.id),
|
||||
name: typeof item.name === "string" ? item.name : "未命名服务器",
|
||||
host: typeof item.host === "string" ? item.host : "",
|
||||
port: toFiniteNumber(item.port, 22),
|
||||
username: typeof item.username === "string" ? item.username : "root",
|
||||
transportMode: item.transportMode === "ios-native" ? "ios-native" : "gateway",
|
||||
authType,
|
||||
...(typeof item.knownHostFingerprint === "string" && item.knownHostFingerprint ? { knownHostFingerprint: item.knownHostFingerprint } : {}),
|
||||
...(typeof item.password === "string" ? { password: item.password } : {}),
|
||||
...(typeof item.privateKey === "string" ? { privateKey: item.privateKey } : {}),
|
||||
...(typeof item.passphrase === "string" ? { passphrase: item.passphrase } : {}),
|
||||
...(typeof item.certificate === "string" ? { certificate: item.certificate } : {}),
|
||||
cols: toFiniteNumber(item.cols, 80),
|
||||
rows: toFiniteNumber(item.rows, 24)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function persistServers(servers: ServerProfile[]): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(servers));
|
||||
} catch {
|
||||
// ignore storage unavailability in embedded/private contexts
|
||||
}
|
||||
}
|
||||
|
||||
function loadSelectedServerId(): string {
|
||||
if (typeof window === "undefined") return "";
|
||||
try {
|
||||
return window.localStorage.getItem(SELECTED_SERVER_KEY) ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function persistSelectedServerId(id: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(SELECTED_SERVER_KEY, id);
|
||||
} catch {
|
||||
// ignore storage unavailability in embedded/private contexts
|
||||
}
|
||||
}
|
||||
|
||||
function buildDefaultServer(): ServerProfile {
|
||||
return {
|
||||
id: `srv-${typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : Date.now()}`,
|
||||
name: "新服务器",
|
||||
host: "",
|
||||
port: 22,
|
||||
username: "root",
|
||||
transportMode: "gateway",
|
||||
authType: "password",
|
||||
password: "",
|
||||
cols: 80,
|
||||
rows: 24
|
||||
};
|
||||
}
|
||||
|
||||
export const useServerStore = defineStore("server", () => {
|
||||
const servers = ref<ServerProfile[]>([]);
|
||||
const selectedServerId = ref<string>("");
|
||||
const loaded = ref(false);
|
||||
let bootstrapPromise: Promise<void> | null = null;
|
||||
|
||||
const selectedServer = computed(() => servers.value.find((item) => item.id === selectedServerId.value));
|
||||
|
||||
async function ensureBootstrapped(): Promise<void> {
|
||||
if (loaded.value) return;
|
||||
if (bootstrapPromise) {
|
||||
await bootstrapPromise;
|
||||
return;
|
||||
}
|
||||
bootstrapPromise = (async () => {
|
||||
const runtimeConfig = await loadRuntimeConfig();
|
||||
const configured = normalizeServers(runtimeConfig?.servers);
|
||||
const stored = loadServers();
|
||||
if (configured.length > 0) {
|
||||
servers.value = configured;
|
||||
} else if (stored.length > 0) {
|
||||
servers.value = stored;
|
||||
} else {
|
||||
const sample = buildDefaultServer();
|
||||
servers.value = [sample];
|
||||
persistServers(servers.value);
|
||||
}
|
||||
|
||||
const preferred = runtimeConfig?.selectedServerId ?? loadSelectedServerId();
|
||||
selectedServerId.value = servers.value.some((item) => item.id === preferred)
|
||||
? preferred
|
||||
: (servers.value[0]?.id ?? "");
|
||||
persistSelectedServerId(selectedServerId.value);
|
||||
loaded.value = true;
|
||||
})();
|
||||
try {
|
||||
await bootstrapPromise;
|
||||
} finally {
|
||||
bootstrapPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
await ensureBootstrapped();
|
||||
}
|
||||
|
||||
function setSelectedServer(serverId: string): void {
|
||||
selectedServerId.value = serverId;
|
||||
persistSelectedServerId(serverId);
|
||||
}
|
||||
|
||||
async function saveServer(server: ServerProfile): Promise<void> {
|
||||
const index = servers.value.findIndex((item) => item.id === server.id);
|
||||
if (index >= 0) {
|
||||
servers.value[index] = server;
|
||||
} else {
|
||||
servers.value.unshift(server);
|
||||
}
|
||||
persistServers(servers.value);
|
||||
if (!selectedServerId.value) {
|
||||
setSelectedServer(server.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function createServer(): Promise<ServerProfile> {
|
||||
const sample = buildDefaultServer();
|
||||
servers.value.unshift(sample);
|
||||
persistServers(servers.value);
|
||||
setSelectedServer(sample.id);
|
||||
return sample;
|
||||
}
|
||||
|
||||
async function deleteServer(serverId: string): Promise<void> {
|
||||
servers.value = servers.value.filter((item) => item.id !== serverId);
|
||||
persistServers(servers.value);
|
||||
if (selectedServerId.value === serverId) {
|
||||
setSelectedServer(servers.value[0]?.id ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveCredential(serverId: string): Promise<TerminalCredential> {
|
||||
const server = servers.value.find((item) => item.id === serverId);
|
||||
if (!server) {
|
||||
throw new Error("目标服务器不存在");
|
||||
}
|
||||
|
||||
if (server.authType === "password") {
|
||||
return {
|
||||
type: "password",
|
||||
password: server.password ?? ""
|
||||
};
|
||||
}
|
||||
|
||||
if (server.authType === "privateKey") {
|
||||
return {
|
||||
type: "privateKey",
|
||||
privateKey: server.privateKey ?? "",
|
||||
...(server.passphrase ? { passphrase: server.passphrase } : {})
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "certificate",
|
||||
privateKey: server.privateKey ?? "",
|
||||
certificate: server.certificate ?? "",
|
||||
...(server.passphrase ? { passphrase: server.passphrase } : {})
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
servers,
|
||||
selectedServerId,
|
||||
selectedServer,
|
||||
ensureBootstrapped,
|
||||
bootstrap,
|
||||
setSelectedServer,
|
||||
saveServer,
|
||||
createServer,
|
||||
deleteServer,
|
||||
resolveCredential
|
||||
};
|
||||
});
|
||||
110
terminal/apps/web/src/stores/settingsStore.ts
Normal file
110
terminal/apps/web/src/stores/settingsStore.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { computed, ref } from "vue";
|
||||
import { loadRuntimeConfig } from "@/utils/runtimeConfig";
|
||||
|
||||
interface RuntimeSettings {
|
||||
gatewayUrl: string;
|
||||
gatewayToken: string;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "remoteconn:web:settings:v1";
|
||||
|
||||
function resolveDefaultGatewayUrl(): string {
|
||||
const env = (import.meta as ImportMeta & { env?: Record<string, string | undefined> }).env;
|
||||
const envUrl = env?.VITE_GATEWAY_URL?.trim();
|
||||
if (envUrl) return envUrl;
|
||||
|
||||
if (typeof window === "undefined") return "ws://127.0.0.1:8787/ws/terminal";
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${protocol}//${window.location.host}/ws/terminal`;
|
||||
}
|
||||
|
||||
function resolveDefaultGatewayToken(): string {
|
||||
const env = (import.meta as ImportMeta & { env?: Record<string, string | undefined> }).env;
|
||||
const envToken = env?.VITE_GATEWAY_TOKEN?.trim();
|
||||
if (envToken) return envToken;
|
||||
return "remoteconn-dev-token";
|
||||
}
|
||||
|
||||
function loadSettings(): RuntimeSettings {
|
||||
if (typeof window === "undefined") {
|
||||
return { gatewayUrl: resolveDefaultGatewayUrl(), gatewayToken: resolveDefaultGatewayToken() };
|
||||
}
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return { gatewayUrl: resolveDefaultGatewayUrl(), gatewayToken: resolveDefaultGatewayToken() };
|
||||
}
|
||||
const parsed = JSON.parse(raw) as Partial<RuntimeSettings>;
|
||||
return {
|
||||
gatewayUrl: String(parsed.gatewayUrl ?? resolveDefaultGatewayUrl()),
|
||||
gatewayToken: String(parsed.gatewayToken ?? resolveDefaultGatewayToken())
|
||||
};
|
||||
} catch {
|
||||
return { gatewayUrl: resolveDefaultGatewayUrl(), gatewayToken: resolveDefaultGatewayToken() };
|
||||
}
|
||||
}
|
||||
|
||||
function persistSettings(settings: RuntimeSettings): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||
} catch {
|
||||
// ignore storage unavailability in embedded/private contexts
|
||||
}
|
||||
}
|
||||
|
||||
export const useSettingsStore = defineStore("settings", () => {
|
||||
const settings = ref<RuntimeSettings>({
|
||||
gatewayUrl: resolveDefaultGatewayUrl(),
|
||||
gatewayToken: resolveDefaultGatewayToken()
|
||||
});
|
||||
const loaded = ref(false);
|
||||
let bootstrapPromise: Promise<void> | null = null;
|
||||
|
||||
async function ensureBootstrapped(): Promise<void> {
|
||||
if (loaded.value) return;
|
||||
if (bootstrapPromise) {
|
||||
await bootstrapPromise;
|
||||
return;
|
||||
}
|
||||
bootstrapPromise = (async () => {
|
||||
const runtimeConfig = await loadRuntimeConfig();
|
||||
const localSettings = loadSettings();
|
||||
settings.value = {
|
||||
gatewayUrl: String(runtimeConfig?.gatewayUrl ?? localSettings.gatewayUrl ?? resolveDefaultGatewayUrl()),
|
||||
gatewayToken: String(runtimeConfig?.gatewayToken ?? localSettings.gatewayToken ?? resolveDefaultGatewayToken())
|
||||
};
|
||||
loaded.value = true;
|
||||
})();
|
||||
try {
|
||||
await bootstrapPromise;
|
||||
} finally {
|
||||
bootstrapPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
await ensureBootstrapped();
|
||||
}
|
||||
|
||||
async function save(next: Partial<RuntimeSettings>): Promise<void> {
|
||||
settings.value = {
|
||||
gatewayUrl: String(next.gatewayUrl ?? settings.value.gatewayUrl ?? resolveDefaultGatewayUrl()),
|
||||
gatewayToken: String(next.gatewayToken ?? settings.value.gatewayToken ?? resolveDefaultGatewayToken())
|
||||
};
|
||||
persistSettings(settings.value);
|
||||
}
|
||||
|
||||
const gatewayUrl = computed(() => settings.value.gatewayUrl || resolveDefaultGatewayUrl());
|
||||
const gatewayToken = computed(() => settings.value.gatewayToken || resolveDefaultGatewayToken());
|
||||
|
||||
return {
|
||||
settings,
|
||||
gatewayUrl,
|
||||
gatewayToken,
|
||||
ensureBootstrapped,
|
||||
bootstrap,
|
||||
save
|
||||
};
|
||||
});
|
||||
21
terminal/apps/web/src/style.css
Normal file
21
terminal/apps/web/src/style.css
Normal file
@@ -0,0 +1,21 @@
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
background: #1a1a1a;
|
||||
overscroll-behavior-y: none;
|
||||
}
|
||||
|
||||
body {
|
||||
overflow: hidden;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
484
terminal/apps/web/src/terminal/TerminalPage.vue
Normal file
484
terminal/apps/web/src/terminal/TerminalPage.vue
Normal file
@@ -0,0 +1,484 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* TerminalPage — 新 terminal-core 架构的实验终端页面。
|
||||
*
|
||||
* 职责:
|
||||
* - 编排 TerminalToolbar / TerminalViewport / TerminalInputBar / TerminalTouchTools
|
||||
* - 处理连接/断开流程(从 serverStore 取服务器与凭据)
|
||||
* - BEL → navigator.vibrate / toast
|
||||
* - 标题 OSC → toolbar 展示
|
||||
* - 触控模式检测(viewport 点击 → focus input anchor)
|
||||
* - 软键盘弹出/收回:提示符行置于屏幕距顶 1/4 处,收回时恢复滚动
|
||||
*/
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from "vue";
|
||||
import { InputBridge } from "@remoteconn/terminal-core";
|
||||
import { useTerminalStore } from "@/terminal/stores/useTerminalStore";
|
||||
import { useServerStore } from "@/stores/serverStore";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import TerminalToolbar from "./components/TerminalToolbar.vue";
|
||||
import TerminalViewport from "./components/TerminalViewport.vue";
|
||||
import TerminalInputBar from "./components/TerminalInputBar.vue";
|
||||
import TerminalTouchTools from "./components/TerminalTouchTools.vue";
|
||||
import { KeyboardAdjustController } from "@/terminal/input/keyboardAdjustController";
|
||||
import { formatActionError } from "@/utils/feedback";
|
||||
|
||||
const store = useTerminalStore();
|
||||
const serverStore = useServerStore();
|
||||
const appStore = useAppStore();
|
||||
const pasteBridge = new InputBridge({ cursorKeyMode: "normal", bracketedPaste: true });
|
||||
|
||||
const toolbarWrapRef = ref<HTMLElement | null>(null);
|
||||
const viewportWrapRef = ref<HTMLElement | null>(null);
|
||||
const inputBarRef = ref<InstanceType<typeof TerminalInputBar> | null>(null);
|
||||
|
||||
const AUTO_RECONNECT_KEY = "remoteconn:web:auto-reconnect:v1";
|
||||
const CLIENT_SESSION_KEY_PREFIX = "remoteconn:web:client-session-key:v1:";
|
||||
|
||||
let pageUnloading = false;
|
||||
|
||||
// ── 触控状态 ────────────────────────────────────────────────────────────────
|
||||
let touchStartX = 0;
|
||||
let touchStartY = 0;
|
||||
let touchMoved = false;
|
||||
let touchStartAt = 0;
|
||||
let touchStartScrollTop = 0;
|
||||
let touchDidScroll = false;
|
||||
let lastZoomGuardTapAt = 0;
|
||||
let keyboardLikelyVisible = false;
|
||||
|
||||
const DOUBLE_TAP_ZOOM_GUARD_MS = 320;
|
||||
const TAP_MOVE_THRESHOLD_PX = 12;
|
||||
const TAP_MAX_DURATION_MS = 260;
|
||||
|
||||
// ── 软键盘调整控制器 ─────────────────────────────────────────────────────────
|
||||
let kbAdjust: KeyboardAdjustController | null = null;
|
||||
|
||||
function isDebugEnabled(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
return (
|
||||
window.localStorage.getItem("terminal.debugKeyboardAdjust") === "1" ||
|
||||
window.localStorage.getItem("terminal.debugTouch") === "1"
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function debugTouch(message: string, detail?: unknown): void {
|
||||
if (!isDebugEnabled()) return;
|
||||
const prefix = `[TerminalPage][Touch][${new Date().toISOString()}] ${message}`;
|
||||
detail !== undefined ? console.log(prefix, detail) : console.log(prefix);
|
||||
}
|
||||
|
||||
function isPasteDebugEnabled(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
return (
|
||||
window.localStorage.getItem("terminal.debugPaste") === "1" ||
|
||||
window.localStorage.getItem("terminal.debugTransport") === "1"
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function debugPaste(message: string, detail?: unknown): void {
|
||||
if (!isPasteDebugEnabled()) return;
|
||||
const prefix = `[TerminalPage][Paste][${new Date().toISOString()}] ${message}`;
|
||||
detail !== undefined ? console.log(prefix, detail) : console.log(prefix);
|
||||
}
|
||||
|
||||
function isTouchDevice(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
return window.matchMedia?.("(pointer: coarse)")?.matches ?? ("ontouchstart" in window);
|
||||
}
|
||||
|
||||
// ── 应用光标键模式(从 TerminalCore 读取,随 DECCKM 状态变化) ───────────────
|
||||
const appCursorKeys = ref(false);
|
||||
|
||||
// ── BEL / Title 事件订阅 ──────────────────────────────────────────────────────
|
||||
let offBell: (() => void) | null = null;
|
||||
let offTitle: (() => void) | null = null;
|
||||
|
||||
function subscribeEvents() {
|
||||
const core = store.getCore();
|
||||
if (!core) return;
|
||||
offBell = core.on("bell", () => {
|
||||
if (typeof navigator !== "undefined" && "vibrate" in navigator) {
|
||||
navigator.vibrate(50);
|
||||
}
|
||||
});
|
||||
offTitle = core.on("titleChange", (newTitle: string) => {
|
||||
store.title = newTitle;
|
||||
});
|
||||
}
|
||||
|
||||
function setAutoReconnect(enabled: boolean): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try { window.localStorage.setItem(AUTO_RECONNECT_KEY, enabled ? "1" : "0"); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function shouldAutoReconnect(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
try { return window.localStorage.getItem(AUTO_RECONNECT_KEY) === "1"; } catch { return false; }
|
||||
}
|
||||
|
||||
function getOrCreateClientSessionKey(serverId: string): string {
|
||||
if (typeof window === "undefined") return `session-${Date.now()}`;
|
||||
const storageKey = `${CLIENT_SESSION_KEY_PREFIX}${serverId}`;
|
||||
try {
|
||||
const existing = window.localStorage.getItem(storageKey);
|
||||
if (existing && existing.trim().length > 0) return existing;
|
||||
const next = (typeof crypto !== "undefined" && "randomUUID" in crypto)
|
||||
? crypto.randomUUID()
|
||||
: `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
window.localStorage.setItem(storageKey, next);
|
||||
return next;
|
||||
} catch {
|
||||
return (typeof crypto !== "undefined" && "randomUUID" in crypto)
|
||||
? crypto.randomUUID()
|
||||
: `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 连接 ──────────────────────────────────────────────────────────────────────
|
||||
async function handleConnect(options: { silent?: boolean } = {}): Promise<void> {
|
||||
const { silent = false } = options;
|
||||
const server = serverStore.selectedServer;
|
||||
if (!server) {
|
||||
if (!silent) appStore.notify("warn", "请先在服务器列表中选择目标服务器");
|
||||
return;
|
||||
}
|
||||
|
||||
const coreSnap = store.getCore()?.snapshot();
|
||||
const cols = server.cols ?? coreSnap?.cols ?? 80;
|
||||
const rows = server.rows ?? coreSnap?.rows ?? 24;
|
||||
|
||||
let credential: Awaited<ReturnType<typeof serverStore.resolveCredential>>;
|
||||
try {
|
||||
credential = await serverStore.resolveCredential(server.id);
|
||||
} catch (err) {
|
||||
appStore.notify("error", formatActionError("凭据解析失败", err));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const clientSessionKey = getOrCreateClientSessionKey(server.id);
|
||||
store.bindOutputCache(`${server.id}:${clientSessionKey}`);
|
||||
await store.connect({
|
||||
host: server.host,
|
||||
port: server.port,
|
||||
username: server.username,
|
||||
credential,
|
||||
clientSessionKey,
|
||||
...(server.knownHostFingerprint ? { knownHostFingerprint: server.knownHostFingerprint } : {}),
|
||||
cols,
|
||||
rows
|
||||
});
|
||||
setAutoReconnect(true);
|
||||
|
||||
// 连接后订阅 core 事件
|
||||
subscribeEvents();
|
||||
await nextTick();
|
||||
inputBarRef.value?.focus();
|
||||
} catch (err) {
|
||||
if (!silent) appStore.notify("error", formatActionError("连接失败", err));
|
||||
}
|
||||
}
|
||||
|
||||
function handleDisconnect(): void {
|
||||
setAutoReconnect(false);
|
||||
store.disconnect("manual");
|
||||
}
|
||||
|
||||
// ── 粘贴 ──────────────────────────────────────────────────────────────────────
|
||||
async function handlePaste(): Promise<void> {
|
||||
if (store.state !== "connected") return;
|
||||
debugPaste("paste:triggered");
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
debugPaste("paste:clipboard_read", { length: text.length });
|
||||
if (text) {
|
||||
const seq = pasteBridge.mapPaste(text);
|
||||
store.sendInput(seq, "paste");
|
||||
debugPaste("paste:dispatched", { normalizedLength: seq.length });
|
||||
}
|
||||
} catch {
|
||||
debugPaste("paste:clipboard_read_failed");
|
||||
appStore.notify("warn", "无法读取剪贴板,请手动粘贴");
|
||||
}
|
||||
}
|
||||
|
||||
// ── 输出区滚动元素 ────────────────────────────────────────────────────────────
|
||||
function getOutputScrollEl(): HTMLElement | null {
|
||||
return store.getOutputEl()
|
||||
?? (viewportWrapRef.value?.querySelector(".tc-output") as HTMLElement | null ?? null);
|
||||
}
|
||||
|
||||
// ── 视口点击 → 聚焦输入锚点 ─────────────────────────────────────────────────
|
||||
function focusInputAnchor(options?: { source?: "tap" | "click" }) {
|
||||
if (store.state !== "connected") return;
|
||||
const selection = typeof window !== "undefined" ? window.getSelection() : null;
|
||||
if (selection && !selection.isCollapsed && selection.toString().length > 0) return;
|
||||
|
||||
const activeEl = document.activeElement as HTMLElement | null;
|
||||
const alreadyAnchorFocused =
|
||||
activeEl?.tagName === "TEXTAREA" && activeEl.classList.contains("tc-input-anchor");
|
||||
|
||||
debugTouch("focus:request", {
|
||||
source: options?.source ?? "unknown",
|
||||
alreadyAnchorFocused,
|
||||
keyboardLikelyVisible,
|
||||
state: store.state,
|
||||
});
|
||||
|
||||
// 已聚焦且软键盘可见时不重复 focus(避免键盘闪退)
|
||||
if (alreadyAnchorFocused && keyboardLikelyVisible) return;
|
||||
inputBarRef.value?.focus();
|
||||
}
|
||||
|
||||
// ── 点击处理(桌面) ─────────────────────────────────────────────────────────
|
||||
function handleViewportClick(e: MouseEvent) {
|
||||
if (isTouchDevice()) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
focusInputAnchor({ source: "click" });
|
||||
}
|
||||
|
||||
// ── 触控处理 ─────────────────────────────────────────────────────────────────
|
||||
function handleViewportTouchStart(e: TouchEvent) {
|
||||
const touch = e.touches[0] ?? e.changedTouches[0];
|
||||
if (!touch) return;
|
||||
touchStartX = touch.clientX;
|
||||
touchStartY = touch.clientY;
|
||||
touchStartAt = Date.now();
|
||||
touchMoved = false;
|
||||
touchDidScroll = false;
|
||||
const scrollEl = getOutputScrollEl();
|
||||
touchStartScrollTop = scrollEl?.scrollTop ?? 0;
|
||||
debugTouch("event:touchstart", { x: touchStartX, y: touchStartY, touchStartScrollTop });
|
||||
}
|
||||
|
||||
function handleViewportTouchMove(e: TouchEvent) {
|
||||
const touch = e.touches[0] ?? e.changedTouches[0];
|
||||
if (!touch) return;
|
||||
const dx = Math.abs(touch.clientX - touchStartX);
|
||||
const dy = Math.abs(touch.clientY - touchStartY);
|
||||
if (dx > TAP_MOVE_THRESHOLD_PX || dy > TAP_MOVE_THRESHOLD_PX) {
|
||||
touchMoved = true;
|
||||
}
|
||||
|
||||
// 兜底滚动:部分机型/输入法场景原生滚动不稳定,手动驱动输出区纵向滚动
|
||||
const scrollEl = getOutputScrollEl();
|
||||
if (!scrollEl) return;
|
||||
const maxTop = Math.max(0, scrollEl.scrollHeight - scrollEl.clientHeight);
|
||||
if (maxTop <= 0) return;
|
||||
|
||||
const deltaY = touch.clientY - touchStartY;
|
||||
const nextTop = Math.max(0, Math.min(maxTop, touchStartScrollTop - deltaY));
|
||||
if (Math.abs(nextTop - scrollEl.scrollTop) < 1) return;
|
||||
|
||||
scrollEl.scrollTop = nextTop;
|
||||
touchDidScroll = true;
|
||||
e.preventDefault();
|
||||
debugTouch("event:touchmove_scroll", { deltaY, nextTop, maxTop });
|
||||
}
|
||||
|
||||
function handleViewportTouchEnd(e: TouchEvent) {
|
||||
const duration = Date.now() - touchStartAt;
|
||||
debugTouch("event:touchend", { touchMoved, touchDidScroll, duration, keyboardLikelyVisible });
|
||||
|
||||
if (touchDidScroll) return;
|
||||
if (touchMoved) return;
|
||||
if (duration > TAP_MAX_DURATION_MS) return;
|
||||
|
||||
// 轻触:阻止合成 click,无延迟聚焦以保持用户手势上下文(移动端弹键盘必须在用户手势内)
|
||||
e.preventDefault();
|
||||
debugTouch("event:tap_focus");
|
||||
focusInputAnchor({ source: "tap" });
|
||||
}
|
||||
|
||||
// ── 防双击缩放(工具栏/输入区) ───────────────────────────────────────────────
|
||||
function handlePageTouchEndCapture(e: TouchEvent) {
|
||||
const touch = e.changedTouches[0] ?? e.touches[0];
|
||||
if (!touch) return;
|
||||
const target = e.target as HTMLElement | null;
|
||||
|
||||
const toolbarBottom = toolbarWrapRef.value?.getBoundingClientRect().bottom ?? 0;
|
||||
const inToolbarBand = touch.clientY <= toolbarBottom + 8;
|
||||
const inInputZone = Boolean(target?.closest('[data-zone="native-input-zone"]'));
|
||||
if (!inToolbarBand && !inInputZone) return;
|
||||
|
||||
const now = Date.now();
|
||||
const dt = now - lastZoomGuardTapAt;
|
||||
lastZoomGuardTapAt = now;
|
||||
if (dt > DOUBLE_TAP_ZOOM_GUARD_MS) return;
|
||||
|
||||
e.preventDefault();
|
||||
debugTouch("event:double_tap_zoom_guard", { dt });
|
||||
}
|
||||
|
||||
// ── 软键盘状态同步(供 focusInputAnchor 判断) ────────────────────────────────
|
||||
// KeyboardAdjustController 通过 visualViewport resize 驱动实际 DOM 滚动。
|
||||
// 此处仅维护 keyboardLikelyVisible 标志供聚焦逻辑使用。
|
||||
const KEYBOARD_OPEN_THRESHOLD_PX = 120;
|
||||
const KEYBOARD_CLOSE_HYSTERESIS_PX = 40;
|
||||
let baselineVpHeight = 0;
|
||||
|
||||
function onVisualViewportResize() {
|
||||
const vv = window.visualViewport;
|
||||
const h = vv ? vv.height : window.innerHeight;
|
||||
if (h <= 0) return;
|
||||
|
||||
if (!keyboardLikelyVisible && h > baselineVpHeight) {
|
||||
baselineVpHeight = h;
|
||||
}
|
||||
|
||||
const shrink = baselineVpHeight - h;
|
||||
|
||||
if (!keyboardLikelyVisible && shrink > KEYBOARD_OPEN_THRESHOLD_PX) {
|
||||
keyboardLikelyVisible = true;
|
||||
debugTouch("keyboard:open_flag_set", { baseline: baselineVpHeight, current: h, shrink });
|
||||
return;
|
||||
}
|
||||
|
||||
if (keyboardLikelyVisible && h >= baselineVpHeight - KEYBOARD_CLOSE_HYSTERESIS_PX) {
|
||||
keyboardLikelyVisible = false;
|
||||
debugTouch("keyboard:close_flag_cleared", { baseline: baselineVpHeight, current: h });
|
||||
}
|
||||
}
|
||||
|
||||
const onPageUnload = () => { pageUnloading = true; };
|
||||
|
||||
// ── 生命周期 ──────────────────────────────────────────────────────────────────
|
||||
onMounted(async () => {
|
||||
await serverStore.ensureBootstrapped();
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("pagehide", onPageUnload);
|
||||
window.addEventListener("beforeunload", onPageUnload);
|
||||
|
||||
const vv = window.visualViewport;
|
||||
baselineVpHeight = vv ? vv.height : window.innerHeight;
|
||||
|
||||
// 软键盘调整控制器
|
||||
kbAdjust = new KeyboardAdjustController({
|
||||
getOutputEl: () => store.getOutputEl(),
|
||||
getCursorOffsetPx: () => store.getCursorOffsetPx(),
|
||||
setAutoFollow: (enabled) => store.setAutoFollow(enabled),
|
||||
setScrollLocked: (locked) => store.setScrollLocked(locked),
|
||||
suppressAutoFollow: (ms) => store.suppressAutoFollow(ms),
|
||||
debug: isDebugEnabled(),
|
||||
});
|
||||
kbAdjust.mount();
|
||||
|
||||
// 同步 keyboardLikelyVisible 标志
|
||||
if (vv) {
|
||||
vv.addEventListener("resize", onVisualViewportResize);
|
||||
} else {
|
||||
window.addEventListener("resize", onVisualViewportResize);
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldAutoReconnect() && serverStore.selectedServer) {
|
||||
await handleConnect({ silent: true });
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => store.state, async (state) => {
|
||||
if (state === "connected") {
|
||||
await nextTick();
|
||||
inputBarRef.value?.focus();
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => store.rendererMode, async () => {
|
||||
if (store.state === "connected") {
|
||||
await nextTick();
|
||||
inputBarRef.value?.focus();
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
offBell?.();
|
||||
offTitle?.();
|
||||
offBell = null;
|
||||
offTitle = null;
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.removeEventListener("pagehide", onPageUnload);
|
||||
window.removeEventListener("beforeunload", onPageUnload);
|
||||
|
||||
const vv = window.visualViewport;
|
||||
if (vv) {
|
||||
vv.removeEventListener("resize", onVisualViewportResize);
|
||||
} else {
|
||||
window.removeEventListener("resize", onVisualViewportResize);
|
||||
}
|
||||
}
|
||||
|
||||
kbAdjust?.forceReset();
|
||||
kbAdjust = null;
|
||||
|
||||
if (!pageUnloading) {
|
||||
store.disconnect("leave_terminal_page");
|
||||
}
|
||||
});
|
||||
|
||||
// ── 计算属性 ──────────────────────────────────────────────────────────────────
|
||||
const isMobile = computed(() =>
|
||||
typeof navigator !== "undefined" &&
|
||||
/Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent)
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="tc-page" @touchend.capture="handlePageTouchEndCapture">
|
||||
<!-- 工具栏 -->
|
||||
<div ref="toolbarWrapRef">
|
||||
<TerminalToolbar @connect="handleConnect" @disconnect="handleDisconnect" />
|
||||
</div>
|
||||
|
||||
<!-- 终端视口 -->
|
||||
<div
|
||||
ref="viewportWrapRef"
|
||||
class="tc-viewport-wrap"
|
||||
@click.stop="handleViewportClick"
|
||||
@touchstart.passive="handleViewportTouchStart"
|
||||
@touchmove="handleViewportTouchMove"
|
||||
@touchend.stop="handleViewportTouchEnd"
|
||||
>
|
||||
<TerminalViewport />
|
||||
</div>
|
||||
|
||||
<!-- 触控辅助按键(移动端显示) -->
|
||||
<TerminalTouchTools
|
||||
v-if="isMobile"
|
||||
:application-cursor-keys="appCursorKeys"
|
||||
@paste="handlePaste"
|
||||
/>
|
||||
|
||||
<!-- 输入栏 -->
|
||||
<TerminalInputBar ref="inputBarRef" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tc-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--tc-bg, #1a1a1a);
|
||||
}
|
||||
|
||||
.tc-viewport-wrap {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
cursor: text;
|
||||
}
|
||||
</style>
|
||||
229
terminal/apps/web/src/terminal/components/TerminalInputBar.vue
Normal file
229
terminal/apps/web/src/terminal/components/TerminalInputBar.vue
Normal file
@@ -0,0 +1,229 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* TerminalInputBar — 原生 textarea 输入锚点 + 工具栏输入行。
|
||||
*
|
||||
* 职责:
|
||||
* - 承载软键盘弹出的原生 textarea(IME 主通道)
|
||||
* - 通过 DomInputBridge + InputBridge 将键盘事件映射为 VT 字节流
|
||||
* - 提供手动发送文本按钮(移动端用途)
|
||||
*/
|
||||
import { ref, onMounted, onUnmounted } from "vue";
|
||||
import { InputBridge } from "@remoteconn/terminal-core";
|
||||
import { DomInputBridge } from "@/terminal/input/domInputBridge";
|
||||
import { DomImeController } from "@/terminal/input/domImeController";
|
||||
import { shouldHandleKeydownDirectly } from "@/terminal/input/inputPolicy";
|
||||
import { useTerminalStore } from "@/terminal/stores/useTerminalStore";
|
||||
|
||||
const store = useTerminalStore();
|
||||
const inputRef = ref<HTMLTextAreaElement | null>(null);
|
||||
const emit = defineEmits<{
|
||||
(e: "anchor-focus"): void;
|
||||
(e: "anchor-blur"): void;
|
||||
}>();
|
||||
|
||||
let domBridge: DomInputBridge | null = null;
|
||||
let imeCtrl: DomImeController | null = null;
|
||||
// 多行粘贴统一走 bracketed paste,避免换行被 shell 解释为直接执行。
|
||||
const inputBridge = new InputBridge({ cursorKeyMode: "normal", bracketedPaste: true });
|
||||
const anchorCleanups: Array<() => void> = [];
|
||||
|
||||
function isInputDebugEnabled(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
return window.localStorage.getItem("terminal.debugInputAnchor") === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function debugInput(message: string, detail?: unknown): void {
|
||||
if (!isInputDebugEnabled()) return;
|
||||
const prefix = `[TerminalInputBar][Anchor][${new Date().toISOString()}] ${message}`;
|
||||
if (typeof detail === "undefined") {
|
||||
console.log(prefix);
|
||||
return;
|
||||
}
|
||||
console.log(prefix, detail);
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
for (const fn of anchorCleanups) fn();
|
||||
anchorCleanups.length = 0;
|
||||
imeCtrl?.dispose();
|
||||
domBridge?.dispose();
|
||||
imeCtrl = null;
|
||||
domBridge = null;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const el = inputRef.value;
|
||||
if (!el) return;
|
||||
|
||||
const onFocus = () => {
|
||||
emit("anchor-focus");
|
||||
debugInput("event:focus", {
|
||||
readonly: el.readOnly,
|
||||
disabled: el.disabled,
|
||||
valueLen: el.value.length,
|
||||
activeTag: (document.activeElement as HTMLElement | null)?.tagName ?? "null",
|
||||
activeClass: (document.activeElement as HTMLElement | null)?.className ?? "",
|
||||
});
|
||||
};
|
||||
const onBlur = () => {
|
||||
emit("anchor-blur");
|
||||
debugInput("event:blur", {
|
||||
readonly: el.readOnly,
|
||||
disabled: el.disabled,
|
||||
valueLen: el.value.length,
|
||||
activeTag: (document.activeElement as HTMLElement | null)?.tagName ?? "null",
|
||||
activeClass: (document.activeElement as HTMLElement | null)?.className ?? "",
|
||||
});
|
||||
};
|
||||
el.addEventListener("focus", onFocus);
|
||||
el.addEventListener("blur", onBlur);
|
||||
anchorCleanups.push(() => {
|
||||
el.removeEventListener("focus", onFocus);
|
||||
el.removeEventListener("blur", onBlur);
|
||||
});
|
||||
|
||||
domBridge = new DomInputBridge();
|
||||
domBridge.mount(el);
|
||||
|
||||
imeCtrl = new DomImeController();
|
||||
imeCtrl.connect(domBridge);
|
||||
|
||||
// ── 键盘事件处理 ────────────────────────────────────────────────────────
|
||||
domBridge.on("key", (p) => {
|
||||
if (store.state !== "connected") return;
|
||||
|
||||
// 文本输入(含第三方输入法 key="测试"/"jk")统一走 input,避免双发。
|
||||
if (!shouldHandleKeydownDirectly(p)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+C 无选区时发 ETX
|
||||
if ((p.ctrlKey || p.metaKey) && p.key === "c") {
|
||||
const sel = window.getSelection()?.toString() ?? "";
|
||||
if (!sel) {
|
||||
store.sendInput("\u0003", "keyboard");
|
||||
} else {
|
||||
document.execCommand("copy");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+V / Cmd+V 粘贴
|
||||
if ((p.ctrlKey || p.metaKey) && p.key === "v") {
|
||||
// 由 paste 事件处理,此处阻止
|
||||
return;
|
||||
}
|
||||
|
||||
const seq = inputBridge.mapKey(p.key, p.code, p.ctrlKey, p.altKey, p.shiftKey, p.metaKey);
|
||||
if (seq !== null) {
|
||||
store.sendInput(seq, "keyboard");
|
||||
}
|
||||
});
|
||||
|
||||
// ── input 事件(处理未被 keydown 拦截的 ASCII + IME 非组合落笔) ────────
|
||||
domBridge.on("input", (p) => {
|
||||
if (p.isComposing) return; // 组合中由 compositionend 处理
|
||||
if (imeCtrl?.core.shouldConsumeInputEvent()) return;
|
||||
if (store.state !== "connected") return;
|
||||
|
||||
if (p.data) {
|
||||
store.sendInput(p.data, "keyboard");
|
||||
}
|
||||
|
||||
// 清空 textarea 防止回显积累
|
||||
const el = inputRef.value;
|
||||
if (el) el.value = "";
|
||||
});
|
||||
|
||||
// ── 粘贴事件 ──────────────────────────────────────────────────────────────
|
||||
domBridge.on("paste", (p) => {
|
||||
if (store.state !== "connected") return;
|
||||
const seq = inputBridge.mapPaste(p.text);
|
||||
store.sendInput(seq, "paste");
|
||||
});
|
||||
|
||||
// ── IME compositionend → 提交候选词 ──────────────────────────────────────
|
||||
domBridge.on("compositionend", (p) => {
|
||||
if (store.state !== "connected") return;
|
||||
if (p.data) {
|
||||
store.sendInput(p.data, "assist");
|
||||
}
|
||||
const el = inputRef.value;
|
||||
if (el) el.value = "";
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
dispose();
|
||||
});
|
||||
|
||||
/** 外部调用:将输入锚点聚焦以弹出软键盘 */
|
||||
function focus() {
|
||||
debugInput("api:focus");
|
||||
inputRef.value?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
defineExpose({ focus });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tc-input-bar" data-zone="native-input-zone">
|
||||
<!-- 原生输入锚点(VT 键盘主通道,对用户不可见) -->
|
||||
<textarea
|
||||
ref="inputRef"
|
||||
class="tc-input-anchor"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
aria-label="终端键盘输入"
|
||||
:readonly="store.state !== 'connected'"
|
||||
rows="1"
|
||||
></textarea>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tc-input-bar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: visible;
|
||||
opacity: 0.01;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* 隐藏的 VT 键盘锚点 */
|
||||
.tc-input-anchor {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
overflow: hidden;
|
||||
opacity: 0.01;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
resize: none;
|
||||
background: transparent;
|
||||
color: transparent;
|
||||
caret-color: transparent;
|
||||
-webkit-appearance: none;
|
||||
/*
|
||||
* 将焦点锚点放在顶部,避免部分移动端浏览器在软键盘弹出时
|
||||
* 为“底部焦点元素可见”而触发布局上推,导致工具条/光标瞬时跳位。
|
||||
*/
|
||||
transform: translateZ(0);
|
||||
}
|
||||
</style>
|
||||
148
terminal/apps/web/src/terminal/components/TerminalToolbar.vue
Normal file
148
terminal/apps/web/src/terminal/components/TerminalToolbar.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useTerminalStore } from "@/terminal/stores/useTerminalStore";
|
||||
|
||||
const store = useTerminalStore();
|
||||
|
||||
const isConnected = computed(() => store.state === "connected");
|
||||
const isConnecting = computed(() => store.state === "connecting" || store.state === "auth_pending" || store.state === "reconnecting");
|
||||
const canReconnect = computed(() => store.state === "disconnected" || store.state === "error");
|
||||
const canDisconnect = computed(() => isConnected.value || isConnecting.value);
|
||||
|
||||
const connectionLabel = computed(() => {
|
||||
if (canReconnect.value) return "重连";
|
||||
if (canDisconnect.value) return "断开";
|
||||
return "连接";
|
||||
});
|
||||
|
||||
const connectionDisabled = computed(() => false);
|
||||
|
||||
function handleClear() {
|
||||
store.clearTerminal();
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "connect"): void;
|
||||
(e: "disconnect"): void;
|
||||
}>();
|
||||
|
||||
function handleConnectionAction() {
|
||||
if (canDisconnect.value) {
|
||||
emit("disconnect");
|
||||
} else {
|
||||
emit("connect");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-toolbar terminal-toolbar tc-toolbar" @dblclick.prevent>
|
||||
<div class="toolbar-left">
|
||||
<button
|
||||
class="icon-btn"
|
||||
type="button"
|
||||
title="清屏"
|
||||
aria-label="清屏"
|
||||
@click="handleClear"
|
||||
>
|
||||
<span aria-hidden="true">⌫</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-spacer"></div>
|
||||
|
||||
<div class="terminal-toolbar-actions">
|
||||
<!-- 标题 -->
|
||||
<h2 class="page-title terminal-title" :title="store.title">
|
||||
{{ store.title || "Terminal" }}
|
||||
</h2>
|
||||
<!-- 状态 -->
|
||||
<span class="state-chip" :class="`state-${store.state}`">{{ store.state }}</span>
|
||||
<!-- 延迟 -->
|
||||
<span v-if="store.latencyMs > 0" class="state-chip">{{ store.latencyMs }}ms</span>
|
||||
<span class="terminal-toolbar-divider" aria-hidden="true"></span>
|
||||
<!-- 连接/断开 -->
|
||||
<button
|
||||
class="terminal-connection-switch"
|
||||
:class="canReconnect ? 'is-reconnect' : 'is-disconnect'"
|
||||
:disabled="connectionDisabled"
|
||||
:aria-label="connectionLabel"
|
||||
@click="handleConnectionAction"
|
||||
>
|
||||
<span class="terminal-connection-switch-label">{{ connectionLabel }}</span>
|
||||
<span class="terminal-connection-switch-knob" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tc-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid #333;
|
||||
background: #1f1f1f;
|
||||
color: #e6e6e6;
|
||||
flex-shrink: 0;
|
||||
/* 禁止连续轻点触发浏览器双击放大 */
|
||||
touch-action: manipulation;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.toolbar-left,
|
||||
.terminal-toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar-spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.icon-btn,
|
||||
.terminal-connection-switch {
|
||||
border: 1px solid #4a4a4a;
|
||||
background: #2a2a2a;
|
||||
color: #e6e6e6;
|
||||
border-radius: 6px;
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.icon-btn:disabled,
|
||||
.terminal-connection-switch:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #e6e6e6;
|
||||
}
|
||||
|
||||
.state-chip {
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
color: #d5d5d5;
|
||||
background: #2b2b2b;
|
||||
}
|
||||
|
||||
.terminal-toolbar-divider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: #444;
|
||||
}
|
||||
</style>
|
||||
191
terminal/apps/web/src/terminal/components/TerminalTouchTools.vue
Normal file
191
terminal/apps/web/src/terminal/components/TerminalTouchTools.vue
Normal file
@@ -0,0 +1,191 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* TerminalTouchTools — 移动端触控辅助按键区。
|
||||
*
|
||||
* 职责:
|
||||
* - 提供方向键、Enter、Ctrl+C、Tab、Paste 等常用控制键
|
||||
* - 输出标准 VT 控制序列,不直接访问 Transport
|
||||
* - 通过 store.sendInput() 发送
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
import { useTerminalStore } from "@/terminal/stores/useTerminalStore";
|
||||
|
||||
const props = defineProps<{
|
||||
/** 是否使用应用光标键模式(DECCKM 激活时为 true) */
|
||||
applicationCursorKeys?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "paste"): void;
|
||||
}>();
|
||||
|
||||
const store = useTerminalStore();
|
||||
const disabled = computed(() => store.state !== "connected");
|
||||
|
||||
/** 发送 VT 控制序列;source 固定为 keyboard,以复用 meta 标记 */
|
||||
function send(seq: string) {
|
||||
if (disabled.value) return;
|
||||
store.sendInput(seq, "keyboard");
|
||||
}
|
||||
|
||||
const arrowUp = computed(() => props.applicationCursorKeys ? "\x1bOA" : "\x1b[A");
|
||||
const arrowDown = computed(() => props.applicationCursorKeys ? "\x1bOB" : "\x1b[B");
|
||||
const arrowRight = computed(() => props.applicationCursorKeys ? "\x1bOC" : "\x1b[C");
|
||||
const arrowLeft = computed(() => props.applicationCursorKeys ? "\x1bOD" : "\x1b[D");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tc-touch-tools" role="toolbar" aria-label="终端触控辅助工具栏">
|
||||
<!-- 方向键 -->
|
||||
<button
|
||||
class="tc-key-btn"
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
aria-label="上箭头"
|
||||
@click="send(arrowUp)"
|
||||
>▲</button>
|
||||
<button
|
||||
class="tc-key-btn"
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
aria-label="下箭头"
|
||||
@click="send(arrowDown)"
|
||||
>▼</button>
|
||||
<button
|
||||
class="tc-key-btn"
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
aria-label="左箭头"
|
||||
@click="send(arrowLeft)"
|
||||
>◀</button>
|
||||
<button
|
||||
class="tc-key-btn"
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
aria-label="右箭头"
|
||||
@click="send(arrowRight)"
|
||||
>▶</button>
|
||||
|
||||
<div class="tc-key-separator" aria-hidden="true"></div>
|
||||
|
||||
<!-- Tab -->
|
||||
<button
|
||||
class="tc-key-btn"
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
aria-label="Tab"
|
||||
@click="send('\t')"
|
||||
>Tab</button>
|
||||
|
||||
<!-- Enter (CR) -->
|
||||
<button
|
||||
class="tc-key-btn tc-key-enter"
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
aria-label="Enter"
|
||||
@click="send('\r')"
|
||||
>↵</button>
|
||||
|
||||
<!-- Ctrl+C -->
|
||||
<button
|
||||
class="tc-key-btn"
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
aria-label="Ctrl+C 中断"
|
||||
@click="send('\u0003')"
|
||||
>^C</button>
|
||||
|
||||
<!-- Ctrl+D -->
|
||||
<button
|
||||
class="tc-key-btn"
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
aria-label="Ctrl+D EOF"
|
||||
@click="send('\u0004')"
|
||||
>^D</button>
|
||||
|
||||
<!-- Esc -->
|
||||
<button
|
||||
class="tc-key-btn"
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
aria-label="Esc"
|
||||
@click="send('\x1b')"
|
||||
>Esc</button>
|
||||
|
||||
<div class="tc-key-separator" aria-hidden="true"></div>
|
||||
|
||||
<!-- 粘贴(调用外部 paste 逻辑) -->
|
||||
<button
|
||||
class="tc-key-btn"
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
aria-label="粘贴"
|
||||
@click="emit('paste')"
|
||||
>粘贴</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tc-touch-tools {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
background: var(--color-surface-1, #1e1e1e);
|
||||
border-top: 1px solid var(--color-border, #333);
|
||||
}
|
||||
|
||||
.tc-touch-tools::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tc-key-btn {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 40px;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--color-border, #444);
|
||||
border-radius: 5px;
|
||||
background: var(--color-surface-2, #2a2a2a);
|
||||
color: var(--color-text, #d4d4d4);
|
||||
font-size: 13px;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.tc-key-btn:active {
|
||||
background: var(--color-surface-3, #3a3a3a);
|
||||
}
|
||||
|
||||
.tc-key-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.tc-key-enter {
|
||||
min-width: 48px;
|
||||
background: var(--color-accent-dim, #1e3a5f);
|
||||
border-color: var(--color-accent, #4a9eff);
|
||||
color: var(--color-accent, #4a9eff);
|
||||
}
|
||||
|
||||
.tc-key-separator {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: var(--color-border, #444);
|
||||
flex-shrink: 0;
|
||||
margin: 0 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from "vue";
|
||||
import { calcSize } from "@remoteconn/terminal-core";
|
||||
import { DomMeasureAdapter } from "@/terminal/layout/domMeasureAdapter";
|
||||
import { useTerminalStore } from "@/terminal/stores/useTerminalStore";
|
||||
|
||||
const store = useTerminalStore();
|
||||
const viewportRef = ref<HTMLElement | null>(null);
|
||||
|
||||
let measure: DomMeasureAdapter | null = null;
|
||||
let offResize: (() => void) | null = null;
|
||||
/** 初次 fit 重试次数上限(应对路由切换延迟布局) */
|
||||
const MAX_FIT_RETRIES = 8;
|
||||
let fitRetryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let fitRetryCount = 0;
|
||||
|
||||
function doFit() {
|
||||
if (!measure) return;
|
||||
|
||||
const { widthPx: charW, heightPx: lineH } = measure.measureChar();
|
||||
const { widthPx: contW, heightPx: contH } = measure.measureContainer();
|
||||
|
||||
if (contW < 10 || contH < 10) {
|
||||
// 容器未就绪,重试
|
||||
if (fitRetryCount < MAX_FIT_RETRIES) {
|
||||
fitRetryCount++;
|
||||
fitRetryTimer = setTimeout(doFit, 120);
|
||||
}
|
||||
return;
|
||||
}
|
||||
fitRetryCount = 0;
|
||||
|
||||
const { cols, rows } = calcSize(contW, contH, charW, lineH);
|
||||
store.resizeTerminal(cols, Math.max(rows, 12));
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const el = viewportRef.value;
|
||||
if (!el) return;
|
||||
|
||||
measure = new DomMeasureAdapter(el, { fontFamily: "monospace", fontSize: "14px" });
|
||||
measure.mount();
|
||||
|
||||
// 挂载渲染器
|
||||
store.mountRenderer(el);
|
||||
|
||||
// 订阅 resize
|
||||
offResize = measure.onResize(() => {
|
||||
doFit();
|
||||
});
|
||||
|
||||
// 首次 fit(多次重试保障)
|
||||
doFit();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
offResize?.();
|
||||
offResize = null;
|
||||
if (fitRetryTimer !== null) {
|
||||
clearTimeout(fitRetryTimer);
|
||||
fitRetryTimer = null;
|
||||
}
|
||||
measure?.dispose();
|
||||
measure = null;
|
||||
// 渲染器 dispose 由 store 管理;此处仅退出测量
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="viewportRef"
|
||||
class="tc-viewport-root"
|
||||
data-zone="terminal-output-zone"
|
||||
aria-label="终端视口"
|
||||
role="region"
|
||||
></div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tc-viewport-root {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: var(--tc-bg, #1a1a1a);
|
||||
color: var(--tc-fg, #d4d4d4);
|
||||
font-family: "JetBrains Mono", "Cascadia Code", "Fira Mono", "Menlo", monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.2;
|
||||
cursor: text;
|
||||
}
|
||||
</style>
|
||||
37
terminal/apps/web/src/terminal/input/domImeController.ts
Normal file
37
terminal/apps/web/src/terminal/input/domImeController.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { ImeController } from "@remoteconn/terminal-core";
|
||||
import type { DomInputBridge } from "./domInputBridge";
|
||||
|
||||
/**
|
||||
* DomImeController — 将 DOM compositionstart/end 事件桥接到 ImeController。
|
||||
* 必须在 DomInputBridge.mount() 之后调用 connect()。
|
||||
*/
|
||||
export class DomImeController {
|
||||
public readonly core: ImeController;
|
||||
private cleanup: (() => void)[] = [];
|
||||
|
||||
constructor() {
|
||||
this.core = new ImeController(
|
||||
(fn, ms) => setTimeout(fn, ms),
|
||||
(id) => clearTimeout(id)
|
||||
);
|
||||
}
|
||||
|
||||
/** 将 inputBridge 的 composition 事件接入 ImeController */
|
||||
connect(bridge: DomInputBridge): void {
|
||||
this.cleanup.push(
|
||||
bridge.on("compositionstart", ({ data }) => {
|
||||
this.core.onCompositionStart(data);
|
||||
}),
|
||||
bridge.on("compositionend", ({ data }) => {
|
||||
this.core.onCompositionEnd(data);
|
||||
// compositionend 后的 input 事件由 ImeController.shouldConsumeInputEvent 拦截
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const fn of this.cleanup) fn();
|
||||
this.cleanup = [];
|
||||
this.core.reset();
|
||||
}
|
||||
}
|
||||
84
terminal/apps/web/src/terminal/input/domInputBridge.ts
Normal file
84
terminal/apps/web/src/terminal/input/domInputBridge.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { IInputSource, InputEventMap } from "@remoteconn/terminal-core";
|
||||
|
||||
type ListenerMap = { [K in keyof InputEventMap]?: Set<(payload: InputEventMap[K]) => void> };
|
||||
|
||||
/**
|
||||
* DomInputBridge — 将 DOM 键盘/input/paste/composition 事件适配为 IInputSource。
|
||||
* 挂载到 textarea 输入锚点元素上。
|
||||
*/
|
||||
export class DomInputBridge implements IInputSource {
|
||||
private el: HTMLElement | null = null;
|
||||
private listeners: ListenerMap = {};
|
||||
private domCleanups: (() => void)[] = [];
|
||||
|
||||
mount(element: HTMLElement): void {
|
||||
this.el = element;
|
||||
const add = <K extends keyof HTMLElementEventMap>(
|
||||
type: K,
|
||||
handler: (e: HTMLElementEventMap[K]) => void,
|
||||
opts?: AddEventListenerOptions
|
||||
) => {
|
||||
element.addEventListener(type, handler as EventListener, opts);
|
||||
this.domCleanups.push(() => element.removeEventListener(type, handler as EventListener, opts));
|
||||
};
|
||||
|
||||
add("keydown", (e) => {
|
||||
if (e.isComposing) return;
|
||||
this.emit("key", {
|
||||
key: e.key,
|
||||
code: e.code,
|
||||
ctrlKey: e.ctrlKey,
|
||||
altKey: e.altKey,
|
||||
shiftKey: e.shiftKey,
|
||||
metaKey: e.metaKey,
|
||||
isComposing: e.isComposing,
|
||||
});
|
||||
});
|
||||
|
||||
add("input", (e) => {
|
||||
const ie = e as InputEvent;
|
||||
this.emit("input", {
|
||||
data: ie.data ?? "",
|
||||
isComposing: ie.isComposing,
|
||||
});
|
||||
});
|
||||
|
||||
add("paste", (e) => {
|
||||
const pe = e as ClipboardEvent;
|
||||
const text = pe.clipboardData?.getData("text") ?? "";
|
||||
if (text) {
|
||||
pe.preventDefault();
|
||||
this.emit("paste", { text });
|
||||
}
|
||||
});
|
||||
|
||||
add("compositionstart", (e) => {
|
||||
this.emit("compositionstart", { data: (e as CompositionEvent).data ?? "" });
|
||||
});
|
||||
|
||||
add("compositionend", (e) => {
|
||||
this.emit("compositionend", { data: (e as CompositionEvent).data ?? "" });
|
||||
});
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const cleanup of this.domCleanups) cleanup();
|
||||
this.domCleanups = [];
|
||||
this.el = null;
|
||||
}
|
||||
|
||||
on<K extends keyof InputEventMap>(event: K, cb: (payload: InputEventMap[K]) => void): () => void {
|
||||
if (!this.listeners[event]) {
|
||||
(this.listeners as Record<string, Set<unknown>>)[event] = new Set();
|
||||
}
|
||||
const set = this.listeners[event] as Set<(p: InputEventMap[K]) => void>;
|
||||
set.add(cb);
|
||||
return () => set.delete(cb);
|
||||
}
|
||||
|
||||
private emit<K extends keyof InputEventMap>(event: K, payload: InputEventMap[K]): void {
|
||||
const set = this.listeners[event] as Set<(p: InputEventMap[K]) => void> | undefined;
|
||||
if (!set) return;
|
||||
for (const fn of set) fn(payload);
|
||||
}
|
||||
}
|
||||
39
terminal/apps/web/src/terminal/input/inputPolicy.test.ts
Normal file
39
terminal/apps/web/src/terminal/input/inputPolicy.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldHandleKeydownDirectly } from "./inputPolicy";
|
||||
|
||||
function makePayload(overrides: Partial<{ key: string; ctrlKey: boolean; altKey: boolean; metaKey: boolean }> = {}) {
|
||||
return {
|
||||
key: "",
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
metaKey: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("shouldHandleKeydownDirectly", () => {
|
||||
it("文本按键应返回 false(普通英文)", () => {
|
||||
expect(shouldHandleKeydownDirectly(makePayload({ key: "a" }))).toBe(false);
|
||||
});
|
||||
|
||||
it("第三方输入法在 keydown 给出整段文本时应返回 false(英文串)", () => {
|
||||
expect(shouldHandleKeydownDirectly(makePayload({ key: "jk" }))).toBe(false);
|
||||
});
|
||||
|
||||
it("第三方输入法在 keydown 给出整段文本时应返回 false(中文)", () => {
|
||||
expect(shouldHandleKeydownDirectly(makePayload({ key: "测试" }))).toBe(false);
|
||||
});
|
||||
|
||||
it("功能键应返回 true(Enter)", () => {
|
||||
expect(shouldHandleKeydownDirectly(makePayload({ key: "Enter" }))).toBe(true);
|
||||
});
|
||||
|
||||
it("方向键应返回 true(ArrowUp)", () => {
|
||||
expect(shouldHandleKeydownDirectly(makePayload({ key: "ArrowUp" }))).toBe(true);
|
||||
});
|
||||
|
||||
it("组合键应返回 true(Ctrl+C)", () => {
|
||||
expect(shouldHandleKeydownDirectly(makePayload({ key: "c", ctrlKey: true }))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
28
terminal/apps/web/src/terminal/input/inputPolicy.ts
Normal file
28
terminal/apps/web/src/terminal/input/inputPolicy.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { KeyPayload } from "@remoteconn/terminal-core";
|
||||
|
||||
/**
|
||||
* 仅这些按键允许在 keydown 阶段直接发送到终端。
|
||||
* 其余文本输入(包括第三方输入法在 keydown 给出的整段文本)必须走 input 事件。
|
||||
*/
|
||||
const KEYDOWN_DIRECT_KEYS = new Set([
|
||||
"Enter", "Backspace", "Tab", "Escape",
|
||||
"Delete", "Insert", "Home", "End",
|
||||
"PageUp", "PageDown",
|
||||
"ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight",
|
||||
"F1", "F2", "F3", "F4", "F5", "F6",
|
||||
"F7", "F8", "F9", "F10", "F11", "F12",
|
||||
]);
|
||||
|
||||
/**
|
||||
* 判断某个 keydown 事件是否应直接进入 VT 发送链路。
|
||||
* 规则:
|
||||
* 1) 带 ctrl/alt/meta 的组合键一律允许(如 Ctrl+C、Alt+X)。
|
||||
* 2) 无组合键时,只允许功能键白名单;文本键一律禁止。
|
||||
*/
|
||||
export function shouldHandleKeydownDirectly(payload: Pick<KeyPayload, "key" | "ctrlKey" | "altKey" | "metaKey">): boolean {
|
||||
if (payload.ctrlKey || payload.altKey || payload.metaKey) {
|
||||
return true;
|
||||
}
|
||||
return KEYDOWN_DIRECT_KEYS.has(payload.key);
|
||||
}
|
||||
|
||||
444
terminal/apps/web/src/terminal/input/keyboardAdjustController.ts
Normal file
444
terminal/apps/web/src/terminal/input/keyboardAdjustController.ts
Normal file
@@ -0,0 +1,444 @@
|
||||
/**
|
||||
* KeyboardAdjustController — 软键盘弹出/收回时的视口滚动控制器。
|
||||
*
|
||||
* 职责:
|
||||
* - 监听 visualViewport resize 判断软键盘状态
|
||||
* - 键盘弹出时:将光标行(提示符行)滚动到屏幕距顶 1/4 处
|
||||
* - 键盘收回时:恢复弹出前的滚动位置
|
||||
*
|
||||
* 设计原则:
|
||||
* - 只使用 visualViewport.resize(不混用 window.resize),避免误触
|
||||
* - 通过回调注入 DOM 获取逻辑,不直接依赖 Vue/Pinia,便于测试
|
||||
* - 所有状态集中在此类,TerminalPage.vue 只负责初始化/销毁
|
||||
*/
|
||||
|
||||
export interface KeyboardAdjustOptions {
|
||||
/**
|
||||
* 获取输出区滚动容器(即渲染器创建的 .tc-output 元素)。
|
||||
* 若返回 null 则跳过本次调整。
|
||||
*/
|
||||
getOutputEl: () => HTMLElement | null;
|
||||
|
||||
/**
|
||||
* 获取光标行距输出区滚动顶部的像素偏移。
|
||||
* 若返回 null 则回退为滚动到底部。
|
||||
*/
|
||||
getCursorOffsetPx: () => number | null;
|
||||
|
||||
/**
|
||||
* 设置渲染器的 autoFollow 状态。
|
||||
* 键盘弹出期间需关闭 autoFollow,防止新输出覆盖定位。
|
||||
* 键盘收回后恢复 autoFollow。
|
||||
*/
|
||||
setAutoFollow: (enabled: boolean) => void;
|
||||
|
||||
/**
|
||||
* 冻结 scroll 事件对 autoFollow 的影响,持续 ms 毫秒。
|
||||
* iOS WebKit 的 scroll 事件是异步的,必须用时间窗口而非同步锁。
|
||||
* ms=0 立即解冻。
|
||||
* 可选:未提供时静默跳过(兼容未注入此回调的调用方)。
|
||||
*/
|
||||
setScrollLocked?: (ms: number) => void;
|
||||
|
||||
/**
|
||||
* 在指定毫秒内抑制 store 的 followBottom 调用。
|
||||
* 用于防止键盘收回后远端 resize 回刷覆盖恢复的滚动位置。
|
||||
*/
|
||||
suppressAutoFollow: (ms: number) => void;
|
||||
|
||||
/** 调试开关(可选,默认关闭)— 保留但现在关键路径已用无条件日志 */
|
||||
debug?: boolean;
|
||||
}
|
||||
|
||||
/** 键盘弹出前保存的视口快照 */
|
||||
interface KeyboardSnapshot {
|
||||
/** 弹出前输出区的 scrollTop */
|
||||
scrollTop: number;
|
||||
/** 弹出前输出区的 clientHeight */
|
||||
clientHeight: number;
|
||||
/** 弹出前输出区的 scrollHeight */
|
||||
scrollHeight: number;
|
||||
/** 弹出前 visualViewport 高度 */
|
||||
viewportHeight: number;
|
||||
/** 用户在键盘可见期间是否手动滚动过 */
|
||||
userScrolled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 软键盘收缩阈值:visualViewport 高度减少超过此值则判定为键盘弹出。
|
||||
* 实测 iOS/Android 软键盘通常 200px+,120px 可覆盖大多数情况。
|
||||
*/
|
||||
const KEYBOARD_OPEN_THRESHOLD_PX = 120;
|
||||
|
||||
/**
|
||||
* 软键盘关闭迟滞:高度恢复至 baseline - HYSTERESIS 以内即判定为收起。
|
||||
* 避免动画过程中反复触发。
|
||||
*/
|
||||
const KEYBOARD_CLOSE_HYSTERESIS_PX = 40;
|
||||
|
||||
/**
|
||||
* 光标行在屏幕中的目标位置比例(0=顶, 1=底)。
|
||||
* 0.25 = 距顶 1/4 处。
|
||||
*/
|
||||
const CURSOR_TARGET_RATIO = 0.25;
|
||||
|
||||
/**
|
||||
* 键盘弹出后等待 DOM 稳定再执行滚动的延迟(ms)。
|
||||
* iOS 软键盘动画约 250ms,此处稍微保守取 280ms。
|
||||
*/
|
||||
const SCROLL_SETTLE_DELAY_MS = 280;
|
||||
|
||||
export class KeyboardAdjustController {
|
||||
private opts: KeyboardAdjustOptions & { debug: boolean };
|
||||
private baselineHeight = 0;
|
||||
private keyboardVisible = false;
|
||||
private snapshot: KeyboardSnapshot | null = null;
|
||||
private settleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// visualViewport resize 处理器引用,用于清理
|
||||
private readonly vpResizeHandler: () => void;
|
||||
// 用户手动滚动检测
|
||||
private readonly scrollHandler: () => void;
|
||||
private scrollListenerAttached = false;
|
||||
/**
|
||||
* 时间戳:直到此时刻前,scrollHandler 不将 snapshot.userScrolled 设为 true。
|
||||
* iOS WebKit scroll 事件是异步的,必须用时间窗口而非布尔标志。
|
||||
*/
|
||||
private programmaticScrollUntil = 0;
|
||||
|
||||
constructor(options: KeyboardAdjustOptions) {
|
||||
this.opts = { debug: false, ...options };
|
||||
this.vpResizeHandler = () => this.onViewportResize();
|
||||
this.scrollHandler = () => this.onUserScroll();
|
||||
}
|
||||
|
||||
private log(msg: string, detail?: unknown): void {
|
||||
// 关键路径使用无条件 console.log,便于线上问题诊断
|
||||
const prefix = `[KeyboardAdjust][${new Date().toISOString()}] ${msg}`;
|
||||
detail !== undefined ? console.log(prefix, detail) : console.log(prefix);
|
||||
}
|
||||
|
||||
/** 初始化:记录基线高度,开始监听 visualViewport */
|
||||
mount(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
const vv = window.visualViewport;
|
||||
if (!vv) {
|
||||
this.log("mount — visualViewport NOT supported, fallback to window.resize");
|
||||
window.addEventListener("resize", this.vpResizeHandler);
|
||||
} else {
|
||||
vv.addEventListener("resize", this.vpResizeHandler);
|
||||
}
|
||||
this.baselineHeight = vv ? vv.height : window.innerHeight;
|
||||
this.log("mount — OK", {
|
||||
baseline: this.baselineHeight,
|
||||
hasVisualViewport: !!vv,
|
||||
innerHeight: window.innerHeight,
|
||||
});
|
||||
}
|
||||
|
||||
/** 销毁:清理监听器和定时器 */
|
||||
dispose(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
const vv = window.visualViewport;
|
||||
if (!vv) {
|
||||
window.removeEventListener("resize", this.vpResizeHandler);
|
||||
} else {
|
||||
vv.removeEventListener("resize", this.vpResizeHandler);
|
||||
}
|
||||
this.detachScrollListener();
|
||||
if (this.settleTimer !== null) {
|
||||
clearTimeout(this.settleTimer);
|
||||
this.settleTimer = null;
|
||||
}
|
||||
// 确保 autoFollow 已恢复
|
||||
this.opts.setAutoFollow(true);
|
||||
this.snapshot = null;
|
||||
this.keyboardVisible = false;
|
||||
this.log("disposed");
|
||||
}
|
||||
|
||||
/** 强制重置(页面卸载时调用) */
|
||||
forceReset(): void {
|
||||
if (this.snapshot) {
|
||||
this.restoreScroll("unmount");
|
||||
}
|
||||
this.dispose();
|
||||
}
|
||||
|
||||
// ── 核心逻辑 ─────────────────────────────────────────────────────────────
|
||||
|
||||
private onViewportResize(): void {
|
||||
const vv = window.visualViewport;
|
||||
const currentH = vv ? vv.height : window.innerHeight;
|
||||
if (currentH <= 0) return;
|
||||
|
||||
// 基线更新:仅在没有键盘时,且高度比当前基线更大才更新
|
||||
if (!this.keyboardVisible && currentH > this.baselineHeight) {
|
||||
this.log("baseline:updated", { old: this.baselineHeight, new: currentH });
|
||||
this.baselineHeight = currentH;
|
||||
}
|
||||
|
||||
const shrink = this.baselineHeight - currentH;
|
||||
|
||||
this.log("viewport:resize", {
|
||||
currentH,
|
||||
baseline: this.baselineHeight,
|
||||
shrink,
|
||||
keyboardVisible: this.keyboardVisible,
|
||||
OPEN_THRESHOLD: KEYBOARD_OPEN_THRESHOLD_PX,
|
||||
CLOSE_HYSTERESIS: KEYBOARD_CLOSE_HYSTERESIS_PX,
|
||||
});
|
||||
|
||||
if (!this.keyboardVisible && shrink > KEYBOARD_OPEN_THRESHOLD_PX) {
|
||||
// 键盘弹出
|
||||
this.keyboardVisible = true;
|
||||
this.onKeyboardOpen(currentH);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.keyboardVisible && currentH >= this.baselineHeight - KEYBOARD_CLOSE_HYSTERESIS_PX) {
|
||||
// 键盘收回
|
||||
this.keyboardVisible = false;
|
||||
this.onKeyboardClose();
|
||||
}
|
||||
}
|
||||
|
||||
private onKeyboardOpen(currentViewportH: number): void {
|
||||
const outputEl = this.opts.getOutputEl();
|
||||
if (!outputEl) {
|
||||
this.log("keyboard:OPEN — no outputEl, skipped");
|
||||
return;
|
||||
}
|
||||
|
||||
// 保存弹出前的状态
|
||||
this.snapshot = {
|
||||
scrollTop: outputEl.scrollTop,
|
||||
clientHeight: outputEl.clientHeight,
|
||||
scrollHeight: outputEl.scrollHeight,
|
||||
viewportHeight: currentViewportH,
|
||||
userScrolled: false,
|
||||
};
|
||||
|
||||
this.log("keyboard:OPEN — snapshot saved", {
|
||||
snapshot: this.snapshot,
|
||||
baseline: this.baselineHeight,
|
||||
currentViewportH,
|
||||
});
|
||||
|
||||
// 关闭 autoFollow,防止新输出把光标滚走
|
||||
this.opts.setAutoFollow(false);
|
||||
|
||||
// 注册用户滚动检测
|
||||
this.attachScrollListener(outputEl);
|
||||
|
||||
// 等键盘动画完成后执行定位
|
||||
if (this.settleTimer !== null) clearTimeout(this.settleTimer);
|
||||
this.log(`keyboard:OPEN — will position in ${SCROLL_SETTLE_DELAY_MS}ms`);
|
||||
this.settleTimer = setTimeout(() => {
|
||||
this.settleTimer = null;
|
||||
this.positionCursorAtQuarter(outputEl);
|
||||
}, SCROLL_SETTLE_DELAY_MS);
|
||||
}
|
||||
|
||||
private onKeyboardClose(): void {
|
||||
this.log("keyboard:CLOSE — detaching scroll listener, cancelling settle timer");
|
||||
this.detachScrollListener();
|
||||
if (this.settleTimer !== null) {
|
||||
clearTimeout(this.settleTimer);
|
||||
this.settleTimer = null;
|
||||
}
|
||||
// iOS 键盘收回时,outputEl.clientHeight 的恢复比 visualViewport.height 更晚,
|
||||
// 固定延迟不可靠。改用轮询:每 32ms 检查一次 clientHeight 是否已恢复到 baseline,
|
||||
// 最多等待 600ms,超时则强制执行。
|
||||
const expectedClientH = this.snapshot
|
||||
? this.snapshot.clientHeight // 弹出前保存的 clientHeight
|
||||
: 0;
|
||||
const startedAt = Date.now();
|
||||
const MAX_WAIT_MS = 600;
|
||||
const POLL_MS = 32;
|
||||
|
||||
// 在整个 poll 等待期间保持 scroll 冻结,防止 iOS 键盘收起动画触发的
|
||||
// scroll 事件把 autoFollow 改回 true,干扰后续 restoreScroll 的逻辑。
|
||||
this.opts.setScrollLocked?.(MAX_WAIT_MS + POLL_MS);
|
||||
|
||||
const poll = () => {
|
||||
const outputEl = this.opts.getOutputEl();
|
||||
const elapsed = Date.now() - startedAt;
|
||||
const currentH = outputEl?.clientHeight ?? 0;
|
||||
const recovered = currentH >= expectedClientH - 4; // 允许 4px 误差
|
||||
|
||||
this.log(`keyboard:CLOSE — poll clientHeight=${currentH} expected=${expectedClientH} recovered=${recovered} elapsed=${elapsed}ms`);
|
||||
|
||||
if (recovered || elapsed >= MAX_WAIT_MS) {
|
||||
this.settleTimer = null;
|
||||
this.restoreScroll("keyboard_close");
|
||||
return;
|
||||
}
|
||||
this.settleTimer = setTimeout(poll, POLL_MS);
|
||||
};
|
||||
|
||||
this.settleTimer = setTimeout(poll, POLL_MS);
|
||||
}
|
||||
|
||||
private onUserScroll(): void {
|
||||
const now = Date.now();
|
||||
if (now < this.programmaticScrollUntil) {
|
||||
this.log(`scroll:handler — programmatic window(${this.programmaticScrollUntil - now}ms), skip userScrolled`);
|
||||
return;
|
||||
}
|
||||
if (this.snapshot) {
|
||||
this.snapshot.userScrolled = true;
|
||||
this.log("scroll:handler — USER scroll detected, userScrolled=true");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将光标行定位到当前可见区域距顶 1/4 处。
|
||||
*
|
||||
* 计算公式:
|
||||
* 目标 scrollTop = cursorOffsetPx - visibleHeight * CURSOR_TARGET_RATIO
|
||||
*
|
||||
* 其中 visibleHeight 是键盘弹出后 outputEl 的实际可见高度。
|
||||
*/
|
||||
private positionCursorAtQuarter(outputEl: HTMLElement): void {
|
||||
const cursorOffsetPx = this.opts.getCursorOffsetPx();
|
||||
|
||||
// 键盘弹出后输出区的实际可见高度(clientHeight 已被键盘压缩)
|
||||
const visibleH = outputEl.clientHeight;
|
||||
|
||||
let targetScrollTop: number;
|
||||
if (cursorOffsetPx !== null) {
|
||||
targetScrollTop = cursorOffsetPx - visibleH * CURSOR_TARGET_RATIO;
|
||||
} else {
|
||||
// 回退:滚到底部
|
||||
targetScrollTop = outputEl.scrollHeight - visibleH;
|
||||
}
|
||||
|
||||
const maxTop = Math.max(0, outputEl.scrollHeight - visibleH);
|
||||
targetScrollTop = Math.max(0, Math.min(maxTop, targetScrollTop));
|
||||
|
||||
this.log("keyboard:POSITION_CURSOR", {
|
||||
cursorOffsetPx,
|
||||
visibleH,
|
||||
CURSOR_TARGET_RATIO,
|
||||
targetScrollTop,
|
||||
currentScrollTop: outputEl.scrollTop,
|
||||
scrollHeight: outputEl.scrollHeight,
|
||||
maxTop,
|
||||
});
|
||||
|
||||
// iOS WebKit 的 scroll 事件是异步的(下一个 task),用时间窗口屏蔽。
|
||||
// 200ms 足够覆盖任何平台的异步 scroll 回调。
|
||||
const FREEZE_MS = 200;
|
||||
this.programmaticScrollUntil = Date.now() + FREEZE_MS;
|
||||
this.opts.setScrollLocked?.(FREEZE_MS); // 同步冻结 renderer scroll → autoFollow
|
||||
outputEl.scrollTop = targetScrollTop;
|
||||
|
||||
// 定位完成后必须强制 autoFollow=false(scroll 事件可能在时间窗口内把它改回 true)
|
||||
this.opts.setAutoFollow(false);
|
||||
// 抑制 store 层 followBottom,整个键盘可见期间屏蔽远端 stdout 的滚动干扰
|
||||
this.opts.suppressAutoFollow(60_000);
|
||||
this.log("keyboard:POSITION_CURSOR — done, final scrollTop=" + outputEl.scrollTop);
|
||||
}
|
||||
|
||||
private restoreScroll(reason: string): void {
|
||||
const snap = this.snapshot;
|
||||
this.snapshot = null;
|
||||
|
||||
// 清除键盘弹出时设置的长期 scroll 冻结(先清,后面可能重新设短窗口)
|
||||
this.opts.setScrollLocked?.(0);
|
||||
|
||||
if (!snap) {
|
||||
this.opts.setAutoFollow(true);
|
||||
this.log(`keyboard:RESTORE — no snapshot (${reason})`);
|
||||
return;
|
||||
}
|
||||
|
||||
const outputEl = this.opts.getOutputEl();
|
||||
if (!outputEl) {
|
||||
this.opts.setAutoFollow(true);
|
||||
this.log(`keyboard:RESTORE — SKIP (no outputEl)`);
|
||||
this.opts.suppressAutoFollow(0);
|
||||
this.opts.suppressAutoFollow(500);
|
||||
return;
|
||||
}
|
||||
|
||||
const before = {
|
||||
scrollTop: outputEl.scrollTop,
|
||||
clientHeight: outputEl.clientHeight,
|
||||
scrollHeight: outputEl.scrollHeight,
|
||||
};
|
||||
|
||||
const contentChanged = Math.abs(before.scrollHeight - snap.scrollHeight) > 4;
|
||||
|
||||
this.log(`keyboard:RESTORE (${reason})`, {
|
||||
userScrolled: snap.userScrolled,
|
||||
contentChanged,
|
||||
snap,
|
||||
before,
|
||||
});
|
||||
|
||||
// 计算目标 scrollTop
|
||||
let targetScrollTop: number;
|
||||
|
||||
if (snap.userScrolled) {
|
||||
// 用户键盘可见期间主动上滚过:恢复弹出前的滚动位置(尊重用户意图)
|
||||
const maxTop = Math.max(0, before.scrollHeight - before.clientHeight);
|
||||
targetScrollTop = Math.max(0, Math.min(maxTop, snap.scrollTop));
|
||||
this.log(`keyboard:RESTORE — userScrolled, target=${targetScrollTop}`);
|
||||
} else {
|
||||
// 用户未主动滚动:直接恢复弹出前保存的 scrollTop。
|
||||
// snap.scrollTop 就是用户弹出键盘前看到的位置,这是最自然的"恢复"语义。
|
||||
// 内容有增长时用 maxTop 兜底(防止超出范围)。
|
||||
const maxTop = Math.max(0, before.scrollHeight - before.clientHeight);
|
||||
targetScrollTop = Math.max(0, Math.min(maxTop, snap.scrollTop));
|
||||
this.log(`keyboard:RESTORE — !userScrolled, restoring snap.scrollTop`, {
|
||||
snapScrollTop: snap.scrollTop, target: targetScrollTop, maxTop,
|
||||
contentChanged,
|
||||
});
|
||||
}
|
||||
|
||||
// 判断目标位置是否在底部(4px 容差)
|
||||
const maxTop = Math.max(0, before.scrollHeight - before.clientHeight);
|
||||
const isAtBottom = Math.abs(targetScrollTop - maxTop) < 4;
|
||||
|
||||
// 关键:先设 autoFollow=false,再设 scrollTop,再按需恢复 autoFollow。
|
||||
// 如果先 setAutoFollow(true),renderSnapshot 的下一帧会立刻把 scrollTop 覆盖成 scrollHeight。
|
||||
this.opts.setAutoFollow(false);
|
||||
|
||||
const FREEZE_MS = 200;
|
||||
this.programmaticScrollUntil = Date.now() + FREEZE_MS;
|
||||
this.opts.setScrollLocked?.(FREEZE_MS);
|
||||
outputEl.scrollTop = targetScrollTop;
|
||||
|
||||
this.log(`keyboard:RESTORE — done, final scrollTop=${outputEl.scrollTop} isAtBottom=${isAtBottom}`);
|
||||
|
||||
if (isAtBottom) {
|
||||
// 在底部:恢复 autoFollow,700ms 内保护远端 resize 回刷
|
||||
this.opts.setAutoFollow(true);
|
||||
this.opts.suppressAutoFollow(0); // 先清除60s长期抑制
|
||||
this.opts.suppressAutoFollow(700); // 再设700ms短保护
|
||||
} else {
|
||||
// 不在底部(光标后有空行):保持 autoFollow=false。
|
||||
// 先清除60s长期抑制,再设700ms短保护。
|
||||
// 顺序必须是:先清(0),再设(700)——反序会导致0把700覆盖。
|
||||
this.opts.suppressAutoFollow(0); // 清除60s长期抑制
|
||||
this.opts.suppressAutoFollow(700); // 设700ms短保护,屏蔽立刻到来的stdout心跳
|
||||
}
|
||||
}
|
||||
|
||||
// ── 辅助 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private attachScrollListener(el: HTMLElement): void {
|
||||
if (this.scrollListenerAttached) return;
|
||||
el.addEventListener("scroll", this.scrollHandler, { passive: true });
|
||||
this.scrollListenerAttached = true;
|
||||
}
|
||||
|
||||
private detachScrollListener(): void {
|
||||
if (!this.scrollListenerAttached) return;
|
||||
const outputEl = this.opts.getOutputEl();
|
||||
outputEl?.removeEventListener("scroll", this.scrollHandler);
|
||||
this.scrollListenerAttached = false;
|
||||
}
|
||||
}
|
||||
81
terminal/apps/web/src/terminal/layout/domMeasureAdapter.ts
Normal file
81
terminal/apps/web/src/terminal/layout/domMeasureAdapter.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { IMeasureAdapter } from "@remoteconn/terminal-core";
|
||||
|
||||
/**
|
||||
* DomMeasureAdapter — 使用 DOM API 实现 IMeasureAdapter。
|
||||
* 通过隐藏测量元素(单字符 span)和 ResizeObserver 获取容器/字符尺寸。
|
||||
*/
|
||||
export class DomMeasureAdapter implements IMeasureAdapter {
|
||||
private charWidthPx = 0;
|
||||
private charHeightPx = 0;
|
||||
private containerW = 0;
|
||||
private containerH = 0;
|
||||
private measureEl: HTMLElement | null = null;
|
||||
private charSpan: HTMLElement | null = null;
|
||||
private observer: ResizeObserver | null = null;
|
||||
private callbacks = new Set<() => void>();
|
||||
|
||||
constructor(
|
||||
private readonly container: HTMLElement,
|
||||
/** 参考字体样式(fontSize, fontFamily 等,会复制到测量元素上) */
|
||||
private readonly fontStyle: Partial<CSSStyleDeclaration> = {}
|
||||
) {}
|
||||
|
||||
mount(): void {
|
||||
this.measureEl = document.createElement("div");
|
||||
Object.assign(this.measureEl.style, {
|
||||
position: "absolute",
|
||||
visibility: "hidden",
|
||||
pointerEvents: "none",
|
||||
top: "0",
|
||||
left: "0",
|
||||
whiteSpace: "pre",
|
||||
...this.fontStyle,
|
||||
});
|
||||
this.charSpan = document.createElement("span");
|
||||
this.charSpan.textContent = "M"; // 等宽字符样本
|
||||
this.measureEl.appendChild(this.charSpan);
|
||||
document.body.appendChild(this.measureEl);
|
||||
|
||||
this.observer = new ResizeObserver(() => {
|
||||
this.refresh();
|
||||
for (const cb of this.callbacks) cb();
|
||||
});
|
||||
this.observer.observe(this.container);
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.observer?.disconnect();
|
||||
this.measureEl?.parentNode?.removeChild(this.measureEl);
|
||||
this.measureEl = null;
|
||||
this.charSpan = null;
|
||||
}
|
||||
|
||||
measureChar(): { widthPx: number; heightPx: number } {
|
||||
if (!this.charWidthPx) this.refresh();
|
||||
return { widthPx: this.charWidthPx || 8, heightPx: this.charHeightPx || 16 };
|
||||
}
|
||||
|
||||
measureContainer(): { widthPx: number; heightPx: number } {
|
||||
if (!this.containerW) this.refresh();
|
||||
return { widthPx: this.containerW || 320, heightPx: this.containerH || 200 };
|
||||
}
|
||||
|
||||
onResize(cb: () => void): () => void {
|
||||
this.callbacks.add(cb);
|
||||
return () => this.callbacks.delete(cb);
|
||||
}
|
||||
|
||||
private refresh(): void {
|
||||
if (this.charSpan) {
|
||||
const rect = this.charSpan.getBoundingClientRect();
|
||||
this.charWidthPx = rect.width || 8;
|
||||
this.charHeightPx = rect.height || 16;
|
||||
}
|
||||
const cRect = this.container.getBoundingClientRect();
|
||||
const style = getComputedStyle(this.container);
|
||||
const px = (s: string) => parseFloat(s) || 0;
|
||||
this.containerW = cRect.width - px(style.paddingLeft) - px(style.paddingRight);
|
||||
this.containerH = cRect.height - px(style.paddingTop) - px(style.paddingBottom);
|
||||
}
|
||||
}
|
||||
208
terminal/apps/web/src/terminal/renderer/compatRenderer.ts
Normal file
208
terminal/apps/web/src/terminal/renderer/compatRenderer.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { TerminalCore, sanitizeTerminalOutput } from "@remoteconn/terminal-core";
|
||||
import type { RendererAdapter, TerminalSnapshot, TerminalLine, TerminalCell, ColorValue } from "@remoteconn/terminal-core";
|
||||
import { FLAG_BOLD, FLAG_DIM, FLAG_ITALIC, FLAG_UNDERLINE, FLAG_INVERSE, FLAG_INVISIBLE, FLAG_STRIKETHROUGH, FLAG_OVERLINE } from "@remoteconn/terminal-core";
|
||||
|
||||
/** 16 色标准 xterm256 调色板(前 16 色) */
|
||||
const ANSI16: string[] = [
|
||||
"#000000","#cc0000","#00aa00","#aaaa00",
|
||||
"#0000ee","#cc00cc","#00aaaa","#aaaaaa",
|
||||
"#555555","#ff5555","#55ff55","#ffff55",
|
||||
"#5555ff","#ff55ff","#55ffff","#ffffff",
|
||||
];
|
||||
|
||||
function colorToCss(c: ColorValue, isFg: boolean): string {
|
||||
switch (c.mode) {
|
||||
case "default": return isFg ? "inherit" : "transparent";
|
||||
case "p16": return ANSI16[c.value] ?? "inherit";
|
||||
case "p256": return p256ToCss(c.value);
|
||||
case "rgb": return `#${(c.value & 0xffffff).toString(16).padStart(6,"0")}`;
|
||||
}
|
||||
}
|
||||
|
||||
function p256ToCss(idx: number): string {
|
||||
if (idx < 16) return ANSI16[idx] ?? "#000";
|
||||
if (idx >= 232) {
|
||||
const v = 8 + (idx - 232) * 10;
|
||||
return `rgb(${v},${v},${v})`;
|
||||
}
|
||||
idx -= 16;
|
||||
const b = idx % 6, g = Math.floor(idx / 6) % 6, r = Math.floor(idx / 36);
|
||||
const ch = (x: number) => x === 0 ? 0 : 55 + x * 40;
|
||||
return `rgb(${ch(r)},${ch(g)},${ch(b)})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* CompatRenderer — DOM div/span 实现的渲染器。
|
||||
* 每次 RAF 消费一次 TerminalCore.snapshot() 进行差量更新。
|
||||
*/
|
||||
export class CompatRenderer implements RendererAdapter {
|
||||
private core: TerminalCore;
|
||||
private container: HTMLElement | null = null;
|
||||
private viewport: HTMLElement | null = null;
|
||||
private cursorEl: HTMLElement | null = null;
|
||||
private rowEls: HTMLElement[] = [];
|
||||
private rafId: number = 0;
|
||||
private lastRevision = -1;
|
||||
|
||||
constructor(cols = 80, rows = 24) {
|
||||
this.core = new TerminalCore(cols, rows);
|
||||
}
|
||||
|
||||
getCore(): TerminalCore { return this.core; }
|
||||
|
||||
mount(container: unknown): void {
|
||||
this.container = container as HTMLElement;
|
||||
this.container.classList.add("tc-compat-root");
|
||||
Object.assign(this.container.style, {
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
fontFamily: "monospace",
|
||||
lineHeight: "1.2em",
|
||||
});
|
||||
|
||||
this.viewport = document.createElement("div");
|
||||
this.viewport.className = "tc-viewport";
|
||||
Object.assign(this.viewport.style, {
|
||||
position: "absolute",
|
||||
top: "0", left: "0",
|
||||
whiteSpace: "pre",
|
||||
});
|
||||
this.container.appendChild(this.viewport);
|
||||
|
||||
this.cursorEl = document.createElement("div");
|
||||
this.cursorEl.className = "tc-cursor";
|
||||
Object.assign(this.cursorEl.style, {
|
||||
position: "absolute",
|
||||
width: "0.6em",
|
||||
height: "1.2em",
|
||||
background:"rgba(255,255,255,0.8)",
|
||||
pointerEvents: "none",
|
||||
zIndex: "1",
|
||||
});
|
||||
this.container.appendChild(this.cursorEl);
|
||||
|
||||
this.rebuildRows(this.core.snapshot().rows);
|
||||
this.scheduleRender();
|
||||
}
|
||||
|
||||
write(data: string): void {
|
||||
const sanitized = sanitizeTerminalOutput(data);
|
||||
if (sanitized) {
|
||||
this.core.write(sanitized);
|
||||
this.scheduleRender();
|
||||
}
|
||||
}
|
||||
|
||||
resize(cols: number, rows: number): void {
|
||||
this.core.resize(cols, rows);
|
||||
this.rebuildRows(rows);
|
||||
this.scheduleRender();
|
||||
}
|
||||
|
||||
applySnapshot(snapshot: TerminalSnapshot): void {
|
||||
// Re-create a clean core from the snapshot is not straightforward;
|
||||
// the caller should pass the same TerminalCore reference instead.
|
||||
// For "mode switch" replays, we just re-render the current state.
|
||||
this.renderSnapshot(snapshot);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.rafId) cancelAnimationFrame(this.rafId);
|
||||
this.container?.classList.remove("tc-compat-root");
|
||||
this.rowEls = [];
|
||||
this.viewport?.parentNode?.removeChild(this.viewport);
|
||||
this.cursorEl?.parentNode?.removeChild(this.cursorEl);
|
||||
this.viewport = null;
|
||||
this.cursorEl = null;
|
||||
this.container = null;
|
||||
}
|
||||
|
||||
// ── Private ────────────────────────────────────────────────────────────
|
||||
|
||||
private scheduleRender(): void {
|
||||
if (this.rafId) return;
|
||||
this.rafId = requestAnimationFrame(() => {
|
||||
this.rafId = 0;
|
||||
const snap = this.core.snapshot();
|
||||
if (snap.revision === this.lastRevision) return;
|
||||
this.lastRevision = snap.revision;
|
||||
this.renderSnapshot(snap);
|
||||
});
|
||||
}
|
||||
|
||||
private rebuildRows(rows: number): void {
|
||||
if (!this.viewport) return;
|
||||
this.viewport.innerHTML = "";
|
||||
this.rowEls = [];
|
||||
for (let r = 0; r < rows; r++) {
|
||||
const div = document.createElement("div");
|
||||
div.className = "tc-row";
|
||||
this.viewport.appendChild(div);
|
||||
this.rowEls.push(div);
|
||||
}
|
||||
}
|
||||
|
||||
private renderSnapshot(snap: TerminalSnapshot): void {
|
||||
if (!this.viewport || !this.cursorEl) return;
|
||||
|
||||
// Ensure row count matches
|
||||
if (this.rowEls.length !== snap.rows) {
|
||||
this.rebuildRows(snap.rows);
|
||||
}
|
||||
|
||||
for (let r = 0; r < snap.rows; r++) {
|
||||
const rowEl = this.rowEls[r];
|
||||
if (!rowEl) continue;
|
||||
const line = snap.lines[r];
|
||||
if (line) {
|
||||
rowEl.innerHTML = lineToHtml(line);
|
||||
}
|
||||
}
|
||||
|
||||
// Update cursor
|
||||
const charEm = 0.6; // em per character (monospace approx)
|
||||
const lineEm = 1.2;
|
||||
Object.assign(this.cursorEl.style, {
|
||||
display: snap.cursor.visible ? "block" : "none",
|
||||
left: `${snap.cursor.x * charEm}em`,
|
||||
top: `${snap.cursor.y * lineEm}em`,
|
||||
width: `${charEm}em`,
|
||||
height: `${lineEm}em`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function lineToHtml(line: TerminalLine): string {
|
||||
let html = "";
|
||||
for (const cell of line.cells) {
|
||||
html += cellToHtml(cell);
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function cellToHtml(cell: TerminalCell): string {
|
||||
const { flags } = cell;
|
||||
let fg = colorToCss(cell.fg, true);
|
||||
let bg = colorToCss(cell.bg, false);
|
||||
|
||||
if (flags & FLAG_INVERSE) { [fg, bg] = [bg === "transparent" ? "#ffffff" : bg, fg === "inherit" ? "#000000" : fg]; }
|
||||
|
||||
const invisible = flags & FLAG_INVISIBLE;
|
||||
const char = invisible ? " " : (cell.char || " ");
|
||||
|
||||
const styles: string[] = [];
|
||||
if (fg !== "inherit") styles.push(`color:${fg}`);
|
||||
if (bg !== "transparent") styles.push(`background:${bg}`);
|
||||
if (flags & FLAG_BOLD) styles.push("font-weight:bold");
|
||||
if (flags & FLAG_DIM) styles.push("opacity:0.5");
|
||||
if (flags & FLAG_ITALIC) styles.push("font-style:italic");
|
||||
const tdecs: string[] = [];
|
||||
if (flags & FLAG_UNDERLINE) tdecs.push("underline");
|
||||
if (flags & FLAG_STRIKETHROUGH) tdecs.push("line-through");
|
||||
if (flags & FLAG_OVERLINE) tdecs.push("overline");
|
||||
if (tdecs.length) styles.push(`text-decoration:${tdecs.join(" ")}`);
|
||||
|
||||
const escChar = char === "&" ? "&" : char === "<" ? "<" : char === ">" ? ">" : char;
|
||||
if (!styles.length) return escChar;
|
||||
return `<span style="${styles.join(";")}">${escChar}</span>`;
|
||||
}
|
||||
381
terminal/apps/web/src/terminal/renderer/textareaRenderer.ts
Normal file
381
terminal/apps/web/src/terminal/renderer/textareaRenderer.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
import { TerminalCore, sanitizeTerminalOutput } from "@remoteconn/terminal-core";
|
||||
import type { RendererAdapter, TerminalSnapshot, TerminalCell, ColorValue } from "@remoteconn/terminal-core";
|
||||
import { FLAG_BOLD, FLAG_DIM, FLAG_ITALIC, FLAG_UNDERLINE, FLAG_INVERSE, FLAG_INVISIBLE, FLAG_STRIKETHROUGH, FLAG_OVERLINE } from "@remoteconn/terminal-core";
|
||||
|
||||
/**
|
||||
* TextareaRenderer — 基于 <pre>/<textarea> 的轻量渲染器。
|
||||
* 适合低端设备和降级场景(rendererFallback=textarea)。
|
||||
* 输出区使用 <pre> + span 显示(支持颜色属性)。
|
||||
*/
|
||||
const ANSI16: string[] = [
|
||||
"#000000","#cc0000","#00aa00","#aaaa00",
|
||||
"#0000ee","#cc00cc","#00aaaa","#aaaaaa",
|
||||
"#555555","#ff5555","#55ff55","#ffff55",
|
||||
"#5555ff","#ff55ff","#55ffff","#ffffff",
|
||||
];
|
||||
|
||||
function colorToCss(c: ColorValue, isFg: boolean): string {
|
||||
switch (c.mode) {
|
||||
case "default": return isFg ? "inherit" : "transparent";
|
||||
case "p16": return ANSI16[c.value] ?? "inherit";
|
||||
case "p256": return p256ToCss(c.value);
|
||||
case "rgb": return `#${(c.value & 0xffffff).toString(16).padStart(6, "0")}`;
|
||||
}
|
||||
}
|
||||
|
||||
function p256ToCss(idx: number): string {
|
||||
if (idx < 16) return ANSI16[idx] ?? "#000";
|
||||
if (idx >= 232) {
|
||||
const v = 8 + (idx - 232) * 10;
|
||||
return `rgb(${v},${v},${v})`;
|
||||
}
|
||||
idx -= 16;
|
||||
const b = idx % 6;
|
||||
const g = Math.floor(idx / 6) % 6;
|
||||
const r = Math.floor(idx / 36);
|
||||
const ch = (x: number) => x === 0 ? 0 : 55 + x * 40;
|
||||
return `rgb(${ch(r)},${ch(g)},${ch(b)})`;
|
||||
}
|
||||
|
||||
function escapeHtml(ch: string): string {
|
||||
if (ch === "&") return "&";
|
||||
if (ch === "<") return "<";
|
||||
if (ch === ">") return ">";
|
||||
return ch;
|
||||
}
|
||||
|
||||
function isTouchDebugEnabled(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
return window.localStorage.getItem("terminal.debugTextareaRenderer") === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function debugRenderer(message: string, detail?: unknown): void {
|
||||
if (!isTouchDebugEnabled()) return;
|
||||
const prefix = `[TextareaRenderer][Touch][${new Date().toISOString()}] ${message}`;
|
||||
if (typeof detail === "undefined") {
|
||||
console.log(prefix);
|
||||
return;
|
||||
}
|
||||
console.log(prefix, detail);
|
||||
}
|
||||
|
||||
export class TextareaRenderer implements RendererAdapter {
|
||||
private core: TerminalCore;
|
||||
private container: HTMLElement | null = null;
|
||||
private outputEl: HTMLPreElement | null = null;
|
||||
private rafId: number = 0;
|
||||
private lastRevision = -1;
|
||||
/** 是否自动跟随滚动到底部 */
|
||||
private autoFollow = true;
|
||||
private blinkVisible = true;
|
||||
private blinkTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private lastSnapshot: TerminalSnapshot | null = null;
|
||||
/**
|
||||
* 时间戳:直到此时刻前,scroll 事件不更新 autoFollow。
|
||||
* iOS WebKit 的 scroll 事件是异步的(下一个 task),不能用同步锁,只能用时间窗口。
|
||||
*/
|
||||
private scrollFrozenUntil = 0;
|
||||
|
||||
constructor(cols = 80, rows = 24) {
|
||||
this.core = new TerminalCore(cols, rows);
|
||||
}
|
||||
|
||||
getCore(): TerminalCore { return this.core; }
|
||||
|
||||
mount(container: unknown): void {
|
||||
this.container = container as HTMLElement;
|
||||
this.container.classList.add("tc-textarea-root");
|
||||
Object.assign(this.container.style, {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
minHeight: "0"
|
||||
});
|
||||
|
||||
// 输出区
|
||||
this.outputEl = document.createElement("pre");
|
||||
this.outputEl.className = "tc-output";
|
||||
Object.assign(this.outputEl.style, {
|
||||
margin: "0",
|
||||
overflow: "auto",
|
||||
fontFamily: "monospace",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-all",
|
||||
flex: "1",
|
||||
minHeight: "0",
|
||||
cursor: "text",
|
||||
userSelect: "text",
|
||||
WebkitUserSelect: "text",
|
||||
WebkitTouchCallout: "default",
|
||||
WebkitOverflowScrolling: "touch",
|
||||
overscrollBehavior: "contain",
|
||||
touchAction: "pan-y",
|
||||
position: "relative",
|
||||
});
|
||||
// 用户手动上滚时禁止自动跟随。
|
||||
// iOS WebKit scroll 事件是异步的(下一个 task),不能用同步锁。
|
||||
// 用时间窗口:scrollFrozenUntil 内的 scroll 事件跳过 autoFollow 更新。
|
||||
this.outputEl.addEventListener("scroll", () => {
|
||||
const now = Date.now();
|
||||
if (now < this.scrollFrozenUntil) {
|
||||
console.log(`[TextareaRenderer][scroll] FROZEN(${this.scrollFrozenUntil - now}ms) — skip autoFollow, scrollTop=${this.outputEl?.scrollTop}`);
|
||||
return;
|
||||
}
|
||||
const el = this.outputEl!;
|
||||
const atBottom = Math.abs(el.scrollTop + el.clientHeight - el.scrollHeight) < 4;
|
||||
const prevAutoFollow = this.autoFollow;
|
||||
this.autoFollow = atBottom;
|
||||
console.log(`[TextareaRenderer][scroll] scrollTop=${el.scrollTop} clientH=${el.clientHeight} scrollH=${el.scrollHeight} atBottom=${atBottom} autoFollow: ${prevAutoFollow} → ${this.autoFollow}`);
|
||||
});
|
||||
this.outputEl.addEventListener("touchstart", (e: TouchEvent) => {
|
||||
const t = e.changedTouches[0];
|
||||
debugRenderer("event:touchstart", t ? { x: t.clientX, y: t.clientY } : undefined);
|
||||
}, { passive: true });
|
||||
this.outputEl.addEventListener("touchmove", (e: TouchEvent) => {
|
||||
const t = e.changedTouches[0];
|
||||
debugRenderer("event:touchmove", t ? { x: t.clientX, y: t.clientY } : undefined);
|
||||
}, { passive: true });
|
||||
this.outputEl.addEventListener("touchend", (e: TouchEvent) => {
|
||||
const t = e.changedTouches[0];
|
||||
debugRenderer("event:touchend", t ? { x: t.clientX, y: t.clientY } : undefined);
|
||||
}, { passive: true });
|
||||
this.container.appendChild(this.outputEl);
|
||||
|
||||
this.startBlink();
|
||||
this.scheduleRender();
|
||||
}
|
||||
|
||||
write(data: string): void {
|
||||
const s = sanitizeTerminalOutput(data);
|
||||
if (s) {
|
||||
this.core.write(s);
|
||||
this.scheduleRender();
|
||||
}
|
||||
}
|
||||
|
||||
resize(cols: number, rows: number): void {
|
||||
this.core.resize(cols, rows);
|
||||
this.scheduleRender();
|
||||
}
|
||||
|
||||
applySnapshot(snapshot: TerminalSnapshot): void {
|
||||
this.renderSnapshot(snapshot);
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制恢复"跟随到底部"行为。
|
||||
* 用于用户执行命令(Enter)后,确保新输出与提示符回到可见底部。
|
||||
*/
|
||||
followBottom(): void {
|
||||
if (!this.outputEl || !this.lastSnapshot) return;
|
||||
if (!this.autoFollow) return;
|
||||
|
||||
// 获取真实的光标DOM进行精准滚动
|
||||
const cursorSpan = this.outputEl.querySelector('.tc-cursor-cell') as HTMLElement;
|
||||
if (cursorSpan) {
|
||||
const cursorBottom = cursorSpan.offsetTop + cursorSpan.offsetHeight;
|
||||
const clientH = this.outputEl.clientHeight;
|
||||
const maxScroll = Math.max(0, this.outputEl.scrollHeight - clientH);
|
||||
// 尝试把光标置于视口底部附近
|
||||
let targetScrollTop = cursorBottom - clientH + 20; // 留一点余量
|
||||
targetScrollTop = Math.max(0, Math.min(maxScroll, targetScrollTop));
|
||||
this.outputEl.scrollTop = targetScrollTop;
|
||||
} else {
|
||||
this.outputEl.scrollTop = this.outputEl.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回光标行在输出滚动容器中距顶部的精准像素偏移量。
|
||||
* 用于软键盘弹出时将提示符行置于屏幕特定位置。
|
||||
* 若渲染器未就绪则返回 null。
|
||||
*
|
||||
* 计算方法:优先使用 getComputedStyle lineHeight 获得精确行高,
|
||||
* 回退使用 scrollHeight/lineCount 估算。
|
||||
*/
|
||||
getCursorOffsetPx(): number | null {
|
||||
if (!this.outputEl || !this.lastSnapshot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 精准获取光标DOM元素的 offsetTop
|
||||
const cursorSpan = this.outputEl.querySelector('.tc-cursor-cell') as HTMLElement;
|
||||
if (cursorSpan) {
|
||||
return cursorSpan.offsetTop;
|
||||
}
|
||||
|
||||
// fallback: 如果没有可见光标,根据 cursor.y 估算
|
||||
const snap = this.lastSnapshot;
|
||||
const totalLines = snap.lines.length;
|
||||
const cursorY = Math.max(0, Math.min(totalLines - 1, snap.cursor.y));
|
||||
const scrollH = this.outputEl.scrollHeight;
|
||||
const lineHeight = scrollH / Math.max(1, totalLines);
|
||||
return cursorY * lineHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取输出滚动容器元素。
|
||||
*/
|
||||
getOutputEl(): HTMLElement | null {
|
||||
return this.outputEl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 冻结 scroll 事件对 autoFollow 的影响,持续 ms 毫秒。
|
||||
* 用于程序主动设置 scrollTop 后,屏蔽 iOS 异步 scroll 事件的干扰。
|
||||
* ms=0 立即解冻。
|
||||
*/
|
||||
setScrollLocked(ms: number): void {
|
||||
this.scrollFrozenUntil = ms > 0 ? Date.now() + ms : 0;
|
||||
console.log(`[TextareaRenderer][setScrollFrozen] ms=${ms} frozenUntil=${ms > 0 ? new Date(this.scrollFrozenUntil).toISOString() : "CLEARED"}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 临时禁止 autoFollow(键盘弹出/收回动画期间保护滚动位置)。
|
||||
*/
|
||||
setAutoFollow(enabled: boolean): void {
|
||||
console.log(`[TextareaRenderer][setAutoFollow] ${this.autoFollow} → ${enabled}`);
|
||||
this.autoFollow = enabled;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.rafId) cancelAnimationFrame(this.rafId);
|
||||
if (this.blinkTimer) {
|
||||
clearInterval(this.blinkTimer);
|
||||
this.blinkTimer = null;
|
||||
}
|
||||
this.container?.classList.remove("tc-textarea-root");
|
||||
this.outputEl?.parentNode?.removeChild(this.outputEl);
|
||||
this.outputEl = null;
|
||||
this.container = null;
|
||||
this.lastSnapshot = null;
|
||||
}
|
||||
|
||||
// ── Private ──────────────────────────────────────────────────────────────
|
||||
|
||||
private scheduleRender(): void {
|
||||
if (this.rafId) return;
|
||||
this.rafId = requestAnimationFrame(() => {
|
||||
this.rafId = 0;
|
||||
const snap = this.core.snapshotWithScrollback();
|
||||
if (snap.revision === this.lastRevision) return;
|
||||
this.lastRevision = snap.revision;
|
||||
this.blinkVisible = true;
|
||||
this.renderSnapshot(snap);
|
||||
});
|
||||
}
|
||||
|
||||
private startBlink(): void {
|
||||
if (this.blinkTimer) clearInterval(this.blinkTimer);
|
||||
this.blinkTimer = setInterval(() => {
|
||||
if (!this.lastSnapshot || !this.lastSnapshot.cursor.visible) return;
|
||||
if (this.hasActiveSelectionInOutput()) return;
|
||||
this.blinkVisible = !this.blinkVisible;
|
||||
this.renderSnapshot(this.lastSnapshot, true);
|
||||
}, 550);
|
||||
}
|
||||
|
||||
private renderSnapshot(snap: TerminalSnapshot, fromBlink = false): void {
|
||||
if (!this.outputEl) return;
|
||||
this.lastSnapshot = snap;
|
||||
if (this.hasActiveSelectionInOutput()) return;
|
||||
|
||||
const htmlRows: string[] = [];
|
||||
const cursorY = Math.max(0, Math.min(snap.lines.length - 1, snap.cursor.y));
|
||||
for (let rowIndex = 0; rowIndex < snap.lines.length; rowIndex++) {
|
||||
const line = snap.lines[rowIndex];
|
||||
if (!line) {
|
||||
htmlRows.push("");
|
||||
continue;
|
||||
}
|
||||
const cells = line.cells;
|
||||
let rowHtml = "";
|
||||
for (let col = 0; col < cells.length; col++) {
|
||||
const cell = cells[col];
|
||||
if (!cell) continue;
|
||||
const cursorLogicalHere = snap.cursor.visible && rowIndex === cursorY && col === snap.cursor.x;
|
||||
const cursorPaintHere = cursorLogicalHere && this.blinkVisible;
|
||||
rowHtml += this.cellToHtml(cell, cursorPaintHere, cursorLogicalHere);
|
||||
}
|
||||
htmlRows.push(rowHtml.replace(/\s+$/g, ""));
|
||||
}
|
||||
|
||||
// iOS Safari 在 innerHTML 赋值后会不稳定地改变 scrollTop。
|
||||
// autoFollow=false(键盘弹出期间)时,必须手动保存和恢复 scrollTop。
|
||||
const savedScrollTop = this.autoFollow ? -1 : this.outputEl.scrollTop;
|
||||
|
||||
this.outputEl.innerHTML = htmlRows.join("\n");
|
||||
|
||||
if (this.autoFollow && !fromBlink) {
|
||||
const clientH = this.outputEl.clientHeight;
|
||||
const maxScroll = Math.max(0, this.outputEl.scrollHeight - clientH);
|
||||
|
||||
const cursorSpan = this.outputEl.querySelector('.tc-cursor-cell') as HTMLElement;
|
||||
if (cursorSpan) {
|
||||
const cursorBottom = cursorSpan.offsetTop + cursorSpan.offsetHeight;
|
||||
let targetScrollTop = cursorBottom - clientH + 20;
|
||||
targetScrollTop = Math.max(0, Math.min(maxScroll, targetScrollTop));
|
||||
this.outputEl.scrollTop = targetScrollTop;
|
||||
} else {
|
||||
this.outputEl.scrollTop = this.outputEl.scrollHeight;
|
||||
}
|
||||
} else if (savedScrollTop >= 0) {
|
||||
// 恢复之前的 scrollTop,防止 iOS 在 innerHTML 赋值后将其重置
|
||||
const maxTop = Math.max(0, this.outputEl.scrollHeight - this.outputEl.clientHeight);
|
||||
const restoredTop = Math.min(savedScrollTop, maxTop);
|
||||
if (Math.abs(restoredTop - this.outputEl.scrollTop) > 1) {
|
||||
console.log(`[TextareaRenderer][renderSnapshot] scrollTop drifted: was=${savedScrollTop} got=${this.outputEl.scrollTop} restoring=${restoredTop} maxTop=${maxTop}`);
|
||||
this.outputEl.scrollTop = restoredTop;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private cellToHtml(cell: TerminalCell, cursorPaintHere: boolean, cursorLogicalHere: boolean): string {
|
||||
const { flags } = cell;
|
||||
let fg = colorToCss(cell.fg, true);
|
||||
let bg = colorToCss(cell.bg, false);
|
||||
if (flags & FLAG_INVERSE) {
|
||||
[fg, bg] = [bg === "transparent" ? "#ffffff" : bg, fg === "inherit" ? "#000000" : fg];
|
||||
}
|
||||
if (cursorPaintHere) {
|
||||
const cursorBg = fg === "inherit" ? "#d9d9d9" : fg;
|
||||
const cursorFg = bg === "transparent" ? "#101010" : bg;
|
||||
fg = cursorFg;
|
||||
bg = cursorBg;
|
||||
}
|
||||
|
||||
const char = (flags & FLAG_INVISIBLE) ? " " : (cell.char || " ");
|
||||
const styles: string[] = [];
|
||||
if (fg !== "inherit") styles.push(`color:${fg}`);
|
||||
if (bg !== "transparent") styles.push(`background:${bg}`);
|
||||
if (flags & FLAG_BOLD) styles.push("font-weight:bold");
|
||||
if (flags & FLAG_DIM) styles.push("opacity:0.5");
|
||||
if (flags & FLAG_ITALIC) styles.push("font-style:italic");
|
||||
const tdecs: string[] = [];
|
||||
if (flags & FLAG_UNDERLINE) tdecs.push("underline");
|
||||
if (flags & FLAG_STRIKETHROUGH) tdecs.push("line-through");
|
||||
if (flags & FLAG_OVERLINE) tdecs.push("overline");
|
||||
if (tdecs.length) styles.push(`text-decoration:${tdecs.join(" ")}`);
|
||||
|
||||
const escaped = escapeHtml(char);
|
||||
const classAttr = cursorLogicalHere ? ` class="tc-cursor-cell"` : "";
|
||||
if (!styles.length) {
|
||||
if (!cursorLogicalHere) return escaped;
|
||||
return `<span${classAttr}>${escaped}</span>`;
|
||||
}
|
||||
return `<span${classAttr} style="${styles.join(";")}">${escaped}</span>`;
|
||||
}
|
||||
|
||||
private hasActiveSelectionInOutput(): boolean {
|
||||
if (!this.outputEl || typeof window === "undefined") return false;
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return false;
|
||||
const range = selection.getRangeAt(0);
|
||||
const node = range.commonAncestorContainer;
|
||||
return this.outputEl.contains(node.nodeType === Node.TEXT_NODE ? node.parentNode : node);
|
||||
}
|
||||
}
|
||||
539
terminal/apps/web/src/terminal/stores/useTerminalStore.ts
Normal file
539
terminal/apps/web/src/terminal/stores/useTerminalStore.ts
Normal file
@@ -0,0 +1,539 @@
|
||||
/**
|
||||
* useTerminalStore — 新 terminal-core 架构的独立 Pinia store。
|
||||
*
|
||||
* 职责:
|
||||
* - 管理 transport / renderer / outputBuffer 生命周期
|
||||
* - 对外暴露 state / title / latencyMs / rendererMode 响应式状态
|
||||
* - 提供 connect / disconnect / sendInput / clearTerminal / switchRenderer / mountRenderer
|
||||
*
|
||||
* 刻意与 sessionStore 分离,保持实验性单一职责。
|
||||
*/
|
||||
import { defineStore } from "pinia";
|
||||
import { ref, shallowRef } from "vue";
|
||||
import { OutputBuffer, SessionMachine, sanitizeTerminalOutput } from "@remoteconn/terminal-core";
|
||||
import type { ConnectParams, SessionState } from "@remoteconn/terminal-core";
|
||||
import type { TerminalTransport } from "@/services/transport/terminalTransport";
|
||||
import { createTransport } from "@/services/transport/factory";
|
||||
import { TextareaRenderer } from "@/terminal/renderer/textareaRenderer";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
|
||||
export type RendererMode = "textarea";
|
||||
|
||||
function logStore(_message: string, _detail?: unknown): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
const enabled =
|
||||
window.localStorage.getItem("terminal.debugTransport") === "1" ||
|
||||
window.localStorage.getItem("terminal.debugPaste") === "1";
|
||||
if (!enabled) return;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const prefix = `[TerminalStore][${new Date().toISOString()}] ${_message}`;
|
||||
if (typeof _detail === "undefined") {
|
||||
console.log(prefix);
|
||||
return;
|
||||
}
|
||||
console.log(prefix, _detail);
|
||||
}
|
||||
|
||||
export const useTerminalStore = defineStore("terminal-core", () => {
|
||||
const OUTPUT_CACHE_PREFIX = "remoteconn:web:output-cache:v1:";
|
||||
const PASTE_CHUNK_SIZE = 256;
|
||||
const DEFAULT_CHUNK_SIZE = 2048;
|
||||
|
||||
// ── 持久配置 ─────────────────────────────────────────────────────────────
|
||||
const rendererMode = ref<RendererMode>("textarea");
|
||||
|
||||
// ── 响应式状态 ───────────────────────────────────────────────────────────
|
||||
const state = ref<SessionState>("idle");
|
||||
const title = ref<string>("");
|
||||
const latencyMs = ref<number>(0);
|
||||
|
||||
// ── 非响应式内部对象 ──────────────────────────────────────────────────────
|
||||
const machine = new SessionMachine();
|
||||
const buffer = new OutputBuffer({ maxEntries: 500, maxBytes: 256 * 1024 });
|
||||
|
||||
const renderer = shallowRef<TextareaRenderer | null>(null);
|
||||
let transport : TerminalTransport | null = null;
|
||||
let offTransport: (() => void) | null = null;
|
||||
let resizeTimer: number | null = null;
|
||||
let pendingResize: { cols: number; rows: number } | null = null;
|
||||
let latestLocalSize: { cols: number; rows: number } | null = null;
|
||||
let lastRemoteSize: { cols: number; rows: number } | null = null;
|
||||
let remoteResizePaused = false;
|
||||
let suppressAutoFollowUntil = 0;
|
||||
const RESIZE_DEBOUNCE_MS = 140;
|
||||
|
||||
let mountedContainer: HTMLElement | null = null;
|
||||
let outputCacheKey: string | null = null;
|
||||
|
||||
function persistOutputCache(): void {
|
||||
if (!outputCacheKey || typeof window === "undefined") return;
|
||||
try {
|
||||
window.sessionStorage.setItem(outputCacheKey, JSON.stringify(buffer.getAll()));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定会话输出缓存。
|
||||
* - scope 建议使用 serverId + clientSessionKey,避免串线;
|
||||
* - 仅在当前 buffer 为空时回放缓存,防止运行中重复叠加。
|
||||
*/
|
||||
function bindOutputCache(scope: string): void {
|
||||
if (!scope || typeof window === "undefined") return;
|
||||
const nextKey = `${OUTPUT_CACHE_PREFIX}${scope}`;
|
||||
if (outputCacheKey === nextKey) return;
|
||||
outputCacheKey = nextKey;
|
||||
|
||||
if (buffer.size > 0) return;
|
||||
|
||||
let restoredAny = false;
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(nextKey);
|
||||
if (!raw) return;
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) return;
|
||||
for (const item of parsed) {
|
||||
if (typeof item !== "string") continue;
|
||||
const sanitized = sanitizeTerminalOutput(item);
|
||||
if (!sanitized) continue;
|
||||
buffer.push(sanitized);
|
||||
restoredAny = true;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (restoredAny && renderer.value) {
|
||||
for (const chunk of buffer.getAll()) {
|
||||
renderer.value.write(chunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearBoundOutputCache(): void {
|
||||
if (!outputCacheKey || typeof window === "undefined") return;
|
||||
try {
|
||||
window.sessionStorage.removeItem(outputCacheKey);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// ── 渲染器挂载点 ─────────────────────────────────────────────────────────
|
||||
/**
|
||||
* 在 TerminalViewport.vue 的 onMounted 后调用,将渲染器挂载到 DOM 容器。
|
||||
* 切换渲染器时也需重新调用(已由 switchRenderer 内部处理)。
|
||||
*/
|
||||
function mountRenderer(container: HTMLElement): void {
|
||||
mountedContainer = container;
|
||||
|
||||
if (!renderer.value) {
|
||||
renderer.value = new TextareaRenderer();
|
||||
}
|
||||
|
||||
renderer.value.mount(container);
|
||||
|
||||
// 重放当前 buffer(初次挂载 / 切换后恢复历史)
|
||||
for (const chunk of buffer.getAll()) {
|
||||
renderer.value.write(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 原子切换渲染模式:dispose → 新建 → 重放 buffer → re-mount。
|
||||
*/
|
||||
function switchRenderer(mode: RendererMode): void {
|
||||
if (rendererMode.value === mode) return;
|
||||
rendererMode.value = "textarea";
|
||||
}
|
||||
|
||||
// ── Transport 事件处理 ───────────────────────────────────────────────────
|
||||
function bindTransport(t: TerminalTransport): void {
|
||||
logStore("transport:bind");
|
||||
offTransport?.();
|
||||
offTransport = t.on((event) => {
|
||||
if (event.type === "stdout" || event.type === "stderr") {
|
||||
logStore("transport:event", { type: event.type, length: event.data.length, machineState: machine.state, state: state.value });
|
||||
} else {
|
||||
logStore("transport:event", { type: event.type, machineState: machine.state, state: state.value, event });
|
||||
}
|
||||
if (event.type === "stdout" || event.type === "stderr") {
|
||||
if (state.value !== "connected" && machine.state === "connecting") {
|
||||
machine.tryTransition("auth_pending");
|
||||
}
|
||||
if (state.value !== "connected" && machine.tryTransition("connected")) {
|
||||
state.value = machine.state;
|
||||
}
|
||||
const sanitized = sanitizeTerminalOutput(event.data);
|
||||
if (sanitized) {
|
||||
// 统一行为:有新输出刷新时保持底部可见(提示符/最新输出不离开视口)。
|
||||
// 例外:键盘收起恢复窗口内,临时抑制 auto-follow,避免覆盖刚恢复的滚动位置。
|
||||
const now = Date.now();
|
||||
if (now >= suppressAutoFollowUntil) {
|
||||
console.log(`[TerminalStore][stdout] followBottom() called, len=${sanitized.length}`);
|
||||
renderer.value?.followBottom();
|
||||
} else {
|
||||
console.log(`[TerminalStore][stdout] followBottom SUPPRESSED, remainMs=${suppressAutoFollowUntil - now}, len=${sanitized.length}`);
|
||||
logStore("follow:skipped_suppressed", { remainMs: suppressAutoFollowUntil - now });
|
||||
}
|
||||
buffer.push(sanitized);
|
||||
renderer.value?.write(sanitized);
|
||||
persistOutputCache();
|
||||
}
|
||||
} else if (event.type === "connected") {
|
||||
if (machine.state === "connecting") {
|
||||
machine.tryTransition("auth_pending");
|
||||
}
|
||||
machine.tryTransition("connected");
|
||||
state.value = machine.state;
|
||||
} else if (event.type === "disconnect") {
|
||||
console.log(`[TerminalStore][${new Date().toISOString()}] transport:disconnect`, { reason: event.reason });
|
||||
console.error(`[TerminalStore][${new Date().toISOString()}] transport:disconnect`, { reason: event.reason });
|
||||
logStore("transport:disconnect", { reason: event.reason });
|
||||
machine.tryTransition("disconnected");
|
||||
state.value = machine.state;
|
||||
} else if (event.type === "latency") {
|
||||
latencyMs.value = event.data;
|
||||
} else if (event.type === "error") {
|
||||
console.log(`[TerminalStore][${new Date().toISOString()}] transport:error`, { code: event.code, message: event.message });
|
||||
console.error(`[TerminalStore][${new Date().toISOString()}] transport:error`, { code: event.code, message: event.message });
|
||||
logStore("transport:error", { code: event.code, message: event.message });
|
||||
machine.tryTransition("error");
|
||||
state.value = machine.state;
|
||||
} else if ((event as unknown as { type?: string; action?: string }).type === "control") {
|
||||
const action = (event as unknown as { action?: string }).action;
|
||||
if (action === "connected") {
|
||||
machine.tryTransition("connected");
|
||||
state.value = machine.state;
|
||||
} else if (action === "disconnect") {
|
||||
machine.tryTransition("disconnected");
|
||||
state.value = machine.state;
|
||||
} else if (action === "pong") {
|
||||
latencyMs.value = 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── 公开 API ──────────────────────────────────────────────────────────────
|
||||
async function connect(params: ConnectParams): Promise<void> {
|
||||
logStore("connect:requested", { state: state.value, host: params.host, port: params.port, username: params.username, cols: params.cols, rows: params.rows });
|
||||
if (state.value === "connected" || state.value === "connecting") {
|
||||
logStore("connect:skip", { reason: "already_connected_or_connecting", state: state.value });
|
||||
return;
|
||||
}
|
||||
|
||||
machine.tryTransition("connecting");
|
||||
state.value = machine.state;
|
||||
logStore("state:update", { machineState: machine.state, state: state.value });
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
await settingsStore.ensureBootstrapped();
|
||||
const gatewayUrl = settingsStore.gatewayUrl as string;
|
||||
const gatewayToken = settingsStore.gatewayToken as string;
|
||||
const isIos = !!(window as unknown as { Capacitor?: { isNativePlatform?: () => boolean } })
|
||||
.Capacitor?.isNativePlatform?.();
|
||||
logStore("connect:settings_ready", {
|
||||
gatewayUrl,
|
||||
hasGatewayToken: Boolean(gatewayToken),
|
||||
transportMode: isIos ? "ios-native" : "gateway"
|
||||
});
|
||||
|
||||
transport = createTransport(
|
||||
isIos ? "ios-native" : "gateway",
|
||||
{ gatewayUrl, gatewayToken }
|
||||
);
|
||||
logStore("transport:created", { transportMode: isIos ? "ios-native" : "gateway" });
|
||||
bindTransport(transport);
|
||||
|
||||
const currentTransport = transport;
|
||||
|
||||
try {
|
||||
logStore("connect:transport_connect_begin");
|
||||
await currentTransport.connect(params);
|
||||
logStore("connect:transport_connect_resolved");
|
||||
|
||||
// If disconnected or transport changed during await
|
||||
if (state.value === "disconnected" || transport !== currentTransport) {
|
||||
logStore("connect:stale_result_ignored", { state: state.value, transportChanged: transport !== currentTransport });
|
||||
return;
|
||||
}
|
||||
|
||||
machine.tryTransition("auth_pending");
|
||||
state.value = machine.state;
|
||||
// init 帧会携带首个 pty 尺寸,这里记录为“远端当前尺寸”基线,避免后续重复下发。
|
||||
lastRemoteSize = { cols: params.cols, rows: params.rows };
|
||||
logStore("state:update", { machineState: machine.state, state: state.value });
|
||||
} catch (err) {
|
||||
if (state.value === "disconnected" || transport !== currentTransport) {
|
||||
logStore("connect:error_ignored_after_disconnect", { state: state.value, transportChanged: transport !== currentTransport, err });
|
||||
return;
|
||||
}
|
||||
machine.tryTransition("error");
|
||||
state.value = machine.state;
|
||||
logStore("state:update", { machineState: machine.state, state: state.value, err });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function disconnect(reason = "manual"): void {
|
||||
logStore("disconnect:requested", { reason, state: state.value });
|
||||
if (transport) {
|
||||
void transport.disconnect(reason).catch((err) => {
|
||||
logStore("disconnect:transport_failed", { reason, err: String(err) });
|
||||
});
|
||||
}
|
||||
offTransport?.();
|
||||
offTransport = null;
|
||||
transport = null;
|
||||
if (resizeTimer !== null) {
|
||||
window.clearTimeout(resizeTimer);
|
||||
resizeTimer = null;
|
||||
}
|
||||
pendingResize = null;
|
||||
latestLocalSize = null;
|
||||
lastRemoteSize = null;
|
||||
remoteResizePaused = false;
|
||||
if (reason === "manual") {
|
||||
clearBoundOutputCache();
|
||||
}
|
||||
machine.tryTransition("disconnected");
|
||||
state.value = machine.state;
|
||||
logStore("state:update", { machineState: machine.state, state: state.value });
|
||||
}
|
||||
|
||||
function sendInput(data: string, source: "keyboard" | "assist" | "paste" = "keyboard"): void {
|
||||
if (!transport || state.value !== "connected") {
|
||||
console.log(`[TerminalStore][${new Date().toISOString()}] stdin:dropped`, {
|
||||
source,
|
||||
length: data.length,
|
||||
hasTransport: Boolean(transport),
|
||||
state: state.value
|
||||
});
|
||||
return;
|
||||
}
|
||||
const currentTransport = transport;
|
||||
// 统一行为:回车/换行、Ctrl+C、Ctrl+D、粘贴/辅助输入都强制回到底部。
|
||||
const shouldForceFollow =
|
||||
data.includes("\r") ||
|
||||
data.includes("\n") ||
|
||||
data.includes("\u0003") ||
|
||||
data.includes("\u0004") ||
|
||||
source !== "keyboard";
|
||||
if (shouldForceFollow) {
|
||||
renderer.value?.followBottom();
|
||||
}
|
||||
// 网关现网 schema 在 stdin 的 union 校验上存在差异:
|
||||
// - 部分环境不接受 meta.source = "paste";
|
||||
// - 部分环境对 paste 分支携带 meta 也会触发 invalid_union。
|
||||
// 因此 paste 统一走“无 meta”发送,keyboard/assist 保持原语义。
|
||||
const wireSource: "keyboard" | "assist" = source === "paste" ? "keyboard" : source;
|
||||
const wireMeta = source === "paste" ? undefined : { source: wireSource };
|
||||
// 大文本(尤其粘贴)分片发送,避免单帧过大触发网关/WS 断连。
|
||||
const chunkSize = source === "paste" ? PASTE_CHUNK_SIZE : DEFAULT_CHUNK_SIZE;
|
||||
const totalChunks = Math.ceil(data.length / chunkSize);
|
||||
if (source === "paste") {
|
||||
console.log(`[TerminalStore][${new Date().toISOString()}] stdin:dispatch`, {
|
||||
source,
|
||||
wireSource,
|
||||
wireMetaSource: wireMeta?.source ?? "none",
|
||||
length: data.length,
|
||||
chunkSize,
|
||||
totalChunks
|
||||
});
|
||||
}
|
||||
logStore("stdin:dispatch", { source, length: data.length, chunkSize, totalChunks });
|
||||
if (data.length <= chunkSize) {
|
||||
void currentTransport.send(data, wireMeta).catch((err) => {
|
||||
console.error(`[TerminalStore][${new Date().toISOString()}] stdin:send_failed`, { source, index: 1, totalChunks: 1, length: data.length, err: String(err) });
|
||||
logStore("stdin:send_failed", { source, index: 1, totalChunks: 1, length: data.length, err: String(err) });
|
||||
});
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < data.length; i += chunkSize) {
|
||||
const chunk = data.slice(i, i + chunkSize);
|
||||
const index = i / chunkSize + 1;
|
||||
if (source === "paste") {
|
||||
console.log(`[TerminalStore][${new Date().toISOString()}] stdin:chunk`, { source, index, totalChunks, length: chunk.length });
|
||||
}
|
||||
logStore("stdin:chunk", { source, index, totalChunks, length: chunk.length });
|
||||
void currentTransport.send(chunk, wireMeta).catch((err) => {
|
||||
console.error(`[TerminalStore][${new Date().toISOString()}] stdin:send_failed`, { source, index, totalChunks, length: chunk.length, err: String(err) });
|
||||
logStore("stdin:send_failed", { source, index, totalChunks, length: chunk.length, err: String(err) });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function clearTerminal(): void {
|
||||
if (transport && state.value === "connected") {
|
||||
// 连接态使用 Ctrl+L,让远端 shell 清屏并重绘提示符,避免提示符消失。
|
||||
sendInput("\x0c", "keyboard");
|
||||
return;
|
||||
}
|
||||
|
||||
// 未连接时执行本地硬清屏。
|
||||
buffer.clear();
|
||||
renderer.value?.write("\x1b[2J\x1b[H");
|
||||
persistOutputCache();
|
||||
}
|
||||
|
||||
function isSameSize(
|
||||
a: { cols: number; rows: number } | null,
|
||||
b: { cols: number; rows: number } | null
|
||||
): boolean {
|
||||
return Boolean(a && b && a.cols === b.cols && a.rows === b.rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* 安排一次“向远端 PTY 下发尺寸”的去抖任务。
|
||||
* - 仅连接态生效;
|
||||
* - 若尺寸与最近成功下发的一致则跳过,避免无效回刷;
|
||||
* - 任务触发时若处于 paused 状态则直接丢弃,由恢复流程统一补发最终尺寸。
|
||||
*/
|
||||
function scheduleRemoteResize(cols: number, rows: number): void {
|
||||
pendingResize = { cols, rows };
|
||||
if (resizeTimer !== null) {
|
||||
window.clearTimeout(resizeTimer);
|
||||
resizeTimer = null;
|
||||
}
|
||||
resizeTimer = window.setTimeout(() => {
|
||||
resizeTimer = null;
|
||||
if (remoteResizePaused) return;
|
||||
if (!pendingResize) return;
|
||||
const next = pendingResize;
|
||||
pendingResize = null;
|
||||
if (!transport || state.value !== "connected") return;
|
||||
if (isSameSize(lastRemoteSize, next)) {
|
||||
logStore("pty:resize_skipped_same", { cols: next.cols, rows: next.rows });
|
||||
return;
|
||||
}
|
||||
void transport.resize(next.cols, next.rows)
|
||||
.then(() => {
|
||||
lastRemoteSize = next;
|
||||
})
|
||||
.catch((err) => {
|
||||
logStore("pty:resize_failed", { cols: next.cols, rows: next.rows, state: state.value, err: String(err) });
|
||||
});
|
||||
}, RESIZE_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 控制“远端 PTY resize”是否暂停。
|
||||
* 典型用途:软键盘动画期间只做本地 resize,避免远端收到中间尺寸后回刷覆盖滚动恢复。
|
||||
*/
|
||||
function setRemoteResizePaused(paused: boolean): void {
|
||||
if (remoteResizePaused === paused) return;
|
||||
remoteResizePaused = paused;
|
||||
logStore("pty:resize_pause_changed", { paused });
|
||||
|
||||
if (paused) {
|
||||
// 进入暂停态时清空未发送任务,防止中间尺寸被发到远端。
|
||||
if (resizeTimer !== null) {
|
||||
window.clearTimeout(resizeTimer);
|
||||
resizeTimer = null;
|
||||
}
|
||||
pendingResize = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// 退出暂停态后补发当前最新本地尺寸(若与远端一致会被 schedule 自动跳过)。
|
||||
if (!transport || state.value !== "connected") return;
|
||||
if (!latestLocalSize) return;
|
||||
scheduleRemoteResize(latestLocalSize.cols, latestLocalSize.rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在指定时间窗口内临时抑制 “stdout/stderr 触发的 followBottom”。
|
||||
* 用于软键盘收起后的滚动恢复保护,避免远端 resize 回刷覆盖恢复位置。
|
||||
*/
|
||||
function suppressAutoFollow(ms: number): void {
|
||||
const nextUntil = Date.now() + Math.max(0, ms);
|
||||
// ms=0 时强制重置(键盘关闭时清除之前设置的长窗口)
|
||||
if (ms > 0 && nextUntil <= suppressAutoFollowUntil) return;
|
||||
suppressAutoFollowUntil = nextUntil;
|
||||
console.log(`[TerminalStore][suppressAutoFollow] ms=${ms} until=${ms === 0 ? "CLEARED" : new Date(suppressAutoFollowUntil).toISOString()}`);
|
||||
logStore("follow:suppressed", { ms, until: suppressAutoFollowUntil });
|
||||
}
|
||||
|
||||
function resizeTerminal(cols: number, rows: number): void {
|
||||
renderer.value?.resize(cols, rows);
|
||||
latestLocalSize = { cols, rows };
|
||||
if (!transport) return;
|
||||
// 仅连接态下向网关发送 resize,避免连接未建立时出现未处理 Promise 拒绝。
|
||||
if (state.value !== "connected") return;
|
||||
if (remoteResizePaused) {
|
||||
logStore("pty:resize_paused_skip", { cols, rows });
|
||||
return;
|
||||
}
|
||||
|
||||
// 仅对“发给远端 PTY”的 resize 去抖:
|
||||
// - 软键盘动画/视口抖动期间会短时间出现多次尺寸跳变(例如 19 -> 12 -> 19);
|
||||
// - 直接逐次下发会触发远端多次重排,导致提示符位置抖动;
|
||||
// - 本地渲染仍即时 resize,保证交互跟手;远端仅发送稳定后的最终尺寸。
|
||||
scheduleRemoteResize(cols, rows);
|
||||
}
|
||||
|
||||
/** 获取当前活跃 TerminalCore(用于 cursorMath、测量等)。 */
|
||||
function getCore() {
|
||||
return renderer.value?.getCore() ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取光标行距输出区顶部的像素偏移。
|
||||
* 用于软键盘弹出时定位提示符行。
|
||||
*/
|
||||
function getCursorOffsetPx(): number | null {
|
||||
return renderer.value?.getCursorOffsetPx() ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取输出滚动容器 DOM 元素。
|
||||
*/
|
||||
function getOutputEl(): HTMLElement | null {
|
||||
return renderer.value?.getOutputEl() ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置输出区是否自动跟随滚动到底部。
|
||||
*/
|
||||
function setAutoFollow(enabled: boolean): void {
|
||||
renderer.value?.setAutoFollow(enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* 冻结 scroll 事件对 autoFollow 的影响,持续 ms 毫秒(iOS scroll 事件是异步的)。
|
||||
* ms=0 立即解冻。
|
||||
*/
|
||||
function setScrollLocked(ms: number): void {
|
||||
renderer.value?.setScrollLocked(ms);
|
||||
}
|
||||
|
||||
return {
|
||||
// 响应式
|
||||
state,
|
||||
title,
|
||||
latencyMs,
|
||||
rendererMode,
|
||||
renderer,
|
||||
// 方法
|
||||
mountRenderer,
|
||||
switchRenderer,
|
||||
bindOutputCache,
|
||||
clearBoundOutputCache,
|
||||
connect,
|
||||
disconnect,
|
||||
sendInput,
|
||||
clearTerminal,
|
||||
resizeTerminal,
|
||||
setRemoteResizePaused,
|
||||
suppressAutoFollow,
|
||||
getCore,
|
||||
getCursorOffsetPx,
|
||||
getOutputEl,
|
||||
setAutoFollow,
|
||||
setScrollLocked,
|
||||
};
|
||||
});
|
||||
81
terminal/apps/web/src/utils/feedback.ts
Normal file
81
terminal/apps/web/src/utils/feedback.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
function asMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return String(error.message || "");
|
||||
}
|
||||
if (typeof error === "string") {
|
||||
return error;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(error);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeText(input: string): string {
|
||||
return input.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function toFriendlyConnectionError(error: unknown): string {
|
||||
const message = asMessage(error);
|
||||
const lower = normalizeText(message);
|
||||
|
||||
if (lower.includes("rate_limit") || message.includes("连接过于频繁")) {
|
||||
return "连接过于频繁,请稍后重试。";
|
||||
}
|
||||
|
||||
if (lower.includes("auth_failed") || message.includes("token 无效")) {
|
||||
return "网关鉴权失败,请联系管理员检查网关令牌。";
|
||||
}
|
||||
|
||||
if (message.includes("SSH 认证失败")) {
|
||||
return "SSH 认证失败。请检查账号/凭据。";
|
||||
}
|
||||
|
||||
if (message.includes("Timed out while waiting for handshake") || message.includes("连接超时") || lower.includes("timeout")) {
|
||||
return "连接超时。请检查服务器地址、端口和网络连通性。";
|
||||
}
|
||||
|
||||
if (message.includes("无法连接网关") || lower.includes("ws_closed") || lower.includes("websocket")) {
|
||||
return "无法连接网关,请检查网关地址、服务状态与网络策略。";
|
||||
}
|
||||
|
||||
if (!message) {
|
||||
return "连接失败,请稍后重试。";
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
export function toFriendlyError(error: unknown): string {
|
||||
const message = asMessage(error);
|
||||
const lower = normalizeText(message);
|
||||
|
||||
if (!message) {
|
||||
return "操作失败,请稍后重试。";
|
||||
}
|
||||
|
||||
if (
|
||||
lower.includes("ws_") ||
|
||||
lower.includes("websocket") ||
|
||||
lower.includes("auth_failed") ||
|
||||
lower.includes("rate_limit") ||
|
||||
message.includes("连接") ||
|
||||
message.includes("网关") ||
|
||||
message.includes("SSH")
|
||||
) {
|
||||
return toFriendlyConnectionError(message);
|
||||
}
|
||||
|
||||
if (message.includes("会话未连接")) {
|
||||
return "当前会话未连接,请先建立连接。";
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
export function formatActionError(action: string, error: unknown): string {
|
||||
const detail = toFriendlyError(error);
|
||||
return `${action}:${detail}`;
|
||||
}
|
||||
|
||||
31
terminal/apps/web/src/utils/runtimeConfig.ts
Normal file
31
terminal/apps/web/src/utils/runtimeConfig.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export interface RuntimeConfig {
|
||||
gatewayUrl?: string;
|
||||
gatewayToken?: string;
|
||||
selectedServerId?: string;
|
||||
servers?: unknown;
|
||||
}
|
||||
|
||||
let runtimeConfigPromise: Promise<RuntimeConfig | null> | null = null;
|
||||
|
||||
export async function loadRuntimeConfig(): Promise<RuntimeConfig | null> {
|
||||
if (runtimeConfigPromise) return runtimeConfigPromise;
|
||||
runtimeConfigPromise = (async () => {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const response = await fetch("/terminal.config.json", { cache: "no-store" });
|
||||
if (!response.ok) return null;
|
||||
const raw = await response.json() as unknown;
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const obj = raw as Record<string, unknown>;
|
||||
return {
|
||||
gatewayUrl: typeof obj.gatewayUrl === "string" ? obj.gatewayUrl : undefined,
|
||||
gatewayToken: typeof obj.gatewayToken === "string" ? obj.gatewayToken : undefined,
|
||||
selectedServerId: typeof obj.selectedServerId === "string" ? obj.selectedServerId : undefined,
|
||||
servers: obj.servers
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
return runtimeConfigPromise;
|
||||
}
|
||||
19
terminal/apps/web/tsconfig.json
Normal file
19
terminal/apps/web/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"types": ["vite/client"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue", "vitest.config.ts", "vite.config.ts"]
|
||||
}
|
||||
53
terminal/apps/web/vite.config.ts
Normal file
53
terminal/apps/web/vite.config.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
import fs from "node:fs";
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
|
||||
const DEV_HOST = "0.0.0.0";
|
||||
const DEV_PORT = 5173;
|
||||
const DEV_PUBLIC_HOST = "shell.biboer.cn";
|
||||
const DEV_CERT_PATH = "/Users/gavin/.acme.sh/shell.biboer.cn_ecc/fullchain.cer";
|
||||
const DEV_KEY_PATH = "/Users/gavin/.acme.sh/shell.biboer.cn_ecc/shell.biboer.cn.key";
|
||||
|
||||
function resolveDevHttpsConfig() {
|
||||
// 优先复用 acme.sh 证书,确保本地开发服务直接以 HTTPS 暴露。
|
||||
if (!fs.existsSync(DEV_CERT_PATH) || !fs.existsSync(DEV_KEY_PATH)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
cert: fs.readFileSync(DEV_CERT_PATH),
|
||||
key: fs.readFileSync(DEV_KEY_PATH)
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
host: DEV_HOST,
|
||||
port: DEV_PORT,
|
||||
strictPort: true,
|
||||
https: resolveDevHttpsConfig(),
|
||||
// 允许通过外网域名访问 dev server(含 HMR websocket 握手)。
|
||||
allowedHosts: [DEV_PUBLIC_HOST],
|
||||
// 明确 HMR 走外网域名,避免客户端回退到 localhost 导致连接拒绝。
|
||||
hmr: {
|
||||
protocol: "wss",
|
||||
host: DEV_PUBLIC_HOST,
|
||||
clientPort: DEV_PORT,
|
||||
port: DEV_PORT
|
||||
},
|
||||
proxy: {
|
||||
"/ws/terminal": {
|
||||
target: "ws://127.0.0.1:8787",
|
||||
ws: true,
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": fileURLToPath(new URL("./src", import.meta.url))
|
||||
}
|
||||
}
|
||||
});
|
||||
11
terminal/apps/web/vitest.config.ts
Normal file
11
terminal/apps/web/vitest.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig, mergeConfig } from "vitest/config";
|
||||
import viteConfig from "./vite.config";
|
||||
|
||||
export default mergeConfig(
|
||||
viteConfig,
|
||||
defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
})
|
||||
);
|
||||
1054
terminal/docs/xterm-standalone-lab-plan-2026-03-01.md
Normal file
1054
terminal/docs/xterm-standalone-lab-plan-2026-03-01.md
Normal file
File diff suppressed because it is too large
Load Diff
3327
terminal/package-lock.json
generated
Normal file
3327
terminal/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
14
terminal/package.json
Normal file
14
terminal/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "terminal-workspace",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"apps/web",
|
||||
"packages/terminal-core"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "npm run dev -w apps/web",
|
||||
"build": "npm run build -w packages/terminal-core && npm run build -w apps/web",
|
||||
"test": "npm run test -w packages/terminal-core",
|
||||
"typecheck": "npm run typecheck -w packages/terminal-core && npm run typecheck -w apps/web"
|
||||
}
|
||||
}
|
||||
26
terminal/packages/terminal-core/package.json
Normal file
26
terminal/packages/terminal-core/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@remoteconn/terminal-core",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"miniprogram": "dist-miniprogram/index.cjs",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"build:miniprogram": "tsc -p tsconfig.json --module commonjs --moduleResolution node --outDir dist-miniprogram --declaration false --sourceMap false",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"typecheck:strict": "tsc -p tsconfig.json --lib esnext --noEmit",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"test": "../../node_modules/.bin/vitest run --passWithNoTests"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.3.3",
|
||||
"eslint": "^10.0.2",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
67
terminal/packages/terminal-core/src/index.ts
Normal file
67
terminal/packages/terminal-core/src/index.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* @remoteconn/terminal-core
|
||||
* 零 DOM 跨平台终端核心库 — 供 Web/iOS/微信小程序三端共用。
|
||||
*/
|
||||
|
||||
// ── 基础类型 ──────────────────────────────────────────────────────────────────
|
||||
export type {
|
||||
SessionState,
|
||||
ConnectParams,
|
||||
TerminalCredential,
|
||||
FrameMeta,
|
||||
TransportEvent,
|
||||
TransportEventListener,
|
||||
ColorValue,
|
||||
CharAttribs,
|
||||
TerminalCell,
|
||||
TerminalLine,
|
||||
CursorState,
|
||||
TerminalSnapshot,
|
||||
} from "./types";
|
||||
|
||||
export {
|
||||
DEFAULT_FG,
|
||||
DEFAULT_BG,
|
||||
DEFAULT_COLOR,
|
||||
FLAG_BOLD,
|
||||
FLAG_DIM,
|
||||
FLAG_ITALIC,
|
||||
FLAG_UNDERLINE,
|
||||
FLAG_BLINK,
|
||||
FLAG_INVERSE,
|
||||
FLAG_INVISIBLE,
|
||||
FLAG_STRIKETHROUGH,
|
||||
FLAG_OVERLINE,
|
||||
makeDefaultAttribs,
|
||||
copyAttribs,
|
||||
makeBlankCell,
|
||||
} from "./types";
|
||||
|
||||
// ── 接口 ──────────────────────────────────────────────────────────────────────
|
||||
export type { TerminalTransport } from "./transport/terminalTransport";
|
||||
export type { RendererAdapter } from "./renderer/rendererAdapter";
|
||||
export type { IInputSource, InputEventMap, KeyPayload, InputPayload, PastePayload, CompositionPayload } from "./input/IInputSource";
|
||||
export type { IMeasureAdapter } from "./layout/IMeasureAdapter";
|
||||
|
||||
// ── Session ───────────────────────────────────────────────────────────────────
|
||||
export { SessionMachine, canTransition, assertTransition } from "./session/sessionMachine";
|
||||
|
||||
// ── Core ──────────────────────────────────────────────────────────────────────
|
||||
export { TerminalCore } from "./renderer/terminalCore";
|
||||
export { OutputBuffer } from "./renderer/outputBuffer";
|
||||
export type { OutputBufferOptions } from "./renderer/outputBuffer";
|
||||
|
||||
// ── Input ─────────────────────────────────────────────────────────────────────
|
||||
export { InputBridge } from "./input/inputBridge";
|
||||
export type { InputBridgeOptions, CursorKeyMode } from "./input/inputBridge";
|
||||
export { ImeController } from "./input/imeController";
|
||||
export type { ImeState } from "./input/imeController";
|
||||
|
||||
// ── Layout ────────────────────────────────────────────────────────────────────
|
||||
export { calcSize, sizeChanged } from "./layout/sizeCalculator";
|
||||
export type { SizeResult } from "./layout/sizeCalculator";
|
||||
export { toGlobalRow, isInBand, textCursorPos, charDisplayWidth } from "./layout/cursorMath";
|
||||
export type { CursorPos } from "./layout/cursorMath";
|
||||
|
||||
// ── Sanitize ──────────────────────────────────────────────────────────────────
|
||||
export { sanitizeTerminalOutput } from "./sanitize/terminalSanitizer";
|
||||
42
terminal/packages/terminal-core/src/input/IInputSource.ts
Normal file
42
terminal/packages/terminal-core/src/input/IInputSource.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* IInputSource — 平台输入事件源抽象接口(Web DOM / 小程序 bindinput)。
|
||||
* 所有实现在 apps/* 中,packages/terminal-core 只定义接口。
|
||||
*/
|
||||
|
||||
export interface KeyPayload {
|
||||
key: string;
|
||||
code: string;
|
||||
ctrlKey: boolean;
|
||||
altKey: boolean;
|
||||
shiftKey: boolean;
|
||||
metaKey: boolean;
|
||||
isComposing: boolean;
|
||||
}
|
||||
|
||||
export interface InputPayload {
|
||||
data: string;
|
||||
isComposing: boolean;
|
||||
}
|
||||
|
||||
export interface PastePayload {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface CompositionPayload {
|
||||
data: string;
|
||||
}
|
||||
|
||||
export type InputEventMap = {
|
||||
key: KeyPayload;
|
||||
input: InputPayload;
|
||||
paste: PastePayload;
|
||||
compositionstart: CompositionPayload;
|
||||
compositionend: CompositionPayload;
|
||||
};
|
||||
|
||||
export interface IInputSource {
|
||||
on<K extends keyof InputEventMap>(
|
||||
event: K,
|
||||
cb: (payload: InputEventMap[K]) => void
|
||||
): () => void;
|
||||
}
|
||||
81
terminal/packages/terminal-core/src/input/imeController.ts
Normal file
81
terminal/packages/terminal-core/src/input/imeController.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* ImeController — IME 输入法状态机(纯逻辑,零 DOM 事件依赖)。
|
||||
*
|
||||
* 状态:
|
||||
* idle → composing(compositionstart)
|
||||
* composing → commit_pending(compositionend)
|
||||
* commit_pending → idle(提交完成或超时)
|
||||
*
|
||||
* 平台层(Web/小程序)负责订阅 compositionstart/end 后调用此 controller 的方法。
|
||||
*/
|
||||
|
||||
export type ImeState = "idle" | "composing" | "commit_pending";
|
||||
|
||||
export class ImeController {
|
||||
private _state: ImeState = "idle";
|
||||
/** 当前组合字符串 */
|
||||
private _composingData = "";
|
||||
/** 防止 compositionend 后对应的 input 事件重复提交的保护窗口(ms) */
|
||||
private _commitGuardUntil = 0;
|
||||
/** 超时守卫 timer(无 DOM API — 由平台层通过注入的 setTimeoutFn 驱动) */
|
||||
private _guardTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly setTimeoutFn: (fn: () => void, ms: number) => ReturnType<typeof setTimeout>,
|
||||
private readonly clearTimeoutFn: (id: ReturnType<typeof setTimeout>) => void,
|
||||
/** 超时自动复位(ms),默认 2000 */
|
||||
private readonly timeoutMs = 2000
|
||||
) {}
|
||||
|
||||
get state(): ImeState { return this._state; }
|
||||
get composingData(): string { return this._composingData; }
|
||||
get isComposing(): boolean { return this._state !== "idle"; }
|
||||
|
||||
onCompositionStart(data: string): void {
|
||||
this._clearGuard();
|
||||
this._state = "composing";
|
||||
this._composingData = data;
|
||||
// 安全超时:若 compositionend 迟迟不来,自动复位
|
||||
this._guardTimer = this.setTimeoutFn(() => {
|
||||
if (this._state !== "idle") {
|
||||
this._state = "idle";
|
||||
this._composingData = "";
|
||||
}
|
||||
}, this.timeoutMs);
|
||||
}
|
||||
|
||||
onCompositionEnd(data: string): string {
|
||||
this._clearGuard();
|
||||
this._state = "commit_pending";
|
||||
this._composingData = data;
|
||||
// 设置提交保护窗口(防 input 事件重复触发)
|
||||
this._commitGuardUntil = Date.now() + 50;
|
||||
const committed = data;
|
||||
this._state = "idle";
|
||||
this._composingData = "";
|
||||
return committed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断当前 input 事件是否应被 IME 消耗(不应单独发送给 Transport)。
|
||||
*/
|
||||
shouldConsumeInputEvent(): boolean {
|
||||
if (this._state === "composing") return true;
|
||||
if (Date.now() < this._commitGuardUntil) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this._clearGuard();
|
||||
this._state = "idle";
|
||||
this._composingData = "";
|
||||
this._commitGuardUntil = 0;
|
||||
}
|
||||
|
||||
private _clearGuard(): void {
|
||||
if (this._guardTimer !== null) {
|
||||
this.clearTimeoutFn(this._guardTimer);
|
||||
this._guardTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { InputBridge } from "./inputBridge";
|
||||
|
||||
describe("InputBridge.mapPaste", () => {
|
||||
it("统一换行为 LF,避免将多行粘贴转换为 CR", () => {
|
||||
const bridge = new InputBridge({ bracketedPaste: false });
|
||||
const input = "line1\r\nline2\nline3\rline4";
|
||||
expect(bridge.mapPaste(input)).toBe("line1\nline2\nline3\nline4");
|
||||
});
|
||||
|
||||
it("开启 bracketed paste 时包裹 ESC[200~/ESC[201~", () => {
|
||||
const bridge = new InputBridge({ bracketedPaste: true });
|
||||
expect(bridge.mapPaste("a\r\nb")).toBe("\x1b[200~a\nb\x1b[201~");
|
||||
});
|
||||
});
|
||||
173
terminal/packages/terminal-core/src/input/inputBridge.ts
Normal file
173
terminal/packages/terminal-core/src/input/inputBridge.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* InputBridge — 键序列映射纯逻辑(零 DOM 事件依赖)。
|
||||
*
|
||||
* 负责将逻辑按键/文本映射为发送给 Transport 的 VT 字节串。
|
||||
* 对齐 xterm.js@5.3.0 src/common/input/Keyboard.ts 行为。
|
||||
*/
|
||||
|
||||
/** 应用光标键模式(DECCKM ?1h 激活时使用 SS3 序列) */
|
||||
export type CursorKeyMode = "normal" | "application";
|
||||
/** bracketed paste 模式 */
|
||||
export type BracketedPasteMode = boolean;
|
||||
|
||||
export interface InputBridgeOptions {
|
||||
cursorKeyMode?: CursorKeyMode;
|
||||
bracketedPaste?: BracketedPasteMode;
|
||||
}
|
||||
|
||||
export class InputBridge {
|
||||
private cursorKeyMode: CursorKeyMode;
|
||||
private bracketedPaste: BracketedPasteMode;
|
||||
|
||||
constructor(opts: InputBridgeOptions = {}) {
|
||||
this.cursorKeyMode = opts.cursorKeyMode ?? "normal";
|
||||
this.bracketedPaste = opts.bracketedPaste ?? false;
|
||||
}
|
||||
|
||||
setCursorKeyMode(mode: CursorKeyMode): void { this.cursorKeyMode = mode; }
|
||||
setBracketedPaste(enabled: boolean): void { this.bracketedPaste = enabled; }
|
||||
|
||||
/**
|
||||
* 将 KeyboardEvent 语义映射为 VT 字节串。
|
||||
* 返回 null 表示该按键不产生终端输入(如单纯 modifier key)。
|
||||
*/
|
||||
mapKey(
|
||||
key: string,
|
||||
code: string,
|
||||
ctrlKey: boolean,
|
||||
altKey: boolean,
|
||||
shiftKey: boolean,
|
||||
metaKey: boolean
|
||||
): string | null {
|
||||
// --- ASCII 控制字符 ---
|
||||
if (ctrlKey && !altKey && !metaKey) {
|
||||
const ctrl = mapCtrlKey(key, code, shiftKey);
|
||||
if (ctrl !== null) return ctrl;
|
||||
}
|
||||
|
||||
// --- 功能键 ---
|
||||
const fn = mapFunctionKey(key, code, shiftKey, ctrlKey, altKey, this.cursorKeyMode);
|
||||
if (fn !== null) return fn;
|
||||
|
||||
// --- Alt/Meta 前缀 ---
|
||||
if ((altKey || metaKey) && key.length === 1) {
|
||||
return `\x1b${key}`;
|
||||
}
|
||||
|
||||
// --- 普通可打印字符 ---
|
||||
if (key.length > 0 && !key.startsWith("Dead") && key !== "Unidentified") {
|
||||
// 过滤掉单纯的 modifier key 名称
|
||||
if (MODIFIER_KEY_NAMES.has(key)) return null;
|
||||
return key;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将粘贴文本处理为终端输入字节串:
|
||||
* - 行结束统一归一为 LF(避免多行粘贴被当作多次回车)
|
||||
* - bracketed paste 模式下用 ESC[200~ / ESC[201~ 包裹
|
||||
*/
|
||||
mapPaste(text: string): string {
|
||||
// 统一处理 CRLF / CR / LF,确保多平台粘贴行为一致。
|
||||
const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
if (this.bracketedPaste) {
|
||||
return `\x1b[200~${normalized}\x1b[201~`;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 内部工具 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const MODIFIER_KEY_NAMES = new Set([
|
||||
"Control", "Shift", "Alt", "Meta",
|
||||
"CapsLock", "NumLock", "ScrollLock",
|
||||
"OS", "Win",
|
||||
]);
|
||||
|
||||
function mapCtrlKey(key: string, _code: string, shiftKey: boolean): string | null {
|
||||
// CTRL + A-Z → 0x01-0x1A
|
||||
if (key.length === 1) {
|
||||
const upper = key.toUpperCase();
|
||||
const cp = upper.charCodeAt(0);
|
||||
if (cp >= 65 && cp <= 90) {
|
||||
return String.fromCharCode(cp - 64);
|
||||
}
|
||||
}
|
||||
// 特殊组合
|
||||
switch (key) {
|
||||
case "@": return "\x00";
|
||||
case "[": return "\x1b";
|
||||
case "\\": return "\x1c";
|
||||
case "]": return "\x1d";
|
||||
case "^": return "\x1e";
|
||||
case "_": return "\x1f";
|
||||
case " ": return "\x00";
|
||||
case "Enter": return shiftKey ? "\n" : "\r";
|
||||
case "Backspace": return "\x08";
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
function mapFunctionKey(
|
||||
key: string,
|
||||
_code: string,
|
||||
shiftKey: boolean,
|
||||
ctrlKey: boolean,
|
||||
_altKey: boolean,
|
||||
cursorKeyMode: CursorKeyMode
|
||||
): string | null {
|
||||
const app = cursorKeyMode === "application";
|
||||
|
||||
switch (key) {
|
||||
// --- 光标键 ---
|
||||
case "ArrowUp": return modifyArrow(app ? "\x1bOA" : "\x1b[A", shiftKey, ctrlKey);
|
||||
case "ArrowDown": return modifyArrow(app ? "\x1bOB" : "\x1b[B", shiftKey, ctrlKey);
|
||||
case "ArrowRight": return modifyArrow(app ? "\x1bOC" : "\x1b[C", shiftKey, ctrlKey);
|
||||
case "ArrowLeft": return modifyArrow(app ? "\x1bOD" : "\x1b[D", shiftKey, ctrlKey);
|
||||
|
||||
// --- Enter / Backspace / Tab ---
|
||||
case "Enter": return "\r";
|
||||
case "Backspace": return ctrlKey ? "\x08" : "\x7f";
|
||||
case "Tab": return shiftKey ? "\x1b[Z" : "\t";
|
||||
case "Escape": return "\x1b";
|
||||
case "Delete": return "\x1b[3~";
|
||||
case "Insert": return "\x1b[2~";
|
||||
|
||||
// --- Home / End ---
|
||||
case "Home": return ctrlKey ? "\x1b[1;5H" : app ? "\x1bOH" : "\x1b[H";
|
||||
case "End": return ctrlKey ? "\x1b[1;5F" : app ? "\x1bOF" : "\x1b[F";
|
||||
|
||||
// --- Page Up / Page Down ---
|
||||
case "PageUp": return shiftKey ? "\x1b[5;2~" : "\x1b[5~";
|
||||
case "PageDown": return shiftKey ? "\x1b[6;2~" : "\x1b[6~";
|
||||
|
||||
// --- F1-F12 ---
|
||||
case "F1": return "\x1bOP";
|
||||
case "F2": return "\x1bOQ";
|
||||
case "F3": return "\x1bOR";
|
||||
case "F4": return "\x1bOS";
|
||||
case "F5": return "\x1b[15~";
|
||||
case "F6": return "\x1b[17~";
|
||||
case "F7": return "\x1b[18~";
|
||||
case "F8": return "\x1b[19~";
|
||||
case "F9": return "\x1b[20~";
|
||||
case "F10": return "\x1b[21~";
|
||||
case "F11": return "\x1b[23~";
|
||||
case "F12": return "\x1b[24~";
|
||||
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
function modifyArrow(base: string, shiftKey: boolean, ctrlKey: boolean): string {
|
||||
if (ctrlKey && shiftKey) {
|
||||
// replace final letter with modifier param
|
||||
return base.slice(0, -1) + ";6" + base.slice(-1);
|
||||
}
|
||||
if (ctrlKey) return base.slice(0, -1) + ";5" + base.slice(-1);
|
||||
if (shiftKey) return base.slice(0, -1) + ";2" + base.slice(-1);
|
||||
return base;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* IMeasureAdapter — 平台测量接口(字符/容器尺寸获取)。
|
||||
* Web 由 ResizeObserver + Canvas 实现;小程序由 wx.createSelectorQuery 实现。
|
||||
*/
|
||||
export interface IMeasureAdapter {
|
||||
/** 测量单个等宽字符宽/高(像素)*/
|
||||
measureChar(): { widthPx: number; heightPx: number };
|
||||
|
||||
/** 测量终端容器内部可用宽/高(已去除 padding,像素)*/
|
||||
measureContainer(): { widthPx: number; heightPx: number };
|
||||
|
||||
/** 订阅容器尺寸变化;返回取消订阅函数 */
|
||||
onResize(cb: () => void): () => void;
|
||||
}
|
||||
80
terminal/packages/terminal-core/src/layout/cursorMath.ts
Normal file
80
terminal/packages/terminal-core/src/layout/cursorMath.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* cursorMath — 光标坐标纯算法工具。
|
||||
* 零 DOM 依赖。区分"终端光标"(VT core 语义)与"输入光标"(输入框 selectionStart)。
|
||||
*/
|
||||
|
||||
export interface CursorPos { row: number; col: number; }
|
||||
|
||||
/** 将终端光标视口坐标转为全局行号 */
|
||||
export function toGlobalRow(baseY: number, cursorY: number): number {
|
||||
return baseY + cursorY;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断触摸行是否在光标激活带内。
|
||||
* activationRadius 建议 2 行。
|
||||
*/
|
||||
export function isInBand(touchRow: number, cursorGlobalRow: number, activationRadius = 2): boolean {
|
||||
return Math.abs(touchRow - cursorGlobalRow) <= activationRadius;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 textarea 文本推导输入框光标位置((row, col) 坐标,0-based)。
|
||||
* 纯函数,O(n) 扫描文本。
|
||||
*/
|
||||
export function textCursorPos(
|
||||
text: string,
|
||||
selectionStart: number,
|
||||
cols: number
|
||||
): CursorPos {
|
||||
let row = 0;
|
||||
let col = 0;
|
||||
for (let i = 0; i < selectionStart && i < text.length; i++) {
|
||||
const ch = text[i] ?? "";
|
||||
if (ch === "\n") {
|
||||
row++;
|
||||
col = 0;
|
||||
} else {
|
||||
const w = charDisplayWidth(ch);
|
||||
col += w;
|
||||
if (col >= cols) {
|
||||
// 自动折行
|
||||
row += Math.floor(col / cols);
|
||||
col = col % cols;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { row, col };
|
||||
}
|
||||
|
||||
/**
|
||||
* 字符显示宽度(ASCII=1, CJK/全角=2, 控制字符=0)。
|
||||
* 使用简化的 East Asian Width 判断;完整版可插入 unicode-east-asian-width 库。
|
||||
*/
|
||||
export function charDisplayWidth(ch: string): 0 | 1 | 2 {
|
||||
if (!ch) return 0;
|
||||
const cp = ch.codePointAt(0) ?? 0;
|
||||
if (cp < 0x20 || (cp >= 0x7f && cp < 0xa0)) return 0; // 控制字符
|
||||
if (isWide(cp)) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function isWide(cp: number): boolean {
|
||||
// GB2312/CJK 基本覆盖范围
|
||||
if (cp >= 0x1100 && cp <= 0x115F) return true; // Hangul Jamo
|
||||
if (cp === 0x2329 || cp === 0x232A) return true;
|
||||
if (cp >= 0x2E80 && cp <= 0x303E) return true; // CJK Radicals
|
||||
if (cp >= 0x3040 && cp <= 0x33FF) return true; // Japanese
|
||||
if (cp >= 0x3400 && cp <= 0x4DBF) return true; // CJK Ext A
|
||||
if (cp >= 0x4E00 && cp <= 0x9FFF) return true; // CJK Unified
|
||||
if (cp >= 0xA000 && cp <= 0xA4CF) return true; // Yi
|
||||
if (cp >= 0xAC00 && cp <= 0xD7AF) return true; // Hangul Syllables
|
||||
if (cp >= 0xF900 && cp <= 0xFAFF) return true; // CJK Compatibility
|
||||
if (cp >= 0xFE10 && cp <= 0xFE19) return true; // Vertical forms
|
||||
if (cp >= 0xFE30 && cp <= 0xFE6F) return true; // CJK Compatibility Small
|
||||
if (cp >= 0xFF00 && cp <= 0xFF60) return true; // Fullwidth
|
||||
if (cp >= 0xFFE0 && cp <= 0xFFE6) return true;
|
||||
if (cp >= 0x1F300 && cp <= 0x1F9FF) return true; // Emoji
|
||||
if (cp >= 0x20000 && cp <= 0x2FFFD) return true; // CJK Ext B-F
|
||||
return false;
|
||||
}
|
||||
27
terminal/packages/terminal-core/src/layout/sizeCalculator.ts
Normal file
27
terminal/packages/terminal-core/src/layout/sizeCalculator.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* sizeCalculator — 纯算法:px → cols/rows。
|
||||
* 零 DOM 依赖,所有 DOM 测量由平台的 IMeasureAdapter 注入。
|
||||
*/
|
||||
|
||||
export interface SizeResult {
|
||||
cols: number;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
export function calcSize(
|
||||
containerWidthPx: number,
|
||||
containerHeightPx: number,
|
||||
charWidthPx: number,
|
||||
lineHeightPx: number
|
||||
): SizeResult {
|
||||
const cols = Math.max(20, Math.floor(containerWidthPx / Math.max(1, charWidthPx)));
|
||||
const rows = Math.max(8, Math.floor(containerHeightPx / Math.max(1, lineHeightPx)));
|
||||
return { cols, rows };
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断尺寸变化是否超过阈值(>= 1 col 或 >= 1 row),避免抖动触发频繁 resize。
|
||||
*/
|
||||
export function sizeChanged(a: SizeResult, b: SizeResult): boolean {
|
||||
return Math.abs(a.cols - b.cols) + Math.abs(a.rows - b.rows) >= 1;
|
||||
}
|
||||
75
terminal/packages/terminal-core/src/renderer/outputBuffer.ts
Normal file
75
terminal/packages/terminal-core/src/renderer/outputBuffer.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* OutputBuffer — 终端输出缓冲区。
|
||||
* 双阈值(条目数 + 字节数)裁剪,保证低端设备不 OOM。
|
||||
*/
|
||||
|
||||
export interface OutputBufferOptions {
|
||||
maxEntries?: number; // 默认 500
|
||||
maxBytes?: number; // 默认 512 * 1024 (512KB)
|
||||
}
|
||||
|
||||
export class OutputBuffer {
|
||||
private entries: string[] = [];
|
||||
private totalBytes = 0;
|
||||
private readonly maxEntries: number;
|
||||
private readonly maxBytes: number;
|
||||
|
||||
/** 缓冲区修订号:每次 push 后递增,供外部脏检测 */
|
||||
public revision = 0;
|
||||
|
||||
constructor(opts: OutputBufferOptions = {}) {
|
||||
this.maxEntries = Math.max(200, opts.maxEntries ?? 500);
|
||||
this.maxBytes = Math.max(65536, opts.maxBytes ?? 512 * 1024);
|
||||
}
|
||||
|
||||
push(data: string): void {
|
||||
if (data.length === 0) return;
|
||||
this.entries.push(data);
|
||||
this.totalBytes += data.length * 2; // UTF-16 字符 ≈ 2 字节估算
|
||||
|
||||
this.trim();
|
||||
this.revision++;
|
||||
}
|
||||
|
||||
/** 获取全部条目(用于重放) */
|
||||
getAll(): readonly string[] {
|
||||
return this.entries;
|
||||
}
|
||||
|
||||
/** 获取从指定修订号之后新增的条目(增量重放辅助) */
|
||||
getSince(revision: number): string {
|
||||
// 简化实现:revision 不足时全量重放
|
||||
if (revision <= 0) return this.entries.join("");
|
||||
// 粗略计算需要从后面取多少条目
|
||||
// 这里保守地返回全量;如需精确增量可增加 revision→index 映射表
|
||||
return this.entries.join("");
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.entries = [];
|
||||
this.totalBytes = 0;
|
||||
this.revision++;
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.entries.length;
|
||||
}
|
||||
|
||||
get bytes(): number {
|
||||
return this.totalBytes;
|
||||
}
|
||||
|
||||
private trim(): void {
|
||||
// 先按条目裁剪
|
||||
while (this.entries.length > this.maxEntries) {
|
||||
const removed = this.entries.shift();
|
||||
if (removed !== undefined) this.totalBytes -= removed.length * 2;
|
||||
}
|
||||
// 再按字节裁剪
|
||||
while (this.totalBytes > this.maxBytes && this.entries.length > 1) {
|
||||
const removed = this.entries.shift();
|
||||
if (removed !== undefined) this.totalBytes -= removed.length * 2;
|
||||
}
|
||||
if (this.totalBytes < 0) this.totalBytes = 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { TerminalSnapshot } from "../types";
|
||||
|
||||
/**
|
||||
* RendererAdapter — 渲染层抽象接口(每个平台各自实现)。
|
||||
* 渲染器只读 snapshot,禁止回写 TerminalCore 内部状态。
|
||||
*/
|
||||
export interface RendererAdapter {
|
||||
/**
|
||||
* 挂载到平台容器。
|
||||
* Web: container 为 HTMLElement;
|
||||
* 小程序: 由 terminal-core-view 组件自行管理,传 null。
|
||||
*/
|
||||
mount(container: unknown): void;
|
||||
|
||||
/** 增量写入 VT 字节流(内部转发给 TerminalCore.write,触发重新渲染) */
|
||||
write(data: string): void;
|
||||
|
||||
/** 通知渲染器终端尺寸已变化 */
|
||||
resize(cols: number, rows: number): void;
|
||||
|
||||
/**
|
||||
* 全量重放快照(模式切换时调用,将渲染器状态同步到最新 snapshot)。
|
||||
*/
|
||||
applySnapshot(snapshot: TerminalSnapshot): void;
|
||||
|
||||
/** 销毁渲染器,释放 DOM/Canvas/定时器等资源 */
|
||||
dispose(): void;
|
||||
}
|
||||
1166
terminal/packages/terminal-core/src/renderer/terminalCore.ts
Normal file
1166
terminal/packages/terminal-core/src/renderer/terminalCore.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* TerminalSanitizer — 过滤 stdout/stderr 中会干扰渲染的噪音序列。
|
||||
*
|
||||
* 目前仅过滤同步更新模式(DECSM ?2026 = DECRQM-like sync-update),
|
||||
* 以及其他已知会在某些 SSH 服务端发送但 terminal-core 不处理的序列。
|
||||
* 不得过滤正常 CSI/OSC 序列,否则会破坏 TUI 程序显示。
|
||||
*/
|
||||
|
||||
/** 同步更新开始/结束序列(DEC private: ?2026h / ?2026l) */
|
||||
const SYNC_UPDATE_RE = /\x1b\[\?2026[hl]/g;
|
||||
/** Bracketed paste 开关(部分链路丢失 ESC 后会残留 '?2004h/l' 文本) */
|
||||
const BRACKETED_PASTE_RE = /(?:\x1b\[)?\?2004[hl]/g;
|
||||
|
||||
/** XTPUSHCOLORS / XTPOPCOLORS(Ps=10/11)—— terminal-core 不处理,安全丢弃 */
|
||||
const XTPUSH_POP_COLORS_RE = /\x1b\[(?:10|11)m/g;
|
||||
|
||||
export function sanitizeTerminalOutput(data: string): string {
|
||||
return data
|
||||
.replace(SYNC_UPDATE_RE, "")
|
||||
.replace(BRACKETED_PASTE_RE, "")
|
||||
.replace(XTPUSH_POP_COLORS_RE, "");
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { SessionState } from "../types";
|
||||
|
||||
/** 合法状态迁移表 */
|
||||
const TRANSITIONS: Record<SessionState, SessionState[]> = {
|
||||
idle: ["connecting", "disconnected"],
|
||||
connecting: ["auth_pending", "error", "disconnected"],
|
||||
auth_pending: ["connected", "error", "disconnected"],
|
||||
connected: ["reconnecting", "disconnected", "error"],
|
||||
reconnecting: ["connected", "error", "disconnected"],
|
||||
disconnected: ["connecting", "idle"],
|
||||
error: ["connecting", "disconnected"],
|
||||
};
|
||||
|
||||
export function canTransition(from: SessionState, to: SessionState): boolean {
|
||||
return (TRANSITIONS[from] ?? []).includes(to);
|
||||
}
|
||||
|
||||
export function assertTransition(from: SessionState, to: SessionState): void {
|
||||
if (!canTransition(from, to)) {
|
||||
throw new Error(`非法状态跳转: ${from} → ${to}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SessionMachine — 会话生命周期状态机。
|
||||
* 纯状态逻辑,无任何副作用,零 DOM 依赖。
|
||||
*/
|
||||
export class SessionMachine {
|
||||
private _state: SessionState = "idle";
|
||||
private listeners = new Set<(state: SessionState) => void>();
|
||||
|
||||
get state(): SessionState {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
transition(to: SessionState): void {
|
||||
assertTransition(this._state, to);
|
||||
this._state = to;
|
||||
for (const fn of this.listeners) {
|
||||
fn(this._state);
|
||||
}
|
||||
}
|
||||
|
||||
tryTransition(to: SessionState): boolean {
|
||||
if (!canTransition(this._state, to)) return false;
|
||||
this.transition(to);
|
||||
return true;
|
||||
}
|
||||
|
||||
onChange(fn: (state: SessionState) => void): () => void {
|
||||
this.listeners.add(fn);
|
||||
return () => this.listeners.delete(fn);
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this._state = "idle";
|
||||
for (const fn of this.listeners) {
|
||||
fn(this._state);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ConnectParams, FrameMeta, SessionState, TransportEventListener } from "../types";
|
||||
|
||||
/**
|
||||
* TerminalTransport — 网络层抽象接口(三端各自实现)。
|
||||
* 此接口在 packages/terminal-core 定义;apps/web 现有实现兼容此签名。
|
||||
*/
|
||||
export interface TerminalTransport {
|
||||
connect(params: ConnectParams): Promise<void>;
|
||||
send(data: string, meta?: FrameMeta): Promise<void>;
|
||||
resize(cols: number, rows: number): Promise<void>;
|
||||
disconnect(reason?: string): Promise<void>;
|
||||
/** 返回取消订阅函数 */
|
||||
on(listener: TransportEventListener): () => void;
|
||||
getState(): SessionState;
|
||||
}
|
||||
149
terminal/packages/terminal-core/src/types.ts
Normal file
149
terminal/packages/terminal-core/src/types.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* 所有平台共用的基础类型定义。
|
||||
* 此文件不得引入任何 DOM/BOM API,不得依赖 @remoteconn/shared 或 apps/* 模块。
|
||||
*/
|
||||
|
||||
// ── 会话状态 ───────────────────────────────────────────────────────────────────
|
||||
export type SessionState =
|
||||
| "idle"
|
||||
| "connecting"
|
||||
| "auth_pending"
|
||||
| "connected"
|
||||
| "reconnecting"
|
||||
| "disconnected"
|
||||
| "error";
|
||||
|
||||
// ── 连接参数 ───────────────────────────────────────────────────────────────────
|
||||
export interface ConnectParams {
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
credential: TerminalCredential;
|
||||
knownHostFingerprint?: string;
|
||||
cols: number;
|
||||
rows: number;
|
||||
clientSessionKey?: string;
|
||||
}
|
||||
|
||||
export type TerminalCredential =
|
||||
| { type: "password"; password: string }
|
||||
| { type: "privateKey"; privateKey: string; passphrase?: string }
|
||||
| { type: "certificate"; privateKey: string; certificate: string; passphrase?: string };
|
||||
|
||||
// ── 帧 meta(stdin 可选附加) ──────────────────────────────────────────────────
|
||||
export interface FrameMeta {
|
||||
source?: "keyboard" | "assist" | "paste";
|
||||
txnId?: string;
|
||||
}
|
||||
|
||||
// ── Transport 事件 ─────────────────────────────────────────────────────────────
|
||||
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 type TransportEventListener = (event: TransportEvent) => void;
|
||||
|
||||
// ── 颜色值 ────────────────────────────────────────────────────────────────────
|
||||
export interface ColorValue {
|
||||
mode: "default" | "p16" | "p256" | "rgb";
|
||||
/** p16: 0-15;p256: 0-255;rgb: 0xRRGGBB */
|
||||
value: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_FG: ColorValue = { mode: "default", value: 0 };
|
||||
export const DEFAULT_BG: ColorValue = { mode: "default", value: 0 };
|
||||
export const DEFAULT_COLOR: ColorValue = { mode: "default", value: 0 };
|
||||
|
||||
// ── SGR flags(bitmask) ───────────────────────────────────────────────────────
|
||||
export const FLAG_BOLD = 1 << 0;
|
||||
export const FLAG_DIM = 1 << 1;
|
||||
export const FLAG_ITALIC = 1 << 2;
|
||||
export const FLAG_UNDERLINE = 1 << 3;
|
||||
export const FLAG_BLINK = 1 << 4;
|
||||
export const FLAG_INVERSE = 1 << 5;
|
||||
export const FLAG_INVISIBLE = 1 << 6;
|
||||
export const FLAG_STRIKETHROUGH = 1 << 7;
|
||||
export const FLAG_OVERLINE = 1 << 8;
|
||||
|
||||
// ── 单元格 ────────────────────────────────────────────────────────────────────
|
||||
export interface TerminalCell {
|
||||
char: string; // 空单元格为 ' '
|
||||
width: 1 | 2; // CJK 宽字符占 2
|
||||
fg: ColorValue;
|
||||
bg: ColorValue;
|
||||
flags: number;
|
||||
underlineStyle: "none" | "single" | "double" | "curly" | "dotted" | "dashed";
|
||||
underlineColor: ColorValue;
|
||||
}
|
||||
|
||||
// ── 行 ────────────────────────────────────────────────────────────────────────
|
||||
export interface TerminalLine {
|
||||
cells: TerminalCell[];
|
||||
isWrapped: boolean;
|
||||
}
|
||||
|
||||
// ── 光标状态 ──────────────────────────────────────────────────────────────────
|
||||
export interface CursorState {
|
||||
x: number; // 列(0-based)
|
||||
y: number; // 行(视口相对,0-based)
|
||||
globalRow: number; // baseY + y(全局行号,供触摸激活带使用)
|
||||
visible: boolean; // DECTCEM
|
||||
}
|
||||
|
||||
// ── 快照(渲染器唯一输入数据结构) ─────────────────────────────────────────────
|
||||
export interface TerminalSnapshot {
|
||||
cols: number;
|
||||
rows: number;
|
||||
cursor: CursorState;
|
||||
lines: TerminalLine[]; // 长度 = rows,仅当前可视区
|
||||
title: string;
|
||||
revision: number; // 每次 write() 后递增
|
||||
isAlternateBuffer: boolean;
|
||||
}
|
||||
|
||||
// ── 字符属性(VT 内部使用) ───────────────────────────────────────────────────
|
||||
export interface CharAttribs {
|
||||
fg: ColorValue;
|
||||
bg: ColorValue;
|
||||
flags: number;
|
||||
underlineStyle: "none" | "single" | "double" | "curly" | "dotted" | "dashed";
|
||||
underlineColor: ColorValue;
|
||||
}
|
||||
|
||||
export function makeDefaultAttribs(): CharAttribs {
|
||||
return {
|
||||
fg: { mode: "default", value: 0 },
|
||||
bg: { mode: "default", value: 0 },
|
||||
flags: 0,
|
||||
underlineStyle: "none",
|
||||
underlineColor: { mode: "default", value: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
export function copyAttribs(a: CharAttribs): CharAttribs {
|
||||
return {
|
||||
fg: { ...a.fg },
|
||||
bg: { ...a.bg },
|
||||
flags: a.flags,
|
||||
underlineStyle: a.underlineStyle,
|
||||
underlineColor: { ...a.underlineColor },
|
||||
};
|
||||
}
|
||||
|
||||
export function makeBlankCell(attribs?: CharAttribs): TerminalCell {
|
||||
if (!attribs) {
|
||||
return { char: " ", width: 1, fg: DEFAULT_FG, bg: DEFAULT_BG, flags: 0, underlineStyle: "none", underlineColor: DEFAULT_COLOR };
|
||||
}
|
||||
return {
|
||||
char: " ", width: 1,
|
||||
fg: { ...attribs.fg },
|
||||
bg: { ...attribs.bg },
|
||||
flags: attribs.flags,
|
||||
underlineStyle: attribs.underlineStyle,
|
||||
underlineColor: { ...attribs.underlineColor },
|
||||
};
|
||||
}
|
||||
21
terminal/packages/terminal-core/tsconfig.json
Normal file
21
terminal/packages/terminal-core/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["esnext"],
|
||||
"strict": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noImplicitOverride": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
155
terminal/question-solved.md
Normal file
155
terminal/question-solved.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# 问题闭环记录(Question Solved)
|
||||
|
||||
本文档记录已确认问题的现象、根因、修复与回归要点,目标是避免重复踩坑。
|
||||
|
||||
## 目录
|
||||
|
||||
1. [2026-03-02:第三方输入法导致输入重复](#ime-dup)
|
||||
2. [2026-03-02:点击粘贴触发 BAD_REQUEST / invalid_union](#paste-invalid-union)
|
||||
3. [2026-03-02:多行粘贴被当作回车执行](#multiline-paste-enter)
|
||||
4. [2026-03-03:回车后首字母重复显示(`aasdf`)](#enter-leading-dup)
|
||||
5. [通用回归清单](#regression-checklist)
|
||||
|
||||
<a id="ime-dup"></a>
|
||||
## 2026-03-02:第三方输入法导致输入重复(`jk -> jkjk`,`测试 -> 测试测试`)
|
||||
|
||||
### 现象
|
||||
- 第三方输入法下输入 `jk` 显示 `jkjk`,输入 `测试` 显示 `测试测试`。
|
||||
- 原生输入法或英文输入模式正常。
|
||||
|
||||
### 根因
|
||||
- 同一文本同时走了 `keydown` 和 `input` 两条发送路径,导致双发。
|
||||
- 第三方输入法会在 `keydown` 阶段给出整段文本(`key="测试"` / `key="jk"`),旧逻辑误判后直接发送。
|
||||
|
||||
### 修复
|
||||
- 文件:`apps/web/src/terminal/components/TerminalInputBar.vue`
|
||||
- 策略:
|
||||
1. `keydown` 只处理控制键/功能键。
|
||||
2. 文本输入统一走 `input` 通道,保证单通道发送。
|
||||
3. `KEYDOWN_DIRECT_KEYS` 作为白名单,非白名单文本直接跳过。
|
||||
|
||||
### 验证
|
||||
- `ls`、`jk`、`测试` 均不重复。
|
||||
- `Enter`、`Ctrl+C` 等控制键行为保持正常。
|
||||
|
||||
### 自动化测试
|
||||
- 文件:`apps/web/src/terminal/input/inputPolicy.test.ts`
|
||||
- 覆盖文本键、功能键、组合键判定。
|
||||
|
||||
---
|
||||
|
||||
<a id="paste-invalid-union"></a>
|
||||
## 2026-03-02:点击粘贴触发 BAD_REQUEST / invalid_union
|
||||
|
||||
### 现象
|
||||
- 点击工具栏“粘贴”后出现 error,连接异常或重连。
|
||||
- 日志出现 `ws:error_frame code=BAD_REQUEST`,消息含 `invalid_union`。
|
||||
|
||||
### 根因
|
||||
- 网关 `stdin` 入参 schema 在现网存在差异;`paste` 携带 `meta` 会在部分环境触发 union 校验失败。
|
||||
|
||||
### 修复
|
||||
- 文件:`apps/web/src/terminal/stores/useTerminalStore.ts`
|
||||
- 策略:
|
||||
1. `paste` 分片发送时不再携带 `meta`。
|
||||
2. `keyboard/assist` 保持原有 `meta.source`。
|
||||
3. 粘贴分片大小从 `512` 调整为 `256`,降低单帧风险。
|
||||
|
||||
### 验证
|
||||
- 粘贴日志出现 `wireMetaSource: "none"`。
|
||||
- 不再出现 `BAD_REQUEST invalid_union`。
|
||||
|
||||
---
|
||||
|
||||
<a id="multiline-paste-enter"></a>
|
||||
## 2026-03-02:多行粘贴被当作回车执行
|
||||
|
||||
### 现象
|
||||
- 复制多行文本后粘贴进 shell,会被当作多次回车,导致命令提前执行。
|
||||
|
||||
### 根因
|
||||
- 旧粘贴逻辑将换行统一转为 `\r`(回车语义),不符合“多行粘贴应作为文本块输入”的预期。
|
||||
|
||||
### 修复
|
||||
- 文件:
|
||||
- `packages/terminal-core/src/input/inputBridge.ts`
|
||||
- `apps/web/src/terminal/components/TerminalInputBar.vue`
|
||||
- `apps/web/src/terminal/TerminalPage.vue`
|
||||
- 策略:
|
||||
1. `mapPaste` 将换行统一归一为 `\n`,不再转成 `\r`。
|
||||
2. Web 两条粘贴链路(原生 `paste`、工具栏“粘贴”)统一走 `InputBridge.mapPaste()`。
|
||||
3. 开启 bracketed paste(`\x1b[200~... \x1b[201~`),避免被解释为逐行回车执行。
|
||||
|
||||
### 验证
|
||||
- 粘贴多行文本默认不自动逐行执行。
|
||||
- 两条粘贴入口行为一致。
|
||||
|
||||
### 自动化测试
|
||||
- 文件:`packages/terminal-core/src/input/inputBridge.test.ts`
|
||||
- 覆盖:
|
||||
1. `CRLF/CR/LF` 统一归一为 `LF`。
|
||||
2. bracketed paste 包裹序列正确。
|
||||
|
||||
---
|
||||
|
||||
<a id="regression-checklist"></a>
|
||||
## 2026-03-02:软键盘弹出后光标行定位不准与收回后页面跳动问题
|
||||
|
||||
### 现象
|
||||
1. 终端在 iOS 移动设备上点击输入区弹出软键盘时,期望将光标(提示符行)定位到屏幕的距顶 1/4 处,但实际经常定位到键盘顶部或在屏幕中部乱跳。
|
||||
2. 软键盘收回时,期望恢复到键盘弹出前的状态,但页面内容经常回跳或者复位到屏幕底部。
|
||||
3. 终端输入文字(`autoFollow=true`)时,滚动条经常突然跳到屏幕中部,无法锁定在最底部。
|
||||
|
||||
### 根因
|
||||
1. **行高计算不准确:** 在 `textareaRenderer` 的旧版计算方式中,利用 `scrollHeight / totalLines` 试图估算出单行的 `lineHeight`。但这在遇到换行文字(如超宽命令占据多行 HTML)或者终端末尾包含空白补齐的 Null Lines 时,按比例换算的 `offsetPx` 数值偏差严重。
|
||||
2. **滚动到 scrollHeight 的陷阱:** `renderSnapshot` 及 `followBottom` 在自动跟随终端内容时,直接触发了 `this.outputEl.scrollTop = this.outputEl.scrollHeight`。但是 `xterm` 的 `snapshot.lines` 通常包含底部的很多未使用的纯空行(通常是为了填满预设高度而预留的。例如在你的日志中底部留有二十几行空行 Padding)。直接将 `scrollTop` 置为最大,会导致包含真实光标的行被视觉上挤到了屏幕上方,呈现出"跳到了屏幕中部"的怪异现象。
|
||||
3. **iOS 异步布局时序缺陷:** `KeyboardAdjustController` 中,iOS 上软键盘收回(`visualViewport.height` 恢复)早于实际的 DOM `clientHeight` 恢复更新,用固定的时间延迟去读取和操作会导致布局错误。同时 iOS 原生的 `scroll` 行为是异步派发的,和程序的滚动重置产生了干扰覆盖(程序刚设回了 `snapshot` 高度,又被键盘底层收回的 `scroll` 冲成了底部)。
|
||||
|
||||
### 修复
|
||||
- 文件:
|
||||
- `apps/web/src/terminal/renderer/textareaRenderer.ts`
|
||||
- `apps/web/src/terminal/input/keyboardAdjustController.ts`
|
||||
- 策略:
|
||||
1. **放弃比例估算,直接获取光标 DOM 节点:** 在 `textareaRenderer` 渲染的光标附带有专门的类名 `tc-cursor-cell`,此时我们设置外部包裹元素为 `position: relative`,并直接借助 `const cursorSpan = this.outputEl.querySelector('.tc-cursor-cell')` 拿到精确的 `offsetTop` 来计算光标此时刻在纵向的确切位置,确保毫无误差。
|
||||
2. **将光标置于可视区底部,拒绝 scrollHeight:** 摒弃粗暴地拉向极限 `scrollHeight` 的做法,改为使用光标偏移量算出安全的底端视区:`let targetScrollTop = cursorBottom - clientH + 20` 并将其作为真实的自动跟随坐标,将跟随对象从"整个 Buffer 的底部"变更为"真正光标所处的区域"。
|
||||
3. **Polling 等待基线恢复,长短结合阻塞 scroll 事件干扰:** 在 `keyboardAdjustController.ts` 中针对键盘关闭的 Layout 响应,引入 `requestAnimationFrame` 及定时检测(Polling),不断比对 `clientHeight` 是否已经长回键盘未弹出前的高度,只有确认恢复或者超市才恢复预存前的 `scrollTop` 快照,借此解决 iOS 下因为布局慢导致的跳闪。并通过定时锁去屏蔽由于视差调整造成的自动 `autofollow` 开关变动。
|
||||
|
||||
### 验证
|
||||
- 点击 iOS 输入区弹出软键盘,终端精确将当前焦点处于 1/4 屏幕处。
|
||||
- 收起键盘后,稳定跳掉之前的阅读点(快照复原准确)。
|
||||
- 长按或者多次键盘输入不再跳动到屏幕中部。
|
||||
|
||||
---
|
||||
|
||||
<a id="enter-leading-dup"></a>
|
||||
## 2026-03-03:回车后首字母重复显示(`aasdf`)
|
||||
|
||||
### 现象
|
||||
- 输入英文命令(如 `asdf`)后回车,终端提示符行显示 `aasdf`,但服务端实际执行的是 `asdf`(例如返回 `zsh: command not found: asdf`)。
|
||||
- 问题具备偶发性,通常出现在回显分片较细时。
|
||||
|
||||
### 根因
|
||||
- 回显数据分帧到达,典型序列为:`"a"`、`"\b as"`、`"df..."`。
|
||||
- 旧实现按帧即时渲染,第一帧字符已经落盘;第二帧开头的 `\b`(Backspace)无法回退上一帧已写入内容,导致视觉上保留多余首字母。
|
||||
- 同时存在 `\r\r\n` 与 ANSI 控制序列交织,如果不按终端语义处理,也会放大错位和空行现象。
|
||||
|
||||
### 修复
|
||||
- 文件:`demo/main.js`
|
||||
- 策略:
|
||||
1. 在 `renderAnsiToHtml` 中补齐控制字符语义:处理 `\x08`(退格)、`\r\n`(归一为换行)和裸 `\r`(回到当前行首覆盖)。
|
||||
2. 增加短窗口流式合帧(`STREAM_BATCH_MS`):将连续 `stdout/stderr` 分片合并后再渲染,确保跨帧 `\b/\r` 能作用于同一文本缓冲。
|
||||
3. 保留原有“本地回显清理”策略(`clearLast()`),避免与远端回显叠加。
|
||||
|
||||
### 验证
|
||||
- 复现输入 `asdf` 回车,不再出现 `aasdf`。
|
||||
- 日志可见发送仍为 `asdf\n`,错误回显保持 `command not found: asdf`,与服务端行为一致。
|
||||
- 其它输出路径(`stdout/stderr`、ANSI 颜色)不回归。
|
||||
|
||||
## 通用回归清单(每次改输入/粘贴链路都执行)
|
||||
|
||||
1. 英文输入 `ls` 不重复。
|
||||
2. 第三方输入法输入 `jk`、`测试` 不重复。
|
||||
3. `Enter`、`Ctrl+C`、方向键功能正常。
|
||||
4. 工具栏粘贴与原生粘贴行为一致。
|
||||
5. 长文本粘贴不触发网关 `BAD_REQUEST invalid_union`。
|
||||
6. 多行粘贴不应被当作多次回车直接执行。
|
||||
730
terminal/xterm-standalone-lab-plan-2026-03-01.md
Normal file
730
terminal/xterm-standalone-lab-plan-2026-03-01.md
Normal file
@@ -0,0 +1,730 @@
|
||||
# xterm 移植独立实验项目实施方案(2026-03-01)
|
||||
|
||||
## 1. 目标与范围
|
||||
|
||||
本方案用于落地一个独立实验项目,验证“xterm.js 基线能力”与“原生 textarea 方案”的迁移可行性。
|
||||
该项目仅用于测试,不替换现有 Web/小程序主链路。
|
||||
|
||||
规范基线:
|
||||
|
||||
1. 渲染与输入语义以 `xterm.js@5.3.0` 源码为准。
|
||||
2. 文档中的换行、回显、光标、粘贴规则均以该版本源码行为校正。
|
||||
3. 若未来升级 xterm 版本,需先做“规范差异审计”再修改本方案。
|
||||
|
||||
目标:
|
||||
|
||||
1. 目录独立:实验代码只在 `labs/xterm-standalone/`。
|
||||
2. 依赖可复用:允许复用现有依赖版本策略和工具链版本。
|
||||
3. 构建/运行可复用:命令约定复用 `dev/build/test/lint/typecheck`。
|
||||
4. 业务零耦合:不复用现有业务模块逻辑,不修改现有状态机与会话实现。
|
||||
5. 可删除无副作用:删除实验目录和小程序入口后,主项目行为不变。
|
||||
|
||||
## 2. 非目标
|
||||
|
||||
1. 不替换 `apps/web` 现有终端实现。
|
||||
2. 不修改 `apps/gateway` 的业务协议和转发逻辑。
|
||||
3. 不改 `packages/shared` 的现有模型定义和状态机实现。
|
||||
4. 不将实验代码接入生产发布链路。
|
||||
|
||||
## 3. 独立性与复用规则
|
||||
|
||||
### 3.1 必须满足
|
||||
|
||||
1. 实验代码不得 `import` 现有业务模块(`apps/*`、`packages/*` 的业务实现)。
|
||||
2. 小程序仅新增“入口级改动”(页面按钮 + web-view 跳转)。
|
||||
3. 实验项目失败、下线、删除,不影响主流程连接与发布。
|
||||
4. 禁止在实验项目中运行时依赖 `@xterm/*` 包;xterm 仅作为源码语义参考,不作为可执行依赖。
|
||||
|
||||
### 3.2 允许复用
|
||||
|
||||
1. 依赖版本策略可复用(例如与根工程保持同主版本)。
|
||||
2. 工程工具可复用(TypeScript、Vite、ESLint、Vitest 配置风格)。
|
||||
3. 运行方式可复用(统一命令命名,不要求命令实现完全一致)。
|
||||
4. 主题 token 可复用现有 Web/小程序的成熟定义(颜色变量、语义色分层、主题切换策略),但实现代码必须保留在实验目录内。
|
||||
|
||||
## 4. 目录规划(实施级)
|
||||
|
||||
```text
|
||||
labs/xterm-standalone/
|
||||
README.md
|
||||
package.json
|
||||
src/
|
||||
main.ts
|
||||
app/App.vue
|
||||
pages/TerminalLabPage.vue
|
||||
components/
|
||||
TerminalToolbar.vue
|
||||
TerminalViewport.vue
|
||||
TerminalInputBar.vue
|
||||
TerminalTouchTools.vue
|
||||
core/
|
||||
session/sessionMachine.ts
|
||||
session/sessionTypes.ts
|
||||
transport/terminalTransport.ts
|
||||
transport/gatewayTransport.ts
|
||||
renderer/rendererAdapter.ts
|
||||
renderer/compatRenderer.ts
|
||||
renderer/textareaRenderer.ts
|
||||
input/inputBridge.ts
|
||||
input/imeController.ts
|
||||
layout/sizeCalculator.ts
|
||||
layout/cursorMath.ts
|
||||
buffer/outputBuffer.ts
|
||||
sanitize/terminalSanitizer.ts
|
||||
stores/useTerminalLabStore.ts
|
||||
styles/terminal-lab.css
|
||||
```
|
||||
|
||||
## 5. 总体架构与数据流
|
||||
|
||||
```text
|
||||
Transport(stdout/stderr) -> Sanitizer -> OutputBuffer -> RendererAdapter(compat|textarea) -> View
|
||||
UserInput(key/ime/paste/touch) -> InputBridge -> xterm-compatible input mapping -> Transport(stdin)
|
||||
LayoutResize -> sizeCalculator(cols/rows) -> Transport(resize) + Renderer.resize
|
||||
```
|
||||
|
||||
关键原则:
|
||||
|
||||
1. 传输层不感知渲染器类型。
|
||||
2. 输入处理统一走 `InputBridge`,避免各组件各自发送 stdin。
|
||||
3. 渲染器通过统一接口实现,便于模式切换和 A/B 对比。
|
||||
4. 输入映射遵循 xterm 规则:`Enter -> CR`,粘贴文本行结束归一到 `CR`。
|
||||
5. 输出渲染遵循 xterm 控制字符纪律:`CR` 与 `LF` 分离语义,`convertEol` 默认 `false`。
|
||||
6. 原生响应优先:软键盘、选区、滚动由原生 `textarea` 通道主导,xterm 仅负责渲染与终端语义,不得接管系统输入响应。
|
||||
7. 事件主权清晰:键盘/鼠标事件先由应用层做区域过滤与路由,再决定是否交给 xterm。
|
||||
|
||||
## 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 `TerminalLabPage.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 `TerminalTransport` 抽象(必须)
|
||||
|
||||
接口:
|
||||
|
||||
1. `connect(params)`
|
||||
2. `send(data, meta?)`
|
||||
3. `resize(cols, rows)`
|
||||
4. `disconnect(reason?)`
|
||||
5. `on(listener)`
|
||||
6. `getState()`
|
||||
|
||||
## 8.2 网关帧协议(实验项目内按同协议实现)
|
||||
|
||||
入站:
|
||||
|
||||
1. `init`
|
||||
2. `stdin`(含可选 `meta: { source, txnId }`)
|
||||
3. `resize`
|
||||
4. `control(ping/pong/disconnect)`
|
||||
|
||||
出站:
|
||||
|
||||
1. `stdout`
|
||||
2. `stderr`
|
||||
3. `control(connected/disconnect/pong)`
|
||||
4. `error`
|
||||
|
||||
## 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` 至少要正确处理本项目目标程序涉及的序列族:`CR/LF/VT/FF/NEL/BS/TAB`、`CSI A/B/C/D/H/f/J/K/m`、`SM/RM(含 Ps=20)`、`DECSET/DECRST(含 DECAWM)`。
|
||||
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. 实验项目只维护“主题映射层”,不复制现有 Web/小程序整套业务主题逻辑。
|
||||
|
||||
xterm 轻量化规则:
|
||||
|
||||
1. 不引入 `@xterm/*` 运行时依赖;实现以 `TerminalCore + 自研渲染器` 为主。
|
||||
2. 兼容能力按需实现并按模块拆分,首屏仅加载连接、输入、回显、resize 必需逻辑。
|
||||
3. 默认使用原生 DOM/Canvas 路径,禁止引入重型图形加速依赖作为前置条件。
|
||||
4. 非核心能力(复杂链接检测、额外动画)默认关闭,确保输入回显路径最短。
|
||||
|
||||
性能预算(建议):
|
||||
|
||||
1. 实验页 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` 不可缺失。
|
||||
|
||||
## 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. 建立目录与模块骨架。
|
||||
2. 完成 `sessionMachine + transport + rendererAdapter`。
|
||||
3. 页面可连接、可收发、可显示状态。
|
||||
|
||||
### 阶段 B:xterm 基线能力
|
||||
|
||||
1. 接入 xterm 渲染器。
|
||||
2. 跑通输入、输出、resize、快捷键、粘贴。
|
||||
3. 完成基线测试用例。
|
||||
|
||||
### 阶段 C:textarea 迁移能力
|
||||
|
||||
1. 实现 textarea 渲染器。
|
||||
2. 接入光标/坐标计算与输入法链路。
|
||||
3. 完成 compat 内核 vs textarea 对比测试。
|
||||
|
||||
### 阶段 D:小程序入口接入
|
||||
|
||||
1. 小程序新增入口按钮 + web-view 页面。
|
||||
2. 地址可配置,未配置时只提示不报错。
|
||||
3. 不改主流程逻辑。
|
||||
|
||||
### 阶段 E:删除演练
|
||||
|
||||
1. 删除 `labs/xterm-standalone/`。
|
||||
2. 删除小程序入口页面与按钮。
|
||||
3. 验证主工程构建与现有测试正常。
|
||||
|
||||
## 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` 兼容正确:切换主/备用屏时两屏内容与滚动状态互不污染。
|
||||
|
||||
性能验收:
|
||||
|
||||
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`。
|
||||
|
||||
## 17. 完成定义(DoD)
|
||||
|
||||
1. 本文档中的状态机、组件职责、算法规则均已对应到代码模块。
|
||||
2. 实验项目可独立开发与验证,且不污染主业务逻辑。
|
||||
3. 小程序只增加测试入口,无业务耦合。
|
||||
4. 已完成删除演练并记录结果。
|
||||
5. 换行与光标行为已与 `xterm.js@5.3.0` 源码逐条对齐,并有对应验收用例。
|
||||
6. 开发者仅依赖本方案即可按阶段落地实现。
|
||||
7. 主屏/备用屏与 `1049` 光标恢复能力已落地并通过验收用例。
|
||||
Reference in New Issue
Block a user