first commit

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

1
terminal/.npmrc Normal file
View File

@@ -0,0 +1 @@
cache=.npm-cache

95
terminal/README.md Normal file
View 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 切换)。

View 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 ≈ 20fpssetData 有限流)
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);
},
});
},
},
});

View File

@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}

View File

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

View File

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

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

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

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

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

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

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

View File

@@ -0,0 +1,72 @@
<script setup lang="ts">
import { storeToRefs } from "pinia";
import { useAppStore } from "@/stores/appStore";
import TerminalPage from "@/terminal/TerminalPage.vue";
const appStore = useAppStore();
const { toasts } = storeToRefs(appStore);
</script>
<template>
<main class="app-root">
<TerminalPage />
<section class="toast-list" aria-live="polite">
<article
v-for="item in toasts"
:key="item.id"
class="toast-item"
:class="`toast-${item.level}`"
>
{{ item.message }}
</article>
</section>
</main>
</template>
<style scoped>
.app-root {
/*
* 使用稳定后的可视视口高度控制根容器。
* 注意:不跟随 --app-vtop 做整体位移,避免键盘动画期间整页被平移到不可见区。
*/
position: fixed;
left: 0;
top: 0;
width: 100%;
height: var(--app-vh, 100dvh);
overflow: hidden;
background: #1a1a1a;
}
.toast-list {
position: fixed;
right: 12px;
bottom: 12px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 99;
}
.toast-item {
max-width: 360px;
padding: 8px 12px;
border-radius: 6px;
color: #fff;
font-size: 13px;
line-height: 1.4;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
}
.toast-info {
background: #2d7ff9;
}
.toast-warn {
background: #b88400;
}
.toast-error {
background: #c0392b;
}
</style>

7
terminal/apps/web/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>;
export default component;
}

View File

@@ -0,0 +1,119 @@
import { createPinia } from "pinia";
import { createApp } from "vue";
import App from "./App.vue";
import "./style.css";
function disablePageZoom(): void {
if (typeof document === "undefined") return;
const prevent = (e: Event) => e.preventDefault();
let lastTapAt = 0;
let lastTapX = 0;
let lastTapY = 0;
const DOUBLE_TAP_GUARD_MS = 320;
const DOUBLE_TAP_DISTANCE_PX = 32;
document.addEventListener("gesturestart", prevent, { passive: false });
document.addEventListener("gesturechange", prevent, { passive: false });
document.addEventListener("gestureend", prevent, { passive: false });
document.addEventListener("touchmove", (e: TouchEvent) => {
if (e.touches.length > 1) e.preventDefault();
}, { passive: false });
document.addEventListener("touchend", (e: TouchEvent) => {
const touch = e.changedTouches[0];
if (!touch) return;
const now = Date.now();
const dt = now - lastTapAt;
const dx = Math.abs(touch.clientX - lastTapX);
const dy = Math.abs(touch.clientY - lastTapY);
lastTapAt = now;
lastTapX = touch.clientX;
lastTapY = touch.clientY;
// 全局双击缩放拦截:在整个 Web 页面内统一禁止 double-tap zoom。
if (dt > 0 && dt <= DOUBLE_TAP_GUARD_MS && dx <= DOUBLE_TAP_DISTANCE_PX && dy <= DOUBLE_TAP_DISTANCE_PX) {
e.preventDefault();
}
}, { passive: false, capture: true });
}
function setupMobileViewportCompensation(): void {
if (typeof window === "undefined" || typeof document === "undefined") return;
const root = document.documentElement;
const MIN_VIEWPORT_RATIO = 0.35;
const MIN_VIEWPORT_PX = 240;
const BIG_JUMP_PX = 64;
const BIG_JUMP_DEBOUNCE_MS = 100;
// 记录“可信高度”基线:用于过滤键盘动画期间偶发的极小中间值(例如瞬时 98px
let baselineViewportHeight = Math.max(window.innerHeight, window.visualViewport?.height ?? 0);
let committedViewportHeight = window.visualViewport?.height ?? window.innerHeight;
let pendingViewportHeight = committedViewportHeight;
let pendingViewportTop = window.visualViewport?.offsetTop ?? 0;
let pendingTimer: number | null = null;
const commitViewport = (viewportHeight: number, viewportTop: number) => {
root.style.setProperty("--app-vh", `${Math.round(viewportHeight)}px`);
root.style.setProperty("--app-vtop", `${Math.round(viewportTop)}px`);
};
const flushPending = () => {
pendingTimer = null;
committedViewportHeight = pendingViewportHeight;
commitViewport(pendingViewportHeight, pendingViewportTop);
};
const scheduleViewport = (viewportHeight: number, viewportTop: number) => {
if (viewportHeight <= 0) return;
if (viewportHeight > baselineViewportHeight) {
baselineViewportHeight = viewportHeight;
}
// 过滤动画毛刺:异常小高度直接丢弃,避免主容器瞬时塌缩导致工具条闪位。
const minReasonable = Math.max(MIN_VIEWPORT_PX, baselineViewportHeight * MIN_VIEWPORT_RATIO);
if (viewportHeight < minReasonable) {
return;
}
pendingViewportHeight = viewportHeight;
pendingViewportTop = viewportTop;
const jump = Math.abs(viewportHeight - committedViewportHeight);
// 大跳变(键盘开关动画期)走短防抖,只提交最终稳定值。
if (jump >= BIG_JUMP_PX) {
if (pendingTimer !== null) {
window.clearTimeout(pendingTimer);
}
pendingTimer = window.setTimeout(flushPending, BIG_JUMP_DEBOUNCE_MS);
return;
}
if (pendingTimer !== null) {
window.clearTimeout(pendingTimer);
pendingTimer = null;
}
committedViewportHeight = viewportHeight;
commitViewport(viewportHeight, viewportTop);
};
const applyViewport = () => {
const vv = window.visualViewport;
const viewportHeight = vv?.height ?? window.innerHeight;
const viewportTop = vv?.offsetTop ?? 0;
scheduleViewport(viewportHeight, viewportTop);
};
applyViewport();
window.addEventListener("resize", applyViewport);
window.addEventListener("orientationchange", () => window.setTimeout(applyViewport, 120));
window.visualViewport?.addEventListener("resize", applyViewport);
window.visualViewport?.addEventListener("scroll", applyViewport);
}
disablePageZoom();
setupMobileViewportCompensation();
const app = createApp(App);
app.use(createPinia());
app.mount("#app");

View File

@@ -0,0 +1,14 @@
import type { TerminalTransport } from "./terminalTransport";
import { GatewayTransport } from "./gatewayTransport";
import { IosNativeTransport } from "./iosNativeTransport";
export function createTransport(
mode: "gateway" | "ios-native",
options: { gatewayUrl: string; gatewayToken: string }
): TerminalTransport {
if (mode === "ios-native") {
return new IosNativeTransport();
}
return new GatewayTransport(options.gatewayUrl, options.gatewayToken);
}

View File

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

View File

@@ -0,0 +1,174 @@
import type { SessionState } from "@remoteconn/terminal-core";
import type { ConnectParams, StdinMeta, TerminalTransport, TransportEvent } from "./terminalTransport";
declare global {
interface Window {
Capacitor?: {
Plugins?: {
RemoteConnSSH?: {
connect(options: unknown): Promise<void>;
send(options: { data: string }): Promise<void>;
resize(options: { cols: number; rows: number }): Promise<void>;
disconnect(options: { reason?: string }): Promise<void>;
addListener(
eventName: "stdout" | "stderr" | "disconnect" | "latency" | "error" | "connected",
listener: (payload: unknown) => void
): Promise<{ remove: () => void }>;
};
};
};
}
}
type NativeCredentialPayload =
| { type: "password"; password: string }
| { type: "privateKey"; privateKey: string; passphrase?: string }
| { type: "certificate"; privateKey: string; passphrase?: string; certificate: string };
interface NativeConnectPayload {
host: string;
port: number;
username: string;
knownHostFingerprint?: string;
cols: number;
rows: number;
credential: NativeCredentialPayload;
}
function buildNativeConnectPayload(params: ConnectParams): NativeConnectPayload {
const base = {
host: String(params.host ?? ""),
port: Number(params.port ?? 22),
username: String(params.username ?? ""),
cols: Number(params.cols ?? 80),
rows: Number(params.rows ?? 24)
};
const knownHostFingerprint =
typeof params.knownHostFingerprint === "string" && params.knownHostFingerprint.trim().length > 0
? params.knownHostFingerprint.trim()
: undefined;
if (params.credential.type === "password") {
return {
...base,
...(knownHostFingerprint ? { knownHostFingerprint } : {}),
credential: {
type: "password",
password: String(params.credential.password ?? "")
}
};
}
if (params.credential.type === "privateKey") {
return {
...base,
...(knownHostFingerprint ? { knownHostFingerprint } : {}),
credential: {
type: "privateKey",
privateKey: String(params.credential.privateKey ?? ""),
...(params.credential.passphrase ? { passphrase: String(params.credential.passphrase) } : {})
}
};
}
return {
...base,
...(knownHostFingerprint ? { knownHostFingerprint } : {}),
credential: {
type: "certificate",
privateKey: String(params.credential.privateKey ?? ""),
certificate: String(params.credential.certificate ?? ""),
...(params.credential.passphrase ? { passphrase: String(params.credential.passphrase) } : {})
}
};
}
export class IosNativeTransport implements TerminalTransport {
private state: SessionState = "idle";
private listeners = new Set<(event: TransportEvent) => void>();
private disposers: Array<() => void> = [];
public async connect(params: ConnectParams): Promise<void> {
const plugin = window.Capacitor?.Plugins?.RemoteConnSSH;
if (!plugin) {
throw new Error("iOS 原生插件不可用");
}
this.state = "connecting";
const onStdout = await plugin.addListener("stdout", (payload) => {
this.state = "connected";
this.emit({ type: "stdout", data: String((payload as { data?: string }).data ?? "") });
});
this.disposers.push(() => onStdout.remove());
const onStderr = await plugin.addListener("stderr", (payload) => {
this.emit({ type: "stderr", data: String((payload as { data?: string }).data ?? "") });
});
this.disposers.push(() => onStderr.remove());
const onDisconnect = await plugin.addListener("disconnect", (payload) => {
this.state = "disconnected";
this.emit({ type: "disconnect", reason: String((payload as { reason?: string }).reason ?? "disconnect") });
});
this.disposers.push(() => onDisconnect.remove());
const onLatency = await plugin.addListener("latency", (payload) => {
this.emit({ type: "latency", data: Number((payload as { latency?: number }).latency ?? 0) });
});
this.disposers.push(() => onLatency.remove());
const onError = await plugin.addListener("error", (payload) => {
this.state = "error";
const error = payload as { code?: string; message?: string };
this.emit({
type: "error",
code: String(error.code ?? "NATIVE_ERROR"),
message: String(error.message ?? "iOS 连接异常")
});
});
this.disposers.push(() => onError.remove());
const onConnected = await plugin.addListener("connected", (payload) => {
this.state = "connected";
this.emit({ type: "connected", fingerprint: String((payload as { fingerprint?: string }).fingerprint ?? "") || undefined });
});
this.disposers.push(() => onConnected.remove());
await plugin.connect(buildNativeConnectPayload(params));
}
public async send(data: string, _meta?: StdinMeta): Promise<void> {
await window.Capacitor?.Plugins?.RemoteConnSSH?.send({ data });
}
public async resize(cols: number, rows: number): Promise<void> {
await window.Capacitor?.Plugins?.RemoteConnSSH?.resize({ cols, rows });
}
public async disconnect(reason?: string): Promise<void> {
await window.Capacitor?.Plugins?.RemoteConnSSH?.disconnect({ reason });
for (const dispose of this.disposers) {
dispose();
}
this.disposers = [];
this.state = "disconnected";
}
public on(listener: (event: TransportEvent) => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
public getState(): SessionState {
return this.state;
}
private emit(event: TransportEvent): void {
for (const listener of this.listeners) {
listener(event);
}
}
}

View File

@@ -0,0 +1,22 @@
import type { ConnectParams as CoreConnectParams, FrameMeta, SessionState } from "@remoteconn/terminal-core";
export type ConnectParams = CoreConnectParams;
export type StdinMeta = FrameMeta;
export type TransportEvent =
| { type: "stdout"; data: string }
| { type: "stderr"; data: string }
| { type: "latency"; data: number }
| { type: "connected"; fingerprint?: string }
| { type: "disconnect"; reason: string }
| { type: "error"; code: string; message: string };
export interface TerminalTransport {
connect(params: ConnectParams): Promise<void>;
send(data: string, meta?: StdinMeta): Promise<void>;
resize(cols: number, rows: number): Promise<void>;
disconnect(reason?: string): Promise<void>;
on(listener: (event: TransportEvent) => void): () => void;
getState(): SessionState;
}

View File

@@ -0,0 +1,32 @@
import { defineStore } from "pinia";
import { ref } from "vue";
interface AppToast {
id: string;
level: "info" | "warn" | "error";
message: string;
}
export const useAppStore = defineStore("app", () => {
const toasts = ref<AppToast[]>([]);
function notify(level: AppToast["level"], message: string): void {
const item: AppToast = {
id: typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `toast-${Date.now()}-${Math.random().toString(16).slice(2)}`,
level,
message
};
toasts.value.push(item);
window.setTimeout(() => {
toasts.value = toasts.value.filter((x) => x.id !== item.id);
}, level === "error" ? 5000 : 3000);
}
return {
toasts,
notify
};
});

View File

@@ -0,0 +1,231 @@
import { defineStore } from "pinia";
import { computed, ref } from "vue";
import type { TerminalCredential } from "@remoteconn/terminal-core";
import { loadRuntimeConfig } from "@/utils/runtimeConfig";
export interface ServerProfile {
id: string;
name: string;
host: string;
port: number;
username: string;
transportMode: "gateway" | "ios-native";
knownHostFingerprint?: string;
authType: "password" | "privateKey" | "certificate";
password?: string;
privateKey?: string;
passphrase?: string;
certificate?: string;
cols?: number;
rows?: number;
}
const STORAGE_KEY = "remoteconn:web:servers:v1";
const SELECTED_SERVER_KEY = "remoteconn:web:selected-server-id:v1";
function loadServers(): ServerProfile[] {
if (typeof window === "undefined") return [];
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as unknown;
return normalizeServers(parsed);
} catch {
return [];
}
}
function toFiniteNumber(value: unknown, fallback: number): number {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
function normalizeServers(input: unknown): ServerProfile[] {
if (!Array.isArray(input)) return [];
return input
.filter((item): item is Record<string, unknown> => !!item && typeof item === "object" && typeof (item as { id?: unknown }).id === "string")
.map((item) => {
const authType = item.authType === "privateKey" || item.authType === "certificate" ? item.authType : "password";
return {
id: String(item.id),
name: typeof item.name === "string" ? item.name : "未命名服务器",
host: typeof item.host === "string" ? item.host : "",
port: toFiniteNumber(item.port, 22),
username: typeof item.username === "string" ? item.username : "root",
transportMode: item.transportMode === "ios-native" ? "ios-native" : "gateway",
authType,
...(typeof item.knownHostFingerprint === "string" && item.knownHostFingerprint ? { knownHostFingerprint: item.knownHostFingerprint } : {}),
...(typeof item.password === "string" ? { password: item.password } : {}),
...(typeof item.privateKey === "string" ? { privateKey: item.privateKey } : {}),
...(typeof item.passphrase === "string" ? { passphrase: item.passphrase } : {}),
...(typeof item.certificate === "string" ? { certificate: item.certificate } : {}),
cols: toFiniteNumber(item.cols, 80),
rows: toFiniteNumber(item.rows, 24)
};
});
}
function persistServers(servers: ServerProfile[]): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(servers));
} catch {
// ignore storage unavailability in embedded/private contexts
}
}
function loadSelectedServerId(): string {
if (typeof window === "undefined") return "";
try {
return window.localStorage.getItem(SELECTED_SERVER_KEY) ?? "";
} catch {
return "";
}
}
function persistSelectedServerId(id: string): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(SELECTED_SERVER_KEY, id);
} catch {
// ignore storage unavailability in embedded/private contexts
}
}
function buildDefaultServer(): ServerProfile {
return {
id: `srv-${typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : Date.now()}`,
name: "新服务器",
host: "",
port: 22,
username: "root",
transportMode: "gateway",
authType: "password",
password: "",
cols: 80,
rows: 24
};
}
export const useServerStore = defineStore("server", () => {
const servers = ref<ServerProfile[]>([]);
const selectedServerId = ref<string>("");
const loaded = ref(false);
let bootstrapPromise: Promise<void> | null = null;
const selectedServer = computed(() => servers.value.find((item) => item.id === selectedServerId.value));
async function ensureBootstrapped(): Promise<void> {
if (loaded.value) return;
if (bootstrapPromise) {
await bootstrapPromise;
return;
}
bootstrapPromise = (async () => {
const runtimeConfig = await loadRuntimeConfig();
const configured = normalizeServers(runtimeConfig?.servers);
const stored = loadServers();
if (configured.length > 0) {
servers.value = configured;
} else if (stored.length > 0) {
servers.value = stored;
} else {
const sample = buildDefaultServer();
servers.value = [sample];
persistServers(servers.value);
}
const preferred = runtimeConfig?.selectedServerId ?? loadSelectedServerId();
selectedServerId.value = servers.value.some((item) => item.id === preferred)
? preferred
: (servers.value[0]?.id ?? "");
persistSelectedServerId(selectedServerId.value);
loaded.value = true;
})();
try {
await bootstrapPromise;
} finally {
bootstrapPromise = null;
}
}
async function bootstrap(): Promise<void> {
await ensureBootstrapped();
}
function setSelectedServer(serverId: string): void {
selectedServerId.value = serverId;
persistSelectedServerId(serverId);
}
async function saveServer(server: ServerProfile): Promise<void> {
const index = servers.value.findIndex((item) => item.id === server.id);
if (index >= 0) {
servers.value[index] = server;
} else {
servers.value.unshift(server);
}
persistServers(servers.value);
if (!selectedServerId.value) {
setSelectedServer(server.id);
}
}
async function createServer(): Promise<ServerProfile> {
const sample = buildDefaultServer();
servers.value.unshift(sample);
persistServers(servers.value);
setSelectedServer(sample.id);
return sample;
}
async function deleteServer(serverId: string): Promise<void> {
servers.value = servers.value.filter((item) => item.id !== serverId);
persistServers(servers.value);
if (selectedServerId.value === serverId) {
setSelectedServer(servers.value[0]?.id ?? "");
}
}
async function resolveCredential(serverId: string): Promise<TerminalCredential> {
const server = servers.value.find((item) => item.id === serverId);
if (!server) {
throw new Error("目标服务器不存在");
}
if (server.authType === "password") {
return {
type: "password",
password: server.password ?? ""
};
}
if (server.authType === "privateKey") {
return {
type: "privateKey",
privateKey: server.privateKey ?? "",
...(server.passphrase ? { passphrase: server.passphrase } : {})
};
}
return {
type: "certificate",
privateKey: server.privateKey ?? "",
certificate: server.certificate ?? "",
...(server.passphrase ? { passphrase: server.passphrase } : {})
};
}
return {
servers,
selectedServerId,
selectedServer,
ensureBootstrapped,
bootstrap,
setSelectedServer,
saveServer,
createServer,
deleteServer,
resolveCredential
};
});

View File

@@ -0,0 +1,110 @@
import { defineStore } from "pinia";
import { computed, ref } from "vue";
import { loadRuntimeConfig } from "@/utils/runtimeConfig";
interface RuntimeSettings {
gatewayUrl: string;
gatewayToken: string;
}
const STORAGE_KEY = "remoteconn:web:settings:v1";
function resolveDefaultGatewayUrl(): string {
const env = (import.meta as ImportMeta & { env?: Record<string, string | undefined> }).env;
const envUrl = env?.VITE_GATEWAY_URL?.trim();
if (envUrl) return envUrl;
if (typeof window === "undefined") return "ws://127.0.0.1:8787/ws/terminal";
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${protocol}//${window.location.host}/ws/terminal`;
}
function resolveDefaultGatewayToken(): string {
const env = (import.meta as ImportMeta & { env?: Record<string, string | undefined> }).env;
const envToken = env?.VITE_GATEWAY_TOKEN?.trim();
if (envToken) return envToken;
return "remoteconn-dev-token";
}
function loadSettings(): RuntimeSettings {
if (typeof window === "undefined") {
return { gatewayUrl: resolveDefaultGatewayUrl(), gatewayToken: resolveDefaultGatewayToken() };
}
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) {
return { gatewayUrl: resolveDefaultGatewayUrl(), gatewayToken: resolveDefaultGatewayToken() };
}
const parsed = JSON.parse(raw) as Partial<RuntimeSettings>;
return {
gatewayUrl: String(parsed.gatewayUrl ?? resolveDefaultGatewayUrl()),
gatewayToken: String(parsed.gatewayToken ?? resolveDefaultGatewayToken())
};
} catch {
return { gatewayUrl: resolveDefaultGatewayUrl(), gatewayToken: resolveDefaultGatewayToken() };
}
}
function persistSettings(settings: RuntimeSettings): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
} catch {
// ignore storage unavailability in embedded/private contexts
}
}
export const useSettingsStore = defineStore("settings", () => {
const settings = ref<RuntimeSettings>({
gatewayUrl: resolveDefaultGatewayUrl(),
gatewayToken: resolveDefaultGatewayToken()
});
const loaded = ref(false);
let bootstrapPromise: Promise<void> | null = null;
async function ensureBootstrapped(): Promise<void> {
if (loaded.value) return;
if (bootstrapPromise) {
await bootstrapPromise;
return;
}
bootstrapPromise = (async () => {
const runtimeConfig = await loadRuntimeConfig();
const localSettings = loadSettings();
settings.value = {
gatewayUrl: String(runtimeConfig?.gatewayUrl ?? localSettings.gatewayUrl ?? resolveDefaultGatewayUrl()),
gatewayToken: String(runtimeConfig?.gatewayToken ?? localSettings.gatewayToken ?? resolveDefaultGatewayToken())
};
loaded.value = true;
})();
try {
await bootstrapPromise;
} finally {
bootstrapPromise = null;
}
}
async function bootstrap(): Promise<void> {
await ensureBootstrapped();
}
async function save(next: Partial<RuntimeSettings>): Promise<void> {
settings.value = {
gatewayUrl: String(next.gatewayUrl ?? settings.value.gatewayUrl ?? resolveDefaultGatewayUrl()),
gatewayToken: String(next.gatewayToken ?? settings.value.gatewayToken ?? resolveDefaultGatewayToken())
};
persistSettings(settings.value);
}
const gatewayUrl = computed(() => settings.value.gatewayUrl || resolveDefaultGatewayUrl());
const gatewayToken = computed(() => settings.value.gatewayToken || resolveDefaultGatewayToken());
return {
settings,
gatewayUrl,
gatewayToken,
ensureBootstrapped,
bootstrap,
save
};
});

View File

@@ -0,0 +1,21 @@
html,
body,
#app {
width: 100%;
min-height: 100%;
height: 100%;
margin: 0;
background: #1a1a1a;
overscroll-behavior-y: none;
}
body {
overflow: hidden;
touch-action: manipulation;
}
*,
*::before,
*::after {
box-sizing: border-box;
}

View File

@@ -0,0 +1,484 @@
<script setup lang="ts">
/**
* TerminalPage — 新 terminal-core 架构的实验终端页面。
*
* 职责:
* - 编排 TerminalToolbar / TerminalViewport / TerminalInputBar / TerminalTouchTools
* - 处理连接/断开流程(从 serverStore 取服务器与凭据)
* - BEL → navigator.vibrate / toast
* - 标题 OSC → toolbar 展示
* - 触控模式检测viewport 点击 → focus input anchor
* - 软键盘弹出/收回:提示符行置于屏幕距顶 1/4 处,收回时恢复滚动
*/
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from "vue";
import { InputBridge } from "@remoteconn/terminal-core";
import { useTerminalStore } from "@/terminal/stores/useTerminalStore";
import { useServerStore } from "@/stores/serverStore";
import { useAppStore } from "@/stores/appStore";
import TerminalToolbar from "./components/TerminalToolbar.vue";
import TerminalViewport from "./components/TerminalViewport.vue";
import TerminalInputBar from "./components/TerminalInputBar.vue";
import TerminalTouchTools from "./components/TerminalTouchTools.vue";
import { KeyboardAdjustController } from "@/terminal/input/keyboardAdjustController";
import { formatActionError } from "@/utils/feedback";
const store = useTerminalStore();
const serverStore = useServerStore();
const appStore = useAppStore();
const pasteBridge = new InputBridge({ cursorKeyMode: "normal", bracketedPaste: true });
const toolbarWrapRef = ref<HTMLElement | null>(null);
const viewportWrapRef = ref<HTMLElement | null>(null);
const inputBarRef = ref<InstanceType<typeof TerminalInputBar> | null>(null);
const AUTO_RECONNECT_KEY = "remoteconn:web:auto-reconnect:v1";
const CLIENT_SESSION_KEY_PREFIX = "remoteconn:web:client-session-key:v1:";
let pageUnloading = false;
// ── 触控状态 ────────────────────────────────────────────────────────────────
let touchStartX = 0;
let touchStartY = 0;
let touchMoved = false;
let touchStartAt = 0;
let touchStartScrollTop = 0;
let touchDidScroll = false;
let lastZoomGuardTapAt = 0;
let keyboardLikelyVisible = false;
const DOUBLE_TAP_ZOOM_GUARD_MS = 320;
const TAP_MOVE_THRESHOLD_PX = 12;
const TAP_MAX_DURATION_MS = 260;
// ── 软键盘调整控制器 ─────────────────────────────────────────────────────────
let kbAdjust: KeyboardAdjustController | null = null;
function isDebugEnabled(): boolean {
if (typeof window === "undefined") return false;
try {
return (
window.localStorage.getItem("terminal.debugKeyboardAdjust") === "1" ||
window.localStorage.getItem("terminal.debugTouch") === "1"
);
} catch {
return false;
}
}
function debugTouch(message: string, detail?: unknown): void {
if (!isDebugEnabled()) return;
const prefix = `[TerminalPage][Touch][${new Date().toISOString()}] ${message}`;
detail !== undefined ? console.log(prefix, detail) : console.log(prefix);
}
function isPasteDebugEnabled(): boolean {
if (typeof window === "undefined") return false;
try {
return (
window.localStorage.getItem("terminal.debugPaste") === "1" ||
window.localStorage.getItem("terminal.debugTransport") === "1"
);
} catch {
return false;
}
}
function debugPaste(message: string, detail?: unknown): void {
if (!isPasteDebugEnabled()) return;
const prefix = `[TerminalPage][Paste][${new Date().toISOString()}] ${message}`;
detail !== undefined ? console.log(prefix, detail) : console.log(prefix);
}
function isTouchDevice(): boolean {
if (typeof window === "undefined") return false;
return window.matchMedia?.("(pointer: coarse)")?.matches ?? ("ontouchstart" in window);
}
// ── 应用光标键模式(从 TerminalCore 读取,随 DECCKM 状态变化) ───────────────
const appCursorKeys = ref(false);
// ── BEL / Title 事件订阅 ──────────────────────────────────────────────────────
let offBell: (() => void) | null = null;
let offTitle: (() => void) | null = null;
function subscribeEvents() {
const core = store.getCore();
if (!core) return;
offBell = core.on("bell", () => {
if (typeof navigator !== "undefined" && "vibrate" in navigator) {
navigator.vibrate(50);
}
});
offTitle = core.on("titleChange", (newTitle: string) => {
store.title = newTitle;
});
}
function setAutoReconnect(enabled: boolean): void {
if (typeof window === "undefined") return;
try { window.localStorage.setItem(AUTO_RECONNECT_KEY, enabled ? "1" : "0"); } catch { /* ignore */ }
}
function shouldAutoReconnect(): boolean {
if (typeof window === "undefined") return false;
try { return window.localStorage.getItem(AUTO_RECONNECT_KEY) === "1"; } catch { return false; }
}
function getOrCreateClientSessionKey(serverId: string): string {
if (typeof window === "undefined") return `session-${Date.now()}`;
const storageKey = `${CLIENT_SESSION_KEY_PREFIX}${serverId}`;
try {
const existing = window.localStorage.getItem(storageKey);
if (existing && existing.trim().length > 0) return existing;
const next = (typeof crypto !== "undefined" && "randomUUID" in crypto)
? crypto.randomUUID()
: `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
window.localStorage.setItem(storageKey, next);
return next;
} catch {
return (typeof crypto !== "undefined" && "randomUUID" in crypto)
? crypto.randomUUID()
: `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
}
// ── 连接 ──────────────────────────────────────────────────────────────────────
async function handleConnect(options: { silent?: boolean } = {}): Promise<void> {
const { silent = false } = options;
const server = serverStore.selectedServer;
if (!server) {
if (!silent) appStore.notify("warn", "请先在服务器列表中选择目标服务器");
return;
}
const coreSnap = store.getCore()?.snapshot();
const cols = server.cols ?? coreSnap?.cols ?? 80;
const rows = server.rows ?? coreSnap?.rows ?? 24;
let credential: Awaited<ReturnType<typeof serverStore.resolveCredential>>;
try {
credential = await serverStore.resolveCredential(server.id);
} catch (err) {
appStore.notify("error", formatActionError("凭据解析失败", err));
return;
}
try {
const clientSessionKey = getOrCreateClientSessionKey(server.id);
store.bindOutputCache(`${server.id}:${clientSessionKey}`);
await store.connect({
host: server.host,
port: server.port,
username: server.username,
credential,
clientSessionKey,
...(server.knownHostFingerprint ? { knownHostFingerprint: server.knownHostFingerprint } : {}),
cols,
rows
});
setAutoReconnect(true);
// 连接后订阅 core 事件
subscribeEvents();
await nextTick();
inputBarRef.value?.focus();
} catch (err) {
if (!silent) appStore.notify("error", formatActionError("连接失败", err));
}
}
function handleDisconnect(): void {
setAutoReconnect(false);
store.disconnect("manual");
}
// ── 粘贴 ──────────────────────────────────────────────────────────────────────
async function handlePaste(): Promise<void> {
if (store.state !== "connected") return;
debugPaste("paste:triggered");
try {
const text = await navigator.clipboard.readText();
debugPaste("paste:clipboard_read", { length: text.length });
if (text) {
const seq = pasteBridge.mapPaste(text);
store.sendInput(seq, "paste");
debugPaste("paste:dispatched", { normalizedLength: seq.length });
}
} catch {
debugPaste("paste:clipboard_read_failed");
appStore.notify("warn", "无法读取剪贴板,请手动粘贴");
}
}
// ── 输出区滚动元素 ────────────────────────────────────────────────────────────
function getOutputScrollEl(): HTMLElement | null {
return store.getOutputEl()
?? (viewportWrapRef.value?.querySelector(".tc-output") as HTMLElement | null ?? null);
}
// ── 视口点击 → 聚焦输入锚点 ─────────────────────────────────────────────────
function focusInputAnchor(options?: { source?: "tap" | "click" }) {
if (store.state !== "connected") return;
const selection = typeof window !== "undefined" ? window.getSelection() : null;
if (selection && !selection.isCollapsed && selection.toString().length > 0) return;
const activeEl = document.activeElement as HTMLElement | null;
const alreadyAnchorFocused =
activeEl?.tagName === "TEXTAREA" && activeEl.classList.contains("tc-input-anchor");
debugTouch("focus:request", {
source: options?.source ?? "unknown",
alreadyAnchorFocused,
keyboardLikelyVisible,
state: store.state,
});
// 已聚焦且软键盘可见时不重复 focus避免键盘闪退
if (alreadyAnchorFocused && keyboardLikelyVisible) return;
inputBarRef.value?.focus();
}
// ── 点击处理(桌面) ─────────────────────────────────────────────────────────
function handleViewportClick(e: MouseEvent) {
if (isTouchDevice()) {
e.preventDefault();
return;
}
focusInputAnchor({ source: "click" });
}
// ── 触控处理 ─────────────────────────────────────────────────────────────────
function handleViewportTouchStart(e: TouchEvent) {
const touch = e.touches[0] ?? e.changedTouches[0];
if (!touch) return;
touchStartX = touch.clientX;
touchStartY = touch.clientY;
touchStartAt = Date.now();
touchMoved = false;
touchDidScroll = false;
const scrollEl = getOutputScrollEl();
touchStartScrollTop = scrollEl?.scrollTop ?? 0;
debugTouch("event:touchstart", { x: touchStartX, y: touchStartY, touchStartScrollTop });
}
function handleViewportTouchMove(e: TouchEvent) {
const touch = e.touches[0] ?? e.changedTouches[0];
if (!touch) return;
const dx = Math.abs(touch.clientX - touchStartX);
const dy = Math.abs(touch.clientY - touchStartY);
if (dx > TAP_MOVE_THRESHOLD_PX || dy > TAP_MOVE_THRESHOLD_PX) {
touchMoved = true;
}
// 兜底滚动:部分机型/输入法场景原生滚动不稳定,手动驱动输出区纵向滚动
const scrollEl = getOutputScrollEl();
if (!scrollEl) return;
const maxTop = Math.max(0, scrollEl.scrollHeight - scrollEl.clientHeight);
if (maxTop <= 0) return;
const deltaY = touch.clientY - touchStartY;
const nextTop = Math.max(0, Math.min(maxTop, touchStartScrollTop - deltaY));
if (Math.abs(nextTop - scrollEl.scrollTop) < 1) return;
scrollEl.scrollTop = nextTop;
touchDidScroll = true;
e.preventDefault();
debugTouch("event:touchmove_scroll", { deltaY, nextTop, maxTop });
}
function handleViewportTouchEnd(e: TouchEvent) {
const duration = Date.now() - touchStartAt;
debugTouch("event:touchend", { touchMoved, touchDidScroll, duration, keyboardLikelyVisible });
if (touchDidScroll) return;
if (touchMoved) return;
if (duration > TAP_MAX_DURATION_MS) return;
// 轻触:阻止合成 click无延迟聚焦以保持用户手势上下文移动端弹键盘必须在用户手势内
e.preventDefault();
debugTouch("event:tap_focus");
focusInputAnchor({ source: "tap" });
}
// ── 防双击缩放(工具栏/输入区) ───────────────────────────────────────────────
function handlePageTouchEndCapture(e: TouchEvent) {
const touch = e.changedTouches[0] ?? e.touches[0];
if (!touch) return;
const target = e.target as HTMLElement | null;
const toolbarBottom = toolbarWrapRef.value?.getBoundingClientRect().bottom ?? 0;
const inToolbarBand = touch.clientY <= toolbarBottom + 8;
const inInputZone = Boolean(target?.closest('[data-zone="native-input-zone"]'));
if (!inToolbarBand && !inInputZone) return;
const now = Date.now();
const dt = now - lastZoomGuardTapAt;
lastZoomGuardTapAt = now;
if (dt > DOUBLE_TAP_ZOOM_GUARD_MS) return;
e.preventDefault();
debugTouch("event:double_tap_zoom_guard", { dt });
}
// ── 软键盘状态同步(供 focusInputAnchor 判断) ────────────────────────────────
// KeyboardAdjustController 通过 visualViewport resize 驱动实际 DOM 滚动。
// 此处仅维护 keyboardLikelyVisible 标志供聚焦逻辑使用。
const KEYBOARD_OPEN_THRESHOLD_PX = 120;
const KEYBOARD_CLOSE_HYSTERESIS_PX = 40;
let baselineVpHeight = 0;
function onVisualViewportResize() {
const vv = window.visualViewport;
const h = vv ? vv.height : window.innerHeight;
if (h <= 0) return;
if (!keyboardLikelyVisible && h > baselineVpHeight) {
baselineVpHeight = h;
}
const shrink = baselineVpHeight - h;
if (!keyboardLikelyVisible && shrink > KEYBOARD_OPEN_THRESHOLD_PX) {
keyboardLikelyVisible = true;
debugTouch("keyboard:open_flag_set", { baseline: baselineVpHeight, current: h, shrink });
return;
}
if (keyboardLikelyVisible && h >= baselineVpHeight - KEYBOARD_CLOSE_HYSTERESIS_PX) {
keyboardLikelyVisible = false;
debugTouch("keyboard:close_flag_cleared", { baseline: baselineVpHeight, current: h });
}
}
const onPageUnload = () => { pageUnloading = true; };
// ── 生命周期 ──────────────────────────────────────────────────────────────────
onMounted(async () => {
await serverStore.ensureBootstrapped();
if (typeof window !== "undefined") {
window.addEventListener("pagehide", onPageUnload);
window.addEventListener("beforeunload", onPageUnload);
const vv = window.visualViewport;
baselineVpHeight = vv ? vv.height : window.innerHeight;
// 软键盘调整控制器
kbAdjust = new KeyboardAdjustController({
getOutputEl: () => store.getOutputEl(),
getCursorOffsetPx: () => store.getCursorOffsetPx(),
setAutoFollow: (enabled) => store.setAutoFollow(enabled),
setScrollLocked: (locked) => store.setScrollLocked(locked),
suppressAutoFollow: (ms) => store.suppressAutoFollow(ms),
debug: isDebugEnabled(),
});
kbAdjust.mount();
// 同步 keyboardLikelyVisible 标志
if (vv) {
vv.addEventListener("resize", onVisualViewportResize);
} else {
window.addEventListener("resize", onVisualViewportResize);
}
}
if (shouldAutoReconnect() && serverStore.selectedServer) {
await handleConnect({ silent: true });
}
});
watch(() => store.state, async (state) => {
if (state === "connected") {
await nextTick();
inputBarRef.value?.focus();
}
});
watch(() => store.rendererMode, async () => {
if (store.state === "connected") {
await nextTick();
inputBarRef.value?.focus();
}
});
onBeforeUnmount(() => {
offBell?.();
offTitle?.();
offBell = null;
offTitle = null;
if (typeof window !== "undefined") {
window.removeEventListener("pagehide", onPageUnload);
window.removeEventListener("beforeunload", onPageUnload);
const vv = window.visualViewport;
if (vv) {
vv.removeEventListener("resize", onVisualViewportResize);
} else {
window.removeEventListener("resize", onVisualViewportResize);
}
}
kbAdjust?.forceReset();
kbAdjust = null;
if (!pageUnloading) {
store.disconnect("leave_terminal_page");
}
});
// ── 计算属性 ──────────────────────────────────────────────────────────────────
const isMobile = computed(() =>
typeof navigator !== "undefined" &&
/Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent)
);
</script>
<template>
<section class="tc-page" @touchend.capture="handlePageTouchEndCapture">
<!-- 工具栏 -->
<div ref="toolbarWrapRef">
<TerminalToolbar @connect="handleConnect" @disconnect="handleDisconnect" />
</div>
<!-- 终端视口 -->
<div
ref="viewportWrapRef"
class="tc-viewport-wrap"
@click.stop="handleViewportClick"
@touchstart.passive="handleViewportTouchStart"
@touchmove="handleViewportTouchMove"
@touchend.stop="handleViewportTouchEnd"
>
<TerminalViewport />
</div>
<!-- 触控辅助按键移动端显示 -->
<TerminalTouchTools
v-if="isMobile"
:application-cursor-keys="appCursorKeys"
@paste="handlePaste"
/>
<!-- 输入栏 -->
<TerminalInputBar ref="inputBarRef" />
</section>
</template>
<style scoped>
.tc-page {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
overflow: hidden;
background: var(--tc-bg, #1a1a1a);
}
.tc-viewport-wrap {
flex: 1 1 0;
min-height: 0;
overflow: hidden;
cursor: text;
}
</style>

View File

@@ -0,0 +1,229 @@
<script setup lang="ts">
/**
* TerminalInputBar — 原生 textarea 输入锚点 + 工具栏输入行。
*
* 职责:
* - 承载软键盘弹出的原生 textareaIME 主通道)
* - 通过 DomInputBridge + InputBridge 将键盘事件映射为 VT 字节流
* - 提供手动发送文本按钮(移动端用途)
*/
import { ref, onMounted, onUnmounted } from "vue";
import { InputBridge } from "@remoteconn/terminal-core";
import { DomInputBridge } from "@/terminal/input/domInputBridge";
import { DomImeController } from "@/terminal/input/domImeController";
import { shouldHandleKeydownDirectly } from "@/terminal/input/inputPolicy";
import { useTerminalStore } from "@/terminal/stores/useTerminalStore";
const store = useTerminalStore();
const inputRef = ref<HTMLTextAreaElement | null>(null);
const emit = defineEmits<{
(e: "anchor-focus"): void;
(e: "anchor-blur"): void;
}>();
let domBridge: DomInputBridge | null = null;
let imeCtrl: DomImeController | null = null;
// 多行粘贴统一走 bracketed paste避免换行被 shell 解释为直接执行。
const inputBridge = new InputBridge({ cursorKeyMode: "normal", bracketedPaste: true });
const anchorCleanups: Array<() => void> = [];
function isInputDebugEnabled(): boolean {
if (typeof window === "undefined") return false;
try {
return window.localStorage.getItem("terminal.debugInputAnchor") === "1";
} catch {
return false;
}
}
function debugInput(message: string, detail?: unknown): void {
if (!isInputDebugEnabled()) return;
const prefix = `[TerminalInputBar][Anchor][${new Date().toISOString()}] ${message}`;
if (typeof detail === "undefined") {
console.log(prefix);
return;
}
console.log(prefix, detail);
}
function dispose() {
for (const fn of anchorCleanups) fn();
anchorCleanups.length = 0;
imeCtrl?.dispose();
domBridge?.dispose();
imeCtrl = null;
domBridge = null;
}
onMounted(() => {
const el = inputRef.value;
if (!el) return;
const onFocus = () => {
emit("anchor-focus");
debugInput("event:focus", {
readonly: el.readOnly,
disabled: el.disabled,
valueLen: el.value.length,
activeTag: (document.activeElement as HTMLElement | null)?.tagName ?? "null",
activeClass: (document.activeElement as HTMLElement | null)?.className ?? "",
});
};
const onBlur = () => {
emit("anchor-blur");
debugInput("event:blur", {
readonly: el.readOnly,
disabled: el.disabled,
valueLen: el.value.length,
activeTag: (document.activeElement as HTMLElement | null)?.tagName ?? "null",
activeClass: (document.activeElement as HTMLElement | null)?.className ?? "",
});
};
el.addEventListener("focus", onFocus);
el.addEventListener("blur", onBlur);
anchorCleanups.push(() => {
el.removeEventListener("focus", onFocus);
el.removeEventListener("blur", onBlur);
});
domBridge = new DomInputBridge();
domBridge.mount(el);
imeCtrl = new DomImeController();
imeCtrl.connect(domBridge);
// ── 键盘事件处理 ────────────────────────────────────────────────────────
domBridge.on("key", (p) => {
if (store.state !== "connected") return;
// 文本输入(含第三方输入法 key="测试"/"jk")统一走 input避免双发。
if (!shouldHandleKeydownDirectly(p)) {
return;
}
// Ctrl+C 无选区时发 ETX
if ((p.ctrlKey || p.metaKey) && p.key === "c") {
const sel = window.getSelection()?.toString() ?? "";
if (!sel) {
store.sendInput("\u0003", "keyboard");
} else {
document.execCommand("copy");
}
return;
}
// Ctrl+V / Cmd+V 粘贴
if ((p.ctrlKey || p.metaKey) && p.key === "v") {
// 由 paste 事件处理,此处阻止
return;
}
const seq = inputBridge.mapKey(p.key, p.code, p.ctrlKey, p.altKey, p.shiftKey, p.metaKey);
if (seq !== null) {
store.sendInput(seq, "keyboard");
}
});
// ── input 事件(处理未被 keydown 拦截的 ASCII + IME 非组合落笔) ────────
domBridge.on("input", (p) => {
if (p.isComposing) return; // 组合中由 compositionend 处理
if (imeCtrl?.core.shouldConsumeInputEvent()) return;
if (store.state !== "connected") return;
if (p.data) {
store.sendInput(p.data, "keyboard");
}
// 清空 textarea 防止回显积累
const el = inputRef.value;
if (el) el.value = "";
});
// ── 粘贴事件 ──────────────────────────────────────────────────────────────
domBridge.on("paste", (p) => {
if (store.state !== "connected") return;
const seq = inputBridge.mapPaste(p.text);
store.sendInput(seq, "paste");
});
// ── IME compositionend → 提交候选词 ──────────────────────────────────────
domBridge.on("compositionend", (p) => {
if (store.state !== "connected") return;
if (p.data) {
store.sendInput(p.data, "assist");
}
const el = inputRef.value;
if (el) el.value = "";
});
});
onUnmounted(() => {
dispose();
});
/** 外部调用:将输入锚点聚焦以弹出软键盘 */
function focus() {
debugInput("api:focus");
inputRef.value?.focus({ preventScroll: true });
}
defineExpose({ focus });
</script>
<template>
<div class="tc-input-bar" data-zone="native-input-zone">
<!-- 原生输入锚点VT 键盘主通道对用户不可见 -->
<textarea
ref="inputRef"
class="tc-input-anchor"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
aria-label="终端键盘输入"
:readonly="store.state !== 'connected'"
rows="1"
></textarea>
</div>
</template>
<style scoped>
.tc-input-bar {
position: fixed;
left: 0;
top: 0;
width: 1px;
height: 1px;
overflow: visible;
opacity: 0.01;
pointer-events: none;
z-index: 10;
}
/* 隐藏的 VT 键盘锚点 */
.tc-input-anchor {
position: fixed;
left: 0;
top: 0;
width: 1px;
height: 1px;
padding: 0;
margin: 0;
border: none;
outline: none;
overflow: hidden;
opacity: 0.01;
pointer-events: none;
z-index: 10;
resize: none;
background: transparent;
color: transparent;
caret-color: transparent;
-webkit-appearance: none;
/*
* 将焦点锚点放在顶部,避免部分移动端浏览器在软键盘弹出时
* 为“底部焦点元素可见”而触发布局上推,导致工具条/光标瞬时跳位。
*/
transform: translateZ(0);
}
</style>

View File

@@ -0,0 +1,148 @@
<script setup lang="ts">
import { computed } from "vue";
import { useTerminalStore } from "@/terminal/stores/useTerminalStore";
const store = useTerminalStore();
const isConnected = computed(() => store.state === "connected");
const isConnecting = computed(() => store.state === "connecting" || store.state === "auth_pending" || store.state === "reconnecting");
const canReconnect = computed(() => store.state === "disconnected" || store.state === "error");
const canDisconnect = computed(() => isConnected.value || isConnecting.value);
const connectionLabel = computed(() => {
if (canReconnect.value) return "重连";
if (canDisconnect.value) return "断开";
return "连接";
});
const connectionDisabled = computed(() => false);
function handleClear() {
store.clearTerminal();
}
const emit = defineEmits<{
(e: "connect"): void;
(e: "disconnect"): void;
}>();
function handleConnectionAction() {
if (canDisconnect.value) {
emit("disconnect");
} else {
emit("connect");
}
}
</script>
<template>
<div class="page-toolbar terminal-toolbar tc-toolbar" @dblclick.prevent>
<div class="toolbar-left">
<button
class="icon-btn"
type="button"
title="清屏"
aria-label="清屏"
@click="handleClear"
>
<span aria-hidden="true"></span>
</button>
</div>
<div class="toolbar-spacer"></div>
<div class="terminal-toolbar-actions">
<!-- 标题 -->
<h2 class="page-title terminal-title" :title="store.title">
{{ store.title || "Terminal" }}
</h2>
<!-- 状态 -->
<span class="state-chip" :class="`state-${store.state}`">{{ store.state }}</span>
<!-- 延迟 -->
<span v-if="store.latencyMs > 0" class="state-chip">{{ store.latencyMs }}ms</span>
<span class="terminal-toolbar-divider" aria-hidden="true"></span>
<!-- 连接/断开 -->
<button
class="terminal-connection-switch"
:class="canReconnect ? 'is-reconnect' : 'is-disconnect'"
:disabled="connectionDisabled"
:aria-label="connectionLabel"
@click="handleConnectionAction"
>
<span class="terminal-connection-switch-label">{{ connectionLabel }}</span>
<span class="terminal-connection-switch-knob" aria-hidden="true"></span>
</button>
</div>
</div>
</template>
<style scoped>
.tc-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-bottom: 1px solid #333;
background: #1f1f1f;
color: #e6e6e6;
flex-shrink: 0;
/* 禁止连续轻点触发浏览器双击放大 */
touch-action: manipulation;
-webkit-user-select: none;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
.toolbar-left,
.terminal-toolbar-actions {
display: flex;
align-items: center;
gap: 8px;
}
.toolbar-spacer {
flex: 1 1 auto;
}
.icon-btn,
.terminal-connection-switch {
border: 1px solid #4a4a4a;
background: #2a2a2a;
color: #e6e6e6;
border-radius: 6px;
height: 30px;
padding: 0 10px;
cursor: pointer;
touch-action: manipulation;
-webkit-user-select: none;
user-select: none;
}
.icon-btn:disabled,
.terminal-connection-switch:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.page-title {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #e6e6e6;
}
.state-chip {
border: 1px solid #4a4a4a;
border-radius: 999px;
padding: 2px 8px;
font-size: 12px;
color: #d5d5d5;
background: #2b2b2b;
}
.terminal-toolbar-divider {
width: 1px;
height: 20px;
background: #444;
}
</style>

View File

@@ -0,0 +1,191 @@
<script setup lang="ts">
/**
* TerminalTouchTools — 移动端触控辅助按键区。
*
* 职责:
* - 提供方向键、Enter、Ctrl+C、Tab、Paste 等常用控制键
* - 输出标准 VT 控制序列,不直接访问 Transport
* - 通过 store.sendInput() 发送
*/
import { computed } from "vue";
import { useTerminalStore } from "@/terminal/stores/useTerminalStore";
const props = defineProps<{
/** 是否使用应用光标键模式DECCKM 激活时为 true */
applicationCursorKeys?: boolean;
}>();
const emit = defineEmits<{
(e: "paste"): void;
}>();
const store = useTerminalStore();
const disabled = computed(() => store.state !== "connected");
/** 发送 VT 控制序列source 固定为 keyboard以复用 meta 标记 */
function send(seq: string) {
if (disabled.value) return;
store.sendInput(seq, "keyboard");
}
const arrowUp = computed(() => props.applicationCursorKeys ? "\x1bOA" : "\x1b[A");
const arrowDown = computed(() => props.applicationCursorKeys ? "\x1bOB" : "\x1b[B");
const arrowRight = computed(() => props.applicationCursorKeys ? "\x1bOC" : "\x1b[C");
const arrowLeft = computed(() => props.applicationCursorKeys ? "\x1bOD" : "\x1b[D");
</script>
<template>
<div class="tc-touch-tools" role="toolbar" aria-label="终端触控辅助工具栏">
<!-- 方向键 -->
<button
class="tc-key-btn"
type="button"
:disabled="disabled"
aria-label="上箭头"
@click="send(arrowUp)"
></button>
<button
class="tc-key-btn"
type="button"
:disabled="disabled"
aria-label="下箭头"
@click="send(arrowDown)"
></button>
<button
class="tc-key-btn"
type="button"
:disabled="disabled"
aria-label="左箭头"
@click="send(arrowLeft)"
></button>
<button
class="tc-key-btn"
type="button"
:disabled="disabled"
aria-label="右箭头"
@click="send(arrowRight)"
></button>
<div class="tc-key-separator" aria-hidden="true"></div>
<!-- Tab -->
<button
class="tc-key-btn"
type="button"
:disabled="disabled"
aria-label="Tab"
@click="send('\t')"
>Tab</button>
<!-- Enter (CR) -->
<button
class="tc-key-btn tc-key-enter"
type="button"
:disabled="disabled"
aria-label="Enter"
@click="send('\r')"
></button>
<!-- Ctrl+C -->
<button
class="tc-key-btn"
type="button"
:disabled="disabled"
aria-label="Ctrl+C 中断"
@click="send('\u0003')"
>^C</button>
<!-- Ctrl+D -->
<button
class="tc-key-btn"
type="button"
:disabled="disabled"
aria-label="Ctrl+D EOF"
@click="send('\u0004')"
>^D</button>
<!-- Esc -->
<button
class="tc-key-btn"
type="button"
:disabled="disabled"
aria-label="Esc"
@click="send('\x1b')"
>Esc</button>
<div class="tc-key-separator" aria-hidden="true"></div>
<!-- 粘贴调用外部 paste 逻辑 -->
<button
class="tc-key-btn"
type="button"
:disabled="disabled"
aria-label="粘贴"
@click="emit('paste')"
>粘贴</button>
</div>
</template>
<style scoped>
.tc-touch-tools {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 4px;
padding: 4px 8px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
background: var(--color-surface-1, #1e1e1e);
border-top: 1px solid var(--color-border, #333);
}
.tc-touch-tools::-webkit-scrollbar {
display: none;
}
.tc-key-btn {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 40px;
height: 34px;
padding: 0 10px;
border: 1px solid var(--color-border, #444);
border-radius: 5px;
background: var(--color-surface-2, #2a2a2a);
color: var(--color-text, #d4d4d4);
font-size: 13px;
font-family: "JetBrains Mono", monospace;
font-weight: 500;
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
transition: background 0.1s;
}
.tc-key-btn:active {
background: var(--color-surface-3, #3a3a3a);
}
.tc-key-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.tc-key-enter {
min-width: 48px;
background: var(--color-accent-dim, #1e3a5f);
border-color: var(--color-accent, #4a9eff);
color: var(--color-accent, #4a9eff);
}
.tc-key-separator {
width: 1px;
height: 20px;
background: var(--color-border, #444);
flex-shrink: 0;
margin: 0 2px;
}
</style>

View File

@@ -0,0 +1,92 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import { calcSize } from "@remoteconn/terminal-core";
import { DomMeasureAdapter } from "@/terminal/layout/domMeasureAdapter";
import { useTerminalStore } from "@/terminal/stores/useTerminalStore";
const store = useTerminalStore();
const viewportRef = ref<HTMLElement | null>(null);
let measure: DomMeasureAdapter | null = null;
let offResize: (() => void) | null = null;
/** 初次 fit 重试次数上限(应对路由切换延迟布局) */
const MAX_FIT_RETRIES = 8;
let fitRetryTimer: ReturnType<typeof setTimeout> | null = null;
let fitRetryCount = 0;
function doFit() {
if (!measure) return;
const { widthPx: charW, heightPx: lineH } = measure.measureChar();
const { widthPx: contW, heightPx: contH } = measure.measureContainer();
if (contW < 10 || contH < 10) {
// 容器未就绪,重试
if (fitRetryCount < MAX_FIT_RETRIES) {
fitRetryCount++;
fitRetryTimer = setTimeout(doFit, 120);
}
return;
}
fitRetryCount = 0;
const { cols, rows } = calcSize(contW, contH, charW, lineH);
store.resizeTerminal(cols, Math.max(rows, 12));
}
onMounted(() => {
const el = viewportRef.value;
if (!el) return;
measure = new DomMeasureAdapter(el, { fontFamily: "monospace", fontSize: "14px" });
measure.mount();
// 挂载渲染器
store.mountRenderer(el);
// 订阅 resize
offResize = measure.onResize(() => {
doFit();
});
// 首次 fit多次重试保障
doFit();
});
onUnmounted(() => {
offResize?.();
offResize = null;
if (fitRetryTimer !== null) {
clearTimeout(fitRetryTimer);
fitRetryTimer = null;
}
measure?.dispose();
measure = null;
// 渲染器 dispose 由 store 管理;此处仅退出测量
});
</script>
<template>
<div
ref="viewportRef"
class="tc-viewport-root"
data-zone="terminal-output-zone"
aria-label="终端视口"
role="region"
></div>
</template>
<style scoped>
.tc-viewport-root {
flex: 1 1 0;
min-height: 0;
overflow: hidden;
position: relative;
background: var(--tc-bg, #1a1a1a);
color: var(--tc-fg, #d4d4d4);
font-family: "JetBrains Mono", "Cascadia Code", "Fira Mono", "Menlo", monospace;
font-size: 14px;
line-height: 1.2;
cursor: text;
}
</style>

View File

@@ -0,0 +1,37 @@
import { ImeController } from "@remoteconn/terminal-core";
import type { DomInputBridge } from "./domInputBridge";
/**
* DomImeController — 将 DOM compositionstart/end 事件桥接到 ImeController。
* 必须在 DomInputBridge.mount() 之后调用 connect()。
*/
export class DomImeController {
public readonly core: ImeController;
private cleanup: (() => void)[] = [];
constructor() {
this.core = new ImeController(
(fn, ms) => setTimeout(fn, ms),
(id) => clearTimeout(id)
);
}
/** 将 inputBridge 的 composition 事件接入 ImeController */
connect(bridge: DomInputBridge): void {
this.cleanup.push(
bridge.on("compositionstart", ({ data }) => {
this.core.onCompositionStart(data);
}),
bridge.on("compositionend", ({ data }) => {
this.core.onCompositionEnd(data);
// compositionend 后的 input 事件由 ImeController.shouldConsumeInputEvent 拦截
})
);
}
dispose(): void {
for (const fn of this.cleanup) fn();
this.cleanup = [];
this.core.reset();
}
}

View File

@@ -0,0 +1,84 @@
import type { IInputSource, InputEventMap } from "@remoteconn/terminal-core";
type ListenerMap = { [K in keyof InputEventMap]?: Set<(payload: InputEventMap[K]) => void> };
/**
* DomInputBridge — 将 DOM 键盘/input/paste/composition 事件适配为 IInputSource。
* 挂载到 textarea 输入锚点元素上。
*/
export class DomInputBridge implements IInputSource {
private el: HTMLElement | null = null;
private listeners: ListenerMap = {};
private domCleanups: (() => void)[] = [];
mount(element: HTMLElement): void {
this.el = element;
const add = <K extends keyof HTMLElementEventMap>(
type: K,
handler: (e: HTMLElementEventMap[K]) => void,
opts?: AddEventListenerOptions
) => {
element.addEventListener(type, handler as EventListener, opts);
this.domCleanups.push(() => element.removeEventListener(type, handler as EventListener, opts));
};
add("keydown", (e) => {
if (e.isComposing) return;
this.emit("key", {
key: e.key,
code: e.code,
ctrlKey: e.ctrlKey,
altKey: e.altKey,
shiftKey: e.shiftKey,
metaKey: e.metaKey,
isComposing: e.isComposing,
});
});
add("input", (e) => {
const ie = e as InputEvent;
this.emit("input", {
data: ie.data ?? "",
isComposing: ie.isComposing,
});
});
add("paste", (e) => {
const pe = e as ClipboardEvent;
const text = pe.clipboardData?.getData("text") ?? "";
if (text) {
pe.preventDefault();
this.emit("paste", { text });
}
});
add("compositionstart", (e) => {
this.emit("compositionstart", { data: (e as CompositionEvent).data ?? "" });
});
add("compositionend", (e) => {
this.emit("compositionend", { data: (e as CompositionEvent).data ?? "" });
});
}
dispose(): void {
for (const cleanup of this.domCleanups) cleanup();
this.domCleanups = [];
this.el = null;
}
on<K extends keyof InputEventMap>(event: K, cb: (payload: InputEventMap[K]) => void): () => void {
if (!this.listeners[event]) {
(this.listeners as Record<string, Set<unknown>>)[event] = new Set();
}
const set = this.listeners[event] as Set<(p: InputEventMap[K]) => void>;
set.add(cb);
return () => set.delete(cb);
}
private emit<K extends keyof InputEventMap>(event: K, payload: InputEventMap[K]): void {
const set = this.listeners[event] as Set<(p: InputEventMap[K]) => void> | undefined;
if (!set) return;
for (const fn of set) fn(payload);
}
}

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { shouldHandleKeydownDirectly } from "./inputPolicy";
function makePayload(overrides: Partial<{ key: string; ctrlKey: boolean; altKey: boolean; metaKey: boolean }> = {}) {
return {
key: "",
ctrlKey: false,
altKey: false,
metaKey: false,
...overrides,
};
}
describe("shouldHandleKeydownDirectly", () => {
it("文本按键应返回 false普通英文", () => {
expect(shouldHandleKeydownDirectly(makePayload({ key: "a" }))).toBe(false);
});
it("第三方输入法在 keydown 给出整段文本时应返回 false英文串", () => {
expect(shouldHandleKeydownDirectly(makePayload({ key: "jk" }))).toBe(false);
});
it("第三方输入法在 keydown 给出整段文本时应返回 false中文", () => {
expect(shouldHandleKeydownDirectly(makePayload({ key: "测试" }))).toBe(false);
});
it("功能键应返回 trueEnter", () => {
expect(shouldHandleKeydownDirectly(makePayload({ key: "Enter" }))).toBe(true);
});
it("方向键应返回 trueArrowUp", () => {
expect(shouldHandleKeydownDirectly(makePayload({ key: "ArrowUp" }))).toBe(true);
});
it("组合键应返回 trueCtrl+C", () => {
expect(shouldHandleKeydownDirectly(makePayload({ key: "c", ctrlKey: true }))).toBe(true);
});
});

View File

@@ -0,0 +1,28 @@
import type { KeyPayload } from "@remoteconn/terminal-core";
/**
* 仅这些按键允许在 keydown 阶段直接发送到终端。
* 其余文本输入(包括第三方输入法在 keydown 给出的整段文本)必须走 input 事件。
*/
const KEYDOWN_DIRECT_KEYS = new Set([
"Enter", "Backspace", "Tab", "Escape",
"Delete", "Insert", "Home", "End",
"PageUp", "PageDown",
"ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight",
"F1", "F2", "F3", "F4", "F5", "F6",
"F7", "F8", "F9", "F10", "F11", "F12",
]);
/**
* 判断某个 keydown 事件是否应直接进入 VT 发送链路。
* 规则:
* 1) 带 ctrl/alt/meta 的组合键一律允许(如 Ctrl+C、Alt+X
* 2) 无组合键时,只允许功能键白名单;文本键一律禁止。
*/
export function shouldHandleKeydownDirectly(payload: Pick<KeyPayload, "key" | "ctrlKey" | "altKey" | "metaKey">): boolean {
if (payload.ctrlKey || payload.altKey || payload.metaKey) {
return true;
}
return KEYDOWN_DIRECT_KEYS.has(payload.key);
}

View File

@@ -0,0 +1,444 @@
/**
* KeyboardAdjustController — 软键盘弹出/收回时的视口滚动控制器。
*
* 职责:
* - 监听 visualViewport resize 判断软键盘状态
* - 键盘弹出时:将光标行(提示符行)滚动到屏幕距顶 1/4 处
* - 键盘收回时:恢复弹出前的滚动位置
*
* 设计原则:
* - 只使用 visualViewport.resize不混用 window.resize避免误触
* - 通过回调注入 DOM 获取逻辑,不直接依赖 Vue/Pinia便于测试
* - 所有状态集中在此类TerminalPage.vue 只负责初始化/销毁
*/
export interface KeyboardAdjustOptions {
/**
* 获取输出区滚动容器(即渲染器创建的 .tc-output 元素)。
* 若返回 null 则跳过本次调整。
*/
getOutputEl: () => HTMLElement | null;
/**
* 获取光标行距输出区滚动顶部的像素偏移。
* 若返回 null 则回退为滚动到底部。
*/
getCursorOffsetPx: () => number | null;
/**
* 设置渲染器的 autoFollow 状态。
* 键盘弹出期间需关闭 autoFollow防止新输出覆盖定位。
* 键盘收回后恢复 autoFollow。
*/
setAutoFollow: (enabled: boolean) => void;
/**
* 冻结 scroll 事件对 autoFollow 的影响,持续 ms 毫秒。
* iOS WebKit 的 scroll 事件是异步的,必须用时间窗口而非同步锁。
* ms=0 立即解冻。
* 可选:未提供时静默跳过(兼容未注入此回调的调用方)。
*/
setScrollLocked?: (ms: number) => void;
/**
* 在指定毫秒内抑制 store 的 followBottom 调用。
* 用于防止键盘收回后远端 resize 回刷覆盖恢复的滚动位置。
*/
suppressAutoFollow: (ms: number) => void;
/** 调试开关(可选,默认关闭)— 保留但现在关键路径已用无条件日志 */
debug?: boolean;
}
/** 键盘弹出前保存的视口快照 */
interface KeyboardSnapshot {
/** 弹出前输出区的 scrollTop */
scrollTop: number;
/** 弹出前输出区的 clientHeight */
clientHeight: number;
/** 弹出前输出区的 scrollHeight */
scrollHeight: number;
/** 弹出前 visualViewport 高度 */
viewportHeight: number;
/** 用户在键盘可见期间是否手动滚动过 */
userScrolled: boolean;
}
/**
* 软键盘收缩阈值visualViewport 高度减少超过此值则判定为键盘弹出。
* 实测 iOS/Android 软键盘通常 200px+120px 可覆盖大多数情况。
*/
const KEYBOARD_OPEN_THRESHOLD_PX = 120;
/**
* 软键盘关闭迟滞:高度恢复至 baseline - HYSTERESIS 以内即判定为收起。
* 避免动画过程中反复触发。
*/
const KEYBOARD_CLOSE_HYSTERESIS_PX = 40;
/**
* 光标行在屏幕中的目标位置比例0=顶, 1=底)。
* 0.25 = 距顶 1/4 处。
*/
const CURSOR_TARGET_RATIO = 0.25;
/**
* 键盘弹出后等待 DOM 稳定再执行滚动的延迟ms
* iOS 软键盘动画约 250ms此处稍微保守取 280ms。
*/
const SCROLL_SETTLE_DELAY_MS = 280;
export class KeyboardAdjustController {
private opts: KeyboardAdjustOptions & { debug: boolean };
private baselineHeight = 0;
private keyboardVisible = false;
private snapshot: KeyboardSnapshot | null = null;
private settleTimer: ReturnType<typeof setTimeout> | null = null;
// visualViewport resize 处理器引用,用于清理
private readonly vpResizeHandler: () => void;
// 用户手动滚动检测
private readonly scrollHandler: () => void;
private scrollListenerAttached = false;
/**
* 时间戳直到此时刻前scrollHandler 不将 snapshot.userScrolled 设为 true。
* iOS WebKit scroll 事件是异步的,必须用时间窗口而非布尔标志。
*/
private programmaticScrollUntil = 0;
constructor(options: KeyboardAdjustOptions) {
this.opts = { debug: false, ...options };
this.vpResizeHandler = () => this.onViewportResize();
this.scrollHandler = () => this.onUserScroll();
}
private log(msg: string, detail?: unknown): void {
// 关键路径使用无条件 console.log便于线上问题诊断
const prefix = `[KeyboardAdjust][${new Date().toISOString()}] ${msg}`;
detail !== undefined ? console.log(prefix, detail) : console.log(prefix);
}
/** 初始化:记录基线高度,开始监听 visualViewport */
mount(): void {
if (typeof window === "undefined") return;
const vv = window.visualViewport;
if (!vv) {
this.log("mount — visualViewport NOT supported, fallback to window.resize");
window.addEventListener("resize", this.vpResizeHandler);
} else {
vv.addEventListener("resize", this.vpResizeHandler);
}
this.baselineHeight = vv ? vv.height : window.innerHeight;
this.log("mount — OK", {
baseline: this.baselineHeight,
hasVisualViewport: !!vv,
innerHeight: window.innerHeight,
});
}
/** 销毁:清理监听器和定时器 */
dispose(): void {
if (typeof window === "undefined") return;
const vv = window.visualViewport;
if (!vv) {
window.removeEventListener("resize", this.vpResizeHandler);
} else {
vv.removeEventListener("resize", this.vpResizeHandler);
}
this.detachScrollListener();
if (this.settleTimer !== null) {
clearTimeout(this.settleTimer);
this.settleTimer = null;
}
// 确保 autoFollow 已恢复
this.opts.setAutoFollow(true);
this.snapshot = null;
this.keyboardVisible = false;
this.log("disposed");
}
/** 强制重置(页面卸载时调用) */
forceReset(): void {
if (this.snapshot) {
this.restoreScroll("unmount");
}
this.dispose();
}
// ── 核心逻辑 ─────────────────────────────────────────────────────────────
private onViewportResize(): void {
const vv = window.visualViewport;
const currentH = vv ? vv.height : window.innerHeight;
if (currentH <= 0) return;
// 基线更新:仅在没有键盘时,且高度比当前基线更大才更新
if (!this.keyboardVisible && currentH > this.baselineHeight) {
this.log("baseline:updated", { old: this.baselineHeight, new: currentH });
this.baselineHeight = currentH;
}
const shrink = this.baselineHeight - currentH;
this.log("viewport:resize", {
currentH,
baseline: this.baselineHeight,
shrink,
keyboardVisible: this.keyboardVisible,
OPEN_THRESHOLD: KEYBOARD_OPEN_THRESHOLD_PX,
CLOSE_HYSTERESIS: KEYBOARD_CLOSE_HYSTERESIS_PX,
});
if (!this.keyboardVisible && shrink > KEYBOARD_OPEN_THRESHOLD_PX) {
// 键盘弹出
this.keyboardVisible = true;
this.onKeyboardOpen(currentH);
return;
}
if (this.keyboardVisible && currentH >= this.baselineHeight - KEYBOARD_CLOSE_HYSTERESIS_PX) {
// 键盘收回
this.keyboardVisible = false;
this.onKeyboardClose();
}
}
private onKeyboardOpen(currentViewportH: number): void {
const outputEl = this.opts.getOutputEl();
if (!outputEl) {
this.log("keyboard:OPEN — no outputEl, skipped");
return;
}
// 保存弹出前的状态
this.snapshot = {
scrollTop: outputEl.scrollTop,
clientHeight: outputEl.clientHeight,
scrollHeight: outputEl.scrollHeight,
viewportHeight: currentViewportH,
userScrolled: false,
};
this.log("keyboard:OPEN — snapshot saved", {
snapshot: this.snapshot,
baseline: this.baselineHeight,
currentViewportH,
});
// 关闭 autoFollow防止新输出把光标滚走
this.opts.setAutoFollow(false);
// 注册用户滚动检测
this.attachScrollListener(outputEl);
// 等键盘动画完成后执行定位
if (this.settleTimer !== null) clearTimeout(this.settleTimer);
this.log(`keyboard:OPEN — will position in ${SCROLL_SETTLE_DELAY_MS}ms`);
this.settleTimer = setTimeout(() => {
this.settleTimer = null;
this.positionCursorAtQuarter(outputEl);
}, SCROLL_SETTLE_DELAY_MS);
}
private onKeyboardClose(): void {
this.log("keyboard:CLOSE — detaching scroll listener, cancelling settle timer");
this.detachScrollListener();
if (this.settleTimer !== null) {
clearTimeout(this.settleTimer);
this.settleTimer = null;
}
// iOS 键盘收回时outputEl.clientHeight 的恢复比 visualViewport.height 更晚,
// 固定延迟不可靠。改用轮询:每 32ms 检查一次 clientHeight 是否已恢复到 baseline
// 最多等待 600ms超时则强制执行。
const expectedClientH = this.snapshot
? this.snapshot.clientHeight // 弹出前保存的 clientHeight
: 0;
const startedAt = Date.now();
const MAX_WAIT_MS = 600;
const POLL_MS = 32;
// 在整个 poll 等待期间保持 scroll 冻结,防止 iOS 键盘收起动画触发的
// scroll 事件把 autoFollow 改回 true干扰后续 restoreScroll 的逻辑。
this.opts.setScrollLocked?.(MAX_WAIT_MS + POLL_MS);
const poll = () => {
const outputEl = this.opts.getOutputEl();
const elapsed = Date.now() - startedAt;
const currentH = outputEl?.clientHeight ?? 0;
const recovered = currentH >= expectedClientH - 4; // 允许 4px 误差
this.log(`keyboard:CLOSE — poll clientHeight=${currentH} expected=${expectedClientH} recovered=${recovered} elapsed=${elapsed}ms`);
if (recovered || elapsed >= MAX_WAIT_MS) {
this.settleTimer = null;
this.restoreScroll("keyboard_close");
return;
}
this.settleTimer = setTimeout(poll, POLL_MS);
};
this.settleTimer = setTimeout(poll, POLL_MS);
}
private onUserScroll(): void {
const now = Date.now();
if (now < this.programmaticScrollUntil) {
this.log(`scroll:handler — programmatic window(${this.programmaticScrollUntil - now}ms), skip userScrolled`);
return;
}
if (this.snapshot) {
this.snapshot.userScrolled = true;
this.log("scroll:handler — USER scroll detected, userScrolled=true");
}
}
/**
* 将光标行定位到当前可见区域距顶 1/4 处。
*
* 计算公式:
* 目标 scrollTop = cursorOffsetPx - visibleHeight * CURSOR_TARGET_RATIO
*
* 其中 visibleHeight 是键盘弹出后 outputEl 的实际可见高度。
*/
private positionCursorAtQuarter(outputEl: HTMLElement): void {
const cursorOffsetPx = this.opts.getCursorOffsetPx();
// 键盘弹出后输出区的实际可见高度clientHeight 已被键盘压缩)
const visibleH = outputEl.clientHeight;
let targetScrollTop: number;
if (cursorOffsetPx !== null) {
targetScrollTop = cursorOffsetPx - visibleH * CURSOR_TARGET_RATIO;
} else {
// 回退:滚到底部
targetScrollTop = outputEl.scrollHeight - visibleH;
}
const maxTop = Math.max(0, outputEl.scrollHeight - visibleH);
targetScrollTop = Math.max(0, Math.min(maxTop, targetScrollTop));
this.log("keyboard:POSITION_CURSOR", {
cursorOffsetPx,
visibleH,
CURSOR_TARGET_RATIO,
targetScrollTop,
currentScrollTop: outputEl.scrollTop,
scrollHeight: outputEl.scrollHeight,
maxTop,
});
// iOS WebKit 的 scroll 事件是异步的(下一个 task用时间窗口屏蔽。
// 200ms 足够覆盖任何平台的异步 scroll 回调。
const FREEZE_MS = 200;
this.programmaticScrollUntil = Date.now() + FREEZE_MS;
this.opts.setScrollLocked?.(FREEZE_MS); // 同步冻结 renderer scroll → autoFollow
outputEl.scrollTop = targetScrollTop;
// 定位完成后必须强制 autoFollow=falsescroll 事件可能在时间窗口内把它改回 true
this.opts.setAutoFollow(false);
// 抑制 store 层 followBottom整个键盘可见期间屏蔽远端 stdout 的滚动干扰
this.opts.suppressAutoFollow(60_000);
this.log("keyboard:POSITION_CURSOR — done, final scrollTop=" + outputEl.scrollTop);
}
private restoreScroll(reason: string): void {
const snap = this.snapshot;
this.snapshot = null;
// 清除键盘弹出时设置的长期 scroll 冻结(先清,后面可能重新设短窗口)
this.opts.setScrollLocked?.(0);
if (!snap) {
this.opts.setAutoFollow(true);
this.log(`keyboard:RESTORE — no snapshot (${reason})`);
return;
}
const outputEl = this.opts.getOutputEl();
if (!outputEl) {
this.opts.setAutoFollow(true);
this.log(`keyboard:RESTORE — SKIP (no outputEl)`);
this.opts.suppressAutoFollow(0);
this.opts.suppressAutoFollow(500);
return;
}
const before = {
scrollTop: outputEl.scrollTop,
clientHeight: outputEl.clientHeight,
scrollHeight: outputEl.scrollHeight,
};
const contentChanged = Math.abs(before.scrollHeight - snap.scrollHeight) > 4;
this.log(`keyboard:RESTORE (${reason})`, {
userScrolled: snap.userScrolled,
contentChanged,
snap,
before,
});
// 计算目标 scrollTop
let targetScrollTop: number;
if (snap.userScrolled) {
// 用户键盘可见期间主动上滚过:恢复弹出前的滚动位置(尊重用户意图)
const maxTop = Math.max(0, before.scrollHeight - before.clientHeight);
targetScrollTop = Math.max(0, Math.min(maxTop, snap.scrollTop));
this.log(`keyboard:RESTORE — userScrolled, target=${targetScrollTop}`);
} else {
// 用户未主动滚动:直接恢复弹出前保存的 scrollTop。
// snap.scrollTop 就是用户弹出键盘前看到的位置,这是最自然的"恢复"语义。
// 内容有增长时用 maxTop 兜底(防止超出范围)。
const maxTop = Math.max(0, before.scrollHeight - before.clientHeight);
targetScrollTop = Math.max(0, Math.min(maxTop, snap.scrollTop));
this.log(`keyboard:RESTORE — !userScrolled, restoring snap.scrollTop`, {
snapScrollTop: snap.scrollTop, target: targetScrollTop, maxTop,
contentChanged,
});
}
// 判断目标位置是否在底部4px 容差)
const maxTop = Math.max(0, before.scrollHeight - before.clientHeight);
const isAtBottom = Math.abs(targetScrollTop - maxTop) < 4;
// 关键:先设 autoFollow=false再设 scrollTop再按需恢复 autoFollow。
// 如果先 setAutoFollow(true)renderSnapshot 的下一帧会立刻把 scrollTop 覆盖成 scrollHeight。
this.opts.setAutoFollow(false);
const FREEZE_MS = 200;
this.programmaticScrollUntil = Date.now() + FREEZE_MS;
this.opts.setScrollLocked?.(FREEZE_MS);
outputEl.scrollTop = targetScrollTop;
this.log(`keyboard:RESTORE — done, final scrollTop=${outputEl.scrollTop} isAtBottom=${isAtBottom}`);
if (isAtBottom) {
// 在底部:恢复 autoFollow700ms 内保护远端 resize 回刷
this.opts.setAutoFollow(true);
this.opts.suppressAutoFollow(0); // 先清除60s长期抑制
this.opts.suppressAutoFollow(700); // 再设700ms短保护
} else {
// 不在底部(光标后有空行):保持 autoFollow=false。
// 先清除60s长期抑制再设700ms短保护。
// 顺序必须是:先清(0),再设(700)——反序会导致0把700覆盖。
this.opts.suppressAutoFollow(0); // 清除60s长期抑制
this.opts.suppressAutoFollow(700); // 设700ms短保护屏蔽立刻到来的stdout心跳
}
}
// ── 辅助 ─────────────────────────────────────────────────────────────────
private attachScrollListener(el: HTMLElement): void {
if (this.scrollListenerAttached) return;
el.addEventListener("scroll", this.scrollHandler, { passive: true });
this.scrollListenerAttached = true;
}
private detachScrollListener(): void {
if (!this.scrollListenerAttached) return;
const outputEl = this.opts.getOutputEl();
outputEl?.removeEventListener("scroll", this.scrollHandler);
this.scrollListenerAttached = false;
}
}

View File

@@ -0,0 +1,81 @@
import type { IMeasureAdapter } from "@remoteconn/terminal-core";
/**
* DomMeasureAdapter — 使用 DOM API 实现 IMeasureAdapter。
* 通过隐藏测量元素(单字符 span和 ResizeObserver 获取容器/字符尺寸。
*/
export class DomMeasureAdapter implements IMeasureAdapter {
private charWidthPx = 0;
private charHeightPx = 0;
private containerW = 0;
private containerH = 0;
private measureEl: HTMLElement | null = null;
private charSpan: HTMLElement | null = null;
private observer: ResizeObserver | null = null;
private callbacks = new Set<() => void>();
constructor(
private readonly container: HTMLElement,
/** 参考字体样式fontSize, fontFamily 等,会复制到测量元素上) */
private readonly fontStyle: Partial<CSSStyleDeclaration> = {}
) {}
mount(): void {
this.measureEl = document.createElement("div");
Object.assign(this.measureEl.style, {
position: "absolute",
visibility: "hidden",
pointerEvents: "none",
top: "0",
left: "0",
whiteSpace: "pre",
...this.fontStyle,
});
this.charSpan = document.createElement("span");
this.charSpan.textContent = "M"; // 等宽字符样本
this.measureEl.appendChild(this.charSpan);
document.body.appendChild(this.measureEl);
this.observer = new ResizeObserver(() => {
this.refresh();
for (const cb of this.callbacks) cb();
});
this.observer.observe(this.container);
this.refresh();
}
dispose(): void {
this.observer?.disconnect();
this.measureEl?.parentNode?.removeChild(this.measureEl);
this.measureEl = null;
this.charSpan = null;
}
measureChar(): { widthPx: number; heightPx: number } {
if (!this.charWidthPx) this.refresh();
return { widthPx: this.charWidthPx || 8, heightPx: this.charHeightPx || 16 };
}
measureContainer(): { widthPx: number; heightPx: number } {
if (!this.containerW) this.refresh();
return { widthPx: this.containerW || 320, heightPx: this.containerH || 200 };
}
onResize(cb: () => void): () => void {
this.callbacks.add(cb);
return () => this.callbacks.delete(cb);
}
private refresh(): void {
if (this.charSpan) {
const rect = this.charSpan.getBoundingClientRect();
this.charWidthPx = rect.width || 8;
this.charHeightPx = rect.height || 16;
}
const cRect = this.container.getBoundingClientRect();
const style = getComputedStyle(this.container);
const px = (s: string) => parseFloat(s) || 0;
this.containerW = cRect.width - px(style.paddingLeft) - px(style.paddingRight);
this.containerH = cRect.height - px(style.paddingTop) - px(style.paddingBottom);
}
}

View File

@@ -0,0 +1,208 @@
import { TerminalCore, sanitizeTerminalOutput } from "@remoteconn/terminal-core";
import type { RendererAdapter, TerminalSnapshot, TerminalLine, TerminalCell, ColorValue } from "@remoteconn/terminal-core";
import { FLAG_BOLD, FLAG_DIM, FLAG_ITALIC, FLAG_UNDERLINE, FLAG_INVERSE, FLAG_INVISIBLE, FLAG_STRIKETHROUGH, FLAG_OVERLINE } from "@remoteconn/terminal-core";
/** 16 色标准 xterm256 调色板(前 16 色) */
const ANSI16: string[] = [
"#000000","#cc0000","#00aa00","#aaaa00",
"#0000ee","#cc00cc","#00aaaa","#aaaaaa",
"#555555","#ff5555","#55ff55","#ffff55",
"#5555ff","#ff55ff","#55ffff","#ffffff",
];
function colorToCss(c: ColorValue, isFg: boolean): string {
switch (c.mode) {
case "default": return isFg ? "inherit" : "transparent";
case "p16": return ANSI16[c.value] ?? "inherit";
case "p256": return p256ToCss(c.value);
case "rgb": return `#${(c.value & 0xffffff).toString(16).padStart(6,"0")}`;
}
}
function p256ToCss(idx: number): string {
if (idx < 16) return ANSI16[idx] ?? "#000";
if (idx >= 232) {
const v = 8 + (idx - 232) * 10;
return `rgb(${v},${v},${v})`;
}
idx -= 16;
const b = idx % 6, g = Math.floor(idx / 6) % 6, r = Math.floor(idx / 36);
const ch = (x: number) => x === 0 ? 0 : 55 + x * 40;
return `rgb(${ch(r)},${ch(g)},${ch(b)})`;
}
/**
* CompatRenderer — DOM div/span 实现的渲染器。
* 每次 RAF 消费一次 TerminalCore.snapshot() 进行差量更新。
*/
export class CompatRenderer implements RendererAdapter {
private core: TerminalCore;
private container: HTMLElement | null = null;
private viewport: HTMLElement | null = null;
private cursorEl: HTMLElement | null = null;
private rowEls: HTMLElement[] = [];
private rafId: number = 0;
private lastRevision = -1;
constructor(cols = 80, rows = 24) {
this.core = new TerminalCore(cols, rows);
}
getCore(): TerminalCore { return this.core; }
mount(container: unknown): void {
this.container = container as HTMLElement;
this.container.classList.add("tc-compat-root");
Object.assign(this.container.style, {
position: "relative",
overflow: "hidden",
fontFamily: "monospace",
lineHeight: "1.2em",
});
this.viewport = document.createElement("div");
this.viewport.className = "tc-viewport";
Object.assign(this.viewport.style, {
position: "absolute",
top: "0", left: "0",
whiteSpace: "pre",
});
this.container.appendChild(this.viewport);
this.cursorEl = document.createElement("div");
this.cursorEl.className = "tc-cursor";
Object.assign(this.cursorEl.style, {
position: "absolute",
width: "0.6em",
height: "1.2em",
background:"rgba(255,255,255,0.8)",
pointerEvents: "none",
zIndex: "1",
});
this.container.appendChild(this.cursorEl);
this.rebuildRows(this.core.snapshot().rows);
this.scheduleRender();
}
write(data: string): void {
const sanitized = sanitizeTerminalOutput(data);
if (sanitized) {
this.core.write(sanitized);
this.scheduleRender();
}
}
resize(cols: number, rows: number): void {
this.core.resize(cols, rows);
this.rebuildRows(rows);
this.scheduleRender();
}
applySnapshot(snapshot: TerminalSnapshot): void {
// Re-create a clean core from the snapshot is not straightforward;
// the caller should pass the same TerminalCore reference instead.
// For "mode switch" replays, we just re-render the current state.
this.renderSnapshot(snapshot);
}
dispose(): void {
if (this.rafId) cancelAnimationFrame(this.rafId);
this.container?.classList.remove("tc-compat-root");
this.rowEls = [];
this.viewport?.parentNode?.removeChild(this.viewport);
this.cursorEl?.parentNode?.removeChild(this.cursorEl);
this.viewport = null;
this.cursorEl = null;
this.container = null;
}
// ── Private ────────────────────────────────────────────────────────────
private scheduleRender(): void {
if (this.rafId) return;
this.rafId = requestAnimationFrame(() => {
this.rafId = 0;
const snap = this.core.snapshot();
if (snap.revision === this.lastRevision) return;
this.lastRevision = snap.revision;
this.renderSnapshot(snap);
});
}
private rebuildRows(rows: number): void {
if (!this.viewport) return;
this.viewport.innerHTML = "";
this.rowEls = [];
for (let r = 0; r < rows; r++) {
const div = document.createElement("div");
div.className = "tc-row";
this.viewport.appendChild(div);
this.rowEls.push(div);
}
}
private renderSnapshot(snap: TerminalSnapshot): void {
if (!this.viewport || !this.cursorEl) return;
// Ensure row count matches
if (this.rowEls.length !== snap.rows) {
this.rebuildRows(snap.rows);
}
for (let r = 0; r < snap.rows; r++) {
const rowEl = this.rowEls[r];
if (!rowEl) continue;
const line = snap.lines[r];
if (line) {
rowEl.innerHTML = lineToHtml(line);
}
}
// Update cursor
const charEm = 0.6; // em per character (monospace approx)
const lineEm = 1.2;
Object.assign(this.cursorEl.style, {
display: snap.cursor.visible ? "block" : "none",
left: `${snap.cursor.x * charEm}em`,
top: `${snap.cursor.y * lineEm}em`,
width: `${charEm}em`,
height: `${lineEm}em`,
});
}
}
function lineToHtml(line: TerminalLine): string {
let html = "";
for (const cell of line.cells) {
html += cellToHtml(cell);
}
return html;
}
function cellToHtml(cell: TerminalCell): string {
const { flags } = cell;
let fg = colorToCss(cell.fg, true);
let bg = colorToCss(cell.bg, false);
if (flags & FLAG_INVERSE) { [fg, bg] = [bg === "transparent" ? "#ffffff" : bg, fg === "inherit" ? "#000000" : fg]; }
const invisible = flags & FLAG_INVISIBLE;
const char = invisible ? " " : (cell.char || " ");
const styles: string[] = [];
if (fg !== "inherit") styles.push(`color:${fg}`);
if (bg !== "transparent") styles.push(`background:${bg}`);
if (flags & FLAG_BOLD) styles.push("font-weight:bold");
if (flags & FLAG_DIM) styles.push("opacity:0.5");
if (flags & FLAG_ITALIC) styles.push("font-style:italic");
const tdecs: string[] = [];
if (flags & FLAG_UNDERLINE) tdecs.push("underline");
if (flags & FLAG_STRIKETHROUGH) tdecs.push("line-through");
if (flags & FLAG_OVERLINE) tdecs.push("overline");
if (tdecs.length) styles.push(`text-decoration:${tdecs.join(" ")}`);
const escChar = char === "&" ? "&amp;" : char === "<" ? "&lt;" : char === ">" ? "&gt;" : char;
if (!styles.length) return escChar;
return `<span style="${styles.join(";")}">${escChar}</span>`;
}

View File

@@ -0,0 +1,381 @@
import { TerminalCore, sanitizeTerminalOutput } from "@remoteconn/terminal-core";
import type { RendererAdapter, TerminalSnapshot, TerminalCell, ColorValue } from "@remoteconn/terminal-core";
import { FLAG_BOLD, FLAG_DIM, FLAG_ITALIC, FLAG_UNDERLINE, FLAG_INVERSE, FLAG_INVISIBLE, FLAG_STRIKETHROUGH, FLAG_OVERLINE } from "@remoteconn/terminal-core";
/**
* TextareaRenderer — 基于 <pre>/<textarea> 的轻量渲染器。
* 适合低端设备和降级场景rendererFallback=textarea
* 输出区使用 <pre> + span 显示(支持颜色属性)。
*/
const ANSI16: string[] = [
"#000000","#cc0000","#00aa00","#aaaa00",
"#0000ee","#cc00cc","#00aaaa","#aaaaaa",
"#555555","#ff5555","#55ff55","#ffff55",
"#5555ff","#ff55ff","#55ffff","#ffffff",
];
function colorToCss(c: ColorValue, isFg: boolean): string {
switch (c.mode) {
case "default": return isFg ? "inherit" : "transparent";
case "p16": return ANSI16[c.value] ?? "inherit";
case "p256": return p256ToCss(c.value);
case "rgb": return `#${(c.value & 0xffffff).toString(16).padStart(6, "0")}`;
}
}
function p256ToCss(idx: number): string {
if (idx < 16) return ANSI16[idx] ?? "#000";
if (idx >= 232) {
const v = 8 + (idx - 232) * 10;
return `rgb(${v},${v},${v})`;
}
idx -= 16;
const b = idx % 6;
const g = Math.floor(idx / 6) % 6;
const r = Math.floor(idx / 36);
const ch = (x: number) => x === 0 ? 0 : 55 + x * 40;
return `rgb(${ch(r)},${ch(g)},${ch(b)})`;
}
function escapeHtml(ch: string): string {
if (ch === "&") return "&amp;";
if (ch === "<") return "&lt;";
if (ch === ">") return "&gt;";
return ch;
}
function isTouchDebugEnabled(): boolean {
if (typeof window === "undefined") return false;
try {
return window.localStorage.getItem("terminal.debugTextareaRenderer") === "1";
} catch {
return false;
}
}
function debugRenderer(message: string, detail?: unknown): void {
if (!isTouchDebugEnabled()) return;
const prefix = `[TextareaRenderer][Touch][${new Date().toISOString()}] ${message}`;
if (typeof detail === "undefined") {
console.log(prefix);
return;
}
console.log(prefix, detail);
}
export class TextareaRenderer implements RendererAdapter {
private core: TerminalCore;
private container: HTMLElement | null = null;
private outputEl: HTMLPreElement | null = null;
private rafId: number = 0;
private lastRevision = -1;
/** 是否自动跟随滚动到底部 */
private autoFollow = true;
private blinkVisible = true;
private blinkTimer: ReturnType<typeof setInterval> | null = null;
private lastSnapshot: TerminalSnapshot | null = null;
/**
* 时间戳直到此时刻前scroll 事件不更新 autoFollow。
* iOS WebKit 的 scroll 事件是异步的(下一个 task不能用同步锁只能用时间窗口。
*/
private scrollFrozenUntil = 0;
constructor(cols = 80, rows = 24) {
this.core = new TerminalCore(cols, rows);
}
getCore(): TerminalCore { return this.core; }
mount(container: unknown): void {
this.container = container as HTMLElement;
this.container.classList.add("tc-textarea-root");
Object.assign(this.container.style, {
display: "flex",
flexDirection: "column",
height: "100%",
minHeight: "0"
});
// 输出区
this.outputEl = document.createElement("pre");
this.outputEl.className = "tc-output";
Object.assign(this.outputEl.style, {
margin: "0",
overflow: "auto",
fontFamily: "monospace",
whiteSpace: "pre-wrap",
wordBreak: "break-all",
flex: "1",
minHeight: "0",
cursor: "text",
userSelect: "text",
WebkitUserSelect: "text",
WebkitTouchCallout: "default",
WebkitOverflowScrolling: "touch",
overscrollBehavior: "contain",
touchAction: "pan-y",
position: "relative",
});
// 用户手动上滚时禁止自动跟随。
// iOS WebKit scroll 事件是异步的(下一个 task不能用同步锁。
// 用时间窗口scrollFrozenUntil 内的 scroll 事件跳过 autoFollow 更新。
this.outputEl.addEventListener("scroll", () => {
const now = Date.now();
if (now < this.scrollFrozenUntil) {
console.log(`[TextareaRenderer][scroll] FROZEN(${this.scrollFrozenUntil - now}ms) — skip autoFollow, scrollTop=${this.outputEl?.scrollTop}`);
return;
}
const el = this.outputEl!;
const atBottom = Math.abs(el.scrollTop + el.clientHeight - el.scrollHeight) < 4;
const prevAutoFollow = this.autoFollow;
this.autoFollow = atBottom;
console.log(`[TextareaRenderer][scroll] scrollTop=${el.scrollTop} clientH=${el.clientHeight} scrollH=${el.scrollHeight} atBottom=${atBottom} autoFollow: ${prevAutoFollow}${this.autoFollow}`);
});
this.outputEl.addEventListener("touchstart", (e: TouchEvent) => {
const t = e.changedTouches[0];
debugRenderer("event:touchstart", t ? { x: t.clientX, y: t.clientY } : undefined);
}, { passive: true });
this.outputEl.addEventListener("touchmove", (e: TouchEvent) => {
const t = e.changedTouches[0];
debugRenderer("event:touchmove", t ? { x: t.clientX, y: t.clientY } : undefined);
}, { passive: true });
this.outputEl.addEventListener("touchend", (e: TouchEvent) => {
const t = e.changedTouches[0];
debugRenderer("event:touchend", t ? { x: t.clientX, y: t.clientY } : undefined);
}, { passive: true });
this.container.appendChild(this.outputEl);
this.startBlink();
this.scheduleRender();
}
write(data: string): void {
const s = sanitizeTerminalOutput(data);
if (s) {
this.core.write(s);
this.scheduleRender();
}
}
resize(cols: number, rows: number): void {
this.core.resize(cols, rows);
this.scheduleRender();
}
applySnapshot(snapshot: TerminalSnapshot): void {
this.renderSnapshot(snapshot);
}
/**
* 强制恢复"跟随到底部"行为。
* 用于用户执行命令Enter确保新输出与提示符回到可见底部。
*/
followBottom(): void {
if (!this.outputEl || !this.lastSnapshot) return;
if (!this.autoFollow) return;
// 获取真实的光标DOM进行精准滚动
const cursorSpan = this.outputEl.querySelector('.tc-cursor-cell') as HTMLElement;
if (cursorSpan) {
const cursorBottom = cursorSpan.offsetTop + cursorSpan.offsetHeight;
const clientH = this.outputEl.clientHeight;
const maxScroll = Math.max(0, this.outputEl.scrollHeight - clientH);
// 尝试把光标置于视口底部附近
let targetScrollTop = cursorBottom - clientH + 20; // 留一点余量
targetScrollTop = Math.max(0, Math.min(maxScroll, targetScrollTop));
this.outputEl.scrollTop = targetScrollTop;
} else {
this.outputEl.scrollTop = this.outputEl.scrollHeight;
}
}
/**
* 返回光标行在输出滚动容器中距顶部的精准像素偏移量。
* 用于软键盘弹出时将提示符行置于屏幕特定位置。
* 若渲染器未就绪则返回 null。
*
* 计算方法:优先使用 getComputedStyle lineHeight 获得精确行高,
* 回退使用 scrollHeight/lineCount 估算。
*/
getCursorOffsetPx(): number | null {
if (!this.outputEl || !this.lastSnapshot) {
return null;
}
// 精准获取光标DOM元素的 offsetTop
const cursorSpan = this.outputEl.querySelector('.tc-cursor-cell') as HTMLElement;
if (cursorSpan) {
return cursorSpan.offsetTop;
}
// fallback: 如果没有可见光标,根据 cursor.y 估算
const snap = this.lastSnapshot;
const totalLines = snap.lines.length;
const cursorY = Math.max(0, Math.min(totalLines - 1, snap.cursor.y));
const scrollH = this.outputEl.scrollHeight;
const lineHeight = scrollH / Math.max(1, totalLines);
return cursorY * lineHeight;
}
/**
* 获取输出滚动容器元素。
*/
getOutputEl(): HTMLElement | null {
return this.outputEl;
}
/**
* 冻结 scroll 事件对 autoFollow 的影响,持续 ms 毫秒。
* 用于程序主动设置 scrollTop 后,屏蔽 iOS 异步 scroll 事件的干扰。
* ms=0 立即解冻。
*/
setScrollLocked(ms: number): void {
this.scrollFrozenUntil = ms > 0 ? Date.now() + ms : 0;
console.log(`[TextareaRenderer][setScrollFrozen] ms=${ms} frozenUntil=${ms > 0 ? new Date(this.scrollFrozenUntil).toISOString() : "CLEARED"}`);
}
/**
* 临时禁止 autoFollow键盘弹出/收回动画期间保护滚动位置)。
*/
setAutoFollow(enabled: boolean): void {
console.log(`[TextareaRenderer][setAutoFollow] ${this.autoFollow}${enabled}`);
this.autoFollow = enabled;
}
dispose(): void {
if (this.rafId) cancelAnimationFrame(this.rafId);
if (this.blinkTimer) {
clearInterval(this.blinkTimer);
this.blinkTimer = null;
}
this.container?.classList.remove("tc-textarea-root");
this.outputEl?.parentNode?.removeChild(this.outputEl);
this.outputEl = null;
this.container = null;
this.lastSnapshot = null;
}
// ── Private ──────────────────────────────────────────────────────────────
private scheduleRender(): void {
if (this.rafId) return;
this.rafId = requestAnimationFrame(() => {
this.rafId = 0;
const snap = this.core.snapshotWithScrollback();
if (snap.revision === this.lastRevision) return;
this.lastRevision = snap.revision;
this.blinkVisible = true;
this.renderSnapshot(snap);
});
}
private startBlink(): void {
if (this.blinkTimer) clearInterval(this.blinkTimer);
this.blinkTimer = setInterval(() => {
if (!this.lastSnapshot || !this.lastSnapshot.cursor.visible) return;
if (this.hasActiveSelectionInOutput()) return;
this.blinkVisible = !this.blinkVisible;
this.renderSnapshot(this.lastSnapshot, true);
}, 550);
}
private renderSnapshot(snap: TerminalSnapshot, fromBlink = false): void {
if (!this.outputEl) return;
this.lastSnapshot = snap;
if (this.hasActiveSelectionInOutput()) return;
const htmlRows: string[] = [];
const cursorY = Math.max(0, Math.min(snap.lines.length - 1, snap.cursor.y));
for (let rowIndex = 0; rowIndex < snap.lines.length; rowIndex++) {
const line = snap.lines[rowIndex];
if (!line) {
htmlRows.push("");
continue;
}
const cells = line.cells;
let rowHtml = "";
for (let col = 0; col < cells.length; col++) {
const cell = cells[col];
if (!cell) continue;
const cursorLogicalHere = snap.cursor.visible && rowIndex === cursorY && col === snap.cursor.x;
const cursorPaintHere = cursorLogicalHere && this.blinkVisible;
rowHtml += this.cellToHtml(cell, cursorPaintHere, cursorLogicalHere);
}
htmlRows.push(rowHtml.replace(/\s+$/g, ""));
}
// iOS Safari 在 innerHTML 赋值后会不稳定地改变 scrollTop。
// autoFollow=false键盘弹出期间必须手动保存和恢复 scrollTop。
const savedScrollTop = this.autoFollow ? -1 : this.outputEl.scrollTop;
this.outputEl.innerHTML = htmlRows.join("\n");
if (this.autoFollow && !fromBlink) {
const clientH = this.outputEl.clientHeight;
const maxScroll = Math.max(0, this.outputEl.scrollHeight - clientH);
const cursorSpan = this.outputEl.querySelector('.tc-cursor-cell') as HTMLElement;
if (cursorSpan) {
const cursorBottom = cursorSpan.offsetTop + cursorSpan.offsetHeight;
let targetScrollTop = cursorBottom - clientH + 20;
targetScrollTop = Math.max(0, Math.min(maxScroll, targetScrollTop));
this.outputEl.scrollTop = targetScrollTop;
} else {
this.outputEl.scrollTop = this.outputEl.scrollHeight;
}
} else if (savedScrollTop >= 0) {
// 恢复之前的 scrollTop防止 iOS 在 innerHTML 赋值后将其重置
const maxTop = Math.max(0, this.outputEl.scrollHeight - this.outputEl.clientHeight);
const restoredTop = Math.min(savedScrollTop, maxTop);
if (Math.abs(restoredTop - this.outputEl.scrollTop) > 1) {
console.log(`[TextareaRenderer][renderSnapshot] scrollTop drifted: was=${savedScrollTop} got=${this.outputEl.scrollTop} restoring=${restoredTop} maxTop=${maxTop}`);
this.outputEl.scrollTop = restoredTop;
}
}
}
private cellToHtml(cell: TerminalCell, cursorPaintHere: boolean, cursorLogicalHere: boolean): string {
const { flags } = cell;
let fg = colorToCss(cell.fg, true);
let bg = colorToCss(cell.bg, false);
if (flags & FLAG_INVERSE) {
[fg, bg] = [bg === "transparent" ? "#ffffff" : bg, fg === "inherit" ? "#000000" : fg];
}
if (cursorPaintHere) {
const cursorBg = fg === "inherit" ? "#d9d9d9" : fg;
const cursorFg = bg === "transparent" ? "#101010" : bg;
fg = cursorFg;
bg = cursorBg;
}
const char = (flags & FLAG_INVISIBLE) ? " " : (cell.char || " ");
const styles: string[] = [];
if (fg !== "inherit") styles.push(`color:${fg}`);
if (bg !== "transparent") styles.push(`background:${bg}`);
if (flags & FLAG_BOLD) styles.push("font-weight:bold");
if (flags & FLAG_DIM) styles.push("opacity:0.5");
if (flags & FLAG_ITALIC) styles.push("font-style:italic");
const tdecs: string[] = [];
if (flags & FLAG_UNDERLINE) tdecs.push("underline");
if (flags & FLAG_STRIKETHROUGH) tdecs.push("line-through");
if (flags & FLAG_OVERLINE) tdecs.push("overline");
if (tdecs.length) styles.push(`text-decoration:${tdecs.join(" ")}`);
const escaped = escapeHtml(char);
const classAttr = cursorLogicalHere ? ` class="tc-cursor-cell"` : "";
if (!styles.length) {
if (!cursorLogicalHere) return escaped;
return `<span${classAttr}>${escaped}</span>`;
}
return `<span${classAttr} style="${styles.join(";")}">${escaped}</span>`;
}
private hasActiveSelectionInOutput(): boolean {
if (!this.outputEl || typeof window === "undefined") return false;
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return false;
const range = selection.getRangeAt(0);
const node = range.commonAncestorContainer;
return this.outputEl.contains(node.nodeType === Node.TEXT_NODE ? node.parentNode : node);
}
}

View File

@@ -0,0 +1,539 @@
/**
* useTerminalStore — 新 terminal-core 架构的独立 Pinia store。
*
* 职责:
* - 管理 transport / renderer / outputBuffer 生命周期
* - 对外暴露 state / title / latencyMs / rendererMode 响应式状态
* - 提供 connect / disconnect / sendInput / clearTerminal / switchRenderer / mountRenderer
*
* 刻意与 sessionStore 分离,保持实验性单一职责。
*/
import { defineStore } from "pinia";
import { ref, shallowRef } from "vue";
import { OutputBuffer, SessionMachine, sanitizeTerminalOutput } from "@remoteconn/terminal-core";
import type { ConnectParams, SessionState } from "@remoteconn/terminal-core";
import type { TerminalTransport } from "@/services/transport/terminalTransport";
import { createTransport } from "@/services/transport/factory";
import { TextareaRenderer } from "@/terminal/renderer/textareaRenderer";
import { useSettingsStore } from "@/stores/settingsStore";
export type RendererMode = "textarea";
function logStore(_message: string, _detail?: unknown): void {
if (typeof window === "undefined") return;
try {
const enabled =
window.localStorage.getItem("terminal.debugTransport") === "1" ||
window.localStorage.getItem("terminal.debugPaste") === "1";
if (!enabled) return;
} catch {
return;
}
const prefix = `[TerminalStore][${new Date().toISOString()}] ${_message}`;
if (typeof _detail === "undefined") {
console.log(prefix);
return;
}
console.log(prefix, _detail);
}
export const useTerminalStore = defineStore("terminal-core", () => {
const OUTPUT_CACHE_PREFIX = "remoteconn:web:output-cache:v1:";
const PASTE_CHUNK_SIZE = 256;
const DEFAULT_CHUNK_SIZE = 2048;
// ── 持久配置 ─────────────────────────────────────────────────────────────
const rendererMode = ref<RendererMode>("textarea");
// ── 响应式状态 ───────────────────────────────────────────────────────────
const state = ref<SessionState>("idle");
const title = ref<string>("");
const latencyMs = ref<number>(0);
// ── 非响应式内部对象 ──────────────────────────────────────────────────────
const machine = new SessionMachine();
const buffer = new OutputBuffer({ maxEntries: 500, maxBytes: 256 * 1024 });
const renderer = shallowRef<TextareaRenderer | null>(null);
let transport : TerminalTransport | null = null;
let offTransport: (() => void) | null = null;
let resizeTimer: number | null = null;
let pendingResize: { cols: number; rows: number } | null = null;
let latestLocalSize: { cols: number; rows: number } | null = null;
let lastRemoteSize: { cols: number; rows: number } | null = null;
let remoteResizePaused = false;
let suppressAutoFollowUntil = 0;
const RESIZE_DEBOUNCE_MS = 140;
let mountedContainer: HTMLElement | null = null;
let outputCacheKey: string | null = null;
function persistOutputCache(): void {
if (!outputCacheKey || typeof window === "undefined") return;
try {
window.sessionStorage.setItem(outputCacheKey, JSON.stringify(buffer.getAll()));
} catch {
// ignore
}
}
/**
* 绑定会话输出缓存。
* - scope 建议使用 serverId + clientSessionKey避免串线
* - 仅在当前 buffer 为空时回放缓存,防止运行中重复叠加。
*/
function bindOutputCache(scope: string): void {
if (!scope || typeof window === "undefined") return;
const nextKey = `${OUTPUT_CACHE_PREFIX}${scope}`;
if (outputCacheKey === nextKey) return;
outputCacheKey = nextKey;
if (buffer.size > 0) return;
let restoredAny = false;
try {
const raw = window.sessionStorage.getItem(nextKey);
if (!raw) return;
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return;
for (const item of parsed) {
if (typeof item !== "string") continue;
const sanitized = sanitizeTerminalOutput(item);
if (!sanitized) continue;
buffer.push(sanitized);
restoredAny = true;
}
} catch {
// ignore
}
if (restoredAny && renderer.value) {
for (const chunk of buffer.getAll()) {
renderer.value.write(chunk);
}
}
}
function clearBoundOutputCache(): void {
if (!outputCacheKey || typeof window === "undefined") return;
try {
window.sessionStorage.removeItem(outputCacheKey);
} catch {
// ignore
}
}
// ── 渲染器挂载点 ─────────────────────────────────────────────────────────
/**
* 在 TerminalViewport.vue 的 onMounted 后调用,将渲染器挂载到 DOM 容器。
* 切换渲染器时也需重新调用(已由 switchRenderer 内部处理)。
*/
function mountRenderer(container: HTMLElement): void {
mountedContainer = container;
if (!renderer.value) {
renderer.value = new TextareaRenderer();
}
renderer.value.mount(container);
// 重放当前 buffer初次挂载 / 切换后恢复历史)
for (const chunk of buffer.getAll()) {
renderer.value.write(chunk);
}
}
/**
* 原子切换渲染模式dispose → 新建 → 重放 buffer → re-mount。
*/
function switchRenderer(mode: RendererMode): void {
if (rendererMode.value === mode) return;
rendererMode.value = "textarea";
}
// ── Transport 事件处理 ───────────────────────────────────────────────────
function bindTransport(t: TerminalTransport): void {
logStore("transport:bind");
offTransport?.();
offTransport = t.on((event) => {
if (event.type === "stdout" || event.type === "stderr") {
logStore("transport:event", { type: event.type, length: event.data.length, machineState: machine.state, state: state.value });
} else {
logStore("transport:event", { type: event.type, machineState: machine.state, state: state.value, event });
}
if (event.type === "stdout" || event.type === "stderr") {
if (state.value !== "connected" && machine.state === "connecting") {
machine.tryTransition("auth_pending");
}
if (state.value !== "connected" && machine.tryTransition("connected")) {
state.value = machine.state;
}
const sanitized = sanitizeTerminalOutput(event.data);
if (sanitized) {
// 统一行为:有新输出刷新时保持底部可见(提示符/最新输出不离开视口)。
// 例外:键盘收起恢复窗口内,临时抑制 auto-follow避免覆盖刚恢复的滚动位置。
const now = Date.now();
if (now >= suppressAutoFollowUntil) {
console.log(`[TerminalStore][stdout] followBottom() called, len=${sanitized.length}`);
renderer.value?.followBottom();
} else {
console.log(`[TerminalStore][stdout] followBottom SUPPRESSED, remainMs=${suppressAutoFollowUntil - now}, len=${sanitized.length}`);
logStore("follow:skipped_suppressed", { remainMs: suppressAutoFollowUntil - now });
}
buffer.push(sanitized);
renderer.value?.write(sanitized);
persistOutputCache();
}
} else if (event.type === "connected") {
if (machine.state === "connecting") {
machine.tryTransition("auth_pending");
}
machine.tryTransition("connected");
state.value = machine.state;
} else if (event.type === "disconnect") {
console.log(`[TerminalStore][${new Date().toISOString()}] transport:disconnect`, { reason: event.reason });
console.error(`[TerminalStore][${new Date().toISOString()}] transport:disconnect`, { reason: event.reason });
logStore("transport:disconnect", { reason: event.reason });
machine.tryTransition("disconnected");
state.value = machine.state;
} else if (event.type === "latency") {
latencyMs.value = event.data;
} else if (event.type === "error") {
console.log(`[TerminalStore][${new Date().toISOString()}] transport:error`, { code: event.code, message: event.message });
console.error(`[TerminalStore][${new Date().toISOString()}] transport:error`, { code: event.code, message: event.message });
logStore("transport:error", { code: event.code, message: event.message });
machine.tryTransition("error");
state.value = machine.state;
} else if ((event as unknown as { type?: string; action?: string }).type === "control") {
const action = (event as unknown as { action?: string }).action;
if (action === "connected") {
machine.tryTransition("connected");
state.value = machine.state;
} else if (action === "disconnect") {
machine.tryTransition("disconnected");
state.value = machine.state;
} else if (action === "pong") {
latencyMs.value = 0;
}
}
});
}
// ── 公开 API ──────────────────────────────────────────────────────────────
async function connect(params: ConnectParams): Promise<void> {
logStore("connect:requested", { state: state.value, host: params.host, port: params.port, username: params.username, cols: params.cols, rows: params.rows });
if (state.value === "connected" || state.value === "connecting") {
logStore("connect:skip", { reason: "already_connected_or_connecting", state: state.value });
return;
}
machine.tryTransition("connecting");
state.value = machine.state;
logStore("state:update", { machineState: machine.state, state: state.value });
const settingsStore = useSettingsStore();
await settingsStore.ensureBootstrapped();
const gatewayUrl = settingsStore.gatewayUrl as string;
const gatewayToken = settingsStore.gatewayToken as string;
const isIos = !!(window as unknown as { Capacitor?: { isNativePlatform?: () => boolean } })
.Capacitor?.isNativePlatform?.();
logStore("connect:settings_ready", {
gatewayUrl,
hasGatewayToken: Boolean(gatewayToken),
transportMode: isIos ? "ios-native" : "gateway"
});
transport = createTransport(
isIos ? "ios-native" : "gateway",
{ gatewayUrl, gatewayToken }
);
logStore("transport:created", { transportMode: isIos ? "ios-native" : "gateway" });
bindTransport(transport);
const currentTransport = transport;
try {
logStore("connect:transport_connect_begin");
await currentTransport.connect(params);
logStore("connect:transport_connect_resolved");
// If disconnected or transport changed during await
if (state.value === "disconnected" || transport !== currentTransport) {
logStore("connect:stale_result_ignored", { state: state.value, transportChanged: transport !== currentTransport });
return;
}
machine.tryTransition("auth_pending");
state.value = machine.state;
// init 帧会携带首个 pty 尺寸,这里记录为“远端当前尺寸”基线,避免后续重复下发。
lastRemoteSize = { cols: params.cols, rows: params.rows };
logStore("state:update", { machineState: machine.state, state: state.value });
} catch (err) {
if (state.value === "disconnected" || transport !== currentTransport) {
logStore("connect:error_ignored_after_disconnect", { state: state.value, transportChanged: transport !== currentTransport, err });
return;
}
machine.tryTransition("error");
state.value = machine.state;
logStore("state:update", { machineState: machine.state, state: state.value, err });
throw err;
}
}
function disconnect(reason = "manual"): void {
logStore("disconnect:requested", { reason, state: state.value });
if (transport) {
void transport.disconnect(reason).catch((err) => {
logStore("disconnect:transport_failed", { reason, err: String(err) });
});
}
offTransport?.();
offTransport = null;
transport = null;
if (resizeTimer !== null) {
window.clearTimeout(resizeTimer);
resizeTimer = null;
}
pendingResize = null;
latestLocalSize = null;
lastRemoteSize = null;
remoteResizePaused = false;
if (reason === "manual") {
clearBoundOutputCache();
}
machine.tryTransition("disconnected");
state.value = machine.state;
logStore("state:update", { machineState: machine.state, state: state.value });
}
function sendInput(data: string, source: "keyboard" | "assist" | "paste" = "keyboard"): void {
if (!transport || state.value !== "connected") {
console.log(`[TerminalStore][${new Date().toISOString()}] stdin:dropped`, {
source,
length: data.length,
hasTransport: Boolean(transport),
state: state.value
});
return;
}
const currentTransport = transport;
// 统一行为:回车/换行、Ctrl+C、Ctrl+D、粘贴/辅助输入都强制回到底部。
const shouldForceFollow =
data.includes("\r") ||
data.includes("\n") ||
data.includes("\u0003") ||
data.includes("\u0004") ||
source !== "keyboard";
if (shouldForceFollow) {
renderer.value?.followBottom();
}
// 网关现网 schema 在 stdin 的 union 校验上存在差异:
// - 部分环境不接受 meta.source = "paste"
// - 部分环境对 paste 分支携带 meta 也会触发 invalid_union。
// 因此 paste 统一走“无 meta”发送keyboard/assist 保持原语义。
const wireSource: "keyboard" | "assist" = source === "paste" ? "keyboard" : source;
const wireMeta = source === "paste" ? undefined : { source: wireSource };
// 大文本(尤其粘贴)分片发送,避免单帧过大触发网关/WS 断连。
const chunkSize = source === "paste" ? PASTE_CHUNK_SIZE : DEFAULT_CHUNK_SIZE;
const totalChunks = Math.ceil(data.length / chunkSize);
if (source === "paste") {
console.log(`[TerminalStore][${new Date().toISOString()}] stdin:dispatch`, {
source,
wireSource,
wireMetaSource: wireMeta?.source ?? "none",
length: data.length,
chunkSize,
totalChunks
});
}
logStore("stdin:dispatch", { source, length: data.length, chunkSize, totalChunks });
if (data.length <= chunkSize) {
void currentTransport.send(data, wireMeta).catch((err) => {
console.error(`[TerminalStore][${new Date().toISOString()}] stdin:send_failed`, { source, index: 1, totalChunks: 1, length: data.length, err: String(err) });
logStore("stdin:send_failed", { source, index: 1, totalChunks: 1, length: data.length, err: String(err) });
});
return;
}
for (let i = 0; i < data.length; i += chunkSize) {
const chunk = data.slice(i, i + chunkSize);
const index = i / chunkSize + 1;
if (source === "paste") {
console.log(`[TerminalStore][${new Date().toISOString()}] stdin:chunk`, { source, index, totalChunks, length: chunk.length });
}
logStore("stdin:chunk", { source, index, totalChunks, length: chunk.length });
void currentTransport.send(chunk, wireMeta).catch((err) => {
console.error(`[TerminalStore][${new Date().toISOString()}] stdin:send_failed`, { source, index, totalChunks, length: chunk.length, err: String(err) });
logStore("stdin:send_failed", { source, index, totalChunks, length: chunk.length, err: String(err) });
});
}
}
function clearTerminal(): void {
if (transport && state.value === "connected") {
// 连接态使用 Ctrl+L让远端 shell 清屏并重绘提示符,避免提示符消失。
sendInput("\x0c", "keyboard");
return;
}
// 未连接时执行本地硬清屏。
buffer.clear();
renderer.value?.write("\x1b[2J\x1b[H");
persistOutputCache();
}
function isSameSize(
a: { cols: number; rows: number } | null,
b: { cols: number; rows: number } | null
): boolean {
return Boolean(a && b && a.cols === b.cols && a.rows === b.rows);
}
/**
* 安排一次“向远端 PTY 下发尺寸”的去抖任务。
* - 仅连接态生效;
* - 若尺寸与最近成功下发的一致则跳过,避免无效回刷;
* - 任务触发时若处于 paused 状态则直接丢弃,由恢复流程统一补发最终尺寸。
*/
function scheduleRemoteResize(cols: number, rows: number): void {
pendingResize = { cols, rows };
if (resizeTimer !== null) {
window.clearTimeout(resizeTimer);
resizeTimer = null;
}
resizeTimer = window.setTimeout(() => {
resizeTimer = null;
if (remoteResizePaused) return;
if (!pendingResize) return;
const next = pendingResize;
pendingResize = null;
if (!transport || state.value !== "connected") return;
if (isSameSize(lastRemoteSize, next)) {
logStore("pty:resize_skipped_same", { cols: next.cols, rows: next.rows });
return;
}
void transport.resize(next.cols, next.rows)
.then(() => {
lastRemoteSize = next;
})
.catch((err) => {
logStore("pty:resize_failed", { cols: next.cols, rows: next.rows, state: state.value, err: String(err) });
});
}, RESIZE_DEBOUNCE_MS);
}
/**
* 控制“远端 PTY resize”是否暂停。
* 典型用途:软键盘动画期间只做本地 resize避免远端收到中间尺寸后回刷覆盖滚动恢复。
*/
function setRemoteResizePaused(paused: boolean): void {
if (remoteResizePaused === paused) return;
remoteResizePaused = paused;
logStore("pty:resize_pause_changed", { paused });
if (paused) {
// 进入暂停态时清空未发送任务,防止中间尺寸被发到远端。
if (resizeTimer !== null) {
window.clearTimeout(resizeTimer);
resizeTimer = null;
}
pendingResize = null;
return;
}
// 退出暂停态后补发当前最新本地尺寸(若与远端一致会被 schedule 自动跳过)。
if (!transport || state.value !== "connected") return;
if (!latestLocalSize) return;
scheduleRemoteResize(latestLocalSize.cols, latestLocalSize.rows);
}
/**
* 在指定时间窗口内临时抑制 “stdout/stderr 触发的 followBottom”。
* 用于软键盘收起后的滚动恢复保护,避免远端 resize 回刷覆盖恢复位置。
*/
function suppressAutoFollow(ms: number): void {
const nextUntil = Date.now() + Math.max(0, ms);
// ms=0 时强制重置(键盘关闭时清除之前设置的长窗口)
if (ms > 0 && nextUntil <= suppressAutoFollowUntil) return;
suppressAutoFollowUntil = nextUntil;
console.log(`[TerminalStore][suppressAutoFollow] ms=${ms} until=${ms === 0 ? "CLEARED" : new Date(suppressAutoFollowUntil).toISOString()}`);
logStore("follow:suppressed", { ms, until: suppressAutoFollowUntil });
}
function resizeTerminal(cols: number, rows: number): void {
renderer.value?.resize(cols, rows);
latestLocalSize = { cols, rows };
if (!transport) return;
// 仅连接态下向网关发送 resize避免连接未建立时出现未处理 Promise 拒绝。
if (state.value !== "connected") return;
if (remoteResizePaused) {
logStore("pty:resize_paused_skip", { cols, rows });
return;
}
// 仅对“发给远端 PTY”的 resize 去抖:
// - 软键盘动画/视口抖动期间会短时间出现多次尺寸跳变(例如 19 -> 12 -> 19
// - 直接逐次下发会触发远端多次重排,导致提示符位置抖动;
// - 本地渲染仍即时 resize保证交互跟手远端仅发送稳定后的最终尺寸。
scheduleRemoteResize(cols, rows);
}
/** 获取当前活跃 TerminalCore用于 cursorMath、测量等。 */
function getCore() {
return renderer.value?.getCore() ?? null;
}
/**
* 获取光标行距输出区顶部的像素偏移。
* 用于软键盘弹出时定位提示符行。
*/
function getCursorOffsetPx(): number | null {
return renderer.value?.getCursorOffsetPx() ?? null;
}
/**
* 获取输出滚动容器 DOM 元素。
*/
function getOutputEl(): HTMLElement | null {
return renderer.value?.getOutputEl() ?? null;
}
/**
* 设置输出区是否自动跟随滚动到底部。
*/
function setAutoFollow(enabled: boolean): void {
renderer.value?.setAutoFollow(enabled);
}
/**
* 冻结 scroll 事件对 autoFollow 的影响,持续 ms 毫秒iOS scroll 事件是异步的)。
* ms=0 立即解冻。
*/
function setScrollLocked(ms: number): void {
renderer.value?.setScrollLocked(ms);
}
return {
// 响应式
state,
title,
latencyMs,
rendererMode,
renderer,
// 方法
mountRenderer,
switchRenderer,
bindOutputCache,
clearBoundOutputCache,
connect,
disconnect,
sendInput,
clearTerminal,
resizeTerminal,
setRemoteResizePaused,
suppressAutoFollow,
getCore,
getCursorOffsetPx,
getOutputEl,
setAutoFollow,
setScrollLocked,
};
});

View File

@@ -0,0 +1,81 @@
function asMessage(error: unknown): string {
if (error instanceof Error) {
return String(error.message || "");
}
if (typeof error === "string") {
return error;
}
try {
return JSON.stringify(error);
} catch {
return "";
}
}
function normalizeText(input: string): string {
return input.trim().toLowerCase();
}
export function toFriendlyConnectionError(error: unknown): string {
const message = asMessage(error);
const lower = normalizeText(message);
if (lower.includes("rate_limit") || message.includes("连接过于频繁")) {
return "连接过于频繁,请稍后重试。";
}
if (lower.includes("auth_failed") || message.includes("token 无效")) {
return "网关鉴权失败,请联系管理员检查网关令牌。";
}
if (message.includes("SSH 认证失败")) {
return "SSH 认证失败。请检查账号/凭据。";
}
if (message.includes("Timed out while waiting for handshake") || message.includes("连接超时") || lower.includes("timeout")) {
return "连接超时。请检查服务器地址、端口和网络连通性。";
}
if (message.includes("无法连接网关") || lower.includes("ws_closed") || lower.includes("websocket")) {
return "无法连接网关,请检查网关地址、服务状态与网络策略。";
}
if (!message) {
return "连接失败,请稍后重试。";
}
return message;
}
export function toFriendlyError(error: unknown): string {
const message = asMessage(error);
const lower = normalizeText(message);
if (!message) {
return "操作失败,请稍后重试。";
}
if (
lower.includes("ws_") ||
lower.includes("websocket") ||
lower.includes("auth_failed") ||
lower.includes("rate_limit") ||
message.includes("连接") ||
message.includes("网关") ||
message.includes("SSH")
) {
return toFriendlyConnectionError(message);
}
if (message.includes("会话未连接")) {
return "当前会话未连接,请先建立连接。";
}
return message;
}
export function formatActionError(action: string, error: unknown): string {
const detail = toFriendlyError(error);
return `${action}${detail}`;
}

View File

@@ -0,0 +1,31 @@
export interface RuntimeConfig {
gatewayUrl?: string;
gatewayToken?: string;
selectedServerId?: string;
servers?: unknown;
}
let runtimeConfigPromise: Promise<RuntimeConfig | null> | null = null;
export async function loadRuntimeConfig(): Promise<RuntimeConfig | null> {
if (runtimeConfigPromise) return runtimeConfigPromise;
runtimeConfigPromise = (async () => {
if (typeof window === "undefined") return null;
try {
const response = await fetch("/terminal.config.json", { cache: "no-store" });
if (!response.ok) return null;
const raw = await response.json() as unknown;
if (!raw || typeof raw !== "object") return null;
const obj = raw as Record<string, unknown>;
return {
gatewayUrl: typeof obj.gatewayUrl === "string" ? obj.gatewayUrl : undefined,
gatewayToken: typeof obj.gatewayToken === "string" ? obj.gatewayToken : undefined,
selectedServerId: typeof obj.selectedServerId === "string" ? obj.selectedServerId : undefined,
servers: obj.servers
};
} catch {
return null;
}
})();
return runtimeConfigPromise;
}

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

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

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

File diff suppressed because it is too large Load Diff

3327
terminal/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

14
terminal/package.json Normal file
View 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"
}
}

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

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

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

View File

@@ -0,0 +1,81 @@
/**
* ImeController — IME 输入法状态机(纯逻辑,零 DOM 事件依赖)。
*
* 状态:
* idle → composingcompositionstart
* composing → commit_pendingcompositionend
* 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;
}
}
}

View File

@@ -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~");
});
});

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

View File

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

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

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

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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 / XTPOPCOLORSPs=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, "");
}

View File

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

View File

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

View 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 };
// ── 帧 metastdin 可选附加) ──────────────────────────────────────────────────
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-15p256: 0-255rgb: 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 flagsbitmask ───────────────────────────────────────────────────────
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 },
};
}

View 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
View 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. 多行粘贴不应被当作多次回车直接执行。

View 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. 页面可连接、可收发、可显示状态。
### 阶段 Bxterm 基线能力
1. 接入 xterm 渲染器。
2. 跑通输入、输出、resize、快捷键、粘贴。
3. 完成基线测试用例。
### 阶段 Ctextarea 迁移能力
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` 光标恢复能力已落地并通过验收用例。