first commit

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

19
.gitignore vendored Normal file
View 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

1
runit.sh Symbolic link
View File

@@ -0,0 +1 @@
/Users/gavin/tools/runit.sh

19
terminal.config.json Normal file
View File

@@ -0,0 +1,19 @@
{
"gatewayUrl": "/ws/terminal",
"gatewayToken": "remoteconn-dev-token",
"selectedServerId": "dev-server",
"servers": [
{
"id": "dev-server",
"name": "Dev Server",
"host": "mac.biboer.cn",
"port": 22,
"username": "gavin",
"transportMode": "gateway",
"authType": "password",
"password": "Gavin123",
"cols": 80,
"rows": 24
}
]
}

1
terminal/.npmrc Normal file
View File

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

95
terminal/README.md Normal file
View File

@@ -0,0 +1,95 @@
# terminal
这个仓库用于承接从 `remoteconn` 迁出的 xterm/terminal-core 迁移代码,采用 npm workspaces 管理。
## 环境要求
- Node.js 20+(建议使用当前 LTS
- npm 10+
## 快速启动Web
在仓库根目录执行:
```bash
npm install
npm run dev
```
默认会启动 `apps/web`,监听地址为:
- 本机访问:`http://localhost:5173`
- 局域网访问:`http://<你的IP>:5173`
## 常用命令
根目录命令:
```bash
npm run dev # 启动 Web 实验页apps/web
npm run build # 先构建 terminal-core再构建 web
npm run test # 运行 terminal-core 测试
npm run typecheck # 运行 terminal-core + web 类型检查
```
按 workspace 执行命令:
```bash
npm run test -w apps/web
npm run test -w packages/terminal-core
npm run lint -w packages/terminal-core
npm run build:miniprogram -w packages/terminal-core
```
说明:
- 目前根目录未提供统一 `lint``deploy` 脚本。
- `.npmrc` 已将 npm 缓存指向仓库内 `.npm-cache`,减少 `~/.npm` 权限冲突问题。
## Web 配置文件
可通过 `apps/web/public/terminal.config.json` 配置网关与服务器,无需在控制台手动写 `localStorage`
- `gatewayUrl`:网关 WebSocket 地址。推荐同源路径(如 `/ws/terminal`HTTPS 下会自动匹配 `wss`
- `gatewayToken`:网关令牌。
- `selectedServerId`:默认选中的服务器 ID。
- `servers`:服务器列表(`host/port/username/authType/password/privateKey/...`)。
修改配置文件后刷新页面即可生效;若同时存在本地缓存,配置文件优先。
## 目录结构(核心)
```text
.
├── apps/
│ ├── web/
│ └── miniprogram/
├── packages/
│ └── terminal-core/
├── docs/
├── README.md
├── package.json
├── package-lock.json
├── .npmrc
└── xterm-standalone-lab-plan-2026-03-01.md
```
## 目录说明
- `apps/web/`Web 端实验应用Vite + Vue + Pinia承载 terminal 实验页与页面级状态。
- `apps/miniprogram/`:小程序侧适配代码与组件(输入桥接、测量适配、传输适配等)。
- `packages/terminal-core/`:跨端复用的终端核心库(状态机、输入、渲染内核、布局与清洗逻辑)。
- `docs/`:迁移与实验相关文档。
## 已迁入内容
- `apps/web/src/terminal/`Web 端新 terminal-core 实验页与组件。
- `apps/web/vitest.config.ts`
- `packages/terminal-core/`:跨平台终端核心包。
- `apps/miniprogram/components/terminal-core-view/`
- `apps/miniprogram/utils/wxInputBridge.js`
- `apps/miniprogram/utils/wxMeasureAdapter.js`
- `apps/miniprogram/utils/wxTransport.js`
- `docs/xterm-standalone-lab-plan-2026-03-01.md`
当前 Web 实验页已收敛为 `textarea` 渲染模式(不再提供 compat 切换)。

View File

@@ -0,0 +1,369 @@
/**
* terminal-core-view/index.js
*
* 微信小程序终端渲染组件。
* 消费 @remoteconn/terminal-core 构建产物dist-miniprogram/index.js
* 通过 setData({rows, cursor}) 驱动 rich-text 渲染。
*
* 外部属性properties
* - gatewayUrl {string} 网关 WebSocket 地址
* - gatewayToken {string} 网关鉴权 Token
*
* 外部方法(通过组件引用调用):
* - connect(params) 开始连接params: { host, port, username, password? }
* - disconnect(reason?) 断开连接
* - sendInput(data) 发送输入
*/
const path = '../../../../packages/terminal-core/dist-miniprogram/index';
let TerminalCore, InputBridge, OutputBuffer, SessionMachine, sanitizeTerminalOutput, calcSize;
try {
const core = require(path);
TerminalCore = core.TerminalCore;
InputBridge = core.InputBridge;
OutputBuffer = core.OutputBuffer;
SessionMachine = core.SessionMachine;
sanitizeTerminalOutput = core.sanitizeTerminalOutput;
calcSize = core.calcSize;
} catch(e) {
console.error('[terminal-core-view] 无法加载 terminal-core请先执行 build:miniprogram', e);
}
const { WxTransport } = require('../../utils/wxTransport');
const { createWxInputBridge } = require('../../utils/wxInputBridge');
const { createWxMeasureAdapter } = require('../../utils/wxMeasureAdapter');
// ── 颜色渲染 ──────────────────────────────────────────────────────────────────
const ANSI16 = [
'#000000','#cc0000','#00aa00','#aaaa00',
'#0000ee','#cc00cc','#00aaaa','#aaaaaa',
'#555555','#ff5555','#55ff55','#ffff55',
'#5555ff','#ff55ff','#55ffff','#ffffff',
];
function p256ToCss(idx) {
if (idx < 16) return ANSI16[idx] || '#000';
if (idx >= 232) {
const v = 8 + (idx - 232) * 10;
return 'rgb(' + v + ',' + v + ',' + v + ')';
}
const i = idx - 16;
const b = i % 6, g = Math.floor(i / 6) % 6, r = Math.floor(i / 36);
const ch = function(x) { return x === 0 ? 0 : 55 + x * 40; };
return 'rgb(' + ch(r) + ',' + ch(g) + ',' + ch(b) + ')';
}
function colorToCss(c, isFg) {
if (!c) return isFg ? 'inherit' : 'transparent';
switch (c.mode) {
case 'default': return isFg ? 'inherit' : 'transparent';
case 'p16': return ANSI16[c.value] || 'inherit';
case 'p256': return p256ToCss(c.value);
case 'rgb': return '#' + ((c.value & 0xffffff) | 0x1000000).toString(16).slice(1);
default: return 'inherit';
}
}
const FLAG_BOLD = 1;
const FLAG_DIM = 2;
const FLAG_ITALIC = 4;
const FLAG_UNDERLINE = 8;
const FLAG_INVERSE = 16;
const FLAG_INVISIBLE = 32;
const FLAG_STRIKETHROUGH = 64;
const FLAG_OVERLINE = 128;
/** 将一行 cells 转换为 rich-text 节点数组 */
function lineToNodes(cells) {
const nodes = [];
let i = 0;
while (i < cells.length) {
const cell = cells[i];
if (!cell) { i++; continue; }
const flags = cell.flags || 0;
const inverse = !!(flags & FLAG_INVERSE);
let fg = cell.fg, bg = cell.bg;
if (inverse) { const tmp = fg; fg = bg; bg = tmp; }
let style = '';
const fgCss = colorToCss(fg, true);
const bgCss = colorToCss(bg, false);
if (fgCss !== 'inherit') style += 'color:' + fgCss + ';';
if (bgCss !== 'transparent') style += 'background:' + bgCss + ';';
if (flags & FLAG_BOLD) style += 'font-weight:bold;';
if (flags & FLAG_DIM) style += 'opacity:0.6;';
if (flags & FLAG_ITALIC) style += 'font-style:italic;';
if (flags & FLAG_UNDERLINE) style += 'text-decoration:underline;';
if (flags & FLAG_STRIKETHROUGH) style += 'text-decoration:line-through;';
if (flags & FLAG_INVISIBLE) style += 'color:transparent;';
// 合并相邻相同 style 的 cells
let text = cell.char || ' ';
i++;
while (i < cells.length) {
const next = cells[i];
if (!next) break;
const nFlags = next.flags || 0;
// 简单比较:只有标志和颜色模式全相同才合并
if (nFlags !== flags ||
JSON.stringify(next.fg) !== JSON.stringify(fg) ||
JSON.stringify(next.bg) !== JSON.stringify(bg)) break;
text += next.char || ' ';
i++;
}
nodes.push({
type: 'node',
name: 'span',
attrs: style ? { style: style } : {},
children: [{ type: 'text', text: text }],
});
}
return nodes;
}
// ── 组件定义 ──────────────────────────────────────────────────────────────────
Component({
properties: {
gatewayUrl: { type: String, value: '' },
gatewayToken: { type: String, value: '' },
},
data: {
rows: [], // Array<{ html: Array }>
cursor: { row: -1, left: 0, visible: false },
scrollTop: 9999999, // 滚到底
state: 'idle',
latencyMs: 0,
title: '',
inputValue: '',
},
lifetimes: {
attached: function() {
this._init();
},
detached: function() {
this._dispose();
},
},
methods: {
// ── 初始化 ───────────────────────────────────────────────────────────────
_init: function() {
const self = this;
// Core
self._core = TerminalCore ? new TerminalCore(80, 24) : null;
self._buffer = OutputBuffer ? new OutputBuffer({ maxEntries: 500, maxBytes: 256 * 1024 }) : null;
self._machine = SessionMachine ? new SessionMachine() : null;
self._inputBridge = InputBridge ? new InputBridge() : null;
self._wxBridge = createWxInputBridge();
self._transport = null;
self._offTransport = null;
self._lastRevision = -1;
self._charW = 7.8; // 默认字符宽度 px (monospace 13px 近似)
self._lineH = 16.25; // 行高 px (13px * 1.25)
// 订阅 core 事件
if (self._core) {
self._core.on('bell', function() { wx.vibrateShort({ type: 'light' }); });
self._core.on('titleChange', function(t) {
self.setData({ title: t });
});
}
// 测量适配器
self._measure = createWxMeasureAdapter(self, '#tc-viewport', self._charW, self._lineH);
self._measure.onResize(function() {
self._doFit();
});
// 输入桥接事件订阅
self._wxBridge.on('key', function(p) {
if (!self._inputBridge) return;
const seq = self._inputBridge.mapKey(p.key, p.code, p.ctrlKey, p.altKey, p.shiftKey, p.metaKey);
if (seq !== null) self.sendInput(seq);
});
self._wxBridge.on('input', function(p) {
if (p.isComposing) return;
if (p.data) self.sendInput(p.data);
self.setData({ inputValue: '' });
});
self._wxBridge.on('paste', function(p) {
if (!self._inputBridge) return;
const seq = self._inputBridge.mapPaste(p.text);
self.sendInput(seq);
});
self._wxBridge.on('compositionend', function(p) {
if (p.data) self.sendInput(p.data);
self.setData({ inputValue: '' });
});
// 启动渲染循环50ms ≈ 20fpssetData 有限流)
self._renderTimer = setInterval(function() { self._renderFrame(); }, 50);
// 启动测量轮询
self._measure.startPoll(600);
},
_dispose: function() {
if (this._renderTimer) { clearInterval(this._renderTimer); this._renderTimer = null; }
this._measure && this._measure.stopPoll();
this._offTransport && this._offTransport();
this._transport && this._transport._cleanup && this._transport._cleanup();
},
// ── 尺寸适配 ─────────────────────────────────────────────────────────────
_doFit: function() {
if (!calcSize || !this._core) return;
const { widthPx: contW, heightPx: contH } = this._measure.measureContainer();
if (contW < 10 || contH < 10) return;
const { widthPx: charW, heightPx: lineH } = this._measure.measureChar();
const { cols, rows } = calcSize(contW, contH, charW, lineH);
this._core.resize(cols, rows);
this._charW = charW;
if (this._transport && this.data.state === 'connected') {
this._transport.resize(cols, rows);
}
},
// ── 渲染帧 ───────────────────────────────────────────────────────────────
_renderFrame: function() {
if (!this._core) return;
const snap = this._core.snapshot();
if (snap.revision === this._lastRevision) return;
this._lastRevision = snap.revision;
const rows = snap.lines.map(function(line) {
return { html: lineToNodes(line.cells) };
});
// 光标位置(像素)
const cursorLeft = snap.cursor.x * this._charW;
const cursorRow = snap.cursor.y; // 相对可视区行号
this.setData({
rows: rows,
cursor: { row: cursorRow, left: cursorLeft, visible: snap.cursor.visible },
title: snap.title || this.data.title,
});
},
// ── 公开 API ──────────────────────────────────────────────────────────────
connect: function(params) {
const self = this;
if (!WxTransport) { console.error('[tc-view] WxTransport 未加载'); return; }
self._transport = new WxTransport({
gatewayUrl: self.properties.gatewayUrl,
gatewayToken: self.properties.gatewayToken,
});
self._offTransport = self._transport.on(function(event) {
if (event.type === 'stdout' || event.type === 'stderr') {
const sanitized = sanitizeTerminalOutput ? sanitizeTerminalOutput(event.data) : event.data;
if (sanitized) {
self._buffer && self._buffer.push(sanitized);
self._core && self._core.write(sanitized);
}
} else if (event.type === 'control') {
if (event.action === 'connected') {
self._machine && self._machine.tryTransition('connected');
self.setData({ state: 'connected', latencyMs: 0 });
self._doFit();
} else if (event.action === 'pong') {
// latency 由 WxTransport 内部 _pingAt 计算
} else if (event.action === 'disconnect') {
self._machine && self._machine.tryTransition('disconnected');
self.setData({ state: 'disconnected' });
}
} else if (event.type === 'error') {
self._machine && self._machine.tryTransition('error');
self.setData({ state: 'error' });
}
});
self._machine && self._machine.tryTransition('connecting');
self.setData({ state: 'connecting' });
self._transport.connect(params).catch(function(err) {
console.error('[tc-view] connect error', err);
self.setData({ state: 'error' });
});
},
disconnect: function(reason) {
if (this._transport) {
this._transport.disconnect(reason || 'manual');
}
this._machine && this._machine.tryTransition('disconnected');
this.setData({ state: 'disconnected' });
},
sendInput: function(data) {
if (this.data.state !== 'connected' || !this._transport) return;
this._transport.send(data);
},
// ── WXML 事件处理 ─────────────────────────────────────────────────────────
onInput: function(e) {
const value = e.detail.value || '';
const prev = this._prevInputValue || '';
// 计算增量字符
if (value.length > prev.length) {
const delta = value.slice(prev.length);
this._wxBridge.triggerInput(delta, e.detail.isComposing);
}
this._prevInputValue = value;
},
onConfirm: function() {
this._prevInputValue = '';
this._wxBridge.triggerConfirm();
},
onSend: function() {
const val = this.data.inputValue;
if (val) {
this._wxBridge.triggerInput(val, false);
this._wxBridge.triggerConfirm();
this.setData({ inputValue: '' });
this._prevInputValue = '';
}
},
onInputBlur: function() { /* no-op */ },
onKeyboardChange: function() { /* no-op */ },
// ── 滚动事件 ──────────────────────────────────────────────────────────────
onScrollToLower: function() {
this._autoScroll = true;
},
onScrollToUpper: function() {
this._autoScroll = false;
},
onScroll: function() { /* handled by upper/lower events */ },
// ── 触控工具栏 ────────────────────────────────────────────────────────────
onTapUp: function() { this._wxBridge.triggerKey('ArrowUp', 'ArrowUp'); },
onTapDown: function() { this._wxBridge.triggerKey('ArrowDown', 'ArrowDown'); },
onTapLeft: function() { this._wxBridge.triggerKey('ArrowLeft', 'ArrowLeft'); },
onTapRight: function() { this._wxBridge.triggerKey('ArrowRight', 'ArrowRight'); },
onTapTab: function() { this._wxBridge.triggerKey('Tab', 'Tab'); },
onTapEnter: function() { this._wxBridge.triggerConfirm(); },
onTapCtrlC: function() { this.sendInput('\u0003'); },
onTapCtrlD: function() { this.sendInput('\u0004'); },
onTapEsc: function() { this.sendInput('\x1b'); },
onTapPaste: function() {
const self = this;
wx.getClipboardData({
success: function(res) {
if (res.data) self._wxBridge.triggerPaste(res.data);
},
});
},
},
});

View File

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

View File

@@ -0,0 +1,69 @@
<!--
terminal-core-view — 微信小程序终端渲染组件。
消费 TerminalCore.snapshot() 通过 setData 驱动 rich-text 渲染。
-->
<view class="tc-root" id="tc-root">
<!-- 终端输出区 -->
<scroll-view
class="tc-viewport"
id="tc-viewport"
scroll-y
scroll-top="{{scrollTop}}"
bindscrolltoupper="onScrollToUpper"
bindscrolltolower="onScrollToLower"
bindscroll="onScroll"
>
<view class="tc-lines">
<block wx:for="{{rows}}" wx:key="index">
<view class="tc-row" style="position: relative;">
<!-- 光标行叠加 -->
<view
wx:if="{{cursor.visible && cursor.row === index}}"
class="tc-cursor"
style="left: {{cursor.left}}px;"
></view>
<rich-text class="tc-rich-row" nodes="{{item.html}}"></rich-text>
</view>
</block>
</view>
</scroll-view>
<!-- 触控辅助工具栏 -->
<view class="tc-touch-bar">
<button class="tc-key" bindtap="onTapUp" size="mini">▲</button>
<button class="tc-key" bindtap="onTapDown" size="mini">▼</button>
<button class="tc-key" bindtap="onTapLeft" size="mini">◀</button>
<button class="tc-key" bindtap="onTapRight" size="mini">▶</button>
<button class="tc-key" bindtap="onTapTab" size="mini">Tab</button>
<button class="tc-key tc-key-enter" bindtap="onTapEnter" size="mini">↵</button>
<button class="tc-key" bindtap="onTapCtrlC" size="mini">^C</button>
<button class="tc-key" bindtap="onTapCtrlD" size="mini">^D</button>
<button class="tc-key" bindtap="onTapEsc" size="mini">Esc</button>
<button class="tc-key" bindtap="onTapPaste" size="mini">粘贴</button>
</view>
<!-- 输入栏 -->
<view class="tc-input-bar">
<input
class="tc-input"
type="text"
confirm-type="send"
placeholder="输入命令…"
value="{{inputValue}}"
bindinput="onInput"
bindconfirm="onConfirm"
bindblur="onInputBlur"
bindkeyboardheightchange="onKeyboardChange"
cursor-spacing="20"
adjust-position
/>
<button class="tc-send-btn" size="mini" bindtap="onSend">发</button>
</view>
<!-- 状态栏 -->
<view class="tc-status-bar">
<text class="tc-state-chip tc-state-{{state}}">{{state}}</text>
<text wx:if="{{latencyMs > 0}}" class="tc-state-chip">{{latencyMs}}ms</text>
<text wx:if="{{title}}" class="tc-title">{{title}}</text>
</view>
</view>

View File

@@ -0,0 +1,154 @@
/* terminal-core-view/index.wxss */
.tc-root {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
background: #1a1a1a;
color: #d4d4d4;
box-sizing: border-box;
overflow: hidden;
}
/* 终端输出区 */
.tc-viewport {
flex: 1;
min-height: 0;
overflow: hidden;
}
.tc-lines {
padding: 2px 4px;
}
.tc-row {
position: relative;
white-space: pre;
}
.tc-rich-row {
display: block;
font-family: "Courier New", "Menlo", monospace;
font-size: 13px;
line-height: 1.25;
white-space: pre;
}
/* 光标 */
.tc-cursor {
position: absolute;
top: 0;
width: 8px;
height: 1.25em;
background: rgba(255, 255, 255, 0.75);
pointer-events: none;
z-index: 1;
}
/* 触控工具栏 */
.tc-touch-bar {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
overflow-x: scroll;
padding: 4px 6px;
background: #252525;
border-top: 1rpx solid #333;
gap: 4px;
-webkit-overflow-scrolling: touch;
}
.tc-key {
flex-shrink: 0;
min-width: 44px;
font-size: 12px;
font-family: "Courier New", monospace;
background: #2e2e2e;
color: #ccc;
border: 1rpx solid #444;
border-radius: 4px;
padding: 0 8px;
height: 32px;
line-height: 32px;
}
.tc-key-enter {
background: #1a3a5c;
border-color: #4a9eff;
color: #4a9eff;
}
/* 输入栏 */
.tc-input-bar {
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
padding: 5px 8px;
background: #1e1e1e;
border-top: 1rpx solid #333;
}
.tc-input {
flex: 1;
min-width: 0;
height: 36px;
border-radius: 6px;
border: 1rpx solid #444;
background: #2a2a2a;
color: #d4d4d4;
font-size: 13px;
font-family: "Courier New", monospace;
padding: 0 10px;
}
.tc-send-btn {
flex-shrink: 0;
width: 44px;
height: 36px;
background: #4a9eff;
color: #fff;
font-size: 13px;
font-weight: 600;
border-radius: 6px;
border: none;
line-height: 36px;
text-align: center;
padding: 0;
}
/* 状态栏 */
.tc-status-bar {
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
padding: 3px 8px;
background: #141414;
border-top: 1rpx solid #2a2a2a;
font-size: 11px;
}
.tc-state-chip {
padding: 1px 6px;
border-radius: 3px;
font-size: 11px;
background: #2a2a2a;
color: #888;
}
.tc-state-chip.tc-state-connected { background: #1a3a1a; color: #4caf50; }
.tc-state-chip.tc-state-connecting { background: #2a2a00; color: #ffc107; }
.tc-state-chip.tc-state-auth_pending { background: #2a2a00; color: #ffc107; }
.tc-state-chip.tc-state-error { background: #3a1a1a; color: #f44336; }
.tc-state-chip.tc-state-disconnected { background: #2a2a2a; color: #666; }
.tc-title {
flex: 1;
font-size: 11px;
color: #888;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -0,0 +1,123 @@
/**
* WxInputBridge — 微信小程序 bindinput/bindconfirm 适配为 IInputSource。
*
* 使用方式(在 terminal-core-view/index.js 中):
* const bridge = createWxInputBridge();
* // 绑定到 InputBridge
* const core_bridge = new InputBridge();
* bridge.on('key', p => { const seq = core_bridge.mapKey(...); transport.send(seq); });
* bridge.on('input', p => { if (!p.isComposing && p.data) transport.send(p.data); });
* bridge.on('paste', p => { transport.send(core_bridge.mapPaste(p.text)); });
*
* 组件调用WXML 事件 → 桥接):
* Page/Component: {
* onInput(e) { bridge.triggerInput(e.detail.value, e.detail.isComposing); },
* onConfirm() { bridge.triggerConfirm(); },
* onPaste(text){ bridge.triggerPaste(text); },
* onFocus() { bridge.triggerFocus(); },
* onBlur() { bridge.triggerBlur(); },
* }
*/
function createWxInputBridge() {
const _listeners = Object.create(null);
function on(event, cb) {
if (!_listeners[event]) _listeners[event] = [];
_listeners[event].push(cb);
return function off() {
const arr = _listeners[event];
if (!arr) return;
const idx = arr.indexOf(cb);
if (idx >= 0) arr.splice(idx, 1);
};
}
function _emit(event, payload) {
const arr = _listeners[event];
if (!arr) return;
for (let i = 0; i < arr.length; i++) {
try { arr[i](payload); } catch(e) { /* isolation */ }
}
}
// ── WXML 事件驱动 API ─────────────────────────────────────────────────────
/**
* 由 WXML bindinput 调用。
* 注意:小程序 bindinput 的 value 是整体输入框值,不是增量;
* 调用方应提取增量(新增字符)后传入 data 参数。
*/
function triggerInput(data, isComposing) {
_emit('input', {
data: data != null ? String(data) : '',
isComposing: !!isComposing,
});
}
/**
* 由 WXML bindconfirm 调用(用户点击软键盘"完成/回车")。
*/
function triggerConfirm() {
_emit('key', {
key: 'Enter',
code: 'Enter',
ctrlKey: false,
altKey: false,
shiftKey: false,
metaKey: false,
});
}
/**
* 由触控工具栏按键调用箭头键、Tab、Ctrl+C 等)。
* @param {string} key KeyboardEvent.key 等价值(如 "ArrowUp"、"c"
* @param {string} code KeyboardEvent.code 等价值(可省略,默认与 key 相同)
* @param {{ ctrlKey, altKey, shiftKey, metaKey }} mods 修饰键状态
*/
function triggerKey(key, code, mods) {
const m = mods || {};
_emit('key', {
key: key,
code: code || key,
ctrlKey: !!m.ctrlKey,
altKey: !!m.altKey,
shiftKey: !!m.shiftKey,
metaKey: !!m.metaKey,
});
}
/**
* 由粘贴操作触发(通过 wx.getClipboardData 读取后调用)。
*/
function triggerPaste(text) {
_emit('paste', { text: String(text || '') });
}
/**
* IME 组合开始(候选词输入开始)。
*/
function triggerCompositionStart(data) {
_emit('compositionstart', { data: String(data || '') });
}
/**
* IME 组合结束(候选词确认)。
*/
function triggerCompositionEnd(data) {
_emit('compositionend', { data: String(data || '') });
}
return {
on: on,
// WXML 事件驱动方法
triggerInput: triggerInput,
triggerConfirm: triggerConfirm,
triggerKey: triggerKey,
triggerPaste: triggerPaste,
triggerCompositionStart: triggerCompositionStart,
triggerCompositionEnd: triggerCompositionEnd,
};
}
module.exports = { createWxInputBridge: createWxInputBridge };

View File

@@ -0,0 +1,112 @@
/**
* WxMeasureAdapter — 微信小程序 wx.createSelectorQuery 实现 IMeasureAdapter。
*
* 由于小程序不支持 ResizeObserver使用 BoundingClientRect 查询容器尺寸,
* 并提供 `poll(intervalMs)` 方法做周期性尺寸检测(模拟 resize 通知)。
*
* 使用方式:
* const m = createWxMeasureAdapter(component, '#tc-viewport', 14, 16.8);
* m.onResize(() => {
* const { widthPx, heightPx } = m.measureContainer();
* const { widthPx: cw, heightPx: ch } = m.measureChar();
* const cols = Math.max(20, Math.floor(widthPx / cw));
* const rows = Math.max(8, Math.floor(heightPx / ch));
* // ... resize terminal
* });
* m.startPoll(500);
* // 在 component detached / onUnload 中:
* m.stopPoll();
*/
/**
* @param {object} ctx - 小程序 Component 实例this用于 createSelectorQuery 作用域
* @param {string} selector - CSS 选择器,如 '#tc-viewport'
* @param {number} charWidthPx - 等宽字符宽度像素(由组件在初始化时测量或硬编码)
* @param {number} charHeightPx - 行高像素
*/
function createWxMeasureAdapter(ctx, selector, charWidthPx, charHeightPx) {
let _containerW = 0;
let _containerH = 0;
let _pollTimer = null;
const _callbacks = [];
/** 实现 IMeasureAdapter.measureChar */
function measureChar() {
return {
widthPx: charWidthPx || 8,
heightPx: charHeightPx || 16,
};
}
/** 实现 IMeasureAdapter.measureContainer */
function measureContainer() {
return {
widthPx: _containerW,
heightPx: _containerH,
};
}
/** 实现 IMeasureAdapter.onResize */
function onResize(cb) {
_callbacks.push(cb);
return function off() {
const idx = _callbacks.indexOf(cb);
if (idx >= 0) _callbacks.splice(idx, 1);
};
}
/** 主动刷新容器尺寸(异步) */
function refresh(onDone) {
const query = ctx.createSelectorQuery();
query.select(selector).boundingClientRect(function(rect) {
if (!rect) return;
const newW = rect.width || 0;
const newH = rect.height || 0;
if (newW !== _containerW || newH !== _containerH) {
_containerW = newW;
_containerH = newH;
for (let i = 0; i < _callbacks.length; i++) {
try { _callbacks[i](); } catch(e) { /* isolation */ }
}
}
if (typeof onDone === 'function') onDone({ widthPx: _containerW, heightPx: _containerH });
}).exec();
}
/** 启动周期性轮询(模拟 ResizeObserver。intervalMs 建议 300~1000ms */
function startPoll(intervalMs) {
stopPoll();
const ms = intervalMs || 500;
// 立刻执行一次
refresh();
_pollTimer = setInterval(function() { refresh(); }, ms);
}
/** 停止轮询 */
function stopPoll() {
if (_pollTimer !== null) {
clearInterval(_pollTimer);
_pollTimer = null;
}
}
/**
* 一次性测量(供初始化使用)。
* @param {function} cb - (result: { widthPx, heightPx }) => void
*/
function measureOnce(cb) {
refresh(cb);
}
return {
measureChar: measureChar,
measureContainer: measureContainer,
onResize: onResize,
refresh: refresh,
startPoll: startPoll,
stopPoll: stopPoll,
measureOnce: measureOnce,
};
}
module.exports = { createWxMeasureAdapter: createWxMeasureAdapter };

View File

@@ -0,0 +1,201 @@
/**
* WxTransport — 微信小程序 wx.connectSocket 实现的 TerminalTransport。
*
* 帧协议与 Web 端 GatewayTransport 完全一致§8.2
* 入站:{ type: 'init'|'stdin'|'resize'|'control', ... }
* 出站:{ type: 'stdout'|'stderr'|'control'|'error', ... }
*
* 使用方式:
* const t = new WxTransport({ gatewayUrl, gatewayToken });
* const off = t.on(event => { ... });
* await t.connect({ host, port, username, password });
* t.send("ls -la\r");
* t.resize(80, 24);
* t.disconnect();
*/
const { sanitizeTerminalOutput } = require('../../../packages/terminal-core/dist-miniprogram/index');
function buildWsUrl(rawUrl, token) {
let base = String(rawUrl || '').trim();
if (!base) throw new Error('WxTransport: 网关地址为空');
if (base.startsWith('http://')) base = 'ws://' + base.slice(7);
if (base.startsWith('https://')) base = 'wss://' + base.slice(8);
if (!base.startsWith('ws://') && !base.startsWith('wss://')) {
base = 'wss://' + base;
}
base = base.replace(/\/+$/, '');
return base + '/ws/terminal?token=' + encodeURIComponent(String(token || ''));
}
function WxTransport(options) {
const cfg = options || {};
this._gatewayUrl = cfg.gatewayUrl || '';
this._gatewayToken = cfg.gatewayToken || '';
this._state = 'idle'; // SessionState
this._sessionId = '';
this._socket = null; // wx.SocketTask
this._listeners = []; // Array<(event) => void>
this._pingTimer = null;
this._pingAt = 0;
}
// ── 接口实现 ──────────────────────────────────────────────────────────────────
/**
* 连接网关并发送 init 帧。
* 返回 Promise在 control.connected 帧到达后 resolve错误时 reject。
*/
WxTransport.prototype.connect = function(params) {
const self = this;
return new Promise(function(resolve, reject) {
if (self._state === 'connecting' || self._state === 'connected') {
resolve();
return;
}
self._state = 'connecting';
const sessionId = 'wx_' + Date.now() + '_' + Math.random().toString(36).slice(2);
self._sessionId = sessionId;
const url = buildWsUrl(self._gatewayUrl, self._gatewayToken);
const task = wx.connectSocket({
url: url,
protocols: ['v1.terminal'],
success: function() { /* socket created */ },
fail: function(err) {
self._state = 'error';
self._emit({ type: 'error', code: 'HOST_UNREACHABLE', message: JSON.stringify(err) });
reject(new Error('wx.connectSocket fail: ' + JSON.stringify(err)));
}
});
self._socket = task;
let resolved = false;
task.onOpen(function() {
// 发送 init 帧
self._sendFrame({ type: 'init', sessionId: sessionId, params: params });
// 启动心跳
self._startPing();
});
task.onMessage(function(res) {
let frame;
try {
frame = JSON.parse(res.data);
} catch(e) {
return;
}
if (frame.type === 'stdout' || frame.type === 'stderr') {
const raw = typeof frame.data === 'string' ? frame.data : '';
const sanitized = sanitizeTerminalOutput(raw);
if (sanitized) self._emit({ type: frame.type, data: sanitized });
} else if (frame.type === 'control') {
if (frame.action === 'connected') {
self._state = 'connected';
self._emit({ type: 'control', action: 'connected', data: frame.data });
if (!resolved) { resolved = true; resolve(); }
} else if (frame.action === 'disconnect') {
self._cleanup();
self._state = 'disconnected';
self._emit({ type: 'control', action: 'disconnect' });
} else if (frame.action === 'pong') {
self._emit({ type: 'control', action: 'pong' });
}
} else if (frame.type === 'error') {
self._state = 'error';
self._emit({ type: 'error', code: frame.code || 'INTERNAL_ERROR', message: frame.message || '' });
if (!resolved) { resolved = true; reject(new Error(frame.message)); }
}
});
task.onError(function(err) {
self._cleanup();
self._state = 'error';
self._emit({ type: 'error', code: 'HOST_UNREACHABLE', message: JSON.stringify(err) });
if (!resolved) { resolved = true; reject(new Error(JSON.stringify(err))); }
});
task.onClose(function() {
self._cleanup();
if (self._state !== 'disconnected' && self._state !== 'error') {
self._state = 'disconnected';
self._emit({ type: 'control', action: 'disconnect' });
}
});
});
};
WxTransport.prototype.send = function(data, meta) {
if (this._state !== 'connected') return;
this._sendFrame({ type: 'stdin', data: data, meta: meta });
};
WxTransport.prototype.resize = function(cols, rows) {
if (this._state !== 'connected') return;
this._sendFrame({ type: 'resize', cols: cols, rows: rows });
};
WxTransport.prototype.disconnect = function(reason) {
this._sendFrame({ type: 'control', action: 'disconnect', reason: reason || 'manual' });
this._cleanup();
this._state = 'disconnected';
};
WxTransport.prototype.on = function(listener) {
const self = this;
self._listeners.push(listener);
return function() {
const idx = self._listeners.indexOf(listener);
if (idx >= 0) self._listeners.splice(idx, 1);
};
};
WxTransport.prototype.getState = function() {
return this._state;
};
// ── 私有方法 ──────────────────────────────────────────────────────────────────
WxTransport.prototype._emit = function(event) {
for (let i = 0; i < this._listeners.length; i++) {
try { this._listeners[i](event); } catch(e) { /* 单个监听器异常不影响其他 */ }
}
};
WxTransport.prototype._sendFrame = function(frame) {
if (!this._socket) return;
try {
this._socket.send({ data: JSON.stringify(frame) });
} catch(e) { /* 忽略发送时 socket 已关闭 */ }
};
WxTransport.prototype._startPing = function() {
const self = this;
self._clearPing();
self._pingTimer = setInterval(function() {
if (self._state !== 'connected') return;
self._pingAt = Date.now();
self._sendFrame({ type: 'control', action: 'ping' });
}, 10000);
};
WxTransport.prototype._clearPing = function() {
if (this._pingTimer !== null) {
clearInterval(this._pingTimer);
this._pingTimer = null;
}
};
WxTransport.prototype._cleanup = function() {
this._clearPing();
if (this._socket) {
try { this._socket.close(); } catch(e) { /* ignore */ }
this._socket = null;
}
};
module.exports = { WxTransport: WxTransport };

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
<title>Terminal Lab</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,25 @@
{
"name": "@remoteconn/web-terminal-lab",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0 --port 5173",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run --passWithNoTests",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"@remoteconn/terminal-core": "1.0.0",
"pinia": "^2.1.7",
"vue": "^3.5.22"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "^5.9.3",
"vite": "^5.4.10",
"vitest": "^4.0.18",
"vue-tsc": "^2.2.4"
}
}

View File

@@ -0,0 +1,19 @@
{
"gatewayUrl": "/ws/terminal",
"gatewayToken": "remoteconn-dev-token",
"selectedServerId": "dev-server",
"servers": [
{
"id": "dev-server",
"name": "Dev Server",
"host": "mac.biboer.cn",
"port": 22,
"username": "gavin",
"transportMode": "gateway",
"authType": "password",
"password": "Gavin123",
"cols": 80,
"rows": 24
}
]
}

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"types": ["vite/client"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.vue", "vitest.config.ts", "vite.config.ts"]
}

View File

@@ -0,0 +1,53 @@
import { fileURLToPath, URL } from "node:url";
import fs from "node:fs";
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
const DEV_HOST = "0.0.0.0";
const DEV_PORT = 5173;
const DEV_PUBLIC_HOST = "shell.biboer.cn";
const DEV_CERT_PATH = "/Users/gavin/.acme.sh/shell.biboer.cn_ecc/fullchain.cer";
const DEV_KEY_PATH = "/Users/gavin/.acme.sh/shell.biboer.cn_ecc/shell.biboer.cn.key";
function resolveDevHttpsConfig() {
// 优先复用 acme.sh 证书,确保本地开发服务直接以 HTTPS 暴露。
if (!fs.existsSync(DEV_CERT_PATH) || !fs.existsSync(DEV_KEY_PATH)) {
return undefined;
}
return {
cert: fs.readFileSync(DEV_CERT_PATH),
key: fs.readFileSync(DEV_KEY_PATH)
};
}
export default defineConfig({
plugins: [vue()],
server: {
host: DEV_HOST,
port: DEV_PORT,
strictPort: true,
https: resolveDevHttpsConfig(),
// 允许通过外网域名访问 dev server含 HMR websocket 握手)。
allowedHosts: [DEV_PUBLIC_HOST],
// 明确 HMR 走外网域名,避免客户端回退到 localhost 导致连接拒绝。
hmr: {
protocol: "wss",
host: DEV_PUBLIC_HOST,
clientPort: DEV_PORT,
port: DEV_PORT
},
proxy: {
"/ws/terminal": {
target: "ws://127.0.0.1:8787",
ws: true,
changeOrigin: true
}
}
},
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url))
}
}
});

View File

@@ -0,0 +1,11 @@
import { defineConfig, mergeConfig } from "vitest/config";
import viteConfig from "./vite.config";
export default mergeConfig(
viteConfig,
defineConfig({
test: {
include: ["src/**/*.test.ts"],
},
})
);

File diff suppressed because it is too large Load Diff

3327
terminal/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

14
terminal/package.json Normal file
View File

@@ -0,0 +1,14 @@
{
"name": "terminal-workspace",
"private": true,
"workspaces": [
"apps/web",
"packages/terminal-core"
],
"scripts": {
"dev": "npm run dev -w apps/web",
"build": "npm run build -w packages/terminal-core && npm run build -w apps/web",
"test": "npm run test -w packages/terminal-core",
"typecheck": "npm run typecheck -w packages/terminal-core && npm run typecheck -w apps/web"
}
}

View File

@@ -0,0 +1,26 @@
{
"name": "@remoteconn/terminal-core",
"version": "1.0.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": "./src/index.ts"
},
"miniprogram": "dist-miniprogram/index.cjs",
"scripts": {
"build": "tsc -p tsconfig.json",
"build:miniprogram": "tsc -p tsconfig.json --module commonjs --moduleResolution node --outDir dist-miniprogram --declaration false --sourceMap false",
"typecheck": "tsc -p tsconfig.json --noEmit",
"typecheck:strict": "tsc -p tsconfig.json --lib esnext --noEmit",
"lint": "eslint src --ext .ts",
"test": "../../node_modules/.bin/vitest run --passWithNoTests"
},
"devDependencies": {
"@types/node": "^25.3.3",
"eslint": "^10.0.2",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vitest": "^4.0.18"
}
}

View File

@@ -0,0 +1,67 @@
/**
* @remoteconn/terminal-core
* 零 DOM 跨平台终端核心库 — 供 Web/iOS/微信小程序三端共用。
*/
// ── 基础类型 ──────────────────────────────────────────────────────────────────
export type {
SessionState,
ConnectParams,
TerminalCredential,
FrameMeta,
TransportEvent,
TransportEventListener,
ColorValue,
CharAttribs,
TerminalCell,
TerminalLine,
CursorState,
TerminalSnapshot,
} from "./types";
export {
DEFAULT_FG,
DEFAULT_BG,
DEFAULT_COLOR,
FLAG_BOLD,
FLAG_DIM,
FLAG_ITALIC,
FLAG_UNDERLINE,
FLAG_BLINK,
FLAG_INVERSE,
FLAG_INVISIBLE,
FLAG_STRIKETHROUGH,
FLAG_OVERLINE,
makeDefaultAttribs,
copyAttribs,
makeBlankCell,
} from "./types";
// ── 接口 ──────────────────────────────────────────────────────────────────────
export type { TerminalTransport } from "./transport/terminalTransport";
export type { RendererAdapter } from "./renderer/rendererAdapter";
export type { IInputSource, InputEventMap, KeyPayload, InputPayload, PastePayload, CompositionPayload } from "./input/IInputSource";
export type { IMeasureAdapter } from "./layout/IMeasureAdapter";
// ── Session ───────────────────────────────────────────────────────────────────
export { SessionMachine, canTransition, assertTransition } from "./session/sessionMachine";
// ── Core ──────────────────────────────────────────────────────────────────────
export { TerminalCore } from "./renderer/terminalCore";
export { OutputBuffer } from "./renderer/outputBuffer";
export type { OutputBufferOptions } from "./renderer/outputBuffer";
// ── Input ─────────────────────────────────────────────────────────────────────
export { InputBridge } from "./input/inputBridge";
export type { InputBridgeOptions, CursorKeyMode } from "./input/inputBridge";
export { ImeController } from "./input/imeController";
export type { ImeState } from "./input/imeController";
// ── Layout ────────────────────────────────────────────────────────────────────
export { calcSize, sizeChanged } from "./layout/sizeCalculator";
export type { SizeResult } from "./layout/sizeCalculator";
export { toGlobalRow, isInBand, textCursorPos, charDisplayWidth } from "./layout/cursorMath";
export type { CursorPos } from "./layout/cursorMath";
// ── Sanitize ──────────────────────────────────────────────────────────────────
export { sanitizeTerminalOutput } from "./sanitize/terminalSanitizer";

View File

@@ -0,0 +1,42 @@
/**
* IInputSource — 平台输入事件源抽象接口Web DOM / 小程序 bindinput
* 所有实现在 apps/* 中packages/terminal-core 只定义接口。
*/
export interface KeyPayload {
key: string;
code: string;
ctrlKey: boolean;
altKey: boolean;
shiftKey: boolean;
metaKey: boolean;
isComposing: boolean;
}
export interface InputPayload {
data: string;
isComposing: boolean;
}
export interface PastePayload {
text: string;
}
export interface CompositionPayload {
data: string;
}
export type InputEventMap = {
key: KeyPayload;
input: InputPayload;
paste: PastePayload;
compositionstart: CompositionPayload;
compositionend: CompositionPayload;
};
export interface IInputSource {
on<K extends keyof InputEventMap>(
event: K,
cb: (payload: InputEventMap[K]) => void
): () => void;
}

View File

@@ -0,0 +1,81 @@
/**
* ImeController — IME 输入法状态机(纯逻辑,零 DOM 事件依赖)。
*
* 状态:
* idle → composingcompositionstart
* composing → commit_pendingcompositionend
* commit_pending → idle提交完成或超时
*
* 平台层Web/小程序)负责订阅 compositionstart/end 后调用此 controller 的方法。
*/
export type ImeState = "idle" | "composing" | "commit_pending";
export class ImeController {
private _state: ImeState = "idle";
/** 当前组合字符串 */
private _composingData = "";
/** 防止 compositionend 后对应的 input 事件重复提交的保护窗口ms */
private _commitGuardUntil = 0;
/** 超时守卫 timer无 DOM API — 由平台层通过注入的 setTimeoutFn 驱动) */
private _guardTimer: ReturnType<typeof setTimeout> | null = null;
constructor(
private readonly setTimeoutFn: (fn: () => void, ms: number) => ReturnType<typeof setTimeout>,
private readonly clearTimeoutFn: (id: ReturnType<typeof setTimeout>) => void,
/** 超时自动复位ms默认 2000 */
private readonly timeoutMs = 2000
) {}
get state(): ImeState { return this._state; }
get composingData(): string { return this._composingData; }
get isComposing(): boolean { return this._state !== "idle"; }
onCompositionStart(data: string): void {
this._clearGuard();
this._state = "composing";
this._composingData = data;
// 安全超时:若 compositionend 迟迟不来,自动复位
this._guardTimer = this.setTimeoutFn(() => {
if (this._state !== "idle") {
this._state = "idle";
this._composingData = "";
}
}, this.timeoutMs);
}
onCompositionEnd(data: string): string {
this._clearGuard();
this._state = "commit_pending";
this._composingData = data;
// 设置提交保护窗口(防 input 事件重复触发)
this._commitGuardUntil = Date.now() + 50;
const committed = data;
this._state = "idle";
this._composingData = "";
return committed;
}
/**
* 判断当前 input 事件是否应被 IME 消耗(不应单独发送给 Transport
*/
shouldConsumeInputEvent(): boolean {
if (this._state === "composing") return true;
if (Date.now() < this._commitGuardUntil) return true;
return false;
}
reset(): void {
this._clearGuard();
this._state = "idle";
this._composingData = "";
this._commitGuardUntil = 0;
}
private _clearGuard(): void {
if (this._guardTimer !== null) {
this.clearTimeoutFn(this._guardTimer);
this._guardTimer = null;
}
}
}

View File

@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { InputBridge } from "./inputBridge";
describe("InputBridge.mapPaste", () => {
it("统一换行为 LF避免将多行粘贴转换为 CR", () => {
const bridge = new InputBridge({ bracketedPaste: false });
const input = "line1\r\nline2\nline3\rline4";
expect(bridge.mapPaste(input)).toBe("line1\nline2\nline3\nline4");
});
it("开启 bracketed paste 时包裹 ESC[200~/ESC[201~", () => {
const bridge = new InputBridge({ bracketedPaste: true });
expect(bridge.mapPaste("a\r\nb")).toBe("\x1b[200~a\nb\x1b[201~");
});
});

View File

@@ -0,0 +1,173 @@
/**
* InputBridge — 键序列映射纯逻辑(零 DOM 事件依赖)。
*
* 负责将逻辑按键/文本映射为发送给 Transport 的 VT 字节串。
* 对齐 xterm.js@5.3.0 src/common/input/Keyboard.ts 行为。
*/
/** 应用光标键模式DECCKM ?1h 激活时使用 SS3 序列) */
export type CursorKeyMode = "normal" | "application";
/** bracketed paste 模式 */
export type BracketedPasteMode = boolean;
export interface InputBridgeOptions {
cursorKeyMode?: CursorKeyMode;
bracketedPaste?: BracketedPasteMode;
}
export class InputBridge {
private cursorKeyMode: CursorKeyMode;
private bracketedPaste: BracketedPasteMode;
constructor(opts: InputBridgeOptions = {}) {
this.cursorKeyMode = opts.cursorKeyMode ?? "normal";
this.bracketedPaste = opts.bracketedPaste ?? false;
}
setCursorKeyMode(mode: CursorKeyMode): void { this.cursorKeyMode = mode; }
setBracketedPaste(enabled: boolean): void { this.bracketedPaste = enabled; }
/**
* 将 KeyboardEvent 语义映射为 VT 字节串。
* 返回 null 表示该按键不产生终端输入(如单纯 modifier key
*/
mapKey(
key: string,
code: string,
ctrlKey: boolean,
altKey: boolean,
shiftKey: boolean,
metaKey: boolean
): string | null {
// --- ASCII 控制字符 ---
if (ctrlKey && !altKey && !metaKey) {
const ctrl = mapCtrlKey(key, code, shiftKey);
if (ctrl !== null) return ctrl;
}
// --- 功能键 ---
const fn = mapFunctionKey(key, code, shiftKey, ctrlKey, altKey, this.cursorKeyMode);
if (fn !== null) return fn;
// --- Alt/Meta 前缀 ---
if ((altKey || metaKey) && key.length === 1) {
return `\x1b${key}`;
}
// --- 普通可打印字符 ---
if (key.length > 0 && !key.startsWith("Dead") && key !== "Unidentified") {
// 过滤掉单纯的 modifier key 名称
if (MODIFIER_KEY_NAMES.has(key)) return null;
return key;
}
return null;
}
/**
* 将粘贴文本处理为终端输入字节串:
* - 行结束统一归一为 LF避免多行粘贴被当作多次回车
* - bracketed paste 模式下用 ESC[200~ / ESC[201~ 包裹
*/
mapPaste(text: string): string {
// 统一处理 CRLF / CR / LF确保多平台粘贴行为一致。
const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
if (this.bracketedPaste) {
return `\x1b[200~${normalized}\x1b[201~`;
}
return normalized;
}
}
// ── 内部工具 ──────────────────────────────────────────────────────────────────
const MODIFIER_KEY_NAMES = new Set([
"Control", "Shift", "Alt", "Meta",
"CapsLock", "NumLock", "ScrollLock",
"OS", "Win",
]);
function mapCtrlKey(key: string, _code: string, shiftKey: boolean): string | null {
// CTRL + A-Z → 0x01-0x1A
if (key.length === 1) {
const upper = key.toUpperCase();
const cp = upper.charCodeAt(0);
if (cp >= 65 && cp <= 90) {
return String.fromCharCode(cp - 64);
}
}
// 特殊组合
switch (key) {
case "@": return "\x00";
case "[": return "\x1b";
case "\\": return "\x1c";
case "]": return "\x1d";
case "^": return "\x1e";
case "_": return "\x1f";
case " ": return "\x00";
case "Enter": return shiftKey ? "\n" : "\r";
case "Backspace": return "\x08";
default: return null;
}
}
function mapFunctionKey(
key: string,
_code: string,
shiftKey: boolean,
ctrlKey: boolean,
_altKey: boolean,
cursorKeyMode: CursorKeyMode
): string | null {
const app = cursorKeyMode === "application";
switch (key) {
// --- 光标键 ---
case "ArrowUp": return modifyArrow(app ? "\x1bOA" : "\x1b[A", shiftKey, ctrlKey);
case "ArrowDown": return modifyArrow(app ? "\x1bOB" : "\x1b[B", shiftKey, ctrlKey);
case "ArrowRight": return modifyArrow(app ? "\x1bOC" : "\x1b[C", shiftKey, ctrlKey);
case "ArrowLeft": return modifyArrow(app ? "\x1bOD" : "\x1b[D", shiftKey, ctrlKey);
// --- Enter / Backspace / Tab ---
case "Enter": return "\r";
case "Backspace": return ctrlKey ? "\x08" : "\x7f";
case "Tab": return shiftKey ? "\x1b[Z" : "\t";
case "Escape": return "\x1b";
case "Delete": return "\x1b[3~";
case "Insert": return "\x1b[2~";
// --- Home / End ---
case "Home": return ctrlKey ? "\x1b[1;5H" : app ? "\x1bOH" : "\x1b[H";
case "End": return ctrlKey ? "\x1b[1;5F" : app ? "\x1bOF" : "\x1b[F";
// --- Page Up / Page Down ---
case "PageUp": return shiftKey ? "\x1b[5;2~" : "\x1b[5~";
case "PageDown": return shiftKey ? "\x1b[6;2~" : "\x1b[6~";
// --- F1-F12 ---
case "F1": return "\x1bOP";
case "F2": return "\x1bOQ";
case "F3": return "\x1bOR";
case "F4": return "\x1bOS";
case "F5": return "\x1b[15~";
case "F6": return "\x1b[17~";
case "F7": return "\x1b[18~";
case "F8": return "\x1b[19~";
case "F9": return "\x1b[20~";
case "F10": return "\x1b[21~";
case "F11": return "\x1b[23~";
case "F12": return "\x1b[24~";
default: return null;
}
}
function modifyArrow(base: string, shiftKey: boolean, ctrlKey: boolean): string {
if (ctrlKey && shiftKey) {
// replace final letter with modifier param
return base.slice(0, -1) + ";6" + base.slice(-1);
}
if (ctrlKey) return base.slice(0, -1) + ";5" + base.slice(-1);
if (shiftKey) return base.slice(0, -1) + ";2" + base.slice(-1);
return base;
}

View File

@@ -0,0 +1,14 @@
/**
* IMeasureAdapter — 平台测量接口(字符/容器尺寸获取)。
* Web 由 ResizeObserver + Canvas 实现;小程序由 wx.createSelectorQuery 实现。
*/
export interface IMeasureAdapter {
/** 测量单个等宽字符宽/高(像素)*/
measureChar(): { widthPx: number; heightPx: number };
/** 测量终端容器内部可用宽/高(已去除 padding像素*/
measureContainer(): { widthPx: number; heightPx: number };
/** 订阅容器尺寸变化;返回取消订阅函数 */
onResize(cb: () => void): () => void;
}

View File

@@ -0,0 +1,80 @@
/**
* cursorMath — 光标坐标纯算法工具。
* 零 DOM 依赖。区分"终端光标"VT core 语义)与"输入光标"(输入框 selectionStart
*/
export interface CursorPos { row: number; col: number; }
/** 将终端光标视口坐标转为全局行号 */
export function toGlobalRow(baseY: number, cursorY: number): number {
return baseY + cursorY;
}
/**
* 判断触摸行是否在光标激活带内。
* activationRadius 建议 2 行。
*/
export function isInBand(touchRow: number, cursorGlobalRow: number, activationRadius = 2): boolean {
return Math.abs(touchRow - cursorGlobalRow) <= activationRadius;
}
/**
* 从 textarea 文本推导输入框光标位置((row, col) 坐标0-based
* 纯函数O(n) 扫描文本。
*/
export function textCursorPos(
text: string,
selectionStart: number,
cols: number
): CursorPos {
let row = 0;
let col = 0;
for (let i = 0; i < selectionStart && i < text.length; i++) {
const ch = text[i] ?? "";
if (ch === "\n") {
row++;
col = 0;
} else {
const w = charDisplayWidth(ch);
col += w;
if (col >= cols) {
// 自动折行
row += Math.floor(col / cols);
col = col % cols;
}
}
}
return { row, col };
}
/**
* 字符显示宽度ASCII=1, CJK/全角=2, 控制字符=0
* 使用简化的 East Asian Width 判断;完整版可插入 unicode-east-asian-width 库。
*/
export function charDisplayWidth(ch: string): 0 | 1 | 2 {
if (!ch) return 0;
const cp = ch.codePointAt(0) ?? 0;
if (cp < 0x20 || (cp >= 0x7f && cp < 0xa0)) return 0; // 控制字符
if (isWide(cp)) return 2;
return 1;
}
function isWide(cp: number): boolean {
// GB2312/CJK 基本覆盖范围
if (cp >= 0x1100 && cp <= 0x115F) return true; // Hangul Jamo
if (cp === 0x2329 || cp === 0x232A) return true;
if (cp >= 0x2E80 && cp <= 0x303E) return true; // CJK Radicals
if (cp >= 0x3040 && cp <= 0x33FF) return true; // Japanese
if (cp >= 0x3400 && cp <= 0x4DBF) return true; // CJK Ext A
if (cp >= 0x4E00 && cp <= 0x9FFF) return true; // CJK Unified
if (cp >= 0xA000 && cp <= 0xA4CF) return true; // Yi
if (cp >= 0xAC00 && cp <= 0xD7AF) return true; // Hangul Syllables
if (cp >= 0xF900 && cp <= 0xFAFF) return true; // CJK Compatibility
if (cp >= 0xFE10 && cp <= 0xFE19) return true; // Vertical forms
if (cp >= 0xFE30 && cp <= 0xFE6F) return true; // CJK Compatibility Small
if (cp >= 0xFF00 && cp <= 0xFF60) return true; // Fullwidth
if (cp >= 0xFFE0 && cp <= 0xFFE6) return true;
if (cp >= 0x1F300 && cp <= 0x1F9FF) return true; // Emoji
if (cp >= 0x20000 && cp <= 0x2FFFD) return true; // CJK Ext B-F
return false;
}

View File

@@ -0,0 +1,27 @@
/**
* sizeCalculator — 纯算法px → cols/rows。
* 零 DOM 依赖,所有 DOM 测量由平台的 IMeasureAdapter 注入。
*/
export interface SizeResult {
cols: number;
rows: number;
}
export function calcSize(
containerWidthPx: number,
containerHeightPx: number,
charWidthPx: number,
lineHeightPx: number
): SizeResult {
const cols = Math.max(20, Math.floor(containerWidthPx / Math.max(1, charWidthPx)));
const rows = Math.max(8, Math.floor(containerHeightPx / Math.max(1, lineHeightPx)));
return { cols, rows };
}
/**
* 判断尺寸变化是否超过阈值(>= 1 col 或 >= 1 row避免抖动触发频繁 resize。
*/
export function sizeChanged(a: SizeResult, b: SizeResult): boolean {
return Math.abs(a.cols - b.cols) + Math.abs(a.rows - b.rows) >= 1;
}

View File

@@ -0,0 +1,75 @@
/**
* OutputBuffer — 终端输出缓冲区。
* 双阈值(条目数 + 字节数)裁剪,保证低端设备不 OOM。
*/
export interface OutputBufferOptions {
maxEntries?: number; // 默认 500
maxBytes?: number; // 默认 512 * 1024 (512KB)
}
export class OutputBuffer {
private entries: string[] = [];
private totalBytes = 0;
private readonly maxEntries: number;
private readonly maxBytes: number;
/** 缓冲区修订号:每次 push 后递增,供外部脏检测 */
public revision = 0;
constructor(opts: OutputBufferOptions = {}) {
this.maxEntries = Math.max(200, opts.maxEntries ?? 500);
this.maxBytes = Math.max(65536, opts.maxBytes ?? 512 * 1024);
}
push(data: string): void {
if (data.length === 0) return;
this.entries.push(data);
this.totalBytes += data.length * 2; // UTF-16 字符 ≈ 2 字节估算
this.trim();
this.revision++;
}
/** 获取全部条目(用于重放) */
getAll(): readonly string[] {
return this.entries;
}
/** 获取从指定修订号之后新增的条目(增量重放辅助) */
getSince(revision: number): string {
// 简化实现revision 不足时全量重放
if (revision <= 0) return this.entries.join("");
// 粗略计算需要从后面取多少条目
// 这里保守地返回全量;如需精确增量可增加 revision→index 映射表
return this.entries.join("");
}
clear(): void {
this.entries = [];
this.totalBytes = 0;
this.revision++;
}
get size(): number {
return this.entries.length;
}
get bytes(): number {
return this.totalBytes;
}
private trim(): void {
// 先按条目裁剪
while (this.entries.length > this.maxEntries) {
const removed = this.entries.shift();
if (removed !== undefined) this.totalBytes -= removed.length * 2;
}
// 再按字节裁剪
while (this.totalBytes > this.maxBytes && this.entries.length > 1) {
const removed = this.entries.shift();
if (removed !== undefined) this.totalBytes -= removed.length * 2;
}
if (this.totalBytes < 0) this.totalBytes = 0;
}
}

View File

@@ -0,0 +1,28 @@
import type { TerminalSnapshot } from "../types";
/**
* RendererAdapter — 渲染层抽象接口(每个平台各自实现)。
* 渲染器只读 snapshot禁止回写 TerminalCore 内部状态。
*/
export interface RendererAdapter {
/**
* 挂载到平台容器。
* Web: container 为 HTMLElement
* 小程序: 由 terminal-core-view 组件自行管理,传 null。
*/
mount(container: unknown): void;
/** 增量写入 VT 字节流(内部转发给 TerminalCore.write触发重新渲染 */
write(data: string): void;
/** 通知渲染器终端尺寸已变化 */
resize(cols: number, rows: number): void;
/**
* 全量重放快照(模式切换时调用,将渲染器状态同步到最新 snapshot
*/
applySnapshot(snapshot: TerminalSnapshot): void;
/** 销毁渲染器,释放 DOM/Canvas/定时器等资源 */
dispose(): void;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,22 @@
/**
* TerminalSanitizer — 过滤 stdout/stderr 中会干扰渲染的噪音序列。
*
* 目前仅过滤同步更新模式DECSM ?2026 = DECRQM-like sync-update
* 以及其他已知会在某些 SSH 服务端发送但 terminal-core 不处理的序列。
* 不得过滤正常 CSI/OSC 序列,否则会破坏 TUI 程序显示。
*/
/** 同步更新开始/结束序列DEC private: ?2026h / ?2026l */
const SYNC_UPDATE_RE = /\x1b\[\?2026[hl]/g;
/** Bracketed paste 开关(部分链路丢失 ESC 后会残留 '?2004h/l' 文本) */
const BRACKETED_PASTE_RE = /(?:\x1b\[)?\?2004[hl]/g;
/** XTPUSHCOLORS / XTPOPCOLORSPs=10/11—— terminal-core 不处理,安全丢弃 */
const XTPUSH_POP_COLORS_RE = /\x1b\[(?:10|11)m/g;
export function sanitizeTerminalOutput(data: string): string {
return data
.replace(SYNC_UPDATE_RE, "")
.replace(BRACKETED_PASTE_RE, "")
.replace(XTPUSH_POP_COLORS_RE, "");
}

View File

@@ -0,0 +1,61 @@
import type { SessionState } from "../types";
/** 合法状态迁移表 */
const TRANSITIONS: Record<SessionState, SessionState[]> = {
idle: ["connecting", "disconnected"],
connecting: ["auth_pending", "error", "disconnected"],
auth_pending: ["connected", "error", "disconnected"],
connected: ["reconnecting", "disconnected", "error"],
reconnecting: ["connected", "error", "disconnected"],
disconnected: ["connecting", "idle"],
error: ["connecting", "disconnected"],
};
export function canTransition(from: SessionState, to: SessionState): boolean {
return (TRANSITIONS[from] ?? []).includes(to);
}
export function assertTransition(from: SessionState, to: SessionState): void {
if (!canTransition(from, to)) {
throw new Error(`非法状态跳转: ${from}${to}`);
}
}
/**
* SessionMachine — 会话生命周期状态机。
* 纯状态逻辑,无任何副作用,零 DOM 依赖。
*/
export class SessionMachine {
private _state: SessionState = "idle";
private listeners = new Set<(state: SessionState) => void>();
get state(): SessionState {
return this._state;
}
transition(to: SessionState): void {
assertTransition(this._state, to);
this._state = to;
for (const fn of this.listeners) {
fn(this._state);
}
}
tryTransition(to: SessionState): boolean {
if (!canTransition(this._state, to)) return false;
this.transition(to);
return true;
}
onChange(fn: (state: SessionState) => void): () => void {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
}
reset(): void {
this._state = "idle";
for (const fn of this.listeners) {
fn(this._state);
}
}
}

View File

@@ -0,0 +1,15 @@
import type { ConnectParams, FrameMeta, SessionState, TransportEventListener } from "../types";
/**
* TerminalTransport — 网络层抽象接口(三端各自实现)。
* 此接口在 packages/terminal-core 定义apps/web 现有实现兼容此签名。
*/
export interface TerminalTransport {
connect(params: ConnectParams): Promise<void>;
send(data: string, meta?: FrameMeta): Promise<void>;
resize(cols: number, rows: number): Promise<void>;
disconnect(reason?: string): Promise<void>;
/** 返回取消订阅函数 */
on(listener: TransportEventListener): () => void;
getState(): SessionState;
}

View File

@@ -0,0 +1,149 @@
/**
* 所有平台共用的基础类型定义。
* 此文件不得引入任何 DOM/BOM API不得依赖 @remoteconn/shared 或 apps/* 模块。
*/
// ── 会话状态 ───────────────────────────────────────────────────────────────────
export type SessionState =
| "idle"
| "connecting"
| "auth_pending"
| "connected"
| "reconnecting"
| "disconnected"
| "error";
// ── 连接参数 ───────────────────────────────────────────────────────────────────
export interface ConnectParams {
host: string;
port: number;
username: string;
credential: TerminalCredential;
knownHostFingerprint?: string;
cols: number;
rows: number;
clientSessionKey?: string;
}
export type TerminalCredential =
| { type: "password"; password: string }
| { type: "privateKey"; privateKey: string; passphrase?: string }
| { type: "certificate"; privateKey: string; certificate: string; passphrase?: string };
// ── 帧 metastdin 可选附加) ──────────────────────────────────────────────────
export interface FrameMeta {
source?: "keyboard" | "assist" | "paste";
txnId?: string;
}
// ── Transport 事件 ─────────────────────────────────────────────────────────────
export type TransportEvent =
| { type: "stdout"; data: string }
| { type: "stderr"; data: string }
| { type: "latency"; data: number }
| { type: "connected"; fingerprint?: string }
| { type: "disconnect"; reason: string }
| { type: "error"; code: string; message: string };
export type TransportEventListener = (event: TransportEvent) => void;
// ── 颜色值 ────────────────────────────────────────────────────────────────────
export interface ColorValue {
mode: "default" | "p16" | "p256" | "rgb";
/** p16: 0-15p256: 0-255rgb: 0xRRGGBB */
value: number;
}
export const DEFAULT_FG: ColorValue = { mode: "default", value: 0 };
export const DEFAULT_BG: ColorValue = { mode: "default", value: 0 };
export const DEFAULT_COLOR: ColorValue = { mode: "default", value: 0 };
// ── SGR flagsbitmask ───────────────────────────────────────────────────────
export const FLAG_BOLD = 1 << 0;
export const FLAG_DIM = 1 << 1;
export const FLAG_ITALIC = 1 << 2;
export const FLAG_UNDERLINE = 1 << 3;
export const FLAG_BLINK = 1 << 4;
export const FLAG_INVERSE = 1 << 5;
export const FLAG_INVISIBLE = 1 << 6;
export const FLAG_STRIKETHROUGH = 1 << 7;
export const FLAG_OVERLINE = 1 << 8;
// ── 单元格 ────────────────────────────────────────────────────────────────────
export interface TerminalCell {
char: string; // 空单元格为 ' '
width: 1 | 2; // CJK 宽字符占 2
fg: ColorValue;
bg: ColorValue;
flags: number;
underlineStyle: "none" | "single" | "double" | "curly" | "dotted" | "dashed";
underlineColor: ColorValue;
}
// ── 行 ────────────────────────────────────────────────────────────────────────
export interface TerminalLine {
cells: TerminalCell[];
isWrapped: boolean;
}
// ── 光标状态 ──────────────────────────────────────────────────────────────────
export interface CursorState {
x: number; // 列0-based
y: number; // 行视口相对0-based
globalRow: number; // baseY + y全局行号供触摸激活带使用
visible: boolean; // DECTCEM
}
// ── 快照(渲染器唯一输入数据结构) ─────────────────────────────────────────────
export interface TerminalSnapshot {
cols: number;
rows: number;
cursor: CursorState;
lines: TerminalLine[]; // 长度 = rows仅当前可视区
title: string;
revision: number; // 每次 write() 后递增
isAlternateBuffer: boolean;
}
// ── 字符属性VT 内部使用) ───────────────────────────────────────────────────
export interface CharAttribs {
fg: ColorValue;
bg: ColorValue;
flags: number;
underlineStyle: "none" | "single" | "double" | "curly" | "dotted" | "dashed";
underlineColor: ColorValue;
}
export function makeDefaultAttribs(): CharAttribs {
return {
fg: { mode: "default", value: 0 },
bg: { mode: "default", value: 0 },
flags: 0,
underlineStyle: "none",
underlineColor: { mode: "default", value: 0 },
};
}
export function copyAttribs(a: CharAttribs): CharAttribs {
return {
fg: { ...a.fg },
bg: { ...a.bg },
flags: a.flags,
underlineStyle: a.underlineStyle,
underlineColor: { ...a.underlineColor },
};
}
export function makeBlankCell(attribs?: CharAttribs): TerminalCell {
if (!attribs) {
return { char: " ", width: 1, fg: DEFAULT_FG, bg: DEFAULT_BG, flags: 0, underlineStyle: "none", underlineColor: DEFAULT_COLOR };
}
return {
char: " ", width: 1,
fg: { ...attribs.fg },
bg: { ...attribs.bg },
flags: attribs.flags,
underlineStyle: attribs.underlineStyle,
underlineColor: { ...attribs.underlineColor },
};
}

View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["esnext"],
"strict": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noImplicitOverride": true,
"noUncheckedIndexedAccess": true,
"declaration": true,
"sourceMap": true,
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*.ts"]
}

155
terminal/question-solved.md Normal file
View File

@@ -0,0 +1,155 @@
# 问题闭环记录Question Solved
本文档记录已确认问题的现象、根因、修复与回归要点,目标是避免重复踩坑。
## 目录
1. [2026-03-02第三方输入法导致输入重复](#ime-dup)
2. [2026-03-02点击粘贴触发 BAD_REQUEST / invalid_union](#paste-invalid-union)
3. [2026-03-02多行粘贴被当作回车执行](#multiline-paste-enter)
4. [2026-03-03回车后首字母重复显示`aasdf`](#enter-leading-dup)
5. [通用回归清单](#regression-checklist)
<a id="ime-dup"></a>
## 2026-03-02第三方输入法导致输入重复`jk -> jkjk``测试 -> 测试测试`
### 现象
- 第三方输入法下输入 `jk` 显示 `jkjk`,输入 `测试` 显示 `测试测试`
- 原生输入法或英文输入模式正常。
### 根因
- 同一文本同时走了 `keydown``input` 两条发送路径,导致双发。
- 第三方输入法会在 `keydown` 阶段给出整段文本(`key="测试"` / `key="jk"`),旧逻辑误判后直接发送。
### 修复
- 文件:`apps/web/src/terminal/components/TerminalInputBar.vue`
- 策略:
1. `keydown` 只处理控制键/功能键。
2. 文本输入统一走 `input` 通道,保证单通道发送。
3. `KEYDOWN_DIRECT_KEYS` 作为白名单,非白名单文本直接跳过。
### 验证
- `ls``jk``测试` 均不重复。
- `Enter``Ctrl+C` 等控制键行为保持正常。
### 自动化测试
- 文件:`apps/web/src/terminal/input/inputPolicy.test.ts`
- 覆盖文本键、功能键、组合键判定。
---
<a id="paste-invalid-union"></a>
## 2026-03-02点击粘贴触发 BAD_REQUEST / invalid_union
### 现象
- 点击工具栏“粘贴”后出现 error连接异常或重连。
- 日志出现 `ws:error_frame code=BAD_REQUEST`,消息含 `invalid_union`
### 根因
- 网关 `stdin` 入参 schema 在现网存在差异;`paste` 携带 `meta` 会在部分环境触发 union 校验失败。
### 修复
- 文件:`apps/web/src/terminal/stores/useTerminalStore.ts`
- 策略:
1. `paste` 分片发送时不再携带 `meta`
2. `keyboard/assist` 保持原有 `meta.source`
3. 粘贴分片大小从 `512` 调整为 `256`,降低单帧风险。
### 验证
- 粘贴日志出现 `wireMetaSource: "none"`
- 不再出现 `BAD_REQUEST invalid_union`
---
<a id="multiline-paste-enter"></a>
## 2026-03-02多行粘贴被当作回车执行
### 现象
- 复制多行文本后粘贴进 shell会被当作多次回车导致命令提前执行。
### 根因
- 旧粘贴逻辑将换行统一转为 `\r`(回车语义),不符合“多行粘贴应作为文本块输入”的预期。
### 修复
- 文件:
- `packages/terminal-core/src/input/inputBridge.ts`
- `apps/web/src/terminal/components/TerminalInputBar.vue`
- `apps/web/src/terminal/TerminalPage.vue`
- 策略:
1. `mapPaste` 将换行统一归一为 `\n`,不再转成 `\r`
2. Web 两条粘贴链路(原生 `paste`、工具栏“粘贴”)统一走 `InputBridge.mapPaste()`
3. 开启 bracketed paste`\x1b[200~... \x1b[201~`),避免被解释为逐行回车执行。
### 验证
- 粘贴多行文本默认不自动逐行执行。
- 两条粘贴入口行为一致。
### 自动化测试
- 文件:`packages/terminal-core/src/input/inputBridge.test.ts`
- 覆盖:
1. `CRLF/CR/LF` 统一归一为 `LF`
2. bracketed paste 包裹序列正确。
---
<a id="regression-checklist"></a>
## 2026-03-02软键盘弹出后光标行定位不准与收回后页面跳动问题
### 现象
1. 终端在 iOS 移动设备上点击输入区弹出软键盘时,期望将光标(提示符行)定位到屏幕的距顶 1/4 处,但实际经常定位到键盘顶部或在屏幕中部乱跳。
2. 软键盘收回时,期望恢复到键盘弹出前的状态,但页面内容经常回跳或者复位到屏幕底部。
3. 终端输入文字(`autoFollow=true`)时,滚动条经常突然跳到屏幕中部,无法锁定在最底部。
### 根因
1. **行高计算不准确:**`textareaRenderer` 的旧版计算方式中,利用 `scrollHeight / totalLines` 试图估算出单行的 `lineHeight`。但这在遇到换行文字(如超宽命令占据多行 HTML或者终端末尾包含空白补齐的 Null Lines 时,按比例换算的 `offsetPx` 数值偏差严重。
2. **滚动到 scrollHeight 的陷阱:** `renderSnapshot``followBottom` 在自动跟随终端内容时,直接触发了 `this.outputEl.scrollTop = this.outputEl.scrollHeight`。但是 `xterm``snapshot.lines` 通常包含底部的很多未使用的纯空行(通常是为了填满预设高度而预留的。例如在你的日志中底部留有二十几行空行 Padding。直接将 `scrollTop` 置为最大,会导致包含真实光标的行被视觉上挤到了屏幕上方,呈现出"跳到了屏幕中部"的怪异现象。
3. **iOS 异步布局时序缺陷:** `KeyboardAdjustController`iOS 上软键盘收回(`visualViewport.height` 恢复)早于实际的 DOM `clientHeight` 恢复更新,用固定的时间延迟去读取和操作会导致布局错误。同时 iOS 原生的 `scroll` 行为是异步派发的,和程序的滚动重置产生了干扰覆盖(程序刚设回了 `snapshot` 高度,又被键盘底层收回的 `scroll` 冲成了底部)。
### 修复
- 文件:
- `apps/web/src/terminal/renderer/textareaRenderer.ts`
- `apps/web/src/terminal/input/keyboardAdjustController.ts`
- 策略:
1. **放弃比例估算,直接获取光标 DOM 节点:**`textareaRenderer` 渲染的光标附带有专门的类名 `tc-cursor-cell`,此时我们设置外部包裹元素为 `position: relative`,并直接借助 `const cursorSpan = this.outputEl.querySelector('.tc-cursor-cell')` 拿到精确的 `offsetTop` 来计算光标此时刻在纵向的确切位置,确保毫无误差。
2. **将光标置于可视区底部,拒绝 scrollHeight** 摒弃粗暴地拉向极限 `scrollHeight` 的做法,改为使用光标偏移量算出安全的底端视区:`let targetScrollTop = cursorBottom - clientH + 20` 并将其作为真实的自动跟随坐标,将跟随对象从"整个 Buffer 的底部"变更为"真正光标所处的区域"。
3. **Polling 等待基线恢复,长短结合阻塞 scroll 事件干扰:**`keyboardAdjustController.ts` 中针对键盘关闭的 Layout 响应,引入 `requestAnimationFrame` 及定时检测Polling不断比对 `clientHeight` 是否已经长回键盘未弹出前的高度,只有确认恢复或者超市才恢复预存前的 `scrollTop` 快照,借此解决 iOS 下因为布局慢导致的跳闪。并通过定时锁去屏蔽由于视差调整造成的自动 `autofollow` 开关变动。
### 验证
- 点击 iOS 输入区弹出软键盘,终端精确将当前焦点处于 1/4 屏幕处。
- 收起键盘后,稳定跳掉之前的阅读点(快照复原准确)。
- 长按或者多次键盘输入不再跳动到屏幕中部。
---
<a id="enter-leading-dup"></a>
## 2026-03-03回车后首字母重复显示`aasdf`
### 现象
- 输入英文命令(如 `asdf`)后回车,终端提示符行显示 `aasdf`,但服务端实际执行的是 `asdf`(例如返回 `zsh: command not found: asdf`)。
- 问题具备偶发性,通常出现在回显分片较细时。
### 根因
- 回显数据分帧到达,典型序列为:`"a"``"\b as"``"df..."`
- 旧实现按帧即时渲染,第一帧字符已经落盘;第二帧开头的 `\b`Backspace无法回退上一帧已写入内容导致视觉上保留多余首字母。
- 同时存在 `\r\r\n` 与 ANSI 控制序列交织,如果不按终端语义处理,也会放大错位和空行现象。
### 修复
- 文件:`demo/main.js`
- 策略:
1.`renderAnsiToHtml` 中补齐控制字符语义:处理 `\x08`(退格)、`\r\n`(归一为换行)和裸 `\r`(回到当前行首覆盖)。
2. 增加短窗口流式合帧(`STREAM_BATCH_MS`):将连续 `stdout/stderr` 分片合并后再渲染,确保跨帧 `\b/\r` 能作用于同一文本缓冲。
3. 保留原有“本地回显清理”策略(`clearLast()`),避免与远端回显叠加。
### 验证
- 复现输入 `asdf` 回车,不再出现 `aasdf`
- 日志可见发送仍为 `asdf\n`,错误回显保持 `command not found: asdf`,与服务端行为一致。
- 其它输出路径(`stdout/stderr`、ANSI 颜色)不回归。
## 通用回归清单(每次改输入/粘贴链路都执行)
1. 英文输入 `ls` 不重复。
2. 第三方输入法输入 `jk``测试` 不重复。
3. `Enter``Ctrl+C`、方向键功能正常。
4. 工具栏粘贴与原生粘贴行为一致。
5. 长文本粘贴不触发网关 `BAD_REQUEST invalid_union`
6. 多行粘贴不应被当作多次回车直接执行。

View File

@@ -0,0 +1,730 @@
# xterm 移植独立实验项目实施方案2026-03-01
## 1. 目标与范围
本方案用于落地一个独立实验项目验证“xterm.js 基线能力”与“原生 textarea 方案”的迁移可行性。
该项目仅用于测试,不替换现有 Web/小程序主链路。
规范基线:
1. 渲染与输入语义以 `xterm.js@5.3.0` 源码为准。
2. 文档中的换行、回显、光标、粘贴规则均以该版本源码行为校正。
3. 若未来升级 xterm 版本,需先做“规范差异审计”再修改本方案。
目标:
1. 目录独立:实验代码只在 `labs/xterm-standalone/`
2. 依赖可复用:允许复用现有依赖版本策略和工具链版本。
3. 构建/运行可复用:命令约定复用 `dev/build/test/lint/typecheck`
4. 业务零耦合:不复用现有业务模块逻辑,不修改现有状态机与会话实现。
5. 可删除无副作用:删除实验目录和小程序入口后,主项目行为不变。
## 2. 非目标
1. 不替换 `apps/web` 现有终端实现。
2. 不修改 `apps/gateway` 的业务协议和转发逻辑。
3. 不改 `packages/shared` 的现有模型定义和状态机实现。
4. 不将实验代码接入生产发布链路。
## 3. 独立性与复用规则
### 3.1 必须满足
1. 实验代码不得 `import` 现有业务模块(`apps/*``packages/*` 的业务实现)。
2. 小程序仅新增“入口级改动”(页面按钮 + web-view 跳转)。
3. 实验项目失败、下线、删除,不影响主流程连接与发布。
4. 禁止在实验项目中运行时依赖 `@xterm/*`xterm 仅作为源码语义参考,不作为可执行依赖。
### 3.2 允许复用
1. 依赖版本策略可复用(例如与根工程保持同主版本)。
2. 工程工具可复用TypeScript、Vite、ESLint、Vitest 配置风格)。
3. 运行方式可复用(统一命令命名,不要求命令实现完全一致)。
4. 主题 token 可复用现有 Web/小程序的成熟定义(颜色变量、语义色分层、主题切换策略),但实现代码必须保留在实验目录内。
## 4. 目录规划(实施级)
```text
labs/xterm-standalone/
README.md
package.json
src/
main.ts
app/App.vue
pages/TerminalLabPage.vue
components/
TerminalToolbar.vue
TerminalViewport.vue
TerminalInputBar.vue
TerminalTouchTools.vue
core/
session/sessionMachine.ts
session/sessionTypes.ts
transport/terminalTransport.ts
transport/gatewayTransport.ts
renderer/rendererAdapter.ts
renderer/compatRenderer.ts
renderer/textareaRenderer.ts
input/inputBridge.ts
input/imeController.ts
layout/sizeCalculator.ts
layout/cursorMath.ts
buffer/outputBuffer.ts
sanitize/terminalSanitizer.ts
stores/useTerminalLabStore.ts
styles/terminal-lab.css
```
## 5. 总体架构与数据流
```text
Transport(stdout/stderr) -> Sanitizer -> OutputBuffer -> RendererAdapter(compat|textarea) -> View
UserInput(key/ime/paste/touch) -> InputBridge -> xterm-compatible input mapping -> Transport(stdin)
LayoutResize -> sizeCalculator(cols/rows) -> Transport(resize) + Renderer.resize
```
关键原则:
1. 传输层不感知渲染器类型。
2. 输入处理统一走 `InputBridge`,避免各组件各自发送 stdin。
3. 渲染器通过统一接口实现,便于模式切换和 A/B 对比。
4. 输入映射遵循 xterm 规则:`Enter -> CR`,粘贴文本行结束归一到 `CR`
5. 输出渲染遵循 xterm 控制字符纪律:`CR``LF` 分离语义,`convertEol` 默认 `false`
6. 原生响应优先:软键盘、选区、滚动由原生 `textarea` 通道主导xterm 仅负责渲染与终端语义,不得接管系统输入响应。
7. 事件主权清晰:键盘/鼠标事件先由应用层做区域过滤与路由,再决定是否交给 xterm。
## 6. 状态机规划
## 6.1 会话状态机(连接生命周期)
状态集合(与现有模型对齐):
1. `idle`
2. `connecting`
3. `auth_pending`
4. `connected`
5. `reconnecting`
6. `disconnected`
7. `error`
合法迁移(必须在 `sessionMachine.ts` 中强校验):
1. `idle -> connecting | disconnected`
2. `connecting -> auth_pending | error | disconnected`
3. `auth_pending -> connected | error | disconnected`
4. `connected -> reconnecting | disconnected | error`
5. `reconnecting -> connected | error | disconnected`
6. `disconnected -> connecting | idle`
7. `error -> connecting | disconnected`
触发事件:
1. `connect_request`
2. `socket_open`
3. `connected_frame`
4. `stdout_first_packet`
5. `disconnect_frame/ws_close`
6. `error_frame/ws_error`
7. `manual_disconnect`
8. `auto_reconnect_timeout`
## 6.2 输入状态机IME/普通输入)
状态:
1. `input_idle`
2. `input_composing`
3. `input_commit_pending`
规则:
1. `compositionstart` -> `input_composing`
2. `compositionend` -> 进入 `input_commit_pending`,提交非 ASCII 候选
3. `beforeinput/input` 非组合态可触发 ASCII 兜底发送
4. 超时守卫(如 1.8s)未收到 `compositionend` 时自动恢复到 `input_idle`
## 6.3 触摸焦点状态机(移动端)
动作集合:
1. `BLUR_ONLY`
2. `FOCUS_KEYBOARD`
3. `PASS_NATIVE`
4. `PASS_SCROLL`
决策优先级:
1. `hasSelectionStart || hasSelectionEnd` -> `PASS_NATIVE`
2. `scrollLike` -> `PASS_SCROLL`
3. `moved` -> `PASS_NATIVE`
4. `!inBand` -> `BLUR_ONLY`
5. `inBand` -> `FOCUS_KEYBOARD`
硬约束(新增):
1. 只有 `FOCUS_KEYBOARD` 动作允许触发原生键盘弹出。
2. `FOCUS_KEYBOARD` 必须满足“光标附近行”条件(`inBand=true`),禁止在非光标邻近区域弹键盘。
3. `PASS_NATIVE/PASS_SCROLL` 禁止 `preventDefault`,确保原生选区和滚动不被破坏。
4. 禁止在终端根容器上全局拦截 `touchstart/touchmove/click`;仅允许在明确手势分支下做最小拦截。
5. 移动端 `compat` 仅承担回显渲染,输入焦点必须由原生 `textarea` 锚点持有。
6. 禁止在非用户手势路径下调用 `terminal.focus()` 触发软键盘。
## 6.4 渲染模式状态机
状态:
1. `renderer_compat`
2. `renderer_textarea`
切换事件:
1. `switch_to_compat`
2. `switch_to_textarea`
切换过程必须原子化:
1. `oldRenderer.dispose()`
2. `newRenderer.mount(container)`
3. `replay buffer`
4. `apply size/theme`
5. `focus restore`(仅桌面自动聚焦)
## 6.5 换行模式状态xterm 对齐)
状态:
1. `convertEol=false`(默认)
2. `convertEol=true`(由 `CSI 20 h` 打开)
规则:
1. `LF/VT/FF` 进入 `lineFeed()`:始终 `y+1`,并在触底时滚屏。
2. `convertEol=true` 时,`lineFeed()` 额外执行 `x=0``false` 时保留列位置。
3. `CR` 仅执行 `x=0`,不改变 `y`
4. `NEL``ESC E` / `C1.NEL`)等价 `x=0 + index()`(下移一行并在必要时滚屏)。
5. 右边界写入是否自动折行由 `DECAWM(wraparound)` 控制。
6. 自动折行触发条件按 xterm 口径:字符写入将越过末列时触发换行;换行行需标记 `isWrapped=true`
7. 显式 `LF` 后目标行应清除 `isWrapped`,避免把硬换行误判为软换行。
## 6.6 屏幕缓冲状态(主屏/备用屏,强制)
状态:
1. `buffer_normal`(主屏)
2. `buffer_alternate`(备用屏)
切换规则xterm 对齐):
1. `CSI ? 47 h` / `CSI ? 1047 h`:切换到备用屏。
2. `CSI ? 47 l` / `CSI ? 1047 l`:返回主屏。
3. `CSI ? 1049 h`:保存光标状态并切换到备用屏。
4. `CSI ? 1049 l`:返回主屏并恢复保存光标状态。
5. 对齐 `xterm.js@5.3.0` 现实行为:`1049` 不强制清空备用屏历史(按源码注释口径处理)。
实现约束:
1. `TerminalCore` 必须同时维护 `normalBuffer``alternateBuffer`,仅一个为 `activeBuffer`
2. 两个缓冲区的 `cursor``scroll region``isWrapped` 状态独立维护,禁止互相污染。
3. 切屏仅切换活动缓冲区引用,不得重建对象导致历史丢失。
4. `1049` 路径必须包含 `saveCursor/restoreCursor`,并保证退出全屏后提示符位置正确。
5. 该能力只影响输出缓冲与光标状态,不改变“原生输入主通道”与事件归属策略。
## 7. 组件职责与接口
## 7.1 `TerminalLabPage.vue`
职责:
1. 页面编排、模式切换、连接/断开、状态展示。
2. 组装 `store + renderer + inputBridge`
## 7.2 `TerminalViewport.vue`
职责:
1. 只负责承载渲染容器(不直连 transport
2. 提供挂载点给 `RendererAdapter`
## 7.3 `TerminalInputBar.vue`
职责:
1. 文本输入框、发送按钮、粘贴、快捷键入口。
2. 通过 `InputBridge.send()` 发送,不直接访问 transport。
## 7.4 `TerminalToolbar.vue`
职责:
1. 展示连接状态、延迟、渲染模式。
2. 触发 `connect/disconnect/clear/switchRenderer`
## 7.5 `TerminalTouchTools.vue`
职责:
1. 方向键、Enter、Ctrl+C、Tab、Paste。
2. 输出标准控制序列(如 `ESC[A`)。
## 8. 传输与协议实现要点
## 8.1 `TerminalTransport` 抽象(必须)
接口:
1. `connect(params)`
2. `send(data, meta?)`
3. `resize(cols, rows)`
4. `disconnect(reason?)`
5. `on(listener)`
6. `getState()`
## 8.2 网关帧协议(实验项目内按同协议实现)
入站:
1. `init`
2. `stdin`(含可选 `meta: { source, txnId }`
3. `resize`
4. `control(ping/pong/disconnect)`
出站:
1. `stdout`
2. `stderr`
3. `control(connected/disconnect/pong)`
4. `error`
## 9. 渲染管线设计
## 9.1 输出处理链
1. 收到 `stdout/stderr`
2. 执行 `sanitizeTerminalOutput`(过滤同步更新模式等控制序列噪音)。
3. 追加到 `OutputBuffer`(条目上限 + 字节上限双阈值)。
4. `outputRevision++`
5. 渲染器增量写入;必要时重放。
## 9.2 缓冲策略(必须)
1. `maxEntries` 下限保护(建议 >= 200
2. `maxBytes` 下限保护(建议 >= 64KB
3. 先按条目裁剪,再按字节裁剪。
4. 至少保留最新一条,避免全清空闪烁。
## 9.3 兼容渲染规则(自研)
1. 使用 `write()` 增量写入,不用 `writeln()` 改写换行语义。
2. `convertEol` 默认值必须为 `false`(与 xterm 默认一致)。
3. 显式 `LF` 不等于 `CRLF``CR``LF` 分别处理,禁止业务层自行合并。
4. `fit` 后同步 `resize(cols, rows)`
## 9.4 textarea 渲染规则
1. 采用“只读显示区 + 输入区”或“单 textarea 双模式”方案(二选一,建议前者)。
2. 输出区按文本流追加,必要时使用 `requestAnimationFrame` 批量刷新。
3. 默认自动滚到底;用户手动上滚时暂停自动跟随。
4. 不允许使用“仅按字符串估算”的粗略光标算法作为最终实现。
5. 必须模拟 xterm 的 C0/C1 换行语义:`CR``LF``VT``FF``NEL`
6. 必须显式维护软换行标记(等价 `isWrapped`),区分“自动折行”与“显式换行”。
## 9.5 精准光标内核(强制)
为满足“回显后光标位置必须精准计算”,实验项目必须引入统一终端状态内核(`TerminalCore`),并将其作为两种渲染模式的唯一真相源。
实现要求:
1. `stdout/stderr` 必须先写入 `TerminalCore`,由内核解析控制序列并更新光标、屏幕缓冲、滚动区域状态。
2. `compat` 模式与 `textarea` 模式都必须消费同一份 `TerminalCore` 状态快照。
3. 禁止直接通过 `selectionStart + 文本长度` 推断“终端光标”,该方法仅可用于“输入框本地插入点”,不能代表远端终端光标。
4. `TerminalCore` 至少要正确处理本项目目标程序涉及的序列族:`CR/LF/VT/FF/NEL/BS/TAB``CSI A/B/C/D/H/f/J/K/m``SM/RM(含 Ps=20)``DECSET/DECRST(含 DECAWM)`
5. `TerminalCore` 必须维护以下状态:`cursorX/cursorY``baseY``viewportY``scrollTop/scrollBottom``convertEol``wraparound``line.isWrapped`
6. 每次输出处理后,必须产出 `cursor: { x, y, globalRow }``viewport` 快照,供触摸激活带与滚动定位复用。
## 9.6 颜色渲染规范xterm 对齐,强制)
目标:
1. 颜色与文本属性语义必须对齐 `xterm.js@5.3.0``SGR(CSI ... m)` 行为。
2. `compat` 模式与 `textarea` 模式必须共享同一套属性状态机,避免模式切换后颜色跳变。
实现边界:
1. 必须支持基础 SGR 属性:`0/1/2/3/4/7/8/9/21/22/23/24/25/27/28/29/53/55`
2. 必须支持前景/背景标准色:`30-37``40-47`
3. 必须支持高亮色:`90-97``100-107`
4. 必须支持扩展色:
- 前景 `38`
- 背景 `48`
- 下划线颜色 `58`
5. 扩展色子模式必须支持:
- `;5;INDEX`256 色索引)
- `;2;R;G;B`TrueColor
6. 必须支持默认色复位:
- `39` 复位前景
- `49` 复位背景
- `59` 复位下划线颜色
颜色状态模型(建议):
1. `fg`: `{ mode: default | p16 | p256 | rgb, value }`
2. `bg`: `{ mode: default | p16 | p256 | rgb, value }`
3. `underlineColor`: `{ mode: default | p256 | rgb, value }`
4. `flags`: `bold/dim/italic/underline/inverse/invisible/strikethrough/overline`
5. `underlineStyle`: `none/single/double/curly/dotted/dashed`
重置纪律:
1. `SGR 0` 必须重置前景、背景、下划线样式与颜色到默认(等价 xterm `_processSGR0`)。
2. `22` 仅取消 `bold``dim`,不影响颜色。
3. `24` 仅取消下划线与下划线样式,不清空其它属性。
4. `27` 仅取消 `inverse`
5. `28` 仅取消 `invisible`
6. `29` 仅取消删除线。
渲染映射规则:
1. `inverse` 采用“渲染时交换 fg/bg”策略不直接改写底层存储值。
2. `invisible` 保留背景渲染,前景以透明或背景同色处理(与终端“隐藏文字”语义一致)。
3. `bold` 只作为属性位,不应强行映射为高亮颜色(除非显式启用“粗体映射亮色”策略)。
4. `blink` 在 xterm 5.3 源码中为已记录属性但渲染支持有限,实验项目默认可不做动画实现,但要保留属性位兼容。
主题与调色板约束:
1. 16 色与 256 色索引色应来自统一调色板定义,避免 `xterm``textarea` 调色板不一致。
2. TrueColor (`rgb`) 必须按原值渲染,不经过主题二次量化。
3. 主题切换只影响“默认色与索引色映射”,不应篡改已存在的 TrueColor 单元格。
## 9.7 主题复用与轻量化策略(强制)
目标:
1. 主题实现参考并复用现有 Web/小程序成熟方案,保持视觉一致与维护成本可控。
2. xterm 移植以“核心稳定 + 轻量高效”为优先级,禁止默认堆叠非必要能力。
主题复用规则:
1. 统一采用语义 token`terminalFg/terminalBg/cursor/selection/ansi16`),禁止在组件内硬编码颜色。
2. 默认前景/背景与 `ansi16` 从现有主题配置映射生成,避免与现有产品主题出现明显偏差。
3. 256 色表采用固定映射(标准 xterm256 表),不随业务主题动态重算。
4. 实验项目只维护“主题映射层”,不复制现有 Web/小程序整套业务主题逻辑。
xterm 轻量化规则:
1. 不引入 `@xterm/*` 运行时依赖;实现以 `TerminalCore + 自研渲染器` 为主。
2. 兼容能力按需实现并按模块拆分首屏仅加载连接、输入、回显、resize 必需逻辑。
3. 默认使用原生 DOM/Canvas 路径,禁止引入重型图形加速依赖作为前置条件。
4. 非核心能力(复杂链接检测、额外动画)默认关闭,确保输入回显路径最短。
性能预算(建议):
1. 实验页 JS 增量目标gzip <= 150KB超限需在评审记录中说明来源与必要性
2. 终端首屏可输入目标:页面进入后 1 秒内可完成输入与回显(普通开发机基线)。
3. 持续输出下避免长卡顿:连续主线程阻塞 >100ms 视为性能问题并需定位。
降级开关(必须):
1. `compatLiteMode`关闭可选增强仅保留连接、输入、回显、resize。
2. `rendererFallback=textarea`:低端设备或异常场景可一键降级。
3. 主题映射失败时自动回退默认主题,禁止阻塞会话连接与输入流程。
## 9.8 主屏/备用屏落地实现(强制)
最小实现步骤:
1.`TerminalCore` 新增 `bufferSet = { normal, alternate, active }``savedCursor`
2. 解析 `DECSET/DECRST` 时接入 `47/1047/1049` 分支,不允许只做日志忽略。
3. `switchToAlternate()`:切换 `active=alternate`,同步 `viewport/baseY/cursor`
4. `switchToNormal()`:切换 `active=normal`,恢复主屏可视与滚动位置。
5. `1049h``saveCursor()` 再切换;`1049l` 先切回主屏再 `restoreCursor()`
6. 渲染器读取统一 `activeBuffer` 快照,禁止自行缓存“上一屏”造成穿透渲染。
兼容边界:
1. 若目标程序未使用备用屏序列,行为与当前方案完全一致。
2. 若收到未知私有模式,记录日志并安全忽略,不得破坏当前缓冲状态。
3. 对于不在首期范围的高级 TUI 能力,可延后,但 `47/1047/1049` 不可缺失。
## 10. 页面渲染与布局规则
## 10.1 基础布局
1. 顶部状态栏state、latency、mode
2. 中部终端视口xterm 或 textarea 输出)。
3. 底部:输入栏与触摸工具。
## 10.2 尺寸与 PTY 计算规则(必须统一)
变量:
1. `containerInnerWidthPx`
2. `containerInnerHeightPx`
3. `charWidthPx`
4. `lineHeightPx`
公式:
1. `cols = max(20, floor(containerInnerWidthPx / charWidthPx))`
2. `rows = max(8, floor(containerInnerHeightPx / lineHeightPx))`
约束:
1. 变化阈值去抖:`|cols-lastCols| + |rows-lastRows| >= 1` 才发 `resize`
2. 初次挂载后至少重试 fit/measure 3~10 次(应对路由切换延迟布局)。
## 11. 光标与坐标计算规则(重点)
说明:本节区分两类光标,避免语义混淆。
1. 终端光标:远端 PTY 回显语义对应的光标(用于激活带判定)。
2. 输入光标:本地输入框 `selectionStart/selectionEnd`(用于编辑行为)。
## 11.1 行高计算
优先级:
1. 优先取真实渲染行高DOM 测量)。
2. 无法测量时回退:`lineHeightPx = fontSize * lineHeight`
## 11.2 触点行号计算
1. `localRow = floor((clientY - viewportTop) / rowHeightPx)`
2. compat 模式:`touchRow = viewportY + localRow`
3. textarea 模式:`touchRow = viewportTopRow + localRow`
4.`clientY` 不在视口边界内,判定为无效触点。
## 11.3 compat 光标行号
1. `cursorLocalRow = core.cursorY`
2. `cursorBaseRow = core.baseY`
3. `cursorRow = cursorBaseRow + cursorLocalRow`(全局行号)
4. 激活带判断:`abs(touchRow - cursorRow) <= activationRadius`(建议 2 行)。
## 11.4 textarea 光标位置计算
输入(终端光标,来自 `TerminalCore`
1. `coreCursorRow`
2. `coreCursorCol`
3. `viewportTopRow`
4. `rows`
规则:
1. `cursorRow = clamp(coreCursorRow, 0, +inf)`
2. `cursorVisualRow = cursorRow - viewportTopRow`
3. 仅当 `0 <= cursorVisualRow < rows` 时认为光标在当前可视窗口内。
4. 激活带判断统一使用 `cursorRow`(全局行号),禁止改用输入框 `selectionStart`
5. `coreCursorRow` 语义需与 xterm 对齐:`globalRow = baseY + cursorY`
输入(本地输入光标,仅编辑用途):
1. `selectionStart`(光标字符索引)
2. `text`(当前显示文本)
3. `cols`(当前列数)
算法(必须实现成纯函数):
1.`text[0..selectionStart)` 顺序扫描。
2. 碰到 `\n``row += 1; col = 0`
3. 普通字符:`col += charDisplayWidth(ch)`
4.`col >= cols`:按自动换行规则折行:`row += floor(col / cols); col = col % cols`
`charDisplayWidth(ch)` 规则:
1. ASCII宽度 1。
2. CJK/全角:宽度 2可用简化 east-asian-width 判定)。
3. 控制字符:宽度 0。
## 11.5 坐标到光标索引(可选增强,仅输入框)
若需要“点按定位光标”:
1. 先由 `(x,y)` 推导目标 `(row,col)`
2. 再线性扫描文本映射回最近字符索引。
3. 时间复杂度 O(n),长文本需缓存行起始索引优化。
## 11.6 原生键盘弹出门控(强制)
必须按以下判定顺序执行:
1. `sessionState === connected`
2. 当前手势动作被状态机判定为 `FOCUS_KEYBOARD`
3. `inBand === true`(即 `abs(touchRow - cursorRow) <= activationRadius`)。
4. 不存在活动原生选区(否则走 `PASS_NATIVE`)。
执行动作:
1. `readOnly=false`
2. `blur -> focus` 顺序触发输入锚点激活。
3. 进入短保护窗口,防止 iOS 瞬时 blur 立刻收回键盘。
禁止动作:
1.`inBand` 区域触摸时强制 `focus()`
2. `PASS_NATIVE/PASS_SCROLL` 分支触发键盘弹出。
## 12. 输入处理规则(必须)
## 12.1 统一发送入口
1. 所有输入只走 `InputBridge.sendRaw()`
2. 键盘 `Enter` 必须映射为 `CR (\r)`(与 xterm `Keyboard.ts` 一致)。
3. 普通文本输入keypress/input按字符原样发送不做全局换行重写。
## 12.2 meta 标记
1. 常规按键:`meta.source = "keyboard"`
2. 语音/候选提交:`meta.source = "assist"`,可携带 `txnId` 去重。
## 12.3 粘贴规则
1. 粘贴前执行 xterm 对齐的行结束归一:`/\r?\n/g -> '\r'`
2. 若开启 bracketed paste`ESC[200~ + text + ESC[201~` 包裹发送。
3. 粘贴文本直接写入 stdin不走逐键模拟。
## 12.4 快捷键规则
1. `Ctrl/Cmd + C`:有选中则复制;无选中则发 `\u0003`
2. `Ctrl/Cmd + V`:读剪贴板后发送。
3. 方向键默认发送:`ESC[A`/`ESC[B`/`ESC[C`/`ESC[D`;应用光标模式下发送:`ESCOA`/`ESCOB`/`ESCOC`/`ESCOD`
## 12.5 反劫持策略(强制)
1. 输入主通道固定为原生 `textarea`,禁止把系统软键盘直接绑定到 xterm 隐藏输入节点。
2. 兼容层键盘监听仅用于桌面对照模式;移动端默认禁用兼容层直连 stdin 的按键采集。
3. 触摸事件默认透传,只有 `FOCUS_KEYBOARD` 分支可执行最小化 `preventDefault`
4. 原生输入法事件(`compositionstart/update/end``beforeinput`)必须完整透传到输入状态机,不得被 xterm 监听提前吞掉。
5. 发生冲突时遵循优先级:系统行为 > 原生输入通道 > xterm 视图行为。
## 12.6 区域过滤与事件归属(强制)
区域定义(建议使用 `data-zone` 标记):
1. `terminal-output-zone`:终端可视回显区(仅显示与文本选区)。
2. `native-input-zone`:原生输入锚点区(软键盘与 IME 主入口)。
3. `app-control-zone`:应用工具栏、按钮、菜单区。
4. `app-overlay-zone`:应用弹层、右键菜单、浮层工具区。
归属规则:
1. 键盘事件默认归 `native-input-zone` 与应用层快捷键系统xterm 仅消费“已路由到终端”的标准控制输入。
2. 鼠标点击在 `app-control-zone/app-overlay-zone` 必须 100% 透传应用层,禁止被 xterm 截获。
3. `wheel/touchmove` 默认交给最近可滚动容器;仅终端主视口滚动时才进入终端滚动链路。
4. 文本选择相关事件优先给系统原生选区xterm 不得覆盖浏览器/系统的选区手柄行为。
实现约束:
1. 在捕获阶段先执行 `eventOwnershipRouter`,返回 `APP | NATIVE | XTERM`,再分发事件。
2. 未命中白名单事件时默认 `APP`,禁止“默认给 xterm”。
3. 仅以下事件允许进入 xterm`stdin` 字符输入、终端方向键控制序列、明确授权的复制粘贴桥接。
4. 右键菜单、双击词选、三击行选等交互由应用策略统一控制,不由 xterm 内建策略隐式决定。
## 13. 关键风险与强约束
1. 风险textarea 模式无法完整支持 TUI 全屏程序。
约束:文档中明确为已知限制,不作为首期阻塞项。
2. 风险IME 双发或丢字。
约束:保留 `composition + beforeinput/input + fallback` 三层机制和去重窗口。
3. 风险:移动端误弹键盘。
约束:触摸状态机按优先级表执行,禁止随意新增 `preventDefault` 分支。
4. 风险:模式切换导致历史丢失。
约束:统一 `OutputBuffer`,切换只换渲染器,不换数据源。
5. 风险xterm 事件劫持导致软键盘、选区、滚动异常。
约束:移动端输入以原生 `textarea` 为唯一焦点源xterm 不持有输入焦点。
## 14. 分阶段实施计划(可直接执行)
### 阶段 A骨架与接口
1. 建立目录与模块骨架。
2. 完成 `sessionMachine + transport + rendererAdapter`
3. 页面可连接、可收发、可显示状态。
### 阶段 Bxterm 基线能力
1. 接入 xterm 渲染器。
2. 跑通输入、输出、resize、快捷键、粘贴。
3. 完成基线测试用例。
### 阶段 Ctextarea 迁移能力
1. 实现 textarea 渲染器。
2. 接入光标/坐标计算与输入法链路。
3. 完成 compat 内核 vs textarea 对比测试。
### 阶段 D小程序入口接入
1. 小程序新增入口按钮 + web-view 页面。
2. 地址可配置,未配置时只提示不报错。
3. 不改主流程逻辑。
### 阶段 E删除演练
1. 删除 `labs/xterm-standalone/`
2. 删除小程序入口页面与按钮。
3. 验证主工程构建与现有测试正常。
## 15. 测试与验收清单
功能验收:
1. 连接状态迁移严格符合状态机。
2. stdout/stderr 能稳定显示,无额外换行污染。
3. Enter 语义对齐 xterm按键 Enter 发送 `CR`,不发送裸 `LF`
4. 中文输入(含候选提交)无明显丢字/双发。
5. 模式切换后输出可重放,连接不中断。
6. 光标精准性:同一份输出流在 `compat``textarea` 模式下,终端光标 `(row,col)` 一致。
7. 光标附近弹键盘:仅在光标邻近行轻触触发原生键盘;非邻近行不弹键盘。
8. 长按选区场景下,键盘不误弹,原生选区与手柄保持可用。
9. `convertEol=false` 时,`LF` 仅下移不回列 0`convertEol=true``LF` 同时回列 0。
10. `CR` 只回列 0`NEL` 执行“回列 0 + 下移一行”。
11. 粘贴文本行结束归一为 `CR`,并在 bracketed paste 模式下正确包裹。
12. 自动折行与显式换行可区分:软换行行具备 `isWrapped=true` 语义。
13. 颜色回显一致性:同一输出流在 `compat``textarea` 模式下,`SGR` 属性(粗体/下划线/反显/隐藏/删除线)渲染结果一致。
14. 颜色指令完整性:`30-37/40-47/90-97/100-107/38/48/58``39/49/59/0/22/24/27/28/29` 复位行为符合预期。
15. 扩展色准确性:`38;5;INDEX``48;5;INDEX``38;2;R;G;B``48;2;R;G;B``58;2;R;G;B` 能精准渲染且模式切换后不漂移。
16. 主题切换约束:切换主题后默认色与索引色生效,历史 TrueColor 单元格颜色值保持不变。
17. 主题复用一致性:实验页主题映射与现有 Web/小程序基线一致,同主题下无明显色偏。
18. 轻量化约束:运行时不依赖 `@xterm/*`,未启用增强能力不进入首屏加载链路。
19. 降级策略有效:`compatLiteMode``rendererFallback=textarea` 能生效且核心会话稳定。
20. 原生响应优先:软键盘弹出/收起、长按选区、滚动手势在移动端与普通 `textarea` 行为一致,无明显被劫持现象。
21. 焦点纪律正确:移动端会话中 `textarea` 始终为输入焦点主体xterm 不直接持有键盘焦点。
22. 区域归属正确:`app-control-zone/app-overlay-zone` 的键鼠事件不被 xterm 捕获,应用可独立演进交互逻辑。
23. 路由兜底正确:未声明事件默认归应用层处理,不出现“事件默认落到 xterm”导致的不可控行为。
24. 备用屏切换正确:`?1049h` 进入全屏后不污染主屏历史,`?1049l` 退出后恢复原提示符与光标位置。
25. `47/1047` 兼容正确:切换主/备用屏时两屏内容与滚动状态互不污染。
性能验收:
1. 高频输出不明显卡顿(基线:普通开发机可持续滚动)。
2. 缓冲裁剪后不出现白屏或大面积闪烁。
回归验收:
1. 仅新增小程序入口改动;主页面行为一致。
2. 删除实验目录后,主项目 `typecheck/lint/test/build` 通过。
## 16. xterm 源码对照
本方案关键规则对应 `xterm.js@5.3.0` 源码:
1. `Enter -> CR``src/common/input/Keyboard.ts`
2. 粘贴换行归一(`/\r?\n/g -> \r``src/browser/Clipboard.ts`
3. `LF/VT/FF -> lineFeed``CR -> carriageReturn``NEL -> nextLine``src/common/InputHandler.ts`
4. `convertEol` 默认 `false`,且受 `CSI 20 h/l` 控制:`src/common/services/OptionsService.ts` + `InputHandler.ts`
5. 自动折行DECAWM`isWrapped``src/common/InputHandler.ts#print`
6. 光标全局行号语义(`cursorY` 相对 `baseY``typings/xterm.d.ts``IBuffer` 定义。
7. `SGR` 属性解析与颜色扩展(含 `38/48/58``39/49/59``0/22/24/27/28/29``src/common/InputHandler.ts#charAttributes`
8. 主屏/备用屏切换与 `1049` 光标保存恢复:`src/common/InputHandler.ts#setModePrivate` + `resetModePrivate`
9. 缓冲区实现与切屏基础结构:`src/common/buffer/BufferSet.ts`
## 17. 完成定义DoD
1. 本文档中的状态机、组件职责、算法规则均已对应到代码模块。
2. 实验项目可独立开发与验证,且不污染主业务逻辑。
3. 小程序只增加测试入口,无业务耦合。
4. 已完成删除演练并记录结果。
5. 换行与光标行为已与 `xterm.js@5.3.0` 源码逐条对齐,并有对应验收用例。
6. 开发者仅依赖本方案即可按阶段落地实现。
7. 主屏/备用屏与 `1049` 光标恢复能力已落地并通过验收用例。

55
xterminal/.github/workflows/deploy.yml vendored Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1,3 @@
echo "Running lint-staged..."
pnpm exec lint-staged

View File

@@ -0,0 +1,6 @@
{
"tabWidth": 4,
"semi": true,
"singleQuote": false,
"trailingComma": "none"
}

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

BIN
xterminal/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 B

View 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

View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.1 KiB

View File

@@ -0,0 +1,6 @@
# Media
![](./black.svg)
![](./white.svg)

View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.1 KiB

View File

@@ -0,0 +1,3 @@
{
"presets": ["@babel/preset-env"]
}

31
xterminal/demo/index.html Normal file
View 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
View 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
View 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;
}
}

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

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

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

View File

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

View 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 &RightArrow;</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>

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

View File

@@ -0,0 +1,5 @@
# History
The story behind the development of this project.
Coming soon...

View 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
View 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>");
// => &lt;b&gt;hello&lt;/b&gt;
```
- **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>');
// &lt;h1&gt;hello&lt;/h1&gt;
```
- **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>');
// &lt;h1&gt;hello&lt;/h1&gt;<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)

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

Some files were not shown because too many files have changed in this diff Show More