commit 3b7c1d558aef31b4862b17ed0307e7b54bc56ae9 Author: douboer@gmail.com Date: Tue Mar 3 13:23:14 2026 +0800 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dfaf4ac --- /dev/null +++ b/.gitignore @@ -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 diff --git a/runit.sh b/runit.sh new file mode 120000 index 0000000..fa5b862 --- /dev/null +++ b/runit.sh @@ -0,0 +1 @@ +/Users/gavin/tools/runit.sh \ No newline at end of file diff --git a/terminal.config.json b/terminal.config.json new file mode 100644 index 0000000..6150a95 --- /dev/null +++ b/terminal.config.json @@ -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 + } + ] +} diff --git a/terminal/.npmrc b/terminal/.npmrc new file mode 100644 index 0000000..1553fcb --- /dev/null +++ b/terminal/.npmrc @@ -0,0 +1 @@ +cache=.npm-cache diff --git a/terminal/README.md b/terminal/README.md new file mode 100644 index 0000000..74fa74d --- /dev/null +++ b/terminal/README.md @@ -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 切换)。 diff --git a/terminal/apps/miniprogram/components/terminal-core-view/index.js b/terminal/apps/miniprogram/components/terminal-core-view/index.js new file mode 100644 index 0000000..44611d4 --- /dev/null +++ b/terminal/apps/miniprogram/components/terminal-core-view/index.js @@ -0,0 +1,369 @@ +/** + * terminal-core-view/index.js + * + * 微信小程序终端渲染组件。 + * 消费 @remoteconn/terminal-core 构建产物(dist-miniprogram/index.js), + * 通过 setData({rows, cursor}) 驱动 rich-text 渲染。 + * + * 外部属性(properties): + * - gatewayUrl {string} 网关 WebSocket 地址 + * - gatewayToken {string} 网关鉴权 Token + * + * 外部方法(通过组件引用调用): + * - connect(params) 开始连接,params: { host, port, username, password? } + * - disconnect(reason?) 断开连接 + * - sendInput(data) 发送输入 + */ + +const path = '../../../../packages/terminal-core/dist-miniprogram/index'; +let TerminalCore, InputBridge, OutputBuffer, SessionMachine, sanitizeTerminalOutput, calcSize; +try { + const core = require(path); + TerminalCore = core.TerminalCore; + InputBridge = core.InputBridge; + OutputBuffer = core.OutputBuffer; + SessionMachine = core.SessionMachine; + sanitizeTerminalOutput = core.sanitizeTerminalOutput; + calcSize = core.calcSize; +} catch(e) { + console.error('[terminal-core-view] 无法加载 terminal-core,请先执行 build:miniprogram', e); +} + +const { WxTransport } = require('../../utils/wxTransport'); +const { createWxInputBridge } = require('../../utils/wxInputBridge'); +const { createWxMeasureAdapter } = require('../../utils/wxMeasureAdapter'); + +// ── 颜色渲染 ────────────────────────────────────────────────────────────────── +const ANSI16 = [ + '#000000','#cc0000','#00aa00','#aaaa00', + '#0000ee','#cc00cc','#00aaaa','#aaaaaa', + '#555555','#ff5555','#55ff55','#ffff55', + '#5555ff','#ff55ff','#55ffff','#ffffff', +]; + +function p256ToCss(idx) { + if (idx < 16) return ANSI16[idx] || '#000'; + if (idx >= 232) { + const v = 8 + (idx - 232) * 10; + return 'rgb(' + v + ',' + v + ',' + v + ')'; + } + const i = idx - 16; + const b = i % 6, g = Math.floor(i / 6) % 6, r = Math.floor(i / 36); + const ch = function(x) { return x === 0 ? 0 : 55 + x * 40; }; + return 'rgb(' + ch(r) + ',' + ch(g) + ',' + ch(b) + ')'; +} + +function colorToCss(c, isFg) { + if (!c) return isFg ? 'inherit' : 'transparent'; + switch (c.mode) { + case 'default': return isFg ? 'inherit' : 'transparent'; + case 'p16': return ANSI16[c.value] || 'inherit'; + case 'p256': return p256ToCss(c.value); + case 'rgb': return '#' + ((c.value & 0xffffff) | 0x1000000).toString(16).slice(1); + default: return 'inherit'; + } +} + +const FLAG_BOLD = 1; +const FLAG_DIM = 2; +const FLAG_ITALIC = 4; +const FLAG_UNDERLINE = 8; +const FLAG_INVERSE = 16; +const FLAG_INVISIBLE = 32; +const FLAG_STRIKETHROUGH = 64; +const FLAG_OVERLINE = 128; + +/** 将一行 cells 转换为 rich-text 节点数组 */ +function lineToNodes(cells) { + const nodes = []; + let i = 0; + while (i < cells.length) { + const cell = cells[i]; + if (!cell) { i++; continue; } + + const flags = cell.flags || 0; + const inverse = !!(flags & FLAG_INVERSE); + + let fg = cell.fg, bg = cell.bg; + if (inverse) { const tmp = fg; fg = bg; bg = tmp; } + + let style = ''; + const fgCss = colorToCss(fg, true); + const bgCss = colorToCss(bg, false); + if (fgCss !== 'inherit') style += 'color:' + fgCss + ';'; + if (bgCss !== 'transparent') style += 'background:' + bgCss + ';'; + if (flags & FLAG_BOLD) style += 'font-weight:bold;'; + if (flags & FLAG_DIM) style += 'opacity:0.6;'; + if (flags & FLAG_ITALIC) style += 'font-style:italic;'; + if (flags & FLAG_UNDERLINE) style += 'text-decoration:underline;'; + if (flags & FLAG_STRIKETHROUGH) style += 'text-decoration:line-through;'; + if (flags & FLAG_INVISIBLE) style += 'color:transparent;'; + + // 合并相邻相同 style 的 cells + let text = cell.char || ' '; + i++; + while (i < cells.length) { + const next = cells[i]; + if (!next) break; + const nFlags = next.flags || 0; + // 简单比较:只有标志和颜色模式全相同才合并 + if (nFlags !== flags || + JSON.stringify(next.fg) !== JSON.stringify(fg) || + JSON.stringify(next.bg) !== JSON.stringify(bg)) break; + text += next.char || ' '; + i++; + } + + nodes.push({ + type: 'node', + name: 'span', + attrs: style ? { style: style } : {}, + children: [{ type: 'text', text: text }], + }); + } + return nodes; +} + +// ── 组件定义 ────────────────────────────────────────────────────────────────── +Component({ + properties: { + gatewayUrl: { type: String, value: '' }, + gatewayToken: { type: String, value: '' }, + }, + + data: { + rows: [], // Array<{ html: Array }> + cursor: { row: -1, left: 0, visible: false }, + scrollTop: 9999999, // 滚到底 + state: 'idle', + latencyMs: 0, + title: '', + inputValue: '', + }, + + lifetimes: { + attached: function() { + this._init(); + }, + detached: function() { + this._dispose(); + }, + }, + + methods: { + // ── 初始化 ─────────────────────────────────────────────────────────────── + _init: function() { + const self = this; + + // Core + self._core = TerminalCore ? new TerminalCore(80, 24) : null; + self._buffer = OutputBuffer ? new OutputBuffer({ maxEntries: 500, maxBytes: 256 * 1024 }) : null; + self._machine = SessionMachine ? new SessionMachine() : null; + self._inputBridge = InputBridge ? new InputBridge() : null; + self._wxBridge = createWxInputBridge(); + self._transport = null; + self._offTransport = null; + self._lastRevision = -1; + self._charW = 7.8; // 默认字符宽度 px (monospace 13px 近似) + self._lineH = 16.25; // 行高 px (13px * 1.25) + + // 订阅 core 事件 + if (self._core) { + self._core.on('bell', function() { wx.vibrateShort({ type: 'light' }); }); + self._core.on('titleChange', function(t) { + self.setData({ title: t }); + }); + } + + // 测量适配器 + self._measure = createWxMeasureAdapter(self, '#tc-viewport', self._charW, self._lineH); + self._measure.onResize(function() { + self._doFit(); + }); + + // 输入桥接事件订阅 + self._wxBridge.on('key', function(p) { + if (!self._inputBridge) return; + const seq = self._inputBridge.mapKey(p.key, p.code, p.ctrlKey, p.altKey, p.shiftKey, p.metaKey); + if (seq !== null) self.sendInput(seq); + }); + self._wxBridge.on('input', function(p) { + if (p.isComposing) return; + if (p.data) self.sendInput(p.data); + self.setData({ inputValue: '' }); + }); + self._wxBridge.on('paste', function(p) { + if (!self._inputBridge) return; + const seq = self._inputBridge.mapPaste(p.text); + self.sendInput(seq); + }); + self._wxBridge.on('compositionend', function(p) { + if (p.data) self.sendInput(p.data); + self.setData({ inputValue: '' }); + }); + + // 启动渲染循环(50ms ≈ 20fps,setData 有限流) + self._renderTimer = setInterval(function() { self._renderFrame(); }, 50); + + // 启动测量轮询 + self._measure.startPoll(600); + }, + + _dispose: function() { + if (this._renderTimer) { clearInterval(this._renderTimer); this._renderTimer = null; } + this._measure && this._measure.stopPoll(); + this._offTransport && this._offTransport(); + this._transport && this._transport._cleanup && this._transport._cleanup(); + }, + + // ── 尺寸适配 ───────────────────────────────────────────────────────────── + _doFit: function() { + if (!calcSize || !this._core) return; + const { widthPx: contW, heightPx: contH } = this._measure.measureContainer(); + if (contW < 10 || contH < 10) return; + const { widthPx: charW, heightPx: lineH } = this._measure.measureChar(); + const { cols, rows } = calcSize(contW, contH, charW, lineH); + this._core.resize(cols, rows); + this._charW = charW; + if (this._transport && this.data.state === 'connected') { + this._transport.resize(cols, rows); + } + }, + + // ── 渲染帧 ─────────────────────────────────────────────────────────────── + _renderFrame: function() { + if (!this._core) return; + const snap = this._core.snapshot(); + if (snap.revision === this._lastRevision) return; + this._lastRevision = snap.revision; + + const rows = snap.lines.map(function(line) { + return { html: lineToNodes(line.cells) }; + }); + + // 光标位置(像素) + const cursorLeft = snap.cursor.x * this._charW; + const cursorRow = snap.cursor.y; // 相对可视区行号 + + this.setData({ + rows: rows, + cursor: { row: cursorRow, left: cursorLeft, visible: snap.cursor.visible }, + title: snap.title || this.data.title, + }); + }, + + // ── 公开 API ────────────────────────────────────────────────────────────── + connect: function(params) { + const self = this; + if (!WxTransport) { console.error('[tc-view] WxTransport 未加载'); return; } + + self._transport = new WxTransport({ + gatewayUrl: self.properties.gatewayUrl, + gatewayToken: self.properties.gatewayToken, + }); + + self._offTransport = self._transport.on(function(event) { + if (event.type === 'stdout' || event.type === 'stderr') { + const sanitized = sanitizeTerminalOutput ? sanitizeTerminalOutput(event.data) : event.data; + if (sanitized) { + self._buffer && self._buffer.push(sanitized); + self._core && self._core.write(sanitized); + } + } else if (event.type === 'control') { + if (event.action === 'connected') { + self._machine && self._machine.tryTransition('connected'); + self.setData({ state: 'connected', latencyMs: 0 }); + self._doFit(); + } else if (event.action === 'pong') { + // latency 由 WxTransport 内部 _pingAt 计算 + } else if (event.action === 'disconnect') { + self._machine && self._machine.tryTransition('disconnected'); + self.setData({ state: 'disconnected' }); + } + } else if (event.type === 'error') { + self._machine && self._machine.tryTransition('error'); + self.setData({ state: 'error' }); + } + }); + + self._machine && self._machine.tryTransition('connecting'); + self.setData({ state: 'connecting' }); + + self._transport.connect(params).catch(function(err) { + console.error('[tc-view] connect error', err); + self.setData({ state: 'error' }); + }); + }, + + disconnect: function(reason) { + if (this._transport) { + this._transport.disconnect(reason || 'manual'); + } + this._machine && this._machine.tryTransition('disconnected'); + this.setData({ state: 'disconnected' }); + }, + + sendInput: function(data) { + if (this.data.state !== 'connected' || !this._transport) return; + this._transport.send(data); + }, + + // ── WXML 事件处理 ───────────────────────────────────────────────────────── + onInput: function(e) { + const value = e.detail.value || ''; + const prev = this._prevInputValue || ''; + // 计算增量字符 + if (value.length > prev.length) { + const delta = value.slice(prev.length); + this._wxBridge.triggerInput(delta, e.detail.isComposing); + } + this._prevInputValue = value; + }, + + onConfirm: function() { + this._prevInputValue = ''; + this._wxBridge.triggerConfirm(); + }, + + onSend: function() { + const val = this.data.inputValue; + if (val) { + this._wxBridge.triggerInput(val, false); + this._wxBridge.triggerConfirm(); + this.setData({ inputValue: '' }); + this._prevInputValue = ''; + } + }, + + onInputBlur: function() { /* no-op */ }, + onKeyboardChange: function() { /* no-op */ }, + + // ── 滚动事件 ────────────────────────────────────────────────────────────── + onScrollToLower: function() { + this._autoScroll = true; + }, + onScrollToUpper: function() { + this._autoScroll = false; + }, + onScroll: function() { /* handled by upper/lower events */ }, + + // ── 触控工具栏 ──────────────────────────────────────────────────────────── + onTapUp: function() { this._wxBridge.triggerKey('ArrowUp', 'ArrowUp'); }, + onTapDown: function() { this._wxBridge.triggerKey('ArrowDown', 'ArrowDown'); }, + onTapLeft: function() { this._wxBridge.triggerKey('ArrowLeft', 'ArrowLeft'); }, + onTapRight: function() { this._wxBridge.triggerKey('ArrowRight', 'ArrowRight'); }, + onTapTab: function() { this._wxBridge.triggerKey('Tab', 'Tab'); }, + onTapEnter: function() { this._wxBridge.triggerConfirm(); }, + onTapCtrlC: function() { this.sendInput('\u0003'); }, + onTapCtrlD: function() { this.sendInput('\u0004'); }, + onTapEsc: function() { this.sendInput('\x1b'); }, + onTapPaste: function() { + const self = this; + wx.getClipboardData({ + success: function(res) { + if (res.data) self._wxBridge.triggerPaste(res.data); + }, + }); + }, + }, +}); diff --git a/terminal/apps/miniprogram/components/terminal-core-view/index.json b/terminal/apps/miniprogram/components/terminal-core-view/index.json new file mode 100644 index 0000000..a89ef4d --- /dev/null +++ b/terminal/apps/miniprogram/components/terminal-core-view/index.json @@ -0,0 +1,4 @@ +{ + "component": true, + "usingComponents": {} +} diff --git a/terminal/apps/miniprogram/components/terminal-core-view/index.wxml b/terminal/apps/miniprogram/components/terminal-core-view/index.wxml new file mode 100644 index 0000000..ea6bfa1 --- /dev/null +++ b/terminal/apps/miniprogram/components/terminal-core-view/index.wxml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{state}} + {{latencyMs}}ms + {{title}} + + diff --git a/terminal/apps/miniprogram/components/terminal-core-view/index.wxss b/terminal/apps/miniprogram/components/terminal-core-view/index.wxss new file mode 100644 index 0000000..784aaca --- /dev/null +++ b/terminal/apps/miniprogram/components/terminal-core-view/index.wxss @@ -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; +} diff --git a/terminal/apps/miniprogram/utils/wxInputBridge.js b/terminal/apps/miniprogram/utils/wxInputBridge.js new file mode 100644 index 0000000..09c451b --- /dev/null +++ b/terminal/apps/miniprogram/utils/wxInputBridge.js @@ -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 }; diff --git a/terminal/apps/miniprogram/utils/wxMeasureAdapter.js b/terminal/apps/miniprogram/utils/wxMeasureAdapter.js new file mode 100644 index 0000000..7da0c71 --- /dev/null +++ b/terminal/apps/miniprogram/utils/wxMeasureAdapter.js @@ -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 }; diff --git a/terminal/apps/miniprogram/utils/wxTransport.js b/terminal/apps/miniprogram/utils/wxTransport.js new file mode 100644 index 0000000..6bfecb1 --- /dev/null +++ b/terminal/apps/miniprogram/utils/wxTransport.js @@ -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 }; diff --git a/terminal/apps/web/index.html b/terminal/apps/web/index.html new file mode 100644 index 0000000..3e3b967 --- /dev/null +++ b/terminal/apps/web/index.html @@ -0,0 +1,12 @@ + + + + + + Terminal Lab + + +
+ + + diff --git a/terminal/apps/web/package.json b/terminal/apps/web/package.json new file mode 100644 index 0000000..551c708 --- /dev/null +++ b/terminal/apps/web/package.json @@ -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" + } +} diff --git a/terminal/apps/web/public/terminal.config.json b/terminal/apps/web/public/terminal.config.json new file mode 100644 index 0000000..6150a95 --- /dev/null +++ b/terminal/apps/web/public/terminal.config.json @@ -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 + } + ] +} diff --git a/terminal/apps/web/src/App.vue b/terminal/apps/web/src/App.vue new file mode 100644 index 0000000..aa0f4b7 --- /dev/null +++ b/terminal/apps/web/src/App.vue @@ -0,0 +1,72 @@ + + + + + diff --git a/terminal/apps/web/src/env.d.ts b/terminal/apps/web/src/env.d.ts new file mode 100644 index 0000000..920f14e --- /dev/null +++ b/terminal/apps/web/src/env.d.ts @@ -0,0 +1,7 @@ +/// + +declare module "*.vue" { + import type { DefineComponent } from "vue"; + const component: DefineComponent, Record, unknown>; + export default component; +} diff --git a/terminal/apps/web/src/main.ts b/terminal/apps/web/src/main.ts new file mode 100644 index 0000000..98ba4af --- /dev/null +++ b/terminal/apps/web/src/main.ts @@ -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"); diff --git a/terminal/apps/web/src/services/transport/factory.ts b/terminal/apps/web/src/services/transport/factory.ts new file mode 100644 index 0000000..59086d2 --- /dev/null +++ b/terminal/apps/web/src/services/transport/factory.ts @@ -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); +} + diff --git a/terminal/apps/web/src/services/transport/gatewayTransport.ts b/terminal/apps/web/src/services/transport/gatewayTransport.ts new file mode 100644 index 0000000..fa74de9 --- /dev/null +++ b/terminal/apps/web/src/services/transport/gatewayTransport.ts @@ -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; + 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 { + 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((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 { + 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 { + this.log("pty:resize", { cols, rows }); + this.sendRaw({ type: "resize", payload: { cols, rows } }); + } + + public async disconnect(reason = "manual"): Promise { + 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): 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 + } + } +} diff --git a/terminal/apps/web/src/services/transport/iosNativeTransport.ts b/terminal/apps/web/src/services/transport/iosNativeTransport.ts new file mode 100644 index 0000000..4a6aa51 --- /dev/null +++ b/terminal/apps/web/src/services/transport/iosNativeTransport.ts @@ -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; + send(options: { data: string }): Promise; + resize(options: { cols: number; rows: number }): Promise; + disconnect(options: { reason?: string }): Promise; + 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 { + 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 { + await window.Capacitor?.Plugins?.RemoteConnSSH?.send({ data }); + } + + public async resize(cols: number, rows: number): Promise { + await window.Capacitor?.Plugins?.RemoteConnSSH?.resize({ cols, rows }); + } + + public async disconnect(reason?: string): Promise { + 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); + } + } +} + diff --git a/terminal/apps/web/src/services/transport/terminalTransport.ts b/terminal/apps/web/src/services/transport/terminalTransport.ts new file mode 100644 index 0000000..ce1456b --- /dev/null +++ b/terminal/apps/web/src/services/transport/terminalTransport.ts @@ -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; + send(data: string, meta?: StdinMeta): Promise; + resize(cols: number, rows: number): Promise; + disconnect(reason?: string): Promise; + on(listener: (event: TransportEvent) => void): () => void; + getState(): SessionState; +} + diff --git a/terminal/apps/web/src/stores/appStore.ts b/terminal/apps/web/src/stores/appStore.ts new file mode 100644 index 0000000..9d6117d --- /dev/null +++ b/terminal/apps/web/src/stores/appStore.ts @@ -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([]); + + 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 + }; +}); + diff --git a/terminal/apps/web/src/stores/serverStore.ts b/terminal/apps/web/src/stores/serverStore.ts new file mode 100644 index 0000000..6a001c6 --- /dev/null +++ b/terminal/apps/web/src/stores/serverStore.ts @@ -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 => !!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([]); + const selectedServerId = ref(""); + const loaded = ref(false); + let bootstrapPromise: Promise | null = null; + + const selectedServer = computed(() => servers.value.find((item) => item.id === selectedServerId.value)); + + async function ensureBootstrapped(): Promise { + 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 { + await ensureBootstrapped(); + } + + function setSelectedServer(serverId: string): void { + selectedServerId.value = serverId; + persistSelectedServerId(serverId); + } + + async function saveServer(server: ServerProfile): Promise { + 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 { + const sample = buildDefaultServer(); + servers.value.unshift(sample); + persistServers(servers.value); + setSelectedServer(sample.id); + return sample; + } + + async function deleteServer(serverId: string): Promise { + 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 { + 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 + }; +}); diff --git a/terminal/apps/web/src/stores/settingsStore.ts b/terminal/apps/web/src/stores/settingsStore.ts new file mode 100644 index 0000000..e762d1f --- /dev/null +++ b/terminal/apps/web/src/stores/settingsStore.ts @@ -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 }).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 }).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; + 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({ + gatewayUrl: resolveDefaultGatewayUrl(), + gatewayToken: resolveDefaultGatewayToken() + }); + const loaded = ref(false); + let bootstrapPromise: Promise | null = null; + + async function ensureBootstrapped(): Promise { + 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 { + await ensureBootstrapped(); + } + + async function save(next: Partial): Promise { + 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 + }; +}); diff --git a/terminal/apps/web/src/style.css b/terminal/apps/web/src/style.css new file mode 100644 index 0000000..3751c61 --- /dev/null +++ b/terminal/apps/web/src/style.css @@ -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; +} diff --git a/terminal/apps/web/src/terminal/TerminalPage.vue b/terminal/apps/web/src/terminal/TerminalPage.vue new file mode 100644 index 0000000..4cc1553 --- /dev/null +++ b/terminal/apps/web/src/terminal/TerminalPage.vue @@ -0,0 +1,484 @@ + + + + + diff --git a/terminal/apps/web/src/terminal/components/TerminalInputBar.vue b/terminal/apps/web/src/terminal/components/TerminalInputBar.vue new file mode 100644 index 0000000..8cdab5c --- /dev/null +++ b/terminal/apps/web/src/terminal/components/TerminalInputBar.vue @@ -0,0 +1,229 @@ + + + + + diff --git a/terminal/apps/web/src/terminal/components/TerminalToolbar.vue b/terminal/apps/web/src/terminal/components/TerminalToolbar.vue new file mode 100644 index 0000000..a5a8437 --- /dev/null +++ b/terminal/apps/web/src/terminal/components/TerminalToolbar.vue @@ -0,0 +1,148 @@ + + + + + diff --git a/terminal/apps/web/src/terminal/components/TerminalTouchTools.vue b/terminal/apps/web/src/terminal/components/TerminalTouchTools.vue new file mode 100644 index 0000000..8367f4b --- /dev/null +++ b/terminal/apps/web/src/terminal/components/TerminalTouchTools.vue @@ -0,0 +1,191 @@ + + + + + diff --git a/terminal/apps/web/src/terminal/components/TerminalViewport.vue b/terminal/apps/web/src/terminal/components/TerminalViewport.vue new file mode 100644 index 0000000..aecb9ac --- /dev/null +++ b/terminal/apps/web/src/terminal/components/TerminalViewport.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/terminal/apps/web/src/terminal/input/domImeController.ts b/terminal/apps/web/src/terminal/input/domImeController.ts new file mode 100644 index 0000000..be71fcf --- /dev/null +++ b/terminal/apps/web/src/terminal/input/domImeController.ts @@ -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(); + } +} diff --git a/terminal/apps/web/src/terminal/input/domInputBridge.ts b/terminal/apps/web/src/terminal/input/domInputBridge.ts new file mode 100644 index 0000000..0e2bd06 --- /dev/null +++ b/terminal/apps/web/src/terminal/input/domInputBridge.ts @@ -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 = ( + 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(event: K, cb: (payload: InputEventMap[K]) => void): () => void { + if (!this.listeners[event]) { + (this.listeners as Record>)[event] = new Set(); + } + const set = this.listeners[event] as Set<(p: InputEventMap[K]) => void>; + set.add(cb); + return () => set.delete(cb); + } + + private emit(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); + } +} diff --git a/terminal/apps/web/src/terminal/input/inputPolicy.test.ts b/terminal/apps/web/src/terminal/input/inputPolicy.test.ts new file mode 100644 index 0000000..19c0797 --- /dev/null +++ b/terminal/apps/web/src/terminal/input/inputPolicy.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { shouldHandleKeydownDirectly } from "./inputPolicy"; + +function makePayload(overrides: Partial<{ key: string; ctrlKey: boolean; altKey: boolean; metaKey: boolean }> = {}) { + return { + key: "", + ctrlKey: false, + altKey: false, + metaKey: false, + ...overrides, + }; +} + +describe("shouldHandleKeydownDirectly", () => { + it("文本按键应返回 false(普通英文)", () => { + expect(shouldHandleKeydownDirectly(makePayload({ key: "a" }))).toBe(false); + }); + + it("第三方输入法在 keydown 给出整段文本时应返回 false(英文串)", () => { + expect(shouldHandleKeydownDirectly(makePayload({ key: "jk" }))).toBe(false); + }); + + it("第三方输入法在 keydown 给出整段文本时应返回 false(中文)", () => { + expect(shouldHandleKeydownDirectly(makePayload({ key: "测试" }))).toBe(false); + }); + + it("功能键应返回 true(Enter)", () => { + expect(shouldHandleKeydownDirectly(makePayload({ key: "Enter" }))).toBe(true); + }); + + it("方向键应返回 true(ArrowUp)", () => { + expect(shouldHandleKeydownDirectly(makePayload({ key: "ArrowUp" }))).toBe(true); + }); + + it("组合键应返回 true(Ctrl+C)", () => { + expect(shouldHandleKeydownDirectly(makePayload({ key: "c", ctrlKey: true }))).toBe(true); + }); +}); + diff --git a/terminal/apps/web/src/terminal/input/inputPolicy.ts b/terminal/apps/web/src/terminal/input/inputPolicy.ts new file mode 100644 index 0000000..6e0de7e --- /dev/null +++ b/terminal/apps/web/src/terminal/input/inputPolicy.ts @@ -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): boolean { + if (payload.ctrlKey || payload.altKey || payload.metaKey) { + return true; + } + return KEYDOWN_DIRECT_KEYS.has(payload.key); +} + diff --git a/terminal/apps/web/src/terminal/input/keyboardAdjustController.ts b/terminal/apps/web/src/terminal/input/keyboardAdjustController.ts new file mode 100644 index 0000000..f6162e6 --- /dev/null +++ b/terminal/apps/web/src/terminal/input/keyboardAdjustController.ts @@ -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 | null = null; + + // visualViewport resize 处理器引用,用于清理 + private readonly vpResizeHandler: () => void; + // 用户手动滚动检测 + private readonly scrollHandler: () => void; + private scrollListenerAttached = false; + /** + * 时间戳:直到此时刻前,scrollHandler 不将 snapshot.userScrolled 设为 true。 + * iOS WebKit scroll 事件是异步的,必须用时间窗口而非布尔标志。 + */ + private programmaticScrollUntil = 0; + + constructor(options: KeyboardAdjustOptions) { + this.opts = { debug: false, ...options }; + this.vpResizeHandler = () => this.onViewportResize(); + this.scrollHandler = () => this.onUserScroll(); + } + + private log(msg: string, detail?: unknown): void { + // 关键路径使用无条件 console.log,便于线上问题诊断 + const prefix = `[KeyboardAdjust][${new Date().toISOString()}] ${msg}`; + detail !== undefined ? console.log(prefix, detail) : console.log(prefix); + } + + /** 初始化:记录基线高度,开始监听 visualViewport */ + mount(): void { + if (typeof window === "undefined") return; + const vv = window.visualViewport; + if (!vv) { + this.log("mount — visualViewport NOT supported, fallback to window.resize"); + window.addEventListener("resize", this.vpResizeHandler); + } else { + vv.addEventListener("resize", this.vpResizeHandler); + } + this.baselineHeight = vv ? vv.height : window.innerHeight; + this.log("mount — OK", { + baseline: this.baselineHeight, + hasVisualViewport: !!vv, + innerHeight: window.innerHeight, + }); + } + + /** 销毁:清理监听器和定时器 */ + dispose(): void { + if (typeof window === "undefined") return; + const vv = window.visualViewport; + if (!vv) { + window.removeEventListener("resize", this.vpResizeHandler); + } else { + vv.removeEventListener("resize", this.vpResizeHandler); + } + this.detachScrollListener(); + if (this.settleTimer !== null) { + clearTimeout(this.settleTimer); + this.settleTimer = null; + } + // 确保 autoFollow 已恢复 + this.opts.setAutoFollow(true); + this.snapshot = null; + this.keyboardVisible = false; + this.log("disposed"); + } + + /** 强制重置(页面卸载时调用) */ + forceReset(): void { + if (this.snapshot) { + this.restoreScroll("unmount"); + } + this.dispose(); + } + + // ── 核心逻辑 ───────────────────────────────────────────────────────────── + + private onViewportResize(): void { + const vv = window.visualViewport; + const currentH = vv ? vv.height : window.innerHeight; + if (currentH <= 0) return; + + // 基线更新:仅在没有键盘时,且高度比当前基线更大才更新 + if (!this.keyboardVisible && currentH > this.baselineHeight) { + this.log("baseline:updated", { old: this.baselineHeight, new: currentH }); + this.baselineHeight = currentH; + } + + const shrink = this.baselineHeight - currentH; + + this.log("viewport:resize", { + currentH, + baseline: this.baselineHeight, + shrink, + keyboardVisible: this.keyboardVisible, + OPEN_THRESHOLD: KEYBOARD_OPEN_THRESHOLD_PX, + CLOSE_HYSTERESIS: KEYBOARD_CLOSE_HYSTERESIS_PX, + }); + + if (!this.keyboardVisible && shrink > KEYBOARD_OPEN_THRESHOLD_PX) { + // 键盘弹出 + this.keyboardVisible = true; + this.onKeyboardOpen(currentH); + return; + } + + if (this.keyboardVisible && currentH >= this.baselineHeight - KEYBOARD_CLOSE_HYSTERESIS_PX) { + // 键盘收回 + this.keyboardVisible = false; + this.onKeyboardClose(); + } + } + + private onKeyboardOpen(currentViewportH: number): void { + const outputEl = this.opts.getOutputEl(); + if (!outputEl) { + this.log("keyboard:OPEN — no outputEl, skipped"); + return; + } + + // 保存弹出前的状态 + this.snapshot = { + scrollTop: outputEl.scrollTop, + clientHeight: outputEl.clientHeight, + scrollHeight: outputEl.scrollHeight, + viewportHeight: currentViewportH, + userScrolled: false, + }; + + this.log("keyboard:OPEN — snapshot saved", { + snapshot: this.snapshot, + baseline: this.baselineHeight, + currentViewportH, + }); + + // 关闭 autoFollow,防止新输出把光标滚走 + this.opts.setAutoFollow(false); + + // 注册用户滚动检测 + this.attachScrollListener(outputEl); + + // 等键盘动画完成后执行定位 + if (this.settleTimer !== null) clearTimeout(this.settleTimer); + this.log(`keyboard:OPEN — will position in ${SCROLL_SETTLE_DELAY_MS}ms`); + this.settleTimer = setTimeout(() => { + this.settleTimer = null; + this.positionCursorAtQuarter(outputEl); + }, SCROLL_SETTLE_DELAY_MS); + } + + private onKeyboardClose(): void { + this.log("keyboard:CLOSE — detaching scroll listener, cancelling settle timer"); + this.detachScrollListener(); + if (this.settleTimer !== null) { + clearTimeout(this.settleTimer); + this.settleTimer = null; + } + // iOS 键盘收回时,outputEl.clientHeight 的恢复比 visualViewport.height 更晚, + // 固定延迟不可靠。改用轮询:每 32ms 检查一次 clientHeight 是否已恢复到 baseline, + // 最多等待 600ms,超时则强制执行。 + const expectedClientH = this.snapshot + ? this.snapshot.clientHeight // 弹出前保存的 clientHeight + : 0; + const startedAt = Date.now(); + const MAX_WAIT_MS = 600; + const POLL_MS = 32; + + // 在整个 poll 等待期间保持 scroll 冻结,防止 iOS 键盘收起动画触发的 + // scroll 事件把 autoFollow 改回 true,干扰后续 restoreScroll 的逻辑。 + this.opts.setScrollLocked?.(MAX_WAIT_MS + POLL_MS); + + const poll = () => { + const outputEl = this.opts.getOutputEl(); + const elapsed = Date.now() - startedAt; + const currentH = outputEl?.clientHeight ?? 0; + const recovered = currentH >= expectedClientH - 4; // 允许 4px 误差 + + this.log(`keyboard:CLOSE — poll clientHeight=${currentH} expected=${expectedClientH} recovered=${recovered} elapsed=${elapsed}ms`); + + if (recovered || elapsed >= MAX_WAIT_MS) { + this.settleTimer = null; + this.restoreScroll("keyboard_close"); + return; + } + this.settleTimer = setTimeout(poll, POLL_MS); + }; + + this.settleTimer = setTimeout(poll, POLL_MS); + } + + private onUserScroll(): void { + const now = Date.now(); + if (now < this.programmaticScrollUntil) { + this.log(`scroll:handler — programmatic window(${this.programmaticScrollUntil - now}ms), skip userScrolled`); + return; + } + if (this.snapshot) { + this.snapshot.userScrolled = true; + this.log("scroll:handler — USER scroll detected, userScrolled=true"); + } + } + + /** + * 将光标行定位到当前可见区域距顶 1/4 处。 + * + * 计算公式: + * 目标 scrollTop = cursorOffsetPx - visibleHeight * CURSOR_TARGET_RATIO + * + * 其中 visibleHeight 是键盘弹出后 outputEl 的实际可见高度。 + */ + private positionCursorAtQuarter(outputEl: HTMLElement): void { + const cursorOffsetPx = this.opts.getCursorOffsetPx(); + + // 键盘弹出后输出区的实际可见高度(clientHeight 已被键盘压缩) + const visibleH = outputEl.clientHeight; + + let targetScrollTop: number; + if (cursorOffsetPx !== null) { + targetScrollTop = cursorOffsetPx - visibleH * CURSOR_TARGET_RATIO; + } else { + // 回退:滚到底部 + targetScrollTop = outputEl.scrollHeight - visibleH; + } + + const maxTop = Math.max(0, outputEl.scrollHeight - visibleH); + targetScrollTop = Math.max(0, Math.min(maxTop, targetScrollTop)); + + this.log("keyboard:POSITION_CURSOR", { + cursorOffsetPx, + visibleH, + CURSOR_TARGET_RATIO, + targetScrollTop, + currentScrollTop: outputEl.scrollTop, + scrollHeight: outputEl.scrollHeight, + maxTop, + }); + + // iOS WebKit 的 scroll 事件是异步的(下一个 task),用时间窗口屏蔽。 + // 200ms 足够覆盖任何平台的异步 scroll 回调。 + const FREEZE_MS = 200; + this.programmaticScrollUntil = Date.now() + FREEZE_MS; + this.opts.setScrollLocked?.(FREEZE_MS); // 同步冻结 renderer scroll → autoFollow + outputEl.scrollTop = targetScrollTop; + + // 定位完成后必须强制 autoFollow=false(scroll 事件可能在时间窗口内把它改回 true) + this.opts.setAutoFollow(false); + // 抑制 store 层 followBottom,整个键盘可见期间屏蔽远端 stdout 的滚动干扰 + this.opts.suppressAutoFollow(60_000); + this.log("keyboard:POSITION_CURSOR — done, final scrollTop=" + outputEl.scrollTop); + } + + private restoreScroll(reason: string): void { + const snap = this.snapshot; + this.snapshot = null; + + // 清除键盘弹出时设置的长期 scroll 冻结(先清,后面可能重新设短窗口) + this.opts.setScrollLocked?.(0); + + if (!snap) { + this.opts.setAutoFollow(true); + this.log(`keyboard:RESTORE — no snapshot (${reason})`); + return; + } + + const outputEl = this.opts.getOutputEl(); + if (!outputEl) { + this.opts.setAutoFollow(true); + this.log(`keyboard:RESTORE — SKIP (no outputEl)`); + this.opts.suppressAutoFollow(0); + this.opts.suppressAutoFollow(500); + return; + } + + const before = { + scrollTop: outputEl.scrollTop, + clientHeight: outputEl.clientHeight, + scrollHeight: outputEl.scrollHeight, + }; + + const contentChanged = Math.abs(before.scrollHeight - snap.scrollHeight) > 4; + + this.log(`keyboard:RESTORE (${reason})`, { + userScrolled: snap.userScrolled, + contentChanged, + snap, + before, + }); + + // 计算目标 scrollTop + let targetScrollTop: number; + + if (snap.userScrolled) { + // 用户键盘可见期间主动上滚过:恢复弹出前的滚动位置(尊重用户意图) + const maxTop = Math.max(0, before.scrollHeight - before.clientHeight); + targetScrollTop = Math.max(0, Math.min(maxTop, snap.scrollTop)); + this.log(`keyboard:RESTORE — userScrolled, target=${targetScrollTop}`); + } else { + // 用户未主动滚动:直接恢复弹出前保存的 scrollTop。 + // snap.scrollTop 就是用户弹出键盘前看到的位置,这是最自然的"恢复"语义。 + // 内容有增长时用 maxTop 兜底(防止超出范围)。 + const maxTop = Math.max(0, before.scrollHeight - before.clientHeight); + targetScrollTop = Math.max(0, Math.min(maxTop, snap.scrollTop)); + this.log(`keyboard:RESTORE — !userScrolled, restoring snap.scrollTop`, { + snapScrollTop: snap.scrollTop, target: targetScrollTop, maxTop, + contentChanged, + }); + } + + // 判断目标位置是否在底部(4px 容差) + const maxTop = Math.max(0, before.scrollHeight - before.clientHeight); + const isAtBottom = Math.abs(targetScrollTop - maxTop) < 4; + + // 关键:先设 autoFollow=false,再设 scrollTop,再按需恢复 autoFollow。 + // 如果先 setAutoFollow(true),renderSnapshot 的下一帧会立刻把 scrollTop 覆盖成 scrollHeight。 + this.opts.setAutoFollow(false); + + const FREEZE_MS = 200; + this.programmaticScrollUntil = Date.now() + FREEZE_MS; + this.opts.setScrollLocked?.(FREEZE_MS); + outputEl.scrollTop = targetScrollTop; + + this.log(`keyboard:RESTORE — done, final scrollTop=${outputEl.scrollTop} isAtBottom=${isAtBottom}`); + + if (isAtBottom) { + // 在底部:恢复 autoFollow,700ms 内保护远端 resize 回刷 + this.opts.setAutoFollow(true); + this.opts.suppressAutoFollow(0); // 先清除60s长期抑制 + this.opts.suppressAutoFollow(700); // 再设700ms短保护 + } else { + // 不在底部(光标后有空行):保持 autoFollow=false。 + // 先清除60s长期抑制,再设700ms短保护。 + // 顺序必须是:先清(0),再设(700)——反序会导致0把700覆盖。 + this.opts.suppressAutoFollow(0); // 清除60s长期抑制 + this.opts.suppressAutoFollow(700); // 设700ms短保护,屏蔽立刻到来的stdout心跳 + } + } + + // ── 辅助 ───────────────────────────────────────────────────────────────── + + private attachScrollListener(el: HTMLElement): void { + if (this.scrollListenerAttached) return; + el.addEventListener("scroll", this.scrollHandler, { passive: true }); + this.scrollListenerAttached = true; + } + + private detachScrollListener(): void { + if (!this.scrollListenerAttached) return; + const outputEl = this.opts.getOutputEl(); + outputEl?.removeEventListener("scroll", this.scrollHandler); + this.scrollListenerAttached = false; + } +} diff --git a/terminal/apps/web/src/terminal/layout/domMeasureAdapter.ts b/terminal/apps/web/src/terminal/layout/domMeasureAdapter.ts new file mode 100644 index 0000000..c395a51 --- /dev/null +++ b/terminal/apps/web/src/terminal/layout/domMeasureAdapter.ts @@ -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 = {} + ) {} + + 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); + } +} diff --git a/terminal/apps/web/src/terminal/renderer/compatRenderer.ts b/terminal/apps/web/src/terminal/renderer/compatRenderer.ts new file mode 100644 index 0000000..221c169 --- /dev/null +++ b/terminal/apps/web/src/terminal/renderer/compatRenderer.ts @@ -0,0 +1,208 @@ +import { TerminalCore, sanitizeTerminalOutput } from "@remoteconn/terminal-core"; +import type { RendererAdapter, TerminalSnapshot, TerminalLine, TerminalCell, ColorValue } from "@remoteconn/terminal-core"; +import { FLAG_BOLD, FLAG_DIM, FLAG_ITALIC, FLAG_UNDERLINE, FLAG_INVERSE, FLAG_INVISIBLE, FLAG_STRIKETHROUGH, FLAG_OVERLINE } from "@remoteconn/terminal-core"; + +/** 16 色标准 xterm256 调色板(前 16 色) */ +const ANSI16: string[] = [ + "#000000","#cc0000","#00aa00","#aaaa00", + "#0000ee","#cc00cc","#00aaaa","#aaaaaa", + "#555555","#ff5555","#55ff55","#ffff55", + "#5555ff","#ff55ff","#55ffff","#ffffff", +]; + +function colorToCss(c: ColorValue, isFg: boolean): string { + switch (c.mode) { + case "default": return isFg ? "inherit" : "transparent"; + case "p16": return ANSI16[c.value] ?? "inherit"; + case "p256": return p256ToCss(c.value); + case "rgb": return `#${(c.value & 0xffffff).toString(16).padStart(6,"0")}`; + } +} + +function p256ToCss(idx: number): string { + if (idx < 16) return ANSI16[idx] ?? "#000"; + if (idx >= 232) { + const v = 8 + (idx - 232) * 10; + return `rgb(${v},${v},${v})`; + } + idx -= 16; + const b = idx % 6, g = Math.floor(idx / 6) % 6, r = Math.floor(idx / 36); + const ch = (x: number) => x === 0 ? 0 : 55 + x * 40; + return `rgb(${ch(r)},${ch(g)},${ch(b)})`; +} + +/** + * CompatRenderer — DOM div/span 实现的渲染器。 + * 每次 RAF 消费一次 TerminalCore.snapshot() 进行差量更新。 + */ +export class CompatRenderer implements RendererAdapter { + private core: TerminalCore; + private container: HTMLElement | null = null; + private viewport: HTMLElement | null = null; + private cursorEl: HTMLElement | null = null; + private rowEls: HTMLElement[] = []; + private rafId: number = 0; + private lastRevision = -1; + + constructor(cols = 80, rows = 24) { + this.core = new TerminalCore(cols, rows); + } + + getCore(): TerminalCore { return this.core; } + + mount(container: unknown): void { + this.container = container as HTMLElement; + this.container.classList.add("tc-compat-root"); + Object.assign(this.container.style, { + position: "relative", + overflow: "hidden", + fontFamily: "monospace", + lineHeight: "1.2em", + }); + + this.viewport = document.createElement("div"); + this.viewport.className = "tc-viewport"; + Object.assign(this.viewport.style, { + position: "absolute", + top: "0", left: "0", + whiteSpace: "pre", + }); + this.container.appendChild(this.viewport); + + this.cursorEl = document.createElement("div"); + this.cursorEl.className = "tc-cursor"; + Object.assign(this.cursorEl.style, { + position: "absolute", + width: "0.6em", + height: "1.2em", + background:"rgba(255,255,255,0.8)", + pointerEvents: "none", + zIndex: "1", + }); + this.container.appendChild(this.cursorEl); + + this.rebuildRows(this.core.snapshot().rows); + this.scheduleRender(); + } + + write(data: string): void { + const sanitized = sanitizeTerminalOutput(data); + if (sanitized) { + this.core.write(sanitized); + this.scheduleRender(); + } + } + + resize(cols: number, rows: number): void { + this.core.resize(cols, rows); + this.rebuildRows(rows); + this.scheduleRender(); + } + + applySnapshot(snapshot: TerminalSnapshot): void { + // Re-create a clean core from the snapshot is not straightforward; + // the caller should pass the same TerminalCore reference instead. + // For "mode switch" replays, we just re-render the current state. + this.renderSnapshot(snapshot); + } + + dispose(): void { + if (this.rafId) cancelAnimationFrame(this.rafId); + this.container?.classList.remove("tc-compat-root"); + this.rowEls = []; + this.viewport?.parentNode?.removeChild(this.viewport); + this.cursorEl?.parentNode?.removeChild(this.cursorEl); + this.viewport = null; + this.cursorEl = null; + this.container = null; + } + + // ── Private ──────────────────────────────────────────────────────────── + + private scheduleRender(): void { + if (this.rafId) return; + this.rafId = requestAnimationFrame(() => { + this.rafId = 0; + const snap = this.core.snapshot(); + if (snap.revision === this.lastRevision) return; + this.lastRevision = snap.revision; + this.renderSnapshot(snap); + }); + } + + private rebuildRows(rows: number): void { + if (!this.viewport) return; + this.viewport.innerHTML = ""; + this.rowEls = []; + for (let r = 0; r < rows; r++) { + const div = document.createElement("div"); + div.className = "tc-row"; + this.viewport.appendChild(div); + this.rowEls.push(div); + } + } + + private renderSnapshot(snap: TerminalSnapshot): void { + if (!this.viewport || !this.cursorEl) return; + + // Ensure row count matches + if (this.rowEls.length !== snap.rows) { + this.rebuildRows(snap.rows); + } + + for (let r = 0; r < snap.rows; r++) { + const rowEl = this.rowEls[r]; + if (!rowEl) continue; + const line = snap.lines[r]; + if (line) { + rowEl.innerHTML = lineToHtml(line); + } + } + + // Update cursor + const charEm = 0.6; // em per character (monospace approx) + const lineEm = 1.2; + Object.assign(this.cursorEl.style, { + display: snap.cursor.visible ? "block" : "none", + left: `${snap.cursor.x * charEm}em`, + top: `${snap.cursor.y * lineEm}em`, + width: `${charEm}em`, + height: `${lineEm}em`, + }); + } +} + +function lineToHtml(line: TerminalLine): string { + let html = ""; + for (const cell of line.cells) { + html += cellToHtml(cell); + } + return html; +} + +function cellToHtml(cell: TerminalCell): string { + const { flags } = cell; + let fg = colorToCss(cell.fg, true); + let bg = colorToCss(cell.bg, false); + + if (flags & FLAG_INVERSE) { [fg, bg] = [bg === "transparent" ? "#ffffff" : bg, fg === "inherit" ? "#000000" : fg]; } + + const invisible = flags & FLAG_INVISIBLE; + const char = invisible ? " " : (cell.char || " "); + + const styles: string[] = []; + if (fg !== "inherit") styles.push(`color:${fg}`); + if (bg !== "transparent") styles.push(`background:${bg}`); + if (flags & FLAG_BOLD) styles.push("font-weight:bold"); + if (flags & FLAG_DIM) styles.push("opacity:0.5"); + if (flags & FLAG_ITALIC) styles.push("font-style:italic"); + const tdecs: string[] = []; + if (flags & FLAG_UNDERLINE) tdecs.push("underline"); + if (flags & FLAG_STRIKETHROUGH) tdecs.push("line-through"); + if (flags & FLAG_OVERLINE) tdecs.push("overline"); + if (tdecs.length) styles.push(`text-decoration:${tdecs.join(" ")}`); + + const escChar = char === "&" ? "&" : char === "<" ? "<" : char === ">" ? ">" : char; + if (!styles.length) return escChar; + return `${escChar}`; +} diff --git a/terminal/apps/web/src/terminal/renderer/textareaRenderer.ts b/terminal/apps/web/src/terminal/renderer/textareaRenderer.ts new file mode 100644 index 0000000..153c543 --- /dev/null +++ b/terminal/apps/web/src/terminal/renderer/textareaRenderer.ts @@ -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 — 基于
/