first commit
19
.gitignore
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# 依赖目录
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# 构建产物
|
||||||
|
**/dist/
|
||||||
|
**/coverage/
|
||||||
|
|
||||||
|
# 本地环境变量
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# 日志与缓存
|
||||||
|
npm-debug.log*
|
||||||
|
.DS_Store
|
||||||
|
.npm-cache/
|
||||||
|
|
||||||
|
# 小程序本地运维配置(由 .env 生成)
|
||||||
|
apps/miniprogram/utils/opsEnv.js
|
||||||
19
terminal.config.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"gatewayUrl": "/ws/terminal",
|
||||||
|
"gatewayToken": "remoteconn-dev-token",
|
||||||
|
"selectedServerId": "dev-server",
|
||||||
|
"servers": [
|
||||||
|
{
|
||||||
|
"id": "dev-server",
|
||||||
|
"name": "Dev Server",
|
||||||
|
"host": "mac.biboer.cn",
|
||||||
|
"port": 22,
|
||||||
|
"username": "gavin",
|
||||||
|
"transportMode": "gateway",
|
||||||
|
"authType": "password",
|
||||||
|
"password": "Gavin123",
|
||||||
|
"cols": 80,
|
||||||
|
"rows": 24
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
1
terminal/.npmrc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
cache=.npm-cache
|
||||||
95
terminal/README.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
# terminal
|
||||||
|
|
||||||
|
这个仓库用于承接从 `remoteconn` 迁出的 xterm/terminal-core 迁移代码,采用 npm workspaces 管理。
|
||||||
|
|
||||||
|
## 环境要求
|
||||||
|
|
||||||
|
- Node.js 20+(建议使用当前 LTS)
|
||||||
|
- npm 10+
|
||||||
|
|
||||||
|
## 快速启动(Web)
|
||||||
|
|
||||||
|
在仓库根目录执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
默认会启动 `apps/web`,监听地址为:
|
||||||
|
|
||||||
|
- 本机访问:`http://localhost:5173`
|
||||||
|
- 局域网访问:`http://<你的IP>:5173`
|
||||||
|
|
||||||
|
## 常用命令
|
||||||
|
|
||||||
|
根目录命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # 启动 Web 实验页(apps/web)
|
||||||
|
npm run build # 先构建 terminal-core,再构建 web
|
||||||
|
npm run test # 运行 terminal-core 测试
|
||||||
|
npm run typecheck # 运行 terminal-core + web 类型检查
|
||||||
|
```
|
||||||
|
|
||||||
|
按 workspace 执行命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test -w apps/web
|
||||||
|
npm run test -w packages/terminal-core
|
||||||
|
npm run lint -w packages/terminal-core
|
||||||
|
npm run build:miniprogram -w packages/terminal-core
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 目前根目录未提供统一 `lint` 与 `deploy` 脚本。
|
||||||
|
- `.npmrc` 已将 npm 缓存指向仓库内 `.npm-cache`,减少 `~/.npm` 权限冲突问题。
|
||||||
|
|
||||||
|
## Web 配置文件
|
||||||
|
|
||||||
|
可通过 `apps/web/public/terminal.config.json` 配置网关与服务器,无需在控制台手动写 `localStorage`。
|
||||||
|
|
||||||
|
- `gatewayUrl`:网关 WebSocket 地址。推荐同源路径(如 `/ws/terminal`),HTTPS 下会自动匹配 `wss`。
|
||||||
|
- `gatewayToken`:网关令牌。
|
||||||
|
- `selectedServerId`:默认选中的服务器 ID。
|
||||||
|
- `servers`:服务器列表(`host/port/username/authType/password/privateKey/...`)。
|
||||||
|
|
||||||
|
修改配置文件后刷新页面即可生效;若同时存在本地缓存,配置文件优先。
|
||||||
|
|
||||||
|
## 目录结构(核心)
|
||||||
|
|
||||||
|
```text
|
||||||
|
.
|
||||||
|
├── apps/
|
||||||
|
│ ├── web/
|
||||||
|
│ └── miniprogram/
|
||||||
|
├── packages/
|
||||||
|
│ └── terminal-core/
|
||||||
|
├── docs/
|
||||||
|
├── README.md
|
||||||
|
├── package.json
|
||||||
|
├── package-lock.json
|
||||||
|
├── .npmrc
|
||||||
|
└── xterm-standalone-lab-plan-2026-03-01.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 目录说明
|
||||||
|
|
||||||
|
- `apps/web/`:Web 端实验应用(Vite + Vue + Pinia),承载 terminal 实验页与页面级状态。
|
||||||
|
- `apps/miniprogram/`:小程序侧适配代码与组件(输入桥接、测量适配、传输适配等)。
|
||||||
|
- `packages/terminal-core/`:跨端复用的终端核心库(状态机、输入、渲染内核、布局与清洗逻辑)。
|
||||||
|
- `docs/`:迁移与实验相关文档。
|
||||||
|
|
||||||
|
## 已迁入内容
|
||||||
|
|
||||||
|
- `apps/web/src/terminal/`:Web 端新 terminal-core 实验页与组件。
|
||||||
|
- `apps/web/vitest.config.ts`。
|
||||||
|
- `packages/terminal-core/`:跨平台终端核心包。
|
||||||
|
- `apps/miniprogram/components/terminal-core-view/`。
|
||||||
|
- `apps/miniprogram/utils/wxInputBridge.js`。
|
||||||
|
- `apps/miniprogram/utils/wxMeasureAdapter.js`。
|
||||||
|
- `apps/miniprogram/utils/wxTransport.js`。
|
||||||
|
- `docs/xterm-standalone-lab-plan-2026-03-01.md`。
|
||||||
|
|
||||||
|
当前 Web 实验页已收敛为 `textarea` 渲染模式(不再提供 compat 切换)。
|
||||||
369
terminal/apps/miniprogram/components/terminal-core-view/index.js
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
/**
|
||||||
|
* terminal-core-view/index.js
|
||||||
|
*
|
||||||
|
* 微信小程序终端渲染组件。
|
||||||
|
* 消费 @remoteconn/terminal-core 构建产物(dist-miniprogram/index.js),
|
||||||
|
* 通过 setData({rows, cursor}) 驱动 rich-text 渲染。
|
||||||
|
*
|
||||||
|
* 外部属性(properties):
|
||||||
|
* - gatewayUrl {string} 网关 WebSocket 地址
|
||||||
|
* - gatewayToken {string} 网关鉴权 Token
|
||||||
|
*
|
||||||
|
* 外部方法(通过组件引用调用):
|
||||||
|
* - connect(params) 开始连接,params: { host, port, username, password? }
|
||||||
|
* - disconnect(reason?) 断开连接
|
||||||
|
* - sendInput(data) 发送输入
|
||||||
|
*/
|
||||||
|
|
||||||
|
const path = '../../../../packages/terminal-core/dist-miniprogram/index';
|
||||||
|
let TerminalCore, InputBridge, OutputBuffer, SessionMachine, sanitizeTerminalOutput, calcSize;
|
||||||
|
try {
|
||||||
|
const core = require(path);
|
||||||
|
TerminalCore = core.TerminalCore;
|
||||||
|
InputBridge = core.InputBridge;
|
||||||
|
OutputBuffer = core.OutputBuffer;
|
||||||
|
SessionMachine = core.SessionMachine;
|
||||||
|
sanitizeTerminalOutput = core.sanitizeTerminalOutput;
|
||||||
|
calcSize = core.calcSize;
|
||||||
|
} catch(e) {
|
||||||
|
console.error('[terminal-core-view] 无法加载 terminal-core,请先执行 build:miniprogram', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { WxTransport } = require('../../utils/wxTransport');
|
||||||
|
const { createWxInputBridge } = require('../../utils/wxInputBridge');
|
||||||
|
const { createWxMeasureAdapter } = require('../../utils/wxMeasureAdapter');
|
||||||
|
|
||||||
|
// ── 颜色渲染 ──────────────────────────────────────────────────────────────────
|
||||||
|
const ANSI16 = [
|
||||||
|
'#000000','#cc0000','#00aa00','#aaaa00',
|
||||||
|
'#0000ee','#cc00cc','#00aaaa','#aaaaaa',
|
||||||
|
'#555555','#ff5555','#55ff55','#ffff55',
|
||||||
|
'#5555ff','#ff55ff','#55ffff','#ffffff',
|
||||||
|
];
|
||||||
|
|
||||||
|
function p256ToCss(idx) {
|
||||||
|
if (idx < 16) return ANSI16[idx] || '#000';
|
||||||
|
if (idx >= 232) {
|
||||||
|
const v = 8 + (idx - 232) * 10;
|
||||||
|
return 'rgb(' + v + ',' + v + ',' + v + ')';
|
||||||
|
}
|
||||||
|
const i = idx - 16;
|
||||||
|
const b = i % 6, g = Math.floor(i / 6) % 6, r = Math.floor(i / 36);
|
||||||
|
const ch = function(x) { return x === 0 ? 0 : 55 + x * 40; };
|
||||||
|
return 'rgb(' + ch(r) + ',' + ch(g) + ',' + ch(b) + ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorToCss(c, isFg) {
|
||||||
|
if (!c) return isFg ? 'inherit' : 'transparent';
|
||||||
|
switch (c.mode) {
|
||||||
|
case 'default': return isFg ? 'inherit' : 'transparent';
|
||||||
|
case 'p16': return ANSI16[c.value] || 'inherit';
|
||||||
|
case 'p256': return p256ToCss(c.value);
|
||||||
|
case 'rgb': return '#' + ((c.value & 0xffffff) | 0x1000000).toString(16).slice(1);
|
||||||
|
default: return 'inherit';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const FLAG_BOLD = 1;
|
||||||
|
const FLAG_DIM = 2;
|
||||||
|
const FLAG_ITALIC = 4;
|
||||||
|
const FLAG_UNDERLINE = 8;
|
||||||
|
const FLAG_INVERSE = 16;
|
||||||
|
const FLAG_INVISIBLE = 32;
|
||||||
|
const FLAG_STRIKETHROUGH = 64;
|
||||||
|
const FLAG_OVERLINE = 128;
|
||||||
|
|
||||||
|
/** 将一行 cells 转换为 rich-text 节点数组 */
|
||||||
|
function lineToNodes(cells) {
|
||||||
|
const nodes = [];
|
||||||
|
let i = 0;
|
||||||
|
while (i < cells.length) {
|
||||||
|
const cell = cells[i];
|
||||||
|
if (!cell) { i++; continue; }
|
||||||
|
|
||||||
|
const flags = cell.flags || 0;
|
||||||
|
const inverse = !!(flags & FLAG_INVERSE);
|
||||||
|
|
||||||
|
let fg = cell.fg, bg = cell.bg;
|
||||||
|
if (inverse) { const tmp = fg; fg = bg; bg = tmp; }
|
||||||
|
|
||||||
|
let style = '';
|
||||||
|
const fgCss = colorToCss(fg, true);
|
||||||
|
const bgCss = colorToCss(bg, false);
|
||||||
|
if (fgCss !== 'inherit') style += 'color:' + fgCss + ';';
|
||||||
|
if (bgCss !== 'transparent') style += 'background:' + bgCss + ';';
|
||||||
|
if (flags & FLAG_BOLD) style += 'font-weight:bold;';
|
||||||
|
if (flags & FLAG_DIM) style += 'opacity:0.6;';
|
||||||
|
if (flags & FLAG_ITALIC) style += 'font-style:italic;';
|
||||||
|
if (flags & FLAG_UNDERLINE) style += 'text-decoration:underline;';
|
||||||
|
if (flags & FLAG_STRIKETHROUGH) style += 'text-decoration:line-through;';
|
||||||
|
if (flags & FLAG_INVISIBLE) style += 'color:transparent;';
|
||||||
|
|
||||||
|
// 合并相邻相同 style 的 cells
|
||||||
|
let text = cell.char || ' ';
|
||||||
|
i++;
|
||||||
|
while (i < cells.length) {
|
||||||
|
const next = cells[i];
|
||||||
|
if (!next) break;
|
||||||
|
const nFlags = next.flags || 0;
|
||||||
|
// 简单比较:只有标志和颜色模式全相同才合并
|
||||||
|
if (nFlags !== flags ||
|
||||||
|
JSON.stringify(next.fg) !== JSON.stringify(fg) ||
|
||||||
|
JSON.stringify(next.bg) !== JSON.stringify(bg)) break;
|
||||||
|
text += next.char || ' ';
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes.push({
|
||||||
|
type: 'node',
|
||||||
|
name: 'span',
|
||||||
|
attrs: style ? { style: style } : {},
|
||||||
|
children: [{ type: 'text', text: text }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 组件定义 ──────────────────────────────────────────────────────────────────
|
||||||
|
Component({
|
||||||
|
properties: {
|
||||||
|
gatewayUrl: { type: String, value: '' },
|
||||||
|
gatewayToken: { type: String, value: '' },
|
||||||
|
},
|
||||||
|
|
||||||
|
data: {
|
||||||
|
rows: [], // Array<{ html: Array }>
|
||||||
|
cursor: { row: -1, left: 0, visible: false },
|
||||||
|
scrollTop: 9999999, // 滚到底
|
||||||
|
state: 'idle',
|
||||||
|
latencyMs: 0,
|
||||||
|
title: '',
|
||||||
|
inputValue: '',
|
||||||
|
},
|
||||||
|
|
||||||
|
lifetimes: {
|
||||||
|
attached: function() {
|
||||||
|
this._init();
|
||||||
|
},
|
||||||
|
detached: function() {
|
||||||
|
this._dispose();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
// ── 初始化 ───────────────────────────────────────────────────────────────
|
||||||
|
_init: function() {
|
||||||
|
const self = this;
|
||||||
|
|
||||||
|
// Core
|
||||||
|
self._core = TerminalCore ? new TerminalCore(80, 24) : null;
|
||||||
|
self._buffer = OutputBuffer ? new OutputBuffer({ maxEntries: 500, maxBytes: 256 * 1024 }) : null;
|
||||||
|
self._machine = SessionMachine ? new SessionMachine() : null;
|
||||||
|
self._inputBridge = InputBridge ? new InputBridge() : null;
|
||||||
|
self._wxBridge = createWxInputBridge();
|
||||||
|
self._transport = null;
|
||||||
|
self._offTransport = null;
|
||||||
|
self._lastRevision = -1;
|
||||||
|
self._charW = 7.8; // 默认字符宽度 px (monospace 13px 近似)
|
||||||
|
self._lineH = 16.25; // 行高 px (13px * 1.25)
|
||||||
|
|
||||||
|
// 订阅 core 事件
|
||||||
|
if (self._core) {
|
||||||
|
self._core.on('bell', function() { wx.vibrateShort({ type: 'light' }); });
|
||||||
|
self._core.on('titleChange', function(t) {
|
||||||
|
self.setData({ title: t });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测量适配器
|
||||||
|
self._measure = createWxMeasureAdapter(self, '#tc-viewport', self._charW, self._lineH);
|
||||||
|
self._measure.onResize(function() {
|
||||||
|
self._doFit();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 输入桥接事件订阅
|
||||||
|
self._wxBridge.on('key', function(p) {
|
||||||
|
if (!self._inputBridge) return;
|
||||||
|
const seq = self._inputBridge.mapKey(p.key, p.code, p.ctrlKey, p.altKey, p.shiftKey, p.metaKey);
|
||||||
|
if (seq !== null) self.sendInput(seq);
|
||||||
|
});
|
||||||
|
self._wxBridge.on('input', function(p) {
|
||||||
|
if (p.isComposing) return;
|
||||||
|
if (p.data) self.sendInput(p.data);
|
||||||
|
self.setData({ inputValue: '' });
|
||||||
|
});
|
||||||
|
self._wxBridge.on('paste', function(p) {
|
||||||
|
if (!self._inputBridge) return;
|
||||||
|
const seq = self._inputBridge.mapPaste(p.text);
|
||||||
|
self.sendInput(seq);
|
||||||
|
});
|
||||||
|
self._wxBridge.on('compositionend', function(p) {
|
||||||
|
if (p.data) self.sendInput(p.data);
|
||||||
|
self.setData({ inputValue: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 启动渲染循环(50ms ≈ 20fps,setData 有限流)
|
||||||
|
self._renderTimer = setInterval(function() { self._renderFrame(); }, 50);
|
||||||
|
|
||||||
|
// 启动测量轮询
|
||||||
|
self._measure.startPoll(600);
|
||||||
|
},
|
||||||
|
|
||||||
|
_dispose: function() {
|
||||||
|
if (this._renderTimer) { clearInterval(this._renderTimer); this._renderTimer = null; }
|
||||||
|
this._measure && this._measure.stopPoll();
|
||||||
|
this._offTransport && this._offTransport();
|
||||||
|
this._transport && this._transport._cleanup && this._transport._cleanup();
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 尺寸适配 ─────────────────────────────────────────────────────────────
|
||||||
|
_doFit: function() {
|
||||||
|
if (!calcSize || !this._core) return;
|
||||||
|
const { widthPx: contW, heightPx: contH } = this._measure.measureContainer();
|
||||||
|
if (contW < 10 || contH < 10) return;
|
||||||
|
const { widthPx: charW, heightPx: lineH } = this._measure.measureChar();
|
||||||
|
const { cols, rows } = calcSize(contW, contH, charW, lineH);
|
||||||
|
this._core.resize(cols, rows);
|
||||||
|
this._charW = charW;
|
||||||
|
if (this._transport && this.data.state === 'connected') {
|
||||||
|
this._transport.resize(cols, rows);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 渲染帧 ───────────────────────────────────────────────────────────────
|
||||||
|
_renderFrame: function() {
|
||||||
|
if (!this._core) return;
|
||||||
|
const snap = this._core.snapshot();
|
||||||
|
if (snap.revision === this._lastRevision) return;
|
||||||
|
this._lastRevision = snap.revision;
|
||||||
|
|
||||||
|
const rows = snap.lines.map(function(line) {
|
||||||
|
return { html: lineToNodes(line.cells) };
|
||||||
|
});
|
||||||
|
|
||||||
|
// 光标位置(像素)
|
||||||
|
const cursorLeft = snap.cursor.x * this._charW;
|
||||||
|
const cursorRow = snap.cursor.y; // 相对可视区行号
|
||||||
|
|
||||||
|
this.setData({
|
||||||
|
rows: rows,
|
||||||
|
cursor: { row: cursorRow, left: cursorLeft, visible: snap.cursor.visible },
|
||||||
|
title: snap.title || this.data.title,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 公开 API ──────────────────────────────────────────────────────────────
|
||||||
|
connect: function(params) {
|
||||||
|
const self = this;
|
||||||
|
if (!WxTransport) { console.error('[tc-view] WxTransport 未加载'); return; }
|
||||||
|
|
||||||
|
self._transport = new WxTransport({
|
||||||
|
gatewayUrl: self.properties.gatewayUrl,
|
||||||
|
gatewayToken: self.properties.gatewayToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
self._offTransport = self._transport.on(function(event) {
|
||||||
|
if (event.type === 'stdout' || event.type === 'stderr') {
|
||||||
|
const sanitized = sanitizeTerminalOutput ? sanitizeTerminalOutput(event.data) : event.data;
|
||||||
|
if (sanitized) {
|
||||||
|
self._buffer && self._buffer.push(sanitized);
|
||||||
|
self._core && self._core.write(sanitized);
|
||||||
|
}
|
||||||
|
} else if (event.type === 'control') {
|
||||||
|
if (event.action === 'connected') {
|
||||||
|
self._machine && self._machine.tryTransition('connected');
|
||||||
|
self.setData({ state: 'connected', latencyMs: 0 });
|
||||||
|
self._doFit();
|
||||||
|
} else if (event.action === 'pong') {
|
||||||
|
// latency 由 WxTransport 内部 _pingAt 计算
|
||||||
|
} else if (event.action === 'disconnect') {
|
||||||
|
self._machine && self._machine.tryTransition('disconnected');
|
||||||
|
self.setData({ state: 'disconnected' });
|
||||||
|
}
|
||||||
|
} else if (event.type === 'error') {
|
||||||
|
self._machine && self._machine.tryTransition('error');
|
||||||
|
self.setData({ state: 'error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
self._machine && self._machine.tryTransition('connecting');
|
||||||
|
self.setData({ state: 'connecting' });
|
||||||
|
|
||||||
|
self._transport.connect(params).catch(function(err) {
|
||||||
|
console.error('[tc-view] connect error', err);
|
||||||
|
self.setData({ state: 'error' });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
disconnect: function(reason) {
|
||||||
|
if (this._transport) {
|
||||||
|
this._transport.disconnect(reason || 'manual');
|
||||||
|
}
|
||||||
|
this._machine && this._machine.tryTransition('disconnected');
|
||||||
|
this.setData({ state: 'disconnected' });
|
||||||
|
},
|
||||||
|
|
||||||
|
sendInput: function(data) {
|
||||||
|
if (this.data.state !== 'connected' || !this._transport) return;
|
||||||
|
this._transport.send(data);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── WXML 事件处理 ─────────────────────────────────────────────────────────
|
||||||
|
onInput: function(e) {
|
||||||
|
const value = e.detail.value || '';
|
||||||
|
const prev = this._prevInputValue || '';
|
||||||
|
// 计算增量字符
|
||||||
|
if (value.length > prev.length) {
|
||||||
|
const delta = value.slice(prev.length);
|
||||||
|
this._wxBridge.triggerInput(delta, e.detail.isComposing);
|
||||||
|
}
|
||||||
|
this._prevInputValue = value;
|
||||||
|
},
|
||||||
|
|
||||||
|
onConfirm: function() {
|
||||||
|
this._prevInputValue = '';
|
||||||
|
this._wxBridge.triggerConfirm();
|
||||||
|
},
|
||||||
|
|
||||||
|
onSend: function() {
|
||||||
|
const val = this.data.inputValue;
|
||||||
|
if (val) {
|
||||||
|
this._wxBridge.triggerInput(val, false);
|
||||||
|
this._wxBridge.triggerConfirm();
|
||||||
|
this.setData({ inputValue: '' });
|
||||||
|
this._prevInputValue = '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onInputBlur: function() { /* no-op */ },
|
||||||
|
onKeyboardChange: function() { /* no-op */ },
|
||||||
|
|
||||||
|
// ── 滚动事件 ──────────────────────────────────────────────────────────────
|
||||||
|
onScrollToLower: function() {
|
||||||
|
this._autoScroll = true;
|
||||||
|
},
|
||||||
|
onScrollToUpper: function() {
|
||||||
|
this._autoScroll = false;
|
||||||
|
},
|
||||||
|
onScroll: function() { /* handled by upper/lower events */ },
|
||||||
|
|
||||||
|
// ── 触控工具栏 ────────────────────────────────────────────────────────────
|
||||||
|
onTapUp: function() { this._wxBridge.triggerKey('ArrowUp', 'ArrowUp'); },
|
||||||
|
onTapDown: function() { this._wxBridge.triggerKey('ArrowDown', 'ArrowDown'); },
|
||||||
|
onTapLeft: function() { this._wxBridge.triggerKey('ArrowLeft', 'ArrowLeft'); },
|
||||||
|
onTapRight: function() { this._wxBridge.triggerKey('ArrowRight', 'ArrowRight'); },
|
||||||
|
onTapTab: function() { this._wxBridge.triggerKey('Tab', 'Tab'); },
|
||||||
|
onTapEnter: function() { this._wxBridge.triggerConfirm(); },
|
||||||
|
onTapCtrlC: function() { this.sendInput('\u0003'); },
|
||||||
|
onTapCtrlD: function() { this.sendInput('\u0004'); },
|
||||||
|
onTapEsc: function() { this.sendInput('\x1b'); },
|
||||||
|
onTapPaste: function() {
|
||||||
|
const self = this;
|
||||||
|
wx.getClipboardData({
|
||||||
|
success: function(res) {
|
||||||
|
if (res.data) self._wxBridge.triggerPaste(res.data);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"component": true,
|
||||||
|
"usingComponents": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<!--
|
||||||
|
terminal-core-view — 微信小程序终端渲染组件。
|
||||||
|
消费 TerminalCore.snapshot() 通过 setData 驱动 rich-text 渲染。
|
||||||
|
-->
|
||||||
|
<view class="tc-root" id="tc-root">
|
||||||
|
<!-- 终端输出区 -->
|
||||||
|
<scroll-view
|
||||||
|
class="tc-viewport"
|
||||||
|
id="tc-viewport"
|
||||||
|
scroll-y
|
||||||
|
scroll-top="{{scrollTop}}"
|
||||||
|
bindscrolltoupper="onScrollToUpper"
|
||||||
|
bindscrolltolower="onScrollToLower"
|
||||||
|
bindscroll="onScroll"
|
||||||
|
>
|
||||||
|
<view class="tc-lines">
|
||||||
|
<block wx:for="{{rows}}" wx:key="index">
|
||||||
|
<view class="tc-row" style="position: relative;">
|
||||||
|
<!-- 光标行叠加 -->
|
||||||
|
<view
|
||||||
|
wx:if="{{cursor.visible && cursor.row === index}}"
|
||||||
|
class="tc-cursor"
|
||||||
|
style="left: {{cursor.left}}px;"
|
||||||
|
></view>
|
||||||
|
<rich-text class="tc-rich-row" nodes="{{item.html}}"></rich-text>
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<!-- 触控辅助工具栏 -->
|
||||||
|
<view class="tc-touch-bar">
|
||||||
|
<button class="tc-key" bindtap="onTapUp" size="mini">▲</button>
|
||||||
|
<button class="tc-key" bindtap="onTapDown" size="mini">▼</button>
|
||||||
|
<button class="tc-key" bindtap="onTapLeft" size="mini">◀</button>
|
||||||
|
<button class="tc-key" bindtap="onTapRight" size="mini">▶</button>
|
||||||
|
<button class="tc-key" bindtap="onTapTab" size="mini">Tab</button>
|
||||||
|
<button class="tc-key tc-key-enter" bindtap="onTapEnter" size="mini">↵</button>
|
||||||
|
<button class="tc-key" bindtap="onTapCtrlC" size="mini">^C</button>
|
||||||
|
<button class="tc-key" bindtap="onTapCtrlD" size="mini">^D</button>
|
||||||
|
<button class="tc-key" bindtap="onTapEsc" size="mini">Esc</button>
|
||||||
|
<button class="tc-key" bindtap="onTapPaste" size="mini">粘贴</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 输入栏 -->
|
||||||
|
<view class="tc-input-bar">
|
||||||
|
<input
|
||||||
|
class="tc-input"
|
||||||
|
type="text"
|
||||||
|
confirm-type="send"
|
||||||
|
placeholder="输入命令…"
|
||||||
|
value="{{inputValue}}"
|
||||||
|
bindinput="onInput"
|
||||||
|
bindconfirm="onConfirm"
|
||||||
|
bindblur="onInputBlur"
|
||||||
|
bindkeyboardheightchange="onKeyboardChange"
|
||||||
|
cursor-spacing="20"
|
||||||
|
adjust-position
|
||||||
|
/>
|
||||||
|
<button class="tc-send-btn" size="mini" bindtap="onSend">发</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 状态栏 -->
|
||||||
|
<view class="tc-status-bar">
|
||||||
|
<text class="tc-state-chip tc-state-{{state}}">{{state}}</text>
|
||||||
|
<text wx:if="{{latencyMs > 0}}" class="tc-state-chip">{{latencyMs}}ms</text>
|
||||||
|
<text wx:if="{{title}}" class="tc-title">{{title}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
/* terminal-core-view/index.wxss */
|
||||||
|
|
||||||
|
.tc-root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #d4d4d4;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 终端输出区 */
|
||||||
|
.tc-viewport {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tc-lines {
|
||||||
|
padding: 2px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tc-row {
|
||||||
|
position: relative;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tc-rich-row {
|
||||||
|
display: block;
|
||||||
|
font-family: "Courier New", "Menlo", monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.25;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 光标 */
|
||||||
|
.tc-cursor {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
width: 8px;
|
||||||
|
height: 1.25em;
|
||||||
|
background: rgba(255, 255, 255, 0.75);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 触控工具栏 */
|
||||||
|
.tc-touch-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
overflow-x: scroll;
|
||||||
|
padding: 4px 6px;
|
||||||
|
background: #252525;
|
||||||
|
border-top: 1rpx solid #333;
|
||||||
|
gap: 4px;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tc-key {
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 44px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: "Courier New", monospace;
|
||||||
|
background: #2e2e2e;
|
||||||
|
color: #ccc;
|
||||||
|
border: 1rpx solid #444;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0 8px;
|
||||||
|
height: 32px;
|
||||||
|
line-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tc-key-enter {
|
||||||
|
background: #1a3a5c;
|
||||||
|
border-color: #4a9eff;
|
||||||
|
color: #4a9eff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 输入栏 */
|
||||||
|
.tc-input-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
background: #1e1e1e;
|
||||||
|
border-top: 1rpx solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tc-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1rpx solid #444;
|
||||||
|
background: #2a2a2a;
|
||||||
|
color: #d4d4d4;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: "Courier New", monospace;
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tc-send-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 44px;
|
||||||
|
height: 36px;
|
||||||
|
background: #4a9eff;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: none;
|
||||||
|
line-height: 36px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 状态栏 */
|
||||||
|
.tc-status-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
background: #141414;
|
||||||
|
border-top: 1rpx solid #2a2a2a;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tc-state-chip {
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
background: #2a2a2a;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tc-state-chip.tc-state-connected { background: #1a3a1a; color: #4caf50; }
|
||||||
|
.tc-state-chip.tc-state-connecting { background: #2a2a00; color: #ffc107; }
|
||||||
|
.tc-state-chip.tc-state-auth_pending { background: #2a2a00; color: #ffc107; }
|
||||||
|
.tc-state-chip.tc-state-error { background: #3a1a1a; color: #f44336; }
|
||||||
|
.tc-state-chip.tc-state-disconnected { background: #2a2a2a; color: #666; }
|
||||||
|
|
||||||
|
.tc-title {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #888;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
123
terminal/apps/miniprogram/utils/wxInputBridge.js
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* WxInputBridge — 微信小程序 bindinput/bindconfirm 适配为 IInputSource。
|
||||||
|
*
|
||||||
|
* 使用方式(在 terminal-core-view/index.js 中):
|
||||||
|
* const bridge = createWxInputBridge();
|
||||||
|
* // 绑定到 InputBridge
|
||||||
|
* const core_bridge = new InputBridge();
|
||||||
|
* bridge.on('key', p => { const seq = core_bridge.mapKey(...); transport.send(seq); });
|
||||||
|
* bridge.on('input', p => { if (!p.isComposing && p.data) transport.send(p.data); });
|
||||||
|
* bridge.on('paste', p => { transport.send(core_bridge.mapPaste(p.text)); });
|
||||||
|
*
|
||||||
|
* 组件调用(WXML 事件 → 桥接):
|
||||||
|
* Page/Component: {
|
||||||
|
* onInput(e) { bridge.triggerInput(e.detail.value, e.detail.isComposing); },
|
||||||
|
* onConfirm() { bridge.triggerConfirm(); },
|
||||||
|
* onPaste(text){ bridge.triggerPaste(text); },
|
||||||
|
* onFocus() { bridge.triggerFocus(); },
|
||||||
|
* onBlur() { bridge.triggerBlur(); },
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|
||||||
|
function createWxInputBridge() {
|
||||||
|
const _listeners = Object.create(null);
|
||||||
|
|
||||||
|
function on(event, cb) {
|
||||||
|
if (!_listeners[event]) _listeners[event] = [];
|
||||||
|
_listeners[event].push(cb);
|
||||||
|
return function off() {
|
||||||
|
const arr = _listeners[event];
|
||||||
|
if (!arr) return;
|
||||||
|
const idx = arr.indexOf(cb);
|
||||||
|
if (idx >= 0) arr.splice(idx, 1);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function _emit(event, payload) {
|
||||||
|
const arr = _listeners[event];
|
||||||
|
if (!arr) return;
|
||||||
|
for (let i = 0; i < arr.length; i++) {
|
||||||
|
try { arr[i](payload); } catch(e) { /* isolation */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── WXML 事件驱动 API ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 由 WXML bindinput 调用。
|
||||||
|
* 注意:小程序 bindinput 的 value 是整体输入框值,不是增量;
|
||||||
|
* 调用方应提取增量(新增字符)后传入 data 参数。
|
||||||
|
*/
|
||||||
|
function triggerInput(data, isComposing) {
|
||||||
|
_emit('input', {
|
||||||
|
data: data != null ? String(data) : '',
|
||||||
|
isComposing: !!isComposing,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 由 WXML bindconfirm 调用(用户点击软键盘"完成/回车")。
|
||||||
|
*/
|
||||||
|
function triggerConfirm() {
|
||||||
|
_emit('key', {
|
||||||
|
key: 'Enter',
|
||||||
|
code: 'Enter',
|
||||||
|
ctrlKey: false,
|
||||||
|
altKey: false,
|
||||||
|
shiftKey: false,
|
||||||
|
metaKey: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 由触控工具栏按键调用(箭头键、Tab、Ctrl+C 等)。
|
||||||
|
* @param {string} key KeyboardEvent.key 等价值(如 "ArrowUp"、"c")
|
||||||
|
* @param {string} code KeyboardEvent.code 等价值(可省略,默认与 key 相同)
|
||||||
|
* @param {{ ctrlKey, altKey, shiftKey, metaKey }} mods 修饰键状态
|
||||||
|
*/
|
||||||
|
function triggerKey(key, code, mods) {
|
||||||
|
const m = mods || {};
|
||||||
|
_emit('key', {
|
||||||
|
key: key,
|
||||||
|
code: code || key,
|
||||||
|
ctrlKey: !!m.ctrlKey,
|
||||||
|
altKey: !!m.altKey,
|
||||||
|
shiftKey: !!m.shiftKey,
|
||||||
|
metaKey: !!m.metaKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 由粘贴操作触发(通过 wx.getClipboardData 读取后调用)。
|
||||||
|
*/
|
||||||
|
function triggerPaste(text) {
|
||||||
|
_emit('paste', { text: String(text || '') });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IME 组合开始(候选词输入开始)。
|
||||||
|
*/
|
||||||
|
function triggerCompositionStart(data) {
|
||||||
|
_emit('compositionstart', { data: String(data || '') });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IME 组合结束(候选词确认)。
|
||||||
|
*/
|
||||||
|
function triggerCompositionEnd(data) {
|
||||||
|
_emit('compositionend', { data: String(data || '') });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
on: on,
|
||||||
|
// WXML 事件驱动方法
|
||||||
|
triggerInput: triggerInput,
|
||||||
|
triggerConfirm: triggerConfirm,
|
||||||
|
triggerKey: triggerKey,
|
||||||
|
triggerPaste: triggerPaste,
|
||||||
|
triggerCompositionStart: triggerCompositionStart,
|
||||||
|
triggerCompositionEnd: triggerCompositionEnd,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createWxInputBridge: createWxInputBridge };
|
||||||
112
terminal/apps/miniprogram/utils/wxMeasureAdapter.js
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* WxMeasureAdapter — 微信小程序 wx.createSelectorQuery 实现 IMeasureAdapter。
|
||||||
|
*
|
||||||
|
* 由于小程序不支持 ResizeObserver,使用 BoundingClientRect 查询容器尺寸,
|
||||||
|
* 并提供 `poll(intervalMs)` 方法做周期性尺寸检测(模拟 resize 通知)。
|
||||||
|
*
|
||||||
|
* 使用方式:
|
||||||
|
* const m = createWxMeasureAdapter(component, '#tc-viewport', 14, 16.8);
|
||||||
|
* m.onResize(() => {
|
||||||
|
* const { widthPx, heightPx } = m.measureContainer();
|
||||||
|
* const { widthPx: cw, heightPx: ch } = m.measureChar();
|
||||||
|
* const cols = Math.max(20, Math.floor(widthPx / cw));
|
||||||
|
* const rows = Math.max(8, Math.floor(heightPx / ch));
|
||||||
|
* // ... resize terminal
|
||||||
|
* });
|
||||||
|
* m.startPoll(500);
|
||||||
|
* // 在 component detached / onUnload 中:
|
||||||
|
* m.stopPoll();
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} ctx - 小程序 Component 实例(this),用于 createSelectorQuery 作用域
|
||||||
|
* @param {string} selector - CSS 选择器,如 '#tc-viewport'
|
||||||
|
* @param {number} charWidthPx - 等宽字符宽度像素(由组件在初始化时测量或硬编码)
|
||||||
|
* @param {number} charHeightPx - 行高像素
|
||||||
|
*/
|
||||||
|
function createWxMeasureAdapter(ctx, selector, charWidthPx, charHeightPx) {
|
||||||
|
let _containerW = 0;
|
||||||
|
let _containerH = 0;
|
||||||
|
let _pollTimer = null;
|
||||||
|
const _callbacks = [];
|
||||||
|
|
||||||
|
/** 实现 IMeasureAdapter.measureChar */
|
||||||
|
function measureChar() {
|
||||||
|
return {
|
||||||
|
widthPx: charWidthPx || 8,
|
||||||
|
heightPx: charHeightPx || 16,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 实现 IMeasureAdapter.measureContainer */
|
||||||
|
function measureContainer() {
|
||||||
|
return {
|
||||||
|
widthPx: _containerW,
|
||||||
|
heightPx: _containerH,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 实现 IMeasureAdapter.onResize */
|
||||||
|
function onResize(cb) {
|
||||||
|
_callbacks.push(cb);
|
||||||
|
return function off() {
|
||||||
|
const idx = _callbacks.indexOf(cb);
|
||||||
|
if (idx >= 0) _callbacks.splice(idx, 1);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主动刷新容器尺寸(异步) */
|
||||||
|
function refresh(onDone) {
|
||||||
|
const query = ctx.createSelectorQuery();
|
||||||
|
query.select(selector).boundingClientRect(function(rect) {
|
||||||
|
if (!rect) return;
|
||||||
|
const newW = rect.width || 0;
|
||||||
|
const newH = rect.height || 0;
|
||||||
|
if (newW !== _containerW || newH !== _containerH) {
|
||||||
|
_containerW = newW;
|
||||||
|
_containerH = newH;
|
||||||
|
for (let i = 0; i < _callbacks.length; i++) {
|
||||||
|
try { _callbacks[i](); } catch(e) { /* isolation */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof onDone === 'function') onDone({ widthPx: _containerW, heightPx: _containerH });
|
||||||
|
}).exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 启动周期性轮询(模拟 ResizeObserver)。intervalMs 建议 300~1000ms */
|
||||||
|
function startPoll(intervalMs) {
|
||||||
|
stopPoll();
|
||||||
|
const ms = intervalMs || 500;
|
||||||
|
// 立刻执行一次
|
||||||
|
refresh();
|
||||||
|
_pollTimer = setInterval(function() { refresh(); }, ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 停止轮询 */
|
||||||
|
function stopPoll() {
|
||||||
|
if (_pollTimer !== null) {
|
||||||
|
clearInterval(_pollTimer);
|
||||||
|
_pollTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一次性测量(供初始化使用)。
|
||||||
|
* @param {function} cb - (result: { widthPx, heightPx }) => void
|
||||||
|
*/
|
||||||
|
function measureOnce(cb) {
|
||||||
|
refresh(cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
measureChar: measureChar,
|
||||||
|
measureContainer: measureContainer,
|
||||||
|
onResize: onResize,
|
||||||
|
refresh: refresh,
|
||||||
|
startPoll: startPoll,
|
||||||
|
stopPoll: stopPoll,
|
||||||
|
measureOnce: measureOnce,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createWxMeasureAdapter: createWxMeasureAdapter };
|
||||||
201
terminal/apps/miniprogram/utils/wxTransport.js
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
/**
|
||||||
|
* WxTransport — 微信小程序 wx.connectSocket 实现的 TerminalTransport。
|
||||||
|
*
|
||||||
|
* 帧协议与 Web 端 GatewayTransport 完全一致(§8.2):
|
||||||
|
* 入站:{ type: 'init'|'stdin'|'resize'|'control', ... }
|
||||||
|
* 出站:{ type: 'stdout'|'stderr'|'control'|'error', ... }
|
||||||
|
*
|
||||||
|
* 使用方式:
|
||||||
|
* const t = new WxTransport({ gatewayUrl, gatewayToken });
|
||||||
|
* const off = t.on(event => { ... });
|
||||||
|
* await t.connect({ host, port, username, password });
|
||||||
|
* t.send("ls -la\r");
|
||||||
|
* t.resize(80, 24);
|
||||||
|
* t.disconnect();
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { sanitizeTerminalOutput } = require('../../../packages/terminal-core/dist-miniprogram/index');
|
||||||
|
|
||||||
|
function buildWsUrl(rawUrl, token) {
|
||||||
|
let base = String(rawUrl || '').trim();
|
||||||
|
if (!base) throw new Error('WxTransport: 网关地址为空');
|
||||||
|
if (base.startsWith('http://')) base = 'ws://' + base.slice(7);
|
||||||
|
if (base.startsWith('https://')) base = 'wss://' + base.slice(8);
|
||||||
|
if (!base.startsWith('ws://') && !base.startsWith('wss://')) {
|
||||||
|
base = 'wss://' + base;
|
||||||
|
}
|
||||||
|
base = base.replace(/\/+$/, '');
|
||||||
|
return base + '/ws/terminal?token=' + encodeURIComponent(String(token || ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
function WxTransport(options) {
|
||||||
|
const cfg = options || {};
|
||||||
|
this._gatewayUrl = cfg.gatewayUrl || '';
|
||||||
|
this._gatewayToken = cfg.gatewayToken || '';
|
||||||
|
|
||||||
|
this._state = 'idle'; // SessionState
|
||||||
|
this._sessionId = '';
|
||||||
|
this._socket = null; // wx.SocketTask
|
||||||
|
this._listeners = []; // Array<(event) => void>
|
||||||
|
this._pingTimer = null;
|
||||||
|
this._pingAt = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 接口实现 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 连接网关并发送 init 帧。
|
||||||
|
* 返回 Promise,在 control.connected 帧到达后 resolve,错误时 reject。
|
||||||
|
*/
|
||||||
|
WxTransport.prototype.connect = function(params) {
|
||||||
|
const self = this;
|
||||||
|
return new Promise(function(resolve, reject) {
|
||||||
|
if (self._state === 'connecting' || self._state === 'connected') {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self._state = 'connecting';
|
||||||
|
const sessionId = 'wx_' + Date.now() + '_' + Math.random().toString(36).slice(2);
|
||||||
|
self._sessionId = sessionId;
|
||||||
|
const url = buildWsUrl(self._gatewayUrl, self._gatewayToken);
|
||||||
|
|
||||||
|
const task = wx.connectSocket({
|
||||||
|
url: url,
|
||||||
|
protocols: ['v1.terminal'],
|
||||||
|
success: function() { /* socket created */ },
|
||||||
|
fail: function(err) {
|
||||||
|
self._state = 'error';
|
||||||
|
self._emit({ type: 'error', code: 'HOST_UNREACHABLE', message: JSON.stringify(err) });
|
||||||
|
reject(new Error('wx.connectSocket fail: ' + JSON.stringify(err)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
self._socket = task;
|
||||||
|
let resolved = false;
|
||||||
|
|
||||||
|
task.onOpen(function() {
|
||||||
|
// 发送 init 帧
|
||||||
|
self._sendFrame({ type: 'init', sessionId: sessionId, params: params });
|
||||||
|
// 启动心跳
|
||||||
|
self._startPing();
|
||||||
|
});
|
||||||
|
|
||||||
|
task.onMessage(function(res) {
|
||||||
|
let frame;
|
||||||
|
try {
|
||||||
|
frame = JSON.parse(res.data);
|
||||||
|
} catch(e) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frame.type === 'stdout' || frame.type === 'stderr') {
|
||||||
|
const raw = typeof frame.data === 'string' ? frame.data : '';
|
||||||
|
const sanitized = sanitizeTerminalOutput(raw);
|
||||||
|
if (sanitized) self._emit({ type: frame.type, data: sanitized });
|
||||||
|
} else if (frame.type === 'control') {
|
||||||
|
if (frame.action === 'connected') {
|
||||||
|
self._state = 'connected';
|
||||||
|
self._emit({ type: 'control', action: 'connected', data: frame.data });
|
||||||
|
if (!resolved) { resolved = true; resolve(); }
|
||||||
|
} else if (frame.action === 'disconnect') {
|
||||||
|
self._cleanup();
|
||||||
|
self._state = 'disconnected';
|
||||||
|
self._emit({ type: 'control', action: 'disconnect' });
|
||||||
|
} else if (frame.action === 'pong') {
|
||||||
|
self._emit({ type: 'control', action: 'pong' });
|
||||||
|
}
|
||||||
|
} else if (frame.type === 'error') {
|
||||||
|
self._state = 'error';
|
||||||
|
self._emit({ type: 'error', code: frame.code || 'INTERNAL_ERROR', message: frame.message || '' });
|
||||||
|
if (!resolved) { resolved = true; reject(new Error(frame.message)); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
task.onError(function(err) {
|
||||||
|
self._cleanup();
|
||||||
|
self._state = 'error';
|
||||||
|
self._emit({ type: 'error', code: 'HOST_UNREACHABLE', message: JSON.stringify(err) });
|
||||||
|
if (!resolved) { resolved = true; reject(new Error(JSON.stringify(err))); }
|
||||||
|
});
|
||||||
|
|
||||||
|
task.onClose(function() {
|
||||||
|
self._cleanup();
|
||||||
|
if (self._state !== 'disconnected' && self._state !== 'error') {
|
||||||
|
self._state = 'disconnected';
|
||||||
|
self._emit({ type: 'control', action: 'disconnect' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
WxTransport.prototype.send = function(data, meta) {
|
||||||
|
if (this._state !== 'connected') return;
|
||||||
|
this._sendFrame({ type: 'stdin', data: data, meta: meta });
|
||||||
|
};
|
||||||
|
|
||||||
|
WxTransport.prototype.resize = function(cols, rows) {
|
||||||
|
if (this._state !== 'connected') return;
|
||||||
|
this._sendFrame({ type: 'resize', cols: cols, rows: rows });
|
||||||
|
};
|
||||||
|
|
||||||
|
WxTransport.prototype.disconnect = function(reason) {
|
||||||
|
this._sendFrame({ type: 'control', action: 'disconnect', reason: reason || 'manual' });
|
||||||
|
this._cleanup();
|
||||||
|
this._state = 'disconnected';
|
||||||
|
};
|
||||||
|
|
||||||
|
WxTransport.prototype.on = function(listener) {
|
||||||
|
const self = this;
|
||||||
|
self._listeners.push(listener);
|
||||||
|
return function() {
|
||||||
|
const idx = self._listeners.indexOf(listener);
|
||||||
|
if (idx >= 0) self._listeners.splice(idx, 1);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
WxTransport.prototype.getState = function() {
|
||||||
|
return this._state;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 私有方法 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
WxTransport.prototype._emit = function(event) {
|
||||||
|
for (let i = 0; i < this._listeners.length; i++) {
|
||||||
|
try { this._listeners[i](event); } catch(e) { /* 单个监听器异常不影响其他 */ }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
WxTransport.prototype._sendFrame = function(frame) {
|
||||||
|
if (!this._socket) return;
|
||||||
|
try {
|
||||||
|
this._socket.send({ data: JSON.stringify(frame) });
|
||||||
|
} catch(e) { /* 忽略发送时 socket 已关闭 */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
WxTransport.prototype._startPing = function() {
|
||||||
|
const self = this;
|
||||||
|
self._clearPing();
|
||||||
|
self._pingTimer = setInterval(function() {
|
||||||
|
if (self._state !== 'connected') return;
|
||||||
|
self._pingAt = Date.now();
|
||||||
|
self._sendFrame({ type: 'control', action: 'ping' });
|
||||||
|
}, 10000);
|
||||||
|
};
|
||||||
|
|
||||||
|
WxTransport.prototype._clearPing = function() {
|
||||||
|
if (this._pingTimer !== null) {
|
||||||
|
clearInterval(this._pingTimer);
|
||||||
|
this._pingTimer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
WxTransport.prototype._cleanup = function() {
|
||||||
|
this._clearPing();
|
||||||
|
if (this._socket) {
|
||||||
|
try { this._socket.close(); } catch(e) { /* ignore */ }
|
||||||
|
this._socket = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { WxTransport: WxTransport };
|
||||||
12
terminal/apps/web/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
|
||||||
|
<title>Terminal Lab</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
25
terminal/apps/web/package.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "@remoteconn/web-terminal-lab",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 0.0.0.0 --port 5173",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run --passWithNoTests",
|
||||||
|
"typecheck": "vue-tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@remoteconn/terminal-core": "1.0.0",
|
||||||
|
"pinia": "^2.1.7",
|
||||||
|
"vue": "^3.5.22"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^5.4.10",
|
||||||
|
"vitest": "^4.0.18",
|
||||||
|
"vue-tsc": "^2.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
19
terminal/apps/web/public/terminal.config.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"gatewayUrl": "/ws/terminal",
|
||||||
|
"gatewayToken": "remoteconn-dev-token",
|
||||||
|
"selectedServerId": "dev-server",
|
||||||
|
"servers": [
|
||||||
|
{
|
||||||
|
"id": "dev-server",
|
||||||
|
"name": "Dev Server",
|
||||||
|
"host": "mac.biboer.cn",
|
||||||
|
"port": 22,
|
||||||
|
"username": "gavin",
|
||||||
|
"transportMode": "gateway",
|
||||||
|
"authType": "password",
|
||||||
|
"password": "Gavin123",
|
||||||
|
"cols": 80,
|
||||||
|
"rows": 24
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
72
terminal/apps/web/src/App.vue
Normal file
@@ -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
@@ -0,0 +1,7 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module "*.vue" {
|
||||||
|
import type { DefineComponent } from "vue";
|
||||||
|
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>;
|
||||||
|
export default component;
|
||||||
|
}
|
||||||
119
terminal/apps/web/src/main.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { createPinia } from "pinia";
|
||||||
|
import { createApp } from "vue";
|
||||||
|
import App from "./App.vue";
|
||||||
|
import "./style.css";
|
||||||
|
|
||||||
|
function disablePageZoom(): void {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
const prevent = (e: Event) => e.preventDefault();
|
||||||
|
let lastTapAt = 0;
|
||||||
|
let lastTapX = 0;
|
||||||
|
let lastTapY = 0;
|
||||||
|
const DOUBLE_TAP_GUARD_MS = 320;
|
||||||
|
const DOUBLE_TAP_DISTANCE_PX = 32;
|
||||||
|
|
||||||
|
document.addEventListener("gesturestart", prevent, { passive: false });
|
||||||
|
document.addEventListener("gesturechange", prevent, { passive: false });
|
||||||
|
document.addEventListener("gestureend", prevent, { passive: false });
|
||||||
|
document.addEventListener("touchmove", (e: TouchEvent) => {
|
||||||
|
if (e.touches.length > 1) e.preventDefault();
|
||||||
|
}, { passive: false });
|
||||||
|
document.addEventListener("touchend", (e: TouchEvent) => {
|
||||||
|
const touch = e.changedTouches[0];
|
||||||
|
if (!touch) return;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const dt = now - lastTapAt;
|
||||||
|
const dx = Math.abs(touch.clientX - lastTapX);
|
||||||
|
const dy = Math.abs(touch.clientY - lastTapY);
|
||||||
|
lastTapAt = now;
|
||||||
|
lastTapX = touch.clientX;
|
||||||
|
lastTapY = touch.clientY;
|
||||||
|
|
||||||
|
// 全局双击缩放拦截:在整个 Web 页面内统一禁止 double-tap zoom。
|
||||||
|
if (dt > 0 && dt <= DOUBLE_TAP_GUARD_MS && dx <= DOUBLE_TAP_DISTANCE_PX && dy <= DOUBLE_TAP_DISTANCE_PX) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}, { passive: false, capture: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupMobileViewportCompensation(): void {
|
||||||
|
if (typeof window === "undefined" || typeof document === "undefined") return;
|
||||||
|
const root = document.documentElement;
|
||||||
|
const MIN_VIEWPORT_RATIO = 0.35;
|
||||||
|
const MIN_VIEWPORT_PX = 240;
|
||||||
|
const BIG_JUMP_PX = 64;
|
||||||
|
const BIG_JUMP_DEBOUNCE_MS = 100;
|
||||||
|
|
||||||
|
// 记录“可信高度”基线:用于过滤键盘动画期间偶发的极小中间值(例如瞬时 98px)。
|
||||||
|
let baselineViewportHeight = Math.max(window.innerHeight, window.visualViewport?.height ?? 0);
|
||||||
|
let committedViewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
||||||
|
let pendingViewportHeight = committedViewportHeight;
|
||||||
|
let pendingViewportTop = window.visualViewport?.offsetTop ?? 0;
|
||||||
|
let pendingTimer: number | null = null;
|
||||||
|
|
||||||
|
const commitViewport = (viewportHeight: number, viewportTop: number) => {
|
||||||
|
root.style.setProperty("--app-vh", `${Math.round(viewportHeight)}px`);
|
||||||
|
root.style.setProperty("--app-vtop", `${Math.round(viewportTop)}px`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const flushPending = () => {
|
||||||
|
pendingTimer = null;
|
||||||
|
committedViewportHeight = pendingViewportHeight;
|
||||||
|
commitViewport(pendingViewportHeight, pendingViewportTop);
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleViewport = (viewportHeight: number, viewportTop: number) => {
|
||||||
|
if (viewportHeight <= 0) return;
|
||||||
|
|
||||||
|
if (viewportHeight > baselineViewportHeight) {
|
||||||
|
baselineViewportHeight = viewportHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 过滤动画毛刺:异常小高度直接丢弃,避免主容器瞬时塌缩导致工具条闪位。
|
||||||
|
const minReasonable = Math.max(MIN_VIEWPORT_PX, baselineViewportHeight * MIN_VIEWPORT_RATIO);
|
||||||
|
if (viewportHeight < minReasonable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingViewportHeight = viewportHeight;
|
||||||
|
pendingViewportTop = viewportTop;
|
||||||
|
const jump = Math.abs(viewportHeight - committedViewportHeight);
|
||||||
|
|
||||||
|
// 大跳变(键盘开关动画期)走短防抖,只提交最终稳定值。
|
||||||
|
if (jump >= BIG_JUMP_PX) {
|
||||||
|
if (pendingTimer !== null) {
|
||||||
|
window.clearTimeout(pendingTimer);
|
||||||
|
}
|
||||||
|
pendingTimer = window.setTimeout(flushPending, BIG_JUMP_DEBOUNCE_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingTimer !== null) {
|
||||||
|
window.clearTimeout(pendingTimer);
|
||||||
|
pendingTimer = null;
|
||||||
|
}
|
||||||
|
committedViewportHeight = viewportHeight;
|
||||||
|
commitViewport(viewportHeight, viewportTop);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyViewport = () => {
|
||||||
|
const vv = window.visualViewport;
|
||||||
|
const viewportHeight = vv?.height ?? window.innerHeight;
|
||||||
|
const viewportTop = vv?.offsetTop ?? 0;
|
||||||
|
scheduleViewport(viewportHeight, viewportTop);
|
||||||
|
};
|
||||||
|
|
||||||
|
applyViewport();
|
||||||
|
window.addEventListener("resize", applyViewport);
|
||||||
|
window.addEventListener("orientationchange", () => window.setTimeout(applyViewport, 120));
|
||||||
|
window.visualViewport?.addEventListener("resize", applyViewport);
|
||||||
|
window.visualViewport?.addEventListener("scroll", applyViewport);
|
||||||
|
}
|
||||||
|
|
||||||
|
disablePageZoom();
|
||||||
|
setupMobileViewportCompensation();
|
||||||
|
|
||||||
|
const app = createApp(App);
|
||||||
|
app.use(createPinia());
|
||||||
|
app.mount("#app");
|
||||||
14
terminal/apps/web/src/services/transport/factory.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { TerminalTransport } from "./terminalTransport";
|
||||||
|
import { GatewayTransport } from "./gatewayTransport";
|
||||||
|
import { IosNativeTransport } from "./iosNativeTransport";
|
||||||
|
|
||||||
|
export function createTransport(
|
||||||
|
mode: "gateway" | "ios-native",
|
||||||
|
options: { gatewayUrl: string; gatewayToken: string }
|
||||||
|
): TerminalTransport {
|
||||||
|
if (mode === "ios-native") {
|
||||||
|
return new IosNativeTransport();
|
||||||
|
}
|
||||||
|
return new GatewayTransport(options.gatewayUrl, options.gatewayToken);
|
||||||
|
}
|
||||||
|
|
||||||
555
terminal/apps/web/src/services/transport/gatewayTransport.ts
Normal file
@@ -0,0 +1,555 @@
|
|||||||
|
import type { SessionState } from "@remoteconn/terminal-core";
|
||||||
|
import type { ConnectParams, StdinMeta, TerminalTransport, TransportEvent } from "./terminalTransport";
|
||||||
|
|
||||||
|
type GatewayFrame = {
|
||||||
|
type: string;
|
||||||
|
payload?: Record<string, unknown>;
|
||||||
|
data?: unknown;
|
||||||
|
action?: unknown;
|
||||||
|
code?: unknown;
|
||||||
|
message?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class GatewayTransport implements TerminalTransport {
|
||||||
|
private static readonly PREFERRED_ENDPOINT_KEY = "terminal.gateway.preferredEndpoint";
|
||||||
|
private static readonly CONNECT_TIMEOUT_MS = 12000;
|
||||||
|
private socket: WebSocket | null = null;
|
||||||
|
private listeners = new Set<(event: TransportEvent) => void>();
|
||||||
|
private pingAt = 0;
|
||||||
|
private heartbeatTimer: number | null = null;
|
||||||
|
private connectAttemptId = 0;
|
||||||
|
private state: SessionState = "idle";
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly gatewayUrl: string,
|
||||||
|
private readonly token: string
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async connect(params: ConnectParams): Promise<void> {
|
||||||
|
const attemptId = ++this.connectAttemptId;
|
||||||
|
this.log("connect:start", {
|
||||||
|
attemptId,
|
||||||
|
host: params.host,
|
||||||
|
port: params.port,
|
||||||
|
username: params.username,
|
||||||
|
cols: params.cols,
|
||||||
|
rows: params.rows,
|
||||||
|
hasClientSessionKey: Boolean(params.clientSessionKey),
|
||||||
|
hasKnownHostFingerprint: Boolean(params.knownHostFingerprint)
|
||||||
|
});
|
||||||
|
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
||||||
|
this.log("connect:already_open");
|
||||||
|
throw new Error("会话已连接");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state = "connecting";
|
||||||
|
|
||||||
|
this.socket = await new Promise<WebSocket>((resolve, reject) => {
|
||||||
|
const endpoints = this.buildEndpoints();
|
||||||
|
this.log("connect:endpoints", { endpoints });
|
||||||
|
const reasons: string[] = [];
|
||||||
|
let index = 0;
|
||||||
|
const candidateHint = `候选地址: ${endpoints.join(", ")}`;
|
||||||
|
|
||||||
|
const tryConnect = (): void => {
|
||||||
|
if (attemptId !== this.connectAttemptId) {
|
||||||
|
this.log("connect:attempt_cancelled", { attemptId });
|
||||||
|
reject(new Error("连接已取消"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const endpoint = endpoints[index];
|
||||||
|
if (!endpoint) {
|
||||||
|
this.log("connect:all_failed", { reasons, candidateHint });
|
||||||
|
reject(new Error(`无法连接网关: ${reasons.join(" | ") || "无可用网关地址"} | ${candidateHint}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let settled = false;
|
||||||
|
let socket: WebSocket;
|
||||||
|
let timeoutTimer: number | null = null;
|
||||||
|
this.log("ws:connecting", { endpoint, attempt: index + 1, total: endpoints.length });
|
||||||
|
|
||||||
|
try {
|
||||||
|
socket = new WebSocket(endpoint);
|
||||||
|
this.socket = socket;
|
||||||
|
} catch {
|
||||||
|
this.log("ws:invalid_endpoint", { endpoint });
|
||||||
|
reasons.push(`地址无效: ${endpoint}`);
|
||||||
|
if (index < endpoints.length - 1) {
|
||||||
|
index += 1;
|
||||||
|
tryConnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(new Error(`无法连接网关: ${reasons.join(" | ")} | ${candidateHint}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
timeoutTimer = window.setTimeout(() => {
|
||||||
|
fail(`连接超时>${GatewayTransport.CONNECT_TIMEOUT_MS}ms`);
|
||||||
|
}, GatewayTransport.CONNECT_TIMEOUT_MS);
|
||||||
|
|
||||||
|
const clearTimer = (): void => {
|
||||||
|
if (timeoutTimer !== null) {
|
||||||
|
window.clearTimeout(timeoutTimer);
|
||||||
|
timeoutTimer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fail = (reason: string): void => {
|
||||||
|
if (settled) return;
|
||||||
|
if (attemptId !== this.connectAttemptId) {
|
||||||
|
settled = true;
|
||||||
|
clearTimer();
|
||||||
|
reject(new Error("连接已取消"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
clearTimer();
|
||||||
|
reasons.push(`${reason}: ${endpoint}`);
|
||||||
|
this.log("ws:connect_failed", { endpoint, reason });
|
||||||
|
if (this.getPreferredEndpoint() === endpoint) {
|
||||||
|
this.clearPreferredEndpoint();
|
||||||
|
this.log("connect:preferred_endpoint_cleared", { endpoint, reason });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
socket.close();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index < endpoints.length - 1) {
|
||||||
|
index += 1;
|
||||||
|
tryConnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(new Error(`无法连接网关: ${reasons.join(" | ")} | ${candidateHint}`));
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onopen = () => {
|
||||||
|
if (settled) return;
|
||||||
|
if (attemptId !== this.connectAttemptId) {
|
||||||
|
settled = true;
|
||||||
|
clearTimer();
|
||||||
|
try {
|
||||||
|
socket.close();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
reject(new Error("连接已取消"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
clearTimer();
|
||||||
|
this.log("ws:open", { endpoint });
|
||||||
|
this.setPreferredEndpoint(endpoint);
|
||||||
|
resolve(socket);
|
||||||
|
};
|
||||||
|
socket.onerror = () => fail("网络或协议错误");
|
||||||
|
socket.onclose = (event) => {
|
||||||
|
if (!settled) {
|
||||||
|
fail(`连接关闭 code=${event.code} reason=${event.reason || "none"}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
tryConnect();
|
||||||
|
});
|
||||||
|
if (attemptId !== this.connectAttemptId) {
|
||||||
|
this.log("connect:socket_ready_but_cancelled", { attemptId });
|
||||||
|
try {
|
||||||
|
this.socket?.close();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
throw new Error("连接已取消");
|
||||||
|
}
|
||||||
|
this.log("connect:socket_ready", { readyState: this.socket.readyState });
|
||||||
|
|
||||||
|
this.socket.onmessage = (event) => {
|
||||||
|
const text = typeof event.data === "string" ? event.data : "";
|
||||||
|
if (!text) return;
|
||||||
|
let frame: GatewayFrame;
|
||||||
|
try {
|
||||||
|
frame = JSON.parse(text) as GatewayFrame;
|
||||||
|
} catch {
|
||||||
|
this.log("ws:message_parse_failed", { sample: text.slice(0, 120) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.handleFrame(frame);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.socket.onclose = (event) => {
|
||||||
|
console.log(`[GatewayTransport][${new Date().toISOString()}] ws:onclose`, {
|
||||||
|
code: event.code,
|
||||||
|
reason: event.reason || "none",
|
||||||
|
wasClean: event.wasClean
|
||||||
|
});
|
||||||
|
console.error(`[GatewayTransport][${new Date().toISOString()}] ws:onclose`, {
|
||||||
|
code: event.code,
|
||||||
|
reason: event.reason || "none",
|
||||||
|
wasClean: event.wasClean
|
||||||
|
});
|
||||||
|
this.log("ws:onclose", { code: event.code, reason: event.reason || "none", wasClean: event.wasClean });
|
||||||
|
this.stopHeartbeat();
|
||||||
|
this.state = "disconnected";
|
||||||
|
this.emit({
|
||||||
|
type: "disconnect",
|
||||||
|
reason: `ws_closed(code=${event.code},reason=${event.reason || "none"},clean=${event.wasClean})`
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
this.socket.onerror = () => {
|
||||||
|
console.log(`[GatewayTransport][${new Date().toISOString()}] ws:onerror`);
|
||||||
|
console.error(`[GatewayTransport][${new Date().toISOString()}] ws:onerror`);
|
||||||
|
this.log("ws:onerror");
|
||||||
|
this.stopHeartbeat();
|
||||||
|
this.state = "error";
|
||||||
|
this.emit({ type: "error", code: "WS_ERROR", message: "WebSocket 异常" });
|
||||||
|
};
|
||||||
|
|
||||||
|
this.log("connect:send_init", {
|
||||||
|
host: params.host,
|
||||||
|
port: params.port,
|
||||||
|
username: params.username,
|
||||||
|
cols: params.cols,
|
||||||
|
rows: params.rows,
|
||||||
|
hasKnownHostFingerprint: Boolean(params.knownHostFingerprint)
|
||||||
|
});
|
||||||
|
this.sendRaw({
|
||||||
|
type: "init",
|
||||||
|
payload: {
|
||||||
|
host: params.host,
|
||||||
|
port: params.port,
|
||||||
|
username: params.username,
|
||||||
|
...(params.clientSessionKey ? { clientSessionKey: params.clientSessionKey } : {}),
|
||||||
|
credential: params.credential,
|
||||||
|
...(params.knownHostFingerprint ? { knownHostFingerprint: params.knownHostFingerprint } : {}),
|
||||||
|
pty: { cols: params.cols, rows: params.rows }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.startHeartbeat();
|
||||||
|
this.state = "auth_pending";
|
||||||
|
this.log("connect:state_auth_pending");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async send(data: string, meta?: StdinMeta): Promise<void> {
|
||||||
|
this.log("stdin:send", { length: data.length, source: meta?.source ?? "keyboard" });
|
||||||
|
this.sendRaw({
|
||||||
|
type: "stdin",
|
||||||
|
payload: {
|
||||||
|
data,
|
||||||
|
...(meta ? { meta } : {})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async resize(cols: number, rows: number): Promise<void> {
|
||||||
|
this.log("pty:resize", { cols, rows });
|
||||||
|
this.sendRaw({ type: "resize", payload: { cols, rows } });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async disconnect(reason = "manual"): Promise<void> {
|
||||||
|
this.connectAttemptId += 1;
|
||||||
|
this.log("disconnect:requested", { reason, readyState: this.socket?.readyState ?? null });
|
||||||
|
this.stopHeartbeat();
|
||||||
|
if (this.socket) {
|
||||||
|
if (this.socket.readyState === WebSocket.OPEN) {
|
||||||
|
this.log("disconnect:send_control", { reason });
|
||||||
|
this.sendRaw({ type: "control", payload: { action: "disconnect", reason } });
|
||||||
|
}
|
||||||
|
// Force close regardless of state (e.g. CONNECTING)
|
||||||
|
try {
|
||||||
|
this.socket.close();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.socket = null;
|
||||||
|
this.state = "disconnected";
|
||||||
|
this.log("disconnect:done");
|
||||||
|
}
|
||||||
|
|
||||||
|
public on(listener: (event: TransportEvent) => void): () => void {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => this.listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getState(): SessionState {
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendRaw(frame: Record<string, unknown>): void {
|
||||||
|
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
||||||
|
this.log("ws:send_blocked", {
|
||||||
|
frameType: String(frame.type ?? "unknown"),
|
||||||
|
readyState: this.socket?.readyState ?? null,
|
||||||
|
state: this.state
|
||||||
|
});
|
||||||
|
throw new Error("网关连接未建立");
|
||||||
|
}
|
||||||
|
this.socket.send(JSON.stringify(frame));
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleFrame(frame: GatewayFrame): void {
|
||||||
|
const payload = frame.payload ?? {};
|
||||||
|
const type = String(frame.type ?? "");
|
||||||
|
const action = String((payload.action ?? frame.action ?? "") as string);
|
||||||
|
if (type !== "stdout" && type !== "stderr") {
|
||||||
|
this.log("ws:frame", { type, action: action || undefined });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "stdout") {
|
||||||
|
const data = String(payload.data ?? frame.data ?? "");
|
||||||
|
if (!data) return;
|
||||||
|
if (this.state !== "connected") {
|
||||||
|
this.log("ws:stdout_promote_connected");
|
||||||
|
}
|
||||||
|
this.state = "connected";
|
||||||
|
this.emit({ type: "stdout", data });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "stderr") {
|
||||||
|
const data = String(payload.data ?? frame.data ?? "");
|
||||||
|
if (!data) return;
|
||||||
|
this.emit({ type: "stderr", data });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "error") {
|
||||||
|
console.log(`[GatewayTransport][${new Date().toISOString()}] ws:error_frame`, {
|
||||||
|
code: String(payload.code ?? frame.code ?? "INTERNAL_ERROR"),
|
||||||
|
message: String(payload.message ?? frame.message ?? "未知错误")
|
||||||
|
});
|
||||||
|
console.error(`[GatewayTransport][${new Date().toISOString()}] ws:error_frame`, {
|
||||||
|
code: String(payload.code ?? frame.code ?? "INTERNAL_ERROR"),
|
||||||
|
message: String(payload.message ?? frame.message ?? "未知错误")
|
||||||
|
});
|
||||||
|
this.log("ws:error_frame", {
|
||||||
|
code: String(payload.code ?? frame.code ?? "INTERNAL_ERROR"),
|
||||||
|
message: String(payload.message ?? frame.message ?? "未知错误")
|
||||||
|
});
|
||||||
|
this.state = "error";
|
||||||
|
this.emit({
|
||||||
|
type: "error",
|
||||||
|
code: String(payload.code ?? frame.code ?? "INTERNAL_ERROR"),
|
||||||
|
message: String(payload.message ?? frame.message ?? "未知错误")
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "connected") {
|
||||||
|
this.log("ws:connected_frame", { fingerprint: String(payload.fingerprint ?? "") || undefined });
|
||||||
|
this.state = "connected";
|
||||||
|
this.emit({ type: "connected", fingerprint: String(payload.fingerprint ?? "") || undefined });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "disconnect") {
|
||||||
|
this.log("ws:disconnect_frame", { reason: String(payload.reason ?? "unknown") });
|
||||||
|
this.state = "disconnected";
|
||||||
|
this.stopHeartbeat();
|
||||||
|
this.emit({ type: "disconnect", reason: String(payload.reason ?? "unknown") });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "control") {
|
||||||
|
if (action === "ping") {
|
||||||
|
this.log("heartbeat:ping_recv");
|
||||||
|
this.sendRaw({ type: "control", payload: { action: "pong" } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === "pong") {
|
||||||
|
if (this.pingAt > 0) {
|
||||||
|
this.log("heartbeat:pong_recv", { latencyMs: Date.now() - this.pingAt });
|
||||||
|
this.emit({ type: "latency", data: Date.now() - this.pingAt });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === "connected") {
|
||||||
|
this.log("ws:control_connected", { fingerprint: String(payload.fingerprint ?? "") || undefined });
|
||||||
|
this.state = "connected";
|
||||||
|
this.emit({ type: "connected", fingerprint: String(payload.fingerprint ?? "") || undefined });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === "disconnect") {
|
||||||
|
this.log("ws:control_disconnect", { reason: String(payload.reason ?? "unknown") });
|
||||||
|
this.state = "disconnected";
|
||||||
|
this.stopHeartbeat();
|
||||||
|
this.emit({ type: "disconnect", reason: String(payload.reason ?? "unknown") });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit(event: TransportEvent): void {
|
||||||
|
for (const listener of this.listeners) {
|
||||||
|
listener(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private startHeartbeat(): void {
|
||||||
|
this.stopHeartbeat();
|
||||||
|
this.log("heartbeat:start");
|
||||||
|
this.heartbeatTimer = window.setInterval(() => {
|
||||||
|
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.pingAt = Date.now();
|
||||||
|
this.log("heartbeat:ping_send");
|
||||||
|
this.sendRaw({ type: "control", payload: { action: "ping" } });
|
||||||
|
}, 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopHeartbeat(): void {
|
||||||
|
if (this.heartbeatTimer) {
|
||||||
|
this.log("heartbeat:stop");
|
||||||
|
window.clearInterval(this.heartbeatTimer);
|
||||||
|
this.heartbeatTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private log(_message: string, _detail?: unknown): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
const enabled =
|
||||||
|
window.localStorage.getItem("terminal.debugTransport") === "1" ||
|
||||||
|
window.localStorage.getItem("terminal.debugPaste") === "1";
|
||||||
|
if (!enabled) return;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const prefix = `[GatewayTransport][${new Date().toISOString()}] ${_message}`;
|
||||||
|
if (typeof _detail === "undefined") {
|
||||||
|
console.log(prefix);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(prefix, _detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildEndpoints(): string[] {
|
||||||
|
const pageIsHttps = window.location.protocol === "https:";
|
||||||
|
const pageHost = window.location.hostname;
|
||||||
|
const pageHostWithPort = window.location.host;
|
||||||
|
const pageProtocol = pageIsHttps ? "wss:" : "ws:";
|
||||||
|
const pageOrigin = `${pageProtocol}//${pageHostWithPort}`;
|
||||||
|
const rawInput = this.gatewayUrl.trim();
|
||||||
|
const candidates: string[] = [];
|
||||||
|
|
||||||
|
const finalizeEndpoint = (source: URL): string => {
|
||||||
|
const next = new URL(source.toString());
|
||||||
|
const pathname = next.pathname.replace(/\/+$/, "");
|
||||||
|
next.pathname = pathname.endsWith("/ws/terminal") ? pathname : `${pathname}/ws/terminal`.replace(/\/{2,}/g, "/");
|
||||||
|
next.search = `token=${encodeURIComponent(this.token)}`;
|
||||||
|
return next.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const pushCandidate = (next: URL): void => {
|
||||||
|
if (pageIsHttps && next.protocol === "ws:") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
candidates.push(finalizeEndpoint(next));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 兼容相对路径配置(如 /ws/terminal),并以当前页面源为基准解析。
|
||||||
|
const fallbackUrl = new URL(pageOrigin);
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
if (!rawInput) {
|
||||||
|
url = new URL(fallbackUrl.toString());
|
||||||
|
} else if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(rawInput)) {
|
||||||
|
url = new URL(rawInput);
|
||||||
|
} else {
|
||||||
|
url = new URL(rawInput, fallbackUrl.toString());
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
url = new URL(fallbackUrl.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.protocol === "http:") url.protocol = "ws:";
|
||||||
|
if (url.protocol === "https:") url.protocol = "wss:";
|
||||||
|
if (pageIsHttps && url.protocol === "ws:") {
|
||||||
|
url.protocol = "wss:";
|
||||||
|
}
|
||||||
|
|
||||||
|
const localHosts = new Set(["localhost", "127.0.0.1", "::1"]);
|
||||||
|
const pageIsLocal = localHosts.has(pageHost);
|
||||||
|
let targetIsLocal = localHosts.has(url.hostname);
|
||||||
|
if (!pageIsLocal && targetIsLocal) {
|
||||||
|
// 页面从远程域名访问时,若配置仍是 localhost/127.0.0.1,
|
||||||
|
// 优先尝试同源地址(通常由 Vite/Nginx 代理到本地网关)。
|
||||||
|
const sameOrigin = new URL(url.toString());
|
||||||
|
sameOrigin.protocol = pageProtocol;
|
||||||
|
sameOrigin.host = pageHostWithPort;
|
||||||
|
pushCandidate(sameOrigin);
|
||||||
|
|
||||||
|
const sameOriginNoPort = new URL(sameOrigin.toString());
|
||||||
|
sameOriginNoPort.port = "";
|
||||||
|
pushCandidate(sameOriginNoPort);
|
||||||
|
|
||||||
|
url.hostname = pageHost;
|
||||||
|
targetIsLocal = localHosts.has(url.hostname);
|
||||||
|
}
|
||||||
|
|
||||||
|
pushCandidate(url);
|
||||||
|
|
||||||
|
if (!pageIsHttps && url.protocol === "ws:") {
|
||||||
|
const tlsUrl = new URL(url.toString());
|
||||||
|
tlsUrl.protocol = "wss:";
|
||||||
|
pushCandidate(tlsUrl);
|
||||||
|
} else if (url.protocol === "wss:" && !pageIsHttps) {
|
||||||
|
const plainUrl = new URL(url.toString());
|
||||||
|
plainUrl.protocol = "ws:";
|
||||||
|
pushCandidate(plainUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetIsLocal) {
|
||||||
|
const noPort = new URL(url.toString());
|
||||||
|
noPort.port = "";
|
||||||
|
pushCandidate(noPort);
|
||||||
|
|
||||||
|
if (!pageIsHttps && noPort.protocol === "ws:") {
|
||||||
|
const noPortTls = new URL(noPort.toString());
|
||||||
|
noPortTls.protocol = "wss:";
|
||||||
|
pushCandidate(noPortTls);
|
||||||
|
} else if (noPort.protocol === "wss:" && !pageIsHttps) {
|
||||||
|
const noPortPlain = new URL(noPort.toString());
|
||||||
|
noPortPlain.protocol = "ws:";
|
||||||
|
pushCandidate(noPortPlain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ordered = [...new Set(candidates)];
|
||||||
|
if (ordered.length === 0) {
|
||||||
|
return [finalizeEndpoint(fallbackUrl)];
|
||||||
|
}
|
||||||
|
const preferred = this.getPreferredEndpoint();
|
||||||
|
if (preferred && ordered.includes(preferred)) {
|
||||||
|
return [preferred, ...ordered.filter((endpoint) => endpoint !== preferred)];
|
||||||
|
}
|
||||||
|
return ordered;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getPreferredEndpoint(): string | null {
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(GatewayTransport.PREFERRED_ENDPOINT_KEY);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setPreferredEndpoint(endpoint: string): void {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(GatewayTransport.PREFERRED_ENDPOINT_KEY, endpoint);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearPreferredEndpoint(): void {
|
||||||
|
try {
|
||||||
|
window.localStorage.removeItem(GatewayTransport.PREFERRED_ENDPOINT_KEY);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
174
terminal/apps/web/src/services/transport/iosNativeTransport.ts
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import type { SessionState } from "@remoteconn/terminal-core";
|
||||||
|
import type { ConnectParams, StdinMeta, TerminalTransport, TransportEvent } from "./terminalTransport";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
Capacitor?: {
|
||||||
|
Plugins?: {
|
||||||
|
RemoteConnSSH?: {
|
||||||
|
connect(options: unknown): Promise<void>;
|
||||||
|
send(options: { data: string }): Promise<void>;
|
||||||
|
resize(options: { cols: number; rows: number }): Promise<void>;
|
||||||
|
disconnect(options: { reason?: string }): Promise<void>;
|
||||||
|
addListener(
|
||||||
|
eventName: "stdout" | "stderr" | "disconnect" | "latency" | "error" | "connected",
|
||||||
|
listener: (payload: unknown) => void
|
||||||
|
): Promise<{ remove: () => void }>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type NativeCredentialPayload =
|
||||||
|
| { type: "password"; password: string }
|
||||||
|
| { type: "privateKey"; privateKey: string; passphrase?: string }
|
||||||
|
| { type: "certificate"; privateKey: string; passphrase?: string; certificate: string };
|
||||||
|
|
||||||
|
interface NativeConnectPayload {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
knownHostFingerprint?: string;
|
||||||
|
cols: number;
|
||||||
|
rows: number;
|
||||||
|
credential: NativeCredentialPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNativeConnectPayload(params: ConnectParams): NativeConnectPayload {
|
||||||
|
const base = {
|
||||||
|
host: String(params.host ?? ""),
|
||||||
|
port: Number(params.port ?? 22),
|
||||||
|
username: String(params.username ?? ""),
|
||||||
|
cols: Number(params.cols ?? 80),
|
||||||
|
rows: Number(params.rows ?? 24)
|
||||||
|
};
|
||||||
|
|
||||||
|
const knownHostFingerprint =
|
||||||
|
typeof params.knownHostFingerprint === "string" && params.knownHostFingerprint.trim().length > 0
|
||||||
|
? params.knownHostFingerprint.trim()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (params.credential.type === "password") {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
...(knownHostFingerprint ? { knownHostFingerprint } : {}),
|
||||||
|
credential: {
|
||||||
|
type: "password",
|
||||||
|
password: String(params.credential.password ?? "")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.credential.type === "privateKey") {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
...(knownHostFingerprint ? { knownHostFingerprint } : {}),
|
||||||
|
credential: {
|
||||||
|
type: "privateKey",
|
||||||
|
privateKey: String(params.credential.privateKey ?? ""),
|
||||||
|
...(params.credential.passphrase ? { passphrase: String(params.credential.passphrase) } : {})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
...(knownHostFingerprint ? { knownHostFingerprint } : {}),
|
||||||
|
credential: {
|
||||||
|
type: "certificate",
|
||||||
|
privateKey: String(params.credential.privateKey ?? ""),
|
||||||
|
certificate: String(params.credential.certificate ?? ""),
|
||||||
|
...(params.credential.passphrase ? { passphrase: String(params.credential.passphrase) } : {})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class IosNativeTransport implements TerminalTransport {
|
||||||
|
private state: SessionState = "idle";
|
||||||
|
private listeners = new Set<(event: TransportEvent) => void>();
|
||||||
|
private disposers: Array<() => void> = [];
|
||||||
|
|
||||||
|
public async connect(params: ConnectParams): Promise<void> {
|
||||||
|
const plugin = window.Capacitor?.Plugins?.RemoteConnSSH;
|
||||||
|
if (!plugin) {
|
||||||
|
throw new Error("iOS 原生插件不可用");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state = "connecting";
|
||||||
|
|
||||||
|
const onStdout = await plugin.addListener("stdout", (payload) => {
|
||||||
|
this.state = "connected";
|
||||||
|
this.emit({ type: "stdout", data: String((payload as { data?: string }).data ?? "") });
|
||||||
|
});
|
||||||
|
this.disposers.push(() => onStdout.remove());
|
||||||
|
|
||||||
|
const onStderr = await plugin.addListener("stderr", (payload) => {
|
||||||
|
this.emit({ type: "stderr", data: String((payload as { data?: string }).data ?? "") });
|
||||||
|
});
|
||||||
|
this.disposers.push(() => onStderr.remove());
|
||||||
|
|
||||||
|
const onDisconnect = await plugin.addListener("disconnect", (payload) => {
|
||||||
|
this.state = "disconnected";
|
||||||
|
this.emit({ type: "disconnect", reason: String((payload as { reason?: string }).reason ?? "disconnect") });
|
||||||
|
});
|
||||||
|
this.disposers.push(() => onDisconnect.remove());
|
||||||
|
|
||||||
|
const onLatency = await plugin.addListener("latency", (payload) => {
|
||||||
|
this.emit({ type: "latency", data: Number((payload as { latency?: number }).latency ?? 0) });
|
||||||
|
});
|
||||||
|
this.disposers.push(() => onLatency.remove());
|
||||||
|
|
||||||
|
const onError = await plugin.addListener("error", (payload) => {
|
||||||
|
this.state = "error";
|
||||||
|
const error = payload as { code?: string; message?: string };
|
||||||
|
this.emit({
|
||||||
|
type: "error",
|
||||||
|
code: String(error.code ?? "NATIVE_ERROR"),
|
||||||
|
message: String(error.message ?? "iOS 连接异常")
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.disposers.push(() => onError.remove());
|
||||||
|
|
||||||
|
const onConnected = await plugin.addListener("connected", (payload) => {
|
||||||
|
this.state = "connected";
|
||||||
|
this.emit({ type: "connected", fingerprint: String((payload as { fingerprint?: string }).fingerprint ?? "") || undefined });
|
||||||
|
});
|
||||||
|
this.disposers.push(() => onConnected.remove());
|
||||||
|
|
||||||
|
await plugin.connect(buildNativeConnectPayload(params));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async send(data: string, _meta?: StdinMeta): Promise<void> {
|
||||||
|
await window.Capacitor?.Plugins?.RemoteConnSSH?.send({ data });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async resize(cols: number, rows: number): Promise<void> {
|
||||||
|
await window.Capacitor?.Plugins?.RemoteConnSSH?.resize({ cols, rows });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async disconnect(reason?: string): Promise<void> {
|
||||||
|
await window.Capacitor?.Plugins?.RemoteConnSSH?.disconnect({ reason });
|
||||||
|
for (const dispose of this.disposers) {
|
||||||
|
dispose();
|
||||||
|
}
|
||||||
|
this.disposers = [];
|
||||||
|
this.state = "disconnected";
|
||||||
|
}
|
||||||
|
|
||||||
|
public on(listener: (event: TransportEvent) => void): () => void {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => this.listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getState(): SessionState {
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit(event: TransportEvent): void {
|
||||||
|
for (const listener of this.listeners) {
|
||||||
|
listener(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { ConnectParams as CoreConnectParams, FrameMeta, SessionState } from "@remoteconn/terminal-core";
|
||||||
|
|
||||||
|
export type ConnectParams = CoreConnectParams;
|
||||||
|
export type StdinMeta = FrameMeta;
|
||||||
|
|
||||||
|
export type TransportEvent =
|
||||||
|
| { type: "stdout"; data: string }
|
||||||
|
| { type: "stderr"; data: string }
|
||||||
|
| { type: "latency"; data: number }
|
||||||
|
| { type: "connected"; fingerprint?: string }
|
||||||
|
| { type: "disconnect"; reason: string }
|
||||||
|
| { type: "error"; code: string; message: string };
|
||||||
|
|
||||||
|
export interface TerminalTransport {
|
||||||
|
connect(params: ConnectParams): Promise<void>;
|
||||||
|
send(data: string, meta?: StdinMeta): Promise<void>;
|
||||||
|
resize(cols: number, rows: number): Promise<void>;
|
||||||
|
disconnect(reason?: string): Promise<void>;
|
||||||
|
on(listener: (event: TransportEvent) => void): () => void;
|
||||||
|
getState(): SessionState;
|
||||||
|
}
|
||||||
|
|
||||||
32
terminal/apps/web/src/stores/appStore.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { defineStore } from "pinia";
|
||||||
|
import { ref } from "vue";
|
||||||
|
|
||||||
|
interface AppToast {
|
||||||
|
id: string;
|
||||||
|
level: "info" | "warn" | "error";
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAppStore = defineStore("app", () => {
|
||||||
|
const toasts = ref<AppToast[]>([]);
|
||||||
|
|
||||||
|
function notify(level: AppToast["level"], message: string): void {
|
||||||
|
const item: AppToast = {
|
||||||
|
id: typeof crypto !== "undefined" && "randomUUID" in crypto
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: `toast-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||||
|
level,
|
||||||
|
message
|
||||||
|
};
|
||||||
|
toasts.value.push(item);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
toasts.value = toasts.value.filter((x) => x.id !== item.id);
|
||||||
|
}, level === "error" ? 5000 : 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
toasts,
|
||||||
|
notify
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
231
terminal/apps/web/src/stores/serverStore.ts
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import { defineStore } from "pinia";
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
import type { TerminalCredential } from "@remoteconn/terminal-core";
|
||||||
|
import { loadRuntimeConfig } from "@/utils/runtimeConfig";
|
||||||
|
|
||||||
|
export interface ServerProfile {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
transportMode: "gateway" | "ios-native";
|
||||||
|
knownHostFingerprint?: string;
|
||||||
|
authType: "password" | "privateKey" | "certificate";
|
||||||
|
password?: string;
|
||||||
|
privateKey?: string;
|
||||||
|
passphrase?: string;
|
||||||
|
certificate?: string;
|
||||||
|
cols?: number;
|
||||||
|
rows?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = "remoteconn:web:servers:v1";
|
||||||
|
const SELECTED_SERVER_KEY = "remoteconn:web:selected-server-id:v1";
|
||||||
|
|
||||||
|
function loadServers(): ServerProfile[] {
|
||||||
|
if (typeof window === "undefined") return [];
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
return normalizeServers(parsed);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toFiniteNumber(value: unknown, fallback: number): number {
|
||||||
|
const n = Number(value);
|
||||||
|
return Number.isFinite(n) ? n : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeServers(input: unknown): ServerProfile[] {
|
||||||
|
if (!Array.isArray(input)) return [];
|
||||||
|
return input
|
||||||
|
.filter((item): item is Record<string, unknown> => !!item && typeof item === "object" && typeof (item as { id?: unknown }).id === "string")
|
||||||
|
.map((item) => {
|
||||||
|
const authType = item.authType === "privateKey" || item.authType === "certificate" ? item.authType : "password";
|
||||||
|
return {
|
||||||
|
id: String(item.id),
|
||||||
|
name: typeof item.name === "string" ? item.name : "未命名服务器",
|
||||||
|
host: typeof item.host === "string" ? item.host : "",
|
||||||
|
port: toFiniteNumber(item.port, 22),
|
||||||
|
username: typeof item.username === "string" ? item.username : "root",
|
||||||
|
transportMode: item.transportMode === "ios-native" ? "ios-native" : "gateway",
|
||||||
|
authType,
|
||||||
|
...(typeof item.knownHostFingerprint === "string" && item.knownHostFingerprint ? { knownHostFingerprint: item.knownHostFingerprint } : {}),
|
||||||
|
...(typeof item.password === "string" ? { password: item.password } : {}),
|
||||||
|
...(typeof item.privateKey === "string" ? { privateKey: item.privateKey } : {}),
|
||||||
|
...(typeof item.passphrase === "string" ? { passphrase: item.passphrase } : {}),
|
||||||
|
...(typeof item.certificate === "string" ? { certificate: item.certificate } : {}),
|
||||||
|
cols: toFiniteNumber(item.cols, 80),
|
||||||
|
rows: toFiniteNumber(item.rows, 24)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistServers(servers: ServerProfile[]): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(servers));
|
||||||
|
} catch {
|
||||||
|
// ignore storage unavailability in embedded/private contexts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSelectedServerId(): string {
|
||||||
|
if (typeof window === "undefined") return "";
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(SELECTED_SERVER_KEY) ?? "";
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistSelectedServerId(id: string): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(SELECTED_SERVER_KEY, id);
|
||||||
|
} catch {
|
||||||
|
// ignore storage unavailability in embedded/private contexts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDefaultServer(): ServerProfile {
|
||||||
|
return {
|
||||||
|
id: `srv-${typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : Date.now()}`,
|
||||||
|
name: "新服务器",
|
||||||
|
host: "",
|
||||||
|
port: 22,
|
||||||
|
username: "root",
|
||||||
|
transportMode: "gateway",
|
||||||
|
authType: "password",
|
||||||
|
password: "",
|
||||||
|
cols: 80,
|
||||||
|
rows: 24
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useServerStore = defineStore("server", () => {
|
||||||
|
const servers = ref<ServerProfile[]>([]);
|
||||||
|
const selectedServerId = ref<string>("");
|
||||||
|
const loaded = ref(false);
|
||||||
|
let bootstrapPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
const selectedServer = computed(() => servers.value.find((item) => item.id === selectedServerId.value));
|
||||||
|
|
||||||
|
async function ensureBootstrapped(): Promise<void> {
|
||||||
|
if (loaded.value) return;
|
||||||
|
if (bootstrapPromise) {
|
||||||
|
await bootstrapPromise;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bootstrapPromise = (async () => {
|
||||||
|
const runtimeConfig = await loadRuntimeConfig();
|
||||||
|
const configured = normalizeServers(runtimeConfig?.servers);
|
||||||
|
const stored = loadServers();
|
||||||
|
if (configured.length > 0) {
|
||||||
|
servers.value = configured;
|
||||||
|
} else if (stored.length > 0) {
|
||||||
|
servers.value = stored;
|
||||||
|
} else {
|
||||||
|
const sample = buildDefaultServer();
|
||||||
|
servers.value = [sample];
|
||||||
|
persistServers(servers.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferred = runtimeConfig?.selectedServerId ?? loadSelectedServerId();
|
||||||
|
selectedServerId.value = servers.value.some((item) => item.id === preferred)
|
||||||
|
? preferred
|
||||||
|
: (servers.value[0]?.id ?? "");
|
||||||
|
persistSelectedServerId(selectedServerId.value);
|
||||||
|
loaded.value = true;
|
||||||
|
})();
|
||||||
|
try {
|
||||||
|
await bootstrapPromise;
|
||||||
|
} finally {
|
||||||
|
bootstrapPromise = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootstrap(): Promise<void> {
|
||||||
|
await ensureBootstrapped();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSelectedServer(serverId: string): void {
|
||||||
|
selectedServerId.value = serverId;
|
||||||
|
persistSelectedServerId(serverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveServer(server: ServerProfile): Promise<void> {
|
||||||
|
const index = servers.value.findIndex((item) => item.id === server.id);
|
||||||
|
if (index >= 0) {
|
||||||
|
servers.value[index] = server;
|
||||||
|
} else {
|
||||||
|
servers.value.unshift(server);
|
||||||
|
}
|
||||||
|
persistServers(servers.value);
|
||||||
|
if (!selectedServerId.value) {
|
||||||
|
setSelectedServer(server.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createServer(): Promise<ServerProfile> {
|
||||||
|
const sample = buildDefaultServer();
|
||||||
|
servers.value.unshift(sample);
|
||||||
|
persistServers(servers.value);
|
||||||
|
setSelectedServer(sample.id);
|
||||||
|
return sample;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteServer(serverId: string): Promise<void> {
|
||||||
|
servers.value = servers.value.filter((item) => item.id !== serverId);
|
||||||
|
persistServers(servers.value);
|
||||||
|
if (selectedServerId.value === serverId) {
|
||||||
|
setSelectedServer(servers.value[0]?.id ?? "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveCredential(serverId: string): Promise<TerminalCredential> {
|
||||||
|
const server = servers.value.find((item) => item.id === serverId);
|
||||||
|
if (!server) {
|
||||||
|
throw new Error("目标服务器不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.authType === "password") {
|
||||||
|
return {
|
||||||
|
type: "password",
|
||||||
|
password: server.password ?? ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.authType === "privateKey") {
|
||||||
|
return {
|
||||||
|
type: "privateKey",
|
||||||
|
privateKey: server.privateKey ?? "",
|
||||||
|
...(server.passphrase ? { passphrase: server.passphrase } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "certificate",
|
||||||
|
privateKey: server.privateKey ?? "",
|
||||||
|
certificate: server.certificate ?? "",
|
||||||
|
...(server.passphrase ? { passphrase: server.passphrase } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
servers,
|
||||||
|
selectedServerId,
|
||||||
|
selectedServer,
|
||||||
|
ensureBootstrapped,
|
||||||
|
bootstrap,
|
||||||
|
setSelectedServer,
|
||||||
|
saveServer,
|
||||||
|
createServer,
|
||||||
|
deleteServer,
|
||||||
|
resolveCredential
|
||||||
|
};
|
||||||
|
});
|
||||||
110
terminal/apps/web/src/stores/settingsStore.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { defineStore } from "pinia";
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
import { loadRuntimeConfig } from "@/utils/runtimeConfig";
|
||||||
|
|
||||||
|
interface RuntimeSettings {
|
||||||
|
gatewayUrl: string;
|
||||||
|
gatewayToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = "remoteconn:web:settings:v1";
|
||||||
|
|
||||||
|
function resolveDefaultGatewayUrl(): string {
|
||||||
|
const env = (import.meta as ImportMeta & { env?: Record<string, string | undefined> }).env;
|
||||||
|
const envUrl = env?.VITE_GATEWAY_URL?.trim();
|
||||||
|
if (envUrl) return envUrl;
|
||||||
|
|
||||||
|
if (typeof window === "undefined") return "ws://127.0.0.1:8787/ws/terminal";
|
||||||
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
return `${protocol}//${window.location.host}/ws/terminal`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDefaultGatewayToken(): string {
|
||||||
|
const env = (import.meta as ImportMeta & { env?: Record<string, string | undefined> }).env;
|
||||||
|
const envToken = env?.VITE_GATEWAY_TOKEN?.trim();
|
||||||
|
if (envToken) return envToken;
|
||||||
|
return "remoteconn-dev-token";
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSettings(): RuntimeSettings {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return { gatewayUrl: resolveDefaultGatewayUrl(), gatewayToken: resolveDefaultGatewayToken() };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) {
|
||||||
|
return { gatewayUrl: resolveDefaultGatewayUrl(), gatewayToken: resolveDefaultGatewayToken() };
|
||||||
|
}
|
||||||
|
const parsed = JSON.parse(raw) as Partial<RuntimeSettings>;
|
||||||
|
return {
|
||||||
|
gatewayUrl: String(parsed.gatewayUrl ?? resolveDefaultGatewayUrl()),
|
||||||
|
gatewayToken: String(parsed.gatewayToken ?? resolveDefaultGatewayToken())
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { gatewayUrl: resolveDefaultGatewayUrl(), gatewayToken: resolveDefaultGatewayToken() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistSettings(settings: RuntimeSettings): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||||
|
} catch {
|
||||||
|
// ignore storage unavailability in embedded/private contexts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSettingsStore = defineStore("settings", () => {
|
||||||
|
const settings = ref<RuntimeSettings>({
|
||||||
|
gatewayUrl: resolveDefaultGatewayUrl(),
|
||||||
|
gatewayToken: resolveDefaultGatewayToken()
|
||||||
|
});
|
||||||
|
const loaded = ref(false);
|
||||||
|
let bootstrapPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
async function ensureBootstrapped(): Promise<void> {
|
||||||
|
if (loaded.value) return;
|
||||||
|
if (bootstrapPromise) {
|
||||||
|
await bootstrapPromise;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bootstrapPromise = (async () => {
|
||||||
|
const runtimeConfig = await loadRuntimeConfig();
|
||||||
|
const localSettings = loadSettings();
|
||||||
|
settings.value = {
|
||||||
|
gatewayUrl: String(runtimeConfig?.gatewayUrl ?? localSettings.gatewayUrl ?? resolveDefaultGatewayUrl()),
|
||||||
|
gatewayToken: String(runtimeConfig?.gatewayToken ?? localSettings.gatewayToken ?? resolveDefaultGatewayToken())
|
||||||
|
};
|
||||||
|
loaded.value = true;
|
||||||
|
})();
|
||||||
|
try {
|
||||||
|
await bootstrapPromise;
|
||||||
|
} finally {
|
||||||
|
bootstrapPromise = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootstrap(): Promise<void> {
|
||||||
|
await ensureBootstrapped();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(next: Partial<RuntimeSettings>): Promise<void> {
|
||||||
|
settings.value = {
|
||||||
|
gatewayUrl: String(next.gatewayUrl ?? settings.value.gatewayUrl ?? resolveDefaultGatewayUrl()),
|
||||||
|
gatewayToken: String(next.gatewayToken ?? settings.value.gatewayToken ?? resolveDefaultGatewayToken())
|
||||||
|
};
|
||||||
|
persistSettings(settings.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const gatewayUrl = computed(() => settings.value.gatewayUrl || resolveDefaultGatewayUrl());
|
||||||
|
const gatewayToken = computed(() => settings.value.gatewayToken || resolveDefaultGatewayToken());
|
||||||
|
|
||||||
|
return {
|
||||||
|
settings,
|
||||||
|
gatewayUrl,
|
||||||
|
gatewayToken,
|
||||||
|
ensureBootstrapped,
|
||||||
|
bootstrap,
|
||||||
|
save
|
||||||
|
};
|
||||||
|
});
|
||||||
21
terminal/apps/web/src/style.css
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
background: #1a1a1a;
|
||||||
|
overscroll-behavior-y: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
overflow: hidden;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
484
terminal/apps/web/src/terminal/TerminalPage.vue
Normal file
@@ -0,0 +1,484 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* TerminalPage — 新 terminal-core 架构的实验终端页面。
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* - 编排 TerminalToolbar / TerminalViewport / TerminalInputBar / TerminalTouchTools
|
||||||
|
* - 处理连接/断开流程(从 serverStore 取服务器与凭据)
|
||||||
|
* - BEL → navigator.vibrate / toast
|
||||||
|
* - 标题 OSC → toolbar 展示
|
||||||
|
* - 触控模式检测(viewport 点击 → focus input anchor)
|
||||||
|
* - 软键盘弹出/收回:提示符行置于屏幕距顶 1/4 处,收回时恢复滚动
|
||||||
|
*/
|
||||||
|
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from "vue";
|
||||||
|
import { InputBridge } from "@remoteconn/terminal-core";
|
||||||
|
import { useTerminalStore } from "@/terminal/stores/useTerminalStore";
|
||||||
|
import { useServerStore } from "@/stores/serverStore";
|
||||||
|
import { useAppStore } from "@/stores/appStore";
|
||||||
|
import TerminalToolbar from "./components/TerminalToolbar.vue";
|
||||||
|
import TerminalViewport from "./components/TerminalViewport.vue";
|
||||||
|
import TerminalInputBar from "./components/TerminalInputBar.vue";
|
||||||
|
import TerminalTouchTools from "./components/TerminalTouchTools.vue";
|
||||||
|
import { KeyboardAdjustController } from "@/terminal/input/keyboardAdjustController";
|
||||||
|
import { formatActionError } from "@/utils/feedback";
|
||||||
|
|
||||||
|
const store = useTerminalStore();
|
||||||
|
const serverStore = useServerStore();
|
||||||
|
const appStore = useAppStore();
|
||||||
|
const pasteBridge = new InputBridge({ cursorKeyMode: "normal", bracketedPaste: true });
|
||||||
|
|
||||||
|
const toolbarWrapRef = ref<HTMLElement | null>(null);
|
||||||
|
const viewportWrapRef = ref<HTMLElement | null>(null);
|
||||||
|
const inputBarRef = ref<InstanceType<typeof TerminalInputBar> | null>(null);
|
||||||
|
|
||||||
|
const AUTO_RECONNECT_KEY = "remoteconn:web:auto-reconnect:v1";
|
||||||
|
const CLIENT_SESSION_KEY_PREFIX = "remoteconn:web:client-session-key:v1:";
|
||||||
|
|
||||||
|
let pageUnloading = false;
|
||||||
|
|
||||||
|
// ── 触控状态 ────────────────────────────────────────────────────────────────
|
||||||
|
let touchStartX = 0;
|
||||||
|
let touchStartY = 0;
|
||||||
|
let touchMoved = false;
|
||||||
|
let touchStartAt = 0;
|
||||||
|
let touchStartScrollTop = 0;
|
||||||
|
let touchDidScroll = false;
|
||||||
|
let lastZoomGuardTapAt = 0;
|
||||||
|
let keyboardLikelyVisible = false;
|
||||||
|
|
||||||
|
const DOUBLE_TAP_ZOOM_GUARD_MS = 320;
|
||||||
|
const TAP_MOVE_THRESHOLD_PX = 12;
|
||||||
|
const TAP_MAX_DURATION_MS = 260;
|
||||||
|
|
||||||
|
// ── 软键盘调整控制器 ─────────────────────────────────────────────────────────
|
||||||
|
let kbAdjust: KeyboardAdjustController | null = null;
|
||||||
|
|
||||||
|
function isDebugEnabled(): boolean {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
try {
|
||||||
|
return (
|
||||||
|
window.localStorage.getItem("terminal.debugKeyboardAdjust") === "1" ||
|
||||||
|
window.localStorage.getItem("terminal.debugTouch") === "1"
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function debugTouch(message: string, detail?: unknown): void {
|
||||||
|
if (!isDebugEnabled()) return;
|
||||||
|
const prefix = `[TerminalPage][Touch][${new Date().toISOString()}] ${message}`;
|
||||||
|
detail !== undefined ? console.log(prefix, detail) : console.log(prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPasteDebugEnabled(): boolean {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
try {
|
||||||
|
return (
|
||||||
|
window.localStorage.getItem("terminal.debugPaste") === "1" ||
|
||||||
|
window.localStorage.getItem("terminal.debugTransport") === "1"
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function debugPaste(message: string, detail?: unknown): void {
|
||||||
|
if (!isPasteDebugEnabled()) return;
|
||||||
|
const prefix = `[TerminalPage][Paste][${new Date().toISOString()}] ${message}`;
|
||||||
|
detail !== undefined ? console.log(prefix, detail) : console.log(prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTouchDevice(): boolean {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
return window.matchMedia?.("(pointer: coarse)")?.matches ?? ("ontouchstart" in window);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 应用光标键模式(从 TerminalCore 读取,随 DECCKM 状态变化) ───────────────
|
||||||
|
const appCursorKeys = ref(false);
|
||||||
|
|
||||||
|
// ── BEL / Title 事件订阅 ──────────────────────────────────────────────────────
|
||||||
|
let offBell: (() => void) | null = null;
|
||||||
|
let offTitle: (() => void) | null = null;
|
||||||
|
|
||||||
|
function subscribeEvents() {
|
||||||
|
const core = store.getCore();
|
||||||
|
if (!core) return;
|
||||||
|
offBell = core.on("bell", () => {
|
||||||
|
if (typeof navigator !== "undefined" && "vibrate" in navigator) {
|
||||||
|
navigator.vibrate(50);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
offTitle = core.on("titleChange", (newTitle: string) => {
|
||||||
|
store.title = newTitle;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAutoReconnect(enabled: boolean): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
try { window.localStorage.setItem(AUTO_RECONNECT_KEY, enabled ? "1" : "0"); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldAutoReconnect(): boolean {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
try { return window.localStorage.getItem(AUTO_RECONNECT_KEY) === "1"; } catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrCreateClientSessionKey(serverId: string): string {
|
||||||
|
if (typeof window === "undefined") return `session-${Date.now()}`;
|
||||||
|
const storageKey = `${CLIENT_SESSION_KEY_PREFIX}${serverId}`;
|
||||||
|
try {
|
||||||
|
const existing = window.localStorage.getItem(storageKey);
|
||||||
|
if (existing && existing.trim().length > 0) return existing;
|
||||||
|
const next = (typeof crypto !== "undefined" && "randomUUID" in crypto)
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
|
window.localStorage.setItem(storageKey, next);
|
||||||
|
return next;
|
||||||
|
} catch {
|
||||||
|
return (typeof crypto !== "undefined" && "randomUUID" in crypto)
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 连接 ──────────────────────────────────────────────────────────────────────
|
||||||
|
async function handleConnect(options: { silent?: boolean } = {}): Promise<void> {
|
||||||
|
const { silent = false } = options;
|
||||||
|
const server = serverStore.selectedServer;
|
||||||
|
if (!server) {
|
||||||
|
if (!silent) appStore.notify("warn", "请先在服务器列表中选择目标服务器");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const coreSnap = store.getCore()?.snapshot();
|
||||||
|
const cols = server.cols ?? coreSnap?.cols ?? 80;
|
||||||
|
const rows = server.rows ?? coreSnap?.rows ?? 24;
|
||||||
|
|
||||||
|
let credential: Awaited<ReturnType<typeof serverStore.resolveCredential>>;
|
||||||
|
try {
|
||||||
|
credential = await serverStore.resolveCredential(server.id);
|
||||||
|
} catch (err) {
|
||||||
|
appStore.notify("error", formatActionError("凭据解析失败", err));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const clientSessionKey = getOrCreateClientSessionKey(server.id);
|
||||||
|
store.bindOutputCache(`${server.id}:${clientSessionKey}`);
|
||||||
|
await store.connect({
|
||||||
|
host: server.host,
|
||||||
|
port: server.port,
|
||||||
|
username: server.username,
|
||||||
|
credential,
|
||||||
|
clientSessionKey,
|
||||||
|
...(server.knownHostFingerprint ? { knownHostFingerprint: server.knownHostFingerprint } : {}),
|
||||||
|
cols,
|
||||||
|
rows
|
||||||
|
});
|
||||||
|
setAutoReconnect(true);
|
||||||
|
|
||||||
|
// 连接后订阅 core 事件
|
||||||
|
subscribeEvents();
|
||||||
|
await nextTick();
|
||||||
|
inputBarRef.value?.focus();
|
||||||
|
} catch (err) {
|
||||||
|
if (!silent) appStore.notify("error", formatActionError("连接失败", err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDisconnect(): void {
|
||||||
|
setAutoReconnect(false);
|
||||||
|
store.disconnect("manual");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 粘贴 ──────────────────────────────────────────────────────────────────────
|
||||||
|
async function handlePaste(): Promise<void> {
|
||||||
|
if (store.state !== "connected") return;
|
||||||
|
debugPaste("paste:triggered");
|
||||||
|
try {
|
||||||
|
const text = await navigator.clipboard.readText();
|
||||||
|
debugPaste("paste:clipboard_read", { length: text.length });
|
||||||
|
if (text) {
|
||||||
|
const seq = pasteBridge.mapPaste(text);
|
||||||
|
store.sendInput(seq, "paste");
|
||||||
|
debugPaste("paste:dispatched", { normalizedLength: seq.length });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
debugPaste("paste:clipboard_read_failed");
|
||||||
|
appStore.notify("warn", "无法读取剪贴板,请手动粘贴");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 输出区滚动元素 ────────────────────────────────────────────────────────────
|
||||||
|
function getOutputScrollEl(): HTMLElement | null {
|
||||||
|
return store.getOutputEl()
|
||||||
|
?? (viewportWrapRef.value?.querySelector(".tc-output") as HTMLElement | null ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 视口点击 → 聚焦输入锚点 ─────────────────────────────────────────────────
|
||||||
|
function focusInputAnchor(options?: { source?: "tap" | "click" }) {
|
||||||
|
if (store.state !== "connected") return;
|
||||||
|
const selection = typeof window !== "undefined" ? window.getSelection() : null;
|
||||||
|
if (selection && !selection.isCollapsed && selection.toString().length > 0) return;
|
||||||
|
|
||||||
|
const activeEl = document.activeElement as HTMLElement | null;
|
||||||
|
const alreadyAnchorFocused =
|
||||||
|
activeEl?.tagName === "TEXTAREA" && activeEl.classList.contains("tc-input-anchor");
|
||||||
|
|
||||||
|
debugTouch("focus:request", {
|
||||||
|
source: options?.source ?? "unknown",
|
||||||
|
alreadyAnchorFocused,
|
||||||
|
keyboardLikelyVisible,
|
||||||
|
state: store.state,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 已聚焦且软键盘可见时不重复 focus(避免键盘闪退)
|
||||||
|
if (alreadyAnchorFocused && keyboardLikelyVisible) return;
|
||||||
|
inputBarRef.value?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 点击处理(桌面) ─────────────────────────────────────────────────────────
|
||||||
|
function handleViewportClick(e: MouseEvent) {
|
||||||
|
if (isTouchDevice()) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
focusInputAnchor({ source: "click" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 触控处理 ─────────────────────────────────────────────────────────────────
|
||||||
|
function handleViewportTouchStart(e: TouchEvent) {
|
||||||
|
const touch = e.touches[0] ?? e.changedTouches[0];
|
||||||
|
if (!touch) return;
|
||||||
|
touchStartX = touch.clientX;
|
||||||
|
touchStartY = touch.clientY;
|
||||||
|
touchStartAt = Date.now();
|
||||||
|
touchMoved = false;
|
||||||
|
touchDidScroll = false;
|
||||||
|
const scrollEl = getOutputScrollEl();
|
||||||
|
touchStartScrollTop = scrollEl?.scrollTop ?? 0;
|
||||||
|
debugTouch("event:touchstart", { x: touchStartX, y: touchStartY, touchStartScrollTop });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleViewportTouchMove(e: TouchEvent) {
|
||||||
|
const touch = e.touches[0] ?? e.changedTouches[0];
|
||||||
|
if (!touch) return;
|
||||||
|
const dx = Math.abs(touch.clientX - touchStartX);
|
||||||
|
const dy = Math.abs(touch.clientY - touchStartY);
|
||||||
|
if (dx > TAP_MOVE_THRESHOLD_PX || dy > TAP_MOVE_THRESHOLD_PX) {
|
||||||
|
touchMoved = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兜底滚动:部分机型/输入法场景原生滚动不稳定,手动驱动输出区纵向滚动
|
||||||
|
const scrollEl = getOutputScrollEl();
|
||||||
|
if (!scrollEl) return;
|
||||||
|
const maxTop = Math.max(0, scrollEl.scrollHeight - scrollEl.clientHeight);
|
||||||
|
if (maxTop <= 0) return;
|
||||||
|
|
||||||
|
const deltaY = touch.clientY - touchStartY;
|
||||||
|
const nextTop = Math.max(0, Math.min(maxTop, touchStartScrollTop - deltaY));
|
||||||
|
if (Math.abs(nextTop - scrollEl.scrollTop) < 1) return;
|
||||||
|
|
||||||
|
scrollEl.scrollTop = nextTop;
|
||||||
|
touchDidScroll = true;
|
||||||
|
e.preventDefault();
|
||||||
|
debugTouch("event:touchmove_scroll", { deltaY, nextTop, maxTop });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleViewportTouchEnd(e: TouchEvent) {
|
||||||
|
const duration = Date.now() - touchStartAt;
|
||||||
|
debugTouch("event:touchend", { touchMoved, touchDidScroll, duration, keyboardLikelyVisible });
|
||||||
|
|
||||||
|
if (touchDidScroll) return;
|
||||||
|
if (touchMoved) return;
|
||||||
|
if (duration > TAP_MAX_DURATION_MS) return;
|
||||||
|
|
||||||
|
// 轻触:阻止合成 click,无延迟聚焦以保持用户手势上下文(移动端弹键盘必须在用户手势内)
|
||||||
|
e.preventDefault();
|
||||||
|
debugTouch("event:tap_focus");
|
||||||
|
focusInputAnchor({ source: "tap" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 防双击缩放(工具栏/输入区) ───────────────────────────────────────────────
|
||||||
|
function handlePageTouchEndCapture(e: TouchEvent) {
|
||||||
|
const touch = e.changedTouches[0] ?? e.touches[0];
|
||||||
|
if (!touch) return;
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
|
||||||
|
const toolbarBottom = toolbarWrapRef.value?.getBoundingClientRect().bottom ?? 0;
|
||||||
|
const inToolbarBand = touch.clientY <= toolbarBottom + 8;
|
||||||
|
const inInputZone = Boolean(target?.closest('[data-zone="native-input-zone"]'));
|
||||||
|
if (!inToolbarBand && !inInputZone) return;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const dt = now - lastZoomGuardTapAt;
|
||||||
|
lastZoomGuardTapAt = now;
|
||||||
|
if (dt > DOUBLE_TAP_ZOOM_GUARD_MS) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
debugTouch("event:double_tap_zoom_guard", { dt });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 软键盘状态同步(供 focusInputAnchor 判断) ────────────────────────────────
|
||||||
|
// KeyboardAdjustController 通过 visualViewport resize 驱动实际 DOM 滚动。
|
||||||
|
// 此处仅维护 keyboardLikelyVisible 标志供聚焦逻辑使用。
|
||||||
|
const KEYBOARD_OPEN_THRESHOLD_PX = 120;
|
||||||
|
const KEYBOARD_CLOSE_HYSTERESIS_PX = 40;
|
||||||
|
let baselineVpHeight = 0;
|
||||||
|
|
||||||
|
function onVisualViewportResize() {
|
||||||
|
const vv = window.visualViewport;
|
||||||
|
const h = vv ? vv.height : window.innerHeight;
|
||||||
|
if (h <= 0) return;
|
||||||
|
|
||||||
|
if (!keyboardLikelyVisible && h > baselineVpHeight) {
|
||||||
|
baselineVpHeight = h;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shrink = baselineVpHeight - h;
|
||||||
|
|
||||||
|
if (!keyboardLikelyVisible && shrink > KEYBOARD_OPEN_THRESHOLD_PX) {
|
||||||
|
keyboardLikelyVisible = true;
|
||||||
|
debugTouch("keyboard:open_flag_set", { baseline: baselineVpHeight, current: h, shrink });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyboardLikelyVisible && h >= baselineVpHeight - KEYBOARD_CLOSE_HYSTERESIS_PX) {
|
||||||
|
keyboardLikelyVisible = false;
|
||||||
|
debugTouch("keyboard:close_flag_cleared", { baseline: baselineVpHeight, current: h });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPageUnload = () => { pageUnloading = true; };
|
||||||
|
|
||||||
|
// ── 生命周期 ──────────────────────────────────────────────────────────────────
|
||||||
|
onMounted(async () => {
|
||||||
|
await serverStore.ensureBootstrapped();
|
||||||
|
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.addEventListener("pagehide", onPageUnload);
|
||||||
|
window.addEventListener("beforeunload", onPageUnload);
|
||||||
|
|
||||||
|
const vv = window.visualViewport;
|
||||||
|
baselineVpHeight = vv ? vv.height : window.innerHeight;
|
||||||
|
|
||||||
|
// 软键盘调整控制器
|
||||||
|
kbAdjust = new KeyboardAdjustController({
|
||||||
|
getOutputEl: () => store.getOutputEl(),
|
||||||
|
getCursorOffsetPx: () => store.getCursorOffsetPx(),
|
||||||
|
setAutoFollow: (enabled) => store.setAutoFollow(enabled),
|
||||||
|
setScrollLocked: (locked) => store.setScrollLocked(locked),
|
||||||
|
suppressAutoFollow: (ms) => store.suppressAutoFollow(ms),
|
||||||
|
debug: isDebugEnabled(),
|
||||||
|
});
|
||||||
|
kbAdjust.mount();
|
||||||
|
|
||||||
|
// 同步 keyboardLikelyVisible 标志
|
||||||
|
if (vv) {
|
||||||
|
vv.addEventListener("resize", onVisualViewportResize);
|
||||||
|
} else {
|
||||||
|
window.addEventListener("resize", onVisualViewportResize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldAutoReconnect() && serverStore.selectedServer) {
|
||||||
|
await handleConnect({ silent: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => store.state, async (state) => {
|
||||||
|
if (state === "connected") {
|
||||||
|
await nextTick();
|
||||||
|
inputBarRef.value?.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => store.rendererMode, async () => {
|
||||||
|
if (store.state === "connected") {
|
||||||
|
await nextTick();
|
||||||
|
inputBarRef.value?.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
offBell?.();
|
||||||
|
offTitle?.();
|
||||||
|
offBell = null;
|
||||||
|
offTitle = null;
|
||||||
|
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.removeEventListener("pagehide", onPageUnload);
|
||||||
|
window.removeEventListener("beforeunload", onPageUnload);
|
||||||
|
|
||||||
|
const vv = window.visualViewport;
|
||||||
|
if (vv) {
|
||||||
|
vv.removeEventListener("resize", onVisualViewportResize);
|
||||||
|
} else {
|
||||||
|
window.removeEventListener("resize", onVisualViewportResize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
kbAdjust?.forceReset();
|
||||||
|
kbAdjust = null;
|
||||||
|
|
||||||
|
if (!pageUnloading) {
|
||||||
|
store.disconnect("leave_terminal_page");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 计算属性 ──────────────────────────────────────────────────────────────────
|
||||||
|
const isMobile = computed(() =>
|
||||||
|
typeof navigator !== "undefined" &&
|
||||||
|
/Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent)
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="tc-page" @touchend.capture="handlePageTouchEndCapture">
|
||||||
|
<!-- 工具栏 -->
|
||||||
|
<div ref="toolbarWrapRef">
|
||||||
|
<TerminalToolbar @connect="handleConnect" @disconnect="handleDisconnect" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 终端视口 -->
|
||||||
|
<div
|
||||||
|
ref="viewportWrapRef"
|
||||||
|
class="tc-viewport-wrap"
|
||||||
|
@click.stop="handleViewportClick"
|
||||||
|
@touchstart.passive="handleViewportTouchStart"
|
||||||
|
@touchmove="handleViewportTouchMove"
|
||||||
|
@touchend.stop="handleViewportTouchEnd"
|
||||||
|
>
|
||||||
|
<TerminalViewport />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 触控辅助按键(移动端显示) -->
|
||||||
|
<TerminalTouchTools
|
||||||
|
v-if="isMobile"
|
||||||
|
:application-cursor-keys="appCursorKeys"
|
||||||
|
@paste="handlePaste"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 输入栏 -->
|
||||||
|
<TerminalInputBar ref="inputBarRef" />
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tc-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--tc-bg, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tc-viewport-wrap {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
229
terminal/apps/web/src/terminal/components/TerminalInputBar.vue
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* TerminalInputBar — 原生 textarea 输入锚点 + 工具栏输入行。
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* - 承载软键盘弹出的原生 textarea(IME 主通道)
|
||||||
|
* - 通过 DomInputBridge + InputBridge 将键盘事件映射为 VT 字节流
|
||||||
|
* - 提供手动发送文本按钮(移动端用途)
|
||||||
|
*/
|
||||||
|
import { ref, onMounted, onUnmounted } from "vue";
|
||||||
|
import { InputBridge } from "@remoteconn/terminal-core";
|
||||||
|
import { DomInputBridge } from "@/terminal/input/domInputBridge";
|
||||||
|
import { DomImeController } from "@/terminal/input/domImeController";
|
||||||
|
import { shouldHandleKeydownDirectly } from "@/terminal/input/inputPolicy";
|
||||||
|
import { useTerminalStore } from "@/terminal/stores/useTerminalStore";
|
||||||
|
|
||||||
|
const store = useTerminalStore();
|
||||||
|
const inputRef = ref<HTMLTextAreaElement | null>(null);
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "anchor-focus"): void;
|
||||||
|
(e: "anchor-blur"): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
let domBridge: DomInputBridge | null = null;
|
||||||
|
let imeCtrl: DomImeController | null = null;
|
||||||
|
// 多行粘贴统一走 bracketed paste,避免换行被 shell 解释为直接执行。
|
||||||
|
const inputBridge = new InputBridge({ cursorKeyMode: "normal", bracketedPaste: true });
|
||||||
|
const anchorCleanups: Array<() => void> = [];
|
||||||
|
|
||||||
|
function isInputDebugEnabled(): boolean {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem("terminal.debugInputAnchor") === "1";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function debugInput(message: string, detail?: unknown): void {
|
||||||
|
if (!isInputDebugEnabled()) return;
|
||||||
|
const prefix = `[TerminalInputBar][Anchor][${new Date().toISOString()}] ${message}`;
|
||||||
|
if (typeof detail === "undefined") {
|
||||||
|
console.log(prefix);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(prefix, detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispose() {
|
||||||
|
for (const fn of anchorCleanups) fn();
|
||||||
|
anchorCleanups.length = 0;
|
||||||
|
imeCtrl?.dispose();
|
||||||
|
domBridge?.dispose();
|
||||||
|
imeCtrl = null;
|
||||||
|
domBridge = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const el = inputRef.value;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const onFocus = () => {
|
||||||
|
emit("anchor-focus");
|
||||||
|
debugInput("event:focus", {
|
||||||
|
readonly: el.readOnly,
|
||||||
|
disabled: el.disabled,
|
||||||
|
valueLen: el.value.length,
|
||||||
|
activeTag: (document.activeElement as HTMLElement | null)?.tagName ?? "null",
|
||||||
|
activeClass: (document.activeElement as HTMLElement | null)?.className ?? "",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const onBlur = () => {
|
||||||
|
emit("anchor-blur");
|
||||||
|
debugInput("event:blur", {
|
||||||
|
readonly: el.readOnly,
|
||||||
|
disabled: el.disabled,
|
||||||
|
valueLen: el.value.length,
|
||||||
|
activeTag: (document.activeElement as HTMLElement | null)?.tagName ?? "null",
|
||||||
|
activeClass: (document.activeElement as HTMLElement | null)?.className ?? "",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
el.addEventListener("focus", onFocus);
|
||||||
|
el.addEventListener("blur", onBlur);
|
||||||
|
anchorCleanups.push(() => {
|
||||||
|
el.removeEventListener("focus", onFocus);
|
||||||
|
el.removeEventListener("blur", onBlur);
|
||||||
|
});
|
||||||
|
|
||||||
|
domBridge = new DomInputBridge();
|
||||||
|
domBridge.mount(el);
|
||||||
|
|
||||||
|
imeCtrl = new DomImeController();
|
||||||
|
imeCtrl.connect(domBridge);
|
||||||
|
|
||||||
|
// ── 键盘事件处理 ────────────────────────────────────────────────────────
|
||||||
|
domBridge.on("key", (p) => {
|
||||||
|
if (store.state !== "connected") return;
|
||||||
|
|
||||||
|
// 文本输入(含第三方输入法 key="测试"/"jk")统一走 input,避免双发。
|
||||||
|
if (!shouldHandleKeydownDirectly(p)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+C 无选区时发 ETX
|
||||||
|
if ((p.ctrlKey || p.metaKey) && p.key === "c") {
|
||||||
|
const sel = window.getSelection()?.toString() ?? "";
|
||||||
|
if (!sel) {
|
||||||
|
store.sendInput("\u0003", "keyboard");
|
||||||
|
} else {
|
||||||
|
document.execCommand("copy");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+V / Cmd+V 粘贴
|
||||||
|
if ((p.ctrlKey || p.metaKey) && p.key === "v") {
|
||||||
|
// 由 paste 事件处理,此处阻止
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seq = inputBridge.mapKey(p.key, p.code, p.ctrlKey, p.altKey, p.shiftKey, p.metaKey);
|
||||||
|
if (seq !== null) {
|
||||||
|
store.sendInput(seq, "keyboard");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── input 事件(处理未被 keydown 拦截的 ASCII + IME 非组合落笔) ────────
|
||||||
|
domBridge.on("input", (p) => {
|
||||||
|
if (p.isComposing) return; // 组合中由 compositionend 处理
|
||||||
|
if (imeCtrl?.core.shouldConsumeInputEvent()) return;
|
||||||
|
if (store.state !== "connected") return;
|
||||||
|
|
||||||
|
if (p.data) {
|
||||||
|
store.sendInput(p.data, "keyboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空 textarea 防止回显积累
|
||||||
|
const el = inputRef.value;
|
||||||
|
if (el) el.value = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 粘贴事件 ──────────────────────────────────────────────────────────────
|
||||||
|
domBridge.on("paste", (p) => {
|
||||||
|
if (store.state !== "connected") return;
|
||||||
|
const seq = inputBridge.mapPaste(p.text);
|
||||||
|
store.sendInput(seq, "paste");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── IME compositionend → 提交候选词 ──────────────────────────────────────
|
||||||
|
domBridge.on("compositionend", (p) => {
|
||||||
|
if (store.state !== "connected") return;
|
||||||
|
if (p.data) {
|
||||||
|
store.sendInput(p.data, "assist");
|
||||||
|
}
|
||||||
|
const el = inputRef.value;
|
||||||
|
if (el) el.value = "";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 外部调用:将输入锚点聚焦以弹出软键盘 */
|
||||||
|
function focus() {
|
||||||
|
debugInput("api:focus");
|
||||||
|
inputRef.value?.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ focus });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="tc-input-bar" data-zone="native-input-zone">
|
||||||
|
<!-- 原生输入锚点(VT 键盘主通道,对用户不可见) -->
|
||||||
|
<textarea
|
||||||
|
ref="inputRef"
|
||||||
|
class="tc-input-anchor"
|
||||||
|
autocomplete="off"
|
||||||
|
autocorrect="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
spellcheck="false"
|
||||||
|
aria-label="终端键盘输入"
|
||||||
|
:readonly="store.state !== 'connected'"
|
||||||
|
rows="1"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tc-input-bar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: visible;
|
||||||
|
opacity: 0.01;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 隐藏的 VT 键盘锚点 */
|
||||||
|
.tc-input-anchor {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0.01;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 10;
|
||||||
|
resize: none;
|
||||||
|
background: transparent;
|
||||||
|
color: transparent;
|
||||||
|
caret-color: transparent;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
/*
|
||||||
|
* 将焦点锚点放在顶部,避免部分移动端浏览器在软键盘弹出时
|
||||||
|
* 为“底部焦点元素可见”而触发布局上推,导致工具条/光标瞬时跳位。
|
||||||
|
*/
|
||||||
|
transform: translateZ(0);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
148
terminal/apps/web/src/terminal/components/TerminalToolbar.vue
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { useTerminalStore } from "@/terminal/stores/useTerminalStore";
|
||||||
|
|
||||||
|
const store = useTerminalStore();
|
||||||
|
|
||||||
|
const isConnected = computed(() => store.state === "connected");
|
||||||
|
const isConnecting = computed(() => store.state === "connecting" || store.state === "auth_pending" || store.state === "reconnecting");
|
||||||
|
const canReconnect = computed(() => store.state === "disconnected" || store.state === "error");
|
||||||
|
const canDisconnect = computed(() => isConnected.value || isConnecting.value);
|
||||||
|
|
||||||
|
const connectionLabel = computed(() => {
|
||||||
|
if (canReconnect.value) return "重连";
|
||||||
|
if (canDisconnect.value) return "断开";
|
||||||
|
return "连接";
|
||||||
|
});
|
||||||
|
|
||||||
|
const connectionDisabled = computed(() => false);
|
||||||
|
|
||||||
|
function handleClear() {
|
||||||
|
store.clearTerminal();
|
||||||
|
}
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "connect"): void;
|
||||||
|
(e: "disconnect"): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function handleConnectionAction() {
|
||||||
|
if (canDisconnect.value) {
|
||||||
|
emit("disconnect");
|
||||||
|
} else {
|
||||||
|
emit("connect");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-toolbar terminal-toolbar tc-toolbar" @dblclick.prevent>
|
||||||
|
<div class="toolbar-left">
|
||||||
|
<button
|
||||||
|
class="icon-btn"
|
||||||
|
type="button"
|
||||||
|
title="清屏"
|
||||||
|
aria-label="清屏"
|
||||||
|
@click="handleClear"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">⌫</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar-spacer"></div>
|
||||||
|
|
||||||
|
<div class="terminal-toolbar-actions">
|
||||||
|
<!-- 标题 -->
|
||||||
|
<h2 class="page-title terminal-title" :title="store.title">
|
||||||
|
{{ store.title || "Terminal" }}
|
||||||
|
</h2>
|
||||||
|
<!-- 状态 -->
|
||||||
|
<span class="state-chip" :class="`state-${store.state}`">{{ store.state }}</span>
|
||||||
|
<!-- 延迟 -->
|
||||||
|
<span v-if="store.latencyMs > 0" class="state-chip">{{ store.latencyMs }}ms</span>
|
||||||
|
<span class="terminal-toolbar-divider" aria-hidden="true"></span>
|
||||||
|
<!-- 连接/断开 -->
|
||||||
|
<button
|
||||||
|
class="terminal-connection-switch"
|
||||||
|
:class="canReconnect ? 'is-reconnect' : 'is-disconnect'"
|
||||||
|
:disabled="connectionDisabled"
|
||||||
|
:aria-label="connectionLabel"
|
||||||
|
@click="handleConnectionAction"
|
||||||
|
>
|
||||||
|
<span class="terminal-connection-switch-label">{{ connectionLabel }}</span>
|
||||||
|
<span class="terminal-connection-switch-knob" aria-hidden="true"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tc-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
background: #1f1f1f;
|
||||||
|
color: #e6e6e6;
|
||||||
|
flex-shrink: 0;
|
||||||
|
/* 禁止连续轻点触发浏览器双击放大 */
|
||||||
|
touch-action: manipulation;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-left,
|
||||||
|
.terminal-toolbar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-spacer {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn,
|
||||||
|
.terminal-connection-switch {
|
||||||
|
border: 1px solid #4a4a4a;
|
||||||
|
background: #2a2a2a;
|
||||||
|
color: #e6e6e6;
|
||||||
|
border-radius: 6px;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
touch-action: manipulation;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:disabled,
|
||||||
|
.terminal-connection-switch:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #e6e6e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-chip {
|
||||||
|
border: 1px solid #4a4a4a;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #d5d5d5;
|
||||||
|
background: #2b2b2b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-toolbar-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 20px;
|
||||||
|
background: #444;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
191
terminal/apps/web/src/terminal/components/TerminalTouchTools.vue
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* TerminalTouchTools — 移动端触控辅助按键区。
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* - 提供方向键、Enter、Ctrl+C、Tab、Paste 等常用控制键
|
||||||
|
* - 输出标准 VT 控制序列,不直接访问 Transport
|
||||||
|
* - 通过 store.sendInput() 发送
|
||||||
|
*/
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { useTerminalStore } from "@/terminal/stores/useTerminalStore";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** 是否使用应用光标键模式(DECCKM 激活时为 true) */
|
||||||
|
applicationCursorKeys?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "paste"): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const store = useTerminalStore();
|
||||||
|
const disabled = computed(() => store.state !== "connected");
|
||||||
|
|
||||||
|
/** 发送 VT 控制序列;source 固定为 keyboard,以复用 meta 标记 */
|
||||||
|
function send(seq: string) {
|
||||||
|
if (disabled.value) return;
|
||||||
|
store.sendInput(seq, "keyboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrowUp = computed(() => props.applicationCursorKeys ? "\x1bOA" : "\x1b[A");
|
||||||
|
const arrowDown = computed(() => props.applicationCursorKeys ? "\x1bOB" : "\x1b[B");
|
||||||
|
const arrowRight = computed(() => props.applicationCursorKeys ? "\x1bOC" : "\x1b[C");
|
||||||
|
const arrowLeft = computed(() => props.applicationCursorKeys ? "\x1bOD" : "\x1b[D");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="tc-touch-tools" role="toolbar" aria-label="终端触控辅助工具栏">
|
||||||
|
<!-- 方向键 -->
|
||||||
|
<button
|
||||||
|
class="tc-key-btn"
|
||||||
|
type="button"
|
||||||
|
:disabled="disabled"
|
||||||
|
aria-label="上箭头"
|
||||||
|
@click="send(arrowUp)"
|
||||||
|
>▲</button>
|
||||||
|
<button
|
||||||
|
class="tc-key-btn"
|
||||||
|
type="button"
|
||||||
|
:disabled="disabled"
|
||||||
|
aria-label="下箭头"
|
||||||
|
@click="send(arrowDown)"
|
||||||
|
>▼</button>
|
||||||
|
<button
|
||||||
|
class="tc-key-btn"
|
||||||
|
type="button"
|
||||||
|
:disabled="disabled"
|
||||||
|
aria-label="左箭头"
|
||||||
|
@click="send(arrowLeft)"
|
||||||
|
>◀</button>
|
||||||
|
<button
|
||||||
|
class="tc-key-btn"
|
||||||
|
type="button"
|
||||||
|
:disabled="disabled"
|
||||||
|
aria-label="右箭头"
|
||||||
|
@click="send(arrowRight)"
|
||||||
|
>▶</button>
|
||||||
|
|
||||||
|
<div class="tc-key-separator" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<!-- Tab -->
|
||||||
|
<button
|
||||||
|
class="tc-key-btn"
|
||||||
|
type="button"
|
||||||
|
:disabled="disabled"
|
||||||
|
aria-label="Tab"
|
||||||
|
@click="send('\t')"
|
||||||
|
>Tab</button>
|
||||||
|
|
||||||
|
<!-- Enter (CR) -->
|
||||||
|
<button
|
||||||
|
class="tc-key-btn tc-key-enter"
|
||||||
|
type="button"
|
||||||
|
:disabled="disabled"
|
||||||
|
aria-label="Enter"
|
||||||
|
@click="send('\r')"
|
||||||
|
>↵</button>
|
||||||
|
|
||||||
|
<!-- Ctrl+C -->
|
||||||
|
<button
|
||||||
|
class="tc-key-btn"
|
||||||
|
type="button"
|
||||||
|
:disabled="disabled"
|
||||||
|
aria-label="Ctrl+C 中断"
|
||||||
|
@click="send('\u0003')"
|
||||||
|
>^C</button>
|
||||||
|
|
||||||
|
<!-- Ctrl+D -->
|
||||||
|
<button
|
||||||
|
class="tc-key-btn"
|
||||||
|
type="button"
|
||||||
|
:disabled="disabled"
|
||||||
|
aria-label="Ctrl+D EOF"
|
||||||
|
@click="send('\u0004')"
|
||||||
|
>^D</button>
|
||||||
|
|
||||||
|
<!-- Esc -->
|
||||||
|
<button
|
||||||
|
class="tc-key-btn"
|
||||||
|
type="button"
|
||||||
|
:disabled="disabled"
|
||||||
|
aria-label="Esc"
|
||||||
|
@click="send('\x1b')"
|
||||||
|
>Esc</button>
|
||||||
|
|
||||||
|
<div class="tc-key-separator" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<!-- 粘贴(调用外部 paste 逻辑) -->
|
||||||
|
<button
|
||||||
|
class="tc-key-btn"
|
||||||
|
type="button"
|
||||||
|
:disabled="disabled"
|
||||||
|
aria-label="粘贴"
|
||||||
|
@click="emit('paste')"
|
||||||
|
>粘贴</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tc-touch-tools {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
background: var(--color-surface-1, #1e1e1e);
|
||||||
|
border-top: 1px solid var(--color-border, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tc-touch-tools::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tc-key-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 40px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid var(--color-border, #444);
|
||||||
|
border-radius: 5px;
|
||||||
|
background: var(--color-surface-2, #2a2a2a);
|
||||||
|
color: var(--color-text, #d4d4d4);
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: "JetBrains Mono", monospace;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tc-key-btn:active {
|
||||||
|
background: var(--color-surface-3, #3a3a3a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tc-key-btn:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tc-key-enter {
|
||||||
|
min-width: 48px;
|
||||||
|
background: var(--color-accent-dim, #1e3a5f);
|
||||||
|
border-color: var(--color-accent, #4a9eff);
|
||||||
|
color: var(--color-accent, #4a9eff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tc-key-separator {
|
||||||
|
width: 1px;
|
||||||
|
height: 20px;
|
||||||
|
background: var(--color-border, #444);
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin: 0 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted } from "vue";
|
||||||
|
import { calcSize } from "@remoteconn/terminal-core";
|
||||||
|
import { DomMeasureAdapter } from "@/terminal/layout/domMeasureAdapter";
|
||||||
|
import { useTerminalStore } from "@/terminal/stores/useTerminalStore";
|
||||||
|
|
||||||
|
const store = useTerminalStore();
|
||||||
|
const viewportRef = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
let measure: DomMeasureAdapter | null = null;
|
||||||
|
let offResize: (() => void) | null = null;
|
||||||
|
/** 初次 fit 重试次数上限(应对路由切换延迟布局) */
|
||||||
|
const MAX_FIT_RETRIES = 8;
|
||||||
|
let fitRetryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let fitRetryCount = 0;
|
||||||
|
|
||||||
|
function doFit() {
|
||||||
|
if (!measure) return;
|
||||||
|
|
||||||
|
const { widthPx: charW, heightPx: lineH } = measure.measureChar();
|
||||||
|
const { widthPx: contW, heightPx: contH } = measure.measureContainer();
|
||||||
|
|
||||||
|
if (contW < 10 || contH < 10) {
|
||||||
|
// 容器未就绪,重试
|
||||||
|
if (fitRetryCount < MAX_FIT_RETRIES) {
|
||||||
|
fitRetryCount++;
|
||||||
|
fitRetryTimer = setTimeout(doFit, 120);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fitRetryCount = 0;
|
||||||
|
|
||||||
|
const { cols, rows } = calcSize(contW, contH, charW, lineH);
|
||||||
|
store.resizeTerminal(cols, Math.max(rows, 12));
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const el = viewportRef.value;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
measure = new DomMeasureAdapter(el, { fontFamily: "monospace", fontSize: "14px" });
|
||||||
|
measure.mount();
|
||||||
|
|
||||||
|
// 挂载渲染器
|
||||||
|
store.mountRenderer(el);
|
||||||
|
|
||||||
|
// 订阅 resize
|
||||||
|
offResize = measure.onResize(() => {
|
||||||
|
doFit();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 首次 fit(多次重试保障)
|
||||||
|
doFit();
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
offResize?.();
|
||||||
|
offResize = null;
|
||||||
|
if (fitRetryTimer !== null) {
|
||||||
|
clearTimeout(fitRetryTimer);
|
||||||
|
fitRetryTimer = null;
|
||||||
|
}
|
||||||
|
measure?.dispose();
|
||||||
|
measure = null;
|
||||||
|
// 渲染器 dispose 由 store 管理;此处仅退出测量
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="viewportRef"
|
||||||
|
class="tc-viewport-root"
|
||||||
|
data-zone="terminal-output-zone"
|
||||||
|
aria-label="终端视口"
|
||||||
|
role="region"
|
||||||
|
></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tc-viewport-root {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
background: var(--tc-bg, #1a1a1a);
|
||||||
|
color: var(--tc-fg, #d4d4d4);
|
||||||
|
font-family: "JetBrains Mono", "Cascadia Code", "Fira Mono", "Menlo", monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.2;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
37
terminal/apps/web/src/terminal/input/domImeController.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { ImeController } from "@remoteconn/terminal-core";
|
||||||
|
import type { DomInputBridge } from "./domInputBridge";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DomImeController — 将 DOM compositionstart/end 事件桥接到 ImeController。
|
||||||
|
* 必须在 DomInputBridge.mount() 之后调用 connect()。
|
||||||
|
*/
|
||||||
|
export class DomImeController {
|
||||||
|
public readonly core: ImeController;
|
||||||
|
private cleanup: (() => void)[] = [];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.core = new ImeController(
|
||||||
|
(fn, ms) => setTimeout(fn, ms),
|
||||||
|
(id) => clearTimeout(id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将 inputBridge 的 composition 事件接入 ImeController */
|
||||||
|
connect(bridge: DomInputBridge): void {
|
||||||
|
this.cleanup.push(
|
||||||
|
bridge.on("compositionstart", ({ data }) => {
|
||||||
|
this.core.onCompositionStart(data);
|
||||||
|
}),
|
||||||
|
bridge.on("compositionend", ({ data }) => {
|
||||||
|
this.core.onCompositionEnd(data);
|
||||||
|
// compositionend 后的 input 事件由 ImeController.shouldConsumeInputEvent 拦截
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
for (const fn of this.cleanup) fn();
|
||||||
|
this.cleanup = [];
|
||||||
|
this.core.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
84
terminal/apps/web/src/terminal/input/domInputBridge.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import type { IInputSource, InputEventMap } from "@remoteconn/terminal-core";
|
||||||
|
|
||||||
|
type ListenerMap = { [K in keyof InputEventMap]?: Set<(payload: InputEventMap[K]) => void> };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DomInputBridge — 将 DOM 键盘/input/paste/composition 事件适配为 IInputSource。
|
||||||
|
* 挂载到 textarea 输入锚点元素上。
|
||||||
|
*/
|
||||||
|
export class DomInputBridge implements IInputSource {
|
||||||
|
private el: HTMLElement | null = null;
|
||||||
|
private listeners: ListenerMap = {};
|
||||||
|
private domCleanups: (() => void)[] = [];
|
||||||
|
|
||||||
|
mount(element: HTMLElement): void {
|
||||||
|
this.el = element;
|
||||||
|
const add = <K extends keyof HTMLElementEventMap>(
|
||||||
|
type: K,
|
||||||
|
handler: (e: HTMLElementEventMap[K]) => void,
|
||||||
|
opts?: AddEventListenerOptions
|
||||||
|
) => {
|
||||||
|
element.addEventListener(type, handler as EventListener, opts);
|
||||||
|
this.domCleanups.push(() => element.removeEventListener(type, handler as EventListener, opts));
|
||||||
|
};
|
||||||
|
|
||||||
|
add("keydown", (e) => {
|
||||||
|
if (e.isComposing) return;
|
||||||
|
this.emit("key", {
|
||||||
|
key: e.key,
|
||||||
|
code: e.code,
|
||||||
|
ctrlKey: e.ctrlKey,
|
||||||
|
altKey: e.altKey,
|
||||||
|
shiftKey: e.shiftKey,
|
||||||
|
metaKey: e.metaKey,
|
||||||
|
isComposing: e.isComposing,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
add("input", (e) => {
|
||||||
|
const ie = e as InputEvent;
|
||||||
|
this.emit("input", {
|
||||||
|
data: ie.data ?? "",
|
||||||
|
isComposing: ie.isComposing,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
add("paste", (e) => {
|
||||||
|
const pe = e as ClipboardEvent;
|
||||||
|
const text = pe.clipboardData?.getData("text") ?? "";
|
||||||
|
if (text) {
|
||||||
|
pe.preventDefault();
|
||||||
|
this.emit("paste", { text });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
add("compositionstart", (e) => {
|
||||||
|
this.emit("compositionstart", { data: (e as CompositionEvent).data ?? "" });
|
||||||
|
});
|
||||||
|
|
||||||
|
add("compositionend", (e) => {
|
||||||
|
this.emit("compositionend", { data: (e as CompositionEvent).data ?? "" });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
for (const cleanup of this.domCleanups) cleanup();
|
||||||
|
this.domCleanups = [];
|
||||||
|
this.el = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
on<K extends keyof InputEventMap>(event: K, cb: (payload: InputEventMap[K]) => void): () => void {
|
||||||
|
if (!this.listeners[event]) {
|
||||||
|
(this.listeners as Record<string, Set<unknown>>)[event] = new Set();
|
||||||
|
}
|
||||||
|
const set = this.listeners[event] as Set<(p: InputEventMap[K]) => void>;
|
||||||
|
set.add(cb);
|
||||||
|
return () => set.delete(cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit<K extends keyof InputEventMap>(event: K, payload: InputEventMap[K]): void {
|
||||||
|
const set = this.listeners[event] as Set<(p: InputEventMap[K]) => void> | undefined;
|
||||||
|
if (!set) return;
|
||||||
|
for (const fn of set) fn(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
39
terminal/apps/web/src/terminal/input/inputPolicy.test.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { shouldHandleKeydownDirectly } from "./inputPolicy";
|
||||||
|
|
||||||
|
function makePayload(overrides: Partial<{ key: string; ctrlKey: boolean; altKey: boolean; metaKey: boolean }> = {}) {
|
||||||
|
return {
|
||||||
|
key: "",
|
||||||
|
ctrlKey: false,
|
||||||
|
altKey: false,
|
||||||
|
metaKey: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("shouldHandleKeydownDirectly", () => {
|
||||||
|
it("文本按键应返回 false(普通英文)", () => {
|
||||||
|
expect(shouldHandleKeydownDirectly(makePayload({ key: "a" }))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("第三方输入法在 keydown 给出整段文本时应返回 false(英文串)", () => {
|
||||||
|
expect(shouldHandleKeydownDirectly(makePayload({ key: "jk" }))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("第三方输入法在 keydown 给出整段文本时应返回 false(中文)", () => {
|
||||||
|
expect(shouldHandleKeydownDirectly(makePayload({ key: "测试" }))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("功能键应返回 true(Enter)", () => {
|
||||||
|
expect(shouldHandleKeydownDirectly(makePayload({ key: "Enter" }))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("方向键应返回 true(ArrowUp)", () => {
|
||||||
|
expect(shouldHandleKeydownDirectly(makePayload({ key: "ArrowUp" }))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("组合键应返回 true(Ctrl+C)", () => {
|
||||||
|
expect(shouldHandleKeydownDirectly(makePayload({ key: "c", ctrlKey: true }))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
28
terminal/apps/web/src/terminal/input/inputPolicy.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import type { KeyPayload } from "@remoteconn/terminal-core";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅这些按键允许在 keydown 阶段直接发送到终端。
|
||||||
|
* 其余文本输入(包括第三方输入法在 keydown 给出的整段文本)必须走 input 事件。
|
||||||
|
*/
|
||||||
|
const KEYDOWN_DIRECT_KEYS = new Set([
|
||||||
|
"Enter", "Backspace", "Tab", "Escape",
|
||||||
|
"Delete", "Insert", "Home", "End",
|
||||||
|
"PageUp", "PageDown",
|
||||||
|
"ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight",
|
||||||
|
"F1", "F2", "F3", "F4", "F5", "F6",
|
||||||
|
"F7", "F8", "F9", "F10", "F11", "F12",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断某个 keydown 事件是否应直接进入 VT 发送链路。
|
||||||
|
* 规则:
|
||||||
|
* 1) 带 ctrl/alt/meta 的组合键一律允许(如 Ctrl+C、Alt+X)。
|
||||||
|
* 2) 无组合键时,只允许功能键白名单;文本键一律禁止。
|
||||||
|
*/
|
||||||
|
export function shouldHandleKeydownDirectly(payload: Pick<KeyPayload, "key" | "ctrlKey" | "altKey" | "metaKey">): boolean {
|
||||||
|
if (payload.ctrlKey || payload.altKey || payload.metaKey) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return KEYDOWN_DIRECT_KEYS.has(payload.key);
|
||||||
|
}
|
||||||
|
|
||||||
444
terminal/apps/web/src/terminal/input/keyboardAdjustController.ts
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
/**
|
||||||
|
* KeyboardAdjustController — 软键盘弹出/收回时的视口滚动控制器。
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* - 监听 visualViewport resize 判断软键盘状态
|
||||||
|
* - 键盘弹出时:将光标行(提示符行)滚动到屏幕距顶 1/4 处
|
||||||
|
* - 键盘收回时:恢复弹出前的滚动位置
|
||||||
|
*
|
||||||
|
* 设计原则:
|
||||||
|
* - 只使用 visualViewport.resize(不混用 window.resize),避免误触
|
||||||
|
* - 通过回调注入 DOM 获取逻辑,不直接依赖 Vue/Pinia,便于测试
|
||||||
|
* - 所有状态集中在此类,TerminalPage.vue 只负责初始化/销毁
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface KeyboardAdjustOptions {
|
||||||
|
/**
|
||||||
|
* 获取输出区滚动容器(即渲染器创建的 .tc-output 元素)。
|
||||||
|
* 若返回 null 则跳过本次调整。
|
||||||
|
*/
|
||||||
|
getOutputEl: () => HTMLElement | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取光标行距输出区滚动顶部的像素偏移。
|
||||||
|
* 若返回 null 则回退为滚动到底部。
|
||||||
|
*/
|
||||||
|
getCursorOffsetPx: () => number | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置渲染器的 autoFollow 状态。
|
||||||
|
* 键盘弹出期间需关闭 autoFollow,防止新输出覆盖定位。
|
||||||
|
* 键盘收回后恢复 autoFollow。
|
||||||
|
*/
|
||||||
|
setAutoFollow: (enabled: boolean) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 冻结 scroll 事件对 autoFollow 的影响,持续 ms 毫秒。
|
||||||
|
* iOS WebKit 的 scroll 事件是异步的,必须用时间窗口而非同步锁。
|
||||||
|
* ms=0 立即解冻。
|
||||||
|
* 可选:未提供时静默跳过(兼容未注入此回调的调用方)。
|
||||||
|
*/
|
||||||
|
setScrollLocked?: (ms: number) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在指定毫秒内抑制 store 的 followBottom 调用。
|
||||||
|
* 用于防止键盘收回后远端 resize 回刷覆盖恢复的滚动位置。
|
||||||
|
*/
|
||||||
|
suppressAutoFollow: (ms: number) => void;
|
||||||
|
|
||||||
|
/** 调试开关(可选,默认关闭)— 保留但现在关键路径已用无条件日志 */
|
||||||
|
debug?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 键盘弹出前保存的视口快照 */
|
||||||
|
interface KeyboardSnapshot {
|
||||||
|
/** 弹出前输出区的 scrollTop */
|
||||||
|
scrollTop: number;
|
||||||
|
/** 弹出前输出区的 clientHeight */
|
||||||
|
clientHeight: number;
|
||||||
|
/** 弹出前输出区的 scrollHeight */
|
||||||
|
scrollHeight: number;
|
||||||
|
/** 弹出前 visualViewport 高度 */
|
||||||
|
viewportHeight: number;
|
||||||
|
/** 用户在键盘可见期间是否手动滚动过 */
|
||||||
|
userScrolled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 软键盘收缩阈值:visualViewport 高度减少超过此值则判定为键盘弹出。
|
||||||
|
* 实测 iOS/Android 软键盘通常 200px+,120px 可覆盖大多数情况。
|
||||||
|
*/
|
||||||
|
const KEYBOARD_OPEN_THRESHOLD_PX = 120;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 软键盘关闭迟滞:高度恢复至 baseline - HYSTERESIS 以内即判定为收起。
|
||||||
|
* 避免动画过程中反复触发。
|
||||||
|
*/
|
||||||
|
const KEYBOARD_CLOSE_HYSTERESIS_PX = 40;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 光标行在屏幕中的目标位置比例(0=顶, 1=底)。
|
||||||
|
* 0.25 = 距顶 1/4 处。
|
||||||
|
*/
|
||||||
|
const CURSOR_TARGET_RATIO = 0.25;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 键盘弹出后等待 DOM 稳定再执行滚动的延迟(ms)。
|
||||||
|
* iOS 软键盘动画约 250ms,此处稍微保守取 280ms。
|
||||||
|
*/
|
||||||
|
const SCROLL_SETTLE_DELAY_MS = 280;
|
||||||
|
|
||||||
|
export class KeyboardAdjustController {
|
||||||
|
private opts: KeyboardAdjustOptions & { debug: boolean };
|
||||||
|
private baselineHeight = 0;
|
||||||
|
private keyboardVisible = false;
|
||||||
|
private snapshot: KeyboardSnapshot | null = null;
|
||||||
|
private settleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
// visualViewport resize 处理器引用,用于清理
|
||||||
|
private readonly vpResizeHandler: () => void;
|
||||||
|
// 用户手动滚动检测
|
||||||
|
private readonly scrollHandler: () => void;
|
||||||
|
private scrollListenerAttached = false;
|
||||||
|
/**
|
||||||
|
* 时间戳:直到此时刻前,scrollHandler 不将 snapshot.userScrolled 设为 true。
|
||||||
|
* iOS WebKit scroll 事件是异步的,必须用时间窗口而非布尔标志。
|
||||||
|
*/
|
||||||
|
private programmaticScrollUntil = 0;
|
||||||
|
|
||||||
|
constructor(options: KeyboardAdjustOptions) {
|
||||||
|
this.opts = { debug: false, ...options };
|
||||||
|
this.vpResizeHandler = () => this.onViewportResize();
|
||||||
|
this.scrollHandler = () => this.onUserScroll();
|
||||||
|
}
|
||||||
|
|
||||||
|
private log(msg: string, detail?: unknown): void {
|
||||||
|
// 关键路径使用无条件 console.log,便于线上问题诊断
|
||||||
|
const prefix = `[KeyboardAdjust][${new Date().toISOString()}] ${msg}`;
|
||||||
|
detail !== undefined ? console.log(prefix, detail) : console.log(prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 初始化:记录基线高度,开始监听 visualViewport */
|
||||||
|
mount(): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const vv = window.visualViewport;
|
||||||
|
if (!vv) {
|
||||||
|
this.log("mount — visualViewport NOT supported, fallback to window.resize");
|
||||||
|
window.addEventListener("resize", this.vpResizeHandler);
|
||||||
|
} else {
|
||||||
|
vv.addEventListener("resize", this.vpResizeHandler);
|
||||||
|
}
|
||||||
|
this.baselineHeight = vv ? vv.height : window.innerHeight;
|
||||||
|
this.log("mount — OK", {
|
||||||
|
baseline: this.baselineHeight,
|
||||||
|
hasVisualViewport: !!vv,
|
||||||
|
innerHeight: window.innerHeight,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 销毁:清理监听器和定时器 */
|
||||||
|
dispose(): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const vv = window.visualViewport;
|
||||||
|
if (!vv) {
|
||||||
|
window.removeEventListener("resize", this.vpResizeHandler);
|
||||||
|
} else {
|
||||||
|
vv.removeEventListener("resize", this.vpResizeHandler);
|
||||||
|
}
|
||||||
|
this.detachScrollListener();
|
||||||
|
if (this.settleTimer !== null) {
|
||||||
|
clearTimeout(this.settleTimer);
|
||||||
|
this.settleTimer = null;
|
||||||
|
}
|
||||||
|
// 确保 autoFollow 已恢复
|
||||||
|
this.opts.setAutoFollow(true);
|
||||||
|
this.snapshot = null;
|
||||||
|
this.keyboardVisible = false;
|
||||||
|
this.log("disposed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 强制重置(页面卸载时调用) */
|
||||||
|
forceReset(): void {
|
||||||
|
if (this.snapshot) {
|
||||||
|
this.restoreScroll("unmount");
|
||||||
|
}
|
||||||
|
this.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 核心逻辑 ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private onViewportResize(): void {
|
||||||
|
const vv = window.visualViewport;
|
||||||
|
const currentH = vv ? vv.height : window.innerHeight;
|
||||||
|
if (currentH <= 0) return;
|
||||||
|
|
||||||
|
// 基线更新:仅在没有键盘时,且高度比当前基线更大才更新
|
||||||
|
if (!this.keyboardVisible && currentH > this.baselineHeight) {
|
||||||
|
this.log("baseline:updated", { old: this.baselineHeight, new: currentH });
|
||||||
|
this.baselineHeight = currentH;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shrink = this.baselineHeight - currentH;
|
||||||
|
|
||||||
|
this.log("viewport:resize", {
|
||||||
|
currentH,
|
||||||
|
baseline: this.baselineHeight,
|
||||||
|
shrink,
|
||||||
|
keyboardVisible: this.keyboardVisible,
|
||||||
|
OPEN_THRESHOLD: KEYBOARD_OPEN_THRESHOLD_PX,
|
||||||
|
CLOSE_HYSTERESIS: KEYBOARD_CLOSE_HYSTERESIS_PX,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!this.keyboardVisible && shrink > KEYBOARD_OPEN_THRESHOLD_PX) {
|
||||||
|
// 键盘弹出
|
||||||
|
this.keyboardVisible = true;
|
||||||
|
this.onKeyboardOpen(currentH);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.keyboardVisible && currentH >= this.baselineHeight - KEYBOARD_CLOSE_HYSTERESIS_PX) {
|
||||||
|
// 键盘收回
|
||||||
|
this.keyboardVisible = false;
|
||||||
|
this.onKeyboardClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private onKeyboardOpen(currentViewportH: number): void {
|
||||||
|
const outputEl = this.opts.getOutputEl();
|
||||||
|
if (!outputEl) {
|
||||||
|
this.log("keyboard:OPEN — no outputEl, skipped");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存弹出前的状态
|
||||||
|
this.snapshot = {
|
||||||
|
scrollTop: outputEl.scrollTop,
|
||||||
|
clientHeight: outputEl.clientHeight,
|
||||||
|
scrollHeight: outputEl.scrollHeight,
|
||||||
|
viewportHeight: currentViewportH,
|
||||||
|
userScrolled: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.log("keyboard:OPEN — snapshot saved", {
|
||||||
|
snapshot: this.snapshot,
|
||||||
|
baseline: this.baselineHeight,
|
||||||
|
currentViewportH,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 关闭 autoFollow,防止新输出把光标滚走
|
||||||
|
this.opts.setAutoFollow(false);
|
||||||
|
|
||||||
|
// 注册用户滚动检测
|
||||||
|
this.attachScrollListener(outputEl);
|
||||||
|
|
||||||
|
// 等键盘动画完成后执行定位
|
||||||
|
if (this.settleTimer !== null) clearTimeout(this.settleTimer);
|
||||||
|
this.log(`keyboard:OPEN — will position in ${SCROLL_SETTLE_DELAY_MS}ms`);
|
||||||
|
this.settleTimer = setTimeout(() => {
|
||||||
|
this.settleTimer = null;
|
||||||
|
this.positionCursorAtQuarter(outputEl);
|
||||||
|
}, SCROLL_SETTLE_DELAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onKeyboardClose(): void {
|
||||||
|
this.log("keyboard:CLOSE — detaching scroll listener, cancelling settle timer");
|
||||||
|
this.detachScrollListener();
|
||||||
|
if (this.settleTimer !== null) {
|
||||||
|
clearTimeout(this.settleTimer);
|
||||||
|
this.settleTimer = null;
|
||||||
|
}
|
||||||
|
// iOS 键盘收回时,outputEl.clientHeight 的恢复比 visualViewport.height 更晚,
|
||||||
|
// 固定延迟不可靠。改用轮询:每 32ms 检查一次 clientHeight 是否已恢复到 baseline,
|
||||||
|
// 最多等待 600ms,超时则强制执行。
|
||||||
|
const expectedClientH = this.snapshot
|
||||||
|
? this.snapshot.clientHeight // 弹出前保存的 clientHeight
|
||||||
|
: 0;
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const MAX_WAIT_MS = 600;
|
||||||
|
const POLL_MS = 32;
|
||||||
|
|
||||||
|
// 在整个 poll 等待期间保持 scroll 冻结,防止 iOS 键盘收起动画触发的
|
||||||
|
// scroll 事件把 autoFollow 改回 true,干扰后续 restoreScroll 的逻辑。
|
||||||
|
this.opts.setScrollLocked?.(MAX_WAIT_MS + POLL_MS);
|
||||||
|
|
||||||
|
const poll = () => {
|
||||||
|
const outputEl = this.opts.getOutputEl();
|
||||||
|
const elapsed = Date.now() - startedAt;
|
||||||
|
const currentH = outputEl?.clientHeight ?? 0;
|
||||||
|
const recovered = currentH >= expectedClientH - 4; // 允许 4px 误差
|
||||||
|
|
||||||
|
this.log(`keyboard:CLOSE — poll clientHeight=${currentH} expected=${expectedClientH} recovered=${recovered} elapsed=${elapsed}ms`);
|
||||||
|
|
||||||
|
if (recovered || elapsed >= MAX_WAIT_MS) {
|
||||||
|
this.settleTimer = null;
|
||||||
|
this.restoreScroll("keyboard_close");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.settleTimer = setTimeout(poll, POLL_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.settleTimer = setTimeout(poll, POLL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onUserScroll(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now < this.programmaticScrollUntil) {
|
||||||
|
this.log(`scroll:handler — programmatic window(${this.programmaticScrollUntil - now}ms), skip userScrolled`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.snapshot) {
|
||||||
|
this.snapshot.userScrolled = true;
|
||||||
|
this.log("scroll:handler — USER scroll detected, userScrolled=true");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将光标行定位到当前可见区域距顶 1/4 处。
|
||||||
|
*
|
||||||
|
* 计算公式:
|
||||||
|
* 目标 scrollTop = cursorOffsetPx - visibleHeight * CURSOR_TARGET_RATIO
|
||||||
|
*
|
||||||
|
* 其中 visibleHeight 是键盘弹出后 outputEl 的实际可见高度。
|
||||||
|
*/
|
||||||
|
private positionCursorAtQuarter(outputEl: HTMLElement): void {
|
||||||
|
const cursorOffsetPx = this.opts.getCursorOffsetPx();
|
||||||
|
|
||||||
|
// 键盘弹出后输出区的实际可见高度(clientHeight 已被键盘压缩)
|
||||||
|
const visibleH = outputEl.clientHeight;
|
||||||
|
|
||||||
|
let targetScrollTop: number;
|
||||||
|
if (cursorOffsetPx !== null) {
|
||||||
|
targetScrollTop = cursorOffsetPx - visibleH * CURSOR_TARGET_RATIO;
|
||||||
|
} else {
|
||||||
|
// 回退:滚到底部
|
||||||
|
targetScrollTop = outputEl.scrollHeight - visibleH;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxTop = Math.max(0, outputEl.scrollHeight - visibleH);
|
||||||
|
targetScrollTop = Math.max(0, Math.min(maxTop, targetScrollTop));
|
||||||
|
|
||||||
|
this.log("keyboard:POSITION_CURSOR", {
|
||||||
|
cursorOffsetPx,
|
||||||
|
visibleH,
|
||||||
|
CURSOR_TARGET_RATIO,
|
||||||
|
targetScrollTop,
|
||||||
|
currentScrollTop: outputEl.scrollTop,
|
||||||
|
scrollHeight: outputEl.scrollHeight,
|
||||||
|
maxTop,
|
||||||
|
});
|
||||||
|
|
||||||
|
// iOS WebKit 的 scroll 事件是异步的(下一个 task),用时间窗口屏蔽。
|
||||||
|
// 200ms 足够覆盖任何平台的异步 scroll 回调。
|
||||||
|
const FREEZE_MS = 200;
|
||||||
|
this.programmaticScrollUntil = Date.now() + FREEZE_MS;
|
||||||
|
this.opts.setScrollLocked?.(FREEZE_MS); // 同步冻结 renderer scroll → autoFollow
|
||||||
|
outputEl.scrollTop = targetScrollTop;
|
||||||
|
|
||||||
|
// 定位完成后必须强制 autoFollow=false(scroll 事件可能在时间窗口内把它改回 true)
|
||||||
|
this.opts.setAutoFollow(false);
|
||||||
|
// 抑制 store 层 followBottom,整个键盘可见期间屏蔽远端 stdout 的滚动干扰
|
||||||
|
this.opts.suppressAutoFollow(60_000);
|
||||||
|
this.log("keyboard:POSITION_CURSOR — done, final scrollTop=" + outputEl.scrollTop);
|
||||||
|
}
|
||||||
|
|
||||||
|
private restoreScroll(reason: string): void {
|
||||||
|
const snap = this.snapshot;
|
||||||
|
this.snapshot = null;
|
||||||
|
|
||||||
|
// 清除键盘弹出时设置的长期 scroll 冻结(先清,后面可能重新设短窗口)
|
||||||
|
this.opts.setScrollLocked?.(0);
|
||||||
|
|
||||||
|
if (!snap) {
|
||||||
|
this.opts.setAutoFollow(true);
|
||||||
|
this.log(`keyboard:RESTORE — no snapshot (${reason})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputEl = this.opts.getOutputEl();
|
||||||
|
if (!outputEl) {
|
||||||
|
this.opts.setAutoFollow(true);
|
||||||
|
this.log(`keyboard:RESTORE — SKIP (no outputEl)`);
|
||||||
|
this.opts.suppressAutoFollow(0);
|
||||||
|
this.opts.suppressAutoFollow(500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const before = {
|
||||||
|
scrollTop: outputEl.scrollTop,
|
||||||
|
clientHeight: outputEl.clientHeight,
|
||||||
|
scrollHeight: outputEl.scrollHeight,
|
||||||
|
};
|
||||||
|
|
||||||
|
const contentChanged = Math.abs(before.scrollHeight - snap.scrollHeight) > 4;
|
||||||
|
|
||||||
|
this.log(`keyboard:RESTORE (${reason})`, {
|
||||||
|
userScrolled: snap.userScrolled,
|
||||||
|
contentChanged,
|
||||||
|
snap,
|
||||||
|
before,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 计算目标 scrollTop
|
||||||
|
let targetScrollTop: number;
|
||||||
|
|
||||||
|
if (snap.userScrolled) {
|
||||||
|
// 用户键盘可见期间主动上滚过:恢复弹出前的滚动位置(尊重用户意图)
|
||||||
|
const maxTop = Math.max(0, before.scrollHeight - before.clientHeight);
|
||||||
|
targetScrollTop = Math.max(0, Math.min(maxTop, snap.scrollTop));
|
||||||
|
this.log(`keyboard:RESTORE — userScrolled, target=${targetScrollTop}`);
|
||||||
|
} else {
|
||||||
|
// 用户未主动滚动:直接恢复弹出前保存的 scrollTop。
|
||||||
|
// snap.scrollTop 就是用户弹出键盘前看到的位置,这是最自然的"恢复"语义。
|
||||||
|
// 内容有增长时用 maxTop 兜底(防止超出范围)。
|
||||||
|
const maxTop = Math.max(0, before.scrollHeight - before.clientHeight);
|
||||||
|
targetScrollTop = Math.max(0, Math.min(maxTop, snap.scrollTop));
|
||||||
|
this.log(`keyboard:RESTORE — !userScrolled, restoring snap.scrollTop`, {
|
||||||
|
snapScrollTop: snap.scrollTop, target: targetScrollTop, maxTop,
|
||||||
|
contentChanged,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断目标位置是否在底部(4px 容差)
|
||||||
|
const maxTop = Math.max(0, before.scrollHeight - before.clientHeight);
|
||||||
|
const isAtBottom = Math.abs(targetScrollTop - maxTop) < 4;
|
||||||
|
|
||||||
|
// 关键:先设 autoFollow=false,再设 scrollTop,再按需恢复 autoFollow。
|
||||||
|
// 如果先 setAutoFollow(true),renderSnapshot 的下一帧会立刻把 scrollTop 覆盖成 scrollHeight。
|
||||||
|
this.opts.setAutoFollow(false);
|
||||||
|
|
||||||
|
const FREEZE_MS = 200;
|
||||||
|
this.programmaticScrollUntil = Date.now() + FREEZE_MS;
|
||||||
|
this.opts.setScrollLocked?.(FREEZE_MS);
|
||||||
|
outputEl.scrollTop = targetScrollTop;
|
||||||
|
|
||||||
|
this.log(`keyboard:RESTORE — done, final scrollTop=${outputEl.scrollTop} isAtBottom=${isAtBottom}`);
|
||||||
|
|
||||||
|
if (isAtBottom) {
|
||||||
|
// 在底部:恢复 autoFollow,700ms 内保护远端 resize 回刷
|
||||||
|
this.opts.setAutoFollow(true);
|
||||||
|
this.opts.suppressAutoFollow(0); // 先清除60s长期抑制
|
||||||
|
this.opts.suppressAutoFollow(700); // 再设700ms短保护
|
||||||
|
} else {
|
||||||
|
// 不在底部(光标后有空行):保持 autoFollow=false。
|
||||||
|
// 先清除60s长期抑制,再设700ms短保护。
|
||||||
|
// 顺序必须是:先清(0),再设(700)——反序会导致0把700覆盖。
|
||||||
|
this.opts.suppressAutoFollow(0); // 清除60s长期抑制
|
||||||
|
this.opts.suppressAutoFollow(700); // 设700ms短保护,屏蔽立刻到来的stdout心跳
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 辅助 ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private attachScrollListener(el: HTMLElement): void {
|
||||||
|
if (this.scrollListenerAttached) return;
|
||||||
|
el.addEventListener("scroll", this.scrollHandler, { passive: true });
|
||||||
|
this.scrollListenerAttached = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private detachScrollListener(): void {
|
||||||
|
if (!this.scrollListenerAttached) return;
|
||||||
|
const outputEl = this.opts.getOutputEl();
|
||||||
|
outputEl?.removeEventListener("scroll", this.scrollHandler);
|
||||||
|
this.scrollListenerAttached = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
81
terminal/apps/web/src/terminal/layout/domMeasureAdapter.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import type { IMeasureAdapter } from "@remoteconn/terminal-core";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DomMeasureAdapter — 使用 DOM API 实现 IMeasureAdapter。
|
||||||
|
* 通过隐藏测量元素(单字符 span)和 ResizeObserver 获取容器/字符尺寸。
|
||||||
|
*/
|
||||||
|
export class DomMeasureAdapter implements IMeasureAdapter {
|
||||||
|
private charWidthPx = 0;
|
||||||
|
private charHeightPx = 0;
|
||||||
|
private containerW = 0;
|
||||||
|
private containerH = 0;
|
||||||
|
private measureEl: HTMLElement | null = null;
|
||||||
|
private charSpan: HTMLElement | null = null;
|
||||||
|
private observer: ResizeObserver | null = null;
|
||||||
|
private callbacks = new Set<() => void>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly container: HTMLElement,
|
||||||
|
/** 参考字体样式(fontSize, fontFamily 等,会复制到测量元素上) */
|
||||||
|
private readonly fontStyle: Partial<CSSStyleDeclaration> = {}
|
||||||
|
) {}
|
||||||
|
|
||||||
|
mount(): void {
|
||||||
|
this.measureEl = document.createElement("div");
|
||||||
|
Object.assign(this.measureEl.style, {
|
||||||
|
position: "absolute",
|
||||||
|
visibility: "hidden",
|
||||||
|
pointerEvents: "none",
|
||||||
|
top: "0",
|
||||||
|
left: "0",
|
||||||
|
whiteSpace: "pre",
|
||||||
|
...this.fontStyle,
|
||||||
|
});
|
||||||
|
this.charSpan = document.createElement("span");
|
||||||
|
this.charSpan.textContent = "M"; // 等宽字符样本
|
||||||
|
this.measureEl.appendChild(this.charSpan);
|
||||||
|
document.body.appendChild(this.measureEl);
|
||||||
|
|
||||||
|
this.observer = new ResizeObserver(() => {
|
||||||
|
this.refresh();
|
||||||
|
for (const cb of this.callbacks) cb();
|
||||||
|
});
|
||||||
|
this.observer.observe(this.container);
|
||||||
|
this.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.observer?.disconnect();
|
||||||
|
this.measureEl?.parentNode?.removeChild(this.measureEl);
|
||||||
|
this.measureEl = null;
|
||||||
|
this.charSpan = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
measureChar(): { widthPx: number; heightPx: number } {
|
||||||
|
if (!this.charWidthPx) this.refresh();
|
||||||
|
return { widthPx: this.charWidthPx || 8, heightPx: this.charHeightPx || 16 };
|
||||||
|
}
|
||||||
|
|
||||||
|
measureContainer(): { widthPx: number; heightPx: number } {
|
||||||
|
if (!this.containerW) this.refresh();
|
||||||
|
return { widthPx: this.containerW || 320, heightPx: this.containerH || 200 };
|
||||||
|
}
|
||||||
|
|
||||||
|
onResize(cb: () => void): () => void {
|
||||||
|
this.callbacks.add(cb);
|
||||||
|
return () => this.callbacks.delete(cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
private refresh(): void {
|
||||||
|
if (this.charSpan) {
|
||||||
|
const rect = this.charSpan.getBoundingClientRect();
|
||||||
|
this.charWidthPx = rect.width || 8;
|
||||||
|
this.charHeightPx = rect.height || 16;
|
||||||
|
}
|
||||||
|
const cRect = this.container.getBoundingClientRect();
|
||||||
|
const style = getComputedStyle(this.container);
|
||||||
|
const px = (s: string) => parseFloat(s) || 0;
|
||||||
|
this.containerW = cRect.width - px(style.paddingLeft) - px(style.paddingRight);
|
||||||
|
this.containerH = cRect.height - px(style.paddingTop) - px(style.paddingBottom);
|
||||||
|
}
|
||||||
|
}
|
||||||
208
terminal/apps/web/src/terminal/renderer/compatRenderer.ts
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import { TerminalCore, sanitizeTerminalOutput } from "@remoteconn/terminal-core";
|
||||||
|
import type { RendererAdapter, TerminalSnapshot, TerminalLine, TerminalCell, ColorValue } from "@remoteconn/terminal-core";
|
||||||
|
import { FLAG_BOLD, FLAG_DIM, FLAG_ITALIC, FLAG_UNDERLINE, FLAG_INVERSE, FLAG_INVISIBLE, FLAG_STRIKETHROUGH, FLAG_OVERLINE } from "@remoteconn/terminal-core";
|
||||||
|
|
||||||
|
/** 16 色标准 xterm256 调色板(前 16 色) */
|
||||||
|
const ANSI16: string[] = [
|
||||||
|
"#000000","#cc0000","#00aa00","#aaaa00",
|
||||||
|
"#0000ee","#cc00cc","#00aaaa","#aaaaaa",
|
||||||
|
"#555555","#ff5555","#55ff55","#ffff55",
|
||||||
|
"#5555ff","#ff55ff","#55ffff","#ffffff",
|
||||||
|
];
|
||||||
|
|
||||||
|
function colorToCss(c: ColorValue, isFg: boolean): string {
|
||||||
|
switch (c.mode) {
|
||||||
|
case "default": return isFg ? "inherit" : "transparent";
|
||||||
|
case "p16": return ANSI16[c.value] ?? "inherit";
|
||||||
|
case "p256": return p256ToCss(c.value);
|
||||||
|
case "rgb": return `#${(c.value & 0xffffff).toString(16).padStart(6,"0")}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function p256ToCss(idx: number): string {
|
||||||
|
if (idx < 16) return ANSI16[idx] ?? "#000";
|
||||||
|
if (idx >= 232) {
|
||||||
|
const v = 8 + (idx - 232) * 10;
|
||||||
|
return `rgb(${v},${v},${v})`;
|
||||||
|
}
|
||||||
|
idx -= 16;
|
||||||
|
const b = idx % 6, g = Math.floor(idx / 6) % 6, r = Math.floor(idx / 36);
|
||||||
|
const ch = (x: number) => x === 0 ? 0 : 55 + x * 40;
|
||||||
|
return `rgb(${ch(r)},${ch(g)},${ch(b)})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CompatRenderer — DOM div/span 实现的渲染器。
|
||||||
|
* 每次 RAF 消费一次 TerminalCore.snapshot() 进行差量更新。
|
||||||
|
*/
|
||||||
|
export class CompatRenderer implements RendererAdapter {
|
||||||
|
private core: TerminalCore;
|
||||||
|
private container: HTMLElement | null = null;
|
||||||
|
private viewport: HTMLElement | null = null;
|
||||||
|
private cursorEl: HTMLElement | null = null;
|
||||||
|
private rowEls: HTMLElement[] = [];
|
||||||
|
private rafId: number = 0;
|
||||||
|
private lastRevision = -1;
|
||||||
|
|
||||||
|
constructor(cols = 80, rows = 24) {
|
||||||
|
this.core = new TerminalCore(cols, rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
getCore(): TerminalCore { return this.core; }
|
||||||
|
|
||||||
|
mount(container: unknown): void {
|
||||||
|
this.container = container as HTMLElement;
|
||||||
|
this.container.classList.add("tc-compat-root");
|
||||||
|
Object.assign(this.container.style, {
|
||||||
|
position: "relative",
|
||||||
|
overflow: "hidden",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
lineHeight: "1.2em",
|
||||||
|
});
|
||||||
|
|
||||||
|
this.viewport = document.createElement("div");
|
||||||
|
this.viewport.className = "tc-viewport";
|
||||||
|
Object.assign(this.viewport.style, {
|
||||||
|
position: "absolute",
|
||||||
|
top: "0", left: "0",
|
||||||
|
whiteSpace: "pre",
|
||||||
|
});
|
||||||
|
this.container.appendChild(this.viewport);
|
||||||
|
|
||||||
|
this.cursorEl = document.createElement("div");
|
||||||
|
this.cursorEl.className = "tc-cursor";
|
||||||
|
Object.assign(this.cursorEl.style, {
|
||||||
|
position: "absolute",
|
||||||
|
width: "0.6em",
|
||||||
|
height: "1.2em",
|
||||||
|
background:"rgba(255,255,255,0.8)",
|
||||||
|
pointerEvents: "none",
|
||||||
|
zIndex: "1",
|
||||||
|
});
|
||||||
|
this.container.appendChild(this.cursorEl);
|
||||||
|
|
||||||
|
this.rebuildRows(this.core.snapshot().rows);
|
||||||
|
this.scheduleRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
write(data: string): void {
|
||||||
|
const sanitized = sanitizeTerminalOutput(data);
|
||||||
|
if (sanitized) {
|
||||||
|
this.core.write(sanitized);
|
||||||
|
this.scheduleRender();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resize(cols: number, rows: number): void {
|
||||||
|
this.core.resize(cols, rows);
|
||||||
|
this.rebuildRows(rows);
|
||||||
|
this.scheduleRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
applySnapshot(snapshot: TerminalSnapshot): void {
|
||||||
|
// Re-create a clean core from the snapshot is not straightforward;
|
||||||
|
// the caller should pass the same TerminalCore reference instead.
|
||||||
|
// For "mode switch" replays, we just re-render the current state.
|
||||||
|
this.renderSnapshot(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
if (this.rafId) cancelAnimationFrame(this.rafId);
|
||||||
|
this.container?.classList.remove("tc-compat-root");
|
||||||
|
this.rowEls = [];
|
||||||
|
this.viewport?.parentNode?.removeChild(this.viewport);
|
||||||
|
this.cursorEl?.parentNode?.removeChild(this.cursorEl);
|
||||||
|
this.viewport = null;
|
||||||
|
this.cursorEl = null;
|
||||||
|
this.container = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private scheduleRender(): void {
|
||||||
|
if (this.rafId) return;
|
||||||
|
this.rafId = requestAnimationFrame(() => {
|
||||||
|
this.rafId = 0;
|
||||||
|
const snap = this.core.snapshot();
|
||||||
|
if (snap.revision === this.lastRevision) return;
|
||||||
|
this.lastRevision = snap.revision;
|
||||||
|
this.renderSnapshot(snap);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private rebuildRows(rows: number): void {
|
||||||
|
if (!this.viewport) return;
|
||||||
|
this.viewport.innerHTML = "";
|
||||||
|
this.rowEls = [];
|
||||||
|
for (let r = 0; r < rows; r++) {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.className = "tc-row";
|
||||||
|
this.viewport.appendChild(div);
|
||||||
|
this.rowEls.push(div);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderSnapshot(snap: TerminalSnapshot): void {
|
||||||
|
if (!this.viewport || !this.cursorEl) return;
|
||||||
|
|
||||||
|
// Ensure row count matches
|
||||||
|
if (this.rowEls.length !== snap.rows) {
|
||||||
|
this.rebuildRows(snap.rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let r = 0; r < snap.rows; r++) {
|
||||||
|
const rowEl = this.rowEls[r];
|
||||||
|
if (!rowEl) continue;
|
||||||
|
const line = snap.lines[r];
|
||||||
|
if (line) {
|
||||||
|
rowEl.innerHTML = lineToHtml(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update cursor
|
||||||
|
const charEm = 0.6; // em per character (monospace approx)
|
||||||
|
const lineEm = 1.2;
|
||||||
|
Object.assign(this.cursorEl.style, {
|
||||||
|
display: snap.cursor.visible ? "block" : "none",
|
||||||
|
left: `${snap.cursor.x * charEm}em`,
|
||||||
|
top: `${snap.cursor.y * lineEm}em`,
|
||||||
|
width: `${charEm}em`,
|
||||||
|
height: `${lineEm}em`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineToHtml(line: TerminalLine): string {
|
||||||
|
let html = "";
|
||||||
|
for (const cell of line.cells) {
|
||||||
|
html += cellToHtml(cell);
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellToHtml(cell: TerminalCell): string {
|
||||||
|
const { flags } = cell;
|
||||||
|
let fg = colorToCss(cell.fg, true);
|
||||||
|
let bg = colorToCss(cell.bg, false);
|
||||||
|
|
||||||
|
if (flags & FLAG_INVERSE) { [fg, bg] = [bg === "transparent" ? "#ffffff" : bg, fg === "inherit" ? "#000000" : fg]; }
|
||||||
|
|
||||||
|
const invisible = flags & FLAG_INVISIBLE;
|
||||||
|
const char = invisible ? " " : (cell.char || " ");
|
||||||
|
|
||||||
|
const styles: string[] = [];
|
||||||
|
if (fg !== "inherit") styles.push(`color:${fg}`);
|
||||||
|
if (bg !== "transparent") styles.push(`background:${bg}`);
|
||||||
|
if (flags & FLAG_BOLD) styles.push("font-weight:bold");
|
||||||
|
if (flags & FLAG_DIM) styles.push("opacity:0.5");
|
||||||
|
if (flags & FLAG_ITALIC) styles.push("font-style:italic");
|
||||||
|
const tdecs: string[] = [];
|
||||||
|
if (flags & FLAG_UNDERLINE) tdecs.push("underline");
|
||||||
|
if (flags & FLAG_STRIKETHROUGH) tdecs.push("line-through");
|
||||||
|
if (flags & FLAG_OVERLINE) tdecs.push("overline");
|
||||||
|
if (tdecs.length) styles.push(`text-decoration:${tdecs.join(" ")}`);
|
||||||
|
|
||||||
|
const escChar = char === "&" ? "&" : char === "<" ? "<" : char === ">" ? ">" : char;
|
||||||
|
if (!styles.length) return escChar;
|
||||||
|
return `<span style="${styles.join(";")}">${escChar}</span>`;
|
||||||
|
}
|
||||||
381
terminal/apps/web/src/terminal/renderer/textareaRenderer.ts
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
import { TerminalCore, sanitizeTerminalOutput } from "@remoteconn/terminal-core";
|
||||||
|
import type { RendererAdapter, TerminalSnapshot, TerminalCell, ColorValue } from "@remoteconn/terminal-core";
|
||||||
|
import { FLAG_BOLD, FLAG_DIM, FLAG_ITALIC, FLAG_UNDERLINE, FLAG_INVERSE, FLAG_INVISIBLE, FLAG_STRIKETHROUGH, FLAG_OVERLINE } from "@remoteconn/terminal-core";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TextareaRenderer — 基于 <pre>/<textarea> 的轻量渲染器。
|
||||||
|
* 适合低端设备和降级场景(rendererFallback=textarea)。
|
||||||
|
* 输出区使用 <pre> + span 显示(支持颜色属性)。
|
||||||
|
*/
|
||||||
|
const ANSI16: string[] = [
|
||||||
|
"#000000","#cc0000","#00aa00","#aaaa00",
|
||||||
|
"#0000ee","#cc00cc","#00aaaa","#aaaaaa",
|
||||||
|
"#555555","#ff5555","#55ff55","#ffff55",
|
||||||
|
"#5555ff","#ff55ff","#55ffff","#ffffff",
|
||||||
|
];
|
||||||
|
|
||||||
|
function colorToCss(c: ColorValue, isFg: boolean): string {
|
||||||
|
switch (c.mode) {
|
||||||
|
case "default": return isFg ? "inherit" : "transparent";
|
||||||
|
case "p16": return ANSI16[c.value] ?? "inherit";
|
||||||
|
case "p256": return p256ToCss(c.value);
|
||||||
|
case "rgb": return `#${(c.value & 0xffffff).toString(16).padStart(6, "0")}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function p256ToCss(idx: number): string {
|
||||||
|
if (idx < 16) return ANSI16[idx] ?? "#000";
|
||||||
|
if (idx >= 232) {
|
||||||
|
const v = 8 + (idx - 232) * 10;
|
||||||
|
return `rgb(${v},${v},${v})`;
|
||||||
|
}
|
||||||
|
idx -= 16;
|
||||||
|
const b = idx % 6;
|
||||||
|
const g = Math.floor(idx / 6) % 6;
|
||||||
|
const r = Math.floor(idx / 36);
|
||||||
|
const ch = (x: number) => x === 0 ? 0 : 55 + x * 40;
|
||||||
|
return `rgb(${ch(r)},${ch(g)},${ch(b)})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(ch: string): string {
|
||||||
|
if (ch === "&") return "&";
|
||||||
|
if (ch === "<") return "<";
|
||||||
|
if (ch === ">") return ">";
|
||||||
|
return ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTouchDebugEnabled(): boolean {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem("terminal.debugTextareaRenderer") === "1";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function debugRenderer(message: string, detail?: unknown): void {
|
||||||
|
if (!isTouchDebugEnabled()) return;
|
||||||
|
const prefix = `[TextareaRenderer][Touch][${new Date().toISOString()}] ${message}`;
|
||||||
|
if (typeof detail === "undefined") {
|
||||||
|
console.log(prefix);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(prefix, detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TextareaRenderer implements RendererAdapter {
|
||||||
|
private core: TerminalCore;
|
||||||
|
private container: HTMLElement | null = null;
|
||||||
|
private outputEl: HTMLPreElement | null = null;
|
||||||
|
private rafId: number = 0;
|
||||||
|
private lastRevision = -1;
|
||||||
|
/** 是否自动跟随滚动到底部 */
|
||||||
|
private autoFollow = true;
|
||||||
|
private blinkVisible = true;
|
||||||
|
private blinkTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private lastSnapshot: TerminalSnapshot | null = null;
|
||||||
|
/**
|
||||||
|
* 时间戳:直到此时刻前,scroll 事件不更新 autoFollow。
|
||||||
|
* iOS WebKit 的 scroll 事件是异步的(下一个 task),不能用同步锁,只能用时间窗口。
|
||||||
|
*/
|
||||||
|
private scrollFrozenUntil = 0;
|
||||||
|
|
||||||
|
constructor(cols = 80, rows = 24) {
|
||||||
|
this.core = new TerminalCore(cols, rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
getCore(): TerminalCore { return this.core; }
|
||||||
|
|
||||||
|
mount(container: unknown): void {
|
||||||
|
this.container = container as HTMLElement;
|
||||||
|
this.container.classList.add("tc-textarea-root");
|
||||||
|
Object.assign(this.container.style, {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
height: "100%",
|
||||||
|
minHeight: "0"
|
||||||
|
});
|
||||||
|
|
||||||
|
// 输出区
|
||||||
|
this.outputEl = document.createElement("pre");
|
||||||
|
this.outputEl.className = "tc-output";
|
||||||
|
Object.assign(this.outputEl.style, {
|
||||||
|
margin: "0",
|
||||||
|
overflow: "auto",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
wordBreak: "break-all",
|
||||||
|
flex: "1",
|
||||||
|
minHeight: "0",
|
||||||
|
cursor: "text",
|
||||||
|
userSelect: "text",
|
||||||
|
WebkitUserSelect: "text",
|
||||||
|
WebkitTouchCallout: "default",
|
||||||
|
WebkitOverflowScrolling: "touch",
|
||||||
|
overscrollBehavior: "contain",
|
||||||
|
touchAction: "pan-y",
|
||||||
|
position: "relative",
|
||||||
|
});
|
||||||
|
// 用户手动上滚时禁止自动跟随。
|
||||||
|
// iOS WebKit scroll 事件是异步的(下一个 task),不能用同步锁。
|
||||||
|
// 用时间窗口:scrollFrozenUntil 内的 scroll 事件跳过 autoFollow 更新。
|
||||||
|
this.outputEl.addEventListener("scroll", () => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now < this.scrollFrozenUntil) {
|
||||||
|
console.log(`[TextareaRenderer][scroll] FROZEN(${this.scrollFrozenUntil - now}ms) — skip autoFollow, scrollTop=${this.outputEl?.scrollTop}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const el = this.outputEl!;
|
||||||
|
const atBottom = Math.abs(el.scrollTop + el.clientHeight - el.scrollHeight) < 4;
|
||||||
|
const prevAutoFollow = this.autoFollow;
|
||||||
|
this.autoFollow = atBottom;
|
||||||
|
console.log(`[TextareaRenderer][scroll] scrollTop=${el.scrollTop} clientH=${el.clientHeight} scrollH=${el.scrollHeight} atBottom=${atBottom} autoFollow: ${prevAutoFollow} → ${this.autoFollow}`);
|
||||||
|
});
|
||||||
|
this.outputEl.addEventListener("touchstart", (e: TouchEvent) => {
|
||||||
|
const t = e.changedTouches[0];
|
||||||
|
debugRenderer("event:touchstart", t ? { x: t.clientX, y: t.clientY } : undefined);
|
||||||
|
}, { passive: true });
|
||||||
|
this.outputEl.addEventListener("touchmove", (e: TouchEvent) => {
|
||||||
|
const t = e.changedTouches[0];
|
||||||
|
debugRenderer("event:touchmove", t ? { x: t.clientX, y: t.clientY } : undefined);
|
||||||
|
}, { passive: true });
|
||||||
|
this.outputEl.addEventListener("touchend", (e: TouchEvent) => {
|
||||||
|
const t = e.changedTouches[0];
|
||||||
|
debugRenderer("event:touchend", t ? { x: t.clientX, y: t.clientY } : undefined);
|
||||||
|
}, { passive: true });
|
||||||
|
this.container.appendChild(this.outputEl);
|
||||||
|
|
||||||
|
this.startBlink();
|
||||||
|
this.scheduleRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
write(data: string): void {
|
||||||
|
const s = sanitizeTerminalOutput(data);
|
||||||
|
if (s) {
|
||||||
|
this.core.write(s);
|
||||||
|
this.scheduleRender();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resize(cols: number, rows: number): void {
|
||||||
|
this.core.resize(cols, rows);
|
||||||
|
this.scheduleRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
applySnapshot(snapshot: TerminalSnapshot): void {
|
||||||
|
this.renderSnapshot(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 强制恢复"跟随到底部"行为。
|
||||||
|
* 用于用户执行命令(Enter)后,确保新输出与提示符回到可见底部。
|
||||||
|
*/
|
||||||
|
followBottom(): void {
|
||||||
|
if (!this.outputEl || !this.lastSnapshot) return;
|
||||||
|
if (!this.autoFollow) return;
|
||||||
|
|
||||||
|
// 获取真实的光标DOM进行精准滚动
|
||||||
|
const cursorSpan = this.outputEl.querySelector('.tc-cursor-cell') as HTMLElement;
|
||||||
|
if (cursorSpan) {
|
||||||
|
const cursorBottom = cursorSpan.offsetTop + cursorSpan.offsetHeight;
|
||||||
|
const clientH = this.outputEl.clientHeight;
|
||||||
|
const maxScroll = Math.max(0, this.outputEl.scrollHeight - clientH);
|
||||||
|
// 尝试把光标置于视口底部附近
|
||||||
|
let targetScrollTop = cursorBottom - clientH + 20; // 留一点余量
|
||||||
|
targetScrollTop = Math.max(0, Math.min(maxScroll, targetScrollTop));
|
||||||
|
this.outputEl.scrollTop = targetScrollTop;
|
||||||
|
} else {
|
||||||
|
this.outputEl.scrollTop = this.outputEl.scrollHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回光标行在输出滚动容器中距顶部的精准像素偏移量。
|
||||||
|
* 用于软键盘弹出时将提示符行置于屏幕特定位置。
|
||||||
|
* 若渲染器未就绪则返回 null。
|
||||||
|
*
|
||||||
|
* 计算方法:优先使用 getComputedStyle lineHeight 获得精确行高,
|
||||||
|
* 回退使用 scrollHeight/lineCount 估算。
|
||||||
|
*/
|
||||||
|
getCursorOffsetPx(): number | null {
|
||||||
|
if (!this.outputEl || !this.lastSnapshot) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 精准获取光标DOM元素的 offsetTop
|
||||||
|
const cursorSpan = this.outputEl.querySelector('.tc-cursor-cell') as HTMLElement;
|
||||||
|
if (cursorSpan) {
|
||||||
|
return cursorSpan.offsetTop;
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallback: 如果没有可见光标,根据 cursor.y 估算
|
||||||
|
const snap = this.lastSnapshot;
|
||||||
|
const totalLines = snap.lines.length;
|
||||||
|
const cursorY = Math.max(0, Math.min(totalLines - 1, snap.cursor.y));
|
||||||
|
const scrollH = this.outputEl.scrollHeight;
|
||||||
|
const lineHeight = scrollH / Math.max(1, totalLines);
|
||||||
|
return cursorY * lineHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取输出滚动容器元素。
|
||||||
|
*/
|
||||||
|
getOutputEl(): HTMLElement | null {
|
||||||
|
return this.outputEl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 冻结 scroll 事件对 autoFollow 的影响,持续 ms 毫秒。
|
||||||
|
* 用于程序主动设置 scrollTop 后,屏蔽 iOS 异步 scroll 事件的干扰。
|
||||||
|
* ms=0 立即解冻。
|
||||||
|
*/
|
||||||
|
setScrollLocked(ms: number): void {
|
||||||
|
this.scrollFrozenUntil = ms > 0 ? Date.now() + ms : 0;
|
||||||
|
console.log(`[TextareaRenderer][setScrollFrozen] ms=${ms} frozenUntil=${ms > 0 ? new Date(this.scrollFrozenUntil).toISOString() : "CLEARED"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 临时禁止 autoFollow(键盘弹出/收回动画期间保护滚动位置)。
|
||||||
|
*/
|
||||||
|
setAutoFollow(enabled: boolean): void {
|
||||||
|
console.log(`[TextareaRenderer][setAutoFollow] ${this.autoFollow} → ${enabled}`);
|
||||||
|
this.autoFollow = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
if (this.rafId) cancelAnimationFrame(this.rafId);
|
||||||
|
if (this.blinkTimer) {
|
||||||
|
clearInterval(this.blinkTimer);
|
||||||
|
this.blinkTimer = null;
|
||||||
|
}
|
||||||
|
this.container?.classList.remove("tc-textarea-root");
|
||||||
|
this.outputEl?.parentNode?.removeChild(this.outputEl);
|
||||||
|
this.outputEl = null;
|
||||||
|
this.container = null;
|
||||||
|
this.lastSnapshot = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private scheduleRender(): void {
|
||||||
|
if (this.rafId) return;
|
||||||
|
this.rafId = requestAnimationFrame(() => {
|
||||||
|
this.rafId = 0;
|
||||||
|
const snap = this.core.snapshotWithScrollback();
|
||||||
|
if (snap.revision === this.lastRevision) return;
|
||||||
|
this.lastRevision = snap.revision;
|
||||||
|
this.blinkVisible = true;
|
||||||
|
this.renderSnapshot(snap);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private startBlink(): void {
|
||||||
|
if (this.blinkTimer) clearInterval(this.blinkTimer);
|
||||||
|
this.blinkTimer = setInterval(() => {
|
||||||
|
if (!this.lastSnapshot || !this.lastSnapshot.cursor.visible) return;
|
||||||
|
if (this.hasActiveSelectionInOutput()) return;
|
||||||
|
this.blinkVisible = !this.blinkVisible;
|
||||||
|
this.renderSnapshot(this.lastSnapshot, true);
|
||||||
|
}, 550);
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderSnapshot(snap: TerminalSnapshot, fromBlink = false): void {
|
||||||
|
if (!this.outputEl) return;
|
||||||
|
this.lastSnapshot = snap;
|
||||||
|
if (this.hasActiveSelectionInOutput()) return;
|
||||||
|
|
||||||
|
const htmlRows: string[] = [];
|
||||||
|
const cursorY = Math.max(0, Math.min(snap.lines.length - 1, snap.cursor.y));
|
||||||
|
for (let rowIndex = 0; rowIndex < snap.lines.length; rowIndex++) {
|
||||||
|
const line = snap.lines[rowIndex];
|
||||||
|
if (!line) {
|
||||||
|
htmlRows.push("");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const cells = line.cells;
|
||||||
|
let rowHtml = "";
|
||||||
|
for (let col = 0; col < cells.length; col++) {
|
||||||
|
const cell = cells[col];
|
||||||
|
if (!cell) continue;
|
||||||
|
const cursorLogicalHere = snap.cursor.visible && rowIndex === cursorY && col === snap.cursor.x;
|
||||||
|
const cursorPaintHere = cursorLogicalHere && this.blinkVisible;
|
||||||
|
rowHtml += this.cellToHtml(cell, cursorPaintHere, cursorLogicalHere);
|
||||||
|
}
|
||||||
|
htmlRows.push(rowHtml.replace(/\s+$/g, ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
// iOS Safari 在 innerHTML 赋值后会不稳定地改变 scrollTop。
|
||||||
|
// autoFollow=false(键盘弹出期间)时,必须手动保存和恢复 scrollTop。
|
||||||
|
const savedScrollTop = this.autoFollow ? -1 : this.outputEl.scrollTop;
|
||||||
|
|
||||||
|
this.outputEl.innerHTML = htmlRows.join("\n");
|
||||||
|
|
||||||
|
if (this.autoFollow && !fromBlink) {
|
||||||
|
const clientH = this.outputEl.clientHeight;
|
||||||
|
const maxScroll = Math.max(0, this.outputEl.scrollHeight - clientH);
|
||||||
|
|
||||||
|
const cursorSpan = this.outputEl.querySelector('.tc-cursor-cell') as HTMLElement;
|
||||||
|
if (cursorSpan) {
|
||||||
|
const cursorBottom = cursorSpan.offsetTop + cursorSpan.offsetHeight;
|
||||||
|
let targetScrollTop = cursorBottom - clientH + 20;
|
||||||
|
targetScrollTop = Math.max(0, Math.min(maxScroll, targetScrollTop));
|
||||||
|
this.outputEl.scrollTop = targetScrollTop;
|
||||||
|
} else {
|
||||||
|
this.outputEl.scrollTop = this.outputEl.scrollHeight;
|
||||||
|
}
|
||||||
|
} else if (savedScrollTop >= 0) {
|
||||||
|
// 恢复之前的 scrollTop,防止 iOS 在 innerHTML 赋值后将其重置
|
||||||
|
const maxTop = Math.max(0, this.outputEl.scrollHeight - this.outputEl.clientHeight);
|
||||||
|
const restoredTop = Math.min(savedScrollTop, maxTop);
|
||||||
|
if (Math.abs(restoredTop - this.outputEl.scrollTop) > 1) {
|
||||||
|
console.log(`[TextareaRenderer][renderSnapshot] scrollTop drifted: was=${savedScrollTop} got=${this.outputEl.scrollTop} restoring=${restoredTop} maxTop=${maxTop}`);
|
||||||
|
this.outputEl.scrollTop = restoredTop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private cellToHtml(cell: TerminalCell, cursorPaintHere: boolean, cursorLogicalHere: boolean): string {
|
||||||
|
const { flags } = cell;
|
||||||
|
let fg = colorToCss(cell.fg, true);
|
||||||
|
let bg = colorToCss(cell.bg, false);
|
||||||
|
if (flags & FLAG_INVERSE) {
|
||||||
|
[fg, bg] = [bg === "transparent" ? "#ffffff" : bg, fg === "inherit" ? "#000000" : fg];
|
||||||
|
}
|
||||||
|
if (cursorPaintHere) {
|
||||||
|
const cursorBg = fg === "inherit" ? "#d9d9d9" : fg;
|
||||||
|
const cursorFg = bg === "transparent" ? "#101010" : bg;
|
||||||
|
fg = cursorFg;
|
||||||
|
bg = cursorBg;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char = (flags & FLAG_INVISIBLE) ? " " : (cell.char || " ");
|
||||||
|
const styles: string[] = [];
|
||||||
|
if (fg !== "inherit") styles.push(`color:${fg}`);
|
||||||
|
if (bg !== "transparent") styles.push(`background:${bg}`);
|
||||||
|
if (flags & FLAG_BOLD) styles.push("font-weight:bold");
|
||||||
|
if (flags & FLAG_DIM) styles.push("opacity:0.5");
|
||||||
|
if (flags & FLAG_ITALIC) styles.push("font-style:italic");
|
||||||
|
const tdecs: string[] = [];
|
||||||
|
if (flags & FLAG_UNDERLINE) tdecs.push("underline");
|
||||||
|
if (flags & FLAG_STRIKETHROUGH) tdecs.push("line-through");
|
||||||
|
if (flags & FLAG_OVERLINE) tdecs.push("overline");
|
||||||
|
if (tdecs.length) styles.push(`text-decoration:${tdecs.join(" ")}`);
|
||||||
|
|
||||||
|
const escaped = escapeHtml(char);
|
||||||
|
const classAttr = cursorLogicalHere ? ` class="tc-cursor-cell"` : "";
|
||||||
|
if (!styles.length) {
|
||||||
|
if (!cursorLogicalHere) return escaped;
|
||||||
|
return `<span${classAttr}>${escaped}</span>`;
|
||||||
|
}
|
||||||
|
return `<span${classAttr} style="${styles.join(";")}">${escaped}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasActiveSelectionInOutput(): boolean {
|
||||||
|
if (!this.outputEl || typeof window === "undefined") return false;
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return false;
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
const node = range.commonAncestorContainer;
|
||||||
|
return this.outputEl.contains(node.nodeType === Node.TEXT_NODE ? node.parentNode : node);
|
||||||
|
}
|
||||||
|
}
|
||||||
539
terminal/apps/web/src/terminal/stores/useTerminalStore.ts
Normal file
@@ -0,0 +1,539 @@
|
|||||||
|
/**
|
||||||
|
* useTerminalStore — 新 terminal-core 架构的独立 Pinia store。
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* - 管理 transport / renderer / outputBuffer 生命周期
|
||||||
|
* - 对外暴露 state / title / latencyMs / rendererMode 响应式状态
|
||||||
|
* - 提供 connect / disconnect / sendInput / clearTerminal / switchRenderer / mountRenderer
|
||||||
|
*
|
||||||
|
* 刻意与 sessionStore 分离,保持实验性单一职责。
|
||||||
|
*/
|
||||||
|
import { defineStore } from "pinia";
|
||||||
|
import { ref, shallowRef } from "vue";
|
||||||
|
import { OutputBuffer, SessionMachine, sanitizeTerminalOutput } from "@remoteconn/terminal-core";
|
||||||
|
import type { ConnectParams, SessionState } from "@remoteconn/terminal-core";
|
||||||
|
import type { TerminalTransport } from "@/services/transport/terminalTransport";
|
||||||
|
import { createTransport } from "@/services/transport/factory";
|
||||||
|
import { TextareaRenderer } from "@/terminal/renderer/textareaRenderer";
|
||||||
|
import { useSettingsStore } from "@/stores/settingsStore";
|
||||||
|
|
||||||
|
export type RendererMode = "textarea";
|
||||||
|
|
||||||
|
function logStore(_message: string, _detail?: unknown): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
const enabled =
|
||||||
|
window.localStorage.getItem("terminal.debugTransport") === "1" ||
|
||||||
|
window.localStorage.getItem("terminal.debugPaste") === "1";
|
||||||
|
if (!enabled) return;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const prefix = `[TerminalStore][${new Date().toISOString()}] ${_message}`;
|
||||||
|
if (typeof _detail === "undefined") {
|
||||||
|
console.log(prefix);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(prefix, _detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTerminalStore = defineStore("terminal-core", () => {
|
||||||
|
const OUTPUT_CACHE_PREFIX = "remoteconn:web:output-cache:v1:";
|
||||||
|
const PASTE_CHUNK_SIZE = 256;
|
||||||
|
const DEFAULT_CHUNK_SIZE = 2048;
|
||||||
|
|
||||||
|
// ── 持久配置 ─────────────────────────────────────────────────────────────
|
||||||
|
const rendererMode = ref<RendererMode>("textarea");
|
||||||
|
|
||||||
|
// ── 响应式状态 ───────────────────────────────────────────────────────────
|
||||||
|
const state = ref<SessionState>("idle");
|
||||||
|
const title = ref<string>("");
|
||||||
|
const latencyMs = ref<number>(0);
|
||||||
|
|
||||||
|
// ── 非响应式内部对象 ──────────────────────────────────────────────────────
|
||||||
|
const machine = new SessionMachine();
|
||||||
|
const buffer = new OutputBuffer({ maxEntries: 500, maxBytes: 256 * 1024 });
|
||||||
|
|
||||||
|
const renderer = shallowRef<TextareaRenderer | null>(null);
|
||||||
|
let transport : TerminalTransport | null = null;
|
||||||
|
let offTransport: (() => void) | null = null;
|
||||||
|
let resizeTimer: number | null = null;
|
||||||
|
let pendingResize: { cols: number; rows: number } | null = null;
|
||||||
|
let latestLocalSize: { cols: number; rows: number } | null = null;
|
||||||
|
let lastRemoteSize: { cols: number; rows: number } | null = null;
|
||||||
|
let remoteResizePaused = false;
|
||||||
|
let suppressAutoFollowUntil = 0;
|
||||||
|
const RESIZE_DEBOUNCE_MS = 140;
|
||||||
|
|
||||||
|
let mountedContainer: HTMLElement | null = null;
|
||||||
|
let outputCacheKey: string | null = null;
|
||||||
|
|
||||||
|
function persistOutputCache(): void {
|
||||||
|
if (!outputCacheKey || typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
window.sessionStorage.setItem(outputCacheKey, JSON.stringify(buffer.getAll()));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绑定会话输出缓存。
|
||||||
|
* - scope 建议使用 serverId + clientSessionKey,避免串线;
|
||||||
|
* - 仅在当前 buffer 为空时回放缓存,防止运行中重复叠加。
|
||||||
|
*/
|
||||||
|
function bindOutputCache(scope: string): void {
|
||||||
|
if (!scope || typeof window === "undefined") return;
|
||||||
|
const nextKey = `${OUTPUT_CACHE_PREFIX}${scope}`;
|
||||||
|
if (outputCacheKey === nextKey) return;
|
||||||
|
outputCacheKey = nextKey;
|
||||||
|
|
||||||
|
if (buffer.size > 0) return;
|
||||||
|
|
||||||
|
let restoredAny = false;
|
||||||
|
try {
|
||||||
|
const raw = window.sessionStorage.getItem(nextKey);
|
||||||
|
if (!raw) return;
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
if (!Array.isArray(parsed)) return;
|
||||||
|
for (const item of parsed) {
|
||||||
|
if (typeof item !== "string") continue;
|
||||||
|
const sanitized = sanitizeTerminalOutput(item);
|
||||||
|
if (!sanitized) continue;
|
||||||
|
buffer.push(sanitized);
|
||||||
|
restoredAny = true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
if (restoredAny && renderer.value) {
|
||||||
|
for (const chunk of buffer.getAll()) {
|
||||||
|
renderer.value.write(chunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearBoundOutputCache(): void {
|
||||||
|
if (!outputCacheKey || typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
window.sessionStorage.removeItem(outputCacheKey);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 渲染器挂载点 ─────────────────────────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* 在 TerminalViewport.vue 的 onMounted 后调用,将渲染器挂载到 DOM 容器。
|
||||||
|
* 切换渲染器时也需重新调用(已由 switchRenderer 内部处理)。
|
||||||
|
*/
|
||||||
|
function mountRenderer(container: HTMLElement): void {
|
||||||
|
mountedContainer = container;
|
||||||
|
|
||||||
|
if (!renderer.value) {
|
||||||
|
renderer.value = new TextareaRenderer();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderer.value.mount(container);
|
||||||
|
|
||||||
|
// 重放当前 buffer(初次挂载 / 切换后恢复历史)
|
||||||
|
for (const chunk of buffer.getAll()) {
|
||||||
|
renderer.value.write(chunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原子切换渲染模式:dispose → 新建 → 重放 buffer → re-mount。
|
||||||
|
*/
|
||||||
|
function switchRenderer(mode: RendererMode): void {
|
||||||
|
if (rendererMode.value === mode) return;
|
||||||
|
rendererMode.value = "textarea";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Transport 事件处理 ───────────────────────────────────────────────────
|
||||||
|
function bindTransport(t: TerminalTransport): void {
|
||||||
|
logStore("transport:bind");
|
||||||
|
offTransport?.();
|
||||||
|
offTransport = t.on((event) => {
|
||||||
|
if (event.type === "stdout" || event.type === "stderr") {
|
||||||
|
logStore("transport:event", { type: event.type, length: event.data.length, machineState: machine.state, state: state.value });
|
||||||
|
} else {
|
||||||
|
logStore("transport:event", { type: event.type, machineState: machine.state, state: state.value, event });
|
||||||
|
}
|
||||||
|
if (event.type === "stdout" || event.type === "stderr") {
|
||||||
|
if (state.value !== "connected" && machine.state === "connecting") {
|
||||||
|
machine.tryTransition("auth_pending");
|
||||||
|
}
|
||||||
|
if (state.value !== "connected" && machine.tryTransition("connected")) {
|
||||||
|
state.value = machine.state;
|
||||||
|
}
|
||||||
|
const sanitized = sanitizeTerminalOutput(event.data);
|
||||||
|
if (sanitized) {
|
||||||
|
// 统一行为:有新输出刷新时保持底部可见(提示符/最新输出不离开视口)。
|
||||||
|
// 例外:键盘收起恢复窗口内,临时抑制 auto-follow,避免覆盖刚恢复的滚动位置。
|
||||||
|
const now = Date.now();
|
||||||
|
if (now >= suppressAutoFollowUntil) {
|
||||||
|
console.log(`[TerminalStore][stdout] followBottom() called, len=${sanitized.length}`);
|
||||||
|
renderer.value?.followBottom();
|
||||||
|
} else {
|
||||||
|
console.log(`[TerminalStore][stdout] followBottom SUPPRESSED, remainMs=${suppressAutoFollowUntil - now}, len=${sanitized.length}`);
|
||||||
|
logStore("follow:skipped_suppressed", { remainMs: suppressAutoFollowUntil - now });
|
||||||
|
}
|
||||||
|
buffer.push(sanitized);
|
||||||
|
renderer.value?.write(sanitized);
|
||||||
|
persistOutputCache();
|
||||||
|
}
|
||||||
|
} else if (event.type === "connected") {
|
||||||
|
if (machine.state === "connecting") {
|
||||||
|
machine.tryTransition("auth_pending");
|
||||||
|
}
|
||||||
|
machine.tryTransition("connected");
|
||||||
|
state.value = machine.state;
|
||||||
|
} else if (event.type === "disconnect") {
|
||||||
|
console.log(`[TerminalStore][${new Date().toISOString()}] transport:disconnect`, { reason: event.reason });
|
||||||
|
console.error(`[TerminalStore][${new Date().toISOString()}] transport:disconnect`, { reason: event.reason });
|
||||||
|
logStore("transport:disconnect", { reason: event.reason });
|
||||||
|
machine.tryTransition("disconnected");
|
||||||
|
state.value = machine.state;
|
||||||
|
} else if (event.type === "latency") {
|
||||||
|
latencyMs.value = event.data;
|
||||||
|
} else if (event.type === "error") {
|
||||||
|
console.log(`[TerminalStore][${new Date().toISOString()}] transport:error`, { code: event.code, message: event.message });
|
||||||
|
console.error(`[TerminalStore][${new Date().toISOString()}] transport:error`, { code: event.code, message: event.message });
|
||||||
|
logStore("transport:error", { code: event.code, message: event.message });
|
||||||
|
machine.tryTransition("error");
|
||||||
|
state.value = machine.state;
|
||||||
|
} else if ((event as unknown as { type?: string; action?: string }).type === "control") {
|
||||||
|
const action = (event as unknown as { action?: string }).action;
|
||||||
|
if (action === "connected") {
|
||||||
|
machine.tryTransition("connected");
|
||||||
|
state.value = machine.state;
|
||||||
|
} else if (action === "disconnect") {
|
||||||
|
machine.tryTransition("disconnected");
|
||||||
|
state.value = machine.state;
|
||||||
|
} else if (action === "pong") {
|
||||||
|
latencyMs.value = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 公开 API ──────────────────────────────────────────────────────────────
|
||||||
|
async function connect(params: ConnectParams): Promise<void> {
|
||||||
|
logStore("connect:requested", { state: state.value, host: params.host, port: params.port, username: params.username, cols: params.cols, rows: params.rows });
|
||||||
|
if (state.value === "connected" || state.value === "connecting") {
|
||||||
|
logStore("connect:skip", { reason: "already_connected_or_connecting", state: state.value });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
machine.tryTransition("connecting");
|
||||||
|
state.value = machine.state;
|
||||||
|
logStore("state:update", { machineState: machine.state, state: state.value });
|
||||||
|
|
||||||
|
const settingsStore = useSettingsStore();
|
||||||
|
await settingsStore.ensureBootstrapped();
|
||||||
|
const gatewayUrl = settingsStore.gatewayUrl as string;
|
||||||
|
const gatewayToken = settingsStore.gatewayToken as string;
|
||||||
|
const isIos = !!(window as unknown as { Capacitor?: { isNativePlatform?: () => boolean } })
|
||||||
|
.Capacitor?.isNativePlatform?.();
|
||||||
|
logStore("connect:settings_ready", {
|
||||||
|
gatewayUrl,
|
||||||
|
hasGatewayToken: Boolean(gatewayToken),
|
||||||
|
transportMode: isIos ? "ios-native" : "gateway"
|
||||||
|
});
|
||||||
|
|
||||||
|
transport = createTransport(
|
||||||
|
isIos ? "ios-native" : "gateway",
|
||||||
|
{ gatewayUrl, gatewayToken }
|
||||||
|
);
|
||||||
|
logStore("transport:created", { transportMode: isIos ? "ios-native" : "gateway" });
|
||||||
|
bindTransport(transport);
|
||||||
|
|
||||||
|
const currentTransport = transport;
|
||||||
|
|
||||||
|
try {
|
||||||
|
logStore("connect:transport_connect_begin");
|
||||||
|
await currentTransport.connect(params);
|
||||||
|
logStore("connect:transport_connect_resolved");
|
||||||
|
|
||||||
|
// If disconnected or transport changed during await
|
||||||
|
if (state.value === "disconnected" || transport !== currentTransport) {
|
||||||
|
logStore("connect:stale_result_ignored", { state: state.value, transportChanged: transport !== currentTransport });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
machine.tryTransition("auth_pending");
|
||||||
|
state.value = machine.state;
|
||||||
|
// init 帧会携带首个 pty 尺寸,这里记录为“远端当前尺寸”基线,避免后续重复下发。
|
||||||
|
lastRemoteSize = { cols: params.cols, rows: params.rows };
|
||||||
|
logStore("state:update", { machineState: machine.state, state: state.value });
|
||||||
|
} catch (err) {
|
||||||
|
if (state.value === "disconnected" || transport !== currentTransport) {
|
||||||
|
logStore("connect:error_ignored_after_disconnect", { state: state.value, transportChanged: transport !== currentTransport, err });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
machine.tryTransition("error");
|
||||||
|
state.value = machine.state;
|
||||||
|
logStore("state:update", { machineState: machine.state, state: state.value, err });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnect(reason = "manual"): void {
|
||||||
|
logStore("disconnect:requested", { reason, state: state.value });
|
||||||
|
if (transport) {
|
||||||
|
void transport.disconnect(reason).catch((err) => {
|
||||||
|
logStore("disconnect:transport_failed", { reason, err: String(err) });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
offTransport?.();
|
||||||
|
offTransport = null;
|
||||||
|
transport = null;
|
||||||
|
if (resizeTimer !== null) {
|
||||||
|
window.clearTimeout(resizeTimer);
|
||||||
|
resizeTimer = null;
|
||||||
|
}
|
||||||
|
pendingResize = null;
|
||||||
|
latestLocalSize = null;
|
||||||
|
lastRemoteSize = null;
|
||||||
|
remoteResizePaused = false;
|
||||||
|
if (reason === "manual") {
|
||||||
|
clearBoundOutputCache();
|
||||||
|
}
|
||||||
|
machine.tryTransition("disconnected");
|
||||||
|
state.value = machine.state;
|
||||||
|
logStore("state:update", { machineState: machine.state, state: state.value });
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendInput(data: string, source: "keyboard" | "assist" | "paste" = "keyboard"): void {
|
||||||
|
if (!transport || state.value !== "connected") {
|
||||||
|
console.log(`[TerminalStore][${new Date().toISOString()}] stdin:dropped`, {
|
||||||
|
source,
|
||||||
|
length: data.length,
|
||||||
|
hasTransport: Boolean(transport),
|
||||||
|
state: state.value
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentTransport = transport;
|
||||||
|
// 统一行为:回车/换行、Ctrl+C、Ctrl+D、粘贴/辅助输入都强制回到底部。
|
||||||
|
const shouldForceFollow =
|
||||||
|
data.includes("\r") ||
|
||||||
|
data.includes("\n") ||
|
||||||
|
data.includes("\u0003") ||
|
||||||
|
data.includes("\u0004") ||
|
||||||
|
source !== "keyboard";
|
||||||
|
if (shouldForceFollow) {
|
||||||
|
renderer.value?.followBottom();
|
||||||
|
}
|
||||||
|
// 网关现网 schema 在 stdin 的 union 校验上存在差异:
|
||||||
|
// - 部分环境不接受 meta.source = "paste";
|
||||||
|
// - 部分环境对 paste 分支携带 meta 也会触发 invalid_union。
|
||||||
|
// 因此 paste 统一走“无 meta”发送,keyboard/assist 保持原语义。
|
||||||
|
const wireSource: "keyboard" | "assist" = source === "paste" ? "keyboard" : source;
|
||||||
|
const wireMeta = source === "paste" ? undefined : { source: wireSource };
|
||||||
|
// 大文本(尤其粘贴)分片发送,避免单帧过大触发网关/WS 断连。
|
||||||
|
const chunkSize = source === "paste" ? PASTE_CHUNK_SIZE : DEFAULT_CHUNK_SIZE;
|
||||||
|
const totalChunks = Math.ceil(data.length / chunkSize);
|
||||||
|
if (source === "paste") {
|
||||||
|
console.log(`[TerminalStore][${new Date().toISOString()}] stdin:dispatch`, {
|
||||||
|
source,
|
||||||
|
wireSource,
|
||||||
|
wireMetaSource: wireMeta?.source ?? "none",
|
||||||
|
length: data.length,
|
||||||
|
chunkSize,
|
||||||
|
totalChunks
|
||||||
|
});
|
||||||
|
}
|
||||||
|
logStore("stdin:dispatch", { source, length: data.length, chunkSize, totalChunks });
|
||||||
|
if (data.length <= chunkSize) {
|
||||||
|
void currentTransport.send(data, wireMeta).catch((err) => {
|
||||||
|
console.error(`[TerminalStore][${new Date().toISOString()}] stdin:send_failed`, { source, index: 1, totalChunks: 1, length: data.length, err: String(err) });
|
||||||
|
logStore("stdin:send_failed", { source, index: 1, totalChunks: 1, length: data.length, err: String(err) });
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < data.length; i += chunkSize) {
|
||||||
|
const chunk = data.slice(i, i + chunkSize);
|
||||||
|
const index = i / chunkSize + 1;
|
||||||
|
if (source === "paste") {
|
||||||
|
console.log(`[TerminalStore][${new Date().toISOString()}] stdin:chunk`, { source, index, totalChunks, length: chunk.length });
|
||||||
|
}
|
||||||
|
logStore("stdin:chunk", { source, index, totalChunks, length: chunk.length });
|
||||||
|
void currentTransport.send(chunk, wireMeta).catch((err) => {
|
||||||
|
console.error(`[TerminalStore][${new Date().toISOString()}] stdin:send_failed`, { source, index, totalChunks, length: chunk.length, err: String(err) });
|
||||||
|
logStore("stdin:send_failed", { source, index, totalChunks, length: chunk.length, err: String(err) });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearTerminal(): void {
|
||||||
|
if (transport && state.value === "connected") {
|
||||||
|
// 连接态使用 Ctrl+L,让远端 shell 清屏并重绘提示符,避免提示符消失。
|
||||||
|
sendInput("\x0c", "keyboard");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未连接时执行本地硬清屏。
|
||||||
|
buffer.clear();
|
||||||
|
renderer.value?.write("\x1b[2J\x1b[H");
|
||||||
|
persistOutputCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameSize(
|
||||||
|
a: { cols: number; rows: number } | null,
|
||||||
|
b: { cols: number; rows: number } | null
|
||||||
|
): boolean {
|
||||||
|
return Boolean(a && b && a.cols === b.cols && a.rows === b.rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安排一次“向远端 PTY 下发尺寸”的去抖任务。
|
||||||
|
* - 仅连接态生效;
|
||||||
|
* - 若尺寸与最近成功下发的一致则跳过,避免无效回刷;
|
||||||
|
* - 任务触发时若处于 paused 状态则直接丢弃,由恢复流程统一补发最终尺寸。
|
||||||
|
*/
|
||||||
|
function scheduleRemoteResize(cols: number, rows: number): void {
|
||||||
|
pendingResize = { cols, rows };
|
||||||
|
if (resizeTimer !== null) {
|
||||||
|
window.clearTimeout(resizeTimer);
|
||||||
|
resizeTimer = null;
|
||||||
|
}
|
||||||
|
resizeTimer = window.setTimeout(() => {
|
||||||
|
resizeTimer = null;
|
||||||
|
if (remoteResizePaused) return;
|
||||||
|
if (!pendingResize) return;
|
||||||
|
const next = pendingResize;
|
||||||
|
pendingResize = null;
|
||||||
|
if (!transport || state.value !== "connected") return;
|
||||||
|
if (isSameSize(lastRemoteSize, next)) {
|
||||||
|
logStore("pty:resize_skipped_same", { cols: next.cols, rows: next.rows });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void transport.resize(next.cols, next.rows)
|
||||||
|
.then(() => {
|
||||||
|
lastRemoteSize = next;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
logStore("pty:resize_failed", { cols: next.cols, rows: next.rows, state: state.value, err: String(err) });
|
||||||
|
});
|
||||||
|
}, RESIZE_DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 控制“远端 PTY resize”是否暂停。
|
||||||
|
* 典型用途:软键盘动画期间只做本地 resize,避免远端收到中间尺寸后回刷覆盖滚动恢复。
|
||||||
|
*/
|
||||||
|
function setRemoteResizePaused(paused: boolean): void {
|
||||||
|
if (remoteResizePaused === paused) return;
|
||||||
|
remoteResizePaused = paused;
|
||||||
|
logStore("pty:resize_pause_changed", { paused });
|
||||||
|
|
||||||
|
if (paused) {
|
||||||
|
// 进入暂停态时清空未发送任务,防止中间尺寸被发到远端。
|
||||||
|
if (resizeTimer !== null) {
|
||||||
|
window.clearTimeout(resizeTimer);
|
||||||
|
resizeTimer = null;
|
||||||
|
}
|
||||||
|
pendingResize = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 退出暂停态后补发当前最新本地尺寸(若与远端一致会被 schedule 自动跳过)。
|
||||||
|
if (!transport || state.value !== "connected") return;
|
||||||
|
if (!latestLocalSize) return;
|
||||||
|
scheduleRemoteResize(latestLocalSize.cols, latestLocalSize.rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在指定时间窗口内临时抑制 “stdout/stderr 触发的 followBottom”。
|
||||||
|
* 用于软键盘收起后的滚动恢复保护,避免远端 resize 回刷覆盖恢复位置。
|
||||||
|
*/
|
||||||
|
function suppressAutoFollow(ms: number): void {
|
||||||
|
const nextUntil = Date.now() + Math.max(0, ms);
|
||||||
|
// ms=0 时强制重置(键盘关闭时清除之前设置的长窗口)
|
||||||
|
if (ms > 0 && nextUntil <= suppressAutoFollowUntil) return;
|
||||||
|
suppressAutoFollowUntil = nextUntil;
|
||||||
|
console.log(`[TerminalStore][suppressAutoFollow] ms=${ms} until=${ms === 0 ? "CLEARED" : new Date(suppressAutoFollowUntil).toISOString()}`);
|
||||||
|
logStore("follow:suppressed", { ms, until: suppressAutoFollowUntil });
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizeTerminal(cols: number, rows: number): void {
|
||||||
|
renderer.value?.resize(cols, rows);
|
||||||
|
latestLocalSize = { cols, rows };
|
||||||
|
if (!transport) return;
|
||||||
|
// 仅连接态下向网关发送 resize,避免连接未建立时出现未处理 Promise 拒绝。
|
||||||
|
if (state.value !== "connected") return;
|
||||||
|
if (remoteResizePaused) {
|
||||||
|
logStore("pty:resize_paused_skip", { cols, rows });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 仅对“发给远端 PTY”的 resize 去抖:
|
||||||
|
// - 软键盘动画/视口抖动期间会短时间出现多次尺寸跳变(例如 19 -> 12 -> 19);
|
||||||
|
// - 直接逐次下发会触发远端多次重排,导致提示符位置抖动;
|
||||||
|
// - 本地渲染仍即时 resize,保证交互跟手;远端仅发送稳定后的最终尺寸。
|
||||||
|
scheduleRemoteResize(cols, rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取当前活跃 TerminalCore(用于 cursorMath、测量等)。 */
|
||||||
|
function getCore() {
|
||||||
|
return renderer.value?.getCore() ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取光标行距输出区顶部的像素偏移。
|
||||||
|
* 用于软键盘弹出时定位提示符行。
|
||||||
|
*/
|
||||||
|
function getCursorOffsetPx(): number | null {
|
||||||
|
return renderer.value?.getCursorOffsetPx() ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取输出滚动容器 DOM 元素。
|
||||||
|
*/
|
||||||
|
function getOutputEl(): HTMLElement | null {
|
||||||
|
return renderer.value?.getOutputEl() ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置输出区是否自动跟随滚动到底部。
|
||||||
|
*/
|
||||||
|
function setAutoFollow(enabled: boolean): void {
|
||||||
|
renderer.value?.setAutoFollow(enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 冻结 scroll 事件对 autoFollow 的影响,持续 ms 毫秒(iOS scroll 事件是异步的)。
|
||||||
|
* ms=0 立即解冻。
|
||||||
|
*/
|
||||||
|
function setScrollLocked(ms: number): void {
|
||||||
|
renderer.value?.setScrollLocked(ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 响应式
|
||||||
|
state,
|
||||||
|
title,
|
||||||
|
latencyMs,
|
||||||
|
rendererMode,
|
||||||
|
renderer,
|
||||||
|
// 方法
|
||||||
|
mountRenderer,
|
||||||
|
switchRenderer,
|
||||||
|
bindOutputCache,
|
||||||
|
clearBoundOutputCache,
|
||||||
|
connect,
|
||||||
|
disconnect,
|
||||||
|
sendInput,
|
||||||
|
clearTerminal,
|
||||||
|
resizeTerminal,
|
||||||
|
setRemoteResizePaused,
|
||||||
|
suppressAutoFollow,
|
||||||
|
getCore,
|
||||||
|
getCursorOffsetPx,
|
||||||
|
getOutputEl,
|
||||||
|
setAutoFollow,
|
||||||
|
setScrollLocked,
|
||||||
|
};
|
||||||
|
});
|
||||||
81
terminal/apps/web/src/utils/feedback.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
function asMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return String(error.message || "");
|
||||||
|
}
|
||||||
|
if (typeof error === "string") {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.stringify(error);
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeText(input: string): string {
|
||||||
|
return input.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toFriendlyConnectionError(error: unknown): string {
|
||||||
|
const message = asMessage(error);
|
||||||
|
const lower = normalizeText(message);
|
||||||
|
|
||||||
|
if (lower.includes("rate_limit") || message.includes("连接过于频繁")) {
|
||||||
|
return "连接过于频繁,请稍后重试。";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lower.includes("auth_failed") || message.includes("token 无效")) {
|
||||||
|
return "网关鉴权失败,请联系管理员检查网关令牌。";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.includes("SSH 认证失败")) {
|
||||||
|
return "SSH 认证失败。请检查账号/凭据。";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.includes("Timed out while waiting for handshake") || message.includes("连接超时") || lower.includes("timeout")) {
|
||||||
|
return "连接超时。请检查服务器地址、端口和网络连通性。";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.includes("无法连接网关") || lower.includes("ws_closed") || lower.includes("websocket")) {
|
||||||
|
return "无法连接网关,请检查网关地址、服务状态与网络策略。";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
return "连接失败,请稍后重试。";
|
||||||
|
}
|
||||||
|
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toFriendlyError(error: unknown): string {
|
||||||
|
const message = asMessage(error);
|
||||||
|
const lower = normalizeText(message);
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
return "操作失败,请稍后重试。";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
lower.includes("ws_") ||
|
||||||
|
lower.includes("websocket") ||
|
||||||
|
lower.includes("auth_failed") ||
|
||||||
|
lower.includes("rate_limit") ||
|
||||||
|
message.includes("连接") ||
|
||||||
|
message.includes("网关") ||
|
||||||
|
message.includes("SSH")
|
||||||
|
) {
|
||||||
|
return toFriendlyConnectionError(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.includes("会话未连接")) {
|
||||||
|
return "当前会话未连接,请先建立连接。";
|
||||||
|
}
|
||||||
|
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatActionError(action: string, error: unknown): string {
|
||||||
|
const detail = toFriendlyError(error);
|
||||||
|
return `${action}:${detail}`;
|
||||||
|
}
|
||||||
|
|
||||||
31
terminal/apps/web/src/utils/runtimeConfig.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
export interface RuntimeConfig {
|
||||||
|
gatewayUrl?: string;
|
||||||
|
gatewayToken?: string;
|
||||||
|
selectedServerId?: string;
|
||||||
|
servers?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
let runtimeConfigPromise: Promise<RuntimeConfig | null> | null = null;
|
||||||
|
|
||||||
|
export async function loadRuntimeConfig(): Promise<RuntimeConfig | null> {
|
||||||
|
if (runtimeConfigPromise) return runtimeConfigPromise;
|
||||||
|
runtimeConfigPromise = (async () => {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
try {
|
||||||
|
const response = await fetch("/terminal.config.json", { cache: "no-store" });
|
||||||
|
if (!response.ok) return null;
|
||||||
|
const raw = await response.json() as unknown;
|
||||||
|
if (!raw || typeof raw !== "object") return null;
|
||||||
|
const obj = raw as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
gatewayUrl: typeof obj.gatewayUrl === "string" ? obj.gatewayUrl : undefined,
|
||||||
|
gatewayToken: typeof obj.gatewayToken === "string" ? obj.gatewayToken : undefined,
|
||||||
|
selectedServerId: typeof obj.selectedServerId === "string" ? obj.selectedServerId : undefined,
|
||||||
|
servers: obj.servers
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return runtimeConfigPromise;
|
||||||
|
}
|
||||||
19
terminal/apps/web/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.vue", "vitest.config.ts", "vite.config.ts"]
|
||||||
|
}
|
||||||
53
terminal/apps/web/vite.config.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { fileURLToPath, URL } from "node:url";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
import vue from "@vitejs/plugin-vue";
|
||||||
|
|
||||||
|
const DEV_HOST = "0.0.0.0";
|
||||||
|
const DEV_PORT = 5173;
|
||||||
|
const DEV_PUBLIC_HOST = "shell.biboer.cn";
|
||||||
|
const DEV_CERT_PATH = "/Users/gavin/.acme.sh/shell.biboer.cn_ecc/fullchain.cer";
|
||||||
|
const DEV_KEY_PATH = "/Users/gavin/.acme.sh/shell.biboer.cn_ecc/shell.biboer.cn.key";
|
||||||
|
|
||||||
|
function resolveDevHttpsConfig() {
|
||||||
|
// 优先复用 acme.sh 证书,确保本地开发服务直接以 HTTPS 暴露。
|
||||||
|
if (!fs.existsSync(DEV_CERT_PATH) || !fs.existsSync(DEV_KEY_PATH)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cert: fs.readFileSync(DEV_CERT_PATH),
|
||||||
|
key: fs.readFileSync(DEV_KEY_PATH)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
server: {
|
||||||
|
host: DEV_HOST,
|
||||||
|
port: DEV_PORT,
|
||||||
|
strictPort: true,
|
||||||
|
https: resolveDevHttpsConfig(),
|
||||||
|
// 允许通过外网域名访问 dev server(含 HMR websocket 握手)。
|
||||||
|
allowedHosts: [DEV_PUBLIC_HOST],
|
||||||
|
// 明确 HMR 走外网域名,避免客户端回退到 localhost 导致连接拒绝。
|
||||||
|
hmr: {
|
||||||
|
protocol: "wss",
|
||||||
|
host: DEV_PUBLIC_HOST,
|
||||||
|
clientPort: DEV_PORT,
|
||||||
|
port: DEV_PORT
|
||||||
|
},
|
||||||
|
proxy: {
|
||||||
|
"/ws/terminal": {
|
||||||
|
target: "ws://127.0.0.1:8787",
|
||||||
|
ws: true,
|
||||||
|
changeOrigin: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": fileURLToPath(new URL("./src", import.meta.url))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
11
terminal/apps/web/vitest.config.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig, mergeConfig } from "vitest/config";
|
||||||
|
import viteConfig from "./vite.config";
|
||||||
|
|
||||||
|
export default mergeConfig(
|
||||||
|
viteConfig,
|
||||||
|
defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ["src/**/*.test.ts"],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
1054
terminal/docs/xterm-standalone-lab-plan-2026-03-01.md
Normal file
3327
terminal/package-lock.json
generated
Normal file
14
terminal/package.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "terminal-workspace",
|
||||||
|
"private": true,
|
||||||
|
"workspaces": [
|
||||||
|
"apps/web",
|
||||||
|
"packages/terminal-core"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"dev": "npm run dev -w apps/web",
|
||||||
|
"build": "npm run build -w packages/terminal-core && npm run build -w apps/web",
|
||||||
|
"test": "npm run test -w packages/terminal-core",
|
||||||
|
"typecheck": "npm run typecheck -w packages/terminal-core && npm run typecheck -w apps/web"
|
||||||
|
}
|
||||||
|
}
|
||||||
26
terminal/packages/terminal-core/package.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "@remoteconn/terminal-core",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"types": "src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"miniprogram": "dist-miniprogram/index.cjs",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"build:miniprogram": "tsc -p tsconfig.json --module commonjs --moduleResolution node --outDir dist-miniprogram --declaration false --sourceMap false",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"typecheck:strict": "tsc -p tsconfig.json --lib esnext --noEmit",
|
||||||
|
"lint": "eslint src --ext .ts",
|
||||||
|
"test": "../../node_modules/.bin/vitest run --passWithNoTests"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^25.3.3",
|
||||||
|
"eslint": "^10.0.2",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vitest": "^4.0.18"
|
||||||
|
}
|
||||||
|
}
|
||||||
67
terminal/packages/terminal-core/src/index.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* @remoteconn/terminal-core
|
||||||
|
* 零 DOM 跨平台终端核心库 — 供 Web/iOS/微信小程序三端共用。
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── 基础类型 ──────────────────────────────────────────────────────────────────
|
||||||
|
export type {
|
||||||
|
SessionState,
|
||||||
|
ConnectParams,
|
||||||
|
TerminalCredential,
|
||||||
|
FrameMeta,
|
||||||
|
TransportEvent,
|
||||||
|
TransportEventListener,
|
||||||
|
ColorValue,
|
||||||
|
CharAttribs,
|
||||||
|
TerminalCell,
|
||||||
|
TerminalLine,
|
||||||
|
CursorState,
|
||||||
|
TerminalSnapshot,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export {
|
||||||
|
DEFAULT_FG,
|
||||||
|
DEFAULT_BG,
|
||||||
|
DEFAULT_COLOR,
|
||||||
|
FLAG_BOLD,
|
||||||
|
FLAG_DIM,
|
||||||
|
FLAG_ITALIC,
|
||||||
|
FLAG_UNDERLINE,
|
||||||
|
FLAG_BLINK,
|
||||||
|
FLAG_INVERSE,
|
||||||
|
FLAG_INVISIBLE,
|
||||||
|
FLAG_STRIKETHROUGH,
|
||||||
|
FLAG_OVERLINE,
|
||||||
|
makeDefaultAttribs,
|
||||||
|
copyAttribs,
|
||||||
|
makeBlankCell,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
// ── 接口 ──────────────────────────────────────────────────────────────────────
|
||||||
|
export type { TerminalTransport } from "./transport/terminalTransport";
|
||||||
|
export type { RendererAdapter } from "./renderer/rendererAdapter";
|
||||||
|
export type { IInputSource, InputEventMap, KeyPayload, InputPayload, PastePayload, CompositionPayload } from "./input/IInputSource";
|
||||||
|
export type { IMeasureAdapter } from "./layout/IMeasureAdapter";
|
||||||
|
|
||||||
|
// ── Session ───────────────────────────────────────────────────────────────────
|
||||||
|
export { SessionMachine, canTransition, assertTransition } from "./session/sessionMachine";
|
||||||
|
|
||||||
|
// ── Core ──────────────────────────────────────────────────────────────────────
|
||||||
|
export { TerminalCore } from "./renderer/terminalCore";
|
||||||
|
export { OutputBuffer } from "./renderer/outputBuffer";
|
||||||
|
export type { OutputBufferOptions } from "./renderer/outputBuffer";
|
||||||
|
|
||||||
|
// ── Input ─────────────────────────────────────────────────────────────────────
|
||||||
|
export { InputBridge } from "./input/inputBridge";
|
||||||
|
export type { InputBridgeOptions, CursorKeyMode } from "./input/inputBridge";
|
||||||
|
export { ImeController } from "./input/imeController";
|
||||||
|
export type { ImeState } from "./input/imeController";
|
||||||
|
|
||||||
|
// ── Layout ────────────────────────────────────────────────────────────────────
|
||||||
|
export { calcSize, sizeChanged } from "./layout/sizeCalculator";
|
||||||
|
export type { SizeResult } from "./layout/sizeCalculator";
|
||||||
|
export { toGlobalRow, isInBand, textCursorPos, charDisplayWidth } from "./layout/cursorMath";
|
||||||
|
export type { CursorPos } from "./layout/cursorMath";
|
||||||
|
|
||||||
|
// ── Sanitize ──────────────────────────────────────────────────────────────────
|
||||||
|
export { sanitizeTerminalOutput } from "./sanitize/terminalSanitizer";
|
||||||
42
terminal/packages/terminal-core/src/input/IInputSource.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* IInputSource — 平台输入事件源抽象接口(Web DOM / 小程序 bindinput)。
|
||||||
|
* 所有实现在 apps/* 中,packages/terminal-core 只定义接口。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface KeyPayload {
|
||||||
|
key: string;
|
||||||
|
code: string;
|
||||||
|
ctrlKey: boolean;
|
||||||
|
altKey: boolean;
|
||||||
|
shiftKey: boolean;
|
||||||
|
metaKey: boolean;
|
||||||
|
isComposing: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InputPayload {
|
||||||
|
data: string;
|
||||||
|
isComposing: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PastePayload {
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompositionPayload {
|
||||||
|
data: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InputEventMap = {
|
||||||
|
key: KeyPayload;
|
||||||
|
input: InputPayload;
|
||||||
|
paste: PastePayload;
|
||||||
|
compositionstart: CompositionPayload;
|
||||||
|
compositionend: CompositionPayload;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface IInputSource {
|
||||||
|
on<K extends keyof InputEventMap>(
|
||||||
|
event: K,
|
||||||
|
cb: (payload: InputEventMap[K]) => void
|
||||||
|
): () => void;
|
||||||
|
}
|
||||||
81
terminal/packages/terminal-core/src/input/imeController.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* ImeController — IME 输入法状态机(纯逻辑,零 DOM 事件依赖)。
|
||||||
|
*
|
||||||
|
* 状态:
|
||||||
|
* idle → composing(compositionstart)
|
||||||
|
* composing → commit_pending(compositionend)
|
||||||
|
* commit_pending → idle(提交完成或超时)
|
||||||
|
*
|
||||||
|
* 平台层(Web/小程序)负责订阅 compositionstart/end 后调用此 controller 的方法。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ImeState = "idle" | "composing" | "commit_pending";
|
||||||
|
|
||||||
|
export class ImeController {
|
||||||
|
private _state: ImeState = "idle";
|
||||||
|
/** 当前组合字符串 */
|
||||||
|
private _composingData = "";
|
||||||
|
/** 防止 compositionend 后对应的 input 事件重复提交的保护窗口(ms) */
|
||||||
|
private _commitGuardUntil = 0;
|
||||||
|
/** 超时守卫 timer(无 DOM API — 由平台层通过注入的 setTimeoutFn 驱动) */
|
||||||
|
private _guardTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly setTimeoutFn: (fn: () => void, ms: number) => ReturnType<typeof setTimeout>,
|
||||||
|
private readonly clearTimeoutFn: (id: ReturnType<typeof setTimeout>) => void,
|
||||||
|
/** 超时自动复位(ms),默认 2000 */
|
||||||
|
private readonly timeoutMs = 2000
|
||||||
|
) {}
|
||||||
|
|
||||||
|
get state(): ImeState { return this._state; }
|
||||||
|
get composingData(): string { return this._composingData; }
|
||||||
|
get isComposing(): boolean { return this._state !== "idle"; }
|
||||||
|
|
||||||
|
onCompositionStart(data: string): void {
|
||||||
|
this._clearGuard();
|
||||||
|
this._state = "composing";
|
||||||
|
this._composingData = data;
|
||||||
|
// 安全超时:若 compositionend 迟迟不来,自动复位
|
||||||
|
this._guardTimer = this.setTimeoutFn(() => {
|
||||||
|
if (this._state !== "idle") {
|
||||||
|
this._state = "idle";
|
||||||
|
this._composingData = "";
|
||||||
|
}
|
||||||
|
}, this.timeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
onCompositionEnd(data: string): string {
|
||||||
|
this._clearGuard();
|
||||||
|
this._state = "commit_pending";
|
||||||
|
this._composingData = data;
|
||||||
|
// 设置提交保护窗口(防 input 事件重复触发)
|
||||||
|
this._commitGuardUntil = Date.now() + 50;
|
||||||
|
const committed = data;
|
||||||
|
this._state = "idle";
|
||||||
|
this._composingData = "";
|
||||||
|
return committed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断当前 input 事件是否应被 IME 消耗(不应单独发送给 Transport)。
|
||||||
|
*/
|
||||||
|
shouldConsumeInputEvent(): boolean {
|
||||||
|
if (this._state === "composing") return true;
|
||||||
|
if (Date.now() < this._commitGuardUntil) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this._clearGuard();
|
||||||
|
this._state = "idle";
|
||||||
|
this._composingData = "";
|
||||||
|
this._commitGuardUntil = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _clearGuard(): void {
|
||||||
|
if (this._guardTimer !== null) {
|
||||||
|
this.clearTimeoutFn(this._guardTimer);
|
||||||
|
this._guardTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { InputBridge } from "./inputBridge";
|
||||||
|
|
||||||
|
describe("InputBridge.mapPaste", () => {
|
||||||
|
it("统一换行为 LF,避免将多行粘贴转换为 CR", () => {
|
||||||
|
const bridge = new InputBridge({ bracketedPaste: false });
|
||||||
|
const input = "line1\r\nline2\nline3\rline4";
|
||||||
|
expect(bridge.mapPaste(input)).toBe("line1\nline2\nline3\nline4");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("开启 bracketed paste 时包裹 ESC[200~/ESC[201~", () => {
|
||||||
|
const bridge = new InputBridge({ bracketedPaste: true });
|
||||||
|
expect(bridge.mapPaste("a\r\nb")).toBe("\x1b[200~a\nb\x1b[201~");
|
||||||
|
});
|
||||||
|
});
|
||||||
173
terminal/packages/terminal-core/src/input/inputBridge.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
/**
|
||||||
|
* InputBridge — 键序列映射纯逻辑(零 DOM 事件依赖)。
|
||||||
|
*
|
||||||
|
* 负责将逻辑按键/文本映射为发送给 Transport 的 VT 字节串。
|
||||||
|
* 对齐 xterm.js@5.3.0 src/common/input/Keyboard.ts 行为。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 应用光标键模式(DECCKM ?1h 激活时使用 SS3 序列) */
|
||||||
|
export type CursorKeyMode = "normal" | "application";
|
||||||
|
/** bracketed paste 模式 */
|
||||||
|
export type BracketedPasteMode = boolean;
|
||||||
|
|
||||||
|
export interface InputBridgeOptions {
|
||||||
|
cursorKeyMode?: CursorKeyMode;
|
||||||
|
bracketedPaste?: BracketedPasteMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InputBridge {
|
||||||
|
private cursorKeyMode: CursorKeyMode;
|
||||||
|
private bracketedPaste: BracketedPasteMode;
|
||||||
|
|
||||||
|
constructor(opts: InputBridgeOptions = {}) {
|
||||||
|
this.cursorKeyMode = opts.cursorKeyMode ?? "normal";
|
||||||
|
this.bracketedPaste = opts.bracketedPaste ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCursorKeyMode(mode: CursorKeyMode): void { this.cursorKeyMode = mode; }
|
||||||
|
setBracketedPaste(enabled: boolean): void { this.bracketedPaste = enabled; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 KeyboardEvent 语义映射为 VT 字节串。
|
||||||
|
* 返回 null 表示该按键不产生终端输入(如单纯 modifier key)。
|
||||||
|
*/
|
||||||
|
mapKey(
|
||||||
|
key: string,
|
||||||
|
code: string,
|
||||||
|
ctrlKey: boolean,
|
||||||
|
altKey: boolean,
|
||||||
|
shiftKey: boolean,
|
||||||
|
metaKey: boolean
|
||||||
|
): string | null {
|
||||||
|
// --- ASCII 控制字符 ---
|
||||||
|
if (ctrlKey && !altKey && !metaKey) {
|
||||||
|
const ctrl = mapCtrlKey(key, code, shiftKey);
|
||||||
|
if (ctrl !== null) return ctrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 功能键 ---
|
||||||
|
const fn = mapFunctionKey(key, code, shiftKey, ctrlKey, altKey, this.cursorKeyMode);
|
||||||
|
if (fn !== null) return fn;
|
||||||
|
|
||||||
|
// --- Alt/Meta 前缀 ---
|
||||||
|
if ((altKey || metaKey) && key.length === 1) {
|
||||||
|
return `\x1b${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 普通可打印字符 ---
|
||||||
|
if (key.length > 0 && !key.startsWith("Dead") && key !== "Unidentified") {
|
||||||
|
// 过滤掉单纯的 modifier key 名称
|
||||||
|
if (MODIFIER_KEY_NAMES.has(key)) return null;
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将粘贴文本处理为终端输入字节串:
|
||||||
|
* - 行结束统一归一为 LF(避免多行粘贴被当作多次回车)
|
||||||
|
* - bracketed paste 模式下用 ESC[200~ / ESC[201~ 包裹
|
||||||
|
*/
|
||||||
|
mapPaste(text: string): string {
|
||||||
|
// 统一处理 CRLF / CR / LF,确保多平台粘贴行为一致。
|
||||||
|
const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||||
|
if (this.bracketedPaste) {
|
||||||
|
return `\x1b[200~${normalized}\x1b[201~`;
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 内部工具 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const MODIFIER_KEY_NAMES = new Set([
|
||||||
|
"Control", "Shift", "Alt", "Meta",
|
||||||
|
"CapsLock", "NumLock", "ScrollLock",
|
||||||
|
"OS", "Win",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function mapCtrlKey(key: string, _code: string, shiftKey: boolean): string | null {
|
||||||
|
// CTRL + A-Z → 0x01-0x1A
|
||||||
|
if (key.length === 1) {
|
||||||
|
const upper = key.toUpperCase();
|
||||||
|
const cp = upper.charCodeAt(0);
|
||||||
|
if (cp >= 65 && cp <= 90) {
|
||||||
|
return String.fromCharCode(cp - 64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 特殊组合
|
||||||
|
switch (key) {
|
||||||
|
case "@": return "\x00";
|
||||||
|
case "[": return "\x1b";
|
||||||
|
case "\\": return "\x1c";
|
||||||
|
case "]": return "\x1d";
|
||||||
|
case "^": return "\x1e";
|
||||||
|
case "_": return "\x1f";
|
||||||
|
case " ": return "\x00";
|
||||||
|
case "Enter": return shiftKey ? "\n" : "\r";
|
||||||
|
case "Backspace": return "\x08";
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapFunctionKey(
|
||||||
|
key: string,
|
||||||
|
_code: string,
|
||||||
|
shiftKey: boolean,
|
||||||
|
ctrlKey: boolean,
|
||||||
|
_altKey: boolean,
|
||||||
|
cursorKeyMode: CursorKeyMode
|
||||||
|
): string | null {
|
||||||
|
const app = cursorKeyMode === "application";
|
||||||
|
|
||||||
|
switch (key) {
|
||||||
|
// --- 光标键 ---
|
||||||
|
case "ArrowUp": return modifyArrow(app ? "\x1bOA" : "\x1b[A", shiftKey, ctrlKey);
|
||||||
|
case "ArrowDown": return modifyArrow(app ? "\x1bOB" : "\x1b[B", shiftKey, ctrlKey);
|
||||||
|
case "ArrowRight": return modifyArrow(app ? "\x1bOC" : "\x1b[C", shiftKey, ctrlKey);
|
||||||
|
case "ArrowLeft": return modifyArrow(app ? "\x1bOD" : "\x1b[D", shiftKey, ctrlKey);
|
||||||
|
|
||||||
|
// --- Enter / Backspace / Tab ---
|
||||||
|
case "Enter": return "\r";
|
||||||
|
case "Backspace": return ctrlKey ? "\x08" : "\x7f";
|
||||||
|
case "Tab": return shiftKey ? "\x1b[Z" : "\t";
|
||||||
|
case "Escape": return "\x1b";
|
||||||
|
case "Delete": return "\x1b[3~";
|
||||||
|
case "Insert": return "\x1b[2~";
|
||||||
|
|
||||||
|
// --- Home / End ---
|
||||||
|
case "Home": return ctrlKey ? "\x1b[1;5H" : app ? "\x1bOH" : "\x1b[H";
|
||||||
|
case "End": return ctrlKey ? "\x1b[1;5F" : app ? "\x1bOF" : "\x1b[F";
|
||||||
|
|
||||||
|
// --- Page Up / Page Down ---
|
||||||
|
case "PageUp": return shiftKey ? "\x1b[5;2~" : "\x1b[5~";
|
||||||
|
case "PageDown": return shiftKey ? "\x1b[6;2~" : "\x1b[6~";
|
||||||
|
|
||||||
|
// --- F1-F12 ---
|
||||||
|
case "F1": return "\x1bOP";
|
||||||
|
case "F2": return "\x1bOQ";
|
||||||
|
case "F3": return "\x1bOR";
|
||||||
|
case "F4": return "\x1bOS";
|
||||||
|
case "F5": return "\x1b[15~";
|
||||||
|
case "F6": return "\x1b[17~";
|
||||||
|
case "F7": return "\x1b[18~";
|
||||||
|
case "F8": return "\x1b[19~";
|
||||||
|
case "F9": return "\x1b[20~";
|
||||||
|
case "F10": return "\x1b[21~";
|
||||||
|
case "F11": return "\x1b[23~";
|
||||||
|
case "F12": return "\x1b[24~";
|
||||||
|
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function modifyArrow(base: string, shiftKey: boolean, ctrlKey: boolean): string {
|
||||||
|
if (ctrlKey && shiftKey) {
|
||||||
|
// replace final letter with modifier param
|
||||||
|
return base.slice(0, -1) + ";6" + base.slice(-1);
|
||||||
|
}
|
||||||
|
if (ctrlKey) return base.slice(0, -1) + ";5" + base.slice(-1);
|
||||||
|
if (shiftKey) return base.slice(0, -1) + ";2" + base.slice(-1);
|
||||||
|
return base;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* IMeasureAdapter — 平台测量接口(字符/容器尺寸获取)。
|
||||||
|
* Web 由 ResizeObserver + Canvas 实现;小程序由 wx.createSelectorQuery 实现。
|
||||||
|
*/
|
||||||
|
export interface IMeasureAdapter {
|
||||||
|
/** 测量单个等宽字符宽/高(像素)*/
|
||||||
|
measureChar(): { widthPx: number; heightPx: number };
|
||||||
|
|
||||||
|
/** 测量终端容器内部可用宽/高(已去除 padding,像素)*/
|
||||||
|
measureContainer(): { widthPx: number; heightPx: number };
|
||||||
|
|
||||||
|
/** 订阅容器尺寸变化;返回取消订阅函数 */
|
||||||
|
onResize(cb: () => void): () => void;
|
||||||
|
}
|
||||||
80
terminal/packages/terminal-core/src/layout/cursorMath.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* cursorMath — 光标坐标纯算法工具。
|
||||||
|
* 零 DOM 依赖。区分"终端光标"(VT core 语义)与"输入光标"(输入框 selectionStart)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface CursorPos { row: number; col: number; }
|
||||||
|
|
||||||
|
/** 将终端光标视口坐标转为全局行号 */
|
||||||
|
export function toGlobalRow(baseY: number, cursorY: number): number {
|
||||||
|
return baseY + cursorY;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断触摸行是否在光标激活带内。
|
||||||
|
* activationRadius 建议 2 行。
|
||||||
|
*/
|
||||||
|
export function isInBand(touchRow: number, cursorGlobalRow: number, activationRadius = 2): boolean {
|
||||||
|
return Math.abs(touchRow - cursorGlobalRow) <= activationRadius;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 textarea 文本推导输入框光标位置((row, col) 坐标,0-based)。
|
||||||
|
* 纯函数,O(n) 扫描文本。
|
||||||
|
*/
|
||||||
|
export function textCursorPos(
|
||||||
|
text: string,
|
||||||
|
selectionStart: number,
|
||||||
|
cols: number
|
||||||
|
): CursorPos {
|
||||||
|
let row = 0;
|
||||||
|
let col = 0;
|
||||||
|
for (let i = 0; i < selectionStart && i < text.length; i++) {
|
||||||
|
const ch = text[i] ?? "";
|
||||||
|
if (ch === "\n") {
|
||||||
|
row++;
|
||||||
|
col = 0;
|
||||||
|
} else {
|
||||||
|
const w = charDisplayWidth(ch);
|
||||||
|
col += w;
|
||||||
|
if (col >= cols) {
|
||||||
|
// 自动折行
|
||||||
|
row += Math.floor(col / cols);
|
||||||
|
col = col % cols;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { row, col };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 字符显示宽度(ASCII=1, CJK/全角=2, 控制字符=0)。
|
||||||
|
* 使用简化的 East Asian Width 判断;完整版可插入 unicode-east-asian-width 库。
|
||||||
|
*/
|
||||||
|
export function charDisplayWidth(ch: string): 0 | 1 | 2 {
|
||||||
|
if (!ch) return 0;
|
||||||
|
const cp = ch.codePointAt(0) ?? 0;
|
||||||
|
if (cp < 0x20 || (cp >= 0x7f && cp < 0xa0)) return 0; // 控制字符
|
||||||
|
if (isWide(cp)) return 2;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWide(cp: number): boolean {
|
||||||
|
// GB2312/CJK 基本覆盖范围
|
||||||
|
if (cp >= 0x1100 && cp <= 0x115F) return true; // Hangul Jamo
|
||||||
|
if (cp === 0x2329 || cp === 0x232A) return true;
|
||||||
|
if (cp >= 0x2E80 && cp <= 0x303E) return true; // CJK Radicals
|
||||||
|
if (cp >= 0x3040 && cp <= 0x33FF) return true; // Japanese
|
||||||
|
if (cp >= 0x3400 && cp <= 0x4DBF) return true; // CJK Ext A
|
||||||
|
if (cp >= 0x4E00 && cp <= 0x9FFF) return true; // CJK Unified
|
||||||
|
if (cp >= 0xA000 && cp <= 0xA4CF) return true; // Yi
|
||||||
|
if (cp >= 0xAC00 && cp <= 0xD7AF) return true; // Hangul Syllables
|
||||||
|
if (cp >= 0xF900 && cp <= 0xFAFF) return true; // CJK Compatibility
|
||||||
|
if (cp >= 0xFE10 && cp <= 0xFE19) return true; // Vertical forms
|
||||||
|
if (cp >= 0xFE30 && cp <= 0xFE6F) return true; // CJK Compatibility Small
|
||||||
|
if (cp >= 0xFF00 && cp <= 0xFF60) return true; // Fullwidth
|
||||||
|
if (cp >= 0xFFE0 && cp <= 0xFFE6) return true;
|
||||||
|
if (cp >= 0x1F300 && cp <= 0x1F9FF) return true; // Emoji
|
||||||
|
if (cp >= 0x20000 && cp <= 0x2FFFD) return true; // CJK Ext B-F
|
||||||
|
return false;
|
||||||
|
}
|
||||||
27
terminal/packages/terminal-core/src/layout/sizeCalculator.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* sizeCalculator — 纯算法:px → cols/rows。
|
||||||
|
* 零 DOM 依赖,所有 DOM 测量由平台的 IMeasureAdapter 注入。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface SizeResult {
|
||||||
|
cols: number;
|
||||||
|
rows: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calcSize(
|
||||||
|
containerWidthPx: number,
|
||||||
|
containerHeightPx: number,
|
||||||
|
charWidthPx: number,
|
||||||
|
lineHeightPx: number
|
||||||
|
): SizeResult {
|
||||||
|
const cols = Math.max(20, Math.floor(containerWidthPx / Math.max(1, charWidthPx)));
|
||||||
|
const rows = Math.max(8, Math.floor(containerHeightPx / Math.max(1, lineHeightPx)));
|
||||||
|
return { cols, rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断尺寸变化是否超过阈值(>= 1 col 或 >= 1 row),避免抖动触发频繁 resize。
|
||||||
|
*/
|
||||||
|
export function sizeChanged(a: SizeResult, b: SizeResult): boolean {
|
||||||
|
return Math.abs(a.cols - b.cols) + Math.abs(a.rows - b.rows) >= 1;
|
||||||
|
}
|
||||||
75
terminal/packages/terminal-core/src/renderer/outputBuffer.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* OutputBuffer — 终端输出缓冲区。
|
||||||
|
* 双阈值(条目数 + 字节数)裁剪,保证低端设备不 OOM。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface OutputBufferOptions {
|
||||||
|
maxEntries?: number; // 默认 500
|
||||||
|
maxBytes?: number; // 默认 512 * 1024 (512KB)
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OutputBuffer {
|
||||||
|
private entries: string[] = [];
|
||||||
|
private totalBytes = 0;
|
||||||
|
private readonly maxEntries: number;
|
||||||
|
private readonly maxBytes: number;
|
||||||
|
|
||||||
|
/** 缓冲区修订号:每次 push 后递增,供外部脏检测 */
|
||||||
|
public revision = 0;
|
||||||
|
|
||||||
|
constructor(opts: OutputBufferOptions = {}) {
|
||||||
|
this.maxEntries = Math.max(200, opts.maxEntries ?? 500);
|
||||||
|
this.maxBytes = Math.max(65536, opts.maxBytes ?? 512 * 1024);
|
||||||
|
}
|
||||||
|
|
||||||
|
push(data: string): void {
|
||||||
|
if (data.length === 0) return;
|
||||||
|
this.entries.push(data);
|
||||||
|
this.totalBytes += data.length * 2; // UTF-16 字符 ≈ 2 字节估算
|
||||||
|
|
||||||
|
this.trim();
|
||||||
|
this.revision++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取全部条目(用于重放) */
|
||||||
|
getAll(): readonly string[] {
|
||||||
|
return this.entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取从指定修订号之后新增的条目(增量重放辅助) */
|
||||||
|
getSince(revision: number): string {
|
||||||
|
// 简化实现:revision 不足时全量重放
|
||||||
|
if (revision <= 0) return this.entries.join("");
|
||||||
|
// 粗略计算需要从后面取多少条目
|
||||||
|
// 这里保守地返回全量;如需精确增量可增加 revision→index 映射表
|
||||||
|
return this.entries.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.entries = [];
|
||||||
|
this.totalBytes = 0;
|
||||||
|
this.revision++;
|
||||||
|
}
|
||||||
|
|
||||||
|
get size(): number {
|
||||||
|
return this.entries.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
get bytes(): number {
|
||||||
|
return this.totalBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private trim(): void {
|
||||||
|
// 先按条目裁剪
|
||||||
|
while (this.entries.length > this.maxEntries) {
|
||||||
|
const removed = this.entries.shift();
|
||||||
|
if (removed !== undefined) this.totalBytes -= removed.length * 2;
|
||||||
|
}
|
||||||
|
// 再按字节裁剪
|
||||||
|
while (this.totalBytes > this.maxBytes && this.entries.length > 1) {
|
||||||
|
const removed = this.entries.shift();
|
||||||
|
if (removed !== undefined) this.totalBytes -= removed.length * 2;
|
||||||
|
}
|
||||||
|
if (this.totalBytes < 0) this.totalBytes = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { TerminalSnapshot } from "../types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RendererAdapter — 渲染层抽象接口(每个平台各自实现)。
|
||||||
|
* 渲染器只读 snapshot,禁止回写 TerminalCore 内部状态。
|
||||||
|
*/
|
||||||
|
export interface RendererAdapter {
|
||||||
|
/**
|
||||||
|
* 挂载到平台容器。
|
||||||
|
* Web: container 为 HTMLElement;
|
||||||
|
* 小程序: 由 terminal-core-view 组件自行管理,传 null。
|
||||||
|
*/
|
||||||
|
mount(container: unknown): void;
|
||||||
|
|
||||||
|
/** 增量写入 VT 字节流(内部转发给 TerminalCore.write,触发重新渲染) */
|
||||||
|
write(data: string): void;
|
||||||
|
|
||||||
|
/** 通知渲染器终端尺寸已变化 */
|
||||||
|
resize(cols: number, rows: number): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全量重放快照(模式切换时调用,将渲染器状态同步到最新 snapshot)。
|
||||||
|
*/
|
||||||
|
applySnapshot(snapshot: TerminalSnapshot): void;
|
||||||
|
|
||||||
|
/** 销毁渲染器,释放 DOM/Canvas/定时器等资源 */
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
1166
terminal/packages/terminal-core/src/renderer/terminalCore.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* TerminalSanitizer — 过滤 stdout/stderr 中会干扰渲染的噪音序列。
|
||||||
|
*
|
||||||
|
* 目前仅过滤同步更新模式(DECSM ?2026 = DECRQM-like sync-update),
|
||||||
|
* 以及其他已知会在某些 SSH 服务端发送但 terminal-core 不处理的序列。
|
||||||
|
* 不得过滤正常 CSI/OSC 序列,否则会破坏 TUI 程序显示。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 同步更新开始/结束序列(DEC private: ?2026h / ?2026l) */
|
||||||
|
const SYNC_UPDATE_RE = /\x1b\[\?2026[hl]/g;
|
||||||
|
/** Bracketed paste 开关(部分链路丢失 ESC 后会残留 '?2004h/l' 文本) */
|
||||||
|
const BRACKETED_PASTE_RE = /(?:\x1b\[)?\?2004[hl]/g;
|
||||||
|
|
||||||
|
/** XTPUSHCOLORS / XTPOPCOLORS(Ps=10/11)—— terminal-core 不处理,安全丢弃 */
|
||||||
|
const XTPUSH_POP_COLORS_RE = /\x1b\[(?:10|11)m/g;
|
||||||
|
|
||||||
|
export function sanitizeTerminalOutput(data: string): string {
|
||||||
|
return data
|
||||||
|
.replace(SYNC_UPDATE_RE, "")
|
||||||
|
.replace(BRACKETED_PASTE_RE, "")
|
||||||
|
.replace(XTPUSH_POP_COLORS_RE, "");
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { SessionState } from "../types";
|
||||||
|
|
||||||
|
/** 合法状态迁移表 */
|
||||||
|
const TRANSITIONS: Record<SessionState, SessionState[]> = {
|
||||||
|
idle: ["connecting", "disconnected"],
|
||||||
|
connecting: ["auth_pending", "error", "disconnected"],
|
||||||
|
auth_pending: ["connected", "error", "disconnected"],
|
||||||
|
connected: ["reconnecting", "disconnected", "error"],
|
||||||
|
reconnecting: ["connected", "error", "disconnected"],
|
||||||
|
disconnected: ["connecting", "idle"],
|
||||||
|
error: ["connecting", "disconnected"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function canTransition(from: SessionState, to: SessionState): boolean {
|
||||||
|
return (TRANSITIONS[from] ?? []).includes(to);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertTransition(from: SessionState, to: SessionState): void {
|
||||||
|
if (!canTransition(from, to)) {
|
||||||
|
throw new Error(`非法状态跳转: ${from} → ${to}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SessionMachine — 会话生命周期状态机。
|
||||||
|
* 纯状态逻辑,无任何副作用,零 DOM 依赖。
|
||||||
|
*/
|
||||||
|
export class SessionMachine {
|
||||||
|
private _state: SessionState = "idle";
|
||||||
|
private listeners = new Set<(state: SessionState) => void>();
|
||||||
|
|
||||||
|
get state(): SessionState {
|
||||||
|
return this._state;
|
||||||
|
}
|
||||||
|
|
||||||
|
transition(to: SessionState): void {
|
||||||
|
assertTransition(this._state, to);
|
||||||
|
this._state = to;
|
||||||
|
for (const fn of this.listeners) {
|
||||||
|
fn(this._state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tryTransition(to: SessionState): boolean {
|
||||||
|
if (!canTransition(this._state, to)) return false;
|
||||||
|
this.transition(to);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange(fn: (state: SessionState) => void): () => void {
|
||||||
|
this.listeners.add(fn);
|
||||||
|
return () => this.listeners.delete(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this._state = "idle";
|
||||||
|
for (const fn of this.listeners) {
|
||||||
|
fn(this._state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { ConnectParams, FrameMeta, SessionState, TransportEventListener } from "../types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TerminalTransport — 网络层抽象接口(三端各自实现)。
|
||||||
|
* 此接口在 packages/terminal-core 定义;apps/web 现有实现兼容此签名。
|
||||||
|
*/
|
||||||
|
export interface TerminalTransport {
|
||||||
|
connect(params: ConnectParams): Promise<void>;
|
||||||
|
send(data: string, meta?: FrameMeta): Promise<void>;
|
||||||
|
resize(cols: number, rows: number): Promise<void>;
|
||||||
|
disconnect(reason?: string): Promise<void>;
|
||||||
|
/** 返回取消订阅函数 */
|
||||||
|
on(listener: TransportEventListener): () => void;
|
||||||
|
getState(): SessionState;
|
||||||
|
}
|
||||||
149
terminal/packages/terminal-core/src/types.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* 所有平台共用的基础类型定义。
|
||||||
|
* 此文件不得引入任何 DOM/BOM API,不得依赖 @remoteconn/shared 或 apps/* 模块。
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── 会话状态 ───────────────────────────────────────────────────────────────────
|
||||||
|
export type SessionState =
|
||||||
|
| "idle"
|
||||||
|
| "connecting"
|
||||||
|
| "auth_pending"
|
||||||
|
| "connected"
|
||||||
|
| "reconnecting"
|
||||||
|
| "disconnected"
|
||||||
|
| "error";
|
||||||
|
|
||||||
|
// ── 连接参数 ───────────────────────────────────────────────────────────────────
|
||||||
|
export interface ConnectParams {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
credential: TerminalCredential;
|
||||||
|
knownHostFingerprint?: string;
|
||||||
|
cols: number;
|
||||||
|
rows: number;
|
||||||
|
clientSessionKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TerminalCredential =
|
||||||
|
| { type: "password"; password: string }
|
||||||
|
| { type: "privateKey"; privateKey: string; passphrase?: string }
|
||||||
|
| { type: "certificate"; privateKey: string; certificate: string; passphrase?: string };
|
||||||
|
|
||||||
|
// ── 帧 meta(stdin 可选附加) ──────────────────────────────────────────────────
|
||||||
|
export interface FrameMeta {
|
||||||
|
source?: "keyboard" | "assist" | "paste";
|
||||||
|
txnId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Transport 事件 ─────────────────────────────────────────────────────────────
|
||||||
|
export type TransportEvent =
|
||||||
|
| { type: "stdout"; data: string }
|
||||||
|
| { type: "stderr"; data: string }
|
||||||
|
| { type: "latency"; data: number }
|
||||||
|
| { type: "connected"; fingerprint?: string }
|
||||||
|
| { type: "disconnect"; reason: string }
|
||||||
|
| { type: "error"; code: string; message: string };
|
||||||
|
|
||||||
|
export type TransportEventListener = (event: TransportEvent) => void;
|
||||||
|
|
||||||
|
// ── 颜色值 ────────────────────────────────────────────────────────────────────
|
||||||
|
export interface ColorValue {
|
||||||
|
mode: "default" | "p16" | "p256" | "rgb";
|
||||||
|
/** p16: 0-15;p256: 0-255;rgb: 0xRRGGBB */
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_FG: ColorValue = { mode: "default", value: 0 };
|
||||||
|
export const DEFAULT_BG: ColorValue = { mode: "default", value: 0 };
|
||||||
|
export const DEFAULT_COLOR: ColorValue = { mode: "default", value: 0 };
|
||||||
|
|
||||||
|
// ── SGR flags(bitmask) ───────────────────────────────────────────────────────
|
||||||
|
export const FLAG_BOLD = 1 << 0;
|
||||||
|
export const FLAG_DIM = 1 << 1;
|
||||||
|
export const FLAG_ITALIC = 1 << 2;
|
||||||
|
export const FLAG_UNDERLINE = 1 << 3;
|
||||||
|
export const FLAG_BLINK = 1 << 4;
|
||||||
|
export const FLAG_INVERSE = 1 << 5;
|
||||||
|
export const FLAG_INVISIBLE = 1 << 6;
|
||||||
|
export const FLAG_STRIKETHROUGH = 1 << 7;
|
||||||
|
export const FLAG_OVERLINE = 1 << 8;
|
||||||
|
|
||||||
|
// ── 单元格 ────────────────────────────────────────────────────────────────────
|
||||||
|
export interface TerminalCell {
|
||||||
|
char: string; // 空单元格为 ' '
|
||||||
|
width: 1 | 2; // CJK 宽字符占 2
|
||||||
|
fg: ColorValue;
|
||||||
|
bg: ColorValue;
|
||||||
|
flags: number;
|
||||||
|
underlineStyle: "none" | "single" | "double" | "curly" | "dotted" | "dashed";
|
||||||
|
underlineColor: ColorValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 行 ────────────────────────────────────────────────────────────────────────
|
||||||
|
export interface TerminalLine {
|
||||||
|
cells: TerminalCell[];
|
||||||
|
isWrapped: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 光标状态 ──────────────────────────────────────────────────────────────────
|
||||||
|
export interface CursorState {
|
||||||
|
x: number; // 列(0-based)
|
||||||
|
y: number; // 行(视口相对,0-based)
|
||||||
|
globalRow: number; // baseY + y(全局行号,供触摸激活带使用)
|
||||||
|
visible: boolean; // DECTCEM
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 快照(渲染器唯一输入数据结构) ─────────────────────────────────────────────
|
||||||
|
export interface TerminalSnapshot {
|
||||||
|
cols: number;
|
||||||
|
rows: number;
|
||||||
|
cursor: CursorState;
|
||||||
|
lines: TerminalLine[]; // 长度 = rows,仅当前可视区
|
||||||
|
title: string;
|
||||||
|
revision: number; // 每次 write() 后递增
|
||||||
|
isAlternateBuffer: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 字符属性(VT 内部使用) ───────────────────────────────────────────────────
|
||||||
|
export interface CharAttribs {
|
||||||
|
fg: ColorValue;
|
||||||
|
bg: ColorValue;
|
||||||
|
flags: number;
|
||||||
|
underlineStyle: "none" | "single" | "double" | "curly" | "dotted" | "dashed";
|
||||||
|
underlineColor: ColorValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeDefaultAttribs(): CharAttribs {
|
||||||
|
return {
|
||||||
|
fg: { mode: "default", value: 0 },
|
||||||
|
bg: { mode: "default", value: 0 },
|
||||||
|
flags: 0,
|
||||||
|
underlineStyle: "none",
|
||||||
|
underlineColor: { mode: "default", value: 0 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function copyAttribs(a: CharAttribs): CharAttribs {
|
||||||
|
return {
|
||||||
|
fg: { ...a.fg },
|
||||||
|
bg: { ...a.bg },
|
||||||
|
flags: a.flags,
|
||||||
|
underlineStyle: a.underlineStyle,
|
||||||
|
underlineColor: { ...a.underlineColor },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeBlankCell(attribs?: CharAttribs): TerminalCell {
|
||||||
|
if (!attribs) {
|
||||||
|
return { char: " ", width: 1, fg: DEFAULT_FG, bg: DEFAULT_BG, flags: 0, underlineStyle: "none", underlineColor: DEFAULT_COLOR };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
char: " ", width: 1,
|
||||||
|
fg: { ...attribs.fg },
|
||||||
|
bg: { ...attribs.bg },
|
||||||
|
flags: attribs.flags,
|
||||||
|
underlineStyle: attribs.underlineStyle,
|
||||||
|
underlineColor: { ...attribs.underlineColor },
|
||||||
|
};
|
||||||
|
}
|
||||||
21
terminal/packages/terminal-core/tsconfig.json
Normal file
@@ -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
@@ -0,0 +1,155 @@
|
|||||||
|
# 问题闭环记录(Question Solved)
|
||||||
|
|
||||||
|
本文档记录已确认问题的现象、根因、修复与回归要点,目标是避免重复踩坑。
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
1. [2026-03-02:第三方输入法导致输入重复](#ime-dup)
|
||||||
|
2. [2026-03-02:点击粘贴触发 BAD_REQUEST / invalid_union](#paste-invalid-union)
|
||||||
|
3. [2026-03-02:多行粘贴被当作回车执行](#multiline-paste-enter)
|
||||||
|
4. [2026-03-03:回车后首字母重复显示(`aasdf`)](#enter-leading-dup)
|
||||||
|
5. [通用回归清单](#regression-checklist)
|
||||||
|
|
||||||
|
<a id="ime-dup"></a>
|
||||||
|
## 2026-03-02:第三方输入法导致输入重复(`jk -> jkjk`,`测试 -> 测试测试`)
|
||||||
|
|
||||||
|
### 现象
|
||||||
|
- 第三方输入法下输入 `jk` 显示 `jkjk`,输入 `测试` 显示 `测试测试`。
|
||||||
|
- 原生输入法或英文输入模式正常。
|
||||||
|
|
||||||
|
### 根因
|
||||||
|
- 同一文本同时走了 `keydown` 和 `input` 两条发送路径,导致双发。
|
||||||
|
- 第三方输入法会在 `keydown` 阶段给出整段文本(`key="测试"` / `key="jk"`),旧逻辑误判后直接发送。
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
- 文件:`apps/web/src/terminal/components/TerminalInputBar.vue`
|
||||||
|
- 策略:
|
||||||
|
1. `keydown` 只处理控制键/功能键。
|
||||||
|
2. 文本输入统一走 `input` 通道,保证单通道发送。
|
||||||
|
3. `KEYDOWN_DIRECT_KEYS` 作为白名单,非白名单文本直接跳过。
|
||||||
|
|
||||||
|
### 验证
|
||||||
|
- `ls`、`jk`、`测试` 均不重复。
|
||||||
|
- `Enter`、`Ctrl+C` 等控制键行为保持正常。
|
||||||
|
|
||||||
|
### 自动化测试
|
||||||
|
- 文件:`apps/web/src/terminal/input/inputPolicy.test.ts`
|
||||||
|
- 覆盖文本键、功能键、组合键判定。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="paste-invalid-union"></a>
|
||||||
|
## 2026-03-02:点击粘贴触发 BAD_REQUEST / invalid_union
|
||||||
|
|
||||||
|
### 现象
|
||||||
|
- 点击工具栏“粘贴”后出现 error,连接异常或重连。
|
||||||
|
- 日志出现 `ws:error_frame code=BAD_REQUEST`,消息含 `invalid_union`。
|
||||||
|
|
||||||
|
### 根因
|
||||||
|
- 网关 `stdin` 入参 schema 在现网存在差异;`paste` 携带 `meta` 会在部分环境触发 union 校验失败。
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
- 文件:`apps/web/src/terminal/stores/useTerminalStore.ts`
|
||||||
|
- 策略:
|
||||||
|
1. `paste` 分片发送时不再携带 `meta`。
|
||||||
|
2. `keyboard/assist` 保持原有 `meta.source`。
|
||||||
|
3. 粘贴分片大小从 `512` 调整为 `256`,降低单帧风险。
|
||||||
|
|
||||||
|
### 验证
|
||||||
|
- 粘贴日志出现 `wireMetaSource: "none"`。
|
||||||
|
- 不再出现 `BAD_REQUEST invalid_union`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="multiline-paste-enter"></a>
|
||||||
|
## 2026-03-02:多行粘贴被当作回车执行
|
||||||
|
|
||||||
|
### 现象
|
||||||
|
- 复制多行文本后粘贴进 shell,会被当作多次回车,导致命令提前执行。
|
||||||
|
|
||||||
|
### 根因
|
||||||
|
- 旧粘贴逻辑将换行统一转为 `\r`(回车语义),不符合“多行粘贴应作为文本块输入”的预期。
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
- 文件:
|
||||||
|
- `packages/terminal-core/src/input/inputBridge.ts`
|
||||||
|
- `apps/web/src/terminal/components/TerminalInputBar.vue`
|
||||||
|
- `apps/web/src/terminal/TerminalPage.vue`
|
||||||
|
- 策略:
|
||||||
|
1. `mapPaste` 将换行统一归一为 `\n`,不再转成 `\r`。
|
||||||
|
2. Web 两条粘贴链路(原生 `paste`、工具栏“粘贴”)统一走 `InputBridge.mapPaste()`。
|
||||||
|
3. 开启 bracketed paste(`\x1b[200~... \x1b[201~`),避免被解释为逐行回车执行。
|
||||||
|
|
||||||
|
### 验证
|
||||||
|
- 粘贴多行文本默认不自动逐行执行。
|
||||||
|
- 两条粘贴入口行为一致。
|
||||||
|
|
||||||
|
### 自动化测试
|
||||||
|
- 文件:`packages/terminal-core/src/input/inputBridge.test.ts`
|
||||||
|
- 覆盖:
|
||||||
|
1. `CRLF/CR/LF` 统一归一为 `LF`。
|
||||||
|
2. bracketed paste 包裹序列正确。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="regression-checklist"></a>
|
||||||
|
## 2026-03-02:软键盘弹出后光标行定位不准与收回后页面跳动问题
|
||||||
|
|
||||||
|
### 现象
|
||||||
|
1. 终端在 iOS 移动设备上点击输入区弹出软键盘时,期望将光标(提示符行)定位到屏幕的距顶 1/4 处,但实际经常定位到键盘顶部或在屏幕中部乱跳。
|
||||||
|
2. 软键盘收回时,期望恢复到键盘弹出前的状态,但页面内容经常回跳或者复位到屏幕底部。
|
||||||
|
3. 终端输入文字(`autoFollow=true`)时,滚动条经常突然跳到屏幕中部,无法锁定在最底部。
|
||||||
|
|
||||||
|
### 根因
|
||||||
|
1. **行高计算不准确:** 在 `textareaRenderer` 的旧版计算方式中,利用 `scrollHeight / totalLines` 试图估算出单行的 `lineHeight`。但这在遇到换行文字(如超宽命令占据多行 HTML)或者终端末尾包含空白补齐的 Null Lines 时,按比例换算的 `offsetPx` 数值偏差严重。
|
||||||
|
2. **滚动到 scrollHeight 的陷阱:** `renderSnapshot` 及 `followBottom` 在自动跟随终端内容时,直接触发了 `this.outputEl.scrollTop = this.outputEl.scrollHeight`。但是 `xterm` 的 `snapshot.lines` 通常包含底部的很多未使用的纯空行(通常是为了填满预设高度而预留的。例如在你的日志中底部留有二十几行空行 Padding)。直接将 `scrollTop` 置为最大,会导致包含真实光标的行被视觉上挤到了屏幕上方,呈现出"跳到了屏幕中部"的怪异现象。
|
||||||
|
3. **iOS 异步布局时序缺陷:** `KeyboardAdjustController` 中,iOS 上软键盘收回(`visualViewport.height` 恢复)早于实际的 DOM `clientHeight` 恢复更新,用固定的时间延迟去读取和操作会导致布局错误。同时 iOS 原生的 `scroll` 行为是异步派发的,和程序的滚动重置产生了干扰覆盖(程序刚设回了 `snapshot` 高度,又被键盘底层收回的 `scroll` 冲成了底部)。
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
- 文件:
|
||||||
|
- `apps/web/src/terminal/renderer/textareaRenderer.ts`
|
||||||
|
- `apps/web/src/terminal/input/keyboardAdjustController.ts`
|
||||||
|
- 策略:
|
||||||
|
1. **放弃比例估算,直接获取光标 DOM 节点:** 在 `textareaRenderer` 渲染的光标附带有专门的类名 `tc-cursor-cell`,此时我们设置外部包裹元素为 `position: relative`,并直接借助 `const cursorSpan = this.outputEl.querySelector('.tc-cursor-cell')` 拿到精确的 `offsetTop` 来计算光标此时刻在纵向的确切位置,确保毫无误差。
|
||||||
|
2. **将光标置于可视区底部,拒绝 scrollHeight:** 摒弃粗暴地拉向极限 `scrollHeight` 的做法,改为使用光标偏移量算出安全的底端视区:`let targetScrollTop = cursorBottom - clientH + 20` 并将其作为真实的自动跟随坐标,将跟随对象从"整个 Buffer 的底部"变更为"真正光标所处的区域"。
|
||||||
|
3. **Polling 等待基线恢复,长短结合阻塞 scroll 事件干扰:** 在 `keyboardAdjustController.ts` 中针对键盘关闭的 Layout 响应,引入 `requestAnimationFrame` 及定时检测(Polling),不断比对 `clientHeight` 是否已经长回键盘未弹出前的高度,只有确认恢复或者超市才恢复预存前的 `scrollTop` 快照,借此解决 iOS 下因为布局慢导致的跳闪。并通过定时锁去屏蔽由于视差调整造成的自动 `autofollow` 开关变动。
|
||||||
|
|
||||||
|
### 验证
|
||||||
|
- 点击 iOS 输入区弹出软键盘,终端精确将当前焦点处于 1/4 屏幕处。
|
||||||
|
- 收起键盘后,稳定跳掉之前的阅读点(快照复原准确)。
|
||||||
|
- 长按或者多次键盘输入不再跳动到屏幕中部。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="enter-leading-dup"></a>
|
||||||
|
## 2026-03-03:回车后首字母重复显示(`aasdf`)
|
||||||
|
|
||||||
|
### 现象
|
||||||
|
- 输入英文命令(如 `asdf`)后回车,终端提示符行显示 `aasdf`,但服务端实际执行的是 `asdf`(例如返回 `zsh: command not found: asdf`)。
|
||||||
|
- 问题具备偶发性,通常出现在回显分片较细时。
|
||||||
|
|
||||||
|
### 根因
|
||||||
|
- 回显数据分帧到达,典型序列为:`"a"`、`"\b as"`、`"df..."`。
|
||||||
|
- 旧实现按帧即时渲染,第一帧字符已经落盘;第二帧开头的 `\b`(Backspace)无法回退上一帧已写入内容,导致视觉上保留多余首字母。
|
||||||
|
- 同时存在 `\r\r\n` 与 ANSI 控制序列交织,如果不按终端语义处理,也会放大错位和空行现象。
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
- 文件:`demo/main.js`
|
||||||
|
- 策略:
|
||||||
|
1. 在 `renderAnsiToHtml` 中补齐控制字符语义:处理 `\x08`(退格)、`\r\n`(归一为换行)和裸 `\r`(回到当前行首覆盖)。
|
||||||
|
2. 增加短窗口流式合帧(`STREAM_BATCH_MS`):将连续 `stdout/stderr` 分片合并后再渲染,确保跨帧 `\b/\r` 能作用于同一文本缓冲。
|
||||||
|
3. 保留原有“本地回显清理”策略(`clearLast()`),避免与远端回显叠加。
|
||||||
|
|
||||||
|
### 验证
|
||||||
|
- 复现输入 `asdf` 回车,不再出现 `aasdf`。
|
||||||
|
- 日志可见发送仍为 `asdf\n`,错误回显保持 `command not found: asdf`,与服务端行为一致。
|
||||||
|
- 其它输出路径(`stdout/stderr`、ANSI 颜色)不回归。
|
||||||
|
|
||||||
|
## 通用回归清单(每次改输入/粘贴链路都执行)
|
||||||
|
|
||||||
|
1. 英文输入 `ls` 不重复。
|
||||||
|
2. 第三方输入法输入 `jk`、`测试` 不重复。
|
||||||
|
3. `Enter`、`Ctrl+C`、方向键功能正常。
|
||||||
|
4. 工具栏粘贴与原生粘贴行为一致。
|
||||||
|
5. 长文本粘贴不触发网关 `BAD_REQUEST invalid_union`。
|
||||||
|
6. 多行粘贴不应被当作多次回车直接执行。
|
||||||
730
terminal/xterm-standalone-lab-plan-2026-03-01.md
Normal file
@@ -0,0 +1,730 @@
|
|||||||
|
# xterm 移植独立实验项目实施方案(2026-03-01)
|
||||||
|
|
||||||
|
## 1. 目标与范围
|
||||||
|
|
||||||
|
本方案用于落地一个独立实验项目,验证“xterm.js 基线能力”与“原生 textarea 方案”的迁移可行性。
|
||||||
|
该项目仅用于测试,不替换现有 Web/小程序主链路。
|
||||||
|
|
||||||
|
规范基线:
|
||||||
|
|
||||||
|
1. 渲染与输入语义以 `xterm.js@5.3.0` 源码为准。
|
||||||
|
2. 文档中的换行、回显、光标、粘贴规则均以该版本源码行为校正。
|
||||||
|
3. 若未来升级 xterm 版本,需先做“规范差异审计”再修改本方案。
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
1. 目录独立:实验代码只在 `labs/xterm-standalone/`。
|
||||||
|
2. 依赖可复用:允许复用现有依赖版本策略和工具链版本。
|
||||||
|
3. 构建/运行可复用:命令约定复用 `dev/build/test/lint/typecheck`。
|
||||||
|
4. 业务零耦合:不复用现有业务模块逻辑,不修改现有状态机与会话实现。
|
||||||
|
5. 可删除无副作用:删除实验目录和小程序入口后,主项目行为不变。
|
||||||
|
|
||||||
|
## 2. 非目标
|
||||||
|
|
||||||
|
1. 不替换 `apps/web` 现有终端实现。
|
||||||
|
2. 不修改 `apps/gateway` 的业务协议和转发逻辑。
|
||||||
|
3. 不改 `packages/shared` 的现有模型定义和状态机实现。
|
||||||
|
4. 不将实验代码接入生产发布链路。
|
||||||
|
|
||||||
|
## 3. 独立性与复用规则
|
||||||
|
|
||||||
|
### 3.1 必须满足
|
||||||
|
|
||||||
|
1. 实验代码不得 `import` 现有业务模块(`apps/*`、`packages/*` 的业务实现)。
|
||||||
|
2. 小程序仅新增“入口级改动”(页面按钮 + web-view 跳转)。
|
||||||
|
3. 实验项目失败、下线、删除,不影响主流程连接与发布。
|
||||||
|
4. 禁止在实验项目中运行时依赖 `@xterm/*` 包;xterm 仅作为源码语义参考,不作为可执行依赖。
|
||||||
|
|
||||||
|
### 3.2 允许复用
|
||||||
|
|
||||||
|
1. 依赖版本策略可复用(例如与根工程保持同主版本)。
|
||||||
|
2. 工程工具可复用(TypeScript、Vite、ESLint、Vitest 配置风格)。
|
||||||
|
3. 运行方式可复用(统一命令命名,不要求命令实现完全一致)。
|
||||||
|
4. 主题 token 可复用现有 Web/小程序的成熟定义(颜色变量、语义色分层、主题切换策略),但实现代码必须保留在实验目录内。
|
||||||
|
|
||||||
|
## 4. 目录规划(实施级)
|
||||||
|
|
||||||
|
```text
|
||||||
|
labs/xterm-standalone/
|
||||||
|
README.md
|
||||||
|
package.json
|
||||||
|
src/
|
||||||
|
main.ts
|
||||||
|
app/App.vue
|
||||||
|
pages/TerminalLabPage.vue
|
||||||
|
components/
|
||||||
|
TerminalToolbar.vue
|
||||||
|
TerminalViewport.vue
|
||||||
|
TerminalInputBar.vue
|
||||||
|
TerminalTouchTools.vue
|
||||||
|
core/
|
||||||
|
session/sessionMachine.ts
|
||||||
|
session/sessionTypes.ts
|
||||||
|
transport/terminalTransport.ts
|
||||||
|
transport/gatewayTransport.ts
|
||||||
|
renderer/rendererAdapter.ts
|
||||||
|
renderer/compatRenderer.ts
|
||||||
|
renderer/textareaRenderer.ts
|
||||||
|
input/inputBridge.ts
|
||||||
|
input/imeController.ts
|
||||||
|
layout/sizeCalculator.ts
|
||||||
|
layout/cursorMath.ts
|
||||||
|
buffer/outputBuffer.ts
|
||||||
|
sanitize/terminalSanitizer.ts
|
||||||
|
stores/useTerminalLabStore.ts
|
||||||
|
styles/terminal-lab.css
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 总体架构与数据流
|
||||||
|
|
||||||
|
```text
|
||||||
|
Transport(stdout/stderr) -> Sanitizer -> OutputBuffer -> RendererAdapter(compat|textarea) -> View
|
||||||
|
UserInput(key/ime/paste/touch) -> InputBridge -> xterm-compatible input mapping -> Transport(stdin)
|
||||||
|
LayoutResize -> sizeCalculator(cols/rows) -> Transport(resize) + Renderer.resize
|
||||||
|
```
|
||||||
|
|
||||||
|
关键原则:
|
||||||
|
|
||||||
|
1. 传输层不感知渲染器类型。
|
||||||
|
2. 输入处理统一走 `InputBridge`,避免各组件各自发送 stdin。
|
||||||
|
3. 渲染器通过统一接口实现,便于模式切换和 A/B 对比。
|
||||||
|
4. 输入映射遵循 xterm 规则:`Enter -> CR`,粘贴文本行结束归一到 `CR`。
|
||||||
|
5. 输出渲染遵循 xterm 控制字符纪律:`CR` 与 `LF` 分离语义,`convertEol` 默认 `false`。
|
||||||
|
6. 原生响应优先:软键盘、选区、滚动由原生 `textarea` 通道主导,xterm 仅负责渲染与终端语义,不得接管系统输入响应。
|
||||||
|
7. 事件主权清晰:键盘/鼠标事件先由应用层做区域过滤与路由,再决定是否交给 xterm。
|
||||||
|
|
||||||
|
## 6. 状态机规划
|
||||||
|
|
||||||
|
## 6.1 会话状态机(连接生命周期)
|
||||||
|
|
||||||
|
状态集合(与现有模型对齐):
|
||||||
|
|
||||||
|
1. `idle`
|
||||||
|
2. `connecting`
|
||||||
|
3. `auth_pending`
|
||||||
|
4. `connected`
|
||||||
|
5. `reconnecting`
|
||||||
|
6. `disconnected`
|
||||||
|
7. `error`
|
||||||
|
|
||||||
|
合法迁移(必须在 `sessionMachine.ts` 中强校验):
|
||||||
|
|
||||||
|
1. `idle -> connecting | disconnected`
|
||||||
|
2. `connecting -> auth_pending | error | disconnected`
|
||||||
|
3. `auth_pending -> connected | error | disconnected`
|
||||||
|
4. `connected -> reconnecting | disconnected | error`
|
||||||
|
5. `reconnecting -> connected | error | disconnected`
|
||||||
|
6. `disconnected -> connecting | idle`
|
||||||
|
7. `error -> connecting | disconnected`
|
||||||
|
|
||||||
|
触发事件:
|
||||||
|
|
||||||
|
1. `connect_request`
|
||||||
|
2. `socket_open`
|
||||||
|
3. `connected_frame`
|
||||||
|
4. `stdout_first_packet`
|
||||||
|
5. `disconnect_frame/ws_close`
|
||||||
|
6. `error_frame/ws_error`
|
||||||
|
7. `manual_disconnect`
|
||||||
|
8. `auto_reconnect_timeout`
|
||||||
|
|
||||||
|
## 6.2 输入状态机(IME/普通输入)
|
||||||
|
|
||||||
|
状态:
|
||||||
|
|
||||||
|
1. `input_idle`
|
||||||
|
2. `input_composing`
|
||||||
|
3. `input_commit_pending`
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
1. `compositionstart` -> `input_composing`
|
||||||
|
2. `compositionend` -> 进入 `input_commit_pending`,提交非 ASCII 候选
|
||||||
|
3. `beforeinput/input` 非组合态可触发 ASCII 兜底发送
|
||||||
|
4. 超时守卫(如 1.8s)未收到 `compositionend` 时自动恢复到 `input_idle`
|
||||||
|
|
||||||
|
## 6.3 触摸焦点状态机(移动端)
|
||||||
|
|
||||||
|
动作集合:
|
||||||
|
|
||||||
|
1. `BLUR_ONLY`
|
||||||
|
2. `FOCUS_KEYBOARD`
|
||||||
|
3. `PASS_NATIVE`
|
||||||
|
4. `PASS_SCROLL`
|
||||||
|
|
||||||
|
决策优先级:
|
||||||
|
|
||||||
|
1. `hasSelectionStart || hasSelectionEnd` -> `PASS_NATIVE`
|
||||||
|
2. `scrollLike` -> `PASS_SCROLL`
|
||||||
|
3. `moved` -> `PASS_NATIVE`
|
||||||
|
4. `!inBand` -> `BLUR_ONLY`
|
||||||
|
5. `inBand` -> `FOCUS_KEYBOARD`
|
||||||
|
|
||||||
|
硬约束(新增):
|
||||||
|
|
||||||
|
1. 只有 `FOCUS_KEYBOARD` 动作允许触发原生键盘弹出。
|
||||||
|
2. `FOCUS_KEYBOARD` 必须满足“光标附近行”条件(`inBand=true`),禁止在非光标邻近区域弹键盘。
|
||||||
|
3. `PASS_NATIVE/PASS_SCROLL` 禁止 `preventDefault`,确保原生选区和滚动不被破坏。
|
||||||
|
4. 禁止在终端根容器上全局拦截 `touchstart/touchmove/click`;仅允许在明确手势分支下做最小拦截。
|
||||||
|
5. 移动端 `compat` 仅承担回显渲染,输入焦点必须由原生 `textarea` 锚点持有。
|
||||||
|
6. 禁止在非用户手势路径下调用 `terminal.focus()` 触发软键盘。
|
||||||
|
|
||||||
|
## 6.4 渲染模式状态机
|
||||||
|
|
||||||
|
状态:
|
||||||
|
|
||||||
|
1. `renderer_compat`
|
||||||
|
2. `renderer_textarea`
|
||||||
|
|
||||||
|
切换事件:
|
||||||
|
|
||||||
|
1. `switch_to_compat`
|
||||||
|
2. `switch_to_textarea`
|
||||||
|
|
||||||
|
切换过程必须原子化:
|
||||||
|
|
||||||
|
1. `oldRenderer.dispose()`
|
||||||
|
2. `newRenderer.mount(container)`
|
||||||
|
3. `replay buffer`
|
||||||
|
4. `apply size/theme`
|
||||||
|
5. `focus restore`(仅桌面自动聚焦)
|
||||||
|
|
||||||
|
## 6.5 换行模式状态(xterm 对齐)
|
||||||
|
|
||||||
|
状态:
|
||||||
|
|
||||||
|
1. `convertEol=false`(默认)
|
||||||
|
2. `convertEol=true`(由 `CSI 20 h` 打开)
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
1. `LF/VT/FF` 进入 `lineFeed()`:始终 `y+1`,并在触底时滚屏。
|
||||||
|
2. `convertEol=true` 时,`lineFeed()` 额外执行 `x=0`;`false` 时保留列位置。
|
||||||
|
3. `CR` 仅执行 `x=0`,不改变 `y`。
|
||||||
|
4. `NEL`(`ESC E` / `C1.NEL`)等价 `x=0 + index()`(下移一行并在必要时滚屏)。
|
||||||
|
5. 右边界写入是否自动折行由 `DECAWM(wraparound)` 控制。
|
||||||
|
6. 自动折行触发条件按 xterm 口径:字符写入将越过末列时触发换行;换行行需标记 `isWrapped=true`。
|
||||||
|
7. 显式 `LF` 后目标行应清除 `isWrapped`,避免把硬换行误判为软换行。
|
||||||
|
|
||||||
|
## 6.6 屏幕缓冲状态(主屏/备用屏,强制)
|
||||||
|
|
||||||
|
状态:
|
||||||
|
|
||||||
|
1. `buffer_normal`(主屏)
|
||||||
|
2. `buffer_alternate`(备用屏)
|
||||||
|
|
||||||
|
切换规则(xterm 对齐):
|
||||||
|
|
||||||
|
1. `CSI ? 47 h` / `CSI ? 1047 h`:切换到备用屏。
|
||||||
|
2. `CSI ? 47 l` / `CSI ? 1047 l`:返回主屏。
|
||||||
|
3. `CSI ? 1049 h`:保存光标状态并切换到备用屏。
|
||||||
|
4. `CSI ? 1049 l`:返回主屏并恢复保存光标状态。
|
||||||
|
5. 对齐 `xterm.js@5.3.0` 现实行为:`1049` 不强制清空备用屏历史(按源码注释口径处理)。
|
||||||
|
|
||||||
|
实现约束:
|
||||||
|
|
||||||
|
1. `TerminalCore` 必须同时维护 `normalBuffer` 与 `alternateBuffer`,仅一个为 `activeBuffer`。
|
||||||
|
2. 两个缓冲区的 `cursor`、`scroll region`、`isWrapped` 状态独立维护,禁止互相污染。
|
||||||
|
3. 切屏仅切换活动缓冲区引用,不得重建对象导致历史丢失。
|
||||||
|
4. `1049` 路径必须包含 `saveCursor/restoreCursor`,并保证退出全屏后提示符位置正确。
|
||||||
|
5. 该能力只影响输出缓冲与光标状态,不改变“原生输入主通道”与事件归属策略。
|
||||||
|
|
||||||
|
## 7. 组件职责与接口
|
||||||
|
|
||||||
|
## 7.1 `TerminalLabPage.vue`
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
1. 页面编排、模式切换、连接/断开、状态展示。
|
||||||
|
2. 组装 `store + renderer + inputBridge`。
|
||||||
|
|
||||||
|
## 7.2 `TerminalViewport.vue`
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
1. 只负责承载渲染容器(不直连 transport)。
|
||||||
|
2. 提供挂载点给 `RendererAdapter`。
|
||||||
|
|
||||||
|
## 7.3 `TerminalInputBar.vue`
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
1. 文本输入框、发送按钮、粘贴、快捷键入口。
|
||||||
|
2. 通过 `InputBridge.send()` 发送,不直接访问 transport。
|
||||||
|
|
||||||
|
## 7.4 `TerminalToolbar.vue`
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
1. 展示连接状态、延迟、渲染模式。
|
||||||
|
2. 触发 `connect/disconnect/clear/switchRenderer`。
|
||||||
|
|
||||||
|
## 7.5 `TerminalTouchTools.vue`
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
1. 方向键、Enter、Ctrl+C、Tab、Paste。
|
||||||
|
2. 输出标准控制序列(如 `ESC[A`)。
|
||||||
|
|
||||||
|
## 8. 传输与协议实现要点
|
||||||
|
|
||||||
|
## 8.1 `TerminalTransport` 抽象(必须)
|
||||||
|
|
||||||
|
接口:
|
||||||
|
|
||||||
|
1. `connect(params)`
|
||||||
|
2. `send(data, meta?)`
|
||||||
|
3. `resize(cols, rows)`
|
||||||
|
4. `disconnect(reason?)`
|
||||||
|
5. `on(listener)`
|
||||||
|
6. `getState()`
|
||||||
|
|
||||||
|
## 8.2 网关帧协议(实验项目内按同协议实现)
|
||||||
|
|
||||||
|
入站:
|
||||||
|
|
||||||
|
1. `init`
|
||||||
|
2. `stdin`(含可选 `meta: { source, txnId }`)
|
||||||
|
3. `resize`
|
||||||
|
4. `control(ping/pong/disconnect)`
|
||||||
|
|
||||||
|
出站:
|
||||||
|
|
||||||
|
1. `stdout`
|
||||||
|
2. `stderr`
|
||||||
|
3. `control(connected/disconnect/pong)`
|
||||||
|
4. `error`
|
||||||
|
|
||||||
|
## 9. 渲染管线设计
|
||||||
|
|
||||||
|
## 9.1 输出处理链
|
||||||
|
|
||||||
|
1. 收到 `stdout/stderr`。
|
||||||
|
2. 执行 `sanitizeTerminalOutput`(过滤同步更新模式等控制序列噪音)。
|
||||||
|
3. 追加到 `OutputBuffer`(条目上限 + 字节上限双阈值)。
|
||||||
|
4. `outputRevision++`。
|
||||||
|
5. 渲染器增量写入;必要时重放。
|
||||||
|
|
||||||
|
## 9.2 缓冲策略(必须)
|
||||||
|
|
||||||
|
1. `maxEntries` 下限保护(建议 >= 200)。
|
||||||
|
2. `maxBytes` 下限保护(建议 >= 64KB)。
|
||||||
|
3. 先按条目裁剪,再按字节裁剪。
|
||||||
|
4. 至少保留最新一条,避免全清空闪烁。
|
||||||
|
|
||||||
|
## 9.3 兼容渲染规则(自研)
|
||||||
|
|
||||||
|
1. 使用 `write()` 增量写入,不用 `writeln()` 改写换行语义。
|
||||||
|
2. `convertEol` 默认值必须为 `false`(与 xterm 默认一致)。
|
||||||
|
3. 显式 `LF` 不等于 `CRLF`:`CR` 和 `LF` 分别处理,禁止业务层自行合并。
|
||||||
|
4. `fit` 后同步 `resize(cols, rows)`。
|
||||||
|
|
||||||
|
## 9.4 textarea 渲染规则
|
||||||
|
|
||||||
|
1. 采用“只读显示区 + 输入区”或“单 textarea 双模式”方案(二选一,建议前者)。
|
||||||
|
2. 输出区按文本流追加,必要时使用 `requestAnimationFrame` 批量刷新。
|
||||||
|
3. 默认自动滚到底;用户手动上滚时暂停自动跟随。
|
||||||
|
4. 不允许使用“仅按字符串估算”的粗略光标算法作为最终实现。
|
||||||
|
5. 必须模拟 xterm 的 C0/C1 换行语义:`CR`、`LF`、`VT`、`FF`、`NEL`。
|
||||||
|
6. 必须显式维护软换行标记(等价 `isWrapped`),区分“自动折行”与“显式换行”。
|
||||||
|
|
||||||
|
## 9.5 精准光标内核(强制)
|
||||||
|
|
||||||
|
为满足“回显后光标位置必须精准计算”,实验项目必须引入统一终端状态内核(`TerminalCore`),并将其作为两种渲染模式的唯一真相源。
|
||||||
|
|
||||||
|
实现要求:
|
||||||
|
|
||||||
|
1. `stdout/stderr` 必须先写入 `TerminalCore`,由内核解析控制序列并更新光标、屏幕缓冲、滚动区域状态。
|
||||||
|
2. `compat` 模式与 `textarea` 模式都必须消费同一份 `TerminalCore` 状态快照。
|
||||||
|
3. 禁止直接通过 `selectionStart + 文本长度` 推断“终端光标”,该方法仅可用于“输入框本地插入点”,不能代表远端终端光标。
|
||||||
|
4. `TerminalCore` 至少要正确处理本项目目标程序涉及的序列族:`CR/LF/VT/FF/NEL/BS/TAB`、`CSI A/B/C/D/H/f/J/K/m`、`SM/RM(含 Ps=20)`、`DECSET/DECRST(含 DECAWM)`。
|
||||||
|
5. `TerminalCore` 必须维护以下状态:`cursorX/cursorY`、`baseY`、`viewportY`、`scrollTop/scrollBottom`、`convertEol`、`wraparound`、`line.isWrapped`。
|
||||||
|
6. 每次输出处理后,必须产出 `cursor: { x, y, globalRow }` 与 `viewport` 快照,供触摸激活带与滚动定位复用。
|
||||||
|
|
||||||
|
## 9.6 颜色渲染规范(xterm 对齐,强制)
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
1. 颜色与文本属性语义必须对齐 `xterm.js@5.3.0` 的 `SGR(CSI ... m)` 行为。
|
||||||
|
2. `compat` 模式与 `textarea` 模式必须共享同一套属性状态机,避免模式切换后颜色跳变。
|
||||||
|
|
||||||
|
实现边界:
|
||||||
|
|
||||||
|
1. 必须支持基础 SGR 属性:`0/1/2/3/4/7/8/9/21/22/23/24/25/27/28/29/53/55`。
|
||||||
|
2. 必须支持前景/背景标准色:`30-37`、`40-47`。
|
||||||
|
3. 必须支持高亮色:`90-97`、`100-107`。
|
||||||
|
4. 必须支持扩展色:
|
||||||
|
- 前景 `38`
|
||||||
|
- 背景 `48`
|
||||||
|
- 下划线颜色 `58`
|
||||||
|
5. 扩展色子模式必须支持:
|
||||||
|
- `;5;INDEX`(256 色索引)
|
||||||
|
- `;2;R;G;B`(TrueColor)
|
||||||
|
6. 必须支持默认色复位:
|
||||||
|
- `39` 复位前景
|
||||||
|
- `49` 复位背景
|
||||||
|
- `59` 复位下划线颜色
|
||||||
|
|
||||||
|
颜色状态模型(建议):
|
||||||
|
|
||||||
|
1. `fg`: `{ mode: default | p16 | p256 | rgb, value }`
|
||||||
|
2. `bg`: `{ mode: default | p16 | p256 | rgb, value }`
|
||||||
|
3. `underlineColor`: `{ mode: default | p256 | rgb, value }`
|
||||||
|
4. `flags`: `bold/dim/italic/underline/inverse/invisible/strikethrough/overline`
|
||||||
|
5. `underlineStyle`: `none/single/double/curly/dotted/dashed`
|
||||||
|
|
||||||
|
重置纪律:
|
||||||
|
|
||||||
|
1. `SGR 0` 必须重置前景、背景、下划线样式与颜色到默认(等价 xterm `_processSGR0`)。
|
||||||
|
2. `22` 仅取消 `bold` 与 `dim`,不影响颜色。
|
||||||
|
3. `24` 仅取消下划线与下划线样式,不清空其它属性。
|
||||||
|
4. `27` 仅取消 `inverse`。
|
||||||
|
5. `28` 仅取消 `invisible`。
|
||||||
|
6. `29` 仅取消删除线。
|
||||||
|
|
||||||
|
渲染映射规则:
|
||||||
|
|
||||||
|
1. `inverse` 采用“渲染时交换 fg/bg”策略,不直接改写底层存储值。
|
||||||
|
2. `invisible` 保留背景渲染,前景以透明或背景同色处理(与终端“隐藏文字”语义一致)。
|
||||||
|
3. `bold` 只作为属性位,不应强行映射为高亮颜色(除非显式启用“粗体映射亮色”策略)。
|
||||||
|
4. `blink` 在 xterm 5.3 源码中为已记录属性但渲染支持有限,实验项目默认可不做动画实现,但要保留属性位兼容。
|
||||||
|
|
||||||
|
主题与调色板约束:
|
||||||
|
|
||||||
|
1. 16 色与 256 色索引色应来自统一调色板定义,避免 `xterm` 与 `textarea` 调色板不一致。
|
||||||
|
2. TrueColor (`rgb`) 必须按原值渲染,不经过主题二次量化。
|
||||||
|
3. 主题切换只影响“默认色与索引色映射”,不应篡改已存在的 TrueColor 单元格。
|
||||||
|
|
||||||
|
## 9.7 主题复用与轻量化策略(强制)
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
1. 主题实现参考并复用现有 Web/小程序成熟方案,保持视觉一致与维护成本可控。
|
||||||
|
2. xterm 移植以“核心稳定 + 轻量高效”为优先级,禁止默认堆叠非必要能力。
|
||||||
|
|
||||||
|
主题复用规则:
|
||||||
|
|
||||||
|
1. 统一采用语义 token(如 `terminalFg/terminalBg/cursor/selection/ansi16`),禁止在组件内硬编码颜色。
|
||||||
|
2. 默认前景/背景与 `ansi16` 从现有主题配置映射生成,避免与现有产品主题出现明显偏差。
|
||||||
|
3. 256 色表采用固定映射(标准 xterm256 表),不随业务主题动态重算。
|
||||||
|
4. 实验项目只维护“主题映射层”,不复制现有 Web/小程序整套业务主题逻辑。
|
||||||
|
|
||||||
|
xterm 轻量化规则:
|
||||||
|
|
||||||
|
1. 不引入 `@xterm/*` 运行时依赖;实现以 `TerminalCore + 自研渲染器` 为主。
|
||||||
|
2. 兼容能力按需实现并按模块拆分,首屏仅加载连接、输入、回显、resize 必需逻辑。
|
||||||
|
3. 默认使用原生 DOM/Canvas 路径,禁止引入重型图形加速依赖作为前置条件。
|
||||||
|
4. 非核心能力(复杂链接检测、额外动画)默认关闭,确保输入回显路径最短。
|
||||||
|
|
||||||
|
性能预算(建议):
|
||||||
|
|
||||||
|
1. 实验页 JS 增量目标:gzip <= 150KB(超限需在评审记录中说明来源与必要性)。
|
||||||
|
2. 终端首屏可输入目标:页面进入后 1 秒内可完成输入与回显(普通开发机基线)。
|
||||||
|
3. 持续输出下避免长卡顿:连续主线程阻塞 >100ms 视为性能问题并需定位。
|
||||||
|
|
||||||
|
降级开关(必须):
|
||||||
|
|
||||||
|
1. `compatLiteMode`:关闭可选增强,仅保留连接、输入、回显、resize。
|
||||||
|
2. `rendererFallback=textarea`:低端设备或异常场景可一键降级。
|
||||||
|
3. 主题映射失败时自动回退默认主题,禁止阻塞会话连接与输入流程。
|
||||||
|
|
||||||
|
## 9.8 主屏/备用屏落地实现(强制)
|
||||||
|
|
||||||
|
最小实现步骤:
|
||||||
|
|
||||||
|
1. 在 `TerminalCore` 新增 `bufferSet = { normal, alternate, active }` 与 `savedCursor`。
|
||||||
|
2. 解析 `DECSET/DECRST` 时接入 `47/1047/1049` 分支,不允许只做日志忽略。
|
||||||
|
3. `switchToAlternate()`:切换 `active=alternate`,同步 `viewport/baseY/cursor`。
|
||||||
|
4. `switchToNormal()`:切换 `active=normal`,恢复主屏可视与滚动位置。
|
||||||
|
5. `1049h` 先 `saveCursor()` 再切换;`1049l` 先切回主屏再 `restoreCursor()`。
|
||||||
|
6. 渲染器读取统一 `activeBuffer` 快照,禁止自行缓存“上一屏”造成穿透渲染。
|
||||||
|
|
||||||
|
兼容边界:
|
||||||
|
|
||||||
|
1. 若目标程序未使用备用屏序列,行为与当前方案完全一致。
|
||||||
|
2. 若收到未知私有模式,记录日志并安全忽略,不得破坏当前缓冲状态。
|
||||||
|
3. 对于不在首期范围的高级 TUI 能力,可延后,但 `47/1047/1049` 不可缺失。
|
||||||
|
|
||||||
|
## 10. 页面渲染与布局规则
|
||||||
|
|
||||||
|
## 10.1 基础布局
|
||||||
|
|
||||||
|
1. 顶部:状态栏(state、latency、mode)。
|
||||||
|
2. 中部:终端视口(xterm 或 textarea 输出)。
|
||||||
|
3. 底部:输入栏与触摸工具。
|
||||||
|
|
||||||
|
## 10.2 尺寸与 PTY 计算规则(必须统一)
|
||||||
|
|
||||||
|
变量:
|
||||||
|
|
||||||
|
1. `containerInnerWidthPx`
|
||||||
|
2. `containerInnerHeightPx`
|
||||||
|
3. `charWidthPx`
|
||||||
|
4. `lineHeightPx`
|
||||||
|
|
||||||
|
公式:
|
||||||
|
|
||||||
|
1. `cols = max(20, floor(containerInnerWidthPx / charWidthPx))`
|
||||||
|
2. `rows = max(8, floor(containerInnerHeightPx / lineHeightPx))`
|
||||||
|
|
||||||
|
约束:
|
||||||
|
|
||||||
|
1. 变化阈值去抖:`|cols-lastCols| + |rows-lastRows| >= 1` 才发 `resize`。
|
||||||
|
2. 初次挂载后至少重试 fit/measure 3~10 次(应对路由切换延迟布局)。
|
||||||
|
|
||||||
|
## 11. 光标与坐标计算规则(重点)
|
||||||
|
|
||||||
|
说明:本节区分两类光标,避免语义混淆。
|
||||||
|
|
||||||
|
1. 终端光标:远端 PTY 回显语义对应的光标(用于激活带判定)。
|
||||||
|
2. 输入光标:本地输入框 `selectionStart/selectionEnd`(用于编辑行为)。
|
||||||
|
|
||||||
|
## 11.1 行高计算
|
||||||
|
|
||||||
|
优先级:
|
||||||
|
|
||||||
|
1. 优先取真实渲染行高(DOM 测量)。
|
||||||
|
2. 无法测量时回退:`lineHeightPx = fontSize * lineHeight`。
|
||||||
|
|
||||||
|
## 11.2 触点行号计算
|
||||||
|
|
||||||
|
1. `localRow = floor((clientY - viewportTop) / rowHeightPx)`
|
||||||
|
2. compat 模式:`touchRow = viewportY + localRow`
|
||||||
|
3. textarea 模式:`touchRow = viewportTopRow + localRow`
|
||||||
|
4. 若 `clientY` 不在视口边界内,判定为无效触点。
|
||||||
|
|
||||||
|
## 11.3 compat 光标行号
|
||||||
|
|
||||||
|
1. `cursorLocalRow = core.cursorY`
|
||||||
|
2. `cursorBaseRow = core.baseY`
|
||||||
|
3. `cursorRow = cursorBaseRow + cursorLocalRow`(全局行号)
|
||||||
|
4. 激活带判断:`abs(touchRow - cursorRow) <= activationRadius`(建议 2 行)。
|
||||||
|
|
||||||
|
## 11.4 textarea 光标位置计算
|
||||||
|
|
||||||
|
输入(终端光标,来自 `TerminalCore`):
|
||||||
|
|
||||||
|
1. `coreCursorRow`
|
||||||
|
2. `coreCursorCol`
|
||||||
|
3. `viewportTopRow`
|
||||||
|
4. `rows`
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
1. `cursorRow = clamp(coreCursorRow, 0, +inf)`。
|
||||||
|
2. `cursorVisualRow = cursorRow - viewportTopRow`。
|
||||||
|
3. 仅当 `0 <= cursorVisualRow < rows` 时认为光标在当前可视窗口内。
|
||||||
|
4. 激活带判断统一使用 `cursorRow`(全局行号),禁止改用输入框 `selectionStart`。
|
||||||
|
5. `coreCursorRow` 语义需与 xterm 对齐:`globalRow = baseY + cursorY`。
|
||||||
|
|
||||||
|
输入(本地输入光标,仅编辑用途):
|
||||||
|
|
||||||
|
1. `selectionStart`(光标字符索引)
|
||||||
|
2. `text`(当前显示文本)
|
||||||
|
3. `cols`(当前列数)
|
||||||
|
|
||||||
|
算法(必须实现成纯函数):
|
||||||
|
|
||||||
|
1. 从 `text[0..selectionStart)` 顺序扫描。
|
||||||
|
2. 碰到 `\n`:`row += 1; col = 0`。
|
||||||
|
3. 普通字符:`col += charDisplayWidth(ch)`。
|
||||||
|
4. 若 `col >= cols`:按自动换行规则折行:`row += floor(col / cols); col = col % cols`。
|
||||||
|
|
||||||
|
`charDisplayWidth(ch)` 规则:
|
||||||
|
|
||||||
|
1. ASCII:宽度 1。
|
||||||
|
2. CJK/全角:宽度 2(可用简化 east-asian-width 判定)。
|
||||||
|
3. 控制字符:宽度 0。
|
||||||
|
|
||||||
|
## 11.5 坐标到光标索引(可选增强,仅输入框)
|
||||||
|
|
||||||
|
若需要“点按定位光标”:
|
||||||
|
|
||||||
|
1. 先由 `(x,y)` 推导目标 `(row,col)`。
|
||||||
|
2. 再线性扫描文本映射回最近字符索引。
|
||||||
|
3. 时间复杂度 O(n),长文本需缓存行起始索引优化。
|
||||||
|
|
||||||
|
## 11.6 原生键盘弹出门控(强制)
|
||||||
|
|
||||||
|
必须按以下判定顺序执行:
|
||||||
|
|
||||||
|
1. `sessionState === connected`。
|
||||||
|
2. 当前手势动作被状态机判定为 `FOCUS_KEYBOARD`。
|
||||||
|
3. `inBand === true`(即 `abs(touchRow - cursorRow) <= activationRadius`)。
|
||||||
|
4. 不存在活动原生选区(否则走 `PASS_NATIVE`)。
|
||||||
|
|
||||||
|
执行动作:
|
||||||
|
|
||||||
|
1. `readOnly=false`。
|
||||||
|
2. `blur -> focus` 顺序触发输入锚点激活。
|
||||||
|
3. 进入短保护窗口,防止 iOS 瞬时 blur 立刻收回键盘。
|
||||||
|
|
||||||
|
禁止动作:
|
||||||
|
|
||||||
|
1. 非 `inBand` 区域触摸时强制 `focus()`。
|
||||||
|
2. `PASS_NATIVE/PASS_SCROLL` 分支触发键盘弹出。
|
||||||
|
|
||||||
|
## 12. 输入处理规则(必须)
|
||||||
|
|
||||||
|
## 12.1 统一发送入口
|
||||||
|
|
||||||
|
1. 所有输入只走 `InputBridge.sendRaw()`。
|
||||||
|
2. 键盘 `Enter` 必须映射为 `CR (\r)`(与 xterm `Keyboard.ts` 一致)。
|
||||||
|
3. 普通文本输入(keypress/input)按字符原样发送,不做全局换行重写。
|
||||||
|
|
||||||
|
## 12.2 meta 标记
|
||||||
|
|
||||||
|
1. 常规按键:`meta.source = "keyboard"`。
|
||||||
|
2. 语音/候选提交:`meta.source = "assist"`,可携带 `txnId` 去重。
|
||||||
|
|
||||||
|
## 12.3 粘贴规则
|
||||||
|
|
||||||
|
1. 粘贴前执行 xterm 对齐的行结束归一:`/\r?\n/g -> '\r'`。
|
||||||
|
2. 若开启 bracketed paste,按 `ESC[200~ + text + ESC[201~` 包裹发送。
|
||||||
|
3. 粘贴文本直接写入 stdin,不走逐键模拟。
|
||||||
|
|
||||||
|
## 12.4 快捷键规则
|
||||||
|
|
||||||
|
1. `Ctrl/Cmd + C`:有选中则复制;无选中则发 `\u0003`。
|
||||||
|
2. `Ctrl/Cmd + V`:读剪贴板后发送。
|
||||||
|
3. 方向键默认发送:`ESC[A`/`ESC[B`/`ESC[C`/`ESC[D`;应用光标模式下发送:`ESCOA`/`ESCOB`/`ESCOC`/`ESCOD`。
|
||||||
|
|
||||||
|
## 12.5 反劫持策略(强制)
|
||||||
|
|
||||||
|
1. 输入主通道固定为原生 `textarea`,禁止把系统软键盘直接绑定到 xterm 隐藏输入节点。
|
||||||
|
2. 兼容层键盘监听仅用于桌面对照模式;移动端默认禁用兼容层直连 stdin 的按键采集。
|
||||||
|
3. 触摸事件默认透传,只有 `FOCUS_KEYBOARD` 分支可执行最小化 `preventDefault`。
|
||||||
|
4. 原生输入法事件(`compositionstart/update/end`、`beforeinput`)必须完整透传到输入状态机,不得被 xterm 监听提前吞掉。
|
||||||
|
5. 发生冲突时遵循优先级:系统行为 > 原生输入通道 > xterm 视图行为。
|
||||||
|
|
||||||
|
## 12.6 区域过滤与事件归属(强制)
|
||||||
|
|
||||||
|
区域定义(建议使用 `data-zone` 标记):
|
||||||
|
|
||||||
|
1. `terminal-output-zone`:终端可视回显区(仅显示与文本选区)。
|
||||||
|
2. `native-input-zone`:原生输入锚点区(软键盘与 IME 主入口)。
|
||||||
|
3. `app-control-zone`:应用工具栏、按钮、菜单区。
|
||||||
|
4. `app-overlay-zone`:应用弹层、右键菜单、浮层工具区。
|
||||||
|
|
||||||
|
归属规则:
|
||||||
|
|
||||||
|
1. 键盘事件默认归 `native-input-zone` 与应用层快捷键系统;xterm 仅消费“已路由到终端”的标准控制输入。
|
||||||
|
2. 鼠标点击在 `app-control-zone/app-overlay-zone` 必须 100% 透传应用层,禁止被 xterm 截获。
|
||||||
|
3. `wheel/touchmove` 默认交给最近可滚动容器;仅终端主视口滚动时才进入终端滚动链路。
|
||||||
|
4. 文本选择相关事件优先给系统原生选区,xterm 不得覆盖浏览器/系统的选区手柄行为。
|
||||||
|
|
||||||
|
实现约束:
|
||||||
|
|
||||||
|
1. 在捕获阶段先执行 `eventOwnershipRouter`,返回 `APP | NATIVE | XTERM`,再分发事件。
|
||||||
|
2. 未命中白名单事件时默认 `APP`,禁止“默认给 xterm”。
|
||||||
|
3. 仅以下事件允许进入 xterm:`stdin` 字符输入、终端方向键控制序列、明确授权的复制粘贴桥接。
|
||||||
|
4. 右键菜单、双击词选、三击行选等交互由应用策略统一控制,不由 xterm 内建策略隐式决定。
|
||||||
|
|
||||||
|
## 13. 关键风险与强约束
|
||||||
|
|
||||||
|
1. 风险:textarea 模式无法完整支持 TUI 全屏程序。
|
||||||
|
约束:文档中明确为已知限制,不作为首期阻塞项。
|
||||||
|
2. 风险:IME 双发或丢字。
|
||||||
|
约束:保留 `composition + beforeinput/input + fallback` 三层机制和去重窗口。
|
||||||
|
3. 风险:移动端误弹键盘。
|
||||||
|
约束:触摸状态机按优先级表执行,禁止随意新增 `preventDefault` 分支。
|
||||||
|
4. 风险:模式切换导致历史丢失。
|
||||||
|
约束:统一 `OutputBuffer`,切换只换渲染器,不换数据源。
|
||||||
|
5. 风险:xterm 事件劫持导致软键盘、选区、滚动异常。
|
||||||
|
约束:移动端输入以原生 `textarea` 为唯一焦点源,xterm 不持有输入焦点。
|
||||||
|
|
||||||
|
## 14. 分阶段实施计划(可直接执行)
|
||||||
|
|
||||||
|
### 阶段 A:骨架与接口
|
||||||
|
|
||||||
|
1. 建立目录与模块骨架。
|
||||||
|
2. 完成 `sessionMachine + transport + rendererAdapter`。
|
||||||
|
3. 页面可连接、可收发、可显示状态。
|
||||||
|
|
||||||
|
### 阶段 B:xterm 基线能力
|
||||||
|
|
||||||
|
1. 接入 xterm 渲染器。
|
||||||
|
2. 跑通输入、输出、resize、快捷键、粘贴。
|
||||||
|
3. 完成基线测试用例。
|
||||||
|
|
||||||
|
### 阶段 C:textarea 迁移能力
|
||||||
|
|
||||||
|
1. 实现 textarea 渲染器。
|
||||||
|
2. 接入光标/坐标计算与输入法链路。
|
||||||
|
3. 完成 compat 内核 vs textarea 对比测试。
|
||||||
|
|
||||||
|
### 阶段 D:小程序入口接入
|
||||||
|
|
||||||
|
1. 小程序新增入口按钮 + web-view 页面。
|
||||||
|
2. 地址可配置,未配置时只提示不报错。
|
||||||
|
3. 不改主流程逻辑。
|
||||||
|
|
||||||
|
### 阶段 E:删除演练
|
||||||
|
|
||||||
|
1. 删除 `labs/xterm-standalone/`。
|
||||||
|
2. 删除小程序入口页面与按钮。
|
||||||
|
3. 验证主工程构建与现有测试正常。
|
||||||
|
|
||||||
|
## 15. 测试与验收清单
|
||||||
|
|
||||||
|
功能验收:
|
||||||
|
|
||||||
|
1. 连接状态迁移严格符合状态机。
|
||||||
|
2. stdout/stderr 能稳定显示,无额外换行污染。
|
||||||
|
3. Enter 语义对齐 xterm:按键 Enter 发送 `CR`,不发送裸 `LF`。
|
||||||
|
4. 中文输入(含候选提交)无明显丢字/双发。
|
||||||
|
5. 模式切换后输出可重放,连接不中断。
|
||||||
|
6. 光标精准性:同一份输出流在 `compat` 与 `textarea` 模式下,终端光标 `(row,col)` 一致。
|
||||||
|
7. 光标附近弹键盘:仅在光标邻近行轻触触发原生键盘;非邻近行不弹键盘。
|
||||||
|
8. 长按选区场景下,键盘不误弹,原生选区与手柄保持可用。
|
||||||
|
9. `convertEol=false` 时,`LF` 仅下移不回列 0;`convertEol=true` 时 `LF` 同时回列 0。
|
||||||
|
10. `CR` 只回列 0;`NEL` 执行“回列 0 + 下移一行”。
|
||||||
|
11. 粘贴文本行结束归一为 `CR`,并在 bracketed paste 模式下正确包裹。
|
||||||
|
12. 自动折行与显式换行可区分:软换行行具备 `isWrapped=true` 语义。
|
||||||
|
13. 颜色回显一致性:同一输出流在 `compat` 与 `textarea` 模式下,`SGR` 属性(粗体/下划线/反显/隐藏/删除线)渲染结果一致。
|
||||||
|
14. 颜色指令完整性:`30-37/40-47/90-97/100-107/38/48/58` 与 `39/49/59/0/22/24/27/28/29` 复位行为符合预期。
|
||||||
|
15. 扩展色准确性:`38;5;INDEX`、`48;5;INDEX`、`38;2;R;G;B`、`48;2;R;G;B`、`58;2;R;G;B` 能精准渲染且模式切换后不漂移。
|
||||||
|
16. 主题切换约束:切换主题后默认色与索引色生效,历史 TrueColor 单元格颜色值保持不变。
|
||||||
|
17. 主题复用一致性:实验页主题映射与现有 Web/小程序基线一致,同主题下无明显色偏。
|
||||||
|
18. 轻量化约束:运行时不依赖 `@xterm/*`,未启用增强能力不进入首屏加载链路。
|
||||||
|
19. 降级策略有效:`compatLiteMode` 与 `rendererFallback=textarea` 能生效且核心会话稳定。
|
||||||
|
20. 原生响应优先:软键盘弹出/收起、长按选区、滚动手势在移动端与普通 `textarea` 行为一致,无明显被劫持现象。
|
||||||
|
21. 焦点纪律正确:移动端会话中 `textarea` 始终为输入焦点主体,xterm 不直接持有键盘焦点。
|
||||||
|
22. 区域归属正确:`app-control-zone/app-overlay-zone` 的键鼠事件不被 xterm 捕获,应用可独立演进交互逻辑。
|
||||||
|
23. 路由兜底正确:未声明事件默认归应用层处理,不出现“事件默认落到 xterm”导致的不可控行为。
|
||||||
|
24. 备用屏切换正确:`?1049h` 进入全屏后不污染主屏历史,`?1049l` 退出后恢复原提示符与光标位置。
|
||||||
|
25. `47/1047` 兼容正确:切换主/备用屏时两屏内容与滚动状态互不污染。
|
||||||
|
|
||||||
|
性能验收:
|
||||||
|
|
||||||
|
1. 高频输出不明显卡顿(基线:普通开发机可持续滚动)。
|
||||||
|
2. 缓冲裁剪后不出现白屏或大面积闪烁。
|
||||||
|
|
||||||
|
回归验收:
|
||||||
|
|
||||||
|
1. 仅新增小程序入口改动;主页面行为一致。
|
||||||
|
2. 删除实验目录后,主项目 `typecheck/lint/test/build` 通过。
|
||||||
|
|
||||||
|
## 16. xterm 源码对照
|
||||||
|
|
||||||
|
本方案关键规则对应 `xterm.js@5.3.0` 源码:
|
||||||
|
|
||||||
|
1. `Enter -> CR`:`src/common/input/Keyboard.ts`。
|
||||||
|
2. 粘贴换行归一(`/\r?\n/g -> \r`):`src/browser/Clipboard.ts`。
|
||||||
|
3. `LF/VT/FF -> lineFeed`、`CR -> carriageReturn`、`NEL -> nextLine`:`src/common/InputHandler.ts`。
|
||||||
|
4. `convertEol` 默认 `false`,且受 `CSI 20 h/l` 控制:`src/common/services/OptionsService.ts` + `InputHandler.ts`。
|
||||||
|
5. 自动折行(DECAWM)与 `isWrapped`:`src/common/InputHandler.ts#print`。
|
||||||
|
6. 光标全局行号语义(`cursorY` 相对 `baseY`):`typings/xterm.d.ts` 中 `IBuffer` 定义。
|
||||||
|
7. `SGR` 属性解析与颜色扩展(含 `38/48/58`、`39/49/59`、`0/22/24/27/28/29`):`src/common/InputHandler.ts#charAttributes`。
|
||||||
|
8. 主屏/备用屏切换与 `1049` 光标保存恢复:`src/common/InputHandler.ts#setModePrivate` + `resetModePrivate`。
|
||||||
|
9. 缓冲区实现与切屏基础结构:`src/common/buffer/BufferSet.ts`。
|
||||||
|
|
||||||
|
## 17. 完成定义(DoD)
|
||||||
|
|
||||||
|
1. 本文档中的状态机、组件职责、算法规则均已对应到代码模块。
|
||||||
|
2. 实验项目可独立开发与验证,且不污染主业务逻辑。
|
||||||
|
3. 小程序只增加测试入口,无业务耦合。
|
||||||
|
4. 已完成删除演练并记录结果。
|
||||||
|
5. 换行与光标行为已与 `xterm.js@5.3.0` 源码逐条对齐,并有对应验收用例。
|
||||||
|
6. 开发者仅依赖本方案即可按阶段落地实现。
|
||||||
|
7. 主屏/备用屏与 `1049` 光标恢复能力已落地并通过验收用例。
|
||||||
55
xterminal/.github/workflows/deploy.yml
vendored
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
name: Deploy Documentation
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
paths:
|
||||||
|
- docs/**
|
||||||
|
- README.md
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pages: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: "pages"
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
registry-url: https://registry.npmjs.org/
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
name: Install pnpm
|
||||||
|
id: pnpm-install
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
cache: false
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: pnpm docs:build
|
||||||
|
|
||||||
|
- name: Set CNAME
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "xterminal.js.org" > docs/.vitepress/dist/CNAME
|
||||||
|
|
||||||
|
- name: Deploy
|
||||||
|
uses: peaceiris/actions-gh-pages@v3
|
||||||
|
with:
|
||||||
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
publish_dir: docs/.vitepress/dist
|
||||||
60
xterminal/.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
name: Release NPM package
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
paths:
|
||||||
|
- source/**
|
||||||
|
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# Checkout project repository
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Setup git profile
|
||||||
|
- name: git config
|
||||||
|
run: |
|
||||||
|
git config user.name "${GITHUB_ACTOR}"
|
||||||
|
git config user.email "${GITHUB_ACTOR}@users.noreply.github.com"
|
||||||
|
|
||||||
|
# Setup Node.js environment
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
registry-url: https://registry.npmjs.org/
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
# Install pnpm
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
name: Install pnpm
|
||||||
|
id: pnpm-install
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
- name: Run tests
|
||||||
|
run: pnpm test
|
||||||
|
|
||||||
|
# Setup NPM
|
||||||
|
- name: Create .npmrc
|
||||||
|
run: npm config set //registry.npmjs.org/:_authToken $NPM_TOKEN
|
||||||
|
env:
|
||||||
|
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
|
||||||
|
# Publish version to public repository
|
||||||
|
- name: Release
|
||||||
|
run: pnpx release-it --ci
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
42
xterminal/.gitignore
vendored
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# TypeScript cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# dotenv environment variables file
|
||||||
|
.env
|
||||||
|
.env.test
|
||||||
|
|
||||||
|
# Typescript build output
|
||||||
|
out/
|
||||||
|
|
||||||
|
# IDEs
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# VitePress
|
||||||
|
docs/.vitepress/dist
|
||||||
|
docs/.vitepress/cache
|
||||||
|
|
||||||
|
.npmrc
|
||||||
3
xterminal/.husky/pre-commit
Executable file
@@ -0,0 +1,3 @@
|
|||||||
|
echo "Running lint-staged..."
|
||||||
|
|
||||||
|
pnpm exec lint-staged
|
||||||
6
xterminal/.prettierrc.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"tabWidth": 4,
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"trailingComma": "none"
|
||||||
|
}
|
||||||
34
xterminal/.release-it.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"git": {
|
||||||
|
"tagName": "v${version}",
|
||||||
|
"commitMessage": "chore(release): v${version}",
|
||||||
|
"requireBranch": "master"
|
||||||
|
},
|
||||||
|
"changelogFile": "CHANGELOG.md",
|
||||||
|
"github": {
|
||||||
|
"release": true,
|
||||||
|
"releaseName": "XTerminal v${version}",
|
||||||
|
"autoGenerate": true,
|
||||||
|
"assets": ["dist/"],
|
||||||
|
"comments": true
|
||||||
|
},
|
||||||
|
"npm": {
|
||||||
|
"publish": true,
|
||||||
|
"skipChecks": true
|
||||||
|
},
|
||||||
|
"hooks": {
|
||||||
|
"before:init": "git fetch --tags"
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"@release-it/conventional-changelog": {
|
||||||
|
"infile": "CHANGELOG.md",
|
||||||
|
"header": "# Changelog",
|
||||||
|
"preset": {
|
||||||
|
"name": "conventionalcommits"
|
||||||
|
},
|
||||||
|
"gitRawCommitOpts": {
|
||||||
|
"merge": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
294
xterminal/CHANGELOG.md
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 2.2.1 (2026-01-24)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* set isMounted to false when terminal is disposed ([988a610](https://github.com/henryhale/xterminal/commit/988a610d38bfe09d6ea66bfde9c55bdf7f8cb1b6))
|
||||||
|
|
||||||
|
## 2.2.0 (2026-01-19)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* updated release-it config ([e0420b6](https://github.com/henryhale/xterminal/commit/e0420b65ddcb5d817906af1857c05cb67042d4cf))
|
||||||
|
|
||||||
|
##
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file. Dates are displayed in UTC.
|
||||||
|
|
||||||
|
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
||||||
|
|
||||||
|
#### [v2.1.17](https://github.com/henryhale/xterminal/compare/v2.1.16...v2.1.17)
|
||||||
|
|
||||||
|
- feature - setting & clearing input buffer [`#44`](https://github.com/henryhale/xterminal/pull/44)
|
||||||
|
|
||||||
|
#### [v2.1.16](https://github.com/henryhale/xterminal/compare/v2.1.15...v2.1.16)
|
||||||
|
|
||||||
|
> 8 January 2026
|
||||||
|
|
||||||
|
- docs: added more levels on right sidebar [`a45001c`](https://github.com/henryhale/xterminal/commit/a45001c321c5721262f8be0a946db34334f7522c)
|
||||||
|
- chore(feat): added escapeHTML static method to sanitize arbitrary data before printing [`9d702d2`](https://github.com/henryhale/xterminal/commit/9d702d2148026b4b2e923132144f503a4023720e)
|
||||||
|
- chore(release): v2.1.16 [`264a63a`](https://github.com/henryhale/xterminal/commit/264a63a80adeefa978ca2543521563c9a671e31d)
|
||||||
|
|
||||||
|
#### [v2.1.15](https://github.com/henryhale/xterminal/compare/v2.1.14...v2.1.15)
|
||||||
|
|
||||||
|
> 31 December 2025
|
||||||
|
|
||||||
|
- chore: audited deps for vulnerabilities [`7f61dbf`](https://github.com/henryhale/xterminal/commit/7f61dbf5208f96850812d959327352844b49bd0f)
|
||||||
|
- chore(release): v2.1.15 [`8b2b13a`](https://github.com/henryhale/xterminal/commit/8b2b13af1a19edcfdde982d043b04c9477a2b8e1)
|
||||||
|
- chore: remove unnecessary code [`0e09581`](https://github.com/henryhale/xterminal/commit/0e0958105591e5a4e7192ac88c659293d15627cb)
|
||||||
|
|
||||||
|
#### [v2.1.14](https://github.com/henryhale/xterminal/compare/v2.1.13...v2.1.14)
|
||||||
|
|
||||||
|
> 31 December 2025
|
||||||
|
|
||||||
|
- feat: added term.writeSafe and term.writelnSafe output methods [`ab8133e`](https://github.com/henryhale/xterminal/commit/ab8133ee653d7119a133abe3c40b4be3cae5531e)
|
||||||
|
- chore(release): v2.1.14 [`370b3dd`](https://github.com/henryhale/xterminal/commit/370b3dd7ea16a7ecb2d4125ae6a9d1601a359b4f)
|
||||||
|
|
||||||
|
#### [v2.1.13](https://github.com/henryhale/xterminal/compare/v2.1.12...v2.1.13)
|
||||||
|
|
||||||
|
> 7 November 2025
|
||||||
|
|
||||||
|
- chore(release): v2.1.13 [`14d3dd3`](https://github.com/henryhale/xterminal/commit/14d3dd3c209a11040691db85d04ed23f87b1af11)
|
||||||
|
- chore(fix): update default export in types.d.ts [`de7a6ce`](https://github.com/henryhale/xterminal/commit/de7a6ce36d4736b72a074120220ec5f7ba0eeabc)
|
||||||
|
|
||||||
|
#### [v2.1.12](https://github.com/henryhale/xterminal/compare/v2.1.11...v2.1.12)
|
||||||
|
|
||||||
|
> 18 May 2025
|
||||||
|
|
||||||
|
- ci: update node and pnpm version [`8a91783`](https://github.com/henryhale/xterminal/commit/8a917832d23c18d4e1c98cdc049b5d0b1ec9472d)
|
||||||
|
- fix: missing css file in package [`02717aa`](https://github.com/henryhale/xterminal/commit/02717aa51a36e67da12871a23b5edf994d85de93)
|
||||||
|
- chore(release): v2.1.12 [`eeb0eb8`](https://github.com/henryhale/xterminal/commit/eeb0eb83474144fe3874534afe67e1f8335ea668)
|
||||||
|
|
||||||
|
#### [v2.1.11](https://github.com/henryhale/xterminal/compare/v2.1.10...v2.1.11)
|
||||||
|
|
||||||
|
> 21 December 2024
|
||||||
|
|
||||||
|
- chore(release): v2.1.11 [`0c46bc4`](https://github.com/henryhale/xterminal/commit/0c46bc465ad03b384b5b04c6e72b2a81194941b0)
|
||||||
|
- chore(fix): ensure proper types resolution [`3dcdb59`](https://github.com/henryhale/xterminal/commit/3dcdb592290a2162a1df0e88b46264f4f7118abd)
|
||||||
|
- chore: update license year [`92f2f16`](https://github.com/henryhale/xterminal/commit/92f2f16c528823250623a9efde9fedafbf1f6db3)
|
||||||
|
|
||||||
|
#### [v2.1.10](https://github.com/henryhale/xterminal/compare/v2.1.9...v2.1.10)
|
||||||
|
|
||||||
|
> 21 December 2024
|
||||||
|
|
||||||
|
- chore: update browserlist db [`2ce43ed`](https://github.com/henryhale/xterminal/commit/2ce43edc63efab6e720addc383dcacf692523afd)
|
||||||
|
- chore: update dependencies [`773a41b`](https://github.com/henryhale/xterminal/commit/773a41b623bd16ad9caee0e99b3dfa6ef0e4ab88)
|
||||||
|
- chore: update showcase page in docs [`7b0b20c`](https://github.com/henryhale/xterminal/commit/7b0b20c5029c74935d805c81032e95b07b6991fb)
|
||||||
|
|
||||||
|
#### [v2.1.9](https://github.com/henryhale/xterminal/compare/v2.1.8...v2.1.9)
|
||||||
|
|
||||||
|
> 3 April 2024
|
||||||
|
|
||||||
|
- chore(release): v2.1.9 [`0023f4f`](https://github.com/henryhale/xterminal/commit/0023f4fe139f4ebcfa2ae4ab5c039357b71fb5dd)
|
||||||
|
- merge: pull request #35 from henryhale/nxt [`6880722`](https://github.com/henryhale/xterminal/commit/68807229cfae841d4a68b245622c516a7c557958)
|
||||||
|
- chore: update dependencies [`1412436`](https://github.com/henryhale/xterminal/commit/141243665843b23ff62a8a7977a96f422e6a122d)
|
||||||
|
|
||||||
|
#### [v2.1.8](https://github.com/henryhale/xterminal/compare/v2.1.7...v2.1.8)
|
||||||
|
|
||||||
|
> 29 December 2023
|
||||||
|
|
||||||
|
- chore(release): v2.1.8 [`7971ece`](https://github.com/henryhale/xterminal/commit/7971eceec218d3bcdd6ae1a35b4825ce01ce7877)
|
||||||
|
- merge: merge pull request #34 from henryhale/docs/showcase [`941f205`](https://github.com/henryhale/xterminal/commit/941f20507b2fd34fe361d25b7335d9810c92c7ee)
|
||||||
|
- chore: update dependencies [`cc5c254`](https://github.com/henryhale/xterminal/commit/cc5c25425d18e3bf32ac8c11cbeb8a72ee5072d2)
|
||||||
|
|
||||||
|
#### [v2.1.7](https://github.com/henryhale/xterminal/compare/v2.1.6...v2.1.7)
|
||||||
|
|
||||||
|
> 8 October 2023
|
||||||
|
|
||||||
|
- chore(release): v2.1.7 [`3dac7ef`](https://github.com/henryhale/xterminal/commit/3dac7ef91f92620383606c2007f5f0c2df173625)
|
||||||
|
- merge: Merge pull request #33 from henryhale/docs/contributors [`6a6ac77`](https://github.com/henryhale/xterminal/commit/6a6ac7751388f8cb928f5ad9919b65e4f94503c0)
|
||||||
|
- docs: add contributors [`8ab974a`](https://github.com/henryhale/xterminal/commit/8ab974a04e68301fcc8d3c59996a83cc7f411d1c)
|
||||||
|
|
||||||
|
#### [v2.1.6](https://github.com/henryhale/xterminal/compare/v2.1.5...v2.1.6)
|
||||||
|
|
||||||
|
> 8 October 2023
|
||||||
|
|
||||||
|
- chore(release): v2.1.6 [`3659562`](https://github.com/henryhale/xterminal/commit/3659562fb33b65f69488304e7a03f52dd53a58b6)
|
||||||
|
- merge: Merge pull request #32 from henryhale/fix/changelog [`749f5aa`](https://github.com/henryhale/xterminal/commit/749f5aa2c870b1a52c4bd141db779234035a6ecb)
|
||||||
|
- chore(fix): provide a better changelog and release notes [`bf65ec9`](https://github.com/henryhale/xterminal/commit/bf65ec9abd16367cc3f9414e5582e317583d6fa7)
|
||||||
|
|
||||||
|
#### [v2.1.5](https://github.com/henryhale/xterminal/compare/v2.1.4...v2.1.5)
|
||||||
|
|
||||||
|
> 7 October 2023
|
||||||
|
|
||||||
|
- chore(release): v2.1.5 [`42cb622`](https://github.com/henryhale/xterminal/commit/42cb622c5bcba8c9e271e32e78a8049742544213)
|
||||||
|
- merge: Merge pull request #24 from henryhale/chore/update-deps [`b400af1`](https://github.com/henryhale/xterminal/commit/b400af17ed8fe67c035851e88810fb63ef6dcb78)
|
||||||
|
- build: update node js version [`094eb4d`](https://github.com/henryhale/xterminal/commit/094eb4d7677dbea4e7963602c9eb1d624e15e0f2)
|
||||||
|
|
||||||
|
#### [v2.1.4](https://github.com/henryhale/xterminal/compare/v2.1.3...v2.1.4)
|
||||||
|
|
||||||
|
> 7 October 2023
|
||||||
|
|
||||||
|
- chore(release): v2.1.4 [`f4ce706`](https://github.com/henryhale/xterminal/commit/f4ce706a812eb0be1dc0789725d9a68e6abe964e)
|
||||||
|
- merge: Merge pull request #28 from henryhale/docs/update [`e7e4e56`](https://github.com/henryhale/xterminal/commit/e7e4e569d1380d0063941864e429b269fe9d2634)
|
||||||
|
- docs: update readme links and ci deploy [`eed9308`](https://github.com/henryhale/xterminal/commit/eed9308d16a276c67265223b12e6a2b163405bc0)
|
||||||
|
|
||||||
|
#### [v2.1.3](https://github.com/henryhale/xterminal/compare/v2.1.2...v2.1.3)
|
||||||
|
|
||||||
|
> 7 October 2023
|
||||||
|
|
||||||
|
- chore(release): v2.1.3 [`2986705`](https://github.com/henryhale/xterminal/commit/2986705db945898fffb2d93b4757d6cc0c9859d2)
|
||||||
|
- merge: Merge pull request #26 from henryhale/docs/update [`f024a90`](https://github.com/henryhale/xterminal/commit/f024a908e14eef400fe15404a959e3cb17c9e835)
|
||||||
|
- fix(docs): update base url prefix [`69bd494`](https://github.com/henryhale/xterminal/commit/69bd494f08e4bf8bd56c1243874bf533d79cd1a8)
|
||||||
|
|
||||||
|
#### [v2.1.2](https://github.com/henryhale/xterminal/compare/v2.1.1...v2.1.2)
|
||||||
|
|
||||||
|
> 6 October 2023
|
||||||
|
|
||||||
|
- chore(ci): update deploy.yml with CNAME [`a883ea1`](https://github.com/henryhale/xterminal/commit/a883ea159e04862e9daa33dd4237c05f0844b94c)
|
||||||
|
- chore(release): v2.1.2 [`17f8bf4`](https://github.com/henryhale/xterminal/commit/17f8bf4acf4a6f1c3037fc765b80f08d9e1a5a10)
|
||||||
|
|
||||||
|
#### [v2.1.1](https://github.com/henryhale/xterminal/compare/v2.1.0...v2.1.1)
|
||||||
|
|
||||||
|
> 6 October 2023
|
||||||
|
|
||||||
|
- chore(release): v2.1.1 [`3d256cd`](https://github.com/henryhale/xterminal/commit/3d256cd06a27c92d7f9df8774233258c9b09d092)
|
||||||
|
- merge: Merge pull request #25 from henryhale/docs/update [`5417017`](https://github.com/henryhale/xterminal/commit/5417017ad4822a0c745f0b789ed4a5dad169f8cf)
|
||||||
|
- docs: update documentation [`145a702`](https://github.com/henryhale/xterminal/commit/145a702a748e77e6cf786dc48744aa6817513845)
|
||||||
|
|
||||||
|
#### [v2.1.0](https://github.com/henryhale/xterminal/compare/v2.0.12...v2.1.0)
|
||||||
|
|
||||||
|
> 2 October 2023
|
||||||
|
|
||||||
|
- refactor(types): updated typings and readme, added types.ts [`f136a62`](https://github.com/henryhale/xterminal/commit/f136a622318f2df933fef36049125bb9a4a49f0c)
|
||||||
|
- chore(types): remove old typings [`3033710`](https://github.com/henryhale/xterminal/commit/3033710b3e581dc6951a90a7c29e0c67bd6565e5)
|
||||||
|
- feat: provide static eventemitter class, update docs [`0bc9694`](https://github.com/henryhale/xterminal/commit/0bc9694710cb22969fa6b968913cc60beab891de)
|
||||||
|
|
||||||
|
#### [v2.0.12](https://github.com/henryhale/xterminal/compare/v2.0.11...v2.0.12)
|
||||||
|
|
||||||
|
> 1 October 2023
|
||||||
|
|
||||||
|
- chore: switch to conventional-changelog [`37955e7`](https://github.com/henryhale/xterminal/commit/37955e7a1229eb58a9c8f050539c9d1c2bf110c7)
|
||||||
|
- chore(release): v2.0.12 [`8571c33`](https://github.com/henryhale/xterminal/commit/8571c33f13835dcd1f0c78ea6b98a96a15b620a0)
|
||||||
|
|
||||||
|
#### [v2.0.11](https://github.com/henryhale/xterminal/compare/v2.0.10...v2.0.11)
|
||||||
|
|
||||||
|
> 21 August 2023
|
||||||
|
|
||||||
|
- chore(release): v2.0.11 [`6feec19`](https://github.com/henryhale/xterminal/commit/6feec19ff36055f9c76f2d18ce750b2eb99c5b56)
|
||||||
|
- ci: update workflow access settings [`aa79b92`](https://github.com/henryhale/xterminal/commit/aa79b9229be937b7edc1f7648180812ef1278dc9)
|
||||||
|
|
||||||
|
#### [v2.0.10](https://github.com/henryhale/xterminal/compare/v2.0.9...v2.0.10)
|
||||||
|
|
||||||
|
> 21 August 2023
|
||||||
|
|
||||||
|
- chore: updated deps [`6de8f9d`](https://github.com/henryhale/xterminal/commit/6de8f9d9eb0339185682f56093f46c5611f633c2)
|
||||||
|
- chore(release): v2.0.10 [`75779f3`](https://github.com/henryhale/xterminal/commit/75779f31c5e0fe4317d896c2369cb82d634d8f67)
|
||||||
|
- refactor(fix): prevent eslint from ignoring the return type [`cffff93`](https://github.com/henryhale/xterminal/commit/cffff93c211b6344552d7dddc8dd4b189e8339b6)
|
||||||
|
|
||||||
|
#### [v2.0.9](https://github.com/henryhale/xterminal/compare/v2.0.8...v2.0.9)
|
||||||
|
|
||||||
|
> 21 August 2023
|
||||||
|
|
||||||
|
- chore: update deps [`35b4f8e`](https://github.com/henryhale/xterminal/commit/35b4f8e695f2469b15348791772951a24ab565b0)
|
||||||
|
- chore(release): v2.0.9 [`4fe5a35`](https://github.com/henryhale/xterminal/commit/4fe5a350c880905e854474e719e8f4f977395876)
|
||||||
|
- fix: css import error in development [`d66ec90`](https://github.com/henryhale/xterminal/commit/d66ec9008d621207ee42bdd7d4839e8577348821)
|
||||||
|
|
||||||
|
#### [v2.0.8](https://github.com/henryhale/xterminal/compare/v2.0.7...v2.0.8)
|
||||||
|
|
||||||
|
> 19 July 2023
|
||||||
|
|
||||||
|
- chore(release): v2.0.8 [`3e2a0e0`](https://github.com/henryhale/xterminal/commit/3e2a0e053313ca23ebddfa4a77e06b9622b21900)
|
||||||
|
- add dev server #17 [`718ecb3`](https://github.com/henryhale/xterminal/commit/718ecb394ef67a645e7970ee1f8e4bb6311fb143)
|
||||||
|
- feat: add vite [`fe068c5`](https://github.com/henryhale/xterminal/commit/fe068c5f9daac12658b6225f82f0b0e562c9cd74)
|
||||||
|
|
||||||
|
#### [v2.0.7](https://github.com/henryhale/xterminal/compare/v2.0.6...v2.0.7)
|
||||||
|
|
||||||
|
> 14 July 2023
|
||||||
|
|
||||||
|
- chore: release/changelog update [`#20`](https://github.com/henryhale/xterminal/pull/20)
|
||||||
|
- chore(release): v2.0.7 [`f8ec9ca`](https://github.com/henryhale/xterminal/commit/f8ec9ca4bb47bbc2cf6051c7c2ee5a91fc7b554b)
|
||||||
|
|
||||||
|
#### [v2.0.6](https://github.com/henryhale/xterminal/compare/v2.0.5...v2.0.6)
|
||||||
|
|
||||||
|
> 14 July 2023
|
||||||
|
|
||||||
|
- ci: update release.yml, bump pnpm version [`#19`](https://github.com/henryhale/xterminal/pull/19)
|
||||||
|
- chore(release): v2.0.6 [`5fef58a`](https://github.com/henryhale/xterminal/commit/5fef58a8d349bab4570a4dd8cf400d905329215d)
|
||||||
|
|
||||||
|
#### [v2.0.5](https://github.com/henryhale/xterminal/compare/v2.0.4...v2.0.5)
|
||||||
|
|
||||||
|
> 14 July 2023
|
||||||
|
|
||||||
|
- revert: "merge: release/changelog update (#16)" [`#18`](https://github.com/henryhale/xterminal/pull/18)
|
||||||
|
- merge: release/changelog update [`#16`](https://github.com/henryhale/xterminal/pull/16)
|
||||||
|
- chore(release): v2.0.5 [`200e07e`](https://github.com/henryhale/xterminal/commit/200e07e41630f886ad8770c8b36a4e5d7b16d6fd)
|
||||||
|
|
||||||
|
#### [v2.0.4](https://github.com/henryhale/xterminal/compare/v2.0.3...v2.0.4)
|
||||||
|
|
||||||
|
> 13 July 2023
|
||||||
|
|
||||||
|
- docs/update [`#15`](https://github.com/henryhale/xterminal/pull/15)
|
||||||
|
- chore(release): v2.0.4 [`80d7015`](https://github.com/henryhale/xterminal/commit/80d7015427e1f3945275ecb3517d6896e9901b9f)
|
||||||
|
|
||||||
|
#### [v2.0.3](https://github.com/henryhale/xterminal/compare/v2.0.2...v2.0.3)
|
||||||
|
|
||||||
|
> 13 July 2023
|
||||||
|
|
||||||
|
- docs/update readme [`#14`](https://github.com/henryhale/xterminal/pull/14)
|
||||||
|
- chore(release): v2.0.3 [`5fffcfc`](https://github.com/henryhale/xterminal/commit/5fffcfca5880a60f05c74d567945ed315f882502)
|
||||||
|
|
||||||
|
#### [v2.0.2](https://github.com/henryhale/xterminal/compare/v2.0.1...v2.0.2)
|
||||||
|
|
||||||
|
> 12 July 2023
|
||||||
|
|
||||||
|
- docs(readme): add contribution guide, docs reference, basic usage and inspiration [`#13`](https://github.com/henryhale/xterminal/pull/13)
|
||||||
|
- chore(release): v2.0.2 [`42ddc40`](https://github.com/henryhale/xterminal/commit/42ddc40897185d80e325fb9ed82cd640eda88d2b)
|
||||||
|
|
||||||
|
#### [v2.0.1](https://github.com/henryhale/xterminal/compare/v2.0.0...v2.0.1)
|
||||||
|
|
||||||
|
> 11 July 2023
|
||||||
|
|
||||||
|
- fix: trigger keypress event on every key pressed [`#12`](https://github.com/henryhale/xterminal/pull/12)
|
||||||
|
- chore(release): v2.0.1 [`a3cf93d`](https://github.com/henryhale/xterminal/commit/a3cf93d3474c5b97933872fab32e9d38790cc323)
|
||||||
|
|
||||||
|
### [v2.0.0](https://github.com/henryhale/xterminal/compare/v1.1.3...v2.0.0)
|
||||||
|
|
||||||
|
> 7 July 2023
|
||||||
|
|
||||||
|
- chore: next codebase [`#10`](https://github.com/henryhale/xterminal/pull/10)
|
||||||
|
- chore: added media files [`#9`](https://github.com/henryhale/xterminal/pull/9)
|
||||||
|
- docs(ci): use pnpm to fix setup error in gh actions [`#8`](https://github.com/henryhale/xterminal/pull/8)
|
||||||
|
- docs(ci): updated documentation, deploy to github pages [`#6`](https://github.com/henryhale/xterminal/pull/6)
|
||||||
|
- chore(release): v2.0.0 [`71c4e5f`](https://github.com/henryhale/xterminal/commit/71c4e5fcd4b7a2e123a53992d1528759f32357f1)
|
||||||
|
|
||||||
|
#### [v1.1.3](https://github.com/henryhale/xterminal/compare/v1.1.2...v1.1.3)
|
||||||
|
|
||||||
|
> 6 July 2023
|
||||||
|
|
||||||
|
- merge(#5) : Produce both ESM and UMD builds [`2643436`](https://github.com/henryhale/xterminal/commit/2643436115a35fc01682f303fd08d705ceb89141)
|
||||||
|
- chore(release): v1.1.3 [`b02a107`](https://github.com/henryhale/xterminal/commit/b02a107363d0416ef77db6683a75a01f0ecffdb4)
|
||||||
|
|
||||||
|
#### [v1.1.2](https://github.com/henryhale/xterminal/compare/v1.1.0...v1.1.2)
|
||||||
|
|
||||||
|
> 6 July 2023
|
||||||
|
|
||||||
|
- chore(ci): switch from semantic-release to release-it [`#4`](https://github.com/henryhale/xterminal/pull/4)
|
||||||
|
- ci: employ semantic-release to manage releases [`5eabc90`](https://github.com/henryhale/xterminal/commit/5eabc90d523d3d5c7da3a17777d7807de656cc38)
|
||||||
|
- docs: initial commit [`3892437`](https://github.com/henryhale/xterminal/commit/3892437eccee70e1b1e29c4fddd50a93aa17ae5c)
|
||||||
|
- chore(changelog): use conventional commits based on angular [`65d5108`](https://github.com/henryhale/xterminal/commit/65d51083ab7d77c3af2c058cc948e6f82e726a0d)
|
||||||
|
|
||||||
|
#### [v1.1.0](https://github.com/henryhale/xterminal/compare/v1.0.0...v1.1.0)
|
||||||
|
|
||||||
|
> 27 April 2023
|
||||||
|
|
||||||
|
- refactor: enable key mapping for old browsers [`bade49d`](https://github.com/henryhale/xterminal/commit/bade49d0a524ca8815b71f594af16198c811e16a)
|
||||||
|
- css: added media query [`f9f203b`](https://github.com/henryhale/xterminal/commit/f9f203b2921ef5bc4011c586a565f87a5049631c)
|
||||||
|
- chore: update type declarations [`3d191bb`](https://github.com/henryhale/xterminal/commit/3d191bbd3353ba4d77dffec9041f6ae18e482c61)
|
||||||
|
|
||||||
|
### [v1.0.0](https://github.com/henryhale/xterminal/compare/v0.1.1...v1.0.0)
|
||||||
|
|
||||||
|
> 24 April 2023
|
||||||
|
|
||||||
|
- chore: added lock file for resolving deps fast [`63f4445`](https://github.com/henryhale/xterminal/commit/63f44455c471268fb9f1eeac18c443ddb9d85531)
|
||||||
|
- build: dev dependencies upgrade [`5823d4c`](https://github.com/henryhale/xterminal/commit/5823d4cefb0147b35b457f0a744303d267d8703b)
|
||||||
|
- chore: added husky, prettier, and some fixes [`e76b4a1`](https://github.com/henryhale/xterminal/commit/e76b4a17f4e1a0e340bad649358cb7692bc8fc85)
|
||||||
|
|
||||||
|
#### v0.1.1
|
||||||
|
|
||||||
|
> 7 March 2023
|
||||||
|
|
||||||
|
- initial commit [`7e3b5f8`](https://github.com/henryhale/xterminal/commit/7e3b5f8cd30809a106c6b3acf24b594652bb0a16)
|
||||||
|
- chore: added github action to publish package [`62c196e`](https://github.com/henryhale/xterminal/commit/62c196efc3bdb7369d207b9bee6b7ca9fab4095a)
|
||||||
|
- fix: github action - install pnpm first [`7611d4d`](https://github.com/henryhale/xterminal/commit/7611d4de02f3fdc70f2cb2443ea3dd64ae6382c3)
|
||||||
21
xterminal/LICENSE.txt
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2023-present Henry Hale (https://github.com/henryhale)
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
112
xterminal/README.md
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
<div align="center">
|
||||||
|
<img width="75" height="75" src="https://raw.githubusercontent.com/henryhale/xterminal/master/assets/logo-rounded.png" />
|
||||||
|
<h1>XTerminal</h1>
|
||||||
|
<p><i>Build Web-based Command-line Interfaces.</i></p>
|
||||||
|
<img alt="npm" src="https://img.shields.io/npm/v/xterminal">
|
||||||
|
<img alt="GitHub release (latest SemVer)" src="https://img.shields.io/github/v/release/henryhale/xterminal">
|
||||||
|
<img alt="npm bundle size" src="https://img.shields.io/bundlephobia/minzip/xterminal">
|
||||||
|
<img alt="GitHub" src="https://img.shields.io/github/license/henryhale/xterminal">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
**Welcome to XTerminal!**
|
||||||
|
|
||||||
|
This library provides a simple, lightweight and perfomant solution for creating interactive web-based command-line interfaces (CLIs) with ease in your web applications. It aims to be a minimalist dependency-free alternative to popular libraries like [jquery.terminal](https://github.com/jcubic/jquery.terminal), offering improved performance and a simplified approach.
|
||||||
|
|
||||||
|
## Inspiration
|
||||||
|
|
||||||
|
This project draws inspiration from the powerful [node:readline](https://nodejs.org/api/readline.html) module in Node.js, which has proven to be a reliable and efficient tool for command-line interactions. Additionally, inspiration is derived from the versatility and functionality of [xterm.js](https://github.com/xtermjs), a widely adopted library for web-based terminal emulators.
|
||||||
|
|
||||||
|
By combining the best features and ideas from these sources, this library aims to provide an accessible and performant solution for creating web-based CLIs that meet the needs of modern web applications.
|
||||||
|
|
||||||
|
## Live Demo
|
||||||
|
|
||||||
|
There is a quick demo online: [Try It Yourself](https://xterminal.js.org/demo/).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
To install `xterminal`, use [npm](https://npmjs.org/xterminal):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install xterminal
|
||||||
|
```
|
||||||
|
|
||||||
|
alternatively use [unpkg](https://unpkg.com/xterminal) or [jsdelivr](https://cdn.jsdelivr.net/npm/xterminal).
|
||||||
|
|
||||||
|
### Basic Usage
|
||||||
|
|
||||||
|
Import the package and create a new instance of the `XTerminal` class:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<link rel='stylesheet' href='https://unpkg.com/xterminal/dist/xterminal.css'>
|
||||||
|
|
||||||
|
<div id="app"></div>
|
||||||
|
|
||||||
|
<script src='https://unpkg.com/xterminal/dist/xterminal.umd.js'></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const term = new XTerminal()
|
||||||
|
term.mount('#app');
|
||||||
|
term.write('Hello World!\n# ');
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
For more detailed information, please refer to the [official documentation](https://xterminal.js.org/) online.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
The complete documentation for `XTerminal` can be found [here](https://xterminal.js.org/). It provides detailed information on installation, configuration, usage, and advanced features. You'll also find code examples and API references.
|
||||||
|
|
||||||
|
The full Public API for `XTerminal` can also be found within this [TypeScript declaration file](https://github.com/henryhale/xterminal/blob/master/source/types.ts).
|
||||||
|
|
||||||
|
## Showcase
|
||||||
|
|
||||||
|
Several projects that are using XTerminal are listed [here](https://xterminal.js.org/showcase). Feel free to open a pull request adding your project in this [file](https://github.com/henryhale/xterminal/blob/master/docs/showcase/index.md).
|
||||||
|
|
||||||
|
## Browser Support
|
||||||
|
|
||||||
|
Supporting wide range of browsers is the goal. Modern browsers, most specifically the latest versions of Chrome, Firefox, Safari, and Edge (for desktop and mobile devices) are supported.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Thank you for checking out this awesome project. Any contributions to the project are [appreciated](https://xterminal.js.org/about/team.html), whether it's fixing bugs, adding new features, or improving documentation. To contribute, please follow these guidelines:
|
||||||
|
|
||||||
|
- **Issues**: Before starting to work on a new feature or bug fix, please check the issue tracker to see if the task is already in progress or has been reported. If not, feel free to [open a new issue](https://github.com/henryhale/xterminal/issues/new) to discuss the proposed changes or bug fixes.
|
||||||
|
- **Branching**: Create a new branch for each feature or bug fix you are working on. Use clear descriptive branch names that reflect the purpose of your changes e.g. `feature/events` or `bugfix/issue-1234`.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
To get started with development, follow these steps:
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- [Node.js](https://nodejs.org) (>=22)
|
||||||
|
- [pnpm](https://pnpm.io/) (>=10)
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
1. Clone this repository: `git clone https://github.com/henryhale/xterminal.git`
|
||||||
|
2. Navigate to the project directory: `cd xterminal`
|
||||||
|
3. Install dependencies: `pnpm install`
|
||||||
|
4. Start the development server: `pnpm dev`
|
||||||
|
|
||||||
|
### Building the Library
|
||||||
|
|
||||||
|
To build the library, run `pnpm build`
|
||||||
|
|
||||||
|
This will generate the production-ready distribution files in the `dist` directory.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [chalk-dom](https://github.com/henryhale/chalk-dom) - Chalk for the browser
|
||||||
|
- [inken](https://github.com/henryhale/inken) - Terminal-like string styling for the browser
|
||||||
|
- [viteshell](https://github.com/henryhale/viteshell) - A minimal bash-like shell implementation written in TypeScript
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Copyright (c) 2023-present [Henry Hale](https://github.com/henryhale/).
|
||||||
|
|
||||||
|
Released under the [MIT License](https://github.com/henryhale/xterminal/blob/master/LICENSE.txt).
|
||||||
BIN
xterminal/assets/internals.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
xterminal/assets/logo-rounded.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
xterminal/assets/logo.png
Normal file
|
After Width: | Height: | Size: 310 B |
9
xterminal/assets/logo.svg
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<svg width="100" height="100" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g>
|
||||||
|
<title>XTerminal Logo</title>
|
||||||
|
<path stroke="#000" d="m-4,-2.5l103.99998,0l0,108l-103.99998,0l0,-108z" stroke-width="0" fill="#444444"/>
|
||||||
|
<rect stroke="#000" height="33" width="16" y="29" x="42" stroke-width="0" fill="#ffffff"/>
|
||||||
|
<rect transform="rotate(-45 23.25 40.25)" stroke="#000" height="18.56497" width="6.17157" y="30.96751" x="20.16421" stroke-width="0" fill="#ffffff"/>
|
||||||
|
<rect transform="rotate(45 22.6036 50.25)" stroke="#000" height="18.75736" width="6" y="40.87132" x="19.60356" stroke-width="0" fill="#ffffff"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 616 B |
1
xterminal/assets/media/black-square.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.dev/svgjs" width="1000" height="1000" viewBox="0 0 1000 1000"><rect width="1000" height="1000" fill="#ffffff"></rect><g transform="matrix(0.7,0,0,0.7,149.57575757575756,415.9384332941345)"><svg viewBox="0 0 396 95" data-background-color="#01ff70" preserveAspectRatio="xMidYMid meet" height="239" width="1000" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><g id="tight-bounds" transform="matrix(1,0,0,1,0.2400000000000091,0.23370494355782512)"><svg viewBox="0 0 395.52 94.53259011288438" height="94.53259011288438" width="395.52"><g><svg viewBox="0 0 395.52 94.53259011288438" height="94.53259011288438" width="395.52"><g><svg viewBox="0 0 395.52 94.53259011288438" height="94.53259011288438" width="395.52"><g id="textblocktransform"><svg viewBox="0 0 395.52 94.53259011288438" height="94.53259011288438" width="395.52" id="textblock"><g><svg viewBox="0 0 395.52 94.53259011288438" height="94.53259011288438" width="395.52"><rect width="395.52" height="94.53259011288438" x="0" y="0" opacity="1" fill="#000000" data-fill-palette-color="tertiary"></rect><g transform="matrix(1,0,0,1,23.607599999999998,23.607599999999994)"><svg width="348.3048" viewBox="0.75 -34.900001525878906 256.8800048828125 34.900001525878906" height="47.31739011288439" data-palette-color="#01ff70"><path d="M19.8-17.9L29.85 0 21.6 0 15.25-12.5 14.55-12.5 8.4 0 0.75 0 10.6-17.9 1.3-34.9 9.55-34.9 15.15-23.45 15.95-23.45 21.6-34.9 29.25-34.9 19.8-17.9ZM55.7-28.65L47.35-28.65 47.35 0 40.2 0 40.2-28.65 31.85-28.65 31.85-34.9 55.7-34.9 55.7-28.65ZM81.95 0L60.35 0 60.35-34.9 81.95-34.9 81.95-28.65 67.5-28.65 67.5-20.75 79.75-20.75 79.75-14.55 67.5-14.55 67.5-6.25 81.95-6.25 81.95 0ZM94.94-13.2L94.94 0 87.79 0 87.79-34.9 102.89-34.9Q107.59-34.9 110.04-31.95 112.49-29 112.49-24.05L112.49-24.05Q112.49-20.2 111.04-17.55 109.59-14.9 106.74-13.85L106.74-13.85 112.84 0 105.09 0 99.69-13.2 94.94-13.2ZM94.94-19.15L101.99-19.15Q103.44-19.15 104.27-19.88 105.09-20.6 105.09-22.4L105.09-22.4 105.09-25.4Q105.09-27.2 104.27-27.93 103.44-28.65 101.99-28.65L101.99-28.65 94.94-28.65 94.94-19.15ZM141.64 0L141.64-18.2 142.04-24.05 141.24-24.05 139.09-18.5 133.29-5.85 127.54-18.5 125.39-24.05 124.59-24.05 124.99-18.2 124.99 0 118.29 0 118.29-34.9 125.79-34.9 130.54-24.5 133.14-17.05 133.64-17.05 136.24-24.5 140.89-34.9 148.34-34.9 148.34 0 141.64 0ZM169.39 0L154.04 0 154.04-5.65 158.14-5.65 158.14-29.25 154.04-29.25 154.04-34.9 169.39-34.9 169.39-29.25 165.29-29.25 165.29-5.65 169.39-5.65 169.39 0ZM193.14 0L184.34-18.7 182.14-24.25 181.34-24.25 181.74-18.4 181.74 0 175.04 0 175.04-34.9 182.84-34.9 191.64-16.2 193.84-10.65 194.64-10.65 194.24-16.5 194.24-34.9 200.94-34.9 200.94 0 193.14 0ZM234.23 0L227.18 0 225.03-8.55 214.33-8.55 212.18 0 205.33 0 215.23-34.9 224.33-34.9 234.23 0ZM223.28-14.3L221.13-22.35 220.03-28.4 219.23-28.4 218.13-22.35 216.08-14.3 223.28-14.3ZM257.63 0L238.63 0 238.63-34.9 245.78-34.9 245.78-6.25 257.63-6.25 257.63 0Z" opacity="1" transform="matrix(1,0,0,1,0,0)" fill="#ffffff" class="wordmark-text-0" data-fill-palette-color="quaternary" id="text-0"></path></svg></g></svg></g></svg></g></svg></g></svg></g><defs></defs></svg><rect width="395.52" height="94.53259011288438" fill="none" stroke="none" visibility="hidden"></rect></g></svg></g></svg>
|
||||||
|
After Width: | Height: | Size: 3.3 KiB |
1
xterminal/assets/media/black.svg
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
6
xterminal/assets/media/readme.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# Media
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
1
xterminal/assets/media/white-square.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.dev/svgjs" width="1000" height="1000" viewBox="0 0 1000 1000"><rect width="1000" height="1000" fill="#000000"></rect><g transform="matrix(0.7,0,0,0.7,149.57575757575756,415.9384332941345)"><svg viewBox="0 0 396 95" data-background-color="#01ff70" preserveAspectRatio="xMidYMid meet" height="239" width="1000" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><g id="tight-bounds" transform="matrix(1,0,0,1,0.2400000000000091,0.23370494355782512)"><svg viewBox="0 0 395.52 94.53259011288438" height="94.53259011288438" width="395.52"><g><svg viewBox="0 0 395.52 94.53259011288438" height="94.53259011288438" width="395.52"><g><svg viewBox="0 0 395.52 94.53259011288438" height="94.53259011288438" width="395.52"><g id="textblocktransform"><svg viewBox="0 0 395.52 94.53259011288438" height="94.53259011288438" width="395.52" id="textblock"><g><svg viewBox="0 0 395.52 94.53259011288438" height="94.53259011288438" width="395.52"><rect width="395.52" height="94.53259011288438" x="0" y="0" opacity="1" fill="#ffffff" data-fill-palette-color="tertiary"></rect><g transform="matrix(1,0,0,1,23.607599999999998,23.607599999999994)"><svg width="348.3048" viewBox="0.75 -34.900001525878906 256.8800048828125 34.900001525878906" height="47.31739011288439" data-palette-color="#01ff70"><path d="M19.8-17.9L29.85 0 21.6 0 15.25-12.5 14.55-12.5 8.4 0 0.75 0 10.6-17.9 1.3-34.9 9.55-34.9 15.15-23.45 15.95-23.45 21.6-34.9 29.25-34.9 19.8-17.9ZM55.7-28.65L47.35-28.65 47.35 0 40.2 0 40.2-28.65 31.85-28.65 31.85-34.9 55.7-34.9 55.7-28.65ZM81.95 0L60.35 0 60.35-34.9 81.95-34.9 81.95-28.65 67.5-28.65 67.5-20.75 79.75-20.75 79.75-14.55 67.5-14.55 67.5-6.25 81.95-6.25 81.95 0ZM94.94-13.2L94.94 0 87.79 0 87.79-34.9 102.89-34.9Q107.59-34.9 110.04-31.95 112.49-29 112.49-24.05L112.49-24.05Q112.49-20.2 111.04-17.55 109.59-14.9 106.74-13.85L106.74-13.85 112.84 0 105.09 0 99.69-13.2 94.94-13.2ZM94.94-19.15L101.99-19.15Q103.44-19.15 104.27-19.88 105.09-20.6 105.09-22.4L105.09-22.4 105.09-25.4Q105.09-27.2 104.27-27.93 103.44-28.65 101.99-28.65L101.99-28.65 94.94-28.65 94.94-19.15ZM141.64 0L141.64-18.2 142.04-24.05 141.24-24.05 139.09-18.5 133.29-5.85 127.54-18.5 125.39-24.05 124.59-24.05 124.99-18.2 124.99 0 118.29 0 118.29-34.9 125.79-34.9 130.54-24.5 133.14-17.05 133.64-17.05 136.24-24.5 140.89-34.9 148.34-34.9 148.34 0 141.64 0ZM169.39 0L154.04 0 154.04-5.65 158.14-5.65 158.14-29.25 154.04-29.25 154.04-34.9 169.39-34.9 169.39-29.25 165.29-29.25 165.29-5.65 169.39-5.65 169.39 0ZM193.14 0L184.34-18.7 182.14-24.25 181.34-24.25 181.74-18.4 181.74 0 175.04 0 175.04-34.9 182.84-34.9 191.64-16.2 193.84-10.65 194.64-10.65 194.24-16.5 194.24-34.9 200.94-34.9 200.94 0 193.14 0ZM234.23 0L227.18 0 225.03-8.55 214.33-8.55 212.18 0 205.33 0 215.23-34.9 224.33-34.9 234.23 0ZM223.28-14.3L221.13-22.35 220.03-28.4 219.23-28.4 218.13-22.35 216.08-14.3 223.28-14.3ZM257.63 0L238.63 0 238.63-34.9 245.78-34.9 245.78-6.25 257.63-6.25 257.63 0Z" opacity="1" transform="matrix(1,0,0,1,0,0)" fill="#000000" class="wordmark-text-0" data-fill-palette-color="quaternary" id="text-0"></path></svg></g></svg></g></svg></g></svg></g></svg></g><defs></defs></svg><rect width="395.52" height="94.53259011288438" fill="none" stroke="none" visibility="hidden"></rect></g></svg></g></svg>
|
||||||
|
After Width: | Height: | Size: 3.3 KiB |
1
xterminal/assets/media/white.svg
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
3
xterminal/babel.config.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"presets": ["@babel/preset-env"]
|
||||||
|
}
|
||||||
31
xterminal/demo/index.html
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1.0, user-scalable=0"
|
||||||
|
>
|
||||||
|
<title>Demo | XTerminal</title>
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="demo-shell">
|
||||||
|
<div id="app"></div>
|
||||||
|
<div id="touch-tools" class="tc-touch-tools">
|
||||||
|
<button class="tc-key-btn" data-action="up">↑</button>
|
||||||
|
<button class="tc-key-btn" data-action="down">↓</button>
|
||||||
|
<button class="tc-key-btn" data-action="left">←</button>
|
||||||
|
<button class="tc-key-btn" data-action="right">→</button>
|
||||||
|
<button class="tc-key-btn" data-action="tab">Tab</button>
|
||||||
|
<button class="tc-key-btn" data-action="ls">ls</button>
|
||||||
|
<button class="tc-key-btn" data-action="enter">Enter</button>
|
||||||
|
<button class="tc-key-btn" data-action="ctrlc">Ctrl+C</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script type="module" src="main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
844
xterminal/demo/main.js
Normal file
@@ -0,0 +1,844 @@
|
|||||||
|
import XTerminal from "../out/index.js";
|
||||||
|
import terminalConfig from "../terminal.config.json";
|
||||||
|
|
||||||
|
// 证书路径由网关服务端使用,前端仅保留常量便于排障对齐。
|
||||||
|
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";
|
||||||
|
|
||||||
|
const term = new XTerminal();
|
||||||
|
term.mount("#app");
|
||||||
|
|
||||||
|
let socket = null;
|
||||||
|
let connected = false;
|
||||||
|
let ansiRemainder = "";
|
||||||
|
let lastFingerprint = "";
|
||||||
|
const pendingInputs = [];
|
||||||
|
let pendingNoticeShown = false;
|
||||||
|
// 将短时间内的 stdout/stderr 分片合并后再渲染,避免 `a` 与 `\b as` 跨帧导致视觉重影。
|
||||||
|
const STREAM_BATCH_MS = 12;
|
||||||
|
let stdoutBatch = "";
|
||||||
|
let stderrBatch = "";
|
||||||
|
let streamFlushTimer = 0;
|
||||||
|
// 回车问题定位开关:开启后会在 Console 输出“回车 -> 发送 -> 回显”链路日志。
|
||||||
|
const DEBUG_ENTER_FLOW = false;
|
||||||
|
// 是否启用底部触控工具条
|
||||||
|
const ENABLE_TOUCH_TOOLS = true;
|
||||||
|
const DEBUG_WINDOW_MS = 5000;
|
||||||
|
let enterSeq = 0;
|
||||||
|
let lastEnterMeta = { id: 0, at: 0, input: "" };
|
||||||
|
|
||||||
|
function setConnected(next) {
|
||||||
|
connected = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeSystem(message) {
|
||||||
|
term.writelnSafe(`[system] ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toVisibleText(data) {
|
||||||
|
return String(data || "")
|
||||||
|
.replace(/\u001b/g, "<ESC>")
|
||||||
|
.replace(/\r/g, "<CR>")
|
||||||
|
.replace(/\n/g, "<LF>\n")
|
||||||
|
.replace(/\t/g, "<TAB>")
|
||||||
|
.replace(/\x08/g, "<BS>");
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCharCodes(data, limit = 80) {
|
||||||
|
const list = Array.from(String(data || ""), (ch) => ch.charCodeAt(0));
|
||||||
|
const head = list.slice(0, limit).join(" ");
|
||||||
|
return list.length > limit ? `${head} ... (total=${list.length})` : head;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWithinEnterDebugWindow() {
|
||||||
|
return Date.now() - lastEnterMeta.at <= DEBUG_WINDOW_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function logEnterFlow(stage, data, extra = "") {
|
||||||
|
if (!DEBUG_ENTER_FLOW) return;
|
||||||
|
if (!lastEnterMeta.at) return;
|
||||||
|
if (!isWithinEnterDebugWindow()) return;
|
||||||
|
const delta = Date.now() - lastEnterMeta.at;
|
||||||
|
const text = String(data || "");
|
||||||
|
const suffix = extra ? ` ${extra}` : "";
|
||||||
|
console.log(
|
||||||
|
`[xterm-debug][${stage}] enter#${lastEnterMeta.id} +${delta}ms len=${text.length}${suffix}`
|
||||||
|
);
|
||||||
|
console.log(`[xterm-debug][${stage}] text: ${toVisibleText(text)}`);
|
||||||
|
console.log(`[xterm-debug][${stage}] code: ${toCharCodes(text)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findSelectedServer(config) {
|
||||||
|
const list = Array.isArray(config.servers) ? config.servers : [];
|
||||||
|
if (!list.length) {
|
||||||
|
throw new Error("terminal.config.json 未配置 servers");
|
||||||
|
}
|
||||||
|
if (config.selectedServerId) {
|
||||||
|
const found = list.find((item) => item.id === config.selectedServerId);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
return list[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCredential(server) {
|
||||||
|
if (server.authType === "password") {
|
||||||
|
if (!server.password) {
|
||||||
|
throw new Error("authType=password 但缺少 password");
|
||||||
|
}
|
||||||
|
return { type: "password", password: server.password };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.authType === "privateKey") {
|
||||||
|
if (!server.privateKey) {
|
||||||
|
throw new Error("authType=privateKey 但缺少 privateKey");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: "privateKey",
|
||||||
|
privateKey: server.privateKey,
|
||||||
|
...(server.passphrase ? { passphrase: server.passphrase } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.authType === "certificate") {
|
||||||
|
if (!server.privateKey || !server.certificate) {
|
||||||
|
throw new Error(
|
||||||
|
"authType=certificate 但缺少 privateKey 或 certificate"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: "certificate",
|
||||||
|
privateKey: server.privateKey,
|
||||||
|
certificate: server.certificate,
|
||||||
|
...(server.passphrase ? { passphrase: server.passphrase } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`不支持的 authType: ${String(server.authType || "")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGatewayEndpoint(config) {
|
||||||
|
const pageProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
const rawInput = String(config.gatewayUrl || "").trim();
|
||||||
|
|
||||||
|
if (!rawInput) {
|
||||||
|
throw new Error("缺少 gatewayUrl:为避免误连受保护站点,已禁用默认兜底地址");
|
||||||
|
}
|
||||||
|
|
||||||
|
let url;
|
||||||
|
try {
|
||||||
|
if (rawInput.startsWith("/")) {
|
||||||
|
// 相对路径仅允许落到当前页面域名,避免误连其它受保护站点。
|
||||||
|
url = new URL(`${pageProtocol}//${window.location.host}${rawInput}`);
|
||||||
|
} else if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(rawInput)) {
|
||||||
|
url = new URL(rawInput);
|
||||||
|
} else {
|
||||||
|
url = new URL(`${pageProtocol}//${rawInput}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`gatewayUrl 无效: ${rawInput} (${error instanceof Error ? error.message : String(error)})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.protocol === "http:") url.protocol = "ws:";
|
||||||
|
if (url.protocol === "https:") url.protocol = "wss:";
|
||||||
|
|
||||||
|
const pathname = url.pathname.replace(/\/+$/, "");
|
||||||
|
url.pathname = pathname.endsWith("/ws/terminal")
|
||||||
|
? pathname
|
||||||
|
: `${pathname}/ws/terminal`.replace(/\/{2,}/g, "/");
|
||||||
|
|
||||||
|
// 握手阶段禁止携带业务参数,仅保留认证信息(token)。
|
||||||
|
url.search = "";
|
||||||
|
const gatewayToken = String(config.gatewayToken || "").trim();
|
||||||
|
if (gatewayToken) {
|
||||||
|
url.searchParams.set("token", gatewayToken);
|
||||||
|
}
|
||||||
|
url.hash = "";
|
||||||
|
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendFrame(frame) {
|
||||||
|
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||||
|
throw new Error("网关连接未建立");
|
||||||
|
}
|
||||||
|
socket.send(JSON.stringify(frame));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ANSI SGR 颜色映射(标准 16 色)。
|
||||||
|
* 说明:
|
||||||
|
* - 终端输出若包含 `\x1b[31m` 这类序列,会在这里转换为对应 CSS 颜色;
|
||||||
|
* - 不引入额外依赖,避免增加生产包体与维护复杂度。
|
||||||
|
*/
|
||||||
|
const ANSI_COLOR_TABLE = {
|
||||||
|
30: "#1c1c1c",
|
||||||
|
31: "#d70000",
|
||||||
|
32: "#008700",
|
||||||
|
33: "#875f00",
|
||||||
|
34: "#005faf",
|
||||||
|
35: "#870087",
|
||||||
|
36: "#008787",
|
||||||
|
37: "#bcbcbc",
|
||||||
|
90: "#767676",
|
||||||
|
91: "#ff5f5f",
|
||||||
|
92: "#5fff5f",
|
||||||
|
93: "#ffd75f",
|
||||||
|
94: "#5fafff",
|
||||||
|
95: "#ff5fff",
|
||||||
|
96: "#5fffff",
|
||||||
|
97: "#ffffff"
|
||||||
|
};
|
||||||
|
|
||||||
|
function createAnsiState() {
|
||||||
|
return {
|
||||||
|
bold: false,
|
||||||
|
italic: false,
|
||||||
|
underline: false,
|
||||||
|
inverse: false,
|
||||||
|
fg: null,
|
||||||
|
bg: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ansiState = createAnsiState();
|
||||||
|
|
||||||
|
function resetAnsiState() {
|
||||||
|
Object.assign(ansiState, createAnsiState());
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAnsiColor8Bit(code) {
|
||||||
|
if (code < 0 || code > 255) return null;
|
||||||
|
if (code < 16) {
|
||||||
|
const basic = {
|
||||||
|
0: "#000000",
|
||||||
|
1: "#800000",
|
||||||
|
2: "#008000",
|
||||||
|
3: "#808000",
|
||||||
|
4: "#000080",
|
||||||
|
5: "#800080",
|
||||||
|
6: "#008080",
|
||||||
|
7: "#c0c0c0",
|
||||||
|
8: "#808080",
|
||||||
|
9: "#ff0000",
|
||||||
|
10: "#00ff00",
|
||||||
|
11: "#ffff00",
|
||||||
|
12: "#0000ff",
|
||||||
|
13: "#ff00ff",
|
||||||
|
14: "#00ffff",
|
||||||
|
15: "#ffffff"
|
||||||
|
};
|
||||||
|
return basic[code] || null;
|
||||||
|
}
|
||||||
|
if (code >= 16 && code <= 231) {
|
||||||
|
const offset = code - 16;
|
||||||
|
const r = Math.floor(offset / 36);
|
||||||
|
const g = Math.floor((offset % 36) / 6);
|
||||||
|
const b = offset % 6;
|
||||||
|
const steps = [0, 95, 135, 175, 215, 255];
|
||||||
|
return `rgb(${steps[r]},${steps[g]},${steps[b]})`;
|
||||||
|
}
|
||||||
|
const gray = 8 + (code - 232) * 10;
|
||||||
|
return `rgb(${gray},${gray},${gray})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySgr(params) {
|
||||||
|
const values = params.length ? params : [0];
|
||||||
|
for (let i = 0; i < values.length; i += 1) {
|
||||||
|
const p = values[i];
|
||||||
|
if (p === 0) {
|
||||||
|
resetAnsiState();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (p === 1) {
|
||||||
|
ansiState.bold = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (p === 3) {
|
||||||
|
ansiState.italic = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (p === 4) {
|
||||||
|
ansiState.underline = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (p === 7) {
|
||||||
|
ansiState.inverse = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (p === 22) {
|
||||||
|
ansiState.bold = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (p === 23) {
|
||||||
|
ansiState.italic = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (p === 24) {
|
||||||
|
ansiState.underline = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (p === 27) {
|
||||||
|
ansiState.inverse = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (p >= 30 && p <= 37) {
|
||||||
|
ansiState.fg = ANSI_COLOR_TABLE[p];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (p >= 90 && p <= 97) {
|
||||||
|
ansiState.fg = ANSI_COLOR_TABLE[p];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (p >= 40 && p <= 47) {
|
||||||
|
ansiState.bg = ANSI_COLOR_TABLE[p - 10];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (p >= 100 && p <= 107) {
|
||||||
|
ansiState.bg = ANSI_COLOR_TABLE[p - 10];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (p === 39) {
|
||||||
|
ansiState.fg = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (p === 49) {
|
||||||
|
ansiState.bg = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 兼容 256 色与 true color:
|
||||||
|
// - 38;5;n / 48;5;n
|
||||||
|
// - 38;2;r;g;b / 48;2;r;g;b
|
||||||
|
if (p === 38 || p === 48) {
|
||||||
|
const isForeground = p === 38;
|
||||||
|
const mode = values[i + 1];
|
||||||
|
if (mode === 5) {
|
||||||
|
const code = values[i + 2];
|
||||||
|
const color = parseAnsiColor8Bit(code);
|
||||||
|
if (color) {
|
||||||
|
if (isForeground) ansiState.fg = color;
|
||||||
|
else ansiState.bg = color;
|
||||||
|
}
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (mode === 2) {
|
||||||
|
const r = values[i + 2];
|
||||||
|
const g = values[i + 3];
|
||||||
|
const b = values[i + 4];
|
||||||
|
if (
|
||||||
|
Number.isFinite(r) &&
|
||||||
|
Number.isFinite(g) &&
|
||||||
|
Number.isFinite(b)
|
||||||
|
) {
|
||||||
|
const color = `rgb(${Math.max(0, Math.min(255, r))},${Math.max(0, Math.min(255, g))},${Math.max(0, Math.min(255, b))})`;
|
||||||
|
if (isForeground) ansiState.fg = color;
|
||||||
|
else ansiState.bg = color;
|
||||||
|
}
|
||||||
|
i += 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAnsiStyle() {
|
||||||
|
let fg = ansiState.fg;
|
||||||
|
let bg = ansiState.bg;
|
||||||
|
if (ansiState.inverse) {
|
||||||
|
const nextFg = bg || "#111";
|
||||||
|
const nextBg = fg || "#e5e5e5";
|
||||||
|
fg = nextFg;
|
||||||
|
bg = nextBg;
|
||||||
|
}
|
||||||
|
const styles = [];
|
||||||
|
if (fg) styles.push(`color:${fg}`);
|
||||||
|
if (bg) styles.push(`background-color:${bg}`);
|
||||||
|
if (ansiState.bold) styles.push("font-weight:700");
|
||||||
|
if (ansiState.italic) styles.push("font-style:italic");
|
||||||
|
if (ansiState.underline) styles.push("text-decoration:underline");
|
||||||
|
return styles.join(";");
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushTextFragment(parts, text) {
|
||||||
|
if (!text) return;
|
||||||
|
const safeText = XTerminal.escapeHTML(text);
|
||||||
|
const style = buildAnsiStyle();
|
||||||
|
if (style) {
|
||||||
|
parts.push(`<span style="${style}">${safeText}</span>`);
|
||||||
|
} else {
|
||||||
|
parts.push(safeText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 ANSI 序列转换为 HTML 片段(保留样式,过滤控制指令)。
|
||||||
|
* 说明:
|
||||||
|
* - 支持跨帧残缺序列(通过 ansiRemainder 缓冲);
|
||||||
|
* - 未实现光标定位/清屏等复杂控制,仅做样式渲染与噪声过滤,
|
||||||
|
* 可覆盖当前 SSH 输出里的主流序列。
|
||||||
|
*/
|
||||||
|
function renderAnsiToHtml(chunk) {
|
||||||
|
const input = `${ansiRemainder}${String(chunk || "")}`;
|
||||||
|
const parts = [];
|
||||||
|
let textBuffer = "";
|
||||||
|
let i = 0;
|
||||||
|
ansiRemainder = "";
|
||||||
|
|
||||||
|
const applyBackspace = () => {
|
||||||
|
if (!textBuffer.length) return;
|
||||||
|
textBuffer = textBuffer.slice(0, -1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyCarriageReturn = () => {
|
||||||
|
// CR 语义:光标回到“当前行首”。
|
||||||
|
// 这里通过清理 textBuffer 里最后一个换行之后的内容来模拟行首覆盖。
|
||||||
|
const lastNewlineIndex = textBuffer.lastIndexOf("\n");
|
||||||
|
textBuffer =
|
||||||
|
lastNewlineIndex === -1
|
||||||
|
? ""
|
||||||
|
: textBuffer.slice(0, lastNewlineIndex + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
while (i < input.length) {
|
||||||
|
const ch = input[i];
|
||||||
|
if (ch === "\x08") {
|
||||||
|
// 退格:删除前一个可见字符,避免出现类似 `a<BS>as` 的重影。
|
||||||
|
applyBackspace();
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ch === "\r") {
|
||||||
|
// CRLF 统一折叠为 LF,减少重复换行导致的空白行。
|
||||||
|
if (input[i + 1] === "\n") {
|
||||||
|
textBuffer += "\n";
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 裸 CR 按“回到行首”处理。
|
||||||
|
applyCarriageReturn();
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ch !== "\u001b") {
|
||||||
|
textBuffer += ch;
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
pushTextFragment(parts, textBuffer);
|
||||||
|
textBuffer = "";
|
||||||
|
|
||||||
|
const next = input[i + 1];
|
||||||
|
if (!next) {
|
||||||
|
ansiRemainder = "\u001b";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSI: ESC [ ... final
|
||||||
|
if (next === "[") {
|
||||||
|
let j = i + 2;
|
||||||
|
while (j < input.length) {
|
||||||
|
const code = input.charCodeAt(j);
|
||||||
|
if (code >= 0x40 && code <= 0x7e) break;
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
if (j >= input.length) {
|
||||||
|
ansiRemainder = input.slice(i);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalChar = input[j];
|
||||||
|
const rawParams = input.slice(i + 2, j);
|
||||||
|
// 私有模式(如 ?2004h/?2004l)不参与渲染,直接吞掉。
|
||||||
|
if (finalChar === "m") {
|
||||||
|
const params = rawParams
|
||||||
|
.split(";")
|
||||||
|
.filter((item) => item.length > 0)
|
||||||
|
.map((item) => Number.parseInt(item, 10))
|
||||||
|
.filter((item) => Number.isFinite(item));
|
||||||
|
applySgr(params);
|
||||||
|
}
|
||||||
|
i = j + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// OSC: ESC ] ... BEL 或 ESC \
|
||||||
|
if (next === "]") {
|
||||||
|
let j = i + 2;
|
||||||
|
let foundEnd = false;
|
||||||
|
while (j < input.length) {
|
||||||
|
if (input[j] === "\u0007") {
|
||||||
|
foundEnd = true;
|
||||||
|
j += 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (input[j] === "\u001b" && input[j + 1] === "\\") {
|
||||||
|
foundEnd = true;
|
||||||
|
j += 2;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
if (!foundEnd) {
|
||||||
|
ansiRemainder = input.slice(i);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
i = j;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其余 ESC 序列按单字符忽略。
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
pushTextFragment(parts, textBuffer);
|
||||||
|
return parts.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendInputData(input) {
|
||||||
|
logEnterFlow("TX stdin", `${input}\n`, "note=append-newline");
|
||||||
|
sendFrame({
|
||||||
|
type: "stdin",
|
||||||
|
payload: {
|
||||||
|
data: `${input}\n`,
|
||||||
|
meta: { source: "keyboard" }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeStreamOutput(kind, data) {
|
||||||
|
const html = renderAnsiToHtml(data);
|
||||||
|
if (!html) return;
|
||||||
|
if (kind === "stderr") {
|
||||||
|
term.write(`<span class="error">${html}</span>`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
term.write(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushStreamBatch() {
|
||||||
|
if (streamFlushTimer) {
|
||||||
|
clearTimeout(streamFlushTimer);
|
||||||
|
streamFlushTimer = 0;
|
||||||
|
}
|
||||||
|
const stdout = stdoutBatch;
|
||||||
|
const stderr = stderrBatch;
|
||||||
|
stdoutBatch = "";
|
||||||
|
stderrBatch = "";
|
||||||
|
|
||||||
|
if (stdout) {
|
||||||
|
logEnterFlow("RX stdout", stdout, "batched=yes");
|
||||||
|
writeStreamOutput("stdout", stdout);
|
||||||
|
}
|
||||||
|
if (stderr) {
|
||||||
|
logEnterFlow("RX stderr", stderr, "batched=yes");
|
||||||
|
writeStreamOutput("stderr", stderr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleStreamBatchFlush() {
|
||||||
|
if (streamFlushTimer) return;
|
||||||
|
streamFlushTimer = window.setTimeout(() => {
|
||||||
|
streamFlushTimer = 0;
|
||||||
|
flushStreamBatch();
|
||||||
|
}, STREAM_BATCH_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueStreamFrame(kind, data) {
|
||||||
|
const text = String(data || "");
|
||||||
|
if (!text) return;
|
||||||
|
if (kind === "stderr") {
|
||||||
|
stderrBatch += text;
|
||||||
|
logEnterFlow("RX stderr-chunk", text, `queue=${stderrBatch.length}`);
|
||||||
|
} else {
|
||||||
|
stdoutBatch += text;
|
||||||
|
logEnterFlow("RX stdout-chunk", text, `queue=${stdoutBatch.length}`);
|
||||||
|
}
|
||||||
|
scheduleStreamBatchFlush();
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushPendingInputs() {
|
||||||
|
if (!pendingInputs.length) return;
|
||||||
|
const queue = pendingInputs.splice(0, pendingInputs.length);
|
||||||
|
for (const text of queue) {
|
||||||
|
sendInputData(text);
|
||||||
|
}
|
||||||
|
pendingNoticeShown = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGatewayFrame(frame) {
|
||||||
|
if (!frame || typeof frame !== "object") return;
|
||||||
|
|
||||||
|
if (frame.type === "stdout" && frame.payload?.data) {
|
||||||
|
queueStreamFrame("stdout", frame.payload.data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frame.type === "stderr" && frame.payload?.data) {
|
||||||
|
queueStreamFrame("stderr", frame.payload.data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
flushStreamBatch();
|
||||||
|
|
||||||
|
if (frame.type === "error") {
|
||||||
|
setConnected(false);
|
||||||
|
writeSystem(
|
||||||
|
`网关错误 ${frame.payload?.code || "UNKNOWN"}: ${frame.payload?.message || "未知错误"}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frame.type === "control") {
|
||||||
|
const action = frame.payload?.action;
|
||||||
|
if (action === "connected") {
|
||||||
|
const wasConnected = connected;
|
||||||
|
setConnected(true);
|
||||||
|
if (!wasConnected) {
|
||||||
|
term.resume();
|
||||||
|
term.focus();
|
||||||
|
}
|
||||||
|
const nextFingerprint = frame.payload?.fingerprint
|
||||||
|
? String(frame.payload.fingerprint)
|
||||||
|
: "";
|
||||||
|
if (nextFingerprint && nextFingerprint !== lastFingerprint) {
|
||||||
|
lastFingerprint = nextFingerprint;
|
||||||
|
writeSystem(`SSH 已连接,指纹 ${nextFingerprint}`);
|
||||||
|
} else if (!wasConnected) {
|
||||||
|
writeSystem("SSH 已连接");
|
||||||
|
}
|
||||||
|
if (!wasConnected) {
|
||||||
|
flushPendingInputs();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "ping") {
|
||||||
|
sendFrame({ type: "control", payload: { action: "pong" } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "pong") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "disconnect") {
|
||||||
|
setConnected(false);
|
||||||
|
writeSystem(`连接断开: ${frame.payload?.reason || "unknown"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectByConfig(config) {
|
||||||
|
const server = findSelectedServer(config);
|
||||||
|
const endpoint = buildGatewayEndpoint(config);
|
||||||
|
const cols = Number(server.cols) > 0 ? Number(server.cols) : 120;
|
||||||
|
const rows = Number(server.rows) > 0 ? Number(server.rows) : 30;
|
||||||
|
|
||||||
|
// writeSystem(`正在连接网关 ${endpoint}`);
|
||||||
|
// writeSystem(`TLS 证书: ${DEV_CERT_PATH}`);
|
||||||
|
// writeSystem(`TLS 私钥: ${DEV_KEY_PATH}`);
|
||||||
|
|
||||||
|
socket = new WebSocket(endpoint);
|
||||||
|
|
||||||
|
socket.onopen = () => {
|
||||||
|
const initFrame = {
|
||||||
|
type: "init",
|
||||||
|
payload: {
|
||||||
|
host: server.host,
|
||||||
|
port: Number(server.port) || 22,
|
||||||
|
username: server.username,
|
||||||
|
clientSessionKey: `xterminal-${Date.now()}`,
|
||||||
|
credential: buildCredential(server),
|
||||||
|
pty: { cols, rows }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
sendFrame(initFrame);
|
||||||
|
term.pause();
|
||||||
|
// writeSystem(
|
||||||
|
// `已发送 init,目标 ${server.username}@${server.host}:${server.port || 22}`
|
||||||
|
// );
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
handleGatewayFrame(JSON.parse(String(event.data || "{}")));
|
||||||
|
} catch (error) {
|
||||||
|
writeSystem(
|
||||||
|
`帧解析失败: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onclose = (event) => {
|
||||||
|
flushStreamBatch();
|
||||||
|
setConnected(false);
|
||||||
|
writeSystem(`WebSocket 关闭 code=${event.code}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onerror = () => {
|
||||||
|
flushStreamBatch();
|
||||||
|
setConnected(false);
|
||||||
|
writeSystem("WebSocket 异常");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
term.on("data", (input) => {
|
||||||
|
enterSeq += 1;
|
||||||
|
lastEnterMeta = { id: enterSeq, at: Date.now(), input };
|
||||||
|
logEnterFlow(
|
||||||
|
"ENTER local",
|
||||||
|
input,
|
||||||
|
`connected=${connected ? "yes" : "no"}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// 关闭本地回显:xterminal 在 Enter 时会先写一行 `${input}\n`,
|
||||||
|
// SSH 远端通常也会回显同一行,二者叠加会出现“中间空一行/偶发两行空白”。
|
||||||
|
term.clearLast();
|
||||||
|
|
||||||
|
if (input === "clear") {
|
||||||
|
term.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!connected) {
|
||||||
|
logEnterFlow("QUEUE local", input, "reason=not-connected");
|
||||||
|
pendingInputs.push(input);
|
||||||
|
if (!pendingNoticeShown) {
|
||||||
|
pendingNoticeShown = true;
|
||||||
|
writeSystem("会话建立中,输入已排队,连接后自动发送");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
sendInputData(input);
|
||||||
|
} catch (error) {
|
||||||
|
writeSystem(
|
||||||
|
`发送失败: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("beforeunload", () => {
|
||||||
|
try {
|
||||||
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||||
|
sendFrame({
|
||||||
|
type: "control",
|
||||||
|
payload: { action: "disconnect", reason: "page_unload" }
|
||||||
|
});
|
||||||
|
socket.close();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 忽略页面卸载时的关闭异常。
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
connectByConfig(terminalConfig);
|
||||||
|
|
||||||
|
// --- 触控工具条逻辑 ---
|
||||||
|
function setupTouchTools() {
|
||||||
|
const touchTools = document.getElementById("touch-tools");
|
||||||
|
if (!touchTools) return;
|
||||||
|
|
||||||
|
if (!ENABLE_TOUCH_TOOLS) {
|
||||||
|
touchTools.classList.add("is-hidden");
|
||||||
|
document.documentElement.style.setProperty('--tc-toolbar-height', '0px');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 动态调整位置:当键盘弹起时,保持在软键盘和地址胶囊上方
|
||||||
|
const adjustPosition = () => {
|
||||||
|
if (window.visualViewport) {
|
||||||
|
const bottomInset = Math.max(0, window.innerHeight - (window.visualViewport.height + window.visualViewport.offsetTop));
|
||||||
|
document.documentElement.style.setProperty('--tc-bottom-inset', `${bottomInset}px`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (window.visualViewport) {
|
||||||
|
window.visualViewport.addEventListener("resize", adjustPosition);
|
||||||
|
window.visualViewport.addEventListener("scroll", adjustPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 阻止获得焦点,保留 xterminal 的焦点
|
||||||
|
touchTools.addEventListener("pointerdown", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const btn = e.target.closest(".tc-key-btn");
|
||||||
|
if (!btn) return;
|
||||||
|
const action = btn.dataset.action;
|
||||||
|
|
||||||
|
const textarea = document.querySelector('textarea[name="xterminal_input"]');
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case "up":
|
||||||
|
case "down":
|
||||||
|
if (textarea) {
|
||||||
|
const keyMap = { up: "ArrowUp", down: "ArrowDown" };
|
||||||
|
const ev = new Event('keydown', { bubbles: true, cancelable: true });
|
||||||
|
Object.defineProperty(ev, "key", { value: keyMap[action] });
|
||||||
|
textarea.dispatchEvent(ev);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "tab":
|
||||||
|
if (textarea) {
|
||||||
|
if (socket && connected) {
|
||||||
|
// 发送当前已经输入的字符串以及一个 Tab 字符给网关交互(为了触发远端 Shell 的真实补全逻辑)
|
||||||
|
sendFrame({ type: "stdin", payload: { data: textarea.value + "\t", meta: { source: "keyboard" } } });
|
||||||
|
// 清空本地输入缓冲。远端 PTY 在收到后会主动将该字符串 + 补全的结果完整 Echo 回显到页面上。
|
||||||
|
textarea.value = "";
|
||||||
|
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
} else {
|
||||||
|
const ev = new Event('keydown', { bubbles: true, cancelable: true });
|
||||||
|
Object.defineProperty(ev, "key", { value: "Tab" });
|
||||||
|
textarea.dispatchEvent(ev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "enter":
|
||||||
|
if (textarea) {
|
||||||
|
const ev = new Event('keydown', { bubbles: true, cancelable: true });
|
||||||
|
Object.defineProperty(ev, "key", { value: "Enter" });
|
||||||
|
textarea.dispatchEvent(ev);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "ls":
|
||||||
|
if (textarea) {
|
||||||
|
textarea.value += "ls";
|
||||||
|
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
|
||||||
|
// 防抖同步有延迟,等下一个事件循环再触发回车,确保 XTerminal 已取到 'ls' 并更新内部的 data.value
|
||||||
|
setTimeout(() => {
|
||||||
|
const ev = new Event('keydown', { bubbles: true, cancelable: true });
|
||||||
|
Object.defineProperty(ev, "key", { value: "Enter" });
|
||||||
|
textarea.dispatchEvent(ev);
|
||||||
|
}, 10);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "left":
|
||||||
|
case "right":
|
||||||
|
if (textarea) {
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
if (action === "left") {
|
||||||
|
textarea.setSelectionRange(Math.max(0, start - 1), Math.max(0, start - 1));
|
||||||
|
} else {
|
||||||
|
textarea.setSelectionRange(Math.min(textarea.value.length, start + 1), Math.min(textarea.value.length, start + 1));
|
||||||
|
}
|
||||||
|
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "ctrlc":
|
||||||
|
if (socket && connected) {
|
||||||
|
sendFrame({ type: "stdin", payload: { data: "\x03", meta: { source: "keyboard" } } });
|
||||||
|
}
|
||||||
|
if (textarea) {
|
||||||
|
textarea.value = "";
|
||||||
|
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setupTouchTools();
|
||||||
86
xterminal/demo/styles.css
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
@import "../theme/index.css";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--tc-toolbar-height: calc(48px + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::after, *::before {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
html, body {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
#demo-shell {
|
||||||
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tc-touch-tools {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: var(--tc-bottom-inset, 0px);
|
||||||
|
height: var(--tc-toolbar-height);
|
||||||
|
z-index: 50;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px 8px calc(6px + env(safe-area-inset-bottom));
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
/* Transparent frosty look */
|
||||||
|
background: rgba(30,30,30, 0.6);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
border-top: 1px solid rgba(80,80,80, 0.4);
|
||||||
|
touch-action: manipulation;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tc-touch-tools.is-hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tc-touch-tools::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tc-key-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 44px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(50, 50, 50, 0.8);
|
||||||
|
color: #e5e5e5;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tc-key-btn:active {
|
||||||
|
background: rgba(100, 100, 100, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
.tc-touch-tools:not(.is-hidden) {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
33
xterminal/demo/vite.https.config.mjs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTPS 开发配置:
|
||||||
|
* - 供 shell.biboer.cn 外部访问开发页面;
|
||||||
|
* - 使用本机 acme 证书,避免浏览器证书不受信告警。
|
||||||
|
*/
|
||||||
|
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";
|
||||||
|
|
||||||
|
if (!fs.existsSync(DEV_CERT_PATH) || !fs.existsSync(DEV_KEY_PATH)) {
|
||||||
|
throw new Error(
|
||||||
|
`HTTPS 证书文件不存在,请检查路径: cert=${DEV_CERT_PATH}, key=${DEV_KEY_PATH}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
// demo 目录作为 Vite 根目录,保持当前示例结构不变。
|
||||||
|
root: "demo",
|
||||||
|
server: {
|
||||||
|
host: "0.0.0.0",
|
||||||
|
port: 5173,
|
||||||
|
strictPort: true,
|
||||||
|
https: {
|
||||||
|
cert: fs.readFileSync(DEV_CERT_PATH),
|
||||||
|
key: fs.readFileSync(DEV_KEY_PATH)
|
||||||
|
},
|
||||||
|
// 允许通过 shell.biboer.cn 访问 dev server。
|
||||||
|
allowedHosts: [DEV_PUBLIC_HOST]
|
||||||
|
}
|
||||||
|
});
|
||||||
123
xterminal/docs/.vitepress/config.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { defineConfig } from "vitepress";
|
||||||
|
|
||||||
|
const TITLE = "XTerminal";
|
||||||
|
const DESCRIPTION = "Build web-based command line interfaces";
|
||||||
|
const IMAGE = "/logo.svg";
|
||||||
|
const LINK = "https://henryhale.github.io/xterminal";
|
||||||
|
|
||||||
|
// https://vitepress.dev/reference/site-config
|
||||||
|
export default defineConfig({
|
||||||
|
base: "/",
|
||||||
|
|
||||||
|
// metadata
|
||||||
|
lang: "en-US",
|
||||||
|
title: TITLE,
|
||||||
|
description: DESCRIPTION,
|
||||||
|
|
||||||
|
head: [
|
||||||
|
// favicon
|
||||||
|
["link", { rel: "shortcut icon", href: IMAGE }],
|
||||||
|
|
||||||
|
// open graph - facebook
|
||||||
|
["meta", { property: "og:type", content: "website" }],
|
||||||
|
["meta", { property: "og:url", content: LINK }],
|
||||||
|
["meta", { property: "og:title", content: TITLE }],
|
||||||
|
["meta", { property: "og:description", content: DESCRIPTION }],
|
||||||
|
["meta", { property: "og:image", content: IMAGE }],
|
||||||
|
|
||||||
|
// twitter
|
||||||
|
["meta", { property: "twitter:card", content: "summary_large_image" }],
|
||||||
|
["meta", { property: "twitter:url", content: LINK }],
|
||||||
|
["meta", { property: "twitter:title", content: TITLE }],
|
||||||
|
["meta", { property: "twitter:description", content: DESCRIPTION }],
|
||||||
|
["meta", { property: "twitter:image", content: IMAGE }]
|
||||||
|
],
|
||||||
|
|
||||||
|
// theme
|
||||||
|
themeConfig: {
|
||||||
|
// https://vitepress.dev/reference/default-theme-config
|
||||||
|
|
||||||
|
siteTitle: TITLE,
|
||||||
|
logo: IMAGE,
|
||||||
|
|
||||||
|
search: {
|
||||||
|
provider: "local"
|
||||||
|
},
|
||||||
|
|
||||||
|
nav: [
|
||||||
|
{ text: "Home", link: "/" },
|
||||||
|
{ text: "Guide", link: "/guide/" },
|
||||||
|
{ text: "Demo", link: "/demo/" },
|
||||||
|
{ text: "Showcase", link: "/showcase/" },
|
||||||
|
{
|
||||||
|
text: "About",
|
||||||
|
items: [
|
||||||
|
{ text: "Team", link: "/about/team" },
|
||||||
|
{ text: "History", link: "/about/history" },
|
||||||
|
{ text: "Code of Conduct", link: "/about/coc" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
sidebar: [
|
||||||
|
{
|
||||||
|
text: "Guide",
|
||||||
|
items: [{ text: "Introduction", link: "/guide/" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "Getting Started",
|
||||||
|
items: [
|
||||||
|
{ text: "Installation", link: "/guide/installation" },
|
||||||
|
{ text: "Quick Start", link: "/guide/quick-start" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "Essentials",
|
||||||
|
collapsed: false,
|
||||||
|
items: [
|
||||||
|
{ text: "Initialization", link: "/guide/initialization" },
|
||||||
|
{ text: "Output", link: "/guide/output" },
|
||||||
|
{ text: "Events", link: "/guide/events" },
|
||||||
|
{ text: "Input", link: "/guide/prompt" },
|
||||||
|
{ text: "History", link: "/guide/history" },
|
||||||
|
{ text: "Key Bindings", link: "/guide/keybindings" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "Advanced",
|
||||||
|
collapsed: true,
|
||||||
|
items: [
|
||||||
|
{ text: "AutoComplete", link: "/guide/autocomplete" },
|
||||||
|
{ text: "Batch Mode", link: "/guide/batchmode" },
|
||||||
|
{ text: "Disposal", link: "/guide/disposal" },
|
||||||
|
{ text: "Theme", link: "/guide/theme" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "API Reference",
|
||||||
|
link: "/api/",
|
||||||
|
items: []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
socialLinks: [
|
||||||
|
{ icon: "github", link: "https://github.com/henryhale/xterminal" }
|
||||||
|
],
|
||||||
|
|
||||||
|
editLink: {
|
||||||
|
text: "Edit this page on GitHub",
|
||||||
|
pattern:
|
||||||
|
"https://github.com/henryhale/xterminal/edit/master/docs/:path"
|
||||||
|
},
|
||||||
|
|
||||||
|
footer: {
|
||||||
|
message:
|
||||||
|
'Released under the <a href="https://github.com/henryhale/xterminal/blob/master/LICENSE.txt">MIT License</a>.',
|
||||||
|
copyright: `Copyright © 2023-present, <a href="https://github.com/henryhale">Henry Hale</a>.`
|
||||||
|
},
|
||||||
|
|
||||||
|
outline: {
|
||||||
|
level: [2, 3]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
24
xterminal/docs/.vitepress/theme/assets/styles.css
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
:root {
|
||||||
|
--vp-c-brand-1: var(--vp-c-green-1);
|
||||||
|
--vp-c-brand-2: var(--vp-c-green-2);
|
||||||
|
--vp-button-brand-bg: var(--vp-c-brand-2);
|
||||||
|
--vp-home-hero-image-background-image: linear-gradient(180deg, var(--vp-home-hero-name-color) 50%,#272b33 50%);
|
||||||
|
--vp-home-hero-image-filter: blur(40px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.VPImage {
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.VPImage.image-src {
|
||||||
|
width: min(25vw, 200px);
|
||||||
|
height: min(25vw, 200px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-src {
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .vp-doc .custom-block a {
|
||||||
|
transition: color 0.25s;
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
hidelabel: {
|
||||||
|
type: Boolean,
|
||||||
|
default: () => false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-if="!props.hidelabel" class="bp-preview">
|
||||||
|
<b>Result: </b>
|
||||||
|
</div>
|
||||||
|
<div class="bp-container">
|
||||||
|
<header class="bp-header">
|
||||||
|
<div class="bp-dots">
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
</div>
|
||||||
|
<div class="bp-title">My First Terminal</div>
|
||||||
|
</header>
|
||||||
|
<main class="bp-main">
|
||||||
|
<slot></slot>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
*,
|
||||||
|
*::after,
|
||||||
|
*::before {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bp-preview {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bp-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-radius: 6px;
|
||||||
|
background-color: var(--vp-code-block-bg);
|
||||||
|
color: var(--vp-c-text-1);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--vp-c-divider);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bp-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--vp-c-divider);
|
||||||
|
}
|
||||||
|
.bp-header > .bp-title {
|
||||||
|
flex-grow: 1;
|
||||||
|
text-align: right;
|
||||||
|
font-size: min(15px, calc(1.5vw + 7px));
|
||||||
|
}
|
||||||
|
.bp-header .bp-dots > * {
|
||||||
|
display: inline-block;
|
||||||
|
width: min(0.65rem, calc(2.25vw));
|
||||||
|
height: min(0.65rem, calc(2.25vw));
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: min(0.25rem, calc(1vw));
|
||||||
|
}
|
||||||
|
.bp-header .bp-dots > *:first-child {
|
||||||
|
background-color: rgba(255, 91, 82, 1);;
|
||||||
|
}
|
||||||
|
.bp-header .bp-dots > *:nth-child(2) {
|
||||||
|
background-color: rgba(83, 195, 43, 1);
|
||||||
|
}
|
||||||
|
.bp-header .bp-dots > *:last-child {
|
||||||
|
background-color: rgba(230, 192, 41, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bp-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: hidden;
|
||||||
|
padding: 0 min(15px, calc(1.5vw + 5px));
|
||||||
|
background-color: rgba(225,225,225,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .bp-main {
|
||||||
|
background-color: rgb(41, 40, 40);
|
||||||
|
}
|
||||||
|
html.dark .bp-container {
|
||||||
|
color: rgba(239, 239, 239, 0.85);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
73
xterminal/docs/.vitepress/theme/components/ProjectCards.vue
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps(["projects"]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="grid">
|
||||||
|
<div class="intro">
|
||||||
|
<h1><b>Showcase</b></h1>
|
||||||
|
<p>Below is a list of projects that are using XTerminal.</p>
|
||||||
|
<p>
|
||||||
|
To add yours, edit this
|
||||||
|
<a
|
||||||
|
href="https://github.com/henryhale/xterminal/blob/master/docs/showcase/index.md"
|
||||||
|
>file</a
|
||||||
|
>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div v-for="(p, i) in projects" :key="i" class="row">
|
||||||
|
<div class="grow">
|
||||||
|
<div class="row">
|
||||||
|
<img :src="p.logo" :alt="p.name" width="45" />
|
||||||
|
<span>
|
||||||
|
{{ p.name }}<br />{{ p.desc }}<br />
|
||||||
|
<a :href="p?.author.link">@{{ p?.author.username }}</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a :href="p.link">Visit →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 4rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid > * {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row > *:not(:last-child) {
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grow {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--vp-c-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro h1 {
|
||||||
|
font-size: 32px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 40px;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
16
xterminal/docs/.vitepress/theme/index.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import DefaultTheme from "vitepress/theme";
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
import BrowserPreview from "./components/BrowserPreview.vue";
|
||||||
|
// @ts-ignore
|
||||||
|
import ProjectCards from "./components/ProjectCards.vue";
|
||||||
|
|
||||||
|
import "./assets/styles.css";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
extends: DefaultTheme,
|
||||||
|
enhanceApp(ctx) {
|
||||||
|
ctx.app.component('BrowserPreview', BrowserPreview);
|
||||||
|
ctx.app.component('ProjectCards', ProjectCards);
|
||||||
|
}
|
||||||
|
};
|
||||||
128
xterminal/docs/about/coc.md
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
# Contributor Covenant Code of Conduct
|
||||||
|
|
||||||
|
## Our Pledge
|
||||||
|
|
||||||
|
We as members, contributors, and leaders pledge to make participation in our
|
||||||
|
community a harassment-free experience for everyone, regardless of age, body
|
||||||
|
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||||
|
identity and expression, level of experience, education, socio-economic status,
|
||||||
|
nationality, personal appearance, race, religion, or sexual identity
|
||||||
|
and orientation.
|
||||||
|
|
||||||
|
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||||
|
diverse, inclusive, and healthy community.
|
||||||
|
|
||||||
|
## Our Standards
|
||||||
|
|
||||||
|
Examples of behavior that contributes to a positive environment for our
|
||||||
|
community include:
|
||||||
|
|
||||||
|
* Demonstrating empathy and kindness toward other people
|
||||||
|
* Being respectful of differing opinions, viewpoints, and experiences
|
||||||
|
* Giving and gracefully accepting constructive feedback
|
||||||
|
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||||
|
and learning from the experience
|
||||||
|
* Focusing on what is best not just for us as individuals, but for the
|
||||||
|
overall community
|
||||||
|
|
||||||
|
Examples of unacceptable behavior include:
|
||||||
|
|
||||||
|
* The use of sexualized language or imagery, and sexual attention or
|
||||||
|
advances of any kind
|
||||||
|
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||||
|
* Public or private harassment
|
||||||
|
* Publishing others' private information, such as a physical or email
|
||||||
|
address, without their explicit permission
|
||||||
|
* Other conduct which could reasonably be considered inappropriate in a
|
||||||
|
professional setting
|
||||||
|
|
||||||
|
## Enforcement Responsibilities
|
||||||
|
|
||||||
|
Community leaders are responsible for clarifying and enforcing our standards of
|
||||||
|
acceptable behavior and will take appropriate and fair corrective action in
|
||||||
|
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||||
|
or harmful.
|
||||||
|
|
||||||
|
Community leaders have the right and responsibility to remove, edit, or reject
|
||||||
|
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||||
|
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||||
|
decisions when appropriate.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This Code of Conduct applies within all community spaces, and also applies when
|
||||||
|
an individual is officially representing the community in public spaces.
|
||||||
|
Examples of representing our community include using an official e-mail address,
|
||||||
|
posting via an official social media account, or acting as an appointed
|
||||||
|
representative at an online or offline event.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||||
|
reported to the community leaders responsible for enforcement at
|
||||||
|
[@devhenryhale](https://twitter.com/devhenryhale) on Twitter.
|
||||||
|
All complaints will be reviewed and investigated promptly and fairly.
|
||||||
|
|
||||||
|
All community leaders are obligated to respect the privacy and security of the
|
||||||
|
reporter of any incident.
|
||||||
|
|
||||||
|
## Enforcement Guidelines
|
||||||
|
|
||||||
|
Community leaders will follow these Community Impact Guidelines in determining
|
||||||
|
the consequences for any action they deem in violation of this Code of Conduct:
|
||||||
|
|
||||||
|
### 1. Correction
|
||||||
|
|
||||||
|
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||||
|
unprofessional or unwelcome in the community.
|
||||||
|
|
||||||
|
**Consequence**: A private, written warning from community leaders, providing
|
||||||
|
clarity around the nature of the violation and an explanation of why the
|
||||||
|
behavior was inappropriate. A public apology may be requested.
|
||||||
|
|
||||||
|
### 2. Warning
|
||||||
|
|
||||||
|
**Community Impact**: A violation through a single incident or series
|
||||||
|
of actions.
|
||||||
|
|
||||||
|
**Consequence**: A warning with consequences for continued behavior. No
|
||||||
|
interaction with the people involved, including unsolicited interaction with
|
||||||
|
those enforcing the Code of Conduct, for a specified period of time. This
|
||||||
|
includes avoiding interactions in community spaces as well as external channels
|
||||||
|
like social media. Violating these terms may lead to a temporary or
|
||||||
|
permanent ban.
|
||||||
|
|
||||||
|
### 3. Temporary Ban
|
||||||
|
|
||||||
|
**Community Impact**: A serious violation of community standards, including
|
||||||
|
sustained inappropriate behavior.
|
||||||
|
|
||||||
|
**Consequence**: A temporary ban from any sort of interaction or public
|
||||||
|
communication with the community for a specified period of time. No public or
|
||||||
|
private interaction with the people involved, including unsolicited interaction
|
||||||
|
with those enforcing the Code of Conduct, is allowed during this period.
|
||||||
|
Violating these terms may lead to a permanent ban.
|
||||||
|
|
||||||
|
### 4. Permanent Ban
|
||||||
|
|
||||||
|
**Community Impact**: Demonstrating a pattern of violation of community
|
||||||
|
standards, including sustained inappropriate behavior, harassment of an
|
||||||
|
individual, or aggression toward or disparagement of classes of individuals.
|
||||||
|
|
||||||
|
**Consequence**: A permanent ban from any sort of public interaction within
|
||||||
|
the community.
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||||
|
version 2.0, available at
|
||||||
|
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||||
|
|
||||||
|
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||||
|
enforcement ladder](https://github.com/mozilla/diversity).
|
||||||
|
|
||||||
|
[homepage]: https://www.contributor-covenant.org
|
||||||
|
|
||||||
|
For answers to common questions about this code of conduct, see the FAQ at
|
||||||
|
https://www.contributor-covenant.org/faq. Translations are available at
|
||||||
|
https://www.contributor-covenant.org/translations.
|
||||||
5
xterminal/docs/about/history.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# History
|
||||||
|
|
||||||
|
The story behind the development of this project.
|
||||||
|
|
||||||
|
Coming soon...
|
||||||
61
xterminal/docs/about/team.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
---
|
||||||
|
layout: page
|
||||||
|
---
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
VPTeamPage,
|
||||||
|
VPTeamPageTitle,
|
||||||
|
VPTeamMembers,
|
||||||
|
VPTeamPageSection
|
||||||
|
} from 'vitepress/theme';
|
||||||
|
|
||||||
|
const members = [
|
||||||
|
{
|
||||||
|
name: 'Henry Hale',
|
||||||
|
title: 'Creator',
|
||||||
|
avatar: 'https://www.github.com/henryhale.png',
|
||||||
|
org: 'xterminal',
|
||||||
|
orgLink: 'https://github.com/henryhale/xterminal',
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
icon: 'github',
|
||||||
|
link: 'https://github.com/henryhale'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'twitter',
|
||||||
|
link: 'https://twitter.com/devhenryhale'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const contributors = [
|
||||||
|
{
|
||||||
|
name: 'Enzo Notario',
|
||||||
|
title: 'Full Stack Developer',
|
||||||
|
avatar: 'https://www.github.com/enzonotario.png',
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
icon: 'github',
|
||||||
|
link: 'https://github.com/enzonotario'
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<VPTeamPage>
|
||||||
|
<VPTeamPageTitle>
|
||||||
|
<template #title>Our Team</template>
|
||||||
|
<template #lead>Say hello to our awesome team.</template>
|
||||||
|
</VPTeamPageTitle>
|
||||||
|
<VPTeamMembers :members="members" />
|
||||||
|
<VPTeamPageSection>
|
||||||
|
<template #title>Contributors</template>
|
||||||
|
<template #lead>A big shout out to these awesome people</template>
|
||||||
|
<template #members>
|
||||||
|
<VPTeamMembers size="small" :members="contributors" />
|
||||||
|
</template>
|
||||||
|
</VPTeamPageSection>
|
||||||
|
</VPTeamPage>
|
||||||
663
xterminal/docs/api/index.md
Normal file
@@ -0,0 +1,663 @@
|
|||||||
|
# API Reference
|
||||||
|
|
||||||
|
### Application
|
||||||
|
|
||||||
|
- [XTerminal](#xterminal) extends [XEventEmitter](#xeventemitter)
|
||||||
|
- [XTerminal.version](#xterminal-version)
|
||||||
|
- [XTerminal.XEventEmitter](#xterminal-xeventemitter)
|
||||||
|
- [term.mount()](#term-mount)
|
||||||
|
- [term.dispose()](#term-dispose)
|
||||||
|
|
||||||
|
### Input
|
||||||
|
|
||||||
|
- [term.focus()](#term-focus)
|
||||||
|
- [term.blur()](#term-blur)
|
||||||
|
- [term.pause()](#term-pause)
|
||||||
|
- [term.resume()](#term-resume)
|
||||||
|
- [term.setInput()](#term-setinput)
|
||||||
|
- [term.clearInput()](#term-clearinput)
|
||||||
|
- [term.setCompleter()](#term-setcompleter)
|
||||||
|
|
||||||
|
### Output
|
||||||
|
|
||||||
|
- [XTerminal.escapeHTML()](#xterminal-escapehtml)
|
||||||
|
- [term.write()](#term-write)
|
||||||
|
- [term.writeln()](#term-writeln)
|
||||||
|
- [term.writeSafe()](#term-writesafe)
|
||||||
|
- [term.writelnSafe()](#term-writelnsafe)
|
||||||
|
- [term.clear()](#term-clear)
|
||||||
|
- [term.clearLast()](#term-clearlast)
|
||||||
|
|
||||||
|
### History
|
||||||
|
|
||||||
|
- [term.history](#term-history)
|
||||||
|
- [term.clearHistory()](#term-clearhistory)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## XEventEmitter
|
||||||
|
|
||||||
|
Event emitter
|
||||||
|
|
||||||
|
- **Type**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Event Identifier
|
||||||
|
type IEventName = string | symbol;
|
||||||
|
|
||||||
|
// Event Listener
|
||||||
|
type IEventListener = (...args: unknown[]) => void;
|
||||||
|
|
||||||
|
type IEventHandler = (ev: IEventName, listener: IEventListener) => void;
|
||||||
|
|
||||||
|
interface IEventEmitter {
|
||||||
|
on: IEventHandler;
|
||||||
|
once: IEventHandler;
|
||||||
|
off: IEventHandler;
|
||||||
|
emit(ev: IEventName, ...args: unknown[]): void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Details**
|
||||||
|
|
||||||
|
**Methods**
|
||||||
|
|
||||||
|
`on`: Appends a event listener to the specified event
|
||||||
|
|
||||||
|
`once`: Appends a **one-time** event listener to the specified event
|
||||||
|
|
||||||
|
`off`: Removes an event listener from the specified event
|
||||||
|
|
||||||
|
`emit`: Triggers an event with arguments if any
|
||||||
|
|
||||||
|
- **See also:** [Guide - Events](../guide/events.md)
|
||||||
|
|
||||||
|
## XTerminal
|
||||||
|
|
||||||
|
Creates a terminal instance
|
||||||
|
|
||||||
|
- **Type**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface XTerminal {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TerminalOptions {
|
||||||
|
target: HTMLElement | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Terminal extends XEventEmitter implements XTerminal {
|
||||||
|
constructor(options: TerminalOptions);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Details**
|
||||||
|
|
||||||
|
The constructor takes one argument, `options` containing the `target` element reference.
|
||||||
|
|
||||||
|
If the `target` element is provided, the [term.mount()](#term-mount) method is called automatically.
|
||||||
|
|
||||||
|
- **Example**
|
||||||
|
|
||||||
|
```js
|
||||||
|
import XTerminal from 'xterminal';
|
||||||
|
|
||||||
|
const term = new XTerminal({/* options */});
|
||||||
|
```
|
||||||
|
|
||||||
|
- **See also:** [Guide - Creating a Terminal](../guide/initialization.md#creating-your-first-terminal)
|
||||||
|
|
||||||
|
## XTerminal.version
|
||||||
|
|
||||||
|
The version number
|
||||||
|
|
||||||
|
- **Type**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface XTerminal {
|
||||||
|
readonly version: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Details**
|
||||||
|
|
||||||
|
This is a static property that stores the version used. It is important when reporting bugs or issues.
|
||||||
|
|
||||||
|
- **Example**
|
||||||
|
|
||||||
|
```js
|
||||||
|
import XTerminal from 'xterminal';
|
||||||
|
|
||||||
|
console.log(XTerminal.version);
|
||||||
|
```
|
||||||
|
|
||||||
|
## XTerminal.XEventEmitter
|
||||||
|
|
||||||
|
The event emitter class
|
||||||
|
|
||||||
|
- **Type**
|
||||||
|
|
||||||
|
Same as [XEventEmitter](#xeventemitter).
|
||||||
|
|
||||||
|
- **Details**
|
||||||
|
|
||||||
|
This is a static property (class) that can be used to create independent instances of the event emitter
|
||||||
|
|
||||||
|
- **Example**
|
||||||
|
|
||||||
|
```js
|
||||||
|
const emitter = new XTerminal.XEventEmitter();
|
||||||
|
```
|
||||||
|
|
||||||
|
## XTerminal.escapeHTML()
|
||||||
|
|
||||||
|
Escapes user input so it can be safely rendered as HTML text.
|
||||||
|
|
||||||
|
- **Type**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface XTerminal {
|
||||||
|
static escapeHTML(data?: string): string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Details**
|
||||||
|
|
||||||
|
It preserves all characters by converting them to HTML entities where needed.
|
||||||
|
This is **recommended** for use on user input or any arbitrary data.
|
||||||
|
|
||||||
|
- **Example**
|
||||||
|
|
||||||
|
```js
|
||||||
|
XTerminal.escapeHTML("<b>hello</b>");
|
||||||
|
// => <b>hello</b>
|
||||||
|
```
|
||||||
|
|
||||||
|
- **See also:** [Guide - Safe Output](../guide/output.md#safe-output)
|
||||||
|
|
||||||
|
## term.mount()
|
||||||
|
|
||||||
|
Mounts the terminal instance structure to the specified DOM element.
|
||||||
|
|
||||||
|
- **Type**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface XTerminal {
|
||||||
|
mount(target: HTMLElement | string): void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Details**
|
||||||
|
|
||||||
|
It takes one argument that must be an actual DOM element or a CSS selector. The element's `innerHTML` is cleared first and then the terminal structure is rendered.
|
||||||
|
|
||||||
|
If no argument is passed, it throws an error and nothing is rendered.
|
||||||
|
|
||||||
|
The `term.mount()` method should only be called once for each terminal instance only if the `target` element option in the [constructor](#xterminal) is not provided.
|
||||||
|
|
||||||
|
- **Example**
|
||||||
|
|
||||||
|
```js
|
||||||
|
import XTerminal from 'xterminal';
|
||||||
|
|
||||||
|
const term = new XTerminal();
|
||||||
|
term.mount('#app');
|
||||||
|
```
|
||||||
|
|
||||||
|
or mount to an actual DOM element directly:
|
||||||
|
|
||||||
|
```js
|
||||||
|
term.mount(
|
||||||
|
document.getElementById('app')
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
- **See also:** [Guide - Creating a Terminal](../guide/initialization.md#creating-your-first-terminal)
|
||||||
|
|
||||||
|
|
||||||
|
## term.dispose()
|
||||||
|
|
||||||
|
Gracefully close the terminal instance.
|
||||||
|
|
||||||
|
- **Type**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface XTerminal {
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Details**
|
||||||
|
|
||||||
|
This detaches all event listeners, unmounts the terminal from the DOM and clears the backing functionality of the terminal.
|
||||||
|
|
||||||
|
_The terminal should not be used again once disposed._
|
||||||
|
|
||||||
|
- **Example**
|
||||||
|
|
||||||
|
Dispose on window unload event
|
||||||
|
|
||||||
|
```js
|
||||||
|
window.onunload = () => term.dispose();
|
||||||
|
```
|
||||||
|
|
||||||
|
- **See also:** [Guide - Disposal](../guide/disposal.md)
|
||||||
|
|
||||||
|
## term.focus()
|
||||||
|
|
||||||
|
Focus the terminal input component - ready for input.
|
||||||
|
|
||||||
|
- **Type**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface XTerminal {
|
||||||
|
focus(): void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Details**
|
||||||
|
|
||||||
|
This method takes no argument. It focuses the underlying input component of the terminal.
|
||||||
|
|
||||||
|
Clicking or tapping in the terminal also invokes the method.
|
||||||
|
|
||||||
|
- **Example**
|
||||||
|
|
||||||
|
After mounting the terminal instance
|
||||||
|
|
||||||
|
```js
|
||||||
|
term.focus();
|
||||||
|
```
|
||||||
|
|
||||||
|
## term.blur()
|
||||||
|
|
||||||
|
Blurs the terminal input component.
|
||||||
|
|
||||||
|
- **Type**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface XTerminal {
|
||||||
|
blur(): void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Details**
|
||||||
|
|
||||||
|
This method blurs the input component of the terminal.
|
||||||
|
|
||||||
|
- **Example**
|
||||||
|
|
||||||
|
```js
|
||||||
|
term.blur();
|
||||||
|
```
|
||||||
|
|
||||||
|
## term.pause()
|
||||||
|
|
||||||
|
Deactivate the terminal input component.
|
||||||
|
|
||||||
|
- **Type**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface XTerminal {
|
||||||
|
pause(): void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Details**
|
||||||
|
|
||||||
|
This method will stop events and input from being written to the terminal but rather input will be buffered.
|
||||||
|
|
||||||
|
**NB:** It is used in conjuction with [term.resume()](#term-resume).
|
||||||
|
|
||||||
|
- **Example**
|
||||||
|
|
||||||
|
Prevent a user from sending input (non-interactive mode)
|
||||||
|
|
||||||
|
```js
|
||||||
|
term.pause();
|
||||||
|
```
|
||||||
|
|
||||||
|
- **See also:** [Guide - Pause & Resume](../guide/prompt.md#pause-resume)
|
||||||
|
|
||||||
|
## term.resume()
|
||||||
|
|
||||||
|
Activate the terminal input component
|
||||||
|
|
||||||
|
- **Type**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface XTerminal {
|
||||||
|
resume(): void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Details**
|
||||||
|
|
||||||
|
This method will enable events dispatch and user input if they were deactivate using [term.pause()](#term-pause).
|
||||||
|
|
||||||
|
- **Example**
|
||||||
|
|
||||||
|
Pause the terminal until user input is required
|
||||||
|
|
||||||
|
```js
|
||||||
|
term.pause();
|
||||||
|
// ...
|
||||||
|
// do something
|
||||||
|
// ...
|
||||||
|
term.resume();
|
||||||
|
```
|
||||||
|
|
||||||
|
- **See also:** [Guide - Pause & Resume](../guide/prompt.md#pause-resume)
|
||||||
|
|
||||||
|
## term.setInput()
|
||||||
|
|
||||||
|
Sets the value of the terminal input buffer
|
||||||
|
|
||||||
|
- **Type**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface XTerminal {
|
||||||
|
setInput(value: string): void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Details**
|
||||||
|
|
||||||
|
This method will set/modify the contents of the input buffer.
|
||||||
|
|
||||||
|
- **Example**
|
||||||
|
|
||||||
|
Presetting some input when the terminal is loaded or resumed
|
||||||
|
|
||||||
|
```js
|
||||||
|
term.setInput("echo 'Hello World'");
|
||||||
|
```
|
||||||
|
|
||||||
|
- **See also:** [Guide - Set & Clear](../guide/prompt.md#set-clear)
|
||||||
|
|
||||||
|
## term.clearInput()
|
||||||
|
|
||||||
|
Clears the terminal input buffer
|
||||||
|
|
||||||
|
- **Type**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface XTerminal {
|
||||||
|
clearInput(): void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Details**
|
||||||
|
|
||||||
|
This method will empty/clear the contents of the input buffer.
|
||||||
|
|
||||||
|
- **Example**
|
||||||
|
|
||||||
|
Clearing the input buffer when the terminal [resumes](#term-resume)
|
||||||
|
|
||||||
|
```js
|
||||||
|
term.clearInput();
|
||||||
|
```
|
||||||
|
|
||||||
|
- **See also:** [Guide - Set & Clear](../guide/prompt.md#set-clear)
|
||||||
|
|
||||||
|
## term.setCompleter()
|
||||||
|
|
||||||
|
Sets the autocomplete function that is invoked on Tab key.
|
||||||
|
|
||||||
|
- **Type**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface XTerminal {
|
||||||
|
setCompleter(fn: (data: string) => string): void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Details**
|
||||||
|
|
||||||
|
This method take one argument that is a function which takes a string parameter and returns a string.
|
||||||
|
|
||||||
|
The autocomplete functionality depends highly on the completer function `fn`.
|
||||||
|
|
||||||
|
The `fn` parameter should return a better match for the input data string.
|
||||||
|
|
||||||
|
- **Example**
|
||||||
|
|
||||||
|
```js
|
||||||
|
term.setCompleter(data => {
|
||||||
|
const options = ['.help', '.clear', '.exit'];
|
||||||
|
return options.filter(s => s.startsWith(data))[0] || '';
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- **See also:** [Guide - Autocomplete](../guide/autocomplete.md)
|
||||||
|
|
||||||
|
## term.write()
|
||||||
|
|
||||||
|
Write data to the terminal.
|
||||||
|
|
||||||
|
- **Type**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface XTerminal {
|
||||||
|
write(data: string | number, callback?: () => void): void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Details**
|
||||||
|
|
||||||
|
`data`: The data to write to the terminal
|
||||||
|
|
||||||
|
`callback`: Optional function invoked on successful write
|
||||||
|
|
||||||
|
- **Example**
|
||||||
|
|
||||||
|
```js
|
||||||
|
term.write('John: Hello ');
|
||||||
|
term.write('from the Eastside', () => console.log('Done!'));
|
||||||
|
```
|
||||||
|
|
||||||
|
- **See also:** [Guide - Output](../guide/output.md#output)
|
||||||
|
|
||||||
|
## term.writeln()
|
||||||
|
|
||||||
|
Write data to the terminal, followed by a break line character (\n).
|
||||||
|
|
||||||
|
- **Type**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface XTerminal {
|
||||||
|
writeln(data: string | number, callback?: () => void): void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Details**
|
||||||
|
|
||||||
|
`data`: The data to write to the terminal
|
||||||
|
|
||||||
|
`callback`: Optional function invoked on successful write
|
||||||
|
|
||||||
|
- **Example**
|
||||||
|
|
||||||
|
```js
|
||||||
|
term.writeln('Hello World!');
|
||||||
|
term.writeln('Welcome!', () => console.log('Done!'));
|
||||||
|
```
|
||||||
|
|
||||||
|
- **See also:** [Guide - Output](../guide/output.md#output)
|
||||||
|
|
||||||
|
|
||||||
|
## term.writeSafe()
|
||||||
|
|
||||||
|
Securely write data to the terminal.
|
||||||
|
|
||||||
|
- **Type**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface XTerminal {
|
||||||
|
writeSafe(data: string | number, callback?: () => void): void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Details**
|
||||||
|
|
||||||
|
`data`: The data to write to the terminal
|
||||||
|
|
||||||
|
`callback`: Optional function invoked on successful write
|
||||||
|
|
||||||
|
- **Example**
|
||||||
|
|
||||||
|
```js
|
||||||
|
term.writeSafe('<h1>hello</h1>');
|
||||||
|
// <h1>hello</h1>
|
||||||
|
```
|
||||||
|
|
||||||
|
- **See also:** [Guide - Output](../guide/output.md#output)
|
||||||
|
|
||||||
|
## term.writelnSafe()
|
||||||
|
|
||||||
|
Securely write data to the terminal, followed by a break line character (\n).
|
||||||
|
|
||||||
|
- **Type**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface XTerminal {
|
||||||
|
writelnSafe(data: string | number, callback?: () => void): void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Details**
|
||||||
|
|
||||||
|
`data`: The data to write to the terminal
|
||||||
|
|
||||||
|
`callback`: Optional function invoked on successful write
|
||||||
|
|
||||||
|
- **Example**
|
||||||
|
|
||||||
|
```js
|
||||||
|
term.writelnSafe('<h1>hello</h1>');
|
||||||
|
// <h1>hello</h1><br/>
|
||||||
|
```
|
||||||
|
|
||||||
|
- **See also:** [Guide - Output](../guide/output.md#output)
|
||||||
|
|
||||||
|
## term.clear()
|
||||||
|
|
||||||
|
Clear the entire terminal.
|
||||||
|
|
||||||
|
- **Type**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface XTerminal {
|
||||||
|
clear(): void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Details**
|
||||||
|
|
||||||
|
When invoked, the entire terminal output is cleared.
|
||||||
|
|
||||||
|
This method also triggers the `clear` event.
|
||||||
|
|
||||||
|
- **Example**
|
||||||
|
|
||||||
|
Clear on CTRL+L using [keypress](../guide/events.md#default-events) event
|
||||||
|
|
||||||
|
```js
|
||||||
|
term.on('keypress', e => {
|
||||||
|
if (e.key == 'l' && e.ctrlKey) {
|
||||||
|
term.clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- **See also:** [Guide - Example using events](../guide/events.md#example)
|
||||||
|
- **See also:** [Guide - Output](../guide/output.md#clear-screen)
|
||||||
|
|
||||||
|
## term.clearLast()
|
||||||
|
|
||||||
|
Remove the element containing the previous output.
|
||||||
|
|
||||||
|
- **Type**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface XTerminal {
|
||||||
|
clearLast(): void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Details**
|
||||||
|
|
||||||
|
This is like the undo for only one write operation.
|
||||||
|
|
||||||
|
- **Example**
|
||||||
|
|
||||||
|
Greet with `Hello World` and replace it with `Hello Dev` after 5 seconds
|
||||||
|
|
||||||
|
```js
|
||||||
|
term.writeln('Hello World!');
|
||||||
|
setTimeout(() => {
|
||||||
|
term.clearLast();
|
||||||
|
term.write('Hello Dev!');
|
||||||
|
}, 5000);
|
||||||
|
```
|
||||||
|
|
||||||
|
- **See also:** [Guide - Output](../guide/output.md#clear-last-output)
|
||||||
|
|
||||||
|
|
||||||
|
## term.history
|
||||||
|
|
||||||
|
Access the history stack.
|
||||||
|
|
||||||
|
- **Type**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface XTerminal {
|
||||||
|
history: string[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Details**
|
||||||
|
|
||||||
|
Manages an array of entries in the history stack.
|
||||||
|
|
||||||
|
- **Example**
|
||||||
|
|
||||||
|
Log the history whenever a new entry is added
|
||||||
|
|
||||||
|
```js
|
||||||
|
term.on('data', () => console.log(term.history));
|
||||||
|
```
|
||||||
|
|
||||||
|
- **See also:** [History](../guide/history.md)
|
||||||
|
|
||||||
|
## term.clearHistory()
|
||||||
|
|
||||||
|
Clear the entire history stack.
|
||||||
|
|
||||||
|
- **Type**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface XTerminal {
|
||||||
|
clearHistory(): void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Details**
|
||||||
|
|
||||||
|
It take no argument as its sole role is to clear the entire local history of inputs which are accessible iteratively using `ArrowUp` and `ArrowDown` keys.
|
||||||
|
|
||||||
|
- **Example**
|
||||||
|
|
||||||
|
Clear history on `CTRL+H` using [keypress](../guide/events.md#default-events) event
|
||||||
|
|
||||||
|
```js
|
||||||
|
term.on('keypress', e => {
|
||||||
|
if (e.ctrlKey && e.key == 'h') {
|
||||||
|
term.clearHistory();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- **See also:** [History](../guide/history.md)
|
||||||
72
xterminal/docs/demo/index.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# Live Demo
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<style>
|
||||||
|
iframe { border: 0 none; }
|
||||||
|
.demo .bp-main { padding: 0; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="demo">
|
||||||
|
|
||||||
|
<a href="../demo.html" target="_blank" rel="noreferrer">View fullpage demo</a>
|
||||||
|
|
||||||
|
<browser-preview hidelabel>
|
||||||
|
|
||||||
|
<iframe src="../demo.html" height="400px"></iframe>
|
||||||
|
|
||||||
|
</browser-preview>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
:::details Code
|
||||||
|
|
||||||
|
:::code-group
|
||||||
|
|
||||||
|
```css [styles.css]
|
||||||
|
@import url('https://unpkg.com/xterminal/dist/xterminal.css');
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: rgb(248, 88, 88);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner:after {
|
||||||
|
animation: changeContent 0.8s linear infinite;
|
||||||
|
content: "⠋";
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes changeContent {
|
||||||
|
10% { content: "⠙"; }
|
||||||
|
20% { content: "⠹"; }
|
||||||
|
30% { content: "⠸"; }
|
||||||
|
40% { content: "⠼"; }
|
||||||
|
50% { content: "⠴"; }
|
||||||
|
60% { content: "⠦"; }
|
||||||
|
70% { content: "⠧"; }
|
||||||
|
80% { content: "⠇"; }
|
||||||
|
90% { content: "⠏"; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```html [index.html]
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
|
||||||
|
<div id="app"></div>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/xterminal/dist/xterminal.umd.js"></script>
|
||||||
|
|
||||||
|
<script src="createShell.js"></script>
|
||||||
|
<script src="createTerminal.js"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
window.onload = () => createTerminal('#app');
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
<<< @/public/demo.js#terminal{js:line-numbers} [createTerminal.js]
|
||||||
|
|
||||||
|
<<< @/public/demo.js#shell{js:line-numbers} [createShell.js]
|
||||||
|
|
||||||
|
:::
|
||||||