commit c49aa1a5e9334c6a2e718e503292a3507dda7520 Author: douboer Date: Sat Mar 21 18:57:10 2026 +0800 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..498e750 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# 依赖目录 +node_modules/ + +# 构建产物 +**/dist/ +**/coverage/ + +# 本地环境变量 +.env +.env.* +!.env.example + +# 日志与缓存 +npm-debug.log* +.DS_Store +.npm-cache/ +data/tts-cache/ + +# 小程序本地运维配置(由 .env 生成) +apps/miniprogram/utils/opsEnv.js +docs +data +artifacts + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3c9e7e4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,281 @@ +# Changelog + +本文件提供连续版本索引,便于快速查看版本演进。 + +- 详细发布说明见 `release.md`。 +- 若 `release.md` 未单列某个 `vx.y.0`,则按 `history.md` 与 git 历史,按“约每 3 小时补一个 minor 版本”的规则补齐。 +- `x.y.z` 的 patch 空档不额外补齐;历史补齐版本只用于保持序列连续,详细内容以后续正式版本口径为准。 + +## v3.0.1 + +日期:`2026-03-20` + +- 当前版本主要收口使用手册与主题文案: + - 小程序“使用手册”补成图文版,正文前置“为什么需要这个APP?”,并接入 `guide-mobile-*` 配图; + - 小程序主题预设补齐到与 Web 一致的 `21` 套,主题名称支持随语言切换,并统一压短为更易读的单词标签。 +- 版本口径统一升级到 `v3.0.1`: + - 发布说明、根 README、小程序 README、项目介绍、相关方案文档与品牌模板统一同步到当前版本。 +- 当前版本不引入新的同步协议、终端协议或配置字段,继续沿用 `v3.0.0` 已明确的能力边界。 + +## v3.0.0 + +日期:`2026-03-18` + +- 小程序终端补上 caret 稳定化: + - 高压 `stdout` 输出期间会优先保留最近稳定 caret,减少底部后几行行尾来回跳动; + - 最终帧与用户输入相关帧仍会强制提交最新 caret,避免稳定化把真实位置长期冻结。 +- 小程序 shell 输入焦点补上被动 `blur` 保护: + - 当软键盘仍保持可见时,页面不会因为临时 `blur` 立即判定失焦; + - 豆包输入法长按语音输入过程中,不应再被页面侧主动打断语音录入。 +- 小程序终端 perf 日志默认改为关闭: + - 当前仍保留部分性能遗留问题,常态不再持续输出 `perf.summary / perf.snapshot`; + - 调试性能问题时,可通过现有开关临时打开日志收集现场。 +- 版本口径统一升级到 `v3.0.0`: + - 发布说明、根 README、小程序 README、Web / 小程序关于页、多语言文案、小程序工程描述与当前基线文档统一同步到当前版本。 + +## v2.9.6 + +日期:`2026-03-13` + +- 当前版本仅做文档与对外口径同步: + - 不引入新的功能、协议行为或交互变更; + - 继续沿用 `v2.9.5` 已完成的现有终端、同步与时延诊断能力,以及当前语音播报边界口径。 +- 版本口径统一升级到 `v2.9.6`: + - 发布说明、根 README、小程序 README、Web/小程序关于页、多语言文案、项目描述、小程序工程描述与当前基线文档统一同步到当前版本。 + +## v2.9.5 + +日期:`2026-03-13` + +- 当前版本仅做文档与对外口径同步: + - 不引入新的功能、协议行为或交互变更; + - 继续沿用 `v2.9.4` 已完成的现有终端、同步与时延诊断能力。 +- 新登记一条小程序终端语音播报遗留问题: + - 当前播报文本提取与轮次稳定判定仍不够准确,播报内容存在偏差; + - 长时间 `Codex` 交互时,语音播报链路还会放大小程序端的响应压力; + - 现阶段先不建议默认使用,后续继续优化文本提取、触发时机与性能隔离。 +- 版本口径统一升级到 `v2.9.5`: + - 发布说明、根 README、小程序 README、Web/小程序关于页、多语言文案、项目描述、小程序工程描述与当前基线文档统一同步到当前版本。 + +## v2.9.4 + +日期:`2026-03-12` + +- 当前版本仅做文档与对外口径同步: + - 不引入新的功能、协议行为或交互变更; + - 继续沿用 `v2.9.3` 已完成的时延诊断面板与主题对比度收口结果。 +- 版本口径统一升级到 `v2.9.4`: + - 发布说明、根 README、小程序 README、Web/小程序关于页、多语言文案、项目描述、问题闭环记录与当前基线文档统一同步到当前版本。 + +## v2.9.3 + +日期:`2026-03-11` + +- 小程序时延诊断面板完成一轮结构收口: + - 原先分离的“网关响应 / 网络时延”两张图已合并成一张双轴平滑曲线图; + - 左轴保留网关响应量纲,右轴保留网络时延量纲,顶部摘要卡统一收口为两行 `min / max / avg`。 +- 诊断浮窗进一步简化: + - 原“诊断信息”文字卡已删除,只保留图表卡; + - 同一服务器断开重连后会优先延续已有采样,尽量补足最近 `30` 个点。 +- 小程序终端主题适配继续收口: + - 时延面板改为跟随终端主题的反相面板; + - 深色终端单独切换一套更深的蓝橙曲线与指标色,避免浅底上文字与曲线发灰看不清。 +- 版本口径统一升级到 `v2.9.3`: + - 发布说明、根 README、小程序 README、关于页、当前基线文档与问题闭环记录统一同步到当前版本。 + +## v2.9.1 + +日期:`2026-03-11` + +- 小程序终端会话续接恢复继续收口: + - 首次恢复优先使用保存时的 `lines + bufferCols / bufferRows` 还原当前屏幕; + - 被裁剪的 `replayText` 仅保留给后续真实几何变化时重建使用,避免再次出现历史区顶部空白和裸露 `5;2H`。 +- 小程序启动阶段 `bootstrap` 合并配置完成后,首页服务器列表与底栏会立即刷新,不再需要重进页面才看到同步后的配置。 +- 小程序用户隐私政策与 about 隐私页已同步到最新审核口径,补齐录音用途、处理范围与用户权利说明。 +- 版本口径统一升级到 `v2.9.1`: + - 发布说明、根 README、小程序 README、关于页、当前基线文档与问题闭环记录统一同步到当前版本。 + +## v2.9.0 + +日期:`2026-03-11` + +- 小程序终端已修复 `Codex` 交互期间底部提示块缺行与闪动问题: + - normal buffer viewport 不再把光标行之后仍真实存在的 footer 误裁掉; + - `CSI ? 2026 h/l` 同步刷新窗口已做收口,减少重绘中间态直接暴露到界面; + - `Codex` 持续输出时,底部提示块与状态行的可见性和稳定性明显提升。 +- 小程序终端统一高亮背景行改为优先在 line 层绘制: + - `> Use /skills to list available skills`、代码块与同类整行高亮区域不再在行与行之间透出底色细线; + - 该调整未增加渲染节点,统一背景场景下反而减少了 segment 级背景重复绘制。 +- 新增真实 PTY 抓包回放用例,补齐 footer 保留与同步刷新窗口相关回归测试。 +- 小程序终端会话续接恢复口径已修正: + - 首次恢复优先使用保存的 `lines + bufferCols / bufferRows` 还原当前屏幕; + - 被裁剪的 `replayText` 仅保留给后续真实几何变化时重建使用,避免再次出现历史区顶部空白和裸露 `5;2H`; + - 新增终端快照几何持久化测试,锁住该回归场景。 +- 版本口径统一升级到 `v2.9.0`: + - 发布说明、根 README、小程序 README、关于页、当前基线文档与问题闭环记录统一同步到当前版本。 + +## v2.8.2 + +日期:`2026-03-10` + +- 小程序服务器列表 `connect` 按钮与底栏 `shell` 按钮在存在活动连接时统一改为高饱和实底高亮,不再依赖描边反馈。 +- 连接态 SVG 前景色改为运行时跟随界面前景色,保证高亮底色上的图标对比度。 +- about 首页、详情页与 about-app 改为按界面配置推导配色;Web about 页同步切到同一套主题变量策略。 +- 新登记一条 about 反馈遗留问题:当前“反馈”按钮仍只复制邮箱地址,暂不支持直接拉起系统发送邮件组件。 +- 发布说明、根 README、小程序 README、关于页、项目描述与当前基线文档统一更新到 `v2.8.2`。 + +## v2.8.1 + +日期:`2026-03-10` + +- 小程序终端语音区视觉继续收口:展开按钮默认全透明,仅保留 SVG 本体;分类胶囊改为贴文字高度,录音中输入框上方新增更显眼的双环脉冲提示。 +- 录音提示文案更新为“正在收音,松开后发送或记录闪念”;松开本身仍只结束本轮输入,不自动发送。 +- 小程序终端 VT 语义继续修正:`OSC 10 / 11 / 12` 返回真实 shell 主题色,`alt screen` 与 `CSI J / K / X` 擦除空白位统一继承当前擦除背景。 +- 此前点击 Codex 连接选项后的首回显迟滞与等待期间按钮阻塞问题已收敛,当前版本不再将其列为遗留问题。 +- 新登记一条低频终端遗留问题:偶发新连接后首屏只显示光标、提示符稍后才出现;后续需继续优化连接就绪判定与 prompt 首屏兜底。 +- 发布说明、根 README、小程序 README、关于页与当前基线文档统一更新到 `v2.8.1`。 + +## v2.7.1 + +日期:`2026-03-10` + +- 文档与关于页版本口径统一升级到 `v2.7.1`,补齐同步 SQLite 的当前边界说明。 +- 明确 `remoteconn-sync.db-wal` 属于 SQLite 写前日志,文件体积不直接等于当前有效同步数据量;checkpoint 后数据会回写主库。 +- 当前跨设备同步仍仅覆盖 `settings / servers / records`;其中服务器受保护字段继续以加密后的 `secret_blob` 保存,日志、插件运行时日志与终端会话缓冲仍保留本地。 +- 当前小程序终端仍存在明显交互卡顿:点击 Codex 连接选项后约 10 秒才出现回显,等待期间除上下滑动外其余按钮基本阻塞,暂列为当前遗留问题。 + +## v2.7.0 + +日期:`2026-03-09` + +- 小程序设置、服务器配置与闪念记录接入 `Gateway + SQLite` 双层持久化,支持同一微信账号跨设备恢复数据。 +- 同步链路补齐 `bootstrap` 与 `settings / servers / records` 增量推送;服务器和闪念删除支持 `tombstone` 合并。 +- SSH 密码、私钥、口令与证书等受保护字段改为服务端加密保存;终端会话缓冲仍不纳入第一阶段同步范围。 + +## v2.6.6 + +日期:`2026-03-09` + +- 文档口径统一更新到 `v2.6.6`。 +- 将“通过 `npm run mini` 生成的 preview 预览包,不作为正式版本地缓存连续性验证依据”记录为当前遗留问题。 +- 小程序仍以本地 storage 持久化业务数据,后续再补更直接的调试与备份能力。 + +## v2.6.5 + +日期:`2026-03-08` + +- 小程序终端 VT P0 基线收口,补齐双缓冲、备用屏幕切换、`DSR / CPR / DA1 / DA2 / DECSTR` 与基础局部重绘。 +- 修复 normal buffer 的 live tail 与滚动边界问题,并同步更新当前文档基线到 `v2.6.5`。 + +## v2.6.1 + +日期:`2026-03-08` + +- 将“修改字号后偶发吃字/显示不完整”收口为已知遗留问题,并在设置页增加“修改字号后建议断开重连”提示。 +- 收敛数字输入、字体回退与设置页容错逻辑,移除排查阶段的 `terminal.wrap` 调试输出。 + +## v2.6.0 + +日期:`2026-03-07` + +- 小程序补齐跳转主机、AI 快速启动、后台续接与连接反馈等主链路能力。 +- 小程序继续补齐终端可用性细节:光标位置计算修复,AI 启动链路正式落地到小程序端。 +- SSH relay / 跳板机链路从“可配置”推进到“可经 A 主机直接转发连接 B 主机”。 +- 文档口径统一升级到 `v2.6.0`,并将历史 `v2.4.0` 说明并入当前对外版本。 + +## v2.5.0(历史补齐) + +日期:`2026-03-07` + +- 依据 `v2.3.0 -> v2.6.0` 之间的历史记录,按 3 小时一个 minor 版本的规则补齐。 +- 该阶段主要是 `v2.6.0` 之前的小程序终端链路收口与 AI、跳板机场景串联,详细内容已并入 `v2.6.0`。 + +## v2.4.0(历史补齐) + +日期:`2026-03-07` + +- `history.md` 中明确出现 `版本号v2.4.0`,后续在 `v2.6.0` 中并档处理。 +- 该阶段集中于小程序终端光标、列宽与 cell 模型重构,以及相关分析文档沉淀,详细内容已并入 `v2.6.0`。 + +## v2.3.0 + +日期:`2026-03-06` + +- 小程序完成一轮大范围能力对齐,补齐 `connect / server settings / terminal / logs / records / settings / plugins` 主页面链路。 +- Web 端继续精修交互与稳定性,文档口径统一到 `v2.3.0`。 + +## v2.2.0 + +日期:`2026-03-06` + +- 闪念增强定稿,补齐分类、编辑、搜索、过滤、快速改分类与上下文快照。 +- 全局配置补齐闪念分类治理、默认分类与拖拽排序。 + +## v2.1.0(历史补齐) + +日期:`2026-03-01` + +- 依据 `v2.0.0 -> v2.2.0` 之间的历史记录,按 3 小时一个 minor 版本的规则补齐。 +- 该阶段主要完成小程序连接页、服务器配置页、远程目录选择与基础对齐链路,为 `v2.2.0` 的记录增强和 `v2.3.0` 的大范围对齐打底。 + +## v2.0.0 + +日期:`2026-02-27` + +- 语音输入交互对齐 Figma,闪念记录闭环上线。 +- 日志与记录分页、资源缓存治理、文档与发布规范同步统一。 +- 语音层与键盘工具层的命中区继续收敛,修复工具栏被误判为外部点击后自动折叠的问题。 + +## v1.1.0 + +日期:`2026-02-26` + +- 终端语音输入全链路、Gateway ASR 代理与 Web 稳定性增强。 +- 终端输入增强为“原生 textarea + 语音输入面板 + 发送确认”组合,并补入 `TAB` 辅助键。 +- 路由懒加载失败增加自动恢复,缩放防护补齐到 `double-tap / dblclick` 等路径。 + +## v1.0.9 + +日期:`2026-02-25` + +- 连接体验治理、服务器列表拖拽排序与移动端防误触优化。 +- 连接与服务器管理继续收敛:支持连接重试次数与分组;新增服务器改为先进入配置页,未改动不落库。 +- 导航与配置页体验统一:返回语义按历史栈处理,终端配置页增加预览块,配置页 UI 完成一轮重构。 +- 终端工具与视觉细节补齐:新增 `paste` 辅助按钮,修复字体选择,补齐夜间模式与重连提示样式。 +- 会话恢复体验补强:同连接历史可延续,刷新“我的服务器”页默认不主动断开现有会话。 + +## v1.0.8 + +日期:`2026-02-24` + +- 配置中心稳定性、深浅色模式与网关 runtime 配置落地。 +- 配置中心结构重组:服务器基础/认证绑定到当前服务器,终端/主题/安全切到全局配置。 +- 配置与工具区交互继续收敛:点击外部区域可折叠右下角键盘工具条,Figma 配置区交互并入当前基线。 +- 终端提示文案与初始化输出治理:连接提示更友好,中文输入初始化命令回显默认不展示。 + +## v1.0.6 + +日期:`2026-02-24` + +- Console 触摸工具区可点击性与误触防护优化。 + +## v1.0.5 + +日期:`2026-02-23` + +- iPhone 触摸滚动动量阶段性优化。 + +## v1.0.3 + +日期:`2026-02-23` + +- iOS 触摸焦点状态机稳定性修复。 + +## v1.0.1 + +日期:`2026-02-23` + +- 生产架构版稳定性更新,确立 Web、Gateway 与插件运行时的多包工程基线。 +- 补齐多服务器与多认证方式管理,最近连接日志可追溯。 +- Codex 模式主链路可用:连接后自动切目录并启动 `codex`。 +- 主题与界面自定义基础能力上线,含字体、配色与基础配置中心。 diff --git a/PLAN_origin.md b/PLAN_origin.md new file mode 100644 index 0000000..7edc5b8 --- /dev/null +++ b/PLAN_origin.md @@ -0,0 +1,576 @@ +# remote ssh 实现方案(PLAN) + +重要:这份文档只使用与第一次构建。迭代后没有更新。已经不适用。作为历史文档保留 + +## 1. 目标与范围 + +### 1.1 产品目标 +- 提供可在 `Web`、`小程序`、`iOS Hybrid(App)` 使用的 SSH 连接终端。 +- iOS 端支持原生 SSH 能力,连接后在终端内一键进入 Codex 工作流(`cd <项目目录>` + `codex`)。 +- 支持多服务器管理、认证方式管理、最近连接日志、主题定制、中文输入。 +- 支持插件扩展能力(参考 Obsidian),允许用户按规范在 `~/.remoteconn/plugins` 增加功能。 + +### 1.2 本期范围(MVP + 可扩展) +- 必做(MVP): + - SSH 连接建立与交互(密码 + 私钥)。 + - 服务器配置增删改查。 + - xterm.js 终端渲染与基础设置(字体/字号/配色)。 + - “Codex 模式”一键执行。 + - 最近连接记录与会话日志。 + - iOS 原生 SSH 插件接入。 +- 二期: + - 证书认证(OpenSSH certificate)。 + - 小程序网关高可用、审计、限流。 + - 高级主题编辑(背景自动对比优化、导入导出主题)。 + - 插件系统(本地插件加载、插件 API、权限沙箱、插件市场/导入导出)。 + +### 1.3 非目标(当前不做) +- 远程文件管理器(SFTP UI)。 +- 多人协作会话(共享同一终端)。 +- 本地离线执行(必须依赖远程 SSH 主机)。 + +## 2. 核心技术选型 + +### 2.1 前端与终端 +- `Vue 3 + TypeScript`:统一 UI 与状态逻辑。 +- `xterm.js`:终端渲染核心。 +- 建议配套插件: + - `xterm-addon-fit`:自适应布局。 + - `xterm-addon-webgl`:性能优化(可降级 canvas)。 + - `xterm-addon-search`:日志检索。 + - `xterm-addon-unicode11`:中文宽字符兼容。 + +### 2.2 多端容器 +- Web:Vite 构建 SPA。 +- iOS:`Capacitor` + 原生 Swift SSH 插件。 +- 小程序:使用 `WSS SSH Gateway`(小程序无法直接发起原始 TCP SSH,需网关中转)。 + +### 2.3 SSH 实现策略 +- iOS:原生 SSH 客户端能力(Swift 封装 libssh2 或 SwiftNIO SSH,先做 POC 选型)。 +- Web/小程序:统一走 `WSS Gateway -> SSH Server`。 +- 统一抽象 `TerminalTransport` 接口,屏蔽底层差异。 + +## 3. 总体架构 + +### 3.1 分层设计 +- `UI 层`:连接页、服务器管理页、终端页、设置页。 +- `应用层`:连接状态机、命令编排(Codex 模式)、日志服务、主题服务。 +- `传输层`: + - iOS:Native SSH Channel。 + - Web/小程序:WebSocket Channel(对接 SSH 网关)。 +- `存储层`: + - 配置数据:本地数据库(IndexedDB/SQLite)。 + - 密钥与密码:系统安全存储(iOS Keychain / Web 安全策略)。 + +### 3.2 关键模块 +- `server-profile`:服务器配置 CRUD、分组、最近使用时间。 +- `auth-manager`:密码/私钥/证书认证参数组织与安全存取。 +- `session-manager`:连接生命周期(连接、重连、断开、心跳)。 +- `terminal-engine`:xterm 绑定、输入输出转发、编码处理。 +- `codex-orchestrator`:登录后自动执行项目目录切换与 `codex` 启动。 +- `logbook`:最近任务和会话日志归档、检索。 +- `theme-engine`:字体、字号、配色、背景对比度计算。 +- `plugin-runtime`:插件发现、校验、生命周期、错误隔离。 +- `plugin-api`:命令注册、会话事件、UI 扩展、插件存储。 +- `plugin-security`:权限模型、沙箱执行、风控策略。 + +## 4. 详细功能方案 + +### 4.1 多服务器管理(增删改查) +- 数据模型建议: + - `ServerProfile`: `id`, `name`, `host`, `port`, `username`, `authType`, `projectPresets[]`, `tags[]`, `lastConnectedAt`。 + - `CredentialRef`: `id`, `type(password/key/cert)`, `secureStoreKey`, `createdAt`, `updatedAt`。 +- 交互要点: + - 新建时连通性检测(可选)。 + - 列表支持搜索/排序(最近连接优先)。 + - 删除前二次确认,避免误删凭据引用。 + +### 4.2 认证方式 +- 支持: + - 密码认证。 + - 私钥认证(含 passphrase)。 + - 证书认证(二期)。 +- 安全要求: + - 凭据不落明文文件。 + - iOS 使用 Keychain;Web 使用受限存储并提示风险。 + - 首次连接需主机指纹确认(known_hosts 机制)。 + +### 4.3 终端交互与中文支持 +- 输入处理: + - 开启 xterm 中文宽字符能力(Unicode11)。 + - 验证拼音输入法、中文标点、组合键(Ctrl/Cmd)兼容性。 +- 输出处理: + - ANSI 颜色、光标控制、清屏、大输出滚动性能。 + - 终端尺寸变化(横竖屏切换)触发远端 `pty resize`。 + +### 4.4 Codex 模式 +- 入口:终端页主按钮 “Codex 模式”。 +- 执行流程: + 1. SSH 连接成功后读取用户预设项目路径(支持从 `~` 下候选目录选择)。 + 2. 发送 `cd `(路径安全转义)。 + 3. 发送 `command -v codex` 检测。 + 4. 若存在则发送 `codex` 并进入交互;否则提示安装指引。 +- 容错逻辑: + - `cd` 失败时给出“目录不存在/权限不足”提示。 + - `codex` 异常退出时记录退出码并允许一键重试。 + +### 4.5 最近连接任务日志 +- 日志结构: + - `SessionLog`: `sessionId`, `serverId`, `startAt`, `endAt`, `status`, `commandMarkers[]`, `error`。 +- 展示策略: + - 最近 50 条摘要,详情按需加载。 + - 支持按服务器过滤。 + - 提供导出文本(脱敏)。 + +### 4.6 主题与界面自定义 +- 可配置项: + - 字体、字号、行高、光标样式。 + - 终端配色(16 色/扩展色关键字段)。 + - UI 外观:背景色、文本色、强调色、按钮色;UI 与 Shell 独立配色域,互不干扰。 + - 背景自动对比:根据前景色计算最佳对比背景(WCAG 对比度优先,`pickBestBackground`)。 + - 按钮色自动推导:`pickBtnColor(bg, text)` 在背景与文字之间线性插值(t=0.72,偏文本侧),切换预设或自动优化背景时联动更新。 + - SVG 图标按钮通过 CSS `mask-image` + `background-color: var(--btn)` 统一随按钮色变化。 +- 设置即时生效: + - 所有设置项调整后立即应用(CSS 变量实时写入 `document.documentElement`)。 + - 变更后 400ms 防抖自动持久化到 IndexedDB,**无需手动点击保存**。 + - 刷新页面后从 DB 恢复,初始化阶段通过 `initialized` 标志位防止预设联动 watcher 覆盖已保存的自定义值。 +- 默认策略: + - 首次使用加载"tide"主题,`uiBtnColor` 由 `pickBtnColor` 自动推导(默认 `#adb9cd`)。 + - 用户主题本地持久化(IndexedDB),支持重置默认。 + +## 5. 跨平台实现路径 + +### 5.1 Web +- 通过网关建立 WSS 会话,浏览器仅负责终端渲染与输入转发。 +- 重点处理浏览器断网重连、页面切后台心跳恢复。 + +### 5.2 iOS(Hybrid) +- Capacitor 提供 WebView 容器和插件桥接。 +- 原生插件职责: + - 建立 SSH 会话(认证/pty/shell/channel)。 + - 读写流双向桥接到 JS。 + - 连接状态、错误码、重连事件上报。 +- JS 层职责: + - 页面逻辑、xterm 渲染、Codex 编排。 + +### 5.3 小程序 +- 通过 `WSS Gateway` 访问 SSH。 +- 网关最小能力: + - 鉴权(token/session)。 + - 连接到目标 SSH 主机并建立 channel。 + - 双向转发终端数据帧。 + - 会话超时与限流控制。 + +## 6. 网关服务设计(Web/小程序共用) + +### 6.1 协议建议 +- WebSocket 消息类型: + - `init`:目标主机、端口、用户名、认证引用。 + - `stdin`:用户输入。 + - `stdout`:远端输出。 + - `resize`:终端尺寸变化。 + - `control`:心跳、断开、错误。 + +### 6.2 安全与合规 +- 强制 WSS。 +- 不在网关长期保存明文凭据。 +- 审计日志仅记录元信息(不记录完整命令正文,避免敏感泄露)。 +- 增加 IP/账号级限流和失败次数保护。 + +### 6.3 兼容性 +- 目标兼容: + - OpenSSH(主流版本)。 + - Dropbear。 + - Bitvise。 + - Windows OpenSSH。 +- 通过算法协商与回退策略处理差异(KEX、HostKey、Cipher)。 + +## 7. 里程碑与迭代计划 + +### M1:脚手架与基础架构(1 周) +- 交付: + - Vue3 + TS + xterm.js 基础工程。 + - 统一 `TerminalTransport` 接口。 + - 页面骨架(连接/终端/设置)。 +- 验收: + - 本地可打开终端页并完成假数据输入回显。 + +### M2:服务器管理与本地存储(1 周) +- 交付: + - ServerProfile CRUD。 + - 凭据引用模型与安全存储接口。 +- 验收: + - 可新增/编辑/删除服务器并持久化。 + +### M3:iOS 原生 SSH 插件 POC(1-2 周) +- 交付: + - 建立真实 SSH 连接并完成收发。 + - 错误码映射与状态事件回传。 +- 验收: + - iOS 真机成功连接 Linux 主机并执行命令。 + +### M4:网关服务 MVP(1-2 周) +- 交付: + - WSS -> SSH 双向通道。 + - 基础鉴权、心跳、断开处理。 +- 验收: + - Web 可通过网关连接并稳定交互 30 分钟。 + +### M5:Codex 模式与日志(1 周) +- 交付: + - 一键进入 Codex 编排流程。 + - 最近连接/任务日志列表与详情。 +- 验收: + - 单击按钮后可自动 `cd` 并进入 `codex`。 + +### M6:主题、中文输入与兼容性完善(1 周) +- 交付: + - 主题系统 + 自动对比背景。 + - 中文输入兼容修复与回归。 + - SSH 服务端兼容性测试报告。 +- 验收: + - 在目标服务端矩阵中通过率达到约定阈值(建议 >= 95%)。 + +### M7:插件系统 MVP(1-2 周) +- 交付: + - 插件目录扫描与 `manifest.json` 校验。 + - `main.js` 生命周期加载(`onload`/`onunload`)与 `styles.css` 注入。 + - 插件 API(命令注册、会话事件、插件私有存储)。 + - 插件启停管理页(启用/禁用/失败日志)。 +- 验收: + - 手工放入一个符合规范的插件后可成功加载并注册命令。 + - 单插件崩溃不会影响 SSH 主链路与终端会话。 + +## 8. 测试与质量保障 + +### 8.1 测试分层 +- 单元测试: + - 状态机、命令编排、主题对比度算法、日志序列化。 +- 集成测试: + - Transport 层收发、断线重连、resize。 +- 端到端测试: + - 真机 iOS + Web + 小程序核心链路。 + +### 8.2 兼容矩阵 +- 服务端维度:OpenSSH / Dropbear / Bitvise / Windows OpenSSH。 +- 认证维度:密码 / 私钥 / 证书(二期)。 +- 终端维度:中文输入、复制粘贴、快捷键、长日志滚动。 + +### 8.3 验收标准(DoD) +- 核心流程通过: + - 配置服务器 -> 连接 -> 终端交互 -> Codex 模式 -> 断开 -> 日志可查。 +- 崩溃率、连接失败率、重连成功率达到设定阈值。 +- 安全检查通过(凭据保护、主机指纹校验、传输加密)。 + +## 9. 风险与应对 + +### 9.1 小程序网络限制风险 +- 风险:不能直接 SSH TCP。 +- 应对:网关中转作为必选架构,提前做容量与延迟评估。 + +### 9.2 SSH 库兼容性风险(iOS) +- 风险:不同算法和服务端实现差异导致连接失败。 +- 应对:M3 做双库 POC,保留切换空间;建立兼容性回归集。 + +### 9.3 凭据安全风险 +- 风险:本地泄露或日志误记敏感信息。 +- 应对:安全存储 + 脱敏日志 + 最小权限 + 定期安全审计。 + +### 9.4 Codex 可用性风险 +- 风险:远端未安装 codex 或 PATH 异常。 +- 应对:启动前检测并给出安装与修复指引。 + +### 9.5 插件安全与稳定性风险 +- 风险:第三方插件读取敏感数据、阻塞主线程或污染全局样式。 +- 应对:权限模型 + 沙箱执行 + 运行时超时熔断 + 样式作用域隔离 + 默认禁用高危权限。 + +## 10. 实施顺序建议(小步迭代) +- 第一步:先打通单平台(iOS 或 Web)端到端连接链路。 +- 第二步:抽象共用层(状态机、命令编排、主题、日志)。 +- 第三步:接入第二平台并复用核心模块。 +- 第四步:补齐小程序网关与兼容性验证。 +- 第五步:做稳定性、性能与安全收敛后再扩展证书认证。 + +## 11. 需要产品/团队尽快确认的决策 +- 小程序目标平台范围(微信/支付宝/字节)及对应发布优先级。 +- iOS SSH 库最终选型标准(许可证、性能、维护活跃度)。 +- 日志保留周期与隐私策略(是否允许命令内容落盘)。 +- Codex 模式默认工作目录来源(手选/记忆上次/项目列表同步)。 +- 插件发布策略(仅本地安装/官方市场/私有市场)与审核机制。 +- 插件权限默认策略(安装即授权 or 首次调用时授权)。 +- Web 与 iOS 端插件目录映射策略(是否支持跨端同步)。 + +## 12. UI 详细设计(MVP 可落地) + +### 12.1 设计目标 +- 让用户在 3 步内完成连接:选服务器 -> 认证 -> 进入终端。 +- 终端页优先级最高,减少非必要干扰。 +- 支持 Web、iOS、小程序统一体验,交互模型一致,视觉密度按端适配。 + +### 12.2 视觉方向 +- 风格关键词:液态、通透、流动、层次感、高可读。 +- 视觉语言:`液态渐变背景 + 玻璃卡片 + 软阴影`,以“可视层级”替代“重边框”。 +- 字体建议:`Outfit`(UI)+ `JetBrains Mono`(终端与日志)。 +- 圆角:容器 `20px`,卡片 `14px`,输入框 `10px`,标签 `999px`。 +- 动效:按钮反馈 `180ms`,抽屉/弹窗 `240ms`,背景流体动画 `8-20s` 可配置。 +- 移动端策略:优先保证单手操作区,关键按钮放置在拇指热区(屏幕下半部分)。 + +### 12.3 设计令牌(Design Tokens) +- `--bg: #192b4d`(主背景,随 `uiBgColor` 实时更新) +- `--surface: rgba(20,32,56,0.64)`(液态面板) +- `--surface-border: rgba(118,156,213,0.2)`(卡片边框) +- `--text: #e6f0ff`(主文本,随 `uiTextColor` 实时更新) +- `--muted: #9cb1cf`(次文本) +- `--accent: #5bd2ff`(主强调色,随 `uiAccentColor` 实时更新) +- `--btn: #adb9cd`(按钮/图标色,随 `uiBtnColor` 实时更新;SVG 图标通过 `mask + background-color: var(--btn)` 染色) +- `--success: #79f3bd` +- `--danger: #ff7f92` + +### 12.4 信息架构 +- 一级导航:`连接`、`终端`、`日志`、`设置`。 +- Web/iPad:左侧导航 + 右侧内容区。 +- 手机/小程序:底部 Tab + 顶部标题栏。 +- 全局入口:`+ 新建服务器`、全局搜索、连接状态指示器。 + +### 12.5 页面级设计 + +#### 12.5.1 连接页(服务器列表) +- 顶部区:搜索框、筛选(标签/认证类型)、排序(最近连接优先)。 +- 内容区:服务器卡片列表,显示名称、`user@host:port`、认证类型、最近连接时间、标签。 +- 卡片操作:`连接`(主按钮)、`编辑`、`更多`(复制/删除)。 +- 快速入口:支持 `新建服务器`,默认生成草稿并自动定位到配置表单。 +- 空状态:引导文案 + `新建第一台服务器`。 +- 错误状态:网络错误、凭据失效、网关不可达,均提供 `重试`。 + +#### 12.5.2 新建/编辑服务器页 +- 字段分组:基础信息、认证、Codex 预设、高级选项。 +- 必填项:名称、主机、端口、用户名、认证类型。 +- 认证区:密码/私钥二选一;私钥支持 passphrase。 +- 高级项:连接超时、心跳间隔、启动后默认目录。 +- 易用性:按认证类型动态展示字段,隐藏无关输入,减少误填。 +- 底部固定操作栏:`测试连接`、`保存`。 +- 校验策略:失焦即时校验 + 保存前全量校验。 + +#### 12.5.3 终端页(核心) +- 顶部工具栏:会话名、延迟指示、重连、复制、清屏、字体缩放、设置。 +- 主区:xterm 终端占满可视区,支持横竖屏与窗口 resize。 +- 侧边抽屉(Web)/底部抽屉(移动端):会话信息、快捷命令、日志标记。 +- 主按钮:`Codex 模式`,首次点击弹出确认说明。 +- 断线态:覆盖层提示 + `自动重连中` + `手动重连`。 +- 预览联动:设置页调整字体、行高、主题后,终端区实时预览变化。 + +#### 12.5.4 日志页 +- 列表项字段:服务器名、开始/结束时间、状态、耗时、错误摘要。 +- 过滤器:服务器、状态、时间范围。 +- 详情页:时间线展示关键事件(连接、认证、Codex 启动、断开)。 +- 操作:`导出脱敏文本`。 + +#### 12.5.5 设置页 +- 分区:用户界面(UI 外观)、Shell(终端显示 + 缓冲)、连接策略、日志。 +- 外观项:主题预设(切换自动联动颜色)、背景色、文本色、强调色、按钮色(自动推导,可手动覆盖)。 +- Shell 项:字体、字号、行高、光标样式、宽字符支持、终端缓冲阈值。 +- 连接策略:自动重连、重连上限、主机指纹策略、凭据记忆策略、超时参数、服务器预填值。 +- 日志项:保留天数、日志脱敏。 +- **即时生效**:所有项更改后立即反映到 UI(CSS 变量实时写入根节点),400ms 防抖后自动写入 IndexedDB,无手动保存按钮。 +- 自动优化背景:`pickBestBackground` 推导最优背景色,同时联动更新 `uiBtnColor`(`pickBtnColor`)。 + +### 12.6 关键组件规范 +- `ServerCard`:高度 `88px`,主信息左对齐,主按钮固定右侧。 +- `ConnectButton`:默认 `连接`,加载态 `连接中...`,失败态 `重试`。 +- `AuthTypeSegment`:密码/私钥分段选择,切换保留已输入内容。 +- `TerminalToolbar`:图标按钮最小点击区 `40x40`。 +- `HostKeyConfirmModal`:显示指纹、算法、首次发现时间;按钮 `信任并继续` / `取消`。 +- `ThemePresetSelect`:切换后同步更新颜色、透明度、模糊参数,支持用户再微调。 +- `ConfigTabs`:固定五分组(基础/认证/终端/液态主题/安全),避免长表单滚动疲劳。 + +### 12.7 关键交互流程(连接与 Codex) +1. 用户在连接页点击目标服务器 `连接`。 +2. 前端进入 `connecting` 状态,展示进度并允许取消。 +3. 若为首次连接,弹出主机指纹确认弹窗。 +4. 认证成功后进入终端并自动执行 `fit`。 +5. 顶部出现 `Codex 模式` 入口。 +6. 用户点击后依次执行 `cd `、`command -v codex`、`codex`。 +7. 任一步失败时,在终端输出可读错误,并配合 toast 提示。 + +### 12.8 状态机与反馈规范 +- 会话状态:`idle`、`connecting`、`auth_pending`、`connected`、`reconnecting`、`disconnected`、`error`。 +- 状态反馈:每个状态具备统一颜色、提示文案、按钮可用性规则。 +- 消息反馈:信息 `3s` 自动消失,警告 `5s`,错误默认需手动关闭。 + +### 12.9 响应式布局规则 +- `>=1280`:三栏(导航/列表/详情)。 +- `768-1279`:两栏(导航/内容)。 +- `<768`:单栏 + 底部 Tab。 +- 终端最小可用区:宽 `320px`、高 `220px`。 + +### 12.10 可用性与无障碍 +- 文本与关键 UI 对比度满足 WCAG AA。 +- 关键操作支持键盘可达与焦点可视化。 +- 错误提示需定位到字段级,不仅显示“操作失败”。 +- 配置面板「实时生效 + 自动保存」(400ms 防抖写 IndexedDB,刷新不丢失);对比度满足 WCAG AA。 + +### 12.11 配置能力清单(MVP 必备) +- 服务器配置:名称、主机、端口、用户名、标签、项目目录、超时、心跳。 +- 认证配置:密码、私钥、证书;按类型动态展示所需字段。 +- 终端配置:字体、字号、行高、光标、Unicode11、自动重连。 +- 主题配置:预设主题、背景/文本/强调/按钮色自定义(按钮色自动推导)、自动优化背景。所有项实时生效,自动持久化。 +- 安全配置:主机指纹策略、日志保留时长、日志脱敏、凭据记忆策略。 + +## 13. 插件系统实现方案(参考 Obsidian) + +### 13.1 目标与边界 +- 目标: + - 让用户通过本地目录增加插件,不改主程序即可扩展功能。 + - 插件覆盖命令扩展、会话事件处理、轻量 UI 扩展、样式增强。 + - 插件失败可隔离,不能拖垮核心 SSH 链路。 +- 非目标(MVP 不做): + - 插件可执行任意系统命令。 + - 插件直接访问凭据明文(密码、私钥内容)。 + - 复杂插件依赖解析(如 npm 依赖在线安装)。 + +### 13.2 目录与文件规范 +- 默认根目录:`~/.remoteconn`。 +- 插件目录:`~/.remoteconn/plugins//`。 +- 每个插件最少三个文件: + - `manifest.json` + - `main.js` + - `styles.css` +- 推荐目录结构: + - `~/.remoteconn/plugins//data.json`(插件私有配置,可选) + - `~/.remoteconn/plugins//README.md`(插件说明,可选) +- 约束: + - `` 只能包含小写字母、数字、短横线,正则:`^[a-z0-9][a-z0-9-]{1,62}$`。 + - 不允许符号链接跳出插件目录(防止路径穿越)。 + +### 13.3 manifest.json 规范(MVP) +- 必填字段: + - `id`: 插件唯一标识,必须与目录名一致。 + - `name`: 插件展示名。 + - `version`: 语义化版本(SemVer)。 + - `minAppVersion`: 最低主程序版本。 + - `description`: 简介。 + - `entry`: 入口文件,固定 `main.js`。 + - `style`: 样式文件,固定 `styles.css`。 + - `permissions`: 权限数组。 +- 可选字段: + - `author`、`homepage`、`keywords`、`mobileSupported`、`desktopSupported`。 +- 示例: +```json +{ + "id": "codex-shortcuts", + "name": "Codex Shortcuts", + "version": "0.1.0", + "minAppVersion": "0.1.0", + "description": "提供常用 Codex 快捷命令", + "entry": "main.js", + "style": "styles.css", + "permissions": ["commands.register", "session.read", "session.write"] +} +``` + +### 13.4 运行时架构 +- `PluginManager`:扫描目录、触发加载/卸载、处理启停状态。 +- `PluginRegistry`:维护插件清单、版本、权限、运行状态。 +- `PluginValidator`:校验 manifest、文件存在性、版本兼容、权限白名单。 +- `PluginHost`:创建插件执行上下文,注入受限 API。 +- `PluginStyleManager`:注入/移除插件样式,处理作用域和冲突。 +- `PluginErrorBoundary`:捕获插件异常并熔断单插件实例。 +- 插件状态机: + - `discovered` -> `validated` -> `loading` -> `active` -> `stopped` / `failed` + +### 13.5 加载与卸载流程 +1. 启动时扫描 `~/.remoteconn/plugins/*/manifest.json`。 +2. 校验 `manifest.json` 与目录结构;不合法则标记 `failed` 并记录错误。 +3. 校验权限是否在白名单内;高危权限要求用户确认。 +4. 先注入 `styles.css`(带命名空间),再执行 `main.js`。 +5. 若 `main.js` 导出 `onload(ctx)`,则调用并等待完成(设置超时,例如 3000ms)。 +6. 用户禁用或退出时调用 `onunload()`,清理命令注册、事件监听、样式节点。 +7. 插件异常仅影响自身,主应用继续运行。 + +### 13.6 main.js 接口约定(MVP) +- 导出对象: + - `onload(ctx)`:初始化插件。 + - `onunload()`:释放资源。 +- `ctx` 结构: + - `ctx.app`: 主应用只读元信息(版本、平台)。 + - `ctx.commands.register(command)`:注册命令。 + - `ctx.session.on(event, handler)`:订阅会话事件。 + - `ctx.session.send(input)`:发送终端输入(需 `session.write` 权限)。 + - `ctx.storage.get/set`:插件私有 KV 存储(命名空间隔离)。 + - `ctx.ui.showNotice(message, level)`:统一通知接口。 + - `ctx.logger.info/warn/error`:插件日志。 +- 命令对象建议字段: + - `id`、`title`、`handler`、`when`(可见性条件,可选)。 + +### 13.7 权限模型(首版) +- 权限白名单: + - `commands.register` + - `session.read` + - `session.write` + - `ui.notice` + - `ui.statusbar` + - `storage.read` + - `storage.write` + - `logs.read` +- 权限原则: + - 最小权限默认拒绝。 + - 未声明权限时按无权限处理。 + - 插件启用时展示权限摘要。 + - 后续新增权限必须二次确认。 + +### 13.8 沙箱与安全策略 +- 运行隔离: + - 插件不能直接拿到 `window`、`document`、`localStorage` 原始对象。 + - 仅通过 `ctx` 暴露的受限 API 与主程序交互。 +- 执行控制: + - `onload`/事件回调设置超时阈值,超时自动熔断并禁用插件。 + - 连续异常(例如 3 次)自动转 `failed`,需要用户手动恢复。 +- 数据安全: + - 明确禁止插件读取凭据明文。 + - 插件日志默认脱敏(host、token、私钥路径)。 +- 样式安全: + - `styles.css` 注入时添加插件命名空间根节点,避免全局污染。 + - 禁止覆盖全局 reset(如 `* {}`、`body {}`)或在校验时告警。 + +### 13.9 插件管理页(设置 -> 插件) +- 列表字段: + - 名称、版本、作者、状态、权限摘要、最后错误时间。 +- 操作项: + - 启用、禁用、重载、查看日志、打开目录。 +- 安装方式(MVP): + - 手动放入 `~/.remoteconn/plugins` 后点击“刷新插件列表”。 +- 失败可观测性: + - 展示加载阶段(解析失败/权限拒绝/运行异常)和错误栈摘要。 + +### 13.10 跨端路径映射策略 +- Desktop(macOS/Linux):直接使用 `~/.remoteconn`。 +- iOS Hybrid:映射到应用沙箱目录(如 `Library/Application Support/remoteconn`),在 UI 中仍展示逻辑路径 `~/.remoteconn`。 +- Web:浏览器无法直接访问用户主目录;采用“导入插件包(zip)/导出插件包”方案并存储在 IndexedDB。 +- 统一抽象: + - 引入 `PluginFsAdapter`,屏蔽不同平台的文件系统差异。 + +### 13.11 数据模型草案 +- `PluginManifest`: + - `id`, `name`, `version`, `minAppVersion`, `permissions[]`, `entry`, `style`, `author?`, `description` +- `PluginRecord`: + - `id`, `enabled`, `installedAt`, `updatedAt`, `lastError`, `lastLoadedAt` +- `PluginRuntimeState`: + - `id`, `status`, `loadDurationMs`, `errorCount`, `lastHeartbeatAt` + +### 13.12 测试与验收 +- 单元测试: + - manifest 校验、权限校验、状态机转换、API 权限拦截。 +- 集成测试: + - 插件加载/卸载、命令注册、样式注入与清理、异常隔离。 +- 端到端测试: + - 放置插件 -> 启用 -> 执行命令 -> 卸载 -> 重载,核心链路可复现。 +- DoD: + - 非法插件不会进入 active。 + - 插件异常不影响 SSH 会话。 + - 权限未授权时敏感 API 被拒绝且有明确提示。 + +### 13.13 迭代计划(插件专项) +- P1(2-3 天):目录扫描、manifest 校验、插件列表页(只读)。 +- P2(2-3 天):`main.js` 生命周期 + 命令 API + 样式注入。 +- P3(2-3 天):权限模型 + 插件存储 + 错误熔断。 +- P4(2-3 天):跨端 `PluginFsAdapter` + 导入导出 + 完整测试。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..6c7c17e --- /dev/null +++ b/README.md @@ -0,0 +1,251 @@ +RemoteConn / AI矩连 + +

+ English | 中文 +

+ +
+ +# RemoteConn + +RemoteConn is a production-oriented remote access workspace built around `shell + chat`. It targets Web, mobile app, and Mini Program entry points, so AI-assisted server work can start from a phone and stay attached to the right project context. + +The project focuses on three practical problems: + +1. Keep an AI-capable shell available on mobile, not only on desktop IDEs. +2. Use voice input to reduce typing cost on phones. +3. Preserve task context, records, and server bindings so long-running work can continue without handoff friction. + +

+ WeChat Standard Green Version +

+ +## Monorepo Structure + +- `apps/web`: Vue 3 + TypeScript + xterm.js frontend +- `apps/gateway`: Node.js gateway for `WSS -> SSH` +- `packages/shared`: shared models, state machines, Codex orchestration, theme and security helpers +- `packages/plugin-runtime`: plugin runtime with validation, permission control, lifecycle hooks, isolation, and circuit breaking +- `ios/plugin/RemoteConnSSHPlugin`: Capacitor iOS SSH plugin skeleton + +## Implemented Capabilities + +1. Server and credential management with restricted local storage +2. Terminal session state machine for connect, reconnect, auth pending, and error states +3. Bidirectional gateway forwarding for `init/stdin/stdout/stderr/resize/control` +4. Codex launch orchestration with remote environment checks +5. Session logging and redacted export +6. Theme system with contrast tuning +7. Plugin runtime foundation + +## Latest Status + +Current README baseline: `v3.0.1` on `2026-03-20`. + +Recent milestones: + +- `v3.0.1`: Mini Program manual was expanded with image-based guidance, theme presets were aligned with Web to `21` sets, and the project docs were synchronized. +- `v3.0.0`: Terminal interaction stability was improved during Codex output, including caret stability and keyboard-focus handling. +- Cross-device sync currently covers `settings`, `servers`, and `records`. Sensitive server fields remain encrypted as `secret_blob`. + +Known limitations still tracked in the project docs: + +- Some Mini Program terminal performance issues can still appear during long AI output sessions. +- Voice playback extraction and turn detection are not yet considered stable enough for default use. + +For the full Chinese product narrative and detailed historical notes, see [README_cn.md](./README_cn.md), `CHANGELOG.md`, and `release.md`. + +## Operation Guide + +This guide reuses the five annotated screenshots from the project introduction and keeps the same visual grouping so the main workflow can be understood directly from the README. + +For the narrow vertical mobile layout version, see [Mobile Operation Guide](./docs/mobile-operation-guide-2026-03-20.md). + +### 1. Server List And Configuration + +![Server List And Configuration](./snapshots/guide-group-01-servers-config-annotated.png) + +1. The home page opens on the server list, which is the starting point of the product. +2. The top toolbar is used to add, delete, select, and search servers. +3. The main body of each server card opens the configuration page; the quick actions on the right can duplicate a profile, launch AI, or connect directly. +4. The bottom navigation switches between terminal, logs, records, settings, and about pages. +5. The configuration page is where you fill in the basic connection fields, authentication method, and project directory. +6. `AI Working Directory` defines the default project context for AI startup and is a key part of the collaboration flow. +7. If the target host is reached through a jump host, complete the jump-host fields and its separate authentication settings on the same page. +8. The recommended order is to save the profile first and then start the connection. + +### 2. Terminal, AI, And Connection Diagnostics + +![Terminal, AI, And Connection Diagnostics](./snapshots/guide-group-02-terminal-status-annotated.png) + +1. After entering the terminal, you can run shell commands directly or switch to an AI workflow such as `Codex` from the top-left entry. +2. The top status bar continuously shows connection state, latency, and session overview, making it the primary feedback area for mobile troubleshooting. +3. Tapping `Connected` opens the session detail panel with the target host, connection state, and working directory. +4. Tapping `Latency` opens the diagnostics panel so you can inspect gateway response and network latency trends and judge link quality. + +### 3. Voice Input And Shortcut Panel + +![Voice Input And Shortcut Panel](./snapshots/guide-group-03-voice-shortcuts-annotated.png) + +1. The `voice` button at the bottom of the terminal page opens the voice input panel, supporting a "speak first, decide later" input flow. +2. The voice draft can be sent to the terminal directly or saved as a record together with category tags. +3. The `keyboard` button at the bottom opens the shortcut panel to restore terminal control keys that are missing on mobile. +4. The right-side shortcut panel includes arrow keys, `Esc`, `Ctrl+C`, `Tab`, and other high-frequency controls to reduce input friction on phones. + +### 4. Settings Center + +![Settings Center](./snapshots/guide-group-04-settings-annotated.png) + +1. The settings page is grouped into `UI / Terminal / Connection / Records`, covering appearance, terminal behavior, connection policy, and data management. +2. The UI tab controls theme, colors, and language, which shape the overall experience. +3. The terminal tab controls font family, font size, line height, and terminal palette, directly affecting readability and typing efficiency. +4. The connection tab controls default authentication, reconnect strategy, background keepalive, sync switches, and AI defaults. +5. The records tab controls record retention and category management so the information lifecycle stays structured. + +### 5. Records And About + +![Records And About](./snapshots/guide-group-05-records-about-annotated.png) + +1. The records page is used to capture issues, ideas, TODOs, and context fragments. +2. The top area supports search and category filtering so content can be found quickly. +3. Record cards support actions such as copy, mark, and delete. +4. The bottom area provides pagination, add, and export operations. +5. The about page centralizes the brand, version, and product information entry points. +6. It collects the user guide, feedback channel, privacy policy, change log, and about content in one place. +7. Its role is to act as the final landing page for product documentation inside the app, so users can find a consistent explanation without leaving the product. + +## Quick Start + +```bash +npm install +npm run dev:gateway +npm run dev:web +``` + +Default endpoints: + +- Web: `http://localhost:5173` +- Gateway: `http://localhost:8787` +- Gateway WS: `ws://localhost:8787/ws/terminal` + +## Environment Variables + +### Gateway + +```bash +cp apps/gateway/.env.example apps/gateway/.env +``` + +Recommended runtime config: + +```dotenv +PORT=8787 +HOST=0.0.0.0 +GATEWAY_TOKEN=replace-with-strong-random-token +CORS_ORIGIN=https://your-domain.com +DEBUG_IO_HEX=0 +ASR_INCLUDE_RAW_RESULT=0 +ASR_EMPTY_TEXT_WARN_LIMIT=3 +``` + +### Web + +```bash +cp apps/web/.env.example apps/web/.env +``` + +Build-time config: + +```dotenv +VITE_GATEWAY_URL=wss://your-domain.com +VITE_GATEWAY_TOKEN=replace-with-strong-random-token +VITE_ENABLE_PLUGIN_RUNTIME=false +``` + +Notes: + +- `VITE_GATEWAY_TOKEN` must match `GATEWAY_TOKEN`. +- `VITE_ENABLE_PLUGIN_RUNTIME=false` is the recommended initial production posture if plugin support is not needed yet. +- If you expose the service through Nginx or Caddy on `443/80`, the internal gateway can still listen on `8787`. + +## Quality Commands + +```bash +npm run typecheck +npm run lint +npm run test +``` + +## Gateway Operations + +Unified script: `scripts/gatewayctl.sh` + +```bash +# First-time deployment +scripts/gatewayctl.sh ensure-env +scripts/gatewayctl.sh install-service $USER +scripts/gatewayctl.sh deploy + +# Daily operations +scripts/gatewayctl.sh status +scripts/gatewayctl.sh logs 200 +scripts/gatewayctl.sh logs-clear +scripts/gatewayctl.sh restart +scripts/gatewayctl.sh health + +# Foreground local run +scripts/gatewayctl.sh run-local +``` + +- Linux uses `systemd`. +- macOS uses `launchd` at `~/Library/LaunchAgents/com.remoteconn.gateway.plist`. + +## Logs + +```shell +launchctl print gui/$(id -u)/com.remoteconn.gateway | rg -n "stdout path|stderr path" +tail -f /Users/gavin/Library/Logs/com.remoteconn.gateway.out.log +``` + +## Benchmarks + +Gateway SSH2 baseline: + +```bash +npm --workspace @remoteconn/gateway run bench +``` + +Example with explicit parameters: + +```bash +BENCH_SSH_HOST=127.0.0.1 \ +BENCH_SSH_PORT=22 \ +BENCH_SSH_USER="$USER" \ +BENCH_PRIVATE_KEY=~/.ssh/id_ed25519 \ +BENCH_ITERATIONS=30 \ +BENCH_PAYLOAD_KB=1024 \ +npm --workspace @remoteconn/gateway run bench +``` + +TTYD baseline: + +```bash +ttyd -v + +BENCH_SSH_HOST=127.0.0.1 \ +BENCH_SSH_PORT=22 \ +BENCH_SSH_USER="$USER" \ +BENCH_SSH_IDENTITY=~/.ssh/id_ed25519 \ +BENCH_ITERATIONS=30 \ +BENCH_PAYLOAD_KB=1024 \ +npm --workspace @remoteconn/gateway run bench:ttyd +``` + +## Related Documents + +- `docs/server-management-ux-iteration-2026-02-25.md` +- `docs/terminal-voice-input-plan-2026-02-26.md` +- `docs/web-storage-layering-guideline-2026-02-25.md` +- `docs/touch-focus-state-machine-plan-2026-02-23.md` +- `CHANGELOG.md` +- `release.md` diff --git a/README_cn.md b/README_cn.md new file mode 100644 index 0000000..2d35354 --- /dev/null +++ b/README_cn.md @@ -0,0 +1,385 @@ +RemoteConn / AI矩连 + +

+ English | 中文 +

+ +
+ +# RemoteConn - AI矩连 + +为什么做这个工具: + +1. **缘起**:下载了几个收费的终端shell工具,交互效果不理想,比如多语种(中文)的适配。 +2. 有了AI,未来的确保留一个shell入口就可以了,原来无比重要的IDE可以扔进垃圾桶。原来觉得chatGPT这个名字很Low,现在看来很有前瞻性,chat很准确。 +3. 有了AI,屏幕大小没有那么重要,因为很多时候并不需要手动与PC交互coding,尤其是做手机端应用,很多任务可以在手机侧完成。这样,手机侧入口与AI的交互性就变得很重要。也就是解决把任务送给AI的问题。这样AI接到任务 - 完成任务 - 手机上验证就闭环了。 + +=> 所以这个工具的入口就是shell+chat。 + +4. 语音大模型已经很准确了,借助语音识别解决手机键入效率低问题。任务可以喊话完成。 +5. 一个任务往往是反复迭代的过程,因为上下文的关系,这人任务最好持续,否则要写交接文档。但是,真实的情况是,在解决一个问题的过程中,或者调试过程中,会有新的想法、思路、需求,我称之为闪念,怎么办?语音记录闪念目的是解决这个问题。因为闪念是与当前任务的平行事件,这个闪念可以暂时不必理会。闪念可以随时送回AI处理。这个效率非常高。 +6. 因为需要管理很多服务器,所以有个服务器管理入口,项目是服务器的一个工作目录,AI都在这个目录下工作,闪念也与当前服务器的工作目录关联,这样在任务回送的时候会找到算力和迭代项目。 +7. 如何登陆服务器?不同类型的终端和入口,比如web/APP/小程序,没有统一的原生shell与服务器交互,所以需要一个gateway。目前gateway接txt和voice,分别路由。不同的应用类型,APP/小程序/WEB只要写前端,统一一套gateway规范即可。 +8. 必须好用便捷,shell和用户界面使用两套主题。shell等所有元素使用主题配色。 +9. 可扩展插件,这个目前占位,没有实现。思路来自重度使用obsidian,良好的plugin体验,可以方便的扩展前端能力。但这波openclaw热,觉得不能做前端插件,应该做个skills插件,做热插拔skills插件。 +10. shell比较难搞,web端直接使用xterm。小程序端没有现成的,在textarea基础上做了一个shell,很难搞,一堆效率难用问题,不过目前效果很好,与codex交互还是比较丝滑,与PC端使用codex CLI体验基本拉齐。 + +

+ 微信标准绿版 +

+ +--- + +基于 `PLAN.md` 技术栈的多包工程: + +- `apps/web`:Vue3 + TypeScript + xterm.js(含 fit/webgl/search/unicode11) +- `apps/gateway`:Node.js 网关(WSS -> SSH) +- `packages/shared`:共享模型、状态机、Codex 编排、主题与安全工具 +- `packages/plugin-runtime`:插件系统运行时(校验/权限/生命周期/熔断) +- `ios/plugin/RemoteConnSSHPlugin`:iOS Capacitor 原生插件骨架 + +## 已实现能力(对齐 PLAN 核心链路) + +1. 服务器管理与凭据管理(Web IndexedDB + AES-GCM 受限存储) +2. 终端会话状态机(connecting/auth_pending/connected/reconnecting/disconnected/error) +3. 网关双向转发(init/stdin/stdout/stderr/resize/control) +4. Codex 模式编排(`cd` -> `command -v codex` -> `codex --sandbox`) +5. 会话日志记录与脱敏导出 +6. 主题系统与对比度优化 +7. 插件系统(manifest 校验、权限白名单、onload/onunload、命令注册、隔离与熔断) + +## 近期更新(v3.0.1,2026-03-20) + +- 2026-03-20 `v3.0.1` 使用手册与主题口径同步: + - 小程序“使用手册”补成图文版,正文前置“为什么需要这个APP?”,并接入 `guide-mobile-*` 配图; + - 小程序主题预设补齐到与 Web 一致的 `21` 套,主题名称支持随语言切换,并尽量收口为单词短名; + - Root README、Changelog、Release、小程序 README、项目介绍、相关方案文档与品牌模板统一升级到 `v3.0.1`; + - 当前版本不引入新的同步协议、终端协议或配置字段,继续沿用 `v3.0.0` 已明确的能力边界。 + +- 2026-03-18 `v3.0.0` 终端交互稳定性与文档口径同步: + - 小程序终端在codex交互过程中的性能优化,继续优化卡顿不响应问题。 + - 小程序终端新增 caret 稳定窗口,收敛高频 `stdout` 期间底部光标在后几行行尾来回跳的问题; + - shell 激活区在软键盘仍可见时会保护被动 `blur`,避免豆包输入法长按语音输入过程中被页面侧提前打断; + - 当前仍保留一部分性能遗留问题,终端 perf 日志默认改为通过开关关闭,调试时再临时打开; + - Root README、Changelog、Release、小程序 README、关于页、多语言文案、小程序工程描述与当前基线文档统一升级到 `v3.0.0`。 + +- 2026-03-13 `v2.9.6` 文档与对外版本口径同步: + - Root README、Changelog、Release、小程序 README、关于页、多语言文案、项目描述、小程序工程描述与当前基线文档统一升级到 `v2.9.6`; + - 当前版本不引入新的功能、协议行为或交互变更,继续沿用 `v2.9.5` 已完成的现有终端、同步、时延诊断与语音播报边界口径。 + +- 2026-03-13 `v2.9.5` 文档与遗留问题口径同步: + - Root README、Changelog、Release、小程序 README、关于页、多语言文案、项目描述、小程序工程描述与当前基线文档统一升级到 `v2.9.5`; + - 新登记小程序终端语音播报遗留问题:当前播报文本提取与轮次稳定判定仍不够准确,长时间 `Codex` 交互时还会放大小程序客户端响应压力,现阶段暂不建议默认使用。 + +- 2026-03-12 `v2.9.4` 文档与版本口径同步: + - Root README、Changelog、Release、小程序 README、关于页、多语言文案、项目描述、问题闭环记录与当前基线文档统一升级到 `v2.9.4`; + - 当前版本不引入额外功能变更,继续沿用 `v2.9.3` 已完成的时延诊断面板与主题对比度收口结果。 + +- 2026-03-11 `v2.9.3` 小程序时延诊断面板与主题对比度收口: + - 网关响应与网络时延两张折线图合并为一张双轴平滑曲线图,左轴保留网关响应量纲,右轴保留网络时延量纲; + - 原“诊断信息”文字卡已移除,顶部改为两张两行摘要卡;同一服务器断开重连后会优先延续已有采样,尽量补足最近 30 个点; + - 时延面板配色改为跟随终端主题的反相面板,深色终端额外切换一套更深的蓝橙曲线与指标色,保证浅底上的文字和曲线可读性; + - Root README、Changelog、Release、小程序 README、关于页、问题闭环记录与当前基线文档统一升级到 `v2.9.3`。 + +- 2026-03-11 `v2.9.1` 小程序续接恢复、同步刷新与隐私说明同步: + - 小程序终端会话续接首次恢复改为以 `lines + bufferCols / bufferRows` 为准,避免返回服务器列表再进入时出现历史区顶部空白与裸露 `5;2H`; + - 启动阶段 `bootstrap` 合并配置完成后,首页服务器列表与底栏会自动刷新,不再需要重新进入页面才能看到同步后的配置; + - 小程序用户隐私政策与 about 隐私页已按最新审核口径同步,补齐录音用途、处理范围与用户权利说明; + - Root README、Changelog、Release、小程序 README、关于页、问题闭环记录与当前基线文档统一升级到 `v2.9.1`。 + +- 2026-03-11 `v2.9.0` 小程序 Codex 交互区缺行与闪动修复: + - 小程序终端已修复 `Codex` 持续输出期间底部提示块缺行、状态行被裁掉与区域反复闪动的问题; + - normal buffer viewport 现在会保留光标行之后仍真实存在的 footer,`CSI ? 2026 h/l` 同步刷新窗口也已做兼容收口; + - 统一高亮背景行改为优先在 line 层绘制,`> Use /skills to list available skills` 与代码块这类高亮区域不再透出行间底色细线; + - 新增真实 PTY 抓包回放与对应回归测试,锁住这类交互问题; + - Root README、Changelog、Release、小程序 README、关于页、问题闭环记录与当前基线文档统一升级到 `v2.9.0`。 + +- 2026-03-10 `v2.8.2` 连接态强调与 about 页面主题统一: + - 小程序服务器列表里的 `connect` 按钮,以及底栏里的 `shell` 按钮,在存在活动连接时统一改为高饱和实底高亮,不再依赖描边反馈; + - 连接态 SVG 前景色改为运行时跟随界面前景色,避免落在高亮底色上后继续发灰; + - about 首页、详情页与 about-app 改为统一跟随界面配置出色,不再单独维护一套固定浅色主题;Web about 页同步采用同一套主题变量策略; + - Root README、Changelog、Release、小程序 README、关于页、项目描述与当前基线文档统一升级到 `v2.8.2`。 + +- 2026-03-10 `v2.8.1` 终端语音区与发布口径收口: + - 小程序终端语音区展开按钮默认改为全透明,仅保留 SVG 本体颜色; + - 分类胶囊改为贴文字高度,选中态背景切到更明显的实色; + - 录音中的输入框上方新增更显眼的双环脉冲提示,文案更新为“正在收音,松开后发送或记录闪念”; + - `OSC 10 / 11 / 12` 颜色查询已返回真实 shell 主题色,擦除空白位与备用屏空白屏统一继承当前擦除背景; + - 此前点击 Codex 连接选项后的首回显迟滞与等待期间按钮阻塞问题已收敛,不再列为当前遗留问题; + - 新登记一个低频连接时序遗留问题:偶发新连接后首屏只显示光标、提示符稍后才出现; + - Root README、Changelog、Release、小程序 README、关于页与当前基线文档统一升级到 `v2.8.1`。 + +- 2026-03-10 `v2.7.1` 文档与版本口径收口: + - 明确当前跨设备同步仍只覆盖 `settings / servers / records`,其中服务器敏感字段以加密后的 `secret_blob` 保存; + - `logs`、插件运行时日志与终端会话缓冲继续保留在本地,不纳入第一阶段同步; + - 补充 `remoteconn-sync.db-wal` 属于 SQLite WAL 写前日志说明,其文件体积不直接等于当前有效同步数据量; + - 当前小程序终端仍存在明显交互卡顿:点击 Codex 连接选项后约 10 秒才出现回显,等待期间除上下滑动外其余按钮基本阻塞,先作为当前遗留问题登记; + - 根 README、小程序 README、Changelog、Release、同步方案文档与关于页版本展示统一更新到 `v2.7.1`。 + +- 2026-03-13 小程序终端语音播报遗留问题登记: + - 当前播报文本提取与轮次稳定判定仍不够准确,播报内容存在偏差; + - 长时间 `Codex` 交互时,语音播报链路会额外放大小程序客户端响应压力; + - 现阶段暂不建议默认使用,先列为待优化遗留问题,后续继续收口文本提取、触发时机与性能隔离。 + +- 2026-03-19 小程序终端 AI 交互期输入跳跃问题登记: + - 在 `Codex` 等 AI 持续输出期间,点击 shell 激活区弹出软键盘后,输入态仍可能出现输入框/激活区跳跃,导致无法稳定连续输入; + - 当前仅确认问题与 AI 持续输出期间的终端刷新链路有关,尚未完成稳定修复; + - 先按已知遗留问题登记,后续继续抓取输入聚焦、键盘高度变化、viewport 滚动与 stdout 刷新之间的时序关系。 + +- 2026-03-09 小程序配置跨设备同步第一阶段落地: + - `settings / servers / records` 改为“本地 storage + Gateway + SQLite”双层持久化; + - 启动时通过 `bootstrap` 拉取并合并远端数据,日常修改按对象异步推送; + - 服务器和闪念删除支持 `tombstone` 合并,新设备可恢复同账号下的设置、服务器配置与闪念记录; + - SSH 密码、私钥、口令与证书等受保护字段不以明文保存,服务端改为加密后的 `secret_blob` 存储。 + +- 2026-03-09 同步边界与验证口径同步明确: + - 终端会话缓冲仍保留本地,不纳入第一阶段同步; + - 通过 `npm run mini` 生成的 preview 预览二维码,不作为正式版跨设备同步与本地缓存连续性的验证依据。 + +- 2026-03-08 小程序终端继续推进 VT 最小可用能力: + - 双缓冲与备用屏幕切换已进入当前基线,`47 / 1047 / 1048 / 1049`、`DSR / CPR / DA1 / DA2 / DECSTR`、基础局部重绘与输入编码已落地; + - normal buffer 的 live tail 与最大滚动值改为同源,历史回到底部后不再继续把当前命令行往上推; + - 当前 P0 主链路已基本打通,保留为 `v2.7.0` 基线继续迭代 parser 完整度、P1 边界精修与高级交互能力。 + +- 2026-03-08 小程序终端字号问题按“遗留问题”暂存: + - 当前“修改字号后偶发吃字/显示不完整”仍未彻底解决,暂不继续扩大修改范围; + - 设置页在“字号”下新增提示“修改字号后建议断开重连”,作为当前版本的临时规避方案; + - 终端排查期间新增的 `terminal.wrap` 调试输出已移除,保留当前较稳定实现作为 `v2.6.5` 基线。 + +- 2026-03-07 小程序终端完成一轮底层重构: + - 连接前先按真实终端几何计算 `cols/rows`,不再固定 `80x24`; + - 输出缓冲区、光标推进、换行判断统一按 xterm 风格的 cell 模型执行; + - 中文宽字符 continuation 不再丢失到自然文本流,改为固定列宽渲染; + - 可见光标改为终端自绘,原生 `input` 仅保留键盘代理职责; + - 英文右侧 padding、中文输入尾部空白增长、第二行回跳第一行等问题已收敛。 +- 2026-03-07 小程序连接链路补齐后台续接: + - 终端页返回其他页面后,会话默认继续保活 `15` 分钟,支持在设置页改为 `1~60` 分钟; + - 返回服务器列表后再次进入,会优先复用原会话并恢复终端尾部缓冲,避免只有光标没有 prompt; + - 服务器列表中的“连接”按钮在连接态保持亮色,并增加外圈描边,减少状态误判。 +- 2026-03-07 Web 与小程序补齐 SSH 跳转主机链路: + - 服务器配置页新增“跳转主机”配置,支持主机、端口、用户名、认证方式与独立凭据; + - 网关支持“第一跳 SSH + 第二跳 SSH(经 direct-tcpip 转发)”链路; + - 基础信息服务器与跳转主机的主机指纹改为分别上报与校验。 +- 2026-03-07 小程序补齐 AI 快速启动链路: + - 服务器列表 `AI` 按钮可直接进入终端,并自动打开 AI 启动面板; + - 终端页支持先确保会话连接,再执行 Codex 预检与启动; + - 项目目录不存在、服务器未安装 `codex` 等失败场景改为统一前置提示,不再依赖用户手动排查。 +- 小程序版本完成较大能力对齐: + - 补齐 `connect / server settings / terminal / logs / records / settings / plugins` 主页面链路; + - 闪念支持分类、搜索、过滤、编辑、快速改分类与导出; + - 语音输入支持分类写入、未连接可记录闪念、写入 `服务器名称-项目名` 上下文快照; + - `记录 -> 闪念分类` 管理支持新增、设默认、删除、按住拖动排序; + - 服务器配置支持自定义标签,服务器卡片底部展示项目标签与自定义标签。 +- Web 端继续做小幅精修: + - 配置页控件密度、卡片层级和记录页交互细节继续收敛; + - 闪念卡片滑动操作、快速改分类弹层、编辑弹框与分类视觉语义完成多轮细调; + - 终端输入、语音与记录联动补齐边界行为。 +- 文档基线统一升级到 `v2.6.0`: + - 根 README、发布说明、小程序 README、对齐审计、配置实现方案、闪念实现基线和 parity 机读清单统一更新; + - 新增 SSH 跳转链路加密分层图,作为跳板机能力说明; + - 原 `v2.4.0` 说明已并入当前 `v2.6.0`,统一作为当前“Web + 小程序”共同对外口径。 + +## 操作说明 + +这一节复用项目介绍中已经标注好的五张截图,按同样的视觉分组说明主流程,方便直接在 README 里理解整体使用路径。 + +如果需要更适合手机竖屏阅读的版本,见 [移动端操作说明](./docs/mobile-operation-guide-2026-03-20.md)。 + +### 1. 服务器列表与配置 + +![服务器列表与配置](./snapshots/guide-group-01-servers-config-annotated.png) + +1. 首页默认打开服务器列表,这里是整个产品的主入口。 +2. 顶部工具栏用于新增、删除、选择和搜索服务器。 +3. 服务器卡片主体进入配置页,右侧快捷操作可用于复制配置、启动 AI 或直接连接。 +4. 底部导航用于切换终端、日志、记录、设置和关于页面。 +5. 配置页用于填写基础连接字段、认证方式和项目目录。 +6. `AI Working Directory` 用于定义 AI 启动时默认进入的项目上下文,是协作链路里的关键字段。 +7. 如果目标主机需要经过跳板机访问,可以在同页补全跳板机字段和它自己的认证信息。 +8. 推荐顺序是先保存服务器配置,再发起连接。 + +### 2. 终端、AI 与连接诊断 + +![终端、AI 与连接诊断](./snapshots/guide-group-02-terminal-status-annotated.png) + +1. 进入终端后可以直接执行 shell 命令,也可以从左上角切换到 `Codex` 等 AI 工作流。 +2. 顶部状态栏会持续显示连接状态、时延和会话概览,是移动端排障的主反馈区。 +3. 点击 `Connected` 会打开会话详情面板,查看当前目标主机、连接状态和工作目录。 +4. 点击 `Latency` 会打开诊断面板,查看网关响应和网络时延走势,用于判断链路质量。 + +### 3. 语音输入与快捷面板 + +![语音输入与快捷面板](./snapshots/guide-group-03-voice-shortcuts-annotated.png) + +1. 终端页底部的 `voice` 按钮会打开语音输入面板,支持“先说出来,再决定怎么处理”的输入方式。 +2. 语音草稿可以直接发送到终端,也可以带上分类标签保存为记录。 +3. 底部的 `keyboard` 按钮会打开快捷键面板,补足手机上缺失的终端控制键。 +4. 右侧快捷面板提供方向键、`Esc`、`Ctrl+C`、`Tab` 等高频控制,降低手机输入成本。 + +### 4. 设置中心 + +![设置中心](./snapshots/guide-group-04-settings-annotated.png) + +1. 设置页分为 `UI / Terminal / Connection / Records` 四个分组,分别覆盖外观、终端行为、连接策略和数据管理。 +2. UI 页签负责主题、颜色和语言,决定整体视觉体验。 +3. Terminal 页签负责字体、字号、行高和终端配色,直接影响可读性和输入效率。 +4. Connection 页签负责默认认证、重连策略、后台保活、同步开关和 AI 默认项。 +5. Records 页签负责记录保留策略和分类管理,保持信息生命周期有序。 + +### 5. 记录与关于 + +![记录与关于](./snapshots/guide-group-05-records-about-annotated.png) + +1. 记录页用于保存问题、想法、TODO 和上下文碎片。 +2. 顶部区域支持搜索和分类过滤,方便快速定位内容。 +3. 记录卡片支持复制、标记和删除等操作。 +4. 底部区域提供分页、新增和导出入口。 +5. 关于页集中提供品牌、版本和产品信息入口。 +6. 这里会统一收纳使用手册、反馈渠道、隐私政策、变更日志和关于内容。 +7. 它的角色是产品内文档的最终落点,让用户不离开产品也能找到一致的说明。 + +## 快速启动 + +```bash +npm install +npm run dev:gateway +npm run dev:web +``` + +默认地址: + +- Web: `http://localhost:5173` +- Gateway: `http://localhost:8787` +- Gateway WS: `ws://localhost:8787/ws/terminal` + +## 环境变量 + +### Gateway + +复制 `apps/gateway/.env.example`: + +```bash +cp apps/gateway/.env.example apps/gateway/.env +``` + +### Web + +复制 `apps/web/.env.example`: + +```bash +cp apps/web/.env.example apps/web/.env +``` + +### 生产环境建议配置 + +> 注意:前端 `VITE_*` 是构建时注入;`apps/web/.env` 需要在构建前准备好。 + +`apps/gateway/.env`(网关进程运行时读取): + +```dotenv +PORT=8787 +HOST=0.0.0.0 +GATEWAY_TOKEN=replace-with-strong-random-token +CORS_ORIGIN=https://your-domain.com +DEBUG_IO_HEX=0 +ASR_INCLUDE_RAW_RESULT=0 +ASR_EMPTY_TEXT_WARN_LIMIT=3 +``` + +`apps/web/.env`(Web 构建时读取): + +```dotenv +VITE_GATEWAY_URL=wss://your-domain.com +VITE_GATEWAY_TOKEN=replace-with-strong-random-token +VITE_ENABLE_PLUGIN_RUNTIME=false +``` + +说明: + +- `VITE_GATEWAY_TOKEN` 必须与 `GATEWAY_TOKEN` 一致。 +- `VITE_ENABLE_PLUGIN_RUNTIME=false` 可在生产中先关闭插件能力。 +- 若使用反向代理(Nginx/Caddy)对外提供 `443/80`,网关内部仍可监听 `8787`。 + +## 质量命令 + +```bash +npm run typecheck +npm run lint +npm run test +``` + +## 非 Docker 运维(简化网关维护) + +统一脚本:`scripts/gatewayctl.sh` + +```bash +# 1) 首次部署(Linux + systemd / macOS + launchd) +scripts/gatewayctl.sh ensure-env +scripts/gatewayctl.sh install-service $USER +scripts/gatewayctl.sh deploy + +# 2) 日常运维 +scripts/gatewayctl.sh status +scripts/gatewayctl.sh logs 200 +scripts/gatewayctl.sh logs-clear +scripts/gatewayctl.sh restart +scripts/gatewayctl.sh health + +# 3) 本地前台运行(不依赖 systemd) +scripts/gatewayctl.sh run-local +``` + +说明: + +- Linux 上脚本自动使用 `systemd`。 +- macOS 上脚本自动使用 `launchd`(`~/Library/LaunchAgents/com.remoteconn.gateway.plist`)。 + +## 日志 + +```shell +gavin mini remoteconn % launchctl print gui/$(id -u)/com.remoteconn.gateway | rg -n "stdout path|stderr path" +stdout path = /Users/gavin/Library/Logs/com.remoteconn.gateway.out.log +stderr path = /Users/gavin/Library/Logs/com.remoteconn.gateway.err.log +gavin mini remoteconn % tail -f /Users/gavin/Library/Logs/com.remoteconn.gateway.out.log +``` + +## 性能基线压测(Gateway SSH2) + +```bash +# 默认使用 localhost:22 + ~/.ssh/id_ed25519,并自动启动临时 gateway +npm --workspace @remoteconn/gateway run bench + +# 可选参数(示例) +BENCH_SSH_HOST=127.0.0.1 \ +BENCH_SSH_PORT=22 \ +BENCH_SSH_USER="$USER" \ +BENCH_PRIVATE_KEY=~/.ssh/id_ed25519 \ +BENCH_ITERATIONS=30 \ +BENCH_PAYLOAD_KB=1024 \ +npm --workspace @remoteconn/gateway run bench +``` + +## 性能基线压测(TTYD) + +```bash +# 先确保系统已安装 ttyd +ttyd -v + +# 使用 ttyd -> ssh 的链路进行压测 +BENCH_SSH_HOST=127.0.0.1 \ +BENCH_SSH_PORT=22 \ +BENCH_SSH_USER="$USER" \ +BENCH_SSH_IDENTITY=~/.ssh/id_ed25519 \ +BENCH_ITERATIONS=30 \ +BENCH_PAYLOAD_KB=1024 \ +npm --workspace @remoteconn/gateway run bench:ttyd +``` + +## 相关文档 + +- 当日服务器管理交互迭代:`docs/server-management-ux-iteration-2026-02-25.md` +- 当日语音输入迭代:`docs/terminal-voice-input-plan-2026-02-26.md` +- 存储分层规范:`docs/web-storage-layering-guideline-2026-02-25.md` +- 触摸焦点状态机:`docs/touch-focus-state-machine-plan-2026-02-23.md` +- 变更日志:`CHANGELOG.md` +- 发布说明:`release.md` diff --git a/TOP_LEVEL_CONSTRAINTS.md b/TOP_LEVEL_CONSTRAINTS.md new file mode 100644 index 0000000..c4d2862 --- /dev/null +++ b/TOP_LEVEL_CONSTRAINTS.md @@ -0,0 +1,52 @@ +# 移动端终端交互顶级约束 (Mobile Terminal Top-Level Constraints) + +本文档定义了 remoteconn 移动端终端界面的**“四大不可回归”顶级约束**。在未来任何针对 CSS、JS 逻辑、甚至 Xterm.js 版本升级的修改中,都必须且优先满足以下条件。 + +任何破坏以下任一体验的代码修改,都视为 **核心体验回归 (Critical Regression)**,禁止合入。 + +--- + +## 🛑 约束 1:正常的软键盘弹出与收回 (Keyboard Lifecycle) +用户的输入意图必须被绝对尊重,不能因为防阻断逻辑导致系统键盘失灵或无法正常收起。 + +- **约束标准**: + - 点击终端内最后一行空白处或已有文本,必须能够迅速唤起原生软键盘。 + - 在唤起状态下,可以通过 UI 按钮或符合常理的操作顺畅收起键盘,而不引起全屏闪烁或虚假重绘。 +- **QA 测试用例 (Test Case 1)**: + 1. 在手机浏览器中打开终端。 + 2. 短按屏幕输入区,**验证**:系统软键盘立刻出现。 + 3. 点击顶部工具栏的隐藏键盘按钮/或进行收起手势,**验证**:软键盘顺利降下,终端不触发无关的字符输入或选中。 + +## 🛑 约束 2:原生且丝滑的滚动 (Native-like Smooth Scroll) +无论渲染层多么复杂(海量 `span`/`div` 标签),滑动都必须 1:1 跟手,并保留操作系统级的惯性阻尼。 + +- **约束标准**: + - `touchmove` 事件绝不能由于 OS 原生手势侦测而在中途被无故截断(Frozen Screen)。 + - 松开手指后必须有自然的衰减惯性滚动。 +- **QA 测试用例 (Test Case 2)**: + 1. 打印一长串历史构建输出(如 `ls -la /` 或 `dmesg`)。 + 2. 手指在屏幕上连续、快速且不离开屏幕地上下来回拖拽。**验证**:无论速度多快,列表紧跟指尖,没有丝毫卡顿或断流。 + 3. 快速向上一划并立刻松手。**验证**:列表如原生 App 一样继续依靠惯性顺滑滚动,直至自然停止。 + +## 🛑 约束 3:完整的长按选中功能 (Long-Press Text Selection) +滚动优化和事件劫持不能杀灭系统底层的文本选取工作流。 + +- **约束标准**: + - 在触摸初始阶段 (`touchstart` / `pointerdown`) 绝对不滥用 `preventDefault` 导致 OS 选区定时器失效。 + - 一旦触发选区,一切系统原生拖拽行为享有最高优先级,防劫持和自定义滚动必须全面让路。 +- **QA 测试用例 (Test Case 3)**: + 1. 在终端中找到一段带颜色的日志文本。 + 2. 手指长按某个单词,**验证**:出现 iOS/Android 原生放大镜,并且选中该词(高亮或系统菜单出现)。 + 3. 拖拽选区两端的“小拉手”扩大选中范围,**验证**:选区顺利扩大,且此时不应该误触发页面的大幅度重新滚动。 + +## 🛑 约束 4:键盘唤起/收回期间光标与命令行的正确位置 (Viewport Pinning during Transitions) +当可视区域 (`Visual Viewport`) 受到物理屏幕与虚拟键盘挤压变化时,我们必须保证用户正在操作的焦点不丢失。 + +- **约束标准**: + - 软键盘弹出的瞬间,终端内部高度与排版自动重调,强制将**当前活动命令行 (光标所在行)** 顶出至可视范围内(浮于键盘正上方)。 + - 软键盘收回时,恢复之前的满铺视图高度,且当前输入行依然停留在舒适可见范围内,不会沉入看不见的“黑洞(屏幕下方)”。 +- **QA 测试用例 (Test Case 4)**: + 1. 屏幕充满日志内容后,最下面的一行是当前的 Shell 提示符 `$ `。 + 2. 短按唤起键盘,此时屏幕下半部分被键盘遮挡。 + 3. **验证**:整个终端内容自动缩紧或向上推移,最后那行 `$ ` 以及光标刚好贴在这个物理键盘的顶部,能清晰看见输入的内容。 + 4. 顺势收回键盘,**验证**:屏幕重新展平,最后一行依然保留在屏幕最下方,并未丢失位置。 diff --git a/apps/gateway/.env.example b/apps/gateway/.env.example new file mode 100644 index 0000000..e8d96cc --- /dev/null +++ b/apps/gateway/.env.example @@ -0,0 +1,44 @@ +PORT=8787 +HOST=0.0.0.0 +GATEWAY_TOKEN=remoteconn-dev-token +CORS_ORIGIN=* +DEBUG_IO_HEX=0 + +# 豆包 ASR(语音输入) +ASR_PROVIDER=volcengine +ASR_APP_ID= +ASR_ACCESS_TOKEN= +ASR_SECRET_KEY= +ASR_RESOURCE_ID=volc.seedasr.sauc.duration +# 可选:当前 v3/sauc/bigmodel_async 默认可不填 +ASR_CLUSTER= +ASR_WS_URL=wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async +# 生产建议 0:result 帧不携带上游原始 payload,降低下行带宽与前端解析开销 +ASR_INCLUDE_RAW_RESULT=0 +# 单连接内空文本告警上限,避免上游异常时日志风暴 +ASR_EMPTY_TEXT_WARN_LIMIT=3 + +# 小程序 Codex 语音播报(TTS) +# 如使用火山引擎 HTTP Chunked/SSE 单向流式 V3,请把 TTS_PROVIDER 改为 volcengine。 +# 注意:这里填写的是 Access Token,虽然请求头字段名仍叫 `X-Api-Access-Key`。 +# TTS_RESOURCE_ID 还需要和代码里映射的豆包 1.0 speaker 保持同代匹配。 +TTS_PROVIDER=tencent +TTS_APP_ID= +TTS_ACCESS_TOKEN= +TTS_SECRET_ID= +TTS_SECRET_KEY= +TTS_REGION=ap-guangzhou +TTS_CLUSTER=volcano_tts +TTS_RESOURCE_ID=volc.service_type.10029 +TTS_VOICE_DEFAULT=female_v1 +TTS_SPEED_DEFAULT=1 +TTS_TIMEOUT_MS=30000 +TTS_CACHE_FILE_MAX_BYTES=8388608 + +# 小程序配置同步(Gateway + SQLite) +MINIPROGRAM_APP_ID= +MINIPROGRAM_APP_SECRET= +SYNC_SQLITE_PATH=data/remoteconn-sync.db +SYNC_SECRET_CURRENT= +SYNC_SECRET_VERSION=1 +SYNC_TOKEN_TTL_SEC=604800 diff --git a/apps/gateway/config/runtime.json b/apps/gateway/config/runtime.json new file mode 100644 index 0000000..c96a057 --- /dev/null +++ b/apps/gateway/config/runtime.json @@ -0,0 +1,18 @@ +{ + "GATEWAY_PORT": 8787, + "GATEWAY_HOST": "0.0.0.0", + "GATEWAY_CORS_ORIGIN": "*", + "GATEWAY_LOG_LEVEL": "info", + "GATEWAY_DEBUG_IO_HEX": 0, + "RATE_LIMIT_POINTS": 30, + "RATE_LIMIT_DURATION_SEC": 60, + "ASSIST_TXN_TTL_MS": 30000, + "ASSIST_TXN_CACHE_LIMIT": 512, + "SSH_READY_TIMEOUT_MS": 15000, + "SSH_KEEPALIVE_INTERVAL_MS": 10000, + "SSH_KEEPALIVE_COUNT_MAX": 3, + "TERMINAL_RESUME_GRACE_DEFAULT_MS": 20000, + "TERMINAL_RESUME_GRACE_MAX_MS": 3600000, + "PLUGIN_ONLOAD_TIMEOUT_MS": 3000, + "PLUGIN_ONUNLOAD_TIMEOUT_MS": 3000 +} diff --git a/apps/gateway/package.json b/apps/gateway/package.json new file mode 100644 index 0000000..aad6415 --- /dev/null +++ b/apps/gateway/package.json @@ -0,0 +1,35 @@ +{ + "name": "@remoteconn/gateway", + "version": "1.0.0", + "type": "module", + "main": "dist/index.js", + "scripts": { + "start": "tsx src/index.ts", + "dev": "tsx watch src/index.ts", + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "lint": "eslint src --ext .ts", + "test": "vitest run", + "bench": "tsx src/bench/gatewayPerfBench.ts", + "bench:ttyd": "tsx src/bench/ttydPerfBench.ts" + }, + "dependencies": { + "@remoteconn/shared": "1.0.0", + "cors": "^2.8.5", + "express": "^5.1.0", + "helmet": "^8.1.0", + "pino": "^9.9.0", + "pino-http": "^11.0.0", + "rate-limiter-flexible": "^8.1.0", + "ssh2": "^1.17.0", + "ws": "^8.18.3", + "zod": "^4.1.8" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.3", + "@types/ssh2": "^1.15.5", + "@types/ws": "^8.18.1", + "tsx": "^4.20.5" + } +} diff --git a/apps/gateway/src/bench/gatewayPerfBench.ts b/apps/gateway/src/bench/gatewayPerfBench.ts new file mode 100644 index 0000000..06b3e03 --- /dev/null +++ b/apps/gateway/src/bench/gatewayPerfBench.ts @@ -0,0 +1,307 @@ +import { once } from "node:events"; +import { readFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { AddressInfo } from "node:net"; +import { WebSocket } from "ws"; + +interface BenchOptions { + gatewayUrl?: string; + gatewayToken?: string; + sshHost: string; + sshPort: number; + sshUsername: string; + privateKeyPath: string; + iterations: number; + payloadKb: number; + timeoutMs: number; +} + +interface BenchStats { + min: number; + max: number; + avg: number; + p50: number; + p95: number; +} + +/** + * 将 `~` 展开为用户目录,方便通过环境变量传入私钥路径。 + */ +function expandHome(inputPath: string): string { + if (!inputPath.startsWith("~/")) { + return inputPath; + } + return path.join(os.homedir(), inputPath.slice(2)); +} + +/** + * 从环境变量读取压测参数,保证脚本可在 CI/本地复用。 + */ +function loadOptions(): BenchOptions { + return { + gatewayUrl: process.env.BENCH_GATEWAY_URL, + gatewayToken: process.env.BENCH_GATEWAY_TOKEN, + sshHost: process.env.BENCH_SSH_HOST ?? "127.0.0.1", + sshPort: Number(process.env.BENCH_SSH_PORT ?? "22"), + sshUsername: process.env.BENCH_SSH_USER ?? process.env.USER ?? "root", + privateKeyPath: expandHome(process.env.BENCH_PRIVATE_KEY ?? "~/.ssh/id_ed25519"), + iterations: Number(process.env.BENCH_ITERATIONS ?? "30"), + payloadKb: Number(process.env.BENCH_PAYLOAD_KB ?? "1024"), + timeoutMs: Number(process.env.BENCH_TIMEOUT_MS ?? "8000") + }; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * 循环轮询条件,避免把一堆一次性监听器挂到 WS 上导致泄漏。 + */ +async function waitFor(check: () => boolean, timeoutMs: number, label: string): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + if (check()) return; + await delay(10); + } + throw new Error(`等待超时: ${label}`); +} + +function percentile(sorted: number[], p: number): number { + if (sorted.length === 0) return 0; + const pos = (sorted.length - 1) * p; + const base = Math.floor(pos); + const rest = pos - base; + const baseValue = sorted[base] ?? 0; + const nextValue = sorted[base + 1]; + if (nextValue === undefined) { + return baseValue; + } + return baseValue + rest * (nextValue - baseValue); +} + +function buildStats(samples: number[]): BenchStats { + const sorted = [...samples].sort((a, b) => a - b); + const sum = sorted.reduce((acc, item) => acc + item, 0); + return { + min: sorted[0] ?? 0, + max: sorted[sorted.length - 1] ?? 0, + avg: sorted.length > 0 ? sum / sorted.length : 0, + p50: percentile(sorted, 0.5), + p95: percentile(sorted, 0.95) + }; +} + +function formatMs(value: number): string { + return `${value.toFixed(2)}ms`; +} + +function formatMbps(bytes: number, ms: number): string { + if (ms <= 0) return "0.00 MB/s"; + const sec = ms / 1000; + const mb = bytes / 1024 / 1024; + return `${(mb / sec).toFixed(2)} MB/s`; +} + +/** + * 本地未提供网关地址时,启动一份临时网关实例用于基线压测。 + */ +async function startLocalGateway(): Promise<{ url: string; token: string; close: () => Promise }> { + const token = `bench-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + process.env.HOST = "127.0.0.1"; + process.env.PORT = "0"; + process.env.GATEWAY_TOKEN = token; + process.env.CORS_ORIGIN = "*"; + + const { createGatewayServer } = await import("../server"); + const server = createGatewayServer(); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address() as AddressInfo; + + return { + url: `ws://127.0.0.1:${address.port}`, + token, + close: async () => { + server.close(); + await once(server, "close"); + } + }; +} + +async function main(): Promise { + const options = loadOptions(); + const privateKey = await readFile(options.privateKeyPath, "utf8"); + const cleanupTasks: Array<() => Promise> = []; + + let gatewayUrl = options.gatewayUrl; + let gatewayToken = options.gatewayToken; + + if (!gatewayUrl) { + const localGateway = await startLocalGateway(); + gatewayUrl = localGateway.url; + gatewayToken = localGateway.token; + cleanupTasks.push(localGateway.close); + } + + if (!gatewayToken) { + throw new Error("缺少网关 token:请设置 BENCH_GATEWAY_TOKEN,或让脚本自动启动本地网关"); + } + + const endpoint = `${gatewayUrl.replace(/\/$/, "")}/ws/terminal?token=${encodeURIComponent(gatewayToken)}`; + const ws = new WebSocket(endpoint); + + let stdoutText = ""; + let stdoutBytes = 0; + let connected = false; + let disconnectedReason = ""; + let fatalError = ""; + + ws.on("message", (raw) => { + try { + const frame = JSON.parse(raw.toString()) as { + type: string; + payload?: { data?: string; action?: string; reason?: string; message?: string }; + }; + + if (frame.type === "stdout") { + const data = frame.payload?.data ?? ""; + stdoutText += data; + stdoutBytes += Buffer.byteLength(data, "utf8"); + return; + } + + if (frame.type === "control" && frame.payload?.action === "connected") { + connected = true; + return; + } + + if (frame.type === "control" && frame.payload?.action === "disconnect") { + disconnectedReason = frame.payload?.reason ?? "unknown"; + return; + } + + if (frame.type === "error") { + fatalError = frame.payload?.message ?? "gateway error"; + } + } catch { + fatalError = "网关返回了无法解析的消息"; + } + }); + + ws.on("error", (error) => { + fatalError = String(error); + }); + + const connectedStartedAt = performance.now(); + await once(ws, "open"); + ws.send( + JSON.stringify({ + type: "init", + payload: { + host: options.sshHost, + port: options.sshPort, + username: options.sshUsername, + credential: { type: "privateKey", privateKey }, + pty: { cols: 140, rows: 40 } + } + }) + ); + + await waitFor(() => connected || fatalError.length > 0, options.timeoutMs, "SSH 连接建立"); + if (fatalError) { + throw new Error(fatalError); + } + const connectMs = performance.now() - connectedStartedAt; + + // 关闭 TTY 回显,避免命令内容干扰 RTT 统计(否则 marker 可能因本地 echo 提前出现)。 + ws.send(JSON.stringify({ type: "stdin", payload: { data: "stty -echo\n" } })); + await delay(120); + + const rttSamples: number[] = []; + for (let index = 0; index < options.iterations; index += 1) { + const marker = `__RCBENCH_${Date.now()}_${index}__`; + const stdoutStart = stdoutText.length; + const startedAt = performance.now(); + + ws.send(JSON.stringify({ type: "stdin", payload: { data: `printf '${marker}\\n'\n` } })); + + await waitFor( + () => stdoutText.slice(stdoutStart).includes(marker) || fatalError.length > 0, + options.timeoutMs, + `命令回显 RTT #${index + 1}` + ); + + if (fatalError) { + throw new Error(fatalError); + } + + rttSamples.push(performance.now() - startedAt); + } + + const payloadBytes = options.payloadKb * 1024; + const throughputStartBytes = stdoutBytes; + const throughputStartAt = performance.now(); + ws.send( + JSON.stringify({ + type: "stdin", + payload: { + data: `dd if=/dev/zero bs=1024 count=${options.payloadKb} 2>/dev/null | tr '\\0' 'x'\n` + } + }) + ); + + await waitFor( + () => stdoutBytes - throughputStartBytes >= payloadBytes || fatalError.length > 0, + options.timeoutMs * 4, + "吞吐测试输出收集" + ); + if (fatalError) { + throw new Error(fatalError); + } + const throughputMs = performance.now() - throughputStartAt; + + ws.send(JSON.stringify({ type: "control", payload: { action: "disconnect" } })); + await delay(80); + ws.close(); + + const rtt = buildStats(rttSamples); + console.log("=== Gateway SSH2 基线压测结果 ==="); + console.log( + JSON.stringify( + { + ts: new Date().toISOString(), + endpoint, + sshTarget: `${options.sshUsername}@${options.sshHost}:${options.sshPort}`, + iterations: options.iterations, + payloadKb: options.payloadKb, + connect: formatMs(connectMs), + rtt: { + min: formatMs(rtt.min), + p50: formatMs(rtt.p50), + p95: formatMs(rtt.p95), + max: formatMs(rtt.max), + avg: formatMs(rtt.avg) + }, + throughput: { + bytes: payloadBytes, + cost: formatMs(throughputMs), + speed: formatMbps(payloadBytes, throughputMs) + }, + disconnectReason: disconnectedReason || "client_disconnect" + }, + null, + 2 + ) + ); + + for (const task of cleanupTasks) { + await task(); + } +} + +main().catch((error) => { + console.error("[bench] 执行失败:", (error as Error).message); + process.exitCode = 1; +}); diff --git a/apps/gateway/src/bench/ttydPerfBench.ts b/apps/gateway/src/bench/ttydPerfBench.ts new file mode 100644 index 0000000..56a40b6 --- /dev/null +++ b/apps/gateway/src/bench/ttydPerfBench.ts @@ -0,0 +1,307 @@ +import { spawn, type ChildProcessByStdio } from "node:child_process"; +import type { Readable } from "node:stream"; +import net from "node:net"; +import { WebSocket, type RawData } from "ws"; + +interface BenchOptions { + ttydBin: string; + ttydHost: string; + ttydPort: number; + sshHost: string; + sshPort: number; + sshUsername: string; + sshIdentity?: string; + iterations: number; + payloadKb: number; + timeoutMs: number; +} + +interface BenchStats { + min: number; + max: number; + avg: number; + p50: number; + p95: number; +} + +/** + * 读取环境变量,统一与 gateway 基线脚本保持参数名兼容。 + */ +function loadOptions(): BenchOptions { + const identity = process.env.BENCH_SSH_IDENTITY; + return { + ttydBin: process.env.BENCH_TTYD_BIN ?? "ttyd", + ttydHost: process.env.BENCH_TTYD_HOST ?? "127.0.0.1", + ttydPort: Number(process.env.BENCH_TTYD_PORT ?? "0"), + sshHost: process.env.BENCH_SSH_HOST ?? "127.0.0.1", + sshPort: Number(process.env.BENCH_SSH_PORT ?? "22"), + sshUsername: process.env.BENCH_SSH_USER ?? process.env.USER ?? "root", + sshIdentity: identity && identity.trim().length > 0 ? identity : undefined, + iterations: Number(process.env.BENCH_ITERATIONS ?? "30"), + payloadKb: Number(process.env.BENCH_PAYLOAD_KB ?? "1024"), + timeoutMs: Number(process.env.BENCH_TIMEOUT_MS ?? "8000") + }; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * 轮询等待状态变化,避免事件乱序导致监听器遗漏。 + */ +async function waitFor(check: () => boolean, timeoutMs: number, label: string): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + if (check()) return; + await delay(10); + } + throw new Error(`等待超时: ${label}`); +} + +async function pickFreePort(host: string): Promise { + const server = net.createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, host, () => resolve()); + }); + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + await new Promise((resolve) => server.close(() => resolve())); + if (!port) { + throw new Error("无法分配可用端口"); + } + return port; +} + +function percentile(sorted: number[], p: number): number { + if (sorted.length === 0) return 0; + const pos = (sorted.length - 1) * p; + const base = Math.floor(pos); + const rest = pos - base; + const baseValue = sorted[base] ?? 0; + const nextValue = sorted[base + 1]; + if (nextValue === undefined) { + return baseValue; + } + return baseValue + rest * (nextValue - baseValue); +} + +function buildStats(samples: number[]): BenchStats { + const sorted = [...samples].sort((a, b) => a - b); + const sum = sorted.reduce((acc, item) => acc + item, 0); + return { + min: sorted[0] ?? 0, + max: sorted[sorted.length - 1] ?? 0, + avg: sorted.length > 0 ? sum / sorted.length : 0, + p50: percentile(sorted, 0.5), + p95: percentile(sorted, 0.95) + }; +} + +function formatMs(value: number): string { + return `${value.toFixed(2)}ms`; +} + +function formatMbps(bytes: number, ms: number): string { + if (ms <= 0) return "0.00 MB/s"; + const sec = ms / 1000; + const mb = bytes / 1024 / 1024; + return `${(mb / sec).toFixed(2)} MB/s`; +} + +/** + * 启动 ttyd 子进程,命令行为:ttyd -> ssh -> 目标 shell。 + */ +async function startTtyd(options: BenchOptions): Promise<{ proc: ChildProcessByStdio; port: number }> { + const port = options.ttydPort > 0 ? options.ttydPort : await pickFreePort(options.ttydHost); + const sshArgs = [ + "-tt", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "-o", + "LogLevel=ERROR" + ]; + + if (options.sshIdentity) { + sshArgs.push("-i", options.sshIdentity); + } + + sshArgs.push("-p", String(options.sshPort), `${options.sshUsername}@${options.sshHost}`); + + const args = ["-i", options.ttydHost, "-p", String(port), "-W", "--", "ssh", ...sshArgs]; + const proc = spawn(options.ttydBin, args, { + stdio: ["ignore", "pipe", "pipe"] + }); + + let stderrText = ""; + proc.stderr.on("data", (chunk: Buffer) => { + stderrText += chunk.toString("utf8"); + }); + + await waitFor( + () => stderrText.includes("Listening on port:") || proc.exitCode !== null, + 4000, + "ttyd 启动" + ); + + if (proc.exitCode !== null) { + throw new Error(`ttyd 启动失败(exit=${proc.exitCode}): ${stderrText.trim()}`); + } + + return { proc, port }; +} + +function sendTtydInput(ws: WebSocket, data: string): void { + ws.send(Buffer.concat([Buffer.from("0"), Buffer.from(data, "utf8")])); +} + +function toBuffer(raw: RawData): Buffer { + if (Buffer.isBuffer(raw)) { + return raw; + } + + if (raw instanceof ArrayBuffer) { + return Buffer.from(raw); + } + + if (Array.isArray(raw)) { + return Buffer.concat( + raw.map((chunk) => { + if (Buffer.isBuffer(chunk)) { + return chunk; + } + return Buffer.from(chunk); + }) + ); + } + + return Buffer.from(raw); +} + +async function main(): Promise { + const options = loadOptions(); + const { proc, port } = await startTtyd(options); + const endpoint = `ws://${options.ttydHost}:${port}/ws`; + + const ws = new WebSocket(endpoint, ["tty"]); + let stdoutText = ""; + let stdoutBytes = 0; + let fatalError = ""; + let opened = false; + + ws.on("open", () => { + opened = true; + ws.send(JSON.stringify({ columns: 140, rows: 40 })); + }); + + ws.on("message", (raw, isBinary) => { + if (!isBinary) return; + + const data = toBuffer(raw); + const cmd = String.fromCharCode(data[0] ?? 0); + if (cmd !== "0") { + return; + } + + const text = data.slice(1).toString("utf8"); + stdoutText += text; + stdoutBytes += Buffer.byteLength(text, "utf8"); + }); + + ws.on("error", (error) => { + fatalError = String(error); + }); + + const connectStartedAt = performance.now(); + await waitFor(() => opened || fatalError.length > 0, options.timeoutMs, "ttyd ws 连接"); + if (fatalError) { + throw new Error(fatalError); + } + + const markerInit = `__TTYD_INIT_${Date.now()}__`; + sendTtydInput(ws, `printf '${markerInit}\\n'\n`); + await waitFor(() => stdoutText.includes(markerInit) || fatalError.length > 0, options.timeoutMs, "初始就绪"); + if (fatalError) { + throw new Error(fatalError); + } + const connectMs = performance.now() - connectStartedAt; + + // 关闭回显,避免输入回显干扰 RTT 检测。 + sendTtydInput(ws, "stty -echo\n"); + await delay(120); + + const rttSamples: number[] = []; + for (let index = 0; index < options.iterations; index += 1) { + const marker = `__TTYDBENCH_${Date.now()}_${index}__`; + const stdoutStart = stdoutText.length; + const startedAt = performance.now(); + + sendTtydInput(ws, `printf '${marker}\\n'\n`); + + await waitFor( + () => stdoutText.slice(stdoutStart).includes(marker) || fatalError.length > 0, + options.timeoutMs, + `命令回显 RTT #${index + 1}` + ); + + if (fatalError) { + throw new Error(fatalError); + } + + rttSamples.push(performance.now() - startedAt); + } + + const payloadBytes = options.payloadKb * 1024; + const throughputStartBytes = stdoutBytes; + const throughputStartedAt = performance.now(); + sendTtydInput(ws, `dd if=/dev/zero bs=1024 count=${options.payloadKb} 2>/dev/null | tr '\\0' 'x'\n`); + await waitFor( + () => stdoutBytes - throughputStartBytes >= payloadBytes || fatalError.length > 0, + options.timeoutMs * 4, + "吞吐测试输出收集" + ); + if (fatalError) { + throw new Error(fatalError); + } + const throughputMs = performance.now() - throughputStartedAt; + + ws.close(); + proc.kill("SIGTERM"); + + const rtt = buildStats(rttSamples); + console.log("=== TTYD 基线压测结果 ==="); + console.log( + JSON.stringify( + { + ts: new Date().toISOString(), + endpoint, + sshTarget: `${options.sshUsername}@${options.sshHost}:${options.sshPort}`, + iterations: options.iterations, + payloadKb: options.payloadKb, + connect: formatMs(connectMs), + rtt: { + min: formatMs(rtt.min), + p50: formatMs(rtt.p50), + p95: formatMs(rtt.p95), + max: formatMs(rtt.max), + avg: formatMs(rtt.avg) + }, + throughput: { + bytes: payloadBytes, + cost: formatMs(throughputMs), + speed: formatMbps(payloadBytes, throughputMs) + } + }, + null, + 2 + ) + ); +} + +main().catch((error) => { + console.error("[bench] 执行失败:", (error as Error).message); + process.exitCode = 1; +}); diff --git a/apps/gateway/src/config.test.ts b/apps/gateway/src/config.test.ts new file mode 100644 index 0000000..43e1ee4 --- /dev/null +++ b/apps/gateway/src/config.test.ts @@ -0,0 +1,31 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("gateway config", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + process.env = { ...originalEnv }; + vi.resetModules(); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it("应为火山 V3 单向流式 TTS 提供新的默认 resource id", async () => { + process.env.TTS_RESOURCE_ID = ""; + const imported = await import("./config"); + expect(imported.config.tts.resourceId).toBe("volc.service_type.10029"); + }); + + it("应解析 TTS_CACHE_FILE_MAX_BYTES,并在非法值时回退默认上限", async () => { + process.env.TTS_CACHE_FILE_MAX_BYTES = "6291456"; + let imported = await import("./config"); + expect(imported.config.tts.cacheFileMaxBytes).toBe(6 * 1024 * 1024); + + vi.resetModules(); + process.env.TTS_CACHE_FILE_MAX_BYTES = "0"; + imported = await import("./config"); + expect(imported.config.tts.cacheFileMaxBytes).toBe(8 * 1024 * 1024); + }); +}); diff --git a/apps/gateway/src/config.ts b/apps/gateway/src/config.ts new file mode 100644 index 0000000..4925a0a --- /dev/null +++ b/apps/gateway/src/config.ts @@ -0,0 +1,400 @@ +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { z } from "zod"; + +/** + * 运维配置 schema(收敛版)。 + * + * 优先级:Env > runtime.json > 代码内置默认值。 + * 敏感字段(GATEWAY_TOKEN / ASR_ACCESS_TOKEN / ASR_SECRET_KEY)只走 Env,不写入配置文件或仓库。 + * + * 字段对照(按配置计划章节): + * 网关基础:GATEWAY_PORT / GATEWAY_HOST / GATEWAY_TOKEN / GATEWAY_CORS_ORIGIN / GATEWAY_LOG_LEVEL / GATEWAY_DEBUG_IO_HEX + * 语音识别:ASR_PROVIDER / ASR_APP_ID / ASR_ACCESS_TOKEN / ASR_SECRET_KEY / ASR_RESOURCE_ID / ASR_CLUSTER / ASR_WS_URL / ASR_INCLUDE_RAW_RESULT / ASR_EMPTY_TEXT_WARN_LIMIT + * 语音播报:TTS_PROVIDER / TTS_APP_ID / TTS_ACCESS_TOKEN / TTS_SECRET_ID / TTS_SECRET_KEY / TTS_REGION / TTS_CLUSTER / TTS_RESOURCE_ID / TTS_VOICE_DEFAULT / TTS_SPEED_DEFAULT / TTS_TIMEOUT_MS / TTS_CACHE_FILE_MAX_BYTES + * 安全策略:RATE_LIMIT_POINTS / RATE_LIMIT_DURATION_SEC + * 会话策略:ASSIST_TXN_TTL_MS / ASSIST_TXN_CACHE_LIMIT + * SSH 策略:SSH_READY_TIMEOUT_MS / SSH_KEEPALIVE_INTERVAL_MS / SSH_KEEPALIVE_COUNT_MAX / TERMINAL_RESUME_GRACE_DEFAULT_MS / TERMINAL_RESUME_GRACE_MAX_MS + * 插件策略:PLUGIN_ONLOAD_TIMEOUT_MS / PLUGIN_ONUNLOAD_TIMEOUT_MS + */ +const schema = z.object({ + // ── 网关基础 ───────────────────────────────────────────────────────────── + /** 监听端口(别名 PORT 向下兼容) */ + GATEWAY_PORT: z.string().optional(), + PORT: z.string().optional(), + /** 监听地址 */ + GATEWAY_HOST: z.string().optional().default("0.0.0.0"), + /** @deprecated 请使用 GATEWAY_HOST */ + HOST: z.string().optional(), + /** 访问令牌,仅 Env,不可写文件 */ + GATEWAY_TOKEN: z.string().min(8).default("remoteconn-dev-token"), + /** CORS Access-Control-Allow-Origin */ + GATEWAY_CORS_ORIGIN: z.string().optional().default("*"), + /** 日志级别 */ + GATEWAY_LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"), + /** 原始 IO 十六进制调试,1 时输出原始帧转储 */ + GATEWAY_DEBUG_IO_HEX: z.string().optional().default("0"), + + // ── 小程序同步(配置持久化)─────────────────────────────────────────────── + /** 微信小程序 AppID,用于 code2Session */ + MINIPROGRAM_APP_ID: z.string().optional().default(""), + /** 微信小程序 AppSecret,仅 Env */ + MINIPROGRAM_APP_SECRET: z.string().optional().default(""), + /** 同步 SQLite 文件路径 */ + SYNC_SQLITE_PATH: z.string().optional().default("data/remoteconn-sync.db"), + /** 同步敏感字段加密主密钥,仅 Env */ + SYNC_SECRET_CURRENT: z.string().optional().default(""), + /** 当前加密密钥版本 */ + SYNC_SECRET_VERSION: z.string().optional().default("1"), + /** 同步登录 token 有效期(秒) */ + SYNC_TOKEN_TTL_SEC: z.string().optional().default("604800"), + + // ── 语音识别(通用)─────────────────────────────────────────────────────── + /** 语音供应商标识 */ + ASR_PROVIDER: z.string().optional().default("volcengine"), + /** 语音服务 App ID */ + ASR_APP_ID: z.string().optional(), + /** 语音服务 Access Token,仅 Env */ + ASR_ACCESS_TOKEN: z.string().optional(), + /** 语音服务 Secret Key,仅 Env */ + ASR_SECRET_KEY: z.string().optional(), + /** 语音资源标识(ASR 2.0 小时版默认值) */ + ASR_RESOURCE_ID: z.string().optional().default("volc.seedasr.sauc.duration"), + /** 集群参数(可选) */ + ASR_CLUSTER: z.string().optional(), + /** WebSocket 接入地址 */ + ASR_WS_URL: z + .string() + .url() + .optional() + .default("wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async"), + /** 是否在 result 帧携带上游原始 payload(默认关闭,减少传输体积) */ + ASR_INCLUDE_RAW_RESULT: z.string().optional().default("0"), + /** 单连接内“空文本结果”告警上限,避免日志风暴 */ + ASR_EMPTY_TEXT_WARN_LIMIT: z.string().optional().default("3"), + + // ── 语音播报(TTS)─────────────────────────────────────────────────────── + /** TTS 供应商标识 */ + TTS_PROVIDER: z.string().optional().default("tencent"), + /** TTS App ID(当前腾讯云短文本合成保留配置位,未直接参与签名) */ + TTS_APP_ID: z.string().optional(), + /** TTS Access Token(Volcengine 使用) */ + TTS_ACCESS_TOKEN: z.string().optional(), + /** TTS Secret ID,仅 Env */ + TTS_SECRET_ID: z.string().optional(), + /** TTS Secret Key,仅 Env */ + TTS_SECRET_KEY: z.string().optional(), + /** TTS 地域 */ + TTS_REGION: z.string().optional().default("ap-guangzhou"), + /** TTS 集群(Volcengine 使用) */ + TTS_CLUSTER: z.string().optional().default("volcano_tts"), + /** TTS 资源标识(火山 HTTP Chunked/SSE 单向流式 V3 默认值) */ + TTS_RESOURCE_ID: z.string().optional().default("volc.service_type.10029"), + /** 默认音色别名 */ + TTS_VOICE_DEFAULT: z.string().optional().default("female_v1"), + /** 默认语速 */ + TTS_SPEED_DEFAULT: z.string().optional().default("1"), + /** 单次 TTS 请求超时(毫秒) */ + TTS_TIMEOUT_MS: z.string().optional().default("30000"), + /** 单个 TTS 音频缓存文件的最大大小(字节) */ + TTS_CACHE_FILE_MAX_BYTES: z + .string() + .optional() + .default(String(8 * 1024 * 1024)), + + // ── 安全策略(限流)──────────────────────────────────────────────────────── + /** 单 IP 在窗口期内最大请求次数 */ + RATE_LIMIT_POINTS: z.string().optional().default("30"), + /** 限流计数器重置周期(秒) */ + RATE_LIMIT_DURATION_SEC: z.string().optional().default("60"), + + // ── 会话策略(assist 事务去重缓存)────────────────────────────────────────── + /** 同一事务 ID 在此时间内视为重复(毫秒) */ + ASSIST_TXN_TTL_MS: z.string().optional().default("30000"), + /** LRU 缓存最大条目数 */ + ASSIST_TXN_CACHE_LIMIT: z.string().optional().default("512"), + + // ── SSH 策略 ───────────────────────────────────────────────────────────── + /** 等待 SSH ready 事件的超时时间(毫秒) */ + SSH_READY_TIMEOUT_MS: z.string().optional().default("15000"), + /** 心跳包发送间隔(毫秒) */ + SSH_KEEPALIVE_INTERVAL_MS: z.string().optional().default("10000"), + /** 连续无响应超过此次数后断开连接 */ + SSH_KEEPALIVE_COUNT_MAX: z.string().optional().default("3"), + /** 终端续接驻留默认窗口(毫秒) */ + TERMINAL_RESUME_GRACE_DEFAULT_MS: z.string().optional().default("20000"), + /** 终端续接驻留最大窗口(毫秒) */ + TERMINAL_RESUME_GRACE_MAX_MS: z.string().optional().default("3600000"), + + // ── 插件运行时策略 ──────────────────────────────────────────────────────── + /** 单个插件 onLoad 钩子最长执行时间(毫秒) */ + PLUGIN_ONLOAD_TIMEOUT_MS: z.string().optional().default("3000"), + /** 单个插件 onUnload 钩子最长执行时间(毫秒) */ + PLUGIN_ONUNLOAD_TIMEOUT_MS: z.string().optional().default("3000"), + + // ── 向下兼容别名(旧字段,下一个版本窗口删除)──────────────────────────── + /** @deprecated 请使用 GATEWAY_CORS_ORIGIN */ + CORS_ORIGIN: z.string().optional(), + /** @deprecated 请使用 GATEWAY_DEBUG_IO_HEX */ + DEBUG_IO_HEX: z.string().optional() +}); + +const runtimeFileSchema = z.object({ + // ── 网关基础(非敏感)─────────────────────────────────────────────────────── + GATEWAY_PORT: z.union([z.string(), z.number()]).optional(), + PORT: z.union([z.string(), z.number()]).optional(), + GATEWAY_HOST: z.string().optional(), + HOST: z.string().optional(), + GATEWAY_CORS_ORIGIN: z.string().optional(), + GATEWAY_LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).optional(), + GATEWAY_DEBUG_IO_HEX: z.union([z.string(), z.number(), z.boolean()]).optional(), + ASR_INCLUDE_RAW_RESULT: z.union([z.string(), z.number(), z.boolean()]).optional(), + ASR_EMPTY_TEXT_WARN_LIMIT: z.union([z.string(), z.number()]).optional(), + TTS_PROVIDER: z.string().optional(), + TTS_APP_ID: z.string().optional(), + TTS_ACCESS_TOKEN: z.string().optional(), + TTS_REGION: z.string().optional(), + TTS_CLUSTER: z.string().optional(), + TTS_RESOURCE_ID: z.string().optional(), + TTS_VOICE_DEFAULT: z.string().optional(), + TTS_SPEED_DEFAULT: z.union([z.string(), z.number()]).optional(), + TTS_TIMEOUT_MS: z.union([z.string(), z.number()]).optional(), + TTS_CACHE_FILE_MAX_BYTES: z.union([z.string(), z.number()]).optional(), + + // ── 小程序同步(非敏感)─────────────────────────────────────────────────── + MINIPROGRAM_APP_ID: z.string().optional(), + SYNC_SQLITE_PATH: z.string().optional(), + SYNC_SECRET_VERSION: z.union([z.string(), z.number()]).optional(), + SYNC_TOKEN_TTL_SEC: z.union([z.string(), z.number()]).optional(), + + // ── 安全策略(限流)──────────────────────────────────────────────────────── + RATE_LIMIT_POINTS: z.union([z.string(), z.number()]).optional(), + RATE_LIMIT_DURATION_SEC: z.union([z.string(), z.number()]).optional(), + + // ── 会话策略 ───────────────────────────────────────────────────────────── + ASSIST_TXN_TTL_MS: z.union([z.string(), z.number()]).optional(), + ASSIST_TXN_CACHE_LIMIT: z.union([z.string(), z.number()]).optional(), + + // ── SSH 策略 ───────────────────────────────────────────────────────────── + SSH_READY_TIMEOUT_MS: z.union([z.string(), z.number()]).optional(), + SSH_KEEPALIVE_INTERVAL_MS: z.union([z.string(), z.number()]).optional(), + SSH_KEEPALIVE_COUNT_MAX: z.union([z.string(), z.number()]).optional(), + TERMINAL_RESUME_GRACE_DEFAULT_MS: z.union([z.string(), z.number()]).optional(), + TERMINAL_RESUME_GRACE_MAX_MS: z.union([z.string(), z.number()]).optional(), + + // ── 插件运行时策略 ──────────────────────────────────────────────────────── + PLUGIN_ONLOAD_TIMEOUT_MS: z.union([z.string(), z.number()]).optional(), + PLUGIN_ONUNLOAD_TIMEOUT_MS: z.union([z.string(), z.number()]).optional(), + + // ── 向下兼容别名 ───────────────────────────────────────────────────────── + CORS_ORIGIN: z.string().optional(), + DEBUG_IO_HEX: z.union([z.string(), z.number(), z.boolean()]).optional() +}); + +type RuntimeFileConfig = z.infer; + +function resolveRuntimeConfigPath(): string | null { + const fromEnv = process.env.GATEWAY_RUNTIME_CONFIG_PATH?.trim(); + if (fromEnv) return fromEnv; + + const candidates = [ + path.resolve(process.cwd(), "config/runtime.json"), + "/etc/remoteconn/gateway.runtime.json" + ]; + return candidates.find((candidate) => existsSync(candidate)) ?? null; +} + +function scalarToString(value: string | number | boolean): string { + if (typeof value === "boolean") { + return value ? "1" : "0"; + } + return String(value); +} + +function loadRuntimeFileConfig(): RuntimeFileConfig { + const runtimePath = resolveRuntimeConfigPath(); + if (!runtimePath) return {}; + + try { + const raw = readFileSync(runtimePath, "utf-8"); + const parsed = JSON.parse(raw) as unknown; + return runtimeFileSchema.parse(parsed); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error(`invalid runtime config at ${runtimePath}: ${reason}`); + } +} + +function toEnvLikeRecord(runtime: RuntimeFileConfig): Record { + const entries = Object.entries(runtime) + .filter((entry): entry is [string, string | number | boolean] => entry[1] !== undefined) + .map(([key, value]) => [key, scalarToString(value)]); + return Object.fromEntries(entries); +} + +/** + * 轻量 .env 解析(不引入 dotenv 依赖): + * - 仅做 K=V 解析与引号去除; + * - 支持 `export KEY=VALUE`; + * - 不覆盖已存在的 process.env(由合并顺序保证)。 + */ +function parseDotEnv(content: string): Record { + const out: Record = {}; + const lines = content.split(/\r?\n/); + for (const raw of lines) { + const line = raw.trim(); + if (!line || line.startsWith("#")) { + continue; + } + const normalized = line.startsWith("export ") ? line.slice("export ".length).trim() : line; + const idx = normalized.indexOf("="); + if (idx <= 0) { + continue; + } + const key = normalized.slice(0, idx).trim(); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) { + continue; + } + let value = normalized.slice(idx + 1).trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + out[key] = value; + } + return out; +} + +function loadDotEnvConfig(): Record { + const fromEnv = process.env.GATEWAY_ENV_FILE?.trim(); + const candidates = [ + fromEnv, + path.resolve(process.cwd(), ".env"), + path.resolve(process.cwd(), "apps/gateway/.env") + ].filter((item): item is string => Boolean(item)); + + for (const file of candidates) { + if (!existsSync(file)) { + continue; + } + try { + const raw = readFileSync(file, "utf-8"); + return parseDotEnv(raw); + } catch { + // 读取失败时跳过,继续尝试其他候选路径。 + } + } + return {}; +} + +const runtimeFileConfig = loadRuntimeFileConfig(); +const dotenvConfig = loadDotEnvConfig(); +const mergedInput = { + ...toEnvLikeRecord(runtimeFileConfig), + ...dotenvConfig, + ...process.env +}; + +const env = schema.parse(mergedInput); + +function isTtsEnabled(ttsProvider: string): boolean { + const provider = String(ttsProvider || "") + .trim() + .toLowerCase(); + if (provider === "volcengine") { + return Boolean((env.TTS_APP_ID ?? "").trim() && (env.TTS_ACCESS_TOKEN ?? "").trim()); + } + if (provider === "tencent") { + return Boolean((env.TTS_SECRET_ID ?? "").trim() && (env.TTS_SECRET_KEY ?? "").trim()); + } + return false; +} + +function parseBool(value: string): boolean { + return /^(1|true|yes|on)$/i.test(value.trim()); +} + +function parsePositiveInt(value: string, fallback: number): number { + const n = parseInt(value, 10); + return Number.isFinite(n) && n > 0 ? n : fallback; +} + +export const config = { + // ── 网关基础 ───────────────────────────────────────────────────────────── + port: parsePositiveInt(env.GATEWAY_PORT ?? env.PORT ?? "8787", 8787), + host: env.GATEWAY_HOST ?? env.HOST ?? "0.0.0.0", + gatewayToken: env.GATEWAY_TOKEN, + /** 兼容旧字段 CORS_ORIGIN */ + corsOrigin: env.GATEWAY_CORS_ORIGIN !== "*" ? env.GATEWAY_CORS_ORIGIN : (env.CORS_ORIGIN ?? "*"), + logLevel: env.GATEWAY_LOG_LEVEL, + /** 兼容旧字段 DEBUG_IO_HEX */ + debugIoHex: parseBool( + env.GATEWAY_DEBUG_IO_HEX !== "0" ? env.GATEWAY_DEBUG_IO_HEX : (env.DEBUG_IO_HEX ?? "0") + ), + + // ── 小程序同步(配置持久化)─────────────────────────────────────────────── + sync: { + miniprogramAppId: (env.MINIPROGRAM_APP_ID ?? "").trim(), + miniprogramAppSecret: (env.MINIPROGRAM_APP_SECRET ?? "").trim(), + sqlitePath: (env.SYNC_SQLITE_PATH ?? "data/remoteconn-sync.db").trim(), + secretCurrent: (env.SYNC_SECRET_CURRENT ?? "").trim(), + secretVersion: parsePositiveInt(env.SYNC_SECRET_VERSION ?? "1", 1), + tokenTtlSec: parsePositiveInt(env.SYNC_TOKEN_TTL_SEC ?? "604800", 604800), + enabled: Boolean( + (env.MINIPROGRAM_APP_ID ?? "").trim() && + (env.MINIPROGRAM_APP_SECRET ?? "").trim() && + (env.SYNC_SECRET_CURRENT ?? "").trim() + ) + }, + + // ── 安全策略 ───────────────────────────────────────────────────────────── + rateLimitPoints: parsePositiveInt(env.RATE_LIMIT_POINTS, 30), + rateLimitDurationSec: parsePositiveInt(env.RATE_LIMIT_DURATION_SEC, 60), + + // ── 会话策略 ───────────────────────────────────────────────────────────── + assistTxnTtlMs: parsePositiveInt(env.ASSIST_TXN_TTL_MS, 30000), + assistTxnCacheLimit: parsePositiveInt(env.ASSIST_TXN_CACHE_LIMIT, 512), + + // ── SSH 策略 ───────────────────────────────────────────────────────────── + sshReadyTimeoutMs: parsePositiveInt(env.SSH_READY_TIMEOUT_MS, 15000), + sshKeepaliveIntervalMs: parsePositiveInt(env.SSH_KEEPALIVE_INTERVAL_MS, 10000), + sshKeepaliveCountMax: parsePositiveInt(env.SSH_KEEPALIVE_COUNT_MAX, 3), + terminalResumeGraceDefaultMs: parsePositiveInt(env.TERMINAL_RESUME_GRACE_DEFAULT_MS, 20000), + terminalResumeGraceMaxMs: parsePositiveInt(env.TERMINAL_RESUME_GRACE_MAX_MS, 60 * 60 * 1000), + + // ── 语音识别(通用)─────────────────────────────────────────────────────── + asr: { + provider: (env.ASR_PROVIDER ?? "volcengine").trim(), + appId: (env.ASR_APP_ID ?? "").trim(), + accessToken: (env.ASR_ACCESS_TOKEN ?? "").trim(), + secretKey: (env.ASR_SECRET_KEY ?? "").trim(), + resourceId: (env.ASR_RESOURCE_ID ?? "volc.seedasr.sauc.duration").trim(), + cluster: (env.ASR_CLUSTER ?? "").trim(), + wsUrl: (env.ASR_WS_URL ?? "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async").trim(), + includeRawResult: parseBool(env.ASR_INCLUDE_RAW_RESULT ?? "0"), + emptyTextWarnLimit: parsePositiveInt(env.ASR_EMPTY_TEXT_WARN_LIMIT ?? "3", 3) + }, + + // ── 语音播报(TTS)─────────────────────────────────────────────────────── + tts: { + provider: (env.TTS_PROVIDER ?? "tencent").trim(), + appId: (env.TTS_APP_ID ?? "").trim(), + accessToken: (env.TTS_ACCESS_TOKEN ?? "").trim(), + secretId: (env.TTS_SECRET_ID ?? "").trim(), + secretKey: (env.TTS_SECRET_KEY ?? "").trim(), + region: (env.TTS_REGION ?? "ap-guangzhou").trim() || "ap-guangzhou", + cluster: (env.TTS_CLUSTER ?? "volcano_tts").trim() || "volcano_tts", + resourceId: (env.TTS_RESOURCE_ID ?? "volc.service_type.10029").trim() || "volc.service_type.10029", + voiceDefault: (env.TTS_VOICE_DEFAULT ?? "female_v1").trim() || "female_v1", + speedDefault: Number(env.TTS_SPEED_DEFAULT ?? "1") || 1, + timeoutMs: parsePositiveInt(env.TTS_TIMEOUT_MS ?? "30000", 30000), + cacheFileMaxBytes: parsePositiveInt( + env.TTS_CACHE_FILE_MAX_BYTES ?? String(8 * 1024 * 1024), + 8 * 1024 * 1024 + ), + enabled: isTtsEnabled(env.TTS_PROVIDER ?? "tencent") + }, + + // ── 插件运行时策略 ──────────────────────────────────────────────────────── + pluginOnloadTimeoutMs: parsePositiveInt(env.PLUGIN_ONLOAD_TIMEOUT_MS, 3000), + pluginOnunloadTimeoutMs: parsePositiveInt(env.PLUGIN_ONUNLOAD_TIMEOUT_MS, 3000) +}; diff --git a/apps/gateway/src/debug/ioHex.ts b/apps/gateway/src/debug/ioHex.ts new file mode 100644 index 0000000..ed3e87a --- /dev/null +++ b/apps/gateway/src/debug/ioHex.ts @@ -0,0 +1,61 @@ +const PREVIEW_CHARS = 48; +const PREVIEW_BYTES = 64; + +function toHexBytes(input: Uint8Array): string { + return Array.from(input) + .map((b) => b.toString(16).padStart(2, "0")) + .join(" "); +} + +function toHexCodeUnits(input: string, limit = PREVIEW_CHARS): string { + const parts: string[] = []; + const max = Math.min(input.length, limit); + for (let i = 0; i < max; i += 1) { + parts.push(input.charCodeAt(i).toString(16).padStart(4, "0")); + } + return parts.join(" "); +} + +function toHexLatin1Bytes(input: string, limit = PREVIEW_CHARS): string { + const parts: string[] = []; + const max = Math.min(input.length, limit); + for (let i = 0; i < max; i += 1) { + const code = input.charCodeAt(i); + if (code <= 0xff) { + parts.push(code.toString(16).padStart(2, "0")); + } else { + parts.push(".."); + } + } + return parts.join(" "); +} + +/** + * 生成字符串的十六进制调试快照,定位“哪一段链路改坏了字节”。 + * - utf8Hex: 按 UTF-8 编码后的字节; + * - latin1Hex: 将每个 code unit 视作单字节(仅 0x00-0xFF 有效); + * - codeUnitHex: 原始 JS code unit(16 进制)。 + */ +export function inspectStringHex(data: string): Record { + const utf8Bytes = Buffer.from(data, "utf8"); + return { + charLen: data.length, + utf8ByteLen: utf8Bytes.length, + preview: JSON.stringify(data.slice(0, PREVIEW_CHARS)), + codeUnitHex: toHexCodeUnits(data), + latin1Hex: toHexLatin1Bytes(data), + utf8Hex: toHexBytes(utf8Bytes.subarray(0, PREVIEW_BYTES)) + }; +} + +/** + * 生成二进制缓冲区的十六进制快照,用于原始字节层排障。 + */ +export function inspectBufferHex(data: Buffer | Uint8Array): Record { + const bytes = Buffer.from(data); + return { + byteLen: bytes.length, + utf8Preview: JSON.stringify(bytes.toString("utf8").slice(0, PREVIEW_CHARS)), + hex: toHexBytes(bytes.subarray(0, PREVIEW_BYTES)) + }; +} diff --git a/apps/gateway/src/debug/terminalCaptureArm.test.ts b/apps/gateway/src/debug/terminalCaptureArm.test.ts new file mode 100644 index 0000000..7901e2e --- /dev/null +++ b/apps/gateway/src/debug/terminalCaptureArm.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from "vitest"; +import { createTerminalCaptureArmStore } from "./terminalCaptureArm"; + +describe("terminalCaptureArm", () => { + it("会拒绝没有匹配条件的录制规则", () => { + const store = createTerminalCaptureArmStore(); + expect(() => + store.arm({ + captureDir: "/tmp/remoteconn-captures", + ttlMs: 60_000 + }) + ).toThrow(/至少需要提供一个匹配条件/); + }); + + it("命中后会一次性取走规则", () => { + const store = createTerminalCaptureArmStore(); + const rule = store.arm({ + captureDir: "/tmp/remoteconn-captures", + ttlMs: 60_000, + clientSessionKey: "mini-session-1", + host: "example.com", + username: "gavin" + }); + + const matched = store.take({ + ip: "127.0.0.1", + clientSessionKey: "mini-session-1", + host: "example.com", + port: 22, + username: "gavin" + }); + + expect(matched?.id).toBe(rule.id); + expect(store.list()).toEqual([]); + }); + + it("过期规则不会再命中", () => { + const nowSpy = vi.spyOn(Date, "now"); + nowSpy.mockReturnValue(1_000); + const store = createTerminalCaptureArmStore(); + store.arm({ + captureDir: "/tmp/remoteconn-captures", + ttlMs: 1_000, + username: "gavin" + }); + + nowSpy.mockReturnValue(2_500); + const matched = store.take({ + ip: "127.0.0.1", + host: "example.com", + port: 22, + username: "gavin" + }); + + expect(matched).toBeNull(); + nowSpy.mockRestore(); + }); +}); diff --git a/apps/gateway/src/debug/terminalCaptureArm.ts b/apps/gateway/src/debug/terminalCaptureArm.ts new file mode 100644 index 0000000..f264cbb --- /dev/null +++ b/apps/gateway/src/debug/terminalCaptureArm.ts @@ -0,0 +1,148 @@ +import { randomUUID } from "node:crypto"; + +export interface ArmedTerminalCaptureRule { + id: string; + createdAt: number; + expiresAt: number; + captureDir: string; + ip?: string; + clientSessionKey?: string; + host?: string; + port?: number; + username?: string; +} + +export interface TerminalCaptureArmInput { + captureDir: string; + ttlMs: number; + ip?: string; + clientSessionKey?: string; + host?: string; + port?: number; + username?: string; +} + +export interface TerminalCaptureMatchTarget { + ip: string; + clientSessionKey?: string; + host: string; + port: number; + username: string; +} + +export interface TerminalCaptureArmStore { + arm(input: TerminalCaptureArmInput): ArmedTerminalCaptureRule; + take(target: TerminalCaptureMatchTarget): ArmedTerminalCaptureRule | null; + list(): ArmedTerminalCaptureRule[]; +} + +const DEFAULT_TTL_MS = 5 * 60 * 1000; +const MAX_TTL_MS = 60 * 60 * 1000; + +function normalizeString(value: unknown): string { + return String(value || "").trim(); +} + +function normalizeOptionalString(value: unknown): string | undefined { + const next = normalizeString(value); + return next ? next : undefined; +} + +function normalizePort(value: unknown): number | undefined { + const next = Number(value); + if (!Number.isFinite(next) || next <= 0) { + return undefined; + } + return Math.round(next); +} + +function normalizeTtlMs(value: unknown): number { + const next = Number(value); + if (!Number.isFinite(next) || next <= 0) { + return DEFAULT_TTL_MS; + } + return Math.min(Math.max(Math.round(next), 1_000), MAX_TTL_MS); +} + +function hasMatchConstraint(rule: Omit): boolean { + return Boolean(rule.ip || rule.clientSessionKey || rule.host || rule.port || rule.username); +} + +function matchesRule(rule: ArmedTerminalCaptureRule, target: TerminalCaptureMatchTarget): boolean { + if (rule.ip && rule.ip !== target.ip) { + return false; + } + if (rule.clientSessionKey && rule.clientSessionKey !== normalizeString(target.clientSessionKey)) { + return false; + } + if (rule.host && rule.host !== target.host) { + return false; + } + if (rule.port && rule.port !== target.port) { + return false; + } + if (rule.username && rule.username !== target.username) { + return false; + } + return true; +} + +export function createTerminalCaptureArmStore(): TerminalCaptureArmStore { + const rules = new Map(); + + const cleanupExpired = (): void => { + const now = Date.now(); + for (const [id, rule] of rules.entries()) { + if (rule.expiresAt <= now) { + rules.delete(id); + } + } + }; + + return { + arm(input) { + cleanupExpired(); + const captureDir = normalizeString(input.captureDir); + if (!captureDir) { + throw new Error("captureDir 不能为空"); + } + + const normalizedRule = { + captureDir, + ip: normalizeOptionalString(input.ip), + clientSessionKey: normalizeOptionalString(input.clientSessionKey), + host: normalizeOptionalString(input.host), + port: normalizePort(input.port), + username: normalizeOptionalString(input.username) + }; + if (!hasMatchConstraint(normalizedRule)) { + throw new Error("至少需要提供一个匹配条件:ip/clientSessionKey/host/port/username"); + } + + const now = Date.now(); + const rule: ArmedTerminalCaptureRule = { + id: randomUUID(), + createdAt: now, + expiresAt: now + normalizeTtlMs(input.ttlMs), + ...normalizedRule + }; + rules.set(rule.id, rule); + return rule; + }, + take(target) { + cleanupExpired(); + for (const [id, rule] of rules.entries()) { + if (!matchesRule(rule, target)) { + continue; + } + rules.delete(id); + return rule; + } + return null; + }, + list() { + cleanupExpired(); + return Array.from(rules.values()).sort((left, right) => left.createdAt - right.createdAt); + } + }; +} diff --git a/apps/gateway/src/debug/terminalFrameCapture.test.ts b/apps/gateway/src/debug/terminalFrameCapture.test.ts new file mode 100644 index 0000000..5f15899 --- /dev/null +++ b/apps/gateway/src/debug/terminalFrameCapture.test.ts @@ -0,0 +1,78 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { createTerminalFrameCaptureRecorder } from "./terminalFrameCapture"; + +const ORIGINAL_CAPTURE_DIR = process.env.RC_TERMINAL_CAPTURE_DIR; + +afterEach(() => { + if (ORIGINAL_CAPTURE_DIR === undefined) { + delete process.env.RC_TERMINAL_CAPTURE_DIR; + } else { + process.env.RC_TERMINAL_CAPTURE_DIR = ORIGINAL_CAPTURE_DIR; + } +}); + +describe("terminalFrameCapture", () => { + it("未配置目录时不会开启录制", () => { + delete process.env.RC_TERMINAL_CAPTURE_DIR; + const recorder = createTerminalFrameCaptureRecorder({ + ip: "127.0.0.1", + host: "example.com", + port: 22, + username: "gavin" + }); + + expect(recorder).toBeNull(); + }); + + it("会把 meta、frame 和 close 事件写入 jsonl 文件", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "remoteconn-capture-")); + process.env.RC_TERMINAL_CAPTURE_DIR = tempDir; + + const recorder = createTerminalFrameCaptureRecorder({ + ip: "127.0.0.1", + host: "example.com", + port: 22, + username: "gavin", + clientSessionKey: "session-1" + }); + + expect(recorder).not.toBeNull(); + recorder?.record("stdin", "hello\n"); + recorder?.record("stdout", "world\r\n"); + recorder?.close("shell_closed"); + + const filePath = recorder?.filePath; + expect(filePath).toBeTruthy(); + const lines = fs + .readFileSync(String(filePath), "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + + expect(lines[0]).toMatchObject({ + kind: "meta", + host: "example.com", + username: "gavin", + clientSessionKey: "session-1" + }); + expect(lines[1]).toMatchObject({ + kind: "frame", + type: "stdin", + data: "hello\n" + }); + expect(lines[2]).toMatchObject({ + kind: "frame", + type: "stdout", + data: "world\r\n" + }); + expect(lines[3]).toMatchObject({ + kind: "close", + reason: "shell_closed" + }); + + fs.rmSync(tempDir, { recursive: true, force: true }); + }); +}); diff --git a/apps/gateway/src/debug/terminalFrameCapture.ts b/apps/gateway/src/debug/terminalFrameCapture.ts new file mode 100644 index 0000000..22d1fd5 --- /dev/null +++ b/apps/gateway/src/debug/terminalFrameCapture.ts @@ -0,0 +1,97 @@ +import fs from "node:fs"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; + +export type TerminalCaptureFrameType = "stdin" | "stdout" | "stderr"; + +export interface TerminalFrameCaptureRecorder { + filePath: string; + record(type: TerminalCaptureFrameType, data: string): void; + close(reason?: string): void; +} + +interface TerminalFrameCaptureMeta { + ip: string; + host: string; + port: number; + username: string; + clientSessionKey?: string; + captureDir?: string; +} + +function sanitizePathToken(input: string): string { + return String(input || "") + .trim() + .replace(/[^a-zA-Z0-9._-]+/g, "_") + .replace(/^_+|_+$/g, "") || "unknown"; +} + +function resolveCaptureDir(preferredDir?: string): string { + return String(preferredDir || process.env.RC_TERMINAL_CAPTURE_DIR || "").trim(); +} + +export function createTerminalFrameCaptureRecorder( + meta: TerminalFrameCaptureMeta +): TerminalFrameCaptureRecorder | null { + const captureDir = resolveCaptureDir(meta.captureDir); + if (!captureDir) { + return null; + } + + fs.mkdirSync(captureDir, { recursive: true }); + const startedAt = Date.now(); + const fileName = [ + new Date(startedAt).toISOString().replace(/[:.]/g, "-"), + sanitizePathToken(meta.username), + "@", + sanitizePathToken(meta.host), + `-${Number(meta.port) || 22}`, + `-${randomUUID().slice(0, 8)}.jsonl` + ].join(""); + const filePath = path.join(captureDir, fileName); + let closed = false; + + const writeLine = (payload: Record): void => { + fs.appendFileSync(filePath, `${JSON.stringify(payload)}\n`, "utf8"); + }; + + writeLine({ + kind: "meta", + version: 1, + startedAt, + ip: meta.ip, + host: meta.host, + port: meta.port, + username: meta.username, + clientSessionKey: meta.clientSessionKey || "" + }); + + return { + filePath, + record(type, data) { + if (closed || !data) { + return; + } + const now = Date.now(); + writeLine({ + kind: "frame", + at: now, + offsetMs: Math.max(0, now - startedAt), + type, + data + }); + }, + close(reason) { + if (closed) { + return; + } + closed = true; + writeLine({ + kind: "close", + at: Date.now(), + offsetMs: Math.max(0, Date.now() - startedAt), + reason: reason || "" + }); + } + }; +} diff --git a/apps/gateway/src/index.ts b/apps/gateway/src/index.ts new file mode 100644 index 0000000..0a8ef62 --- /dev/null +++ b/apps/gateway/src/index.ts @@ -0,0 +1,9 @@ +import { config } from "./config"; +import { logger } from "./logger"; +import { createGatewayServer } from "./server"; + +const server = createGatewayServer(); + +server.listen(config.port, config.host, () => { + logger.info({ host: config.host, port: config.port }, "gateway started"); +}); diff --git a/apps/gateway/src/logger.ts b/apps/gateway/src/logger.ts new file mode 100644 index 0000000..88dc031 --- /dev/null +++ b/apps/gateway/src/logger.ts @@ -0,0 +1,32 @@ +import pino from "pino"; + +/** + * 将日志时间格式化为紧凑数字串: + * - 目标格式:YYYYMMDDHHmmssSSSS + * - 字段含义:年(4)月(2)日(2)时(2)分(2)秒(2)毫秒(4) + * + * 说明: + * 1) JavaScript 原生毫秒精度是 0-999(三位); + * 2) 这里按产品约定扩展为四位:`毫秒三位 + 末尾补 0`,例如 `120 -> 1200`; + * 3) 使用本地时区,便于和用户界面展示时间对齐。 + */ +function formatCompactLocalTime(now: Date): string { + const year = String(now.getFullYear()); + const month = String(now.getMonth() + 1).padStart(2, "0"); + const day = String(now.getDate()).padStart(2, "0"); + const hour = String(now.getHours()).padStart(2, "0"); + const minute = String(now.getMinutes()).padStart(2, "0"); + const second = String(now.getSeconds()).padStart(2, "0"); + const millisecond4 = `${String(now.getMilliseconds()).padStart(3, "0")}0`; + return `${year}${month}${day}${hour}${minute}${second}${millisecond4}`; +} + +export const logger = pino({ + level: process.env.LOG_LEVEL ?? "info", + /** + * 将默认 epoch 毫秒时间戳(如 1771821864018)改为业务约定数字格式: + * - 输出示例:`"time":202602231245101200` + * - 仍使用 `time` 字段,避免破坏现有日志消费方字段约定。 + */ + timestamp: () => `,"time":${formatCompactLocalTime(new Date())}` +}); diff --git a/apps/gateway/src/security/rateLimit.ts b/apps/gateway/src/security/rateLimit.ts new file mode 100644 index 0000000..f089cab --- /dev/null +++ b/apps/gateway/src/security/rateLimit.ts @@ -0,0 +1,15 @@ +import { RateLimiterMemory } from "rate-limiter-flexible"; +import { config } from "../config"; + +/** + * 连接限流:同一 IP 每分钟限制一定连接次数,防止爆破和资源耗尽。 + * 参数由 config.rateLimitPoints / config.rateLimitDurationSec 控制。 + */ +const limiter = new RateLimiterMemory({ + points: config.rateLimitPoints, + duration: config.rateLimitDurationSec +}); + +export async function checkConnectionRate(ip: string): Promise { + await limiter.consume(ip || "unknown", 1); +} diff --git a/apps/gateway/src/server.ts b/apps/gateway/src/server.ts new file mode 100644 index 0000000..3f6e07b --- /dev/null +++ b/apps/gateway/src/server.ts @@ -0,0 +1,1557 @@ +import http from "node:http"; +import { randomUUID } from "node:crypto"; +import cors from "cors"; +import express from "express"; +import helmet from "helmet"; +import pinoHttp from "pino-http"; +import { WebSocket, WebSocketServer, type RawData } from "ws"; +import { config } from "./config"; +import { logger } from "./logger"; +import { registerSyncRoutes } from "./sync/routes"; +import { checkConnectionRate } from "./security/rateLimit"; +import { createSshSession, type ActiveSshSession } from "./ssh/sshSession"; +import { createAssistTxnDeduper } from "./ws/assistTxnDeduper"; +import { parseInboundFrame, safeSend } from "./ws/protocol"; +import { parseVoiceClientFrame, safeSendVoiceFrame } from "./voice/clientProtocol"; +import { extractAsrText } from "./voice/asrText"; +import { inferAsrJsonFinal, parseLooseJsonPayloads } from "./voice/upstreamPayload"; +import { + createTerminalFrameCaptureRecorder, + type TerminalFrameCaptureRecorder +} from "./debug/terminalFrameCapture"; +import { createTerminalCaptureArmStore } from "./debug/terminalCaptureArm"; +import { + buildAudioOnlyRequestFrame, + buildFullClientRequestFrame, + isFinalServerResponse, + parseVolcServerFrame, + type VolcFullClientRequestPayload +} from "./voice/volcAsrProtocol"; + +type DisconnectActor = "client" | "server" | "network_or_unknown"; + +interface DisconnectLogDetail { + actor: DisconnectActor; + reasonCode: string; + reasonText: string; +} + +interface ResumeSessionEntry { + key: string; + ip: string; + host: string; + port: number; + username: string; + resumeGraceMs: number; + session: ActiveSshSession; + ws: WebSocket | null; + detachedTimer: NodeJS.Timeout | null; + pendingFrames: Array<{ type: "stdout" | "stderr"; data: string }>; + pendingBytes: number; + stdinSeq: number; + closed: boolean; + frameCapture: TerminalFrameCaptureRecorder | null; +} + +const RESUME_PENDING_MAX_BYTES = 64 * 1024; +const VOICE_UPSTREAM_FINALIZE_GRACE_MS = 1_200; + +/** + * 统一断开原因归类: + * 1) reasonCode:机器可检索字段; + * 2) actor:粗粒度责任归因(客户端/服务端/网络或未知); + * 3) reasonText:面向人类阅读的诊断说明。 + */ +function buildDisconnectLogDetail(reasonCode?: string, actorHint?: DisconnectActor): DisconnectLogDetail { + const normalizedReason = reasonCode?.trim() || "unknown"; + + const mappedReason = (() => { + switch (normalizedReason) { + case "manual": + case "switch": + case "host_key_rejected": + case "client_disconnect": + return { actor: "client" as const, reasonText: "客户端主动触发断开" }; + case "auth_failed": + return { actor: "server" as const, reasonText: "服务端鉴权失败后主动断开" }; + case "rate_limit": + return { actor: "server" as const, reasonText: "服务端限流触发断开" }; + case "shell_closed": + return { actor: "server" as const, reasonText: "远端 shell 已关闭" }; + case "connection_closed": + return { actor: "server" as const, reasonText: "SSH 底层连接已关闭" }; + case "ws_error": + return { actor: "server" as const, reasonText: "服务端 WebSocket 处理异常" }; + case "ws_closed": + return { + actor: "network_or_unknown" as const, + reasonText: "WebSocket 已关闭(需结合 close code 继续判定)" + }; + case "ws_peer_normal_close": + return { actor: "client" as const, reasonText: "客户端正常关闭 WebSocket" }; + default: + return null; + } + })(); + + if (mappedReason) { + return { + actor: actorHint ?? mappedReason.actor, + reasonCode: normalizedReason, + reasonText: mappedReason.reasonText + }; + } + + if (normalizedReason.startsWith("ws_close_")) { + return { + actor: actorHint ?? "network_or_unknown", + reasonCode: normalizedReason, + reasonText: "WebSocket 关闭(建议结合 close code 排查客户端/网络链路)" + }; + } + + if (normalizedReason.startsWith("client_")) { + return { + actor: actorHint ?? "client", + reasonCode: normalizedReason, + reasonText: "客户端触发断开" + }; + } + + if (normalizedReason.startsWith("server_")) { + return { + actor: actorHint ?? "server", + reasonCode: normalizedReason, + reasonText: "服务端触发断开" + }; + } + + return { + actor: actorHint ?? "network_or_unknown", + reasonCode: normalizedReason, + reasonText: "未归类断开原因(建议结合上下文日志排查)" + }; +} + +function safeSendIfOpen(ws: WebSocket | null, frame: unknown): void { + if (!ws || ws.readyState !== WebSocket.OPEN) { + return; + } + try { + safeSend(ws, frame); + } catch { + // 连接关闭竞争窗口中允许静默失败。 + } +} + +function safeSendVoiceIfOpen(ws: WebSocket | null, frame: unknown): void { + if (!ws || ws.readyState !== WebSocket.OPEN) { + return; + } + try { + safeSendVoiceFrame(ws, frame); + } catch { + // 连接关闭竞争窗口中允许静默失败。 + } +} + +function rawDataToBuffer(raw: RawData): Buffer { + if (Buffer.isBuffer(raw)) { + return raw; + } + if (raw instanceof ArrayBuffer) { + return Buffer.from(raw); + } + if (Array.isArray(raw)) { + const parts = raw.map((item) => (Buffer.isBuffer(item) ? item : Buffer.from(item))); + return Buffer.concat(parts); + } + return Buffer.from(raw); +} + +function isIgnorableUpstreamFrameParseError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + return error.message.startsWith("invalid volc frame:"); +} + +function normalizeAsrErrorMessage(payload: unknown): string { + if (!payload) { + return "语音识别服务返回错误"; + } + if (typeof payload === "string") { + return payload; + } + if (Buffer.isBuffer(payload)) { + const text = payload.toString("utf8").trim(); + return text || "语音识别服务返回错误"; + } + if (typeof payload === "object") { + const maybeMessage = (payload as Record).message; + if (typeof maybeMessage === "string" && maybeMessage.trim()) { + return maybeMessage; + } + try { + return JSON.stringify(payload); + } catch { + return "语音识别服务返回错误"; + } + } + return String(payload); +} + +function maskSecret(value: string): string { + const raw = String(value ?? ""); + if (!raw) { + return "(empty)"; + } + if (raw.length <= 8) { + return `${raw.slice(0, 1)}***${raw.slice(-1)}`; + } + return `${raw.slice(0, 4)}***${raw.slice(-4)}`; +} + +function normalizeHeaderValue(value: string | string[] | undefined): string { + if (Array.isArray(value)) { + return value.join(", "); + } + return value ?? ""; +} + +function summarizeAsrPayloadShape(payload: unknown): Record { + if (!payload || typeof payload !== "object") { + return { payloadType: typeof payload }; + } + if (Array.isArray(payload)) { + return { payloadType: "array", length: payload.length }; + } + const root = payload as Record; + const result = root.result; + const resultRecord = + result && typeof result === "object" && !Array.isArray(result) + ? (result as Record) + : null; + const payloadMsg = root.payload_msg; + const payloadMsgRecord = + payloadMsg && typeof payloadMsg === "object" && !Array.isArray(payloadMsg) + ? (payloadMsg as Record) + : null; + return { + payloadType: "object", + rootKeys: Object.keys(root), + hasResult: result !== undefined, + resultType: Array.isArray(result) ? "array" : typeof result, + resultKeys: resultRecord ? Object.keys(resultRecord) : [], + hasPayloadMsg: payloadMsg !== undefined, + payloadMsgType: Array.isArray(payloadMsg) ? "array" : typeof payloadMsg, + payloadMsgKeys: payloadMsgRecord ? Object.keys(payloadMsgRecord) : [] + }; +} + +function toAsrPayloadPreview(payload: unknown, maxChars = 512): string { + try { + const raw = typeof payload === "string" ? payload : JSON.stringify(payload); + if (!raw) { + return "(empty)"; + } + if (raw.length <= maxChars) { + return raw; + } + return `${raw.slice(0, maxChars)}...`; + } catch { + return "(unserializable)"; + } +} + +function readHttpBodyPreview( + stream: NodeJS.ReadableStream, + maxBytes = 8192 +): Promise<{ text: string; truncated: boolean }> { + return new Promise((resolve) => { + let kept = 0; + let truncated = false; + const chunks: Buffer[] = []; + + const done = (): void => { + const text = Buffer.concat(chunks).toString("utf8").trim(); + resolve({ text, truncated }); + }; + + stream.on("data", (chunk: unknown) => { + const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk ?? ""), "utf8"); + if (kept >= maxBytes) { + truncated = true; + return; + } + const remain = maxBytes - kept; + if (buf.length <= remain) { + chunks.push(buf); + kept += buf.length; + return; + } + chunks.push(buf.subarray(0, remain)); + kept += remain; + truncated = true; + }); + + stream.once("end", done); + stream.once("close", done); + stream.once("error", () => done()); + }); +} + +function buildAsrRequestPayload(rawPayload: unknown): VolcFullClientRequestPayload { + const input = (rawPayload && typeof rawPayload === "object" ? rawPayload : {}) as { + user?: Record; + audio?: { + format?: "pcm" | "wav" | "ogg" | "mp3"; + codec?: "raw" | "opus"; + rate?: number; + bits?: number; + channel?: number; + language?: string; + }; + request?: Record; + }; + + const audio = input.audio ?? {}; + const rawRequest = input.request ?? {}; + const modelName = + typeof rawRequest.model_name === "string" && rawRequest.model_name.trim() + ? rawRequest.model_name + : "bigmodel"; + const normalizedRequest = { + enable_itn: true, + enable_punc: true, + show_utterances: false, + result_type: "full", + ...rawRequest, + model_name: modelName + } as Record & { model_name: string }; + + return { + ...(input.user ? { user: input.user } : {}), + audio: { + format: audio.format ?? "pcm", + codec: audio.codec ?? "raw", + rate: audio.rate ?? 16000, + bits: audio.bits ?? 16, + channel: audio.channel ?? 1, + ...(audio.language ? { language: audio.language } : {}) + }, + request: normalizedRequest + }; +} + +function canResumeFromClose(reasonCode: string): boolean { + /** + * 说明: + * - 浏览器刷新/页面重载时,服务端有概率收到 1006(未携带 close frame); + * - 将 1006 纳入短时续接,避免“刷新服务器页导致 SSH 立即断开”。 + */ + return [ + "ws_peer_normal_close", + "ws_close_1000", + "ws_close_1001", + "ws_close_1005", + "ws_close_1006" + ].includes(reasonCode); +} + +/** + * 续接驻留窗口收敛: + * 1. 客户端可按连接声明期望时长; + * 2. 服务端仍用默认值兜底,并用最大值做硬上限; + * 3. 最终值至少保留 1 秒,避免异常配置导致“刚挂起就过期”。 + */ +function resolveResumeGraceMs(requestedMs?: number): number { + const fallback = Math.max(1000, config.terminalResumeGraceDefaultMs); + const max = Math.max(fallback, config.terminalResumeGraceMaxMs); + if (!Number.isFinite(requestedMs)) { + return fallback; + } + const normalized = Math.round(Number(requestedMs)); + if (normalized <= 0) { + return fallback; + } + return Math.max(1000, Math.min(normalized, max)); +} + +function isSameResumeTarget( + entry: ResumeSessionEntry, + target: { ip: string; host: string; port: number; username: string } +): boolean { + return ( + entry.ip === target.ip && + entry.host === target.host && + entry.port === target.port && + entry.username === target.username + ); +} + +function clearResumeEntryTimer(entry: ResumeSessionEntry): void { + if (entry.detachedTimer) { + clearTimeout(entry.detachedTimer); + entry.detachedTimer = null; + } +} + +function pushPendingFrame( + entry: ResumeSessionEntry, + frame: { type: "stdout" | "stderr"; data: string } +): void { + entry.pendingFrames.push(frame); + entry.pendingBytes += Buffer.byteLength(frame.data, "utf8"); + while (entry.pendingBytes > RESUME_PENDING_MAX_BYTES && entry.pendingFrames.length > 0) { + const shifted = entry.pendingFrames.shift(); + if (!shifted) { + break; + } + entry.pendingBytes = Math.max(0, entry.pendingBytes - Buffer.byteLength(shifted.data, "utf8")); + } +} + +function flushPendingFrames(entry: ResumeSessionEntry): void { + if (!entry.ws || entry.ws.readyState !== WebSocket.OPEN || entry.pendingFrames.length === 0) { + return; + } + for (const frame of entry.pendingFrames) { + safeSendIfOpen(entry.ws, { type: frame.type, payload: { data: frame.data } }); + } + entry.pendingFrames = []; + entry.pendingBytes = 0; +} + +export function createGatewayServer(): http.Server { + const app = express(); + app.use(helmet()); + app.use(cors({ origin: config.corsOrigin === "*" ? true : config.corsOrigin })); + app.use(express.json({ limit: "1mb" })); + app.use(pinoHttp({ logger })); + + app.get("/health", (_req, res) => { + res.json({ ok: true, service: "remoteconn-gateway", ts: new Date().toISOString() }); + }); + + app.get("/version", (_req, res) => { + res.json({ version: "2.4.0", now: new Date().toISOString() }); + }); + + registerSyncRoutes(app); + + const server = http.createServer(app); + // 生产默认关闭 WebSocket 压缩,避免终端/语音高频小包触发无谓 CPU 消耗。 + const wss = new WebSocketServer({ noServer: true, perMessageDeflate: false }); + const asrWss = new WebSocketServer({ noServer: true, perMessageDeflate: false }); + const assistTxnTtlMs = config.assistTxnTtlMs; + const assistTxnCacheLimit = config.assistTxnCacheLimit; + const resumeSessions = new Map(); + const captureArmStore = createTerminalCaptureArmStore(); + + app.post("/debug/terminal-captures/arm", (req, res) => { + const tokenByQuery = String(req.query?.token || ""); + const token = String(req.headers["x-gateway-token"] || tokenByQuery); + if (token !== config.gatewayToken) { + res.status(401).json({ ok: false, error: "token 无效" }); + return; + } + try { + const body = req.body && typeof req.body === "object" ? req.body : {}; + const rule = captureArmStore.arm({ + captureDir: String(body.captureDir || ""), + ttlMs: Number(body.ttlMs) || 5 * 60 * 1000, + ip: body.ip, + clientSessionKey: body.clientSessionKey, + host: body.host, + port: body.port, + username: body.username + }); + res.json({ ok: true, rule, activeRules: captureArmStore.list() }); + } catch (error) { + res.status(400).json({ + ok: false, + error: error instanceof Error ? error.message : String(error || "arm failed") + }); + } + }); + + /** + * 统一升级路由,避免多 WebSocketServer(path=...) 在同一 HTTP server 上互相抢占 upgrade 事件。 + */ + server.on("upgrade", (req, socket, head) => { + let pathname = ""; + try { + const requestUrl = new URL(req.url ?? "", "http://gateway.local"); + pathname = requestUrl.pathname; + } catch { + socket.destroy(); + return; + } + + const upgrade = (target: WebSocketServer): void => { + target.handleUpgrade(req, socket, head, (client) => { + target.emit("connection", client, req); + }); + }; + + if (pathname === "/ws/terminal") { + upgrade(wss); + return; + } + if (pathname === "/ws/asr") { + upgrade(asrWss); + return; + } + + socket.write("HTTP/1.1 404 Not Found\\r\\n\\r\\n"); + socket.destroy(); + }); + + wss.on("connection", async (ws, req) => { + const ip = req.socket.remoteAddress ?? "unknown"; + const url = new URL(req.url ?? "", "http://gateway.local"); + const tokenByQuery = url.searchParams.get("token") ?? ""; + const token = String(req.headers["x-gateway-token"] || tokenByQuery); + let disconnectDetail: DisconnectLogDetail | null = null; + + /** + * 仅在“新信息更明确”时覆盖当前断开原因: + * - 首次写入直接采纳; + * - 当前是 unknown/network_or_unknown 且新值是 client/server 时允许覆盖; + * - 避免同一连接在多处 close 回调里反复改写原因,导致日志不稳定。 + */ + const rememberDisconnect = (reasonCode?: string, actorHint?: DisconnectActor): DisconnectLogDetail => { + const next = buildDisconnectLogDetail(reasonCode, actorHint); + if (!disconnectDetail) { + disconnectDetail = next; + return disconnectDetail; + } + const currentWeak = + disconnectDetail.reasonCode === "unknown" || disconnectDetail.actor === "network_or_unknown"; + const nextStrong = next.actor === "client" || next.actor === "server"; + if (currentWeak && nextStrong) { + disconnectDetail = next; + } + return disconnectDetail; + }; + + if (token !== config.gatewayToken) { + const detail = rememberDisconnect("auth_failed", "server"); + logger.warn( + { + ip, + disconnectActor: detail.actor, + disconnectReasonCode: detail.reasonCode, + disconnectReasonText: detail.reasonText + }, + "拒绝终端连接" + ); + safeSendIfOpen(ws, { + type: "error", + payload: { + code: "AUTH_FAILED", + message: "token 无效" + } + }); + ws.close(); + return; + } + + try { + await checkConnectionRate(ip); + } catch { + const detail = rememberDisconnect("rate_limit", "server"); + logger.warn( + { + ip, + disconnectActor: detail.actor, + disconnectReasonCode: detail.reasonCode, + disconnectReasonText: detail.reasonText + }, + "拒绝终端连接" + ); + safeSendIfOpen(ws, { + type: "error", + payload: { + code: "RATE_LIMIT", + message: "连接过于频繁,请稍后重试" + } + }); + ws.close(); + return; + } + + logger.info({ ip }, "新的终端连接"); + + let session: ActiveSshSession | null = null; + let heartbeat: NodeJS.Timeout | undefined; + let stdinSeq = 0; + let resumeEntry: ResumeSessionEntry | null = null; + let frameCapture: TerminalFrameCaptureRecorder | null = null; + const isDuplicateAssistTxn = createAssistTxnDeduper({ + ttlMs: assistTxnTtlMs, + cacheLimit: assistTxnCacheLimit + }); + + const stopHeartbeat = (): void => { + if (heartbeat) { + clearInterval(heartbeat); + heartbeat = undefined; + } + }; + + const cleanupResumeEntry = (entry: ResumeSessionEntry | null): void => { + if (!entry) { + return; + } + clearResumeEntryTimer(entry); + entry.ws = null; + if (resumeSessions.get(entry.key) === entry) { + resumeSessions.delete(entry.key); + } + }; + + const attachResumeEntry = (entry: ResumeSessionEntry): void => { + clearResumeEntryTimer(entry); + entry.ws = ws; + session = entry.session; + stdinSeq = entry.stdinSeq; + resumeEntry = entry; + frameCapture = entry.frameCapture; + safeSendIfOpen(ws, { type: "control", payload: { action: "connected", resumed: true } }); + flushPendingFrames(entry); + heartbeat = setInterval(() => { + safeSendIfOpen(ws, { type: "control", payload: { action: "ping" } }); + }, config.sshKeepaliveIntervalMs); + }; + + ws.on("message", async (raw) => { + try { + const frame = parseInboundFrame(raw.toString("utf8")); + + if (frame.type === "control" && frame.payload.action === "ping") { + safeSendIfOpen(ws, { + type: "control", + payload: { + action: "pong" + } + }); + return; + } + + if (frame.type === "control" && frame.payload.action === "pong") { + return; + } + + if (frame.type === "control" && frame.payload.action === "disconnect") { + const detail = rememberDisconnect(frame.payload.reason ?? "client_disconnect", "client"); + logger.info( + { + ip, + disconnectActor: detail.actor, + disconnectReasonCode: detail.reasonCode, + disconnectReasonText: detail.reasonText + }, + "收到客户端断开请求" + ); + cleanupResumeEntry(resumeEntry); + session?.close(frame.payload.reason ?? "client_disconnect"); + ws.close(); + return; + } + + if (frame.type === "init") { + if (session) { + safeSendIfOpen(ws, { + type: "error", + payload: { + code: "ALREADY_INITIALIZED", + message: "会话已初始化" + } + }); + return; + } + + const resumeKey = String(frame.payload.clientSessionKey ?? "").trim(); + const resumeGraceMs = resolveResumeGraceMs(frame.payload.resumeGraceMs); + if (resumeKey) { + const existing = resumeSessions.get(resumeKey); + if ( + existing && + !existing.closed && + existing.ws === null && + isSameResumeTarget(existing, { + ip, + host: frame.payload.host, + port: frame.payload.port, + username: frame.payload.username + }) + ) { + existing.resumeGraceMs = resumeGraceMs; + attachResumeEntry(existing); + logger.info({ ip, resumeKey }, "终端会话续接成功"); + return; + } + if (existing && !existing.closed) { + existing.closed = true; + clearResumeEntryTimer(existing); + existing.ws = null; + resumeSessions.delete(resumeKey); + existing.session.close("resume_replaced"); + logger.warn({ ip, resumeKey }, "检测到会话键冲突,已替换旧会话"); + } + } + + const armedCaptureRule = captureArmStore.take({ + ip, + clientSessionKey: resumeKey, + host: frame.payload.host, + port: frame.payload.port, + username: frame.payload.username + }); + + frameCapture = createTerminalFrameCaptureRecorder({ + ip, + host: frame.payload.host, + port: frame.payload.port, + username: frame.payload.username, + clientSessionKey: resumeKey, + captureDir: armedCaptureRule?.captureDir + }); + if (frameCapture) { + logger.info( + { ip, captureFile: frameCapture.filePath, armedRuleId: armedCaptureRule?.id || "" }, + "终端帧录制已开启" + ); + } + + session = await createSshSession({ + host: frame.payload.host, + port: frame.payload.port, + username: frame.payload.username, + credential: frame.payload.credential, + jumpHost: frame.payload.jumpHost, + knownHostFingerprint: frame.payload.knownHostFingerprint, + pty: frame.payload.pty, + onHostFingerprint({ fingerprint, hostPort }) { + safeSendIfOpen(resumeEntry?.ws ?? ws, { + type: "control", + payload: { + action: "connected", + fingerprint, + fingerprintHostPort: hostPort + } + }); + }, + onStdout(data) { + (resumeEntry?.frameCapture ?? frameCapture)?.record("stdout", data); + if (resumeEntry) { + if (resumeEntry.ws) { + safeSendIfOpen(resumeEntry.ws, { type: "stdout", payload: { data } }); + } else { + pushPendingFrame(resumeEntry, { type: "stdout", data }); + } + return; + } + safeSendIfOpen(ws, { type: "stdout", payload: { data } }); + }, + onStderr(data) { + (resumeEntry?.frameCapture ?? frameCapture)?.record("stderr", data); + if (resumeEntry) { + if (resumeEntry.ws) { + safeSendIfOpen(resumeEntry.ws, { type: "stderr", payload: { data } }); + } else { + pushPendingFrame(resumeEntry, { type: "stderr", data }); + } + return; + } + safeSendIfOpen(ws, { type: "stderr", payload: { data } }); + }, + onClose(reason) { + (resumeEntry?.frameCapture ?? frameCapture)?.close(reason); + const detail = rememberDisconnect(reason, "server"); + logger.info( + { + ip, + disconnectActor: detail.actor, + disconnectReasonCode: detail.reasonCode, + disconnectReasonText: detail.reasonText + }, + "SSH 会话断开" + ); + if (resumeEntry) { + resumeEntry.closed = true; + cleanupResumeEntry(resumeEntry); + } + safeSendIfOpen(resumeEntry?.ws ?? ws, { + type: "control", + payload: { action: "disconnect", reason } + }); + } + }); + + if (resumeKey) { + resumeEntry = { + key: resumeKey, + ip, + host: frame.payload.host, + port: frame.payload.port, + username: frame.payload.username, + resumeGraceMs, + session, + ws, + detachedTimer: null, + pendingFrames: [], + pendingBytes: 0, + stdinSeq: 0, + closed: false, + frameCapture + }; + resumeSessions.set(resumeKey, resumeEntry); + } + + // 会话真正建立(shell ready)后发送 connected,供前端切换到可输入状态。 + safeSendIfOpen(ws, { type: "control", payload: { action: "connected" } }); + + // 心跳:由网关定期向客户端发 ping,客户端不响应时将会被底层断开。 + heartbeat = setInterval(() => { + safeSendIfOpen(ws, { type: "control", payload: { action: "ping" } }); + }, config.sshKeepaliveIntervalMs); + + return; + } + + if (frame.type === "stdin") { + stdinSeq += 1; + if (resumeEntry) { + resumeEntry.stdinSeq = stdinSeq; + } + const source = frame.payload.meta?.source; + const txnId = frame.payload.meta?.txnId; + if (isDuplicateAssistTxn(source, txnId)) { + return; + } + (resumeEntry?.frameCapture ?? frameCapture)?.record("stdin", frame.payload.data); + session?.write(frame.payload.data, stdinSeq); + return; + } + + if (frame.type === "resize") { + session?.resize(frame.payload.cols, frame.payload.rows); + } + } catch (error) { + logger.warn({ error, ip }, "处理 WS 消息失败"); + safeSendIfOpen(ws, { + type: "error", + payload: { + code: "BAD_REQUEST", + message: (error as Error).message + } + }); + } + }); + + ws.on("close", (code, rawReason) => { + if (!disconnectDetail) { + if (code === 1000) { + rememberDisconnect("ws_peer_normal_close", "client"); + } else { + rememberDisconnect(`ws_close_${code}`, "network_or_unknown"); + } + } + const detail = disconnectDetail ?? buildDisconnectLogDetail("unknown"); + stopHeartbeat(); + + const canPark = + Boolean(resumeEntry?.key) && + Boolean(session) && + !resumeEntry?.closed && + canResumeFromClose(detail.reasonCode); + + if (canPark && resumeEntry && session) { + const parkedEntry = resumeEntry; + parkedEntry.ws = null; + parkedEntry.stdinSeq = stdinSeq; + clearResumeEntryTimer(parkedEntry); + parkedEntry.detachedTimer = setTimeout(() => { + if (parkedEntry.closed) { + cleanupResumeEntry(parkedEntry); + return; + } + parkedEntry.closed = true; + cleanupResumeEntry(parkedEntry); + parkedEntry.session.close("resume_timeout"); + logger.info({ ip, resumeKey: parkedEntry.key }, "终端续接超时,已关闭驻留 SSH 会话"); + }, parkedEntry.resumeGraceMs); + session = null; + logger.info( + { + ip, + wsCloseCode: code, + wsCloseReason: rawReason.toString("utf8") || null, + disconnectActor: detail.actor, + disconnectReasonCode: detail.reasonCode, + disconnectReasonText: detail.reasonText, + resumeKey: parkedEntry.key, + resumeGraceMs: parkedEntry.resumeGraceMs + }, + "终端连接关闭(会话驻留等待续接)" + ); + return; + } + + cleanupResumeEntry(resumeEntry); + session?.close("ws_closed"); + session = null; + logger.info( + { + ip, + wsCloseCode: code, + wsCloseReason: rawReason.toString("utf8") || null, + disconnectActor: detail.actor, + disconnectReasonCode: detail.reasonCode, + disconnectReasonText: detail.reasonText + }, + "终端连接关闭" + ); + }); + + ws.on("error", (error) => { + const detail = rememberDisconnect("ws_error", "server"); + logger.warn( + { + ip, + error, + disconnectActor: detail.actor, + disconnectReasonCode: detail.reasonCode, + disconnectReasonText: detail.reasonText + }, + "ws 错误" + ); + stopHeartbeat(); + cleanupResumeEntry(resumeEntry); + session?.close("ws_error"); + session = null; + }); + }); + + asrWss.on("connection", async (ws, req) => { + const ip = req.socket.remoteAddress ?? "unknown"; + const voiceConnLogger = logger.child({ + loggerName: "com.remoteconn.gateway", + module: "voice", + ip + }); + const url = new URL(req.url ?? "", "http://gateway.local"); + const tokenByQuery = url.searchParams.get("token") ?? ""; + const token = String(req.headers["x-gateway-token"] || tokenByQuery); + + if (token !== config.gatewayToken) { + voiceConnLogger.warn({ event: "auth_failed" }, "拒绝语音连接:token 无效"); + safeSendVoiceIfOpen(ws, { + type: "error", + payload: { + code: "AUTH_FAILED", + message: "token 无效" + } + }); + ws.close(); + return; + } + + try { + await checkConnectionRate(ip); + } catch { + voiceConnLogger.warn({ event: "rate_limited" }, "拒绝语音连接:触发限流"); + safeSendVoiceIfOpen(ws, { + type: "error", + payload: { + code: "RATE_LIMIT", + message: "连接过于频繁,请稍后重试" + } + }); + ws.close(); + return; + } + + if (!config.asr.appId || !config.asr.accessToken || !config.asr.resourceId || !config.asr.wsUrl) { + voiceConnLogger.error({ event: "config_missing" }, "语音服务配置缺失"); + safeSendVoiceIfOpen(ws, { + type: "error", + payload: { + code: "ASR_CONFIG_MISSING", + message: "语音服务配置缺失,请检查网关环境变量" + } + }); + ws.close(); + return; + } + + let upstream: WebSocket | null = null; + let startSent = false; + let stopSent = false; + let readySent = false; + let handshakeFailureNotified = false; + const connectId = randomUUID(); + const voiceLogger = voiceConnLogger.child({ connectId }); + let clientAudioFrameCount = 0; + let clientAudioBytes = 0; + let upstreamResponseCount = 0; + let upstreamNonEmptyTextCount = 0; + let upstreamEmptyTextWarnLogged = 0; + let upstreamEmptyTextWarnSuppressed = 0; + let upstreamJsonTextFrameCount = 0; + let upstreamFallbackJsonFrameCount = 0; + let pendingFinalizeTimer: NodeJS.Timeout | null = null; + + const closeUpstream = (reason = "proxy_closed"): void => { + if (pendingFinalizeTimer) { + clearTimeout(pendingFinalizeTimer); + pendingFinalizeTimer = null; + } + if (!upstream) { + return; + } + if (upstream.readyState === WebSocket.OPEN || upstream.readyState === WebSocket.CONNECTING) { + upstream.close(1000, reason); + } + upstream = null; + }; + + const sendFinalStopBeforeClose = (reason: string): void => { + if (!upstream || upstream.readyState !== WebSocket.OPEN || !startSent || stopSent) { + closeUpstream(reason); + return; + } + + try { + upstream.send(buildAudioOnlyRequestFrame(Buffer.alloc(0), true)); + stopSent = true; + voiceLogger.warn( + { + event: "upstream_finalize_on_client_close", + reason, + clientAudioFrameCount, + clientAudioBytes + }, + "检测到客户端异常收尾,已补发 stop 结束包" + ); + } catch (error) { + voiceLogger.warn( + { event: "upstream_finalize_send_failed", reason, error }, + "补发 stop 失败,直接关闭上游" + ); + closeUpstream(reason); + return; + } + + if (pendingFinalizeTimer) { + clearTimeout(pendingFinalizeTimer); + } + pendingFinalizeTimer = setTimeout(() => { + pendingFinalizeTimer = null; + closeUpstream(reason); + }, VOICE_UPSTREAM_FINALIZE_GRACE_MS); + }; + + const forwardJsonPayloadIfMeaningful = (params: { + payload: unknown; + source: "json_text" | "json_binary_fallback"; + sequence: number | null; + flags: number; + }): void => { + if (params.payload && typeof params.payload === "object" && !Array.isArray(params.payload)) { + const keys = Object.keys(params.payload as Record); + if (keys.length === 0) { + voiceLogger.info( + { + event: "upstream_empty_json_payload_ignored", + source: params.source, + sequence: params.sequence + }, + "忽略空 JSON 上游帧" + ); + return; + } + } + + forwardAsrPayload({ + payload: params.payload, + flags: params.flags, + sequence: params.sequence, + isFinal: inferAsrJsonFinal(params.payload), + source: params.source + }); + }; + + try { + const upstreamUrl = new URL(config.asr.wsUrl); + if (config.asr.cluster && !upstreamUrl.searchParams.has("cluster")) { + upstreamUrl.searchParams.set("cluster", config.asr.cluster); + } + + const headers: Record = { + "X-Api-App-Key": config.asr.appId, + "X-Api-Access-Key": config.asr.accessToken, + "X-Api-Resource-Id": config.asr.resourceId, + "X-Api-Connect-Id": connectId + }; + if (config.asr.cluster) { + headers["X-Api-Cluster"] = config.asr.cluster; + } + + voiceLogger.info( + { + event: "upstream_connect_prepare", + upstreamUrl: upstreamUrl.toString(), + upstreamPath: upstreamUrl.pathname, + asrUpstreamHeaders: { + "X-Api-App-Key": headers["X-Api-App-Key"], + "X-Api-Access-Key": maskSecret(headers["X-Api-Access-Key"] ?? ""), + "X-Api-Resource-Id": headers["X-Api-Resource-Id"], + "X-Api-Connect-Id": headers["X-Api-Connect-Id"] + }, + resourceIdMatchesSeedAsr2Duration: headers["X-Api-Resource-Id"] === "volc.seedasr.sauc.duration" + }, + "语音上游建连参数(已打码)" + ); + + // 语音链路以小包高频传输为主,禁用压缩可显著降低 CPU 抖动与时延尾部。 + upstream = new WebSocket(upstreamUrl, { headers, perMessageDeflate: false }); + } catch (error) { + voiceLogger.error({ event: "upstream_init_failed", error }, "连接语音上游失败"); + safeSendVoiceIfOpen(ws, { + type: "error", + payload: { + code: "ASR_UPSTREAM_INIT_FAILED", + message: "语音服务初始化失败" + } + }); + ws.close(); + return; + } + + voiceLogger.info({ event: "connection_opened" }, "新的语音连接"); + + upstream.on("upgrade", (res) => { + if (readySent) { + return; + } + const rawLogId = res.headers["x-tt-logid"]; + const logId = Array.isArray(rawLogId) ? (rawLogId[0] ?? "") : (rawLogId ?? ""); + voiceLogger.info( + { + event: "upstream_handshake_ok", + statusCode: res.statusCode, + ttLogId: logId, + upgradeHeaders: { + "x-tt-logid": logId, + "x-tt-trace-id": normalizeHeaderValue(res.headers["x-tt-trace-id"]), + connection: normalizeHeaderValue(res.headers.connection), + upgrade: normalizeHeaderValue(res.headers.upgrade) + } + }, + "语音上游握手成功" + ); + safeSendVoiceIfOpen(ws, { + type: "ready", + payload: { + connectId, + logId + } + }); + readySent = true; + }); + + upstream.on("open", () => { + if (readySent) { + return; + } + safeSendVoiceIfOpen(ws, { + type: "ready", + payload: { connectId } + }); + readySent = true; + }); + + upstream.on("unexpected-response", async (_request, response) => { + handshakeFailureNotified = true; + const rawLogId = response.headers["x-tt-logid"]; + const logId = Array.isArray(rawLogId) ? (rawLogId[0] ?? "") : (rawLogId ?? ""); + const body = await readHttpBodyPreview(response, 8192); + voiceLogger.warn( + { + event: "upstream_handshake_failed", + statusCode: response.statusCode, + statusMessage: response.statusMessage ?? "", + ttLogId: logId, + responseHeaders: response.headers, + responseBodyPreview: body.text || "(empty)", + responseBodyTruncated: body.truncated + }, + "语音上游握手失败响应" + ); + safeSendVoiceIfOpen(ws, { + type: "error", + payload: { + code: "ASR_UPSTREAM_HANDSHAKE_FAILED", + message: + `语音上游握手失败: HTTP ${response.statusCode ?? 0} ${response.statusMessage ?? ""}`.trim(), + details: { + statusCode: response.statusCode ?? 0, + ttLogId: logId + } + } + }); + }); + + ws.on("message", (raw, isBinary) => { + try { + const frame = parseVoiceClientFrame(raw, isBinary); + if (frame.type === "ping") { + safeSendVoiceIfOpen(ws, { type: "pong" }); + return; + } + + if (!upstream || upstream.readyState !== WebSocket.OPEN) { + throw new Error("语音上游未就绪"); + } + + if (frame.type === "start") { + if (startSent) { + return; + } + const payload = buildAsrRequestPayload(frame.payload); + upstream.send(buildFullClientRequestFrame(payload)); + startSent = true; + stopSent = false; + voiceLogger.info( + { + event: "round_start", + requestModel: payload.request.model_name + }, + "语音轮次开始" + ); + return; + } + + if (frame.type === "audio") { + if (!startSent || stopSent) { + return; + } + clientAudioFrameCount += 1; + clientAudioBytes += frame.payload.length; + upstream.send(buildAudioOnlyRequestFrame(frame.payload, false)); + return; + } + + if (frame.type === "stop") { + if (!startSent || stopSent) { + return; + } + stopSent = true; + upstream.send(buildAudioOnlyRequestFrame(Buffer.alloc(0), true)); + voiceLogger.info( + { + event: "round_stop", + clientAudioFrameCount, + clientAudioBytes + }, + "语音轮次停止" + ); + return; + } + + if (frame.type === "cancel") { + closeUpstream("client_cancel"); + voiceLogger.info({ event: "round_cancel" }, "语音轮次取消"); + if (ws.readyState === WebSocket.OPEN) { + ws.close(1000, "cancel"); + } + } + } catch (error) { + voiceLogger.warn({ event: "client_frame_handle_failed", error }, "处理语音消息失败"); + safeSendVoiceIfOpen(ws, { + type: "error", + payload: { + code: "ASR_BAD_REQUEST", + message: (error as Error).message + } + }); + } + }); + + const forwardAsrPayload = (params: { + payload: unknown; + flags: number; + sequence: number | null; + isFinal: boolean; + source: "volc_binary" | "json_text" | "json_binary_fallback"; + }): void => { + upstreamResponseCount += 1; + const text = extractAsrText(params.payload); + if (text) { + upstreamNonEmptyTextCount += 1; + } + + if (params.isFinal) { + const payloadShape = summarizeAsrPayloadShape(params.payload); + voiceLogger.info( + { + event: "upstream_result_final", + flags: params.flags, + sequence: params.sequence, + source: params.source, + textLength: text.length, + upstreamResponseCount, + upstreamNonEmptyTextCount, + payloadShape + }, + "语音上游结果摘要" + ); + } + + if (!text) { + const overWarnLimit = upstreamEmptyTextWarnLogged >= config.asr.emptyTextWarnLimit; + if (overWarnLimit && !params.isFinal) { + upstreamEmptyTextWarnSuppressed += 1; + } else { + upstreamEmptyTextWarnLogged += 1; + const payloadShape = summarizeAsrPayloadShape(params.payload); + voiceLogger.warn( + { + event: "upstream_result_empty_text", + flags: params.flags, + sequence: params.sequence, + source: params.source, + textLength: 0, + upstreamResponseCount, + upstreamNonEmptyTextCount, + upstreamEmptyTextWarnLogged, + upstreamEmptyTextWarnSuppressed, + payloadShape, + payloadPreview: toAsrPayloadPreview(params.payload) + }, + "语音上游返回结果但未提取到文本" + ); + } + } + + const resultPayload: Record = { + text, + isFinal: params.isFinal, + sequence: params.sequence + }; + if (config.asr.includeRawResult) { + // 生产默认关闭,按需打开用于排障。 + resultPayload.result = params.payload; + } + + safeSendVoiceIfOpen(ws, { + type: "result", + payload: resultPayload + }); + + if (!params.isFinal) { + return; + } + + safeSendVoiceIfOpen(ws, { + type: "round_end", + payload: { + connectId + } + }); + voiceLogger.info( + { + event: "round_end", + source: params.source, + sequence: params.sequence, + textLength: text.length, + upstreamEmptyTextWarnLogged, + upstreamEmptyTextWarnSuppressed + }, + "语音轮次结束" + ); + closeUpstream("round_done"); + }; + + upstream.on("message", (raw, isBinary) => { + try { + if (!isBinary) { + const text = typeof raw === "string" ? raw : rawDataToBuffer(raw).toString("utf8"); + const jsonPayloads = parseLooseJsonPayloads(text); + if (jsonPayloads.length > 0) { + upstreamJsonTextFrameCount += 1; + if (upstreamJsonTextFrameCount <= 2 || jsonPayloads.length > 1) { + voiceLogger.info( + { + event: "upstream_text_frame_parsed", + payloadCount: jsonPayloads.length, + frameCount: upstreamJsonTextFrameCount + }, + "收到上游文本帧,按 JSON 兼容解析" + ); + } + for (const payload of jsonPayloads) { + forwardJsonPayloadIfMeaningful({ + payload, + flags: 0, + sequence: null, + source: "json_text" + }); + } + return; + } + voiceLogger.info( + { + event: "upstream_text_frame_ignored", + length: text.length, + preview: text.slice(0, 200) + }, + "收到上游文本帧(已忽略)" + ); + return; + } + const parsed = parseVolcServerFrame(rawDataToBuffer(raw)); + if (parsed.kind === "server_response") { + forwardAsrPayload({ + payload: parsed.payload, + flags: parsed.flags, + sequence: parsed.sequence, + isFinal: isFinalServerResponse(parsed.flags), + source: "volc_binary" + }); + return; + } + + if (parsed.kind === "error") { + safeSendVoiceIfOpen(ws, { + type: "error", + payload: { + code: `ASR_${parsed.errorCode}`, + message: normalizeAsrErrorMessage(parsed.payload) + } + }); + return; + } + + safeSendVoiceIfOpen(ws, { + type: "event", + payload: { + messageType: parsed.messageType + } + }); + } catch (error) { + if (isIgnorableUpstreamFrameParseError(error)) { + const rawBuffer = rawDataToBuffer(raw); + const fallbackText = rawBuffer.toString("utf8"); + const jsonPayloads = parseLooseJsonPayloads(fallbackText); + if (jsonPayloads.length > 0) { + upstreamFallbackJsonFrameCount += 1; + if (upstreamFallbackJsonFrameCount <= 2 || jsonPayloads.length > 1) { + voiceLogger.info( + { + event: "upstream_frame_fallback_json", + frameLength: rawBuffer.length, + payloadCount: jsonPayloads.length, + frameCount: upstreamFallbackJsonFrameCount + }, + "语音上游返回非标准帧,已按 JSON 兼容解析" + ); + } + for (const payload of jsonPayloads) { + forwardJsonPayloadIfMeaningful({ + payload, + flags: 0, + sequence: null, + source: "json_binary_fallback" + }); + } + return; + } + voiceLogger.warn( + { + event: "upstream_frame_ignored_invalid", + error: (error as Error).message, + frameLength: rawBuffer.length, + payloadPreview: fallbackText.slice(0, 200) + }, + "语音上游返回非标准帧,已忽略" + ); + return; + } + voiceLogger.warn({ event: "upstream_frame_parse_failed", error }, "解析语音上游消息失败"); + safeSendVoiceIfOpen(ws, { + type: "error", + payload: { + code: "ASR_PARSE_FAILED", + message: (error as Error).message + } + }); + } + }); + + upstream.on("error", (error) => { + const detail = error instanceof Error ? error.message : String(error); + voiceLogger.warn( + { event: "upstream_error", error: detail, handshakeFailureNotified }, + "语音上游连接错误" + ); + if (handshakeFailureNotified) { + return; + } + safeSendVoiceIfOpen(ws, { + type: "error", + payload: { + code: "ASR_UPSTREAM_ERROR", + message: `语音上游连接异常: ${detail}` + } + }); + }); + + upstream.on("close", (code, reason) => { + const reasonText = reason.toString("utf8") || `upstream_close_${code}`; + voiceLogger.info({ event: "upstream_close", code, reason: reasonText }, "语音上游连接关闭"); + safeSendVoiceIfOpen(ws, { + type: "closed", + payload: { + code, + reason: reasonText + } + }); + if (ws.readyState === WebSocket.OPEN) { + ws.close(1000, "asr_done"); + } + upstream = null; + }); + + ws.on("close", () => { + voiceLogger.info( + { + event: "connection_closed", + startSent, + stopSent, + clientAudioFrameCount, + clientAudioBytes, + upstreamResponseCount, + upstreamNonEmptyTextCount, + upstreamEmptyTextWarnLogged, + upstreamEmptyTextWarnSuppressed, + upstreamJsonTextFrameCount, + upstreamFallbackJsonFrameCount + }, + "语音连接关闭" + ); + sendFinalStopBeforeClose("client_closed"); + }); + + ws.on("error", (error) => { + voiceLogger.warn({ event: "connection_error", error }, "语音连接异常"); + sendFinalStopBeforeClose("client_error"); + }); + }); + + return server; +} diff --git a/apps/gateway/src/ssh/sshSession.test.ts b/apps/gateway/src/ssh/sshSession.test.ts new file mode 100644 index 0000000..f7a8bb2 --- /dev/null +++ b/apps/gateway/src/ssh/sshSession.test.ts @@ -0,0 +1,421 @@ +import { EventEmitter } from "node:events"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { connectSpy, streamWriteSpy } = vi.hoisted(() => ({ + connectSpy: vi.fn(), + streamWriteSpy: vi.fn() +})); + +let capturedStream: MockChannel | null = null; + +class MockChannel extends EventEmitter { + public stderr = { + on: (_event: string, _listener: (chunk: Buffer) => void) => { + return this.stderr; + } + }; + + public write(data: string | Buffer): void { + streamWriteSpy(data); + } + + public setWindow(_rows: number, _cols: number, _height: number, _width: number): void { + // no-op for tests + } + + public close(): void { + this.emit("close"); + } +} + +class MockForwardStream extends EventEmitter {} + +vi.mock("ssh2", () => ({ + Client: class MockClient extends EventEmitter { + public connect(config: unknown): this { + connectSpy(config); + const host = (config as { host?: string })?.host; + if (host === "ready-host" || host === "jump-host") { + queueMicrotask(() => this.emit("ready")); + } else { + queueMicrotask(() => this.emit("error", new Error("mock connect failed"))); + } + return this; + } + + public shell( + _options: unknown, + callback: (shellError: Error | undefined, stream: MockChannel) => void + ): void { + const ch = new MockChannel(); + capturedStream = ch; + callback(undefined, ch); + } + + public forwardOut( + _srcIP: string, + _srcPort: number, + _dstIP: string, + _dstPort: number, + callback: (error: Error | undefined, stream: MockForwardStream) => void + ): void { + callback(undefined, new MockForwardStream()); + } + + public end(): void { + // 模拟 ssh2 客户端 end 行为,无需实际动作。 + } + } +})); + +import { createSshSession, encodeInputForSsh, INIT_BEGIN_FOR_TEST, INIT_DONE_FOR_TEST } from "./sshSession"; + +describe("sshSession", () => { + beforeEach(() => { + connectSpy.mockReset(); + streamWriteSpy.mockReset(); + capturedStream = null; + }); + + it("密码认证显式优先 keyboard-interactive 再回退 password", async () => { + await expect( + createSshSession({ + host: "127.0.0.1", + port: 22, + username: "gavin", + credential: { type: "password", password: "secret" }, + pty: { cols: 80, rows: 24 }, + onStdout: () => {}, + onStderr: () => {}, + onClose: () => {} + }) + ).rejects.toThrow("mock connect failed"); + + expect(connectSpy).toHaveBeenCalledTimes(1); + const config = connectSpy.mock.calls[0]?.[0] as { + authHandler?: Array<{ type: string; username: string; password?: string; prompt?: (...args: unknown[]) => void }>; + tryKeyboard?: boolean; + password?: string; + }; + expect(config.password).toBe("secret"); + expect(config.tryKeyboard).toBe(true); + expect(config.authHandler).toHaveLength(2); + expect(config.authHandler?.[0]?.type).toBe("keyboard-interactive"); + expect(config.authHandler?.[0]?.username).toBe("gavin"); + expect(typeof config.authHandler?.[0]?.prompt).toBe("function"); + expect(config.authHandler?.[1]).toEqual({ + type: "password", + username: "gavin", + password: "secret" + }); + }); + + it("私钥认证不应注入密码认证策略", async () => { + await expect( + createSshSession({ + host: "127.0.0.1", + port: 22, + username: "gavin", + credential: { type: "privateKey", privateKey: "mock-key" }, + pty: { cols: 80, rows: 24 }, + onStdout: () => {}, + onStderr: () => {}, + onClose: () => {} + }) + ).rejects.toThrow("mock connect failed"); + + expect(connectSpy).toHaveBeenCalledTimes(1); + const config = connectSpy.mock.calls[0]?.[0] as { authHandler?: string[]; privateKey?: string }; + expect(config.privateKey).toBe("mock-key"); + expect(config.authHandler).toBeUndefined(); + }); + + it("字节串样式输入应按 latin1 还原为原始字节", () => { + const byteString = "\u00e4\u00b8\u00ad\u00e6\u0096\u0087"; + const encoded = encodeInputForSsh(byteString); + expect(typeof encoded).toBe("string"); + expect(encoded).toBe("中文"); + }); + + it("普通 Unicode 输入应保持字符串,避免误判", () => { + const encoded = encodeInputForSsh("中文"); + expect(typeof encoded).toBe("string"); + expect(encoded).toBe("中文"); + }); + + it("混合输入(Unicode + 字节串)应归一为正确 Unicode 字符串", () => { + const mixed = "测\u00e8\u00af\u0095"; + const encoded = encodeInputForSsh(mixed); + expect(typeof encoded).toBe("string"); + expect(encoded).toBe("测试"); + }); + + it("非法高位噪声应过滤 C1 控制字符", () => { + const encoded = encodeInputForSsh("\u008b\u0095"); + expect(typeof encoded).toBe("string"); + expect(encoded).toBe(""); + }); + + it("gateway 建链后应分三次写入:w1=stty-echo+BEGIN, w2=init, w3=stty+echo+DONE", async () => { + await createSshSession({ + host: "ready-host", + port: 22, + username: "gavin", + credential: { type: "password", password: "secret" }, + pty: { cols: 80, rows: 24 }, + onStdout: () => {}, + onStderr: () => {}, + onClose: () => {} + }); + + // 独立三次写入,确保 BEGIN/DONE 即使 init 失败也一定发出 + expect(streamWriteSpy).toHaveBeenCalledTimes(3); + + const w1 = Buffer.isBuffer(streamWriteSpy.mock.calls[0]?.[0]) + ? (streamWriteSpy.mock.calls[0][0] as Buffer).toString("utf8") + : String(streamWriteSpy.mock.calls[0]?.[0]); + const w2 = Buffer.isBuffer(streamWriteSpy.mock.calls[1]?.[0]) + ? (streamWriteSpy.mock.calls[1][0] as Buffer).toString("utf8") + : String(streamWriteSpy.mock.calls[1]?.[0]); + const w3 = Buffer.isBuffer(streamWriteSpy.mock.calls[2]?.[0]) + ? (streamWriteSpy.mock.calls[2][0] as Buffer).toString("utf8") + : String(streamWriteSpy.mock.calls[2]?.[0]); + + // w1: 关回显 + BEGIN 哨兵 + expect(w1).toContain("stty -echo"); + expect(w1).toContain("RCSBEGIN"); + + // w2: shell 初始化,不含 ${VAR:-} 语法,兼容 bash/dash + expect(w2).toContain("setopt MULTIBYTE PRINT_EIGHT_BIT"); + expect(w2).not.toContain("${"); // 不用 parameter expansion 默认值语法 + + // w3: 开回显 + DONE 哨兵 + expect(w3).toContain("stty echo"); + expect(w3).toContain("RCSDONE"); + }); + + it("配置跳板机后应先连接 jump,再通过 sock 连接 target", async () => { + await createSshSession({ + host: "ready-host", + port: 22, + username: "target-user", + credential: { type: "password", password: "target-secret" }, + jumpHost: { + host: "jump-host", + port: 2222, + username: "jump-user", + credential: { type: "privateKey", privateKey: "jump-key" } + }, + pty: { cols: 80, rows: 24 }, + onStdout: () => {}, + onStderr: () => {}, + onClose: () => {} + }); + + expect(connectSpy).toHaveBeenCalledTimes(2); + const jumpConfig = connectSpy.mock.calls[0]?.[0] as { host?: string; username?: string; privateKey?: string }; + const targetConfig = connectSpy.mock.calls[1]?.[0] as { + host?: string; + username?: string; + password?: string; + sock?: unknown; + }; + + expect(jumpConfig.host).toBe("jump-host"); + expect(jumpConfig.username).toBe("jump-user"); + expect(jumpConfig.privateKey).toBe("jump-key"); + expect(targetConfig.host).toBe("ready-host"); + expect(targetConfig.username).toBe("target-user"); + expect(targetConfig.password).toBe("target-secret"); + expect(targetConfig.sock).toBeTruthy(); + }); + + it("BEGIN 前的内容(Last login)保留;BEGIN→DONE 之间丢弃;DONE 后正常转发", async () => { + const received: string[] = []; + + await createSshSession({ + host: "ready-host", + port: 22, + username: "gavin", + credential: { type: "password", password: "secret" }, + pty: { cols: 80, rows: 24 }, + onStdout: (d) => received.push(d), + onStderr: () => {}, + onClose: () => {} + }); + + const ch = capturedStream!; + + // Last login(sshd 在 shell 启动前输出) + ch.emit("data", Buffer.from("Last login: Wed Feb 25 10:20:19 2026\r\n", "utf8")); + expect(received).toHaveLength(0); // BEGIN 未到,缓冲中 + + // BEGIN 哨兵 + 命令回显(init 命令被 zsh echo) + // 真实场景:echo 输出为 SENTINEL\r\n,命令回显里 sentinel 后面跟 ' + const beginLine = Buffer.from(INIT_BEGIN_FOR_TEST + "\r\n", "utf8"); + ch.emit("data", beginLine.subarray(0, 3)); + ch.emit("data", Buffer.concat([ + beginLine.subarray(3), + Buffer.from("stty iutf8 2>/dev/null; setopt MULTIBYTE...\r\n", "utf8") + ])); + + // BEGIN 之前的 Last login 应已转发 + expect(received.join("")).toContain("Last login:"); + // init 命令回显在 BEGIN 之后,在丢弃区内 + expect(received.join("")).not.toContain("setopt MULTIBYTE"); + + const prevLen = received.length; + + // DONE 哨兵(分两个 chunk 验证跨 chunk,SENTINEL\r\n 中途截断) + const doneLine = Buffer.from(INIT_DONE_FOR_TEST + "\r\n", "utf8"); + ch.emit("data", doneLine.subarray(0, 3)); + expect(received.length).toBe(prevLen); // DONE 未完整,仍丢弃 + + ch.emit("data", Buffer.concat([ + doneLine.subarray(3), + Buffer.from("gavin mini ~ % ", "utf8") + ])); + + const output = received.join(""); + expect(output).toContain("Last login:"); // 保留 + expect(output).not.toContain("setopt MULTIBYTE"); // init 回显已丢弃 + expect(output).toContain("gavin mini ~ %"); // DONE 后正常转发 + }); + + it("rawBefore 末尾含命令回显(prompt + stty -echo...)时,仅保留 banner,丢弃回显行", async () => { + const received: string[] = []; + + await createSshSession({ + host: "ready-host", + port: 22, + username: "gavin", + credential: { type: "password", password: "secret" }, + pty: { cols: 80, rows: 24 }, + onStdout: (d) => received.push(d), + onStderr: () => {}, + onClose: () => {} + }); + + const ch = capturedStream!; + + // 真实 SSH 场景:banner → 命令回显 → BEGIN + // sshd banner + ch.emit("data", Buffer.from("Last login: Wed Feb 25 10:00:00 2026\r\n\r\n", "utf8")); + // PTY 对 W1 的 echo(提示符 + 命令 + CRLF,由 PTY ONLCR 添加) + ch.emit("data", Buffer.from("~ % stty -echo; echo '__RCSBEGIN_7f3a__'\r\n", "utf8")); + // BEGIN 哨兵本体:echo 输出为 SENTINEL\r\n(PTY ONLCR) + ch.emit("data", Buffer.from(INIT_BEGIN_FOR_TEST + "\r\n", "utf8")); + // W2 回显(在丢弃区内) + ch.emit("data", Buffer.from("stty iutf8; setopt MULTIBYTE...\r\n", "utf8")); + // DONE 哨兵:命令回显(sentinel 后跟 ')+ 实际 echo 输出(sentinel + \r\n) + // 这正是产生 bug 的场景:命令回显里有 __RCSDONE_7f3a__' ,实际输出是 __RCSDONE_7f3a__\r\n + ch.emit("data", Buffer.from("stty echo; echo '__RCSDONE_7f3a__'\r\n", "utf8")); + ch.emit("data", Buffer.concat([ + Buffer.from(INIT_DONE_FOR_TEST + "\r\n", "utf8"), + Buffer.from("gavin mini ~ % ", "utf8") + ])); + + const output = received.join(""); + expect(output).toContain("Last login:"); // banner 保留 + expect(output).not.toContain("stty -echo"); // 命令回显被丢弃 + expect(output).not.toContain("setopt MULTIBYTE"); // init 回显在丢弃区内 + expect(output).not.toContain("__RCSDONE_7f3a__"); // DONE 哨兵本身不可见 + expect(output).not.toContain("stty echo"); // W3 命令回显不可见 + expect(output).toContain("gavin mini ~ %"); // DONE 后正常转发 + }); + + it("哨兵行仅为 LF 时,仍应过滤内部初始化命令", async () => { + const received: string[] = []; + + await createSshSession({ + host: "ready-host", + port: 22, + username: "gavin", + credential: { type: "password", password: "secret" }, + pty: { cols: 80, rows: 24 }, + onStdout: (d) => received.push(d), + onStderr: () => {}, + onClose: () => {} + }); + + const ch = capturedStream!; + ch.emit("data", Buffer.from("Last login: Wed Feb 25 12:07:16 2026 from 202.96.99.162\n", "utf8")); + ch.emit("data", Buffer.from("~ % stty -echo; echo '__RCSBEGIN_7f3a__'\n", "utf8")); + ch.emit("data", Buffer.from(INIT_BEGIN_FOR_TEST + "\n", "utf8")); + ch.emit("data", Buffer.from("stty iutf8; setopt MULTIBYTE...\n", "utf8")); + ch.emit("data", Buffer.from("stty echo; echo '__RCSDONE_7f3a__'\n", "utf8")); + ch.emit("data", Buffer.concat([ + Buffer.from(INIT_DONE_FOR_TEST + "\n", "utf8"), + Buffer.from("gavin mini ~ % ", "utf8") + ])); + + const output = received.join(""); + expect(output).toContain("Last login:"); + expect(output).toContain("gavin mini ~ %"); + expect(output).not.toContain("stty -echo"); + expect(output).not.toContain("setopt MULTIBYTE"); + expect(output).not.toContain("stty echo"); + expect(output).not.toContain("__RCSBEGIN_7f3a__"); + expect(output).not.toContain("__RCSDONE_7f3a__"); + }); + + it("BEGIN/DONE 未命中并超时时,仍应兜底清理内部初始化命令", async () => { + const received: string[] = []; + vi.useFakeTimers(); + + try { + await createSshSession({ + host: "ready-host", + port: 22, + username: "gavin", + credential: { type: "password", password: "secret" }, + pty: { cols: 80, rows: 24 }, + onStdout: (d) => received.push(d), + onStderr: () => {}, + onClose: () => {} + }); + + const ch = capturedStream!; + ch.emit("data", Buffer.from("Activate the web console with: systemctl enable --now cockpit.socket\r\n", "utf8")); + ch.emit("data", Buffer.from("Last login: Wed Feb 25 12:20:32 2026 from 115.193.12.66\r\n", "utf8")); + ch.emit("data", Buffer.from("stty -echo; echo '__RCSBEGIN_7f3a__'\r\n", "utf8")); + ch.emit("data", Buffer.from("stty iutf8 2>/dev/null; setopt MULTIBYTE PRINT_EIGHT_BIT 2>/dev/null; unsetopt PROMPT_SP 2>/dev/null; PROMPT_EOL_MARK=''\r\n", "utf8")); + ch.emit("data", Buffer.from("stty echo; echo '__RCSDONE_7f3a__'\r\n", "utf8")); + ch.emit("data", Buffer.from("[gavin@kvm-douboer ~]$ ", "utf8")); + + await vi.advanceTimersByTimeAsync(3100); + + const output = received.join(""); + expect(output).toContain("Activate the web console"); + expect(output).toContain("Last login:"); + expect(output).toContain("[gavin@kvm-douboer ~]$"); + expect(output).not.toContain("stty -echo; echo"); + expect(output).not.toContain("stty iutf8"); + expect(output).not.toContain("setopt MULTIBYTE"); + expect(output).not.toContain("PROMPT_EOL_MARK"); + expect(output).not.toContain("stty echo; echo"); + expect(output).not.toContain("__RCSBEGIN_7f3a__"); + expect(output).not.toContain("__RCSDONE_7f3a__"); + } finally { + vi.useRealTimers(); + } + }); + + it("主动 close 后不应重复触发 onClose(避免 switch 日志重复)", async () => { + const closeReasons: string[] = []; + + const session = await createSshSession({ + host: "ready-host", + port: 22, + username: "gavin", + credential: { type: "password", password: "secret" }, + pty: { cols: 80, rows: 24 }, + onStdout: () => {}, + onStderr: () => {}, + onClose: (reason) => closeReasons.push(reason) + }); + + session.close("switch"); + expect(closeReasons).toEqual(["switch"]); + }); +}); diff --git a/apps/gateway/src/ssh/sshSession.ts b/apps/gateway/src/ssh/sshSession.ts new file mode 100644 index 0000000..a91a992 --- /dev/null +++ b/apps/gateway/src/ssh/sshSession.ts @@ -0,0 +1,517 @@ +import { Client, type ClientChannel, type ConnectConfig } from "ssh2"; +import { StringDecoder } from "node:string_decoder"; +import { config } from "../config"; + +interface SshHopOptions { + host: string; + port: number; + username: string; + credential: + | { type: "password"; password: string } + | { type: "privateKey"; privateKey: string; passphrase?: string } + | { type: "certificate"; privateKey: string; passphrase?: string; certificate: string }; + knownHostFingerprint?: string; +} + +export interface SshConnectOptions { + host: string; + port: number; + username: string; + credential: + | { type: "password"; password: string } + | { type: "privateKey"; privateKey: string; passphrase?: string } + | { type: "certificate"; privateKey: string; passphrase?: string; certificate: string }; + jumpHost?: SshHopOptions; + pty: { cols: number; rows: number }; + knownHostFingerprint?: string; + onHostFingerprint?: (payload: { fingerprint: string; hostPort: string; role: "target" | "jump" }) => void; + onStdout: (data: string) => void; + onStderr: (data: string) => void; + onClose: (reason: string) => void; +} + +export interface ActiveSshSession { + write(data: string, traceId?: number): void; + resize(cols: number, rows: number): void; + close(reason?: string): void; +} + +/** + * 初始化哨兵:用于标记“静默初始化命令”的开始与结束。 + */ +const INIT_BEGIN = "__RCSBEGIN_7f3a__"; +const INIT_DONE = "__RCSDONE_7f3a__"; + +/** + * 初始化命令分三段写入: + * 1) 关闭回显 + BEGIN 哨兵 + * 2) shell 兼容初始化 + * 3) 打开回显 + DONE 哨兵 + */ +const SHELL_INIT_W1 = "stty -echo; echo '__RCSBEGIN_7f3a__'\r"; +const SHELL_INIT_W2 = [ + "stty iutf8 2>/dev/null", + '; [ -z "$LANG" ] && export LANG=zh_CN.UTF-8', + '; [ -z "$LC_CTYPE" ] && export LC_CTYPE=zh_CN.UTF-8', + '; [ -z "$LC_ALL" ] && export LC_ALL=zh_CN.UTF-8', + "; setopt MULTIBYTE PRINT_EIGHT_BIT 2>/dev/null", + "; unsetopt PROMPT_SP 2>/dev/null", + "; PROMPT_EOL_MARK='' 2>/dev/null" +].join("") + "\r"; +const SHELL_INIT_W3 = "stty echo; echo '__RCSDONE_7f3a__'\r"; + +/** + * 这些特征行一旦出现在 stdout,说明是网关内部初始化泄漏,必须过滤。 + */ +const INIT_LEAK_PATTERNS = [ + "stty -echo; echo '__RCSBEGIN_7f3a__'", + "stty iutf8 2>/dev/null", + "setopt MULTIBYTE PRINT_EIGHT_BIT", + "unsetopt PROMPT_SP", + "PROMPT_EOL_MARK=''", + "stty echo; echo '__RCSDONE_7f3a__'", + INIT_BEGIN, + INIT_DONE +]; + +/** @internal 仅供测试调用。 */ +export const INIT_BEGIN_FOR_TEST = INIT_BEGIN; +/** @internal 仅供测试调用。 */ +export const INIT_DONE_FOR_TEST = INIT_DONE; + +interface SentinelMatch { + index: number; + end: number; +} + +/** + * 输入去噪:去除 C1 控制字符(0x80-0x9F)。 + */ +function stripC1Controls(data: string): string { + return data.replace(/[\u0080-\u009F]/g, ""); +} + +/** + * 判断字节串是否可无损按 UTF-8 解释。 + */ +function isLosslessUtf8Bytes(bytes: Buffer): boolean { + const decoded = bytes.toString("utf8"); + if (decoded.includes("\uFFFD")) { + return false; + } + return Buffer.from(decoded, "utf8").equals(bytes); +} + +function decodeByteRun(bytes: number[]): string { + if (bytes.length === 0) { + return ""; + } + const buf = Buffer.from(bytes); + if (isLosslessUtf8Bytes(buf)) { + return buf.toString("utf8"); + } + return String.fromCharCode(...bytes.filter((b) => (b >= 0x20 && b <= 0x7e) || b === 0x09)); +} + +/** + * 混合输入归一:宽字符原样保留,单字节段按 UTF-8 尝试解码。 + */ +function normalizeMixedInput(data: string): string { + let output = ""; + let byteRun: number[] = []; + + const flushByteRun = (): void => { + output += decodeByteRun(byteRun); + byteRun = []; + }; + + for (const ch of data) { + const codePoint = ch.codePointAt(0) ?? 0; + if (codePoint <= 0xff) { + byteRun.push(codePoint); + continue; + } + flushByteRun(); + output += ch; + } + flushByteRun(); + return output; +} + +/** + * 将前端输入转换为 SSH 可写内容。 + */ +export function encodeInputForSsh(data: string): string { + let hasHighByte = false; + let hasWideChar = false; + + for (let i = 0; i < data.length; i += 1) { + const code = data.charCodeAt(i); + if (code > 0xff) { + hasWideChar = true; + continue; + } + if (code >= 0x80) { + hasHighByte = true; + } + } + + if (hasWideChar) { + return normalizeMixedInput(data).replace(/\uFFFD/g, ""); + } + + if (!hasHighByte) { + return data; + } + + const bytes = Buffer.from(data, "latin1"); + if (isLosslessUtf8Bytes(bytes)) { + return bytes.toString("utf8"); + } + + return stripC1Controls(data); +} + +/** + * 匹配哨兵真实输出行(SENTINEL + \n 或 \r\n)。 + * 注意:不会误匹配命令回显里的 `echo '__RCS...__'`(后面是单引号)。 + */ +function findSentinelLine(buffer: string, sentinel: string): SentinelMatch | null { + let from = 0; + while (from < buffer.length) { + const index = buffer.indexOf(sentinel, from); + if (index < 0) { + return null; + } + + const next = buffer[index + sentinel.length]; + const nextNext = buffer[index + sentinel.length + 1]; + + if (next === "\n") { + return { index, end: index + sentinel.length + 1 }; + } + if (next === "\r" && nextNext === "\n") { + return { index, end: index + sentinel.length + 2 }; + } + + // 落在 chunk 边界,等下个 chunk 再判定。 + if (next === undefined || (next === "\r" && nextNext === undefined)) { + return null; + } + + from = index + 1; + } + + return null; +} + +/** + * 兜底清理初始化泄漏内容,保留 banner 与提示符。 + */ +function sanitizeInitLeakOutput(text: string): string { + return text + .split(/(\r\n|\n|\r)/) + .reduce((acc, current, index, parts) => { + if (index % 2 === 1) { + return acc; + } + const shouldDrop = INIT_LEAK_PATTERNS.some((pattern) => current.includes(pattern)); + if (shouldDrop) { + return acc; + } + return acc + current + (parts[index + 1] ?? ""); + }, ""); +} + +/** + * 统一可读错误信息,便于前端提示用户。 + */ +function normalizeSshError(error: Error): Error { + const raw = String(error.message ?? ""); + if (raw.includes("All configured authentication methods failed")) { + return new Error( + `SSH 认证失败:用户名/密码不正确,或目标服务器要求额外认证(如 publickey + keyboard-interactive 多因子)。原始错误: ${raw}` + ); + } + if (raw.includes("Timed out while waiting for handshake")) { + return new Error(`SSH 握手超时,请检查目标主机连通性与端口(原始错误: ${raw})`); + } + return error; +} + +function toPrivateKeyPayload( + credential: Extract +): string { + if (credential.type === "certificate") { + return `${credential.privateKey}\n${credential.certificate}`; + } + return credential.privateKey; +} + +/** + * 统一构造 ssh2 connect 配置: + * - 密码模式优先 keyboard-interactive,再回退 password; + * - 私钥/证书模式直接走 publickey; + * - hostVerifier 在每个 hop 独立上报指纹,便于前端分别确认。 + */ +function buildSshConnectConfig( + hop: SshHopOptions, + onHostFingerprint: SshConnectOptions["onHostFingerprint"], + role: "target" | "jump", + sock?: ConnectConfig["sock"] +): ConnectConfig { + const hostPort = `${hop.host}:${hop.port}`; + const baseConfig: ConnectConfig = { + host: hop.host, + port: hop.port, + username: hop.username, + readyTimeout: config.sshReadyTimeoutMs, + keepaliveInterval: config.sshKeepaliveIntervalMs, + keepaliveCountMax: config.sshKeepaliveCountMax, + hostHash: "sha256", + ...(sock ? { sock } : {}), + hostVerifier: (keyHash: string) => { + onHostFingerprint?.({ fingerprint: keyHash, hostPort, role }); + return !hop.knownHostFingerprint || keyHash === hop.knownHostFingerprint; + } + }; + + if (hop.credential.type === "password") { + const authPassword = hop.credential.password; + const authHandler = [ + { + type: "keyboard-interactive", + username: hop.username, + prompt( + _name: string, + _instructions: string, + _lang: string, + prompts: Array<{ prompt: string; echo?: boolean }>, + finish: (responses: string[]) => void + ) { + finish(prompts.map(() => authPassword)); + } + }, + { type: "password", username: hop.username, password: authPassword } + ] as unknown as ConnectConfig["authHandler"]; + + return { + ...baseConfig, + password: authPassword, + tryKeyboard: true, + authHandler + }; + } + + return { + ...baseConfig, + privateKey: toPrivateKeyPayload(hop.credential), + passphrase: hop.credential.passphrase + }; +} + +/** + * 建立 SSH 会话并返回可操作句柄。 + * 仅保留已验证有效的链路:双哨兵过滤 + 超时兜底净化。 + */ +export async function createSshSession(options: SshConnectOptions): Promise { + const targetConn = new Client(); + const jumpConn = options.jumpHost ? new Client() : null; + + return await new Promise((resolve, reject) => { + let streamRef: { + write: (data: string | Buffer) => void; + setWindow: (rows: number, cols: number, height: number, width: number) => void; + close: () => void; + on: (event: string, listener: (...args: unknown[]) => void) => void; + stderr: { on: (event: string, listener: (chunk: Buffer) => void) => void }; + } | null = null; + let closeReported = false; + /** + * 关闭原因只允许上报一次,避免 `close()` / `stream.close` / `conn.close` + * 多路并发触发导致日志重复。 + */ + const reportCloseOnce = (reason: string): void => { + if (closeReported) { + return; + } + closeReported = true; + options.onClose(reason); + }; + + const onError = (error: Error): void => { + reject(normalizeSshError(error)); + targetConn.end(); + jumpConn?.end(); + }; + + const openShell = (): void => { + targetConn.shell( + { cols: options.pty.cols, rows: options.pty.rows, term: "xterm-256color", modes: { ECHO: 0 } }, + (shellError: Error | undefined, stream: ClientChannel) => { + if (shellError) { + onError(shellError); + return; + } + + streamRef = stream as typeof streamRef; + const stdoutDecoder = new StringDecoder("utf8"); + const stderrDecoder = new StringDecoder("utf8"); + + let initState: "waiting_begin" | "waiting_done" | "done" = "waiting_begin"; + let initBuffer = ""; + const initTimeoutHandle = setTimeout(() => { + if (initState !== "done") { + initState = "done"; + const sanitized = sanitizeInitLeakOutput(initBuffer); + if (sanitized) { + options.onStdout(sanitized); + } + initBuffer = ""; + } + }, 3000); + + stream.on("data", (chunk: Buffer) => { + const text = stdoutDecoder.write(chunk); + if (!text) { + return; + } + + if (initState === "done") { + options.onStdout(text); + return; + } + + initBuffer += text; + + if (initState === "waiting_begin") { + const beginMatch = findSentinelLine(initBuffer, INIT_BEGIN); + if (!beginMatch) { + return; + } + + const rawBefore = initBuffer.slice(0, beginMatch.index); + const echoIndex = rawBefore.lastIndexOf("stty -echo"); + const newlineBeforeEcho = echoIndex >= 0 ? rawBefore.lastIndexOf("\n", echoIndex) : -1; + const before = + echoIndex >= 0 ? (newlineBeforeEcho >= 0 ? rawBefore.slice(0, newlineBeforeEcho + 1) : "") : rawBefore; + + initBuffer = initBuffer.slice(beginMatch.end); + initState = "waiting_done"; + + const sanitizedBefore = sanitizeInitLeakOutput(before); + if (sanitizedBefore) { + options.onStdout(sanitizedBefore); + } + } + + if (initState === "waiting_done") { + const doneMatch = findSentinelLine(initBuffer, INIT_DONE); + if (!doneMatch) { + const keepTail = INIT_DONE.length + 2; + if (initBuffer.length > keepTail) { + initBuffer = initBuffer.slice(-keepTail); + } + return; + } + + const after = sanitizeInitLeakOutput(initBuffer.slice(doneMatch.end)); + initBuffer = ""; + initState = "done"; + clearTimeout(initTimeoutHandle); + + if (after) { + options.onStdout(after); + } + } + }); + + stream.stderr.on("data", (chunk: Buffer) => { + const text = stderrDecoder.write(chunk); + if (text) { + options.onStderr(text); + } + }); + + try { + stream.write(Buffer.from(SHELL_INIT_W1, "utf8")); + stream.write(Buffer.from(SHELL_INIT_W2, "utf8")); + stream.write(Buffer.from(SHELL_INIT_W3, "utf8")); + } catch { + clearTimeout(initTimeoutHandle); + initState = "done"; + } + + stream.on("close", () => { + clearTimeout(initTimeoutHandle); + const remainOut = stdoutDecoder.end(); + if (remainOut) { + options.onStdout(remainOut); + } + const remainErr = stderrDecoder.end(); + if (remainErr) { + options.onStderr(remainErr); + } + reportCloseOnce("shell_closed"); + targetConn.end(); + jumpConn?.end(); + }); + + resolve({ + write(data: string, _traceId?: number) { + const payload = encodeInputForSsh(data).replace(/\uFFFD/g, ""); + if (payload) { + streamRef?.write(Buffer.from(payload, "utf8")); + } + }, + resize(cols: number, rows: number) { + streamRef?.setWindow(rows, cols, 0, 0); + }, + close(reason = "manual") { + reportCloseOnce(reason); + streamRef?.close(); + targetConn.end(); + jumpConn?.end(); + } + }); + } + ); + }; + + targetConn.on("ready", openShell); + targetConn.on("error", onError); + targetConn.on("close", () => { + reportCloseOnce("connection_closed"); + }); + + const targetHop: SshHopOptions = { + host: options.host, + port: options.port, + username: options.username, + credential: options.credential, + knownHostFingerprint: options.knownHostFingerprint + }; + + if (!options.jumpHost) { + targetConn.connect(buildSshConnectConfig(targetHop, options.onHostFingerprint, "target")); + return; + } + + jumpConn?.on("error", onError); + jumpConn?.on("close", () => { + reportCloseOnce("jump_connection_closed"); + }); + + jumpConn?.on("ready", () => { + jumpConn.forwardOut("127.0.0.1", 0, options.host, options.port, (error, stream) => { + if (error || !stream) { + onError(error ?? new Error("jump forward stream missing")); + return; + } + targetConn.connect(buildSshConnectConfig(targetHop, options.onHostFingerprint, "target", stream)); + }); + }); + + jumpConn?.connect(buildSshConnectConfig(options.jumpHost, options.onHostFingerprint, "jump")); + }); +} diff --git a/apps/gateway/src/sync/crypto.test.ts b/apps/gateway/src/sync/crypto.test.ts new file mode 100644 index 0000000..7a0510a --- /dev/null +++ b/apps/gateway/src/sync/crypto.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; + +process.env.MINIPROGRAM_APP_ID = "wx-test-app"; +process.env.MINIPROGRAM_APP_SECRET = "wx-test-secret"; +process.env.SYNC_SECRET_CURRENT = "sync-secret-for-test"; + +const { createSyncToken, decryptSecretPayload, encryptSecretPayload, verifySyncToken } = await import( + "./crypto" +); + +describe("sync crypto", () => { + it("应能加解密敏感凭据", () => { + const source = { + password: "pw-123456", + privateKey: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----" + }; + const encrypted = encryptSecretPayload(source); + const decrypted = decryptSecretPayload(encrypted.secretBlob, encrypted.secretVersion); + expect(decrypted).toEqual(source); + }); + + it("应能签发并校验同步 token", () => { + const session = createSyncToken("user-1", "openid-1"); + const payload = verifySyncToken(session.token); + expect(payload.uid).toBe("user-1"); + expect(payload.oid).toBe("openid-1"); + expect(payload.exp).toBeGreaterThan(Date.now()); + }); +}); diff --git a/apps/gateway/src/sync/crypto.ts b/apps/gateway/src/sync/crypto.ts new file mode 100644 index 0000000..2a8d4ed --- /dev/null +++ b/apps/gateway/src/sync/crypto.ts @@ -0,0 +1,119 @@ +import { createCipheriv, createDecipheriv, createHash, createHmac, timingSafeEqual, randomBytes } from "node:crypto"; +import { config } from "../config"; + +interface EncryptedSecretBlob { + alg: "aes-256-gcm"; + keyVersion: number; + iv: string; + tag: string; + ciphertext: string; +} + +interface SyncTokenPayload { + uid: string; + oid: string; + exp: number; +} + +function toBase64Url(input: Buffer | string): string { + const source = Buffer.isBuffer(input) ? input : Buffer.from(input, "utf8"); + return source + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); +} + +function fromBase64Url(input: string): Buffer { + const normalized = String(input || "") + .replace(/-/g, "+") + .replace(/_/g, "/"); + const padded = normalized + "=".repeat((4 - (normalized.length % 4 || 4)) % 4); + return Buffer.from(padded, "base64"); +} + +function deriveKey(label: string): Buffer { + return createHash("sha256") + .update(`${config.sync.secretCurrent}:${label}`, "utf8") + .digest(); +} + +function stableJson(value: unknown): string { + return JSON.stringify(value ?? {}); +} + +export function encryptSecretPayload(payload: unknown): { secretBlob: string; secretVersion: number } { + const plaintext = Buffer.from(stableJson(payload), "utf8"); + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", deriveKey("sync-secret"), iv); + const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]); + const tag = cipher.getAuthTag(); + const blob: EncryptedSecretBlob = { + alg: "aes-256-gcm", + keyVersion: config.sync.secretVersion, + iv: toBase64Url(iv), + tag: toBase64Url(tag), + ciphertext: toBase64Url(ciphertext) + }; + return { + secretBlob: JSON.stringify(blob), + secretVersion: config.sync.secretVersion + }; +} + +export function decryptSecretPayload(secretBlob: string, secretVersion: number): Record { + if (!secretBlob) { + return {}; + } + if (secretVersion !== config.sync.secretVersion) { + throw new Error("unsupported secret version"); + } + const blob = JSON.parse(secretBlob) as EncryptedSecretBlob; + const decipher = createDecipheriv("aes-256-gcm", deriveKey("sync-secret"), fromBase64Url(blob.iv)); + decipher.setAuthTag(fromBase64Url(blob.tag)); + const plaintext = Buffer.concat([ + decipher.update(fromBase64Url(blob.ciphertext)), + decipher.final() + ]).toString("utf8"); + const parsed = JSON.parse(plaintext) as Record; + return parsed && typeof parsed === "object" ? parsed : {}; +} + +export function createSyncToken(userId: string, openid: string): { token: string; expiresAt: string } { + const exp = Date.now() + config.sync.tokenTtlSec * 1000; + const payload: SyncTokenPayload = { + uid: userId, + oid: openid, + exp + }; + const encodedPayload = toBase64Url(JSON.stringify(payload)); + const signature = createHmac("sha256", deriveKey("sync-token")) + .update(encodedPayload, "utf8") + .digest(); + return { + token: `v1.${encodedPayload}.${toBase64Url(signature)}`, + expiresAt: new Date(exp).toISOString() + }; +} + +export function verifySyncToken(token: string): SyncTokenPayload { + const [version, encodedPayload, encodedSignature] = String(token || "").split("."); + if (version !== "v1" || !encodedPayload || !encodedSignature) { + throw new Error("invalid token format"); + } + const expected = createHmac("sha256", deriveKey("sync-token")) + .update(encodedPayload, "utf8") + .digest(); + const actual = fromBase64Url(encodedSignature); + if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) { + throw new Error("invalid token signature"); + } + const payload = JSON.parse(fromBase64Url(encodedPayload).toString("utf8")) as SyncTokenPayload; + if (!payload || typeof payload !== "object" || !payload.uid || !payload.oid || !Number.isFinite(payload.exp)) { + throw new Error("invalid token payload"); + } + if (Date.now() >= payload.exp) { + throw new Error("token expired"); + } + return payload; +} diff --git a/apps/gateway/src/sync/repository.test.ts b/apps/gateway/src/sync/repository.test.ts new file mode 100644 index 0000000..2db5ccd --- /dev/null +++ b/apps/gateway/src/sync/repository.test.ts @@ -0,0 +1,105 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { afterEach, describe, expect, it } from "vitest"; + +process.env.MINIPROGRAM_APP_ID = "wx-test-app"; +process.env.MINIPROGRAM_APP_SECRET = "wx-test-secret"; +process.env.SYNC_SECRET_CURRENT = "sync-secret-for-test"; + +const { SyncRepository } = await import("./repository"); +const { initializeSyncDb } = await import("./sqlite"); + +const tempDirs: string[] = []; + +function createRepository() { + const dir = mkdtempSync(path.join(tmpdir(), "remoteconn-sync-")); + tempDirs.push(dir); + const db = initializeSyncDb(new DatabaseSync(path.join(dir, "sync.db"))); + return new SyncRepository(db); +} + +afterEach(() => { + while (tempDirs.length) { + const dir = tempDirs.pop(); + if (!dir) break; + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("sync repository", () => { + it("应保存并取回带加密凭据的服务器配置", () => { + const repo = createRepository(); + const user = repo.getOrCreateUser("openid-user"); + repo.upsertServers(user.id, [ + { + id: "srv-1", + name: "server-1", + tags: ["prod"], + host: "10.0.0.1", + port: 22, + username: "root", + authType: "privateKey", + projectPath: "~/workspace", + timeoutSeconds: 20, + heartbeatSeconds: 15, + transportMode: "gateway", + jumpHost: { + enabled: true, + host: "10.0.0.2", + port: 22, + username: "jump", + authType: "password" + }, + sortOrder: 1, + lastConnectedAt: "", + updatedAt: "2026-03-09T00:00:00.000Z", + deletedAt: null, + password: "", + privateKey: "secret-key", + passphrase: "secret-passphrase", + certificate: "", + jumpPassword: "jump-secret", + jumpPrivateKey: "", + jumpPassphrase: "", + jumpCertificate: "" + } + ]); + + const rows = repo.listServers(user.id); + expect(rows).toHaveLength(1); + const first = rows[0]; + expect(first).toBeDefined(); + expect(first && first.host).toBe("10.0.0.1"); + expect(first && first.privateKey).toBe("secret-key"); + expect(first && first.jumpPassword).toBe("jump-secret"); + }); + + it("应保存并返回闪念记录", () => { + const repo = createRepository(); + const user = repo.getOrCreateUser("openid-user-2"); + repo.upsertRecords(user.id, [ + { + id: "rec-1", + content: "deploy before 18:00", + serverId: "srv-1", + category: "问题", + contextLabel: "prod-api", + processed: false, + discarded: true, + createdAt: "2026-03-09T00:00:00.000Z", + updatedAt: "2026-03-09T00:10:00.000Z", + deletedAt: null + } + ]); + + const rows = repo.listRecords(user.id); + expect(rows).toHaveLength(1); + const first = rows[0]; + expect(first).toBeDefined(); + expect(first && first.content).toBe("deploy before 18:00"); + expect(first && first.category).toBe("问题"); + expect(first && first.discarded).toBe(true); + }); +}); diff --git a/apps/gateway/src/sync/repository.ts b/apps/gateway/src/sync/repository.ts new file mode 100644 index 0000000..8cf8f3e --- /dev/null +++ b/apps/gateway/src/sync/repository.ts @@ -0,0 +1,254 @@ +import { randomUUID } from "node:crypto"; +import type { DatabaseSync } from "node:sqlite"; +import { decryptSecretPayload, encryptSecretPayload } from "./crypto"; +import { getSyncDb } from "./sqlite"; +import type { SyncRecord, SyncServer, SyncServerCommon, SyncServerSecret, SyncSettingsPayload } from "./schema"; + +export interface UserRow { + id: string; + openid: string; + unionid: string | null; + created_at: string; + updated_at: string; +} + +function nowIso(): string { + return new Date().toISOString(); +} + +function parseJsonObject(input: string): Record { + try { + const parsed = JSON.parse(input); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record) : {}; + } catch { + return {}; + } +} + +function parseJsonArrayLike(input: string): T { + return JSON.parse(input) as T; +} + +function pickServerCommon(server: SyncServer): SyncServerCommon { + return { + id: server.id, + name: server.name, + tags: server.tags, + host: server.host, + port: server.port, + username: server.username, + authType: server.authType, + projectPath: server.projectPath, + timeoutSeconds: server.timeoutSeconds, + heartbeatSeconds: server.heartbeatSeconds, + transportMode: server.transportMode, + jumpHost: server.jumpHost, + sortOrder: server.sortOrder, + lastConnectedAt: server.lastConnectedAt, + updatedAt: server.updatedAt, + deletedAt: server.deletedAt ?? null + }; +} + +function pickServerSecrets(server: SyncServer): SyncServerSecret { + return { + password: server.password, + privateKey: server.privateKey, + passphrase: server.passphrase, + certificate: server.certificate, + jumpPassword: server.jumpPassword, + jumpPrivateKey: server.jumpPrivateKey, + jumpPassphrase: server.jumpPassphrase, + jumpCertificate: server.jumpCertificate + }; +} + +export class SyncRepository { + private readonly db: DatabaseSync; + + constructor(database: DatabaseSync = getSyncDb()) { + this.db = database; + } + + getOrCreateUser(openid: string, unionid?: string | null): UserRow { + const found = this.db + .prepare("SELECT id, openid, unionid, created_at, updated_at FROM users WHERE openid = ?") + .get(openid) as UserRow | undefined; + if (found) { + if (unionid && unionid !== found.unionid) { + const updatedAt = nowIso(); + this.db + .prepare("UPDATE users SET unionid = ?, updated_at = ? WHERE id = ?") + .run(unionid, updatedAt, found.id); + found.unionid = unionid; + found.updated_at = updatedAt; + } + return found; + } + const row: UserRow = { + id: randomUUID(), + openid, + unionid: unionid || null, + created_at: nowIso(), + updated_at: nowIso() + }; + this.db + .prepare( + "INSERT INTO users (id, openid, unionid, created_at, updated_at) VALUES (?, ?, ?, ?, ?)" + ) + .run(row.id, row.openid, row.unionid, row.created_at, row.updated_at); + return row; + } + + getSettings(userId: string): SyncSettingsPayload | null { + const row = this.db + .prepare("SELECT settings_json, updated_at FROM user_settings WHERE user_id = ?") + .get(userId) as { settings_json: string; updated_at: string } | undefined; + if (!row) return null; + return { + data: parseJsonObject(row.settings_json), + updatedAt: row.updated_at + }; + } + + upsertSettings(userId: string, payload: SyncSettingsPayload): void { + const current = this.getSettings(userId); + if (current && current.updatedAt > payload.updatedAt) { + return; + } + this.db + .prepare( + ` + INSERT INTO user_settings (user_id, settings_json, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + settings_json = excluded.settings_json, + updated_at = excluded.updated_at + ` + ) + .run(userId, JSON.stringify(payload.data), payload.updatedAt); + } + + listServers(userId: string): SyncServer[] { + const rows = this.db + .prepare( + ` + SELECT server_json, secret_blob, secret_version, deleted_at + FROM user_servers + WHERE user_id = ? + ORDER BY updated_at DESC + ` + ) + .all(userId) as Array<{ + server_json: string; + secret_blob: string | null; + secret_version: number; + deleted_at: string | null; + }>; + return rows.map((row) => { + const common = parseJsonArrayLike(row.server_json); + const secrets = row.secret_blob ? (decryptSecretPayload(row.secret_blob, row.secret_version) as SyncServerSecret) : {}; + return { + ...common, + ...secrets, + deletedAt: row.deleted_at ?? common.deletedAt ?? null + } as SyncServer; + }); + } + + upsertServers(userId: string, servers: SyncServer[]): void { + const selectStmt = this.db.prepare( + "SELECT updated_at FROM user_servers WHERE user_id = ? AND server_id = ?" + ); + const upsertStmt = this.db.prepare(` + INSERT INTO user_servers ( + id, user_id, server_id, server_json, secret_blob, secret_version, updated_at, deleted_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id, server_id) DO UPDATE SET + server_json = excluded.server_json, + secret_blob = excluded.secret_blob, + secret_version = excluded.secret_version, + updated_at = excluded.updated_at, + deleted_at = excluded.deleted_at + `); + this.db.exec("BEGIN"); + try { + servers.forEach((server) => { + const current = selectStmt.get(userId, server.id) as { updated_at: string } | undefined; + if (current && current.updated_at > server.updatedAt) { + return; + } + const encrypted = encryptSecretPayload(pickServerSecrets(server)); + upsertStmt.run( + randomUUID(), + userId, + server.id, + JSON.stringify(pickServerCommon(server)), + encrypted.secretBlob, + encrypted.secretVersion, + server.updatedAt, + server.deletedAt ?? null + ); + }); + this.db.exec("COMMIT"); + } catch (error) { + this.db.exec("ROLLBACK"); + throw error; + } + } + + listRecords(userId: string): SyncRecord[] { + const rows = this.db + .prepare( + ` + SELECT record_json, deleted_at + FROM user_records + WHERE user_id = ? + ORDER BY updated_at DESC + ` + ) + .all(userId) as Array<{ record_json: string; deleted_at: string | null }>; + return rows.map((row) => { + const record = parseJsonArrayLike(row.record_json); + return { + ...record, + deletedAt: row.deleted_at ?? record.deletedAt ?? null + }; + }); + } + + upsertRecords(userId: string, records: SyncRecord[]): void { + const selectStmt = this.db.prepare( + "SELECT updated_at FROM user_records WHERE user_id = ? AND record_id = ?" + ); + const upsertStmt = this.db.prepare(` + INSERT INTO user_records (id, user_id, record_id, record_json, updated_at, deleted_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id, record_id) DO UPDATE SET + record_json = excluded.record_json, + updated_at = excluded.updated_at, + deleted_at = excluded.deleted_at + `); + this.db.exec("BEGIN"); + try { + records.forEach((record) => { + const current = selectStmt.get(userId, record.id) as { updated_at: string } | undefined; + if (current && current.updated_at > record.updatedAt) { + return; + } + upsertStmt.run( + randomUUID(), + userId, + record.id, + JSON.stringify(record), + record.updatedAt, + record.deletedAt ?? null + ); + }); + this.db.exec("COMMIT"); + } catch (error) { + this.db.exec("ROLLBACK"); + throw error; + } + } +} diff --git a/apps/gateway/src/sync/routes.ts b/apps/gateway/src/sync/routes.ts new file mode 100644 index 0000000..694c284 --- /dev/null +++ b/apps/gateway/src/sync/routes.ts @@ -0,0 +1,214 @@ +import type { Express, Request, Response, NextFunction } from "express"; +import { config } from "../config"; +import { logger } from "../logger"; +import { verifySyncToken } from "./crypto"; +import { SyncRepository } from "./repository"; +import { + syncLoginBodySchema, + syncRecordsPayloadSchema, + syncSettingsPayloadSchema, + syncServersPayloadSchema +} from "./schema"; +import { loginMiniprogramUser } from "./userService"; +import { registerMiniprogramTtsRoutes } from "../tts/routes"; + +interface SyncAuthedRequest extends Request { + syncUser?: { + userId: string; + openid: string; + }; +} + +function ensureSyncEnabled(res: Response): boolean { + if (config.sync.enabled) { + return true; + } + logger.warn( + { + hasAppId: Boolean(config.sync.miniprogramAppId), + hasAppSecret: Boolean(config.sync.miniprogramAppSecret), + hasSecretCurrent: Boolean(config.sync.secretCurrent) + }, + "小程序同步服务未启用" + ); + res.status(503).json({ + ok: false, + code: "SYNC_DISABLED", + message: "小程序同步服务未配置" + }); + return false; +} + +function requireGatewayToken(req: Request, res: Response): boolean { + const token = String(req.headers["x-gateway-token"] || ""); + if (token === config.gatewayToken) { + return true; + } + logger.warn( + { + path: req.path, + hasToken: Boolean(token) + }, + "小程序同步登录缺少有效 gateway token" + ); + res.status(401).json({ + ok: false, + code: "AUTH_FAILED", + message: "gateway token 无效" + }); + return false; +} + +function requireSyncUser(req: SyncAuthedRequest, res: Response, next: NextFunction): void { + if (!ensureSyncEnabled(res)) return; + const auth = String(req.headers.authorization || ""); + const token = auth.startsWith("Bearer ") ? auth.slice("Bearer ".length).trim() : ""; + if (!token) { + logger.warn({ path: req.path }, "小程序同步请求缺少 Bearer token"); + res.status(401).json({ ok: false, code: "SYNC_TOKEN_MISSING", message: "缺少同步令牌" }); + return; + } + try { + const payload = verifySyncToken(token); + req.syncUser = { + userId: payload.uid, + openid: payload.oid + }; + next(); + } catch (error) { + logger.warn( + { + path: req.path, + err: error + }, + "小程序同步请求鉴权失败" + ); + res.status(401).json({ + ok: false, + code: "SYNC_TOKEN_INVALID", + message: error instanceof Error ? error.message : "同步令牌无效" + }); + } +} + +export function registerSyncRoutes(app: Express): void { + const repository = new SyncRepository(); + registerMiniprogramTtsRoutes(app, requireSyncUser); + + app.post("/api/miniprogram/auth/login", async (req, res) => { + if (!ensureSyncEnabled(res) || !requireGatewayToken(req, res)) return; + const parsed = syncLoginBodySchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ + ok: false, + code: "INVALID_BODY", + message: "登录参数不合法" + }); + return; + } + try { + const result = await loginMiniprogramUser(parsed.data.code, repository); + res.json({ + ok: true, + token: result.token, + expiresAt: result.expiresAt, + user: { + id: result.user.id + } + }); + } catch (error) { + logger.warn({ err: error }, "小程序同步登录失败"); + res.status(502).json({ + ok: false, + code: "WECHAT_LOGIN_FAILED", + message: error instanceof Error ? error.message : "微信登录失败" + }); + } + }); + + app.get("/api/miniprogram/sync/bootstrap", requireSyncUser, (req: SyncAuthedRequest, res) => { + const userId = req.syncUser?.userId; + if (!userId) { + res.status(401).json({ ok: false, code: "SYNC_TOKEN_INVALID", message: "同步令牌无效" }); + return; + } + res.json({ + ok: true, + settings: repository.getSettings(userId), + servers: repository.listServers(userId), + records: repository.listRecords(userId) + }); + }); + + app.get("/api/miniprogram/sync/settings", requireSyncUser, (req: SyncAuthedRequest, res) => { + const userId = req.syncUser?.userId; + if (!userId) { + res.status(401).json({ ok: false, code: "SYNC_TOKEN_INVALID", message: "同步令牌无效" }); + return; + } + res.json({ ok: true, settings: repository.getSettings(userId) }); + }); + + app.put("/api/miniprogram/sync/settings", requireSyncUser, (req: SyncAuthedRequest, res) => { + const userId = req.syncUser?.userId; + if (!userId) { + res.status(401).json({ ok: false, code: "SYNC_TOKEN_INVALID", message: "同步令牌无效" }); + return; + } + const parsed = syncSettingsPayloadSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ ok: false, code: "INVALID_BODY", message: "settings 参数不合法" }); + return; + } + repository.upsertSettings(userId, parsed.data); + res.json({ ok: true, settings: repository.getSettings(userId) }); + }); + + app.get("/api/miniprogram/sync/servers", requireSyncUser, (req: SyncAuthedRequest, res) => { + const userId = req.syncUser?.userId; + if (!userId) { + res.status(401).json({ ok: false, code: "SYNC_TOKEN_INVALID", message: "同步令牌无效" }); + return; + } + res.json({ ok: true, servers: repository.listServers(userId) }); + }); + + app.put("/api/miniprogram/sync/servers", requireSyncUser, (req: SyncAuthedRequest, res) => { + const userId = req.syncUser?.userId; + if (!userId) { + res.status(401).json({ ok: false, code: "SYNC_TOKEN_INVALID", message: "同步令牌无效" }); + return; + } + const parsed = syncServersPayloadSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ ok: false, code: "INVALID_BODY", message: "servers 参数不合法" }); + return; + } + repository.upsertServers(userId, parsed.data.servers); + res.json({ ok: true, servers: repository.listServers(userId) }); + }); + + app.get("/api/miniprogram/sync/records", requireSyncUser, (req: SyncAuthedRequest, res) => { + const userId = req.syncUser?.userId; + if (!userId) { + res.status(401).json({ ok: false, code: "SYNC_TOKEN_INVALID", message: "同步令牌无效" }); + return; + } + res.json({ ok: true, records: repository.listRecords(userId) }); + }); + + app.put("/api/miniprogram/sync/records", requireSyncUser, (req: SyncAuthedRequest, res) => { + const userId = req.syncUser?.userId; + if (!userId) { + res.status(401).json({ ok: false, code: "SYNC_TOKEN_INVALID", message: "同步令牌无效" }); + return; + } + const parsed = syncRecordsPayloadSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ ok: false, code: "INVALID_BODY", message: "records 参数不合法" }); + return; + } + repository.upsertRecords(userId, parsed.data.records); + res.json({ ok: true, records: repository.listRecords(userId) }); + }); +} diff --git a/apps/gateway/src/sync/schema.ts b/apps/gateway/src/sync/schema.ts new file mode 100644 index 0000000..f3c5d98 --- /dev/null +++ b/apps/gateway/src/sync/schema.ts @@ -0,0 +1,82 @@ +import { z } from "zod"; + +/** + * 小程序同步公共 schema: + * 1. 第一阶段只约束必要字段,避免把本地对象完全写死; + * 2. 服务器普通字段与敏感字段拆开,便于单独加密存储。 + */ +export const syncLoginBodySchema = z.object({ + code: z.string().trim().min(1) +}); + +export const syncSettingsPayloadSchema = z.object({ + updatedAt: z.string().trim().min(1), + data: z.record(z.string(), z.unknown()) +}); + +export const syncJumpHostSchema = z.object({ + enabled: z.boolean().optional().default(false), + host: z.string().optional().default(""), + port: z.number().int().min(1).max(65535).optional().default(22), + username: z.string().optional().default(""), + authType: z.enum(["password", "privateKey", "certificate"]).optional().default("password") +}); + +export const syncServerSecretSchema = z.object({ + password: z.string().optional().default(""), + privateKey: z.string().optional().default(""), + passphrase: z.string().optional().default(""), + certificate: z.string().optional().default(""), + jumpPassword: z.string().optional().default(""), + jumpPrivateKey: z.string().optional().default(""), + jumpPassphrase: z.string().optional().default(""), + jumpCertificate: z.string().optional().default("") +}); + +export const syncServerCommonSchema = z.object({ + id: z.string().trim().min(1), + name: z.string().optional().default(""), + tags: z.array(z.string()).optional().default([]), + host: z.string().optional().default(""), + port: z.number().int().min(1).max(65535).optional().default(22), + username: z.string().optional().default(""), + authType: z.enum(["password", "privateKey", "certificate"]).optional().default("password"), + projectPath: z.string().optional().default(""), + timeoutSeconds: z.number().int().min(1).max(3600).optional().default(15), + heartbeatSeconds: z.number().int().min(1).max(3600).optional().default(10), + transportMode: z.string().optional().default("gateway"), + jumpHost: syncJumpHostSchema.optional().default({ enabled: false, host: "", port: 22, username: "", authType: "password" }), + sortOrder: z.number().int().optional().default(0), + lastConnectedAt: z.string().optional().default(""), + updatedAt: z.string().trim().min(1), + deletedAt: z.string().trim().min(1).nullable().optional().default(null) +}); + +export const syncServerSchema = syncServerCommonSchema.merge(syncServerSecretSchema); + +export const syncServersPayloadSchema = z.object({ + servers: z.array(syncServerSchema) +}); + +export const syncRecordSchema = z.object({ + id: z.string().trim().min(1), + content: z.string().optional().default(""), + serverId: z.string().optional().default(""), + category: z.string().optional().default("未分类"), + contextLabel: z.string().optional().default(""), + processed: z.boolean().optional().default(false), + discarded: z.boolean().optional().default(false), + createdAt: z.string().trim().min(1), + updatedAt: z.string().trim().min(1), + deletedAt: z.string().trim().min(1).nullable().optional().default(null) +}); + +export const syncRecordsPayloadSchema = z.object({ + records: z.array(syncRecordSchema) +}); + +export type SyncSettingsPayload = z.infer; +export type SyncServer = z.infer; +export type SyncServerCommon = z.infer; +export type SyncServerSecret = z.infer; +export type SyncRecord = z.infer; diff --git a/apps/gateway/src/sync/sqlite.ts b/apps/gateway/src/sync/sqlite.ts new file mode 100644 index 0000000..50328d2 --- /dev/null +++ b/apps/gateway/src/sync/sqlite.ts @@ -0,0 +1,77 @@ +import { mkdirSync } from "node:fs"; +import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { config } from "../config"; + +let db: DatabaseSync | null = null; + +export function initializeSyncDb(database: DatabaseSync): DatabaseSync { + database.exec(` + PRAGMA journal_mode = WAL; + PRAGMA foreign_keys = ON; + + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + openid TEXT NOT NULL UNIQUE, + unionid TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS user_settings ( + user_id TEXT PRIMARY KEY, + settings_json TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) + ); + + CREATE TABLE IF NOT EXISTS user_servers ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + server_id TEXT NOT NULL, + server_json TEXT NOT NULL, + secret_blob TEXT, + secret_version INTEGER NOT NULL DEFAULT 1, + updated_at TEXT NOT NULL, + deleted_at TEXT, + FOREIGN KEY (user_id) REFERENCES users(id) + ); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_user_servers_user_server + ON user_servers(user_id, server_id); + CREATE INDEX IF NOT EXISTS idx_user_servers_user_updated + ON user_servers(user_id, updated_at); + + CREATE TABLE IF NOT EXISTS user_records ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + record_id TEXT NOT NULL, + record_json TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT, + FOREIGN KEY (user_id) REFERENCES users(id) + ); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_user_records_user_record + ON user_records(user_id, record_id); + CREATE INDEX IF NOT EXISTS idx_user_records_user_updated + ON user_records(user_id, updated_at); + + CREATE TABLE IF NOT EXISTS schema_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + `); + return database; +} + +export function getSyncDb(): DatabaseSync { + if (db) { + return db; + } + const sqlitePath = path.resolve(process.cwd(), config.sync.sqlitePath); + mkdirSync(path.dirname(sqlitePath), { recursive: true }); + db = new DatabaseSync(sqlitePath); + initializeSyncDb(db); + return db; +} diff --git a/apps/gateway/src/sync/userService.ts b/apps/gateway/src/sync/userService.ts new file mode 100644 index 0000000..f328a66 --- /dev/null +++ b/apps/gateway/src/sync/userService.ts @@ -0,0 +1,43 @@ +import { SyncRepository } from "./repository"; +import { createSyncToken } from "./crypto"; +import { config } from "../config"; + +interface Code2SessionResult { + openid: string; + unionid?: string; +} + +async function fetchCode2Session(code: string): Promise { + const url = new URL("https://api.weixin.qq.com/sns/jscode2session"); + url.searchParams.set("appid", config.sync.miniprogramAppId); + url.searchParams.set("secret", config.sync.miniprogramAppSecret); + url.searchParams.set("js_code", code); + url.searchParams.set("grant_type", "authorization_code"); + const response = await fetch(url, { signal: AbortSignal.timeout(10000) }); + if (!response.ok) { + throw new Error(`wechat code2Session failed: ${response.status}`); + } + const payload = (await response.json()) as { + openid?: string; + unionid?: string; + errcode?: number; + errmsg?: string; + }; + if (!payload.openid) { + throw new Error(payload.errmsg || `wechat code2Session failed: ${payload.errcode || "unknown"}`); + } + return { + openid: payload.openid, + unionid: payload.unionid + }; +} + +export async function loginMiniprogramUser(code: string, repository = new SyncRepository()) { + const result = await fetchCode2Session(code); + const user = repository.getOrCreateUser(result.openid, result.unionid || null); + const session = createSyncToken(user.id, user.openid); + return { + user, + ...session + }; +} diff --git a/apps/gateway/src/tts/cache.ts b/apps/gateway/src/tts/cache.ts new file mode 100644 index 0000000..33ace2a --- /dev/null +++ b/apps/gateway/src/tts/cache.ts @@ -0,0 +1,175 @@ +import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; +import path from "node:path"; + +export interface TtsCacheEntry { + cacheKey: string; + contentType: string; + bytes: number; + createdAt: string; + lastAccessAt: string; +} + +interface TtsCacheFileRecord extends TtsCacheEntry { + version: 1; +} + +interface TtsCacheStoreOptions { + cacheDir: string; + ttlMs: number; + maxTotalBytes: number; + maxFileBytes: number; +} + +/** + * 磁盘缓存采用“音频文件 + metadata sidecar”: + * 1. 命中时不再请求上游 TTS; + * 2. metadata 只保留必要字段,不记录原始文本; + * 3. 每次写入后顺带做一次轻量淘汰,维持总量上限。 + */ +export class TtsCacheStore { + private cacheDir: string; + private ttlMs: number; + private maxTotalBytes: number; + private maxFileBytes: number; + + constructor(options: TtsCacheStoreOptions) { + this.cacheDir = options.cacheDir; + this.ttlMs = options.ttlMs; + this.maxTotalBytes = options.maxTotalBytes; + this.maxFileBytes = options.maxFileBytes; + } + + private audioPath(cacheKey: string): string { + return path.join(this.cacheDir, `${cacheKey}.mp3`); + } + + private metaPath(cacheKey: string): string { + return path.join(this.cacheDir, `${cacheKey}.json`); + } + + private async ensureDir(): Promise { + await mkdir(this.cacheDir, { recursive: true }); + } + + private async removeCacheKey(cacheKey: string): Promise { + await Promise.allSettled([rm(this.audioPath(cacheKey), { force: true }), rm(this.metaPath(cacheKey), { force: true })]); + } + + async get(cacheKey: string): Promise<{ entry: TtsCacheEntry; audioPath: string } | null> { + await this.ensureDir(); + try { + const metaRaw = await readFile(this.metaPath(cacheKey), "utf8"); + const parsed = JSON.parse(metaRaw) as Partial; + const audioPath = this.audioPath(cacheKey); + const audioStat = await stat(audioPath); + const lastAccessAt = parsed.lastAccessAt || parsed.createdAt || new Date().toISOString(); + if (Date.now() - +new Date(lastAccessAt) > this.ttlMs) { + await this.removeCacheKey(cacheKey); + return null; + } + const nowIso = new Date().toISOString(); + const entry: TtsCacheEntry = { + cacheKey, + contentType: String(parsed.contentType || "audio/mpeg"), + bytes: Number(parsed.bytes) || audioStat.size, + createdAt: String(parsed.createdAt || nowIso), + lastAccessAt: nowIso + }; + await writeFile( + this.metaPath(cacheKey), + JSON.stringify( + { + version: 1, + ...entry + } satisfies TtsCacheFileRecord, + null, + 2 + ), + "utf8" + ); + return { entry, audioPath }; + } catch { + await this.removeCacheKey(cacheKey); + return null; + } + } + + async put(cacheKey: string, audio: Buffer, contentType: string): Promise { + await this.ensureDir(); + if (audio.length <= 0) { + throw new Error("audio buffer is empty"); + } + if (audio.length > this.maxFileBytes) { + throw new Error("audio file exceeds cache single-file limit"); + } + const nowIso = new Date().toISOString(); + const entry: TtsCacheEntry = { + cacheKey, + contentType: contentType || "audio/mpeg", + bytes: audio.length, + createdAt: nowIso, + lastAccessAt: nowIso + }; + await writeFile(this.audioPath(cacheKey), audio); + await writeFile( + this.metaPath(cacheKey), + JSON.stringify( + { + version: 1, + ...entry + } satisfies TtsCacheFileRecord, + null, + 2 + ), + "utf8" + ); + await this.prune(); + return entry; + } + + async prune(): Promise { + await this.ensureDir(); + const names = await readdir(this.cacheDir); + const metaFiles = names.filter((name) => name.endsWith(".json")); + const rows: Array = []; + for (const file of metaFiles) { + try { + const metaPath = path.join(this.cacheDir, file); + const raw = await readFile(metaPath, "utf8"); + const parsed = JSON.parse(raw) as Partial; + const cacheKey = file.replace(/\.json$/u, ""); + const audioPath = this.audioPath(cacheKey); + const audioStat = await stat(audioPath); + const lastAccessAt = String(parsed.lastAccessAt || parsed.createdAt || new Date(0).toISOString()); + if (Date.now() - +new Date(lastAccessAt) > this.ttlMs) { + await this.removeCacheKey(cacheKey); + continue; + } + rows.push({ + cacheKey, + contentType: String(parsed.contentType || "audio/mpeg"), + bytes: Number(parsed.bytes) || audioStat.size, + createdAt: String(parsed.createdAt || new Date().toISOString()), + lastAccessAt, + audioPath, + metaPath, + sortValue: +new Date(lastAccessAt || parsed.createdAt || 0) || 0 + }); + } catch { + // 单条损坏直接移除,避免拖垮后续缓存命中。 + const cacheKey = file.replace(/\.json$/u, ""); + await this.removeCacheKey(cacheKey); + } + } + let totalBytes = rows.reduce((sum, item) => sum + item.bytes, 0); + if (totalBytes <= this.maxTotalBytes) { + return; + } + rows.sort((a, b) => a.sortValue - b.sortValue); + for (const row of rows) { + if (totalBytes <= this.maxTotalBytes) break; + await Promise.allSettled([rm(row.audioPath, { force: true }), rm(row.metaPath, { force: true })]); + totalBytes -= row.bytes; + } + } +} diff --git a/apps/gateway/src/tts/provider.test.ts b/apps/gateway/src/tts/provider.test.ts new file mode 100644 index 0000000..ab7d33e --- /dev/null +++ b/apps/gateway/src/tts/provider.test.ts @@ -0,0 +1,42 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +describe("tts provider helpers", () => { + beforeEach(() => { + process.env.TTS_PROVIDER = "tencent"; + process.env.TTS_VOICE_DEFAULT = "female_v1"; + process.env.TTS_SPEED_DEFAULT = "1"; + vi.resetModules(); + }); + + it("normalizeTtsRequest 会压缩空白并生成缓存键", async () => { + const { normalizeTtsRequest } = await import("./provider"); + + const result = normalizeTtsRequest({ + text: "请 先检查\r\n\r\n gateway 配置。。。。", + scene: "codex_terminal" + }); + + expect(result.normalizedText).toBe("请 先检查\ngateway 配置。"); + expect(result.cacheKey).toMatch(/^[a-f0-9]{40}$/); + expect(result.voice.alias).toBe("female_v1"); + expect(result.speed).toBe(1); + }); + + it("resolveTtsVoiceProfile 应映射到豆包 1.0 公共音色", async () => { + const { resolveTtsVoiceProfile } = await import("./provider"); + + expect(resolveTtsVoiceProfile("female_v1").volcVoiceType).toBe("zh_female_cancan_mars_bigtts"); + expect(resolveTtsVoiceProfile("male_v1").volcVoiceType).toBe("zh_male_qingshuangnanda_mars_bigtts"); + }); + + it("normalizeTtsRequest 会拒绝超出腾讯云安全字节上限的文本", async () => { + const { normalizeTtsRequest, TtsServiceError } = await import("./provider"); + + expect(() => + normalizeTtsRequest({ + text: "测".repeat(151), + scene: "codex_terminal" + }) + ).toThrowError(TtsServiceError); + }); +}); diff --git a/apps/gateway/src/tts/provider.ts b/apps/gateway/src/tts/provider.ts new file mode 100644 index 0000000..6dddc94 --- /dev/null +++ b/apps/gateway/src/tts/provider.ts @@ -0,0 +1,209 @@ +import { createHash } from "node:crypto"; +import { config } from "../config"; + +export interface TtsSynthesizeInput { + text: string; + scene: "codex_terminal"; + voice?: string; + speed?: number; +} + +export interface TtsVoiceProfile { + alias: string; + providerVoiceType: number; + volcVoiceType: string; +} + +export interface TtsNormalizedRequest { + scene: "codex_terminal"; + normalizedText: string; + voice: TtsVoiceProfile; + speed: number; + textHash: string; + cacheKey: string; + provider: string; +} + +export interface TtsProviderRequest { + text: string; + voice: TtsVoiceProfile; + speed: number; + traceId: string; +} + +export interface TtsProviderResult { + audio: Buffer; + contentType: string; +} + +export interface TtsProviderAdapter { + readonly providerName: string; + synthesize(request: TtsProviderRequest): Promise; +} + +export const TTS_UPSTREAM_REJECTED_MESSAGE = "TTS 上游鉴权或权限失败,请检查密钥、地域和账号权限"; + +const TTS_VOICE_PROFILES: Record = Object.freeze({ + female_v1: { + alias: "female_v1", + providerVoiceType: 101027, + // 豆包语音合成 1.0 公共女声音色,和 `volc.service_type.10029` 同代可直接配套使用。 + volcVoiceType: "zh_female_cancan_mars_bigtts" + }, + male_v1: { + alias: "male_v1", + providerVoiceType: 101004, + // 同步切到豆包 1.0 公共男声音色,避免旧 BV700 音色与当前 resource_id 代际不匹配。 + volcVoiceType: "zh_male_qingshuangnanda_mars_bigtts" + } +}); + +const TTS_MAX_NORMALIZED_UTF8_BYTES = 450; + +/** + * 对外错误统一带 code / status,路由层只做一次翻译。 + */ +export class TtsServiceError extends Error { + code: string; + status: number; + + constructor(code: string, message: string, status = 400) { + super(message); + this.name = "TtsServiceError"; + this.code = code; + this.status = status; + } +} + +/** + * 上游错误正文经常包含换行、长追踪串或 HTML 片段: + * 1. 压成单行,便于进入日志和小程序 warning; + * 2. 截断到有限长度,避免把整段上游响应直接透给前端; + * 3. 保留最关键的错误码和首句说明。 + */ +export function normalizeTtsUpstreamDetail(rawDetail: unknown): string { + const detail = typeof rawDetail === "string" ? rawDetail : String(rawDetail || ""); + if (!detail.trim()) return ""; + const singleLine = detail.replace(/\s+/g, " ").trim(); + return singleLine.length > 180 ? `${singleLine.slice(0, 177)}...` : singleLine; +} + +export function buildTtsUpstreamRejectedMessage(detail?: string): string { + const normalizedDetail = normalizeTtsUpstreamDetail(detail); + return normalizedDetail + ? `${TTS_UPSTREAM_REJECTED_MESSAGE}(${normalizedDetail})` + : TTS_UPSTREAM_REJECTED_MESSAGE; +} + +export function isTtsUpstreamRejectedDetail(detail: string): boolean { + return /(not granted|access token|authorization|auth|permission|forbidden|unauthorized|resource|grant|鉴权|权限|令牌|密钥)/i.test( + normalizeTtsUpstreamDetail(detail) + ); +} + +export function buildTtsUpstreamHttpError(status: number, detail?: string): TtsServiceError { + if (status === 401 || status === 403) { + return new TtsServiceError("TTS_UPSTREAM_REJECTED", buildTtsUpstreamRejectedMessage(detail), 502); + } + const normalizedDetail = normalizeTtsUpstreamDetail(detail); + return new TtsServiceError( + "TTS_UPSTREAM_FAILED", + normalizedDetail ? `TTS 上游请求失败: ${status} ${normalizedDetail}` : `TTS 上游请求失败: ${status}`, + 502 + ); +} + +export function buildTextHash(text: string): string { + return createHash("sha1") + .update(String(text || ""), "utf8") + .digest("hex"); +} + +/** + * 网关二次归一化文本: + * 1. 合并 CRLF / 多空格,避免同义文本重复生成缓存; + * 2. 压缩重复标点,降低 TTS 朗读噪音; + * 3. 保留自然语言句间空格,不在服务端做过度语义改写。 + */ +export function normalizeTtsText(rawText: string): string { + return String(rawText || "") + .replace(/\r\n?/g, "\n") + .replace(/[ \t\f\v]+/g, " ") + .replace(/\n{2,}/g, "\n") + .replace(/([。!?!?.,,;;::])\1{1,}/g, "$1") + .replace(/[ \t]*\n[ \t]*/g, "\n") + .trim(); +} + +export function normalizeTtsSpeed(rawSpeed: unknown): number { + const fallback = Number(config.tts.speedDefault) || 1; + const numeric = Number(rawSpeed); + if (!Number.isFinite(numeric)) { + return Math.max(0.8, Math.min(1.2, fallback)); + } + return Math.max(0.8, Math.min(1.2, Number(numeric.toFixed(2)))); +} + +export function resolveTtsVoiceProfile(rawVoice: unknown): TtsVoiceProfile { + const normalized = String(rawVoice || config.tts.voiceDefault || "female_v1") + .trim() + .toLowerCase(); + return TTS_VOICE_PROFILES[normalized] ?? TTS_VOICE_PROFILES.female_v1!; +} + +export function buildTtsCacheKey( + providerName: string, + voice: TtsVoiceProfile, + speed: number, + normalizedText: string +): string { + return createHash("sha1") + .update( + [ + String(providerName || "") + .trim() + .toLowerCase(), + String(voice.alias || ""), + String(Number(speed).toFixed(2)), + normalizedText, + "v1" + ].join("\n"), + "utf8" + ) + .digest("hex"); +} + +export function normalizeTtsRequest(input: TtsSynthesizeInput): TtsNormalizedRequest { + const source: Partial = + input && typeof input === "object" ? input : { text: "", scene: "codex_terminal" }; + const rawText = String(source.text || ""); + if (rawText.length > 500) { + throw new TtsServiceError("TEXT_TOO_LONG", "播报文本过长", 400); + } + const normalizedText = normalizeTtsText(rawText); + if (!normalizedText) { + throw new TtsServiceError("TEXT_NOT_SPEAKABLE", "当前内容不适合播报", 400); + } + if (Buffer.byteLength(normalizedText, "utf8") > TTS_MAX_NORMALIZED_UTF8_BYTES) { + throw new TtsServiceError("TEXT_TOO_LONG", "播报文本过长", 400); + } + if (normalizedText.length > 280) { + throw new TtsServiceError("TEXT_TOO_LONG", "播报文本过长", 400); + } + const voice = resolveTtsVoiceProfile(source.voice); + const speed = normalizeTtsSpeed(source.speed); + const providerName = + String(config.tts.provider || "tencent") + .trim() + .toLowerCase() || "tencent"; + const scene = source.scene === "codex_terminal" ? "codex_terminal" : "codex_terminal"; + return { + scene, + normalizedText, + voice, + speed, + textHash: buildTextHash(normalizedText), + cacheKey: buildTtsCacheKey(providerName, voice, speed, normalizedText), + provider: providerName + }; +} diff --git a/apps/gateway/src/tts/providers/tencent.test.ts b/apps/gateway/src/tts/providers/tencent.test.ts new file mode 100644 index 0000000..32d5778 --- /dev/null +++ b/apps/gateway/src/tts/providers/tencent.test.ts @@ -0,0 +1,112 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("tencent tts provider", () => { + beforeEach(() => { + process.env.TTS_PROVIDER = "tencent"; + process.env.TTS_SECRET_ID = "secret-id"; + process.env.TTS_SECRET_KEY = "secret-key"; + process.env.TTS_REGION = "ap-guangzhou"; + process.env.TTS_TIMEOUT_MS = "10000"; + vi.resetModules(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("buildTencentTextToVoiceRequest 应生成 TC3 请求头并映射语速", async () => { + const { buildTencentTextToVoiceRequest } = await import("./tencent"); + + const built = buildTencentTextToVoiceRequest( + { + text: "请先检查 gateway 配置。", + voice: { + alias: "female_v1", + providerVoiceType: 101027, + volcVoiceType: "BV700_V2_streaming" + }, + speed: 1, + traceId: "trace-1" + }, + Date.UTC(2026, 2, 12, 8, 0, 0) + ); + + expect(built.url).toBe("https://tts.tencentcloudapi.com"); + expect(built.headers["X-TC-Action"]).toBe("TextToVoice"); + expect(built.headers["X-TC-Region"]).toBe("ap-guangzhou"); + expect(built.headers.Authorization).toContain("TC3-HMAC-SHA256"); + expect(built.payload.VoiceType).toBe(101027); + expect(built.payload.Speed).toBe(0); + }); + + it("较慢与较快倍速应映射到腾讯云 speed 区间", async () => { + const { buildTencentTextToVoiceRequest } = await import("./tencent"); + + const slowBuilt = buildTencentTextToVoiceRequest({ + text: "slow", + voice: { + alias: "female_v1", + providerVoiceType: 101027, + volcVoiceType: "BV700_V2_streaming" + }, + speed: 0.8, + traceId: "trace-slow" + }); + const fastBuilt = buildTencentTextToVoiceRequest({ + text: "fast", + voice: { + alias: "male_v1", + providerVoiceType: 101004, + volcVoiceType: "BV700_V2_streaming" + }, + speed: 1.2, + traceId: "trace-fast" + }); + + expect(slowBuilt.payload.Speed).toBe(-1); + expect(fastBuilt.payload.Speed).toBe(1); + }); + + it("上游返回 403 时应识别为鉴权或权限失败", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + Response: { + Error: { + Code: "AuthFailure.InvalidSecretId", + Message: "The SecretId is not found" + } + } + }), + { + status: 403, + headers: { + "Content-Type": "application/json" + } + } + ) + ) + ); + const { TencentTtsProvider } = await import("./tencent"); + const provider = new TencentTtsProvider(); + + await expect( + provider.synthesize({ + text: "请先检查 gateway 配置。", + voice: { + alias: "female_v1", + providerVoiceType: 101027, + volcVoiceType: "BV700_V2_streaming" + }, + speed: 1, + traceId: "trace-auth-403" + }) + ).rejects.toMatchObject({ + code: "TTS_UPSTREAM_REJECTED", + status: 502, + message: expect.stringContaining("AuthFailure.InvalidSecretId") + }); + }); +}); diff --git a/apps/gateway/src/tts/providers/tencent.ts b/apps/gateway/src/tts/providers/tencent.ts new file mode 100644 index 0000000..4b25a1c --- /dev/null +++ b/apps/gateway/src/tts/providers/tencent.ts @@ -0,0 +1,216 @@ +import { createHmac, createHash, randomUUID } from "node:crypto"; +import { config } from "../../config"; +import type { TtsProviderAdapter, TtsProviderRequest, TtsProviderResult } from "../provider"; +import { + buildTtsUpstreamHttpError, + buildTtsUpstreamRejectedMessage, + normalizeTtsUpstreamDetail, + TtsServiceError +} from "../provider"; + +const TENCENT_TTS_HOST = "tts.tencentcloudapi.com"; +const TENCENT_TTS_ACTION = "TextToVoice"; +const TENCENT_TTS_VERSION = "2019-08-23"; +const TENCENT_TTS_SERVICE = "tts"; + +interface TencentTtsRequestPayload { + Text: string; + SessionId: string; + ModelType: number; + VoiceType: number; + Codec: "mp3"; + SampleRate: number; + PrimaryLanguage: number; + Speed: number; + Volume: number; +} + +interface TencentTtsResponse { + Response?: { + Audio?: string; + Error?: { + Code?: string; + Message?: string; + }; + }; +} + +/** + * 小程序侧把 speed 暴露为“倍速语义”: + * - 1.0 表示 1x; + * - 0.8 / 1.2 分别对应较慢 / 较快。 + * 腾讯云 `Speed` 的 0 才是 1x,因此这里做一层线性映射: + * 0.8 -> -1 + * 1.0 -> 0 + * 1.2 -> 1 + */ +function mapRatioSpeedToTencentSpeed(speed: number): number { + const normalized = Number.isFinite(Number(speed)) ? Number(speed) : 1; + const providerSpeed = (normalized - 1) / 0.2; + return Math.max(-2, Math.min(6, Number(providerSpeed.toFixed(2)))); +} + +function sha256Hex(value: string): string { + return createHash("sha256").update(value, "utf8").digest("hex"); +} + +function hmacSha256( + key: Buffer | string, + value: string, + output: "hex" | "buffer" = "buffer" +): Buffer | string { + const digest = createHmac("sha256", key).update(value, "utf8"); + return output === "hex" ? digest.digest("hex") : digest.digest(); +} + +function parseTencentTtsResponse(rawText: string): TencentTtsResponse | null { + try { + return JSON.parse(rawText) as TencentTtsResponse; + } catch { + return null; + } +} + +/** + * 腾讯云错误体通常同时带 Code 和 Message: + * 1. 优先把 Code 保留下来,便于直接定位 CAM/签名/权限问题; + * 2. 无 JSON 时再退回原始文本,避免完全丢掉上游返回。 + */ +function formatTencentErrorDetail( + errorPayload?: { Code?: string; Message?: string } | null, + rawText?: string +): string { + const code = normalizeTtsUpstreamDetail(errorPayload?.Code); + const message = normalizeTtsUpstreamDetail(errorPayload?.Message); + if (code && message) { + return `${code}: ${message}`; + } + if (code) { + return code; + } + if (message) { + return message; + } + return normalizeTtsUpstreamDetail(rawText); +} + +/** + * 腾讯云 API 3.0(TC3-HMAC-SHA256)签名: + * 1. 仅签当前固定 header 集合,避免实现过度泛化; + * 2. action / version / host 都来自官方 TextToVoice 接口; + * 3. TTS v1 只走短文本同步合成,返回 base64 音频。 + */ +export function buildTencentTextToVoiceRequest(request: TtsProviderRequest, now = Date.now()) { + const secretId = String(config.tts.secretId || "").trim(); + const secretKey = String(config.tts.secretKey || "").trim(); + if (!secretId || !secretKey) { + throw new TtsServiceError("TTS_DISABLED", "TTS 服务未配置", 503); + } + const payload: TencentTtsRequestPayload = { + Text: request.text, + SessionId: request.traceId || randomUUID(), + ModelType: 1, + VoiceType: request.voice.providerVoiceType, + Codec: "mp3", + SampleRate: 16000, + PrimaryLanguage: 1, + Speed: mapRatioSpeedToTencentSpeed(request.speed), + Volume: 1 + }; + const body = JSON.stringify(payload); + const timestamp = Math.max(1, Math.floor(now / 1000)); + const date = new Date(timestamp * 1000).toISOString().slice(0, 10); + const canonicalHeaders = [ + "content-type:application/json; charset=utf-8", + `host:${TENCENT_TTS_HOST}`, + `x-tc-action:${TENCENT_TTS_ACTION.toLowerCase()}` + ].join("\n"); + const signedHeaders = "content-type;host;x-tc-action"; + const canonicalRequest = ["POST", "/", "", `${canonicalHeaders}\n`, signedHeaders, sha256Hex(body)].join( + "\n" + ); + const credentialScope = `${date}/${TENCENT_TTS_SERVICE}/tc3_request`; + const stringToSign = [ + "TC3-HMAC-SHA256", + String(timestamp), + credentialScope, + sha256Hex(canonicalRequest) + ].join("\n"); + const secretDate = hmacSha256(`TC3${secretKey}`, date) as Buffer; + const secretService = hmacSha256(secretDate, TENCENT_TTS_SERVICE) as Buffer; + const secretSigning = hmacSha256(secretService, "tc3_request") as Buffer; + const signature = hmacSha256(secretSigning, stringToSign, "hex") as string; + const authorization = [ + "TC3-HMAC-SHA256", + `Credential=${secretId}/${credentialScope}`, + `SignedHeaders=${signedHeaders}`, + `Signature=${signature}` + ].join(", "); + return { + url: `https://${TENCENT_TTS_HOST}`, + body, + payload, + headers: { + Authorization: authorization, + "Content-Type": "application/json; charset=utf-8", + Host: TENCENT_TTS_HOST, + "X-TC-Action": TENCENT_TTS_ACTION, + "X-TC-Region": config.tts.region, + "X-TC-Timestamp": String(timestamp), + "X-TC-Version": TENCENT_TTS_VERSION + } + }; +} + +export class TencentTtsProvider implements TtsProviderAdapter { + readonly providerName = "tencent"; + + async synthesize(request: TtsProviderRequest): Promise { + const built = buildTencentTextToVoiceRequest(request); + let response: Response; + try { + response = await fetch(built.url, { + method: "POST", + headers: built.headers, + body: built.body, + signal: AbortSignal.timeout(config.tts.timeoutMs) + }); + } catch (error) { + throw new TtsServiceError( + "TTS_UPSTREAM_FAILED", + error instanceof Error && /timeout/i.test(error.message) + ? "语音生成超时,请稍后重试" + : "语音生成失败", + 502 + ); + } + const rawText = await response.text(); + const parsed = parseTencentTtsResponse(rawText); + if (!response.ok) { + throw buildTtsUpstreamHttpError(response.status, formatTencentErrorDetail(parsed?.Response?.Error, rawText)); + } + if (!parsed) { + throw new TtsServiceError("TTS_UPSTREAM_FAILED", "TTS 上游返回格式异常", 502); + } + const errorPayload = parsed.Response?.Error; + if (errorPayload) { + const detail = formatTencentErrorDetail(errorPayload, rawText); + if (/^(AuthFailure|UnauthorizedOperation)\b/.test(String(errorPayload.Code || "").trim())) { + throw new TtsServiceError("TTS_UPSTREAM_REJECTED", buildTtsUpstreamRejectedMessage(detail), 502); + } + throw new TtsServiceError( + "TTS_UPSTREAM_FAILED", + detail || "TTS 上游返回错误", + 502 + ); + } + const audioBase64 = String(parsed.Response?.Audio || "").trim(); + if (!audioBase64) { + throw new TtsServiceError("TTS_UPSTREAM_FAILED", "TTS 上游未返回音频", 502); + } + return { + audio: Buffer.from(audioBase64, "base64"), + contentType: "audio/mpeg" + }; + } +} diff --git a/apps/gateway/src/tts/providers/volcengine.test.ts b/apps/gateway/src/tts/providers/volcengine.test.ts new file mode 100644 index 0000000..203cf4b --- /dev/null +++ b/apps/gateway/src/tts/providers/volcengine.test.ts @@ -0,0 +1,193 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +function createStreamResponse(chunks: string[], contentType: string): Response { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(encoder.encode(chunk)); + } + controller.close(); + } + }); + return new Response(stream, { + status: 200, + headers: { + "Content-Type": contentType + } + }); +} + +describe("volcengine tts provider", () => { + beforeEach(() => { + process.env.TTS_PROVIDER = "volcengine"; + process.env.TTS_APP_ID = "app-id"; + process.env.TTS_ACCESS_TOKEN = "access-token"; + process.env.TTS_RESOURCE_ID = "volc.service_type.10029"; + process.env.TTS_TIMEOUT_MS = "200"; + vi.resetModules(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("buildVolcengineTtsRequest 应生成 V3 HTTP 单向流式请求", async () => { + const { buildVolcengineTtsRequest } = await import("./volcengine"); + + const built = buildVolcengineTtsRequest( + { + text: "请先检查 gateway 配置。", + voice: { + alias: "female_v1", + providerVoiceType: 101027, + volcVoiceType: "zh_female_cancan_mars_bigtts" + }, + speed: 1.2, + traceId: "trace-1" + }, + "access-token" + ); + + expect(built.url).toBe("https://openspeech.bytedance.com/api/v3/tts/unidirectional/sse"); + expect(built.headers).toMatchObject({ + "Content-Type": "application/json", + "X-Api-App-Id": "app-id", + "X-Api-Access-Key": "access-token", + "X-Api-Resource-Id": "volc.service_type.10029", + "X-Control-Require-Usage-Tokens-Return": "text_words" + }); + expect(built.body).toMatchObject({ + user: { + uid: "trace-1" + }, + req_params: { + text: "请先检查 gateway 配置。", + speaker: "zh_female_cancan_mars_bigtts", + audio_params: { + format: "mp3", + sample_rate: 24000, + speech_rate: 20 + }, + additions: '{"disable_markdown_filter":true}' + } + }); + }); + + it("synthesize 应拼接 HTTP Chunked 流式音频块", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue( + createStreamResponse( + [ + '{"code":0,"message":"Success","sequence":1,"data":"', + 'YXVkaW8tMQ=="}\n{"code":0,"message":"Success","sequence":2,"data":"YXVkaW8tMg=="}', + '\n{"code":20000000,"message":"OK","event":152,"data":null,"usage":{"text_words":7}}\n' + ], + "application/json" + ) + ); + vi.stubGlobal("fetch", fetchMock); + + const { VolcengineTtsProvider } = await import("./volcengine"); + const provider = new VolcengineTtsProvider(); + + const result = await provider.synthesize({ + text: "请先检查 gateway 配置。", + voice: { + alias: "female_v1", + providerVoiceType: 101027, + volcVoiceType: "zh_female_cancan_mars_bigtts" + }, + speed: 1, + traceId: "trace-demo" + }); + + expect(result.contentType).toBe("audio/mpeg"); + expect(result.audio).toEqual(Buffer.from("audio-1audio-2")); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + "https://openspeech.bytedance.com/api/v3/tts/unidirectional/sse", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + "X-Api-Resource-Id": "volc.service_type.10029" + }) + }) + ); + }); + + it("synthesize 应兼容 SSE 单向流式响应", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue( + createStreamResponse( + [ + 'event: 352\ndata: {"code":0,"message":"Success","event":352,"sequence":1,"data":"YXVkaW8tMQ=="}\n\n', + 'event: 351\ndata: {"code":0,"message":"Success","event":351,"data":null}\n\n', + 'event: 352\ndata: {"code":0,"message":"Success","event":352,"sequence":2,"data":"YXVkaW8tMg=="}\n\n', + 'event: 152\ndata: {"code":20000000,"message":"OK","event":152,"data":null,"usage":{"text_words":9}}\n\n' + ], + "text/event-stream" + ) + ); + vi.stubGlobal("fetch", fetchMock); + + const { VolcengineTtsProvider } = await import("./volcengine"); + const provider = new VolcengineTtsProvider(); + + const result = await provider.synthesize({ + text: "请先检查 gateway 配置。", + voice: { + alias: "female_v1", + providerVoiceType: 101027, + volcVoiceType: "zh_female_cancan_mars_bigtts" + }, + speed: 1, + traceId: "trace-sse" + }); + + expect(result.audio).toEqual(Buffer.from("audio-1audio-2")); + expect(result.contentType).toBe("audio/mpeg"); + }); + + it("上游返回鉴权错误时应识别为权限失败", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + code: 45000000, + message: "resource is not authorized" + }), + { + status: 403, + headers: { + "Content-Type": "application/json" + } + } + ) + ) + ); + + const { VolcengineTtsProvider } = await import("./volcengine"); + const provider = new VolcengineTtsProvider(); + + await expect( + provider.synthesize({ + text: "请先检查 gateway 配置。", + voice: { + alias: "female_v1", + providerVoiceType: 101027, + volcVoiceType: "zh_female_cancan_mars_bigtts" + }, + speed: 1, + traceId: "trace-auth-403" + }) + ).rejects.toMatchObject({ + code: "TTS_UPSTREAM_REJECTED", + status: 502, + message: expect.stringContaining("resource is not authorized") + }); + }); +}); diff --git a/apps/gateway/src/tts/providers/volcengine.ts b/apps/gateway/src/tts/providers/volcengine.ts new file mode 100644 index 0000000..febfad1 --- /dev/null +++ b/apps/gateway/src/tts/providers/volcengine.ts @@ -0,0 +1,484 @@ +import { randomUUID } from "node:crypto"; +import { config } from "../../config"; +import { logger } from "../../logger"; +import type { TtsProviderAdapter, TtsProviderRequest, TtsProviderResult } from "../provider"; +import { + buildTtsUpstreamHttpError, + buildTtsUpstreamRejectedMessage, + isTtsUpstreamRejectedDetail, + normalizeTtsUpstreamDetail, + TtsServiceError +} from "../provider"; + +// 对齐当前豆包语音 HTTP 单向流式 SSE demo,默认走 SSE 端点; +// 同时仍保留 chunked JSON 解析兜底,兼容代理层或上游的回退响应。 +const VOLCENGINE_TTS_URL = "https://openspeech.bytedance.com/api/v3/tts/unidirectional/sse"; +const VOLCENGINE_TTS_SAMPLE_RATE = 24000; +const VOLCENGINE_STREAM_SUCCESS_CODE = 20000000; +const VOLCENGINE_STREAM_AUTH_REJECTED_CODE = 45000000; +const VOLCENGINE_STREAM_TEXT_TOO_LONG_CODE = 40402003; +const VOLCENGINE_STREAM_SENTENCE_END_EVENT = 351; +const VOLCENGINE_STREAM_AUDIO_EVENT = 352; +const VOLCENGINE_STREAM_FINISH_EVENT = 152; +const VOLCENGINE_STREAM_ERROR_EVENT = 153; + +interface VolcengineTtsRequestBody { + user: { + uid: string; + }; + req_params: { + text: string; + speaker: string; + audio_params: { + format: "mp3"; + sample_rate: number; + speech_rate: number; + }; + additions: string; + }; +} + +interface VolcengineTtsHttpRequest { + url: string; + headers: Record; + body: VolcengineTtsRequestBody; +} + +interface VolcengineTtsStreamPayload { + code?: number; + message?: string; + event?: number; + sequence?: number; + data?: unknown; + usage?: { + text_words?: number; + }; +} + +interface VolcengineStreamState { + audioChunks: Buffer[]; + firstChunkAtMs: number; + finishCode: number | null; + usageTextWords: number | null; +} + +function ensureVolcengineConfig(): void { + if (!config.tts.appId || !config.tts.accessToken || !config.tts.resourceId) { + throw new TtsServiceError("TTS_DISABLED", "TTS 服务未配置", 503); + } +} + +/** + * V3 文档中 `speech_rate` 的取值范围为 `[-50, 100]`: + * 1. `0` 表示 1.0x; + * 2. `100` 表示 2.0x; + * 3. 当前产品只暴露 0.8 / 1.0 / 1.2 三档,因此继续保守映射到同一线性区间。 + */ +function mapRatioSpeedToVolcengineSpeechRate(speed: number): number { + const normalized = Number.isFinite(Number(speed)) ? Number(speed) : 1; + const mapped = Math.round((normalized - 1) * 100); + return Math.max(-50, Math.min(100, mapped)); +} + +export function buildVolcengineTtsRequest( + request: TtsProviderRequest, + accessToken: string +): VolcengineTtsHttpRequest { + ensureVolcengineConfig(); + const requestId = randomUUID(); + return { + url: VOLCENGINE_TTS_URL, + headers: { + "Content-Type": "application/json", + "X-Api-App-Id": config.tts.appId, + // 文档里的 header 名仍是 `X-Api-Access-Key`,但其值实际应填写控制台签发的 Access Token。 + "X-Api-Access-Key": accessToken, + "X-Api-Resource-Id": config.tts.resourceId, + "X-Api-Request-Id": requestId, + // 要求在结束事件里返回 text_words,方便记录计费量和排障。 + "X-Control-Require-Usage-Tokens-Return": "text_words" + }, + body: { + user: { + uid: request.traceId || requestId + }, + req_params: { + text: request.text, + speaker: request.voice.volcVoiceType, + audio_params: { + format: "mp3", + sample_rate: VOLCENGINE_TTS_SAMPLE_RATE, + speech_rate: mapRatioSpeedToVolcengineSpeechRate(request.speed) + }, + // 小程序送来的播报文本常带 Markdown/终端痕迹,要求上游先做一次语法过滤,降低朗读噪音。 + additions: JSON.stringify({ + disable_markdown_filter: true + }) + } + } + }; +} + +function extractHttpErrorDetail(rawText: string): string { + const text = normalizeTtsUpstreamDetail(rawText); + if (!text) { + return ""; + } + try { + const parsed = JSON.parse(text) as { message?: string; code?: number | string; data?: unknown }; + const detail = normalizeTtsUpstreamDetail( + parsed.message || (typeof parsed.data === "string" ? parsed.data : "") || text + ); + if (parsed.code !== undefined) { + return detail ? `code=${parsed.code} ${detail}` : `code=${parsed.code}`; + } + return detail; + } catch { + return text; + } +} + +function extractStreamDetail(payload: VolcengineTtsStreamPayload): string { + const directMessage = normalizeTtsUpstreamDetail(payload.message || ""); + if (directMessage) { + return directMessage; + } + if (typeof payload.data === "string") { + return normalizeTtsUpstreamDetail(payload.data); + } + return ""; +} + +function resolveAudioBase64(data: unknown): string { + if (typeof data === "string") { + return data.trim(); + } + if (!data || typeof data !== "object") { + return ""; + } + const row = data as Record; + const direct = row.audio_base64 ?? row.audio ?? row.audio_data; + return typeof direct === "string" ? direct.trim() : ""; +} + +function createVolcengineStreamError(payload: VolcengineTtsStreamPayload): TtsServiceError { + const code = Number(payload.code ?? 0); + const detail = extractStreamDetail(payload); + if (code === VOLCENGINE_STREAM_TEXT_TOO_LONG_CODE) { + return new TtsServiceError("TEXT_TOO_LONG", "播报文本过长", 400); + } + if (/quota exceeded.*concurrency|concurrency.*quota exceeded|too many requests/i.test(detail)) { + return new TtsServiceError("TTS_BUSY", "语音生成繁忙,请稍后重试", 503); + } + if (code === VOLCENGINE_STREAM_AUTH_REJECTED_CODE || isTtsUpstreamRejectedDetail(detail)) { + return new TtsServiceError("TTS_UPSTREAM_REJECTED", buildTtsUpstreamRejectedMessage(detail), 502); + } + const codeLabel = code > 0 ? `火山 TTS 错误码 ${code}` : "语音生成失败"; + return new TtsServiceError("TTS_UPSTREAM_FAILED", detail ? `${codeLabel}: ${detail}` : codeLabel, 502); +} + +function extractJsonObjects(source: string): { items: string[]; rest: string } { + const items: string[] = []; + let start = -1; + let depth = 0; + let inString = false; + let escaped = false; + + for (let index = 0; index < source.length; index += 1) { + const char = source[index]!; + if (start < 0) { + if (char === "{") { + start = index; + depth = 1; + } + continue; + } + if (inString) { + if (escaped) { + escaped = false; + continue; + } + if (char === "\\") { + escaped = true; + continue; + } + if (char === '"') { + inString = false; + } + continue; + } + if (char === '"') { + inString = true; + continue; + } + if (char === "{") { + depth += 1; + continue; + } + if (char === "}") { + depth -= 1; + if (depth === 0) { + items.push(source.slice(start, index + 1)); + start = -1; + } + } + } + + return { + items, + rest: start >= 0 ? source.slice(start) : "" + }; +} + +function extractSseBlocks(source: string, flush: boolean): { items: string[]; rest: string } { + const items: string[] = []; + let rest = source; + + while (true) { + const matched = rest.match(/\r\n\r\n|\n\n/); + if (!matched || matched.index === undefined) { + break; + } + const block = rest.slice(0, matched.index); + rest = rest.slice(matched.index + matched[0].length); + const dataLines = block + .split(/\r\n|\n/) + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice("data:".length).trimStart()); + if (dataLines.length > 0) { + items.push(dataLines.join("\n")); + } + } + + if (flush && rest.trim()) { + const dataLines = rest + .split(/\r\n|\n/) + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice("data:".length).trimStart()); + if (dataLines.length > 0) { + items.push(dataLines.join("\n")); + rest = ""; + } + } + + return { items, rest }; +} + +function applyStreamPayload( + state: VolcengineStreamState, + payload: VolcengineTtsStreamPayload, + startedAt: number, + traceId: string +): void { + const event = Number(payload.event ?? 0); + const code = Number(payload.code ?? 0); + if (event === VOLCENGINE_STREAM_ERROR_EVENT || (code !== 0 && code !== VOLCENGINE_STREAM_SUCCESS_CODE)) { + throw createVolcengineStreamError(payload); + } + + const audioBase64 = resolveAudioBase64(payload.data); + if (audioBase64 && (event === 0 || event === VOLCENGINE_STREAM_AUDIO_EVENT)) { + if (!state.firstChunkAtMs) { + state.firstChunkAtMs = Date.now(); + logger.info( + { + traceId, + resourceId: config.tts.resourceId, + elapsedMs: state.firstChunkAtMs - startedAt + }, + "火山 TTS 收到首个音频分片" + ); + } + state.audioChunks.push(Buffer.from(audioBase64, "base64")); + return; + } + + if (event === VOLCENGINE_STREAM_SENTENCE_END_EVENT) { + return; + } + + if (event === VOLCENGINE_STREAM_FINISH_EVENT || code === VOLCENGINE_STREAM_SUCCESS_CODE) { + state.finishCode = code || VOLCENGINE_STREAM_SUCCESS_CODE; + state.usageTextWords = + payload.usage && typeof payload.usage.text_words === "number" + ? payload.usage.text_words + : state.usageTextWords; + } +} + +async function consumeVolcengineStream( + response: Response, + request: TtsProviderRequest, + startedAt: number +): Promise { + if (!response.body) { + throw new TtsServiceError("TTS_UPSTREAM_FAILED", "TTS 上游未返回流式响应体", 502); + } + const contentType = String(response.headers.get("content-type") || "").toLowerCase(); + const isSse = contentType.includes("text/event-stream"); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + const state: VolcengineStreamState = { + audioChunks: [], + firstChunkAtMs: 0, + finishCode: null, + usageTextWords: null + }; + let streamBuffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (value) { + streamBuffer += decoder.decode(value, { stream: !done }); + if (isSse) { + const parsed = extractSseBlocks(streamBuffer, false); + streamBuffer = parsed.rest; + for (const item of parsed.items) { + applyStreamPayload( + state, + JSON.parse(item) as VolcengineTtsStreamPayload, + startedAt, + request.traceId + ); + } + } else { + const parsed = extractJsonObjects(streamBuffer); + streamBuffer = parsed.rest; + for (const item of parsed.items) { + applyStreamPayload( + state, + JSON.parse(item) as VolcengineTtsStreamPayload, + startedAt, + request.traceId + ); + } + } + } + if (done) { + break; + } + } + + streamBuffer += decoder.decode(); + if (streamBuffer.trim()) { + if (isSse) { + const parsed = extractSseBlocks(streamBuffer, true); + streamBuffer = parsed.rest; + for (const item of parsed.items) { + applyStreamPayload(state, JSON.parse(item) as VolcengineTtsStreamPayload, startedAt, request.traceId); + } + } else { + const parsed = extractJsonObjects(streamBuffer); + streamBuffer = parsed.rest; + for (const item of parsed.items) { + applyStreamPayload(state, JSON.parse(item) as VolcengineTtsStreamPayload, startedAt, request.traceId); + } + if (streamBuffer.trim()) { + applyStreamPayload( + state, + JSON.parse(streamBuffer) as VolcengineTtsStreamPayload, + startedAt, + request.traceId + ); + streamBuffer = ""; + } + } + } + + if (streamBuffer.trim()) { + throw new TtsServiceError("TTS_UPSTREAM_FAILED", "TTS 上游流式响应不完整", 502); + } + + return state; +} + +export class VolcengineTtsProvider implements TtsProviderAdapter { + readonly providerName = "volcengine"; + + async synthesize(request: TtsProviderRequest): Promise { + const token = String(config.tts.accessToken || "").trim(); + if (!token) { + throw new TtsServiceError("TTS_DISABLED", "TTS 服务未配置", 503); + } + const built = buildVolcengineTtsRequest(request, token); + const timeoutMs = config.tts.timeoutMs; + const startedAt = Date.now(); + let stage = "requesting"; + + logger.info( + { + traceId: request.traceId, + textLength: request.text.length, + resourceId: config.tts.resourceId, + timeoutMs + }, + "火山 TTS 合成开始" + ); + + try { + const response = await fetch(built.url, { + method: "POST", + headers: built.headers, + body: JSON.stringify(built.body), + signal: AbortSignal.timeout(timeoutMs) + }); + stage = "response_headers"; + + if (!response.ok) { + const detail = extractHttpErrorDetail(await response.text()); + throw buildTtsUpstreamHttpError(response.status, detail); + } + + stage = "streaming"; + const streamState = await consumeVolcengineStream(response, request, startedAt); + + if (streamState.audioChunks.length === 0) { + throw new TtsServiceError("TTS_UPSTREAM_FAILED", "TTS 上游未返回音频", 502); + } + + logger.info( + { + traceId: request.traceId, + resourceId: config.tts.resourceId, + chunkCount: streamState.audioChunks.length, + audioBytes: streamState.audioChunks.reduce((sum, item) => sum + item.length, 0), + elapsedMs: Date.now() - startedAt, + firstChunkDelayMs: streamState.firstChunkAtMs ? streamState.firstChunkAtMs - startedAt : null, + usageTextWords: streamState.usageTextWords + }, + "火山 TTS 合成完成" + ); + + return { + audio: Buffer.concat(streamState.audioChunks), + contentType: "audio/mpeg" + }; + } catch (error) { + logger.warn( + { + traceId: request.traceId, + resourceId: config.tts.resourceId, + stage, + elapsedMs: Date.now() - startedAt, + err: error + }, + "火山 TTS 合成失败" + ); + if (error instanceof TtsServiceError) { + throw error; + } + const message = error instanceof Error ? error.message : String(error || ""); + if (/timeout|timed out|aborted|超时/i.test(message)) { + throw new TtsServiceError("TTS_UPSTREAM_FAILED", "语音生成超时,请稍后重试", 502); + } + if (isTtsUpstreamRejectedDetail(message)) { + throw new TtsServiceError("TTS_UPSTREAM_REJECTED", buildTtsUpstreamRejectedMessage(message), 502); + } + throw new TtsServiceError( + "TTS_UPSTREAM_FAILED", + normalizeTtsUpstreamDetail(message) || "语音生成失败", + 502 + ); + } + } +} diff --git a/apps/gateway/src/tts/providers/volcenginePodcastProtocol.ts b/apps/gateway/src/tts/providers/volcenginePodcastProtocol.ts new file mode 100644 index 0000000..9b8e57b --- /dev/null +++ b/apps/gateway/src/tts/providers/volcenginePodcastProtocol.ts @@ -0,0 +1,434 @@ +import { Buffer } from "node:buffer"; +import type WebSocket from "ws"; +import type { RawData } from "ws"; + +/** + * 这里只保留 gateway 现阶段真正会用到的播客协议常量: + * 1. 连接生命周期; + * 2. 会话生命周期; + * 3. 播客音频 round 输出。 + */ +export enum VolcenginePodcastEventType { + StartConnection = 1, + FinishConnection = 2, + ConnectionStarted = 50, + ConnectionFinished = 52, + StartSession = 100, + FinishSession = 102, + SessionStarted = 150, + SessionFinished = 152, + PodcastRoundStart = 360, + PodcastRoundResponse = 361, + PodcastRoundEnd = 362, + PodcastEnd = 363 +} + +export enum VolcenginePodcastMsgType { + FullClientRequest = 0b1, + FullServerResponse = 0b1001, + AudioOnlyServer = 0b1011, + Error = 0b1111 +} + +export enum VolcenginePodcastMsgFlagBits { + NoSeq = 0, + PositiveSeq = 0b1, + NegativeSeq = 0b11, + WithEvent = 0b100 +} + +enum VolcenginePodcastVersionBits { + Version1 = 1 +} + +enum VolcenginePodcastHeaderSizeBits { + HeaderSize4 = 1 +} + +enum VolcenginePodcastSerializationBits { + JSON = 0b1 +} + +enum VolcenginePodcastCompressionBits { + None = 0 +} + +export interface VolcenginePodcastMessage { + version: VolcenginePodcastVersionBits; + headerSize: VolcenginePodcastHeaderSizeBits; + type: VolcenginePodcastMsgType; + flag: VolcenginePodcastMsgFlagBits; + serialization: VolcenginePodcastSerializationBits; + compression: VolcenginePodcastCompressionBits; + event?: VolcenginePodcastEventType; + sessionId?: string; + connectId?: string; + sequence?: number; + errorCode?: number; + payload: Uint8Array; +} + +const messageQueues = new Map(); +const messageResolvers = new Map< + WebSocket, + Array<{ + resolve: (message: VolcenginePodcastMessage) => void; + reject: (error: Error) => void; + timer?: NodeJS.Timeout; + }> +>(); +const initializedSockets = new WeakSet(); + +export function createVolcenginePodcastMessage( + type: VolcenginePodcastMsgType, + flag: VolcenginePodcastMsgFlagBits +): VolcenginePodcastMessage { + return { + version: VolcenginePodcastVersionBits.Version1, + headerSize: VolcenginePodcastHeaderSizeBits.HeaderSize4, + type, + flag, + serialization: VolcenginePodcastSerializationBits.JSON, + compression: VolcenginePodcastCompressionBits.None, + payload: new Uint8Array(0) + }; +} + +function writeUint32(value: number): Uint8Array { + const buffer = new ArrayBuffer(4); + new DataView(buffer).setUint32(0, value >>> 0, false); + return new Uint8Array(buffer); +} + +function writeInt32(value: number): Uint8Array { + const buffer = new ArrayBuffer(4); + new DataView(buffer).setInt32(0, value | 0, false); + return new Uint8Array(buffer); +} + +function writeString(value: string): Uint8Array { + const bytes = Buffer.from(String(value || ""), "utf8"); + const result = new Uint8Array(4 + bytes.length); + result.set(writeUint32(bytes.length), 0); + result.set(bytes, 4); + return result; +} + +function writePayload(payload: Uint8Array): Uint8Array { + const normalized = payload instanceof Uint8Array ? payload : new Uint8Array(payload || []); + const result = new Uint8Array(4 + normalized.length); + result.set(writeUint32(normalized.length), 0); + result.set(normalized, 4); + return result; +} + +export function marshalVolcenginePodcastMessage(message: VolcenginePodcastMessage): Uint8Array { + const parts: Uint8Array[] = []; + const headerSize = 4 * message.headerSize; + const header = new Uint8Array(headerSize); + header[0] = (message.version << 4) | message.headerSize; + header[1] = (message.type << 4) | message.flag; + header[2] = (message.serialization << 4) | message.compression; + parts.push(header); + + if (message.flag === VolcenginePodcastMsgFlagBits.WithEvent) { + parts.push(writeInt32(message.event ?? 0)); + if ( + message.event === VolcenginePodcastEventType.ConnectionStarted || + message.event === VolcenginePodcastEventType.ConnectionFinished + ) { + parts.push(writeString(message.connectId || "")); + } else if ( + message.event !== VolcenginePodcastEventType.StartConnection && + message.event !== VolcenginePodcastEventType.FinishConnection + ) { + parts.push(writeString(message.sessionId || "")); + } + } + + if ( + message.flag === VolcenginePodcastMsgFlagBits.PositiveSeq || + message.flag === VolcenginePodcastMsgFlagBits.NegativeSeq + ) { + parts.push(writeInt32(message.sequence ?? 0)); + } + + if (message.type === VolcenginePodcastMsgType.Error) { + parts.push(writeUint32(message.errorCode ?? 0)); + } + + parts.push(writePayload(message.payload)); + + const totalLength = parts.reduce((sum, item) => sum + item.length, 0); + const merged = new Uint8Array(totalLength); + let offset = 0; + for (const part of parts) { + merged.set(part, offset); + offset += part.length; + } + return merged; +} + +function readUint32(data: Uint8Array, offset: number): number { + return new DataView(data.buffer, data.byteOffset + offset, 4).getUint32(0, false); +} + +function readInt32(data: Uint8Array, offset: number): number { + return new DataView(data.buffer, data.byteOffset + offset, 4).getInt32(0, false); +} + +function readLengthPrefixedString(data: Uint8Array, offset: number): { value: string; nextOffset: number } { + if (offset + 4 > data.length) { + throw new Error("播客 TTS 协议帧缺少字符串长度"); + } + const size = readUint32(data, offset); + const nextOffset = offset + 4; + const endOffset = nextOffset + size; + if (endOffset > data.length) { + throw new Error("播客 TTS 协议帧字符串数据不完整"); + } + return { + value: size > 0 ? new TextDecoder().decode(data.slice(nextOffset, endOffset)) : "", + nextOffset: endOffset + }; +} + +export function unmarshalVolcenginePodcastMessage(data: Uint8Array): VolcenginePodcastMessage { + if (data.length < 4) { + throw new Error("播客 TTS 协议帧长度不足"); + } + let offset = 0; + const versionAndHeaderSize = data[offset] ?? 0; + offset += 1; + const typeAndFlag = data[offset] ?? 0; + offset += 1; + const serializationAndCompression = data[offset] ?? 0; + offset += 1; + offset = 4 * (versionAndHeaderSize & 0b00001111); + + const message: VolcenginePodcastMessage = { + version: (versionAndHeaderSize >> 4) as VolcenginePodcastVersionBits, + headerSize: (versionAndHeaderSize & 0b00001111) as VolcenginePodcastHeaderSizeBits, + type: (typeAndFlag >> 4) as VolcenginePodcastMsgType, + flag: (typeAndFlag & 0b00001111) as VolcenginePodcastMsgFlagBits, + serialization: (serializationAndCompression >> 4) as VolcenginePodcastSerializationBits, + compression: (serializationAndCompression & 0b00001111) as VolcenginePodcastCompressionBits, + payload: new Uint8Array(0) + }; + + if (message.flag === VolcenginePodcastMsgFlagBits.WithEvent) { + message.event = readInt32(data, offset) as VolcenginePodcastEventType; + offset += 4; + if ( + message.event !== VolcenginePodcastEventType.StartConnection && + message.event !== VolcenginePodcastEventType.FinishConnection && + message.event !== VolcenginePodcastEventType.ConnectionStarted && + message.event !== VolcenginePodcastEventType.ConnectionFinished + ) { + const sessionId = readLengthPrefixedString(data, offset); + message.sessionId = sessionId.value; + offset = sessionId.nextOffset; + } + if ( + message.event === VolcenginePodcastEventType.ConnectionStarted || + message.event === VolcenginePodcastEventType.ConnectionFinished + ) { + const connectId = readLengthPrefixedString(data, offset); + message.connectId = connectId.value; + offset = connectId.nextOffset; + } + } + + if ( + message.flag === VolcenginePodcastMsgFlagBits.PositiveSeq || + message.flag === VolcenginePodcastMsgFlagBits.NegativeSeq + ) { + message.sequence = readInt32(data, offset); + offset += 4; + } + + if (message.type === VolcenginePodcastMsgType.Error) { + message.errorCode = readUint32(data, offset); + offset += 4; + } + + if (offset + 4 > data.length) { + throw new Error("播客 TTS 协议帧缺少 payload 长度"); + } + const payloadSize = readUint32(data, offset); + offset += 4; + if (offset + payloadSize > data.length) { + throw new Error("播客 TTS 协议帧 payload 数据不完整"); + } + message.payload = payloadSize > 0 ? data.slice(offset, offset + payloadSize) : new Uint8Array(0); + return message; +} + +function sendMessage(ws: WebSocket, message: VolcenginePodcastMessage): Promise { + const data = marshalVolcenginePodcastMessage(message); + return new Promise((resolve, reject) => { + ws.send(data, (error?: Error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); +} + +function toUint8Array(data: RawData): Uint8Array { + if (Buffer.isBuffer(data)) { + return new Uint8Array(data); + } + if (data instanceof ArrayBuffer) { + return new Uint8Array(data); + } + if (data instanceof Uint8Array) { + return data; + } + if (Array.isArray(data)) { + return Buffer.concat(data.map((item) => Buffer.from(item))); + } + throw new Error(`不支持的播客 TTS 消息类型: ${typeof data}`); +} + +function rejectAllResolvers(ws: WebSocket, error: Error): void { + const resolvers = messageResolvers.get(ws) || []; + while (resolvers.length > 0) { + const resolver = resolvers.shift(); + if (!resolver) continue; + if (resolver.timer) { + clearTimeout(resolver.timer); + } + resolver.reject(error); + } +} + +function setupMessageHandler(ws: WebSocket): void { + if (initializedSockets.has(ws)) { + return; + } + initializedSockets.add(ws); + messageQueues.set(ws, []); + messageResolvers.set(ws, []); + + ws.on("message", (data: RawData) => { + try { + const message = unmarshalVolcenginePodcastMessage(toUint8Array(data)); + const resolvers = messageResolvers.get(ws) || []; + const queue = messageQueues.get(ws) || []; + const pending = resolvers.shift(); + if (pending) { + if (pending.timer) { + clearTimeout(pending.timer); + } + pending.resolve(message); + return; + } + queue.push(message); + messageQueues.set(ws, queue); + } catch (error) { + rejectAllResolvers( + ws, + error instanceof Error ? error : new Error("解析播客 TTS 消息失败") + ); + } + }); + + ws.on("error", (error) => { + rejectAllResolvers(ws, error instanceof Error ? error : new Error("播客 TTS 连接失败")); + }); + + ws.on("close", () => { + rejectAllResolvers(ws, new Error("播客 TTS 连接已关闭")); + messageQueues.delete(ws); + messageResolvers.delete(ws); + }); +} + +export async function receiveVolcenginePodcastMessage( + ws: WebSocket, + timeoutMs: number +): Promise { + setupMessageHandler(ws); + const queue = messageQueues.get(ws) || []; + if (queue.length > 0) { + return queue.shift() as VolcenginePodcastMessage; + } + return new Promise((resolve, reject) => { + const resolvers = messageResolvers.get(ws) || []; + const resolver = { + resolve, + reject, + timer: + timeoutMs > 0 + ? setTimeout(() => { + const currentResolvers = messageResolvers.get(ws) || []; + const index = currentResolvers.indexOf(resolver); + if (index >= 0) { + currentResolvers.splice(index, 1); + } + reject(new Error("播客 TTS 响应超时")); + }, timeoutMs) + : undefined + }; + resolvers.push(resolver); + messageResolvers.set(ws, resolvers); + }); +} + +export async function waitForVolcenginePodcastEvent( + ws: WebSocket, + messageType: VolcenginePodcastMsgType, + eventType: VolcenginePodcastEventType, + timeoutMs: number +): Promise { + const message = await receiveVolcenginePodcastMessage(ws, timeoutMs); + if (message.type !== messageType || message.event !== eventType) { + throw new Error(`播客 TTS 返回了未预期事件: type=${message.type}, event=${message.event}`); + } + return message; +} + +function buildEventPayload(payload: Uint8Array, event: VolcenginePodcastEventType, sessionId?: string) { + const message = createVolcenginePodcastMessage( + VolcenginePodcastMsgType.FullClientRequest, + VolcenginePodcastMsgFlagBits.WithEvent + ); + message.event = event; + if (sessionId) { + message.sessionId = sessionId; + } + message.payload = payload; + return message; +} + +export async function startVolcenginePodcastConnection(ws: WebSocket): Promise { + await sendMessage( + ws, + buildEventPayload(new TextEncoder().encode("{}"), VolcenginePodcastEventType.StartConnection) + ); +} + +export async function finishVolcenginePodcastConnection(ws: WebSocket): Promise { + await sendMessage( + ws, + buildEventPayload(new TextEncoder().encode("{}"), VolcenginePodcastEventType.FinishConnection) + ); +} + +export async function startVolcenginePodcastSession( + ws: WebSocket, + payload: Uint8Array, + sessionId: string +): Promise { + await sendMessage(ws, buildEventPayload(payload, VolcenginePodcastEventType.StartSession, sessionId)); +} + +export async function finishVolcenginePodcastSession(ws: WebSocket, sessionId: string): Promise { + await sendMessage( + ws, + buildEventPayload(new TextEncoder().encode("{}"), VolcenginePodcastEventType.FinishSession, sessionId) + ); +} diff --git a/apps/gateway/src/tts/routes.ts b/apps/gateway/src/tts/routes.ts new file mode 100644 index 0000000..d49bca0 --- /dev/null +++ b/apps/gateway/src/tts/routes.ts @@ -0,0 +1,170 @@ +import type { Express, Request, Response, NextFunction } from "express"; +import { RateLimiterMemory } from "rate-limiter-flexible"; +import { logger } from "../logger"; +import { miniprogramTtsSynthesizeBodySchema } from "./schema"; +import { TtsService } from "./service"; +import { TtsServiceError } from "./provider"; + +interface SyncAuthedRequest extends Request { + syncUser?: { + userId: string; + openid: string; + }; +} + +const ttsService = new TtsService(); +const userLimiter = new RateLimiterMemory({ + points: 20, + duration: 600 +}); +const ipLimiter = new RateLimiterMemory({ + points: 60, + duration: 600 +}); + +function resolvePublicBaseUrl(req: Request): string { + const forwardedProto = String(req.headers["x-forwarded-proto"] || "") + .split(",") + .at(0) + ?.trim(); + const forwardedHost = String(req.headers["x-forwarded-host"] || "") + .split(",") + .at(0) + ?.trim(); + const host = forwardedHost || req.get("host") || "127.0.0.1:8787"; + const protocol = forwardedProto || req.protocol || "http"; + return `${protocol}://${host}`; +} + +function sendTtsError(res: Response, error: unknown) { + if (error && typeof error === "object" && "msBeforeNext" in error) { + res.status(429).json({ + ok: false, + code: "TTS_RATE_LIMITED", + message: "语音播报过于频繁,请稍后重试" + }); + return; + } + const status = error instanceof TtsServiceError ? error.status : 500; + const code = error instanceof TtsServiceError ? error.code : "TTS_INTERNAL_ERROR"; + const message = error instanceof Error ? error.message : "TTS 内部错误"; + res.status(status).json({ + ok: false, + code, + message + }); +} + +async function checkTtsRateLimit(userId: string, ip: string): Promise { + await Promise.all([ + userLimiter.consume(userId || "unknown_user", 1), + ipLimiter.consume(ip || "unknown_ip", 1) + ]); +} + +export function registerMiniprogramTtsRoutes( + app: Express, + requireSyncUser: (req: SyncAuthedRequest, res: Response, next: NextFunction) => void +): void { + app.post("/api/miniprogram/tts/synthesize", requireSyncUser, async (req: SyncAuthedRequest, res) => { + const userId = String(req.syncUser?.userId || "").trim(); + if (!userId) { + res.status(401).json({ ok: false, code: "SYNC_TOKEN_INVALID", message: "同步令牌无效" }); + return; + } + const parsed = miniprogramTtsSynthesizeBodySchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ ok: false, code: "INVALID_BODY", message: "TTS 参数不合法" }); + return; + } + try { + await checkTtsRateLimit(userId, req.socket.remoteAddress ?? "unknown"); + const payload = await ttsService.synthesizeForUser(resolvePublicBaseUrl(req), userId, parsed.data); + res.json(payload); + } catch (error) { + logger.warn( + { + uid: userId, + ip: req.socket.remoteAddress ?? "unknown", + err: error + }, + "小程序 TTS 合成失败" + ); + sendTtsError(res, error); + } + }); + + app.get("/api/miniprogram/tts/status/:cacheKey", async (req, res) => { + const cacheKey = String(req.params.cacheKey || "").trim(); + const ticket = String(req.query.ticket || "").trim(); + if (!cacheKey || !ticket) { + res.status(400).json({ ok: false, code: "TTS_TICKET_INVALID", message: "缺少音频票据" }); + return; + } + try { + ttsService.verifyAudioAccess(cacheKey, ticket); + const status = await ttsService.getSynthesisStatus(cacheKey); + if (status.state === "ready") { + res.json({ ok: true, status: "ready" }); + return; + } + if (status.state === "pending") { + res.json({ ok: true, status: "pending" }); + return; + } + if (status.state === "error") { + res.json({ + ok: false, + status: "error", + code: status.code, + message: status.message + }); + return; + } + res.json({ + ok: false, + status: "missing", + message: "音频仍在生成,请稍后重试" + }); + } catch (error) { + logger.warn( + { + cacheKey, + err: error + }, + "小程序 TTS 状态查询失败" + ); + sendTtsError(res, error); + } + }); + + app.get("/api/miniprogram/tts/audio/:cacheKey", async (req, res) => { + const cacheKey = String(req.params.cacheKey || "").trim(); + const ticket = String(req.query.ticket || "").trim(); + if (!cacheKey || !ticket) { + res.status(400).json({ ok: false, code: "TTS_TICKET_INVALID", message: "缺少音频票据" }); + return; + } + try { + ttsService.verifyAudioAccess(cacheKey, ticket); + const cached = await ttsService.resolveCachedAudio(cacheKey); + if (!cached) { + res.status(404).json({ ok: false, code: "TTS_AUDIO_NOT_FOUND", message: "音频缓存不存在" }); + return; + } + res.setHeader("Content-Type", cached.entry.contentType); + res.setHeader("Content-Length", String(cached.entry.bytes)); + res.setHeader("Cache-Control", "private, max-age=300"); + res.sendFile(cached.audioPath); + } catch (error) { + logger.warn( + { + cacheKey, + err: error + }, + "小程序 TTS 音频读取失败" + ); + sendTtsError(res, error); + } + }); +} diff --git a/apps/gateway/src/tts/schema.ts b/apps/gateway/src/tts/schema.ts new file mode 100644 index 0000000..70ac54c --- /dev/null +++ b/apps/gateway/src/tts/schema.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; + +/** + * v1 只开放 Codex 终端播报场景,避免接口泛化过早。 + */ +export const miniprogramTtsSynthesizeBodySchema = z.object({ + text: z.string().trim().min(1).max(500), + scene: z.literal("codex_terminal"), + voice: z.string().trim().min(1).max(64).optional(), + speed: z.number().min(0.8).max(1.2).optional() +}); + +export type MiniprogramTtsSynthesizeBody = z.infer; diff --git a/apps/gateway/src/tts/service.test.ts b/apps/gateway/src/tts/service.test.ts new file mode 100644 index 0000000..ba2ee7b --- /dev/null +++ b/apps/gateway/src/tts/service.test.ts @@ -0,0 +1,143 @@ +import os from "node:os"; +import path from "node:path"; +import { mkdtemp, rm } from "node:fs/promises"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("tts service", () => { + const originalEnv = { ...process.env }; + const tempDirs: string[] = []; + + interface MockProvider { + providerName: string; + synthesize: () => Promise<{ audio: Buffer; contentType: string }>; + } + + beforeEach(() => { + process.env = { + ...originalEnv, + TTS_PROVIDER: "volcengine", + TTS_APP_ID: "test-app-id", + TTS_ACCESS_TOKEN: "test-access-token", + GATEWAY_TOKEN: "test-gateway-token", + SYNC_SECRET_CURRENT: "test-sync-secret" + }; + vi.resetModules(); + }); + + afterEach(async () => { + process.env = { ...originalEnv }; + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); + }); + + async function createService(provider: MockProvider, options?: { inlineWaitMs?: number }) { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "remoteconn-tts-")); + tempDirs.push(tempDir); + const [{ TtsService }, { TtsCacheStore }] = await Promise.all([import("./service"), import("./cache")]); + const cache = new TtsCacheStore({ + cacheDir: tempDir, + ttlMs: 60 * 1000, + maxTotalBytes: 32 * 1024 * 1024, + maxFileBytes: 8 * 1024 * 1024 + }); + return new TtsService({ + provider, + cache, + inlineWaitMs: options?.inlineWaitMs + }); + } + + async function waitForIdle(service: { getSynthesisStatus: (cacheKey: string) => Promise<{ state: string }> }, cacheKey: string) { + for (let i = 0; i < 20; i += 1) { + const status = await service.getSynthesisStatus(cacheKey); + if (status.state !== "pending") { + return status; + } + await new Promise((resolve) => { + setTimeout(resolve, 10); + }); + } + throw new Error("后台任务未在预期时间内结束"); + } + + it("应在缓存未命中时立即返回 pending,并在后台合成完成后变为 ready", async () => { + let resolveSynthesize: (value: { audio: Buffer; contentType: string }) => void = () => {}; + const provider: MockProvider = { + providerName: "volcengine", + synthesize: vi.fn(() => { + return new Promise<{ audio: Buffer; contentType: string }>((resolve) => { + resolveSynthesize = resolve; + }); + }) + }; + const service = await createService(provider, { inlineWaitMs: 20 }); + const payload = await service.synthesizeForUser("https://gateway.example.com", "user-1", { + text: "连接成功,可以继续。", + scene: "codex_terminal" + }); + + expect(payload.status).toBe("pending"); + expect(payload.cached).toBe(false); + expect((await service.getSynthesisStatus(payload.cacheKey)).state).toBe("pending"); + + resolveSynthesize({ + audio: Buffer.from("fake-mp3-data"), + contentType: "audio/mpeg" + }); + + expect((await waitForIdle(service, payload.cacheKey)).state).toBe("ready"); + + const cachedPayload = await service.synthesizeForUser("https://gateway.example.com", "user-1", { + text: "连接成功,可以继续。", + scene: "codex_terminal" + }); + expect(cachedPayload.status).toBe("ready"); + expect(cachedPayload.cached).toBe(true); + }); + + it("应在短时间内完成合成时直接返回 ready,减少小程序额外轮询", async () => { + const provider: MockProvider = { + providerName: "volcengine", + synthesize: vi.fn( + async () => + await new Promise<{ audio: Buffer; contentType: string }>((resolve) => { + setTimeout(() => { + resolve({ + audio: Buffer.from("fake-mp3-data"), + contentType: "audio/mpeg" + }); + }, 10); + }) + ) + }; + const service = await createService(provider, { inlineWaitMs: 80 }); + const payload = await service.synthesizeForUser("https://gateway.example.com", "user-1", { + text: "连接成功,可以继续。", + scene: "codex_terminal" + }); + + expect(payload.status).toBe("ready"); + expect(payload.cached).toBe(true); + }); + + it("应暴露后台合成失败状态,便于小程序轮询时停止等待", async () => { + const { TtsServiceError } = await import("./provider"); + const provider: MockProvider = { + providerName: "volcengine", + synthesize: vi.fn(async () => { + throw new TtsServiceError("TTS_UPSTREAM_FAILED", "语音生成失败", 502); + }) + }; + const service = await createService(provider, { inlineWaitMs: 20 }); + const payload = await service.synthesizeForUser("https://gateway.example.com", "user-1", { + text: "连接成功,可以继续。", + scene: "codex_terminal" + }); + + const status = await waitForIdle(service, payload.cacheKey); + expect(status).toMatchObject({ + state: "error", + code: "TTS_UPSTREAM_FAILED", + message: "语音生成失败" + }); + }); +}); diff --git a/apps/gateway/src/tts/service.ts b/apps/gateway/src/tts/service.ts new file mode 100644 index 0000000..643116b --- /dev/null +++ b/apps/gateway/src/tts/service.ts @@ -0,0 +1,339 @@ +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import { config } from "../config"; +import { logger } from "../logger"; +import { TtsCacheStore } from "./cache"; +import type { TtsNormalizedRequest, TtsProviderAdapter, TtsSynthesizeInput } from "./provider"; +import { TtsServiceError, normalizeTtsRequest } from "./provider"; +import { TencentTtsProvider } from "./providers/tencent"; +import { VolcengineTtsProvider } from "./providers/volcengine"; +import { createTtsAudioTicket, verifyTtsAudioTicket } from "./ticket"; + +const TTS_AUDIO_TICKET_TTL_MS = 10 * 60 * 1000; +const TTS_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; +const TTS_CACHE_TOTAL_MAX_BYTES = 256 * 1024 * 1024; +const TTS_PROVIDER_CONCURRENCY = 4; +const TTS_PROVIDER_QUEUE_TIMEOUT_MS = 10 * 60 * 1000; +const TTS_BACKGROUND_FAILURE_TTL_MS = 5 * 60 * 1000; +const TTS_SYNTHESIZE_INLINE_WAIT_MS = 800; + +interface TtsAudioAccess { + uid: string; + cacheKey: string; + exp: number; +} + +interface TtsBackgroundFailure { + code: string; + message: string; + status: number; + expiresAt: number; +} + +type TtsSynthesisStatus = + | { state: "ready" } + | { state: "pending" } + | { state: "missing" } + | { state: "error"; code: string; message: string; status: number }; + +interface TtsServiceOptions { + provider?: TtsProviderAdapter; + cache?: TtsCacheStore; + inlineWaitMs?: number; +} + +function sleep(ms: number): Promise { + const waitMs = Math.max(0, Math.round(Number(ms) || 0)); + return new Promise((resolve) => { + setTimeout(resolve, waitMs); + }); +} + +/** + * 最小并发闸门: + * 1. 每实例只允许少量上游 TTS 并发; + * 2. 等待结束后立即唤醒下一个请求; + * 3. v1 不做复杂优先级,保持实现确定性。 + */ +class AsyncSemaphore { + private capacity: number; + private active: number; + private queue: Array<() => void>; + + constructor(capacity: number) { + this.capacity = Math.max(1, capacity); + this.active = 0; + this.queue = []; + } + + async use(task: () => Promise, waitTimeoutMs: number): Promise { + await this.acquire(waitTimeoutMs); + try { + return await task(); + } finally { + this.release(); + } + } + + private acquire(waitTimeoutMs: number): Promise { + if (this.active < this.capacity) { + this.active += 1; + return Promise.resolve(); + } + return new Promise((resolve, reject) => { + const timeoutMs = Math.max(0, Math.round(Number(waitTimeoutMs) || 0)); + let timeout: NodeJS.Timeout | null = null; + const resume = () => { + if (timeout) { + clearTimeout(timeout); + timeout = null; + } + this.active += 1; + resolve(); + }; + this.queue.push(resume); + if (timeoutMs <= 0) { + return; + } + timeout = setTimeout(() => { + const index = this.queue.indexOf(resume); + if (index >= 0) { + this.queue.splice(index, 1); + } + reject(new TtsServiceError("TTS_BUSY", "语音生成繁忙,请稍后重试", 503)); + }, timeoutMs); + }); + } + + private release(): void { + this.active = Math.max(0, this.active - 1); + const next = this.queue.shift(); + if (next) next(); + } +} + +function createProvider(): TtsProviderAdapter { + const providerName = String(config.tts.provider || "tencent") + .trim() + .toLowerCase(); + if (providerName === "volcengine") { + return new VolcengineTtsProvider(); + } + if (providerName === "tencent") { + return new TencentTtsProvider(); + } + throw new TtsServiceError("TTS_DISABLED", `不支持的 TTS provider: ${providerName}`, 503); +} + +export class TtsService { + private provider: TtsProviderAdapter; + private cache: TtsCacheStore; + private semaphore: AsyncSemaphore; + private inflight: Map>; + private failures: Map; + private inlineWaitMs: number; + + constructor(options?: TtsServiceOptions) { + this.provider = options?.provider ?? createProvider(); + this.cache = + options?.cache ?? + new TtsCacheStore({ + cacheDir: path.resolve(process.cwd(), "data/tts-cache"), + ttlMs: TTS_CACHE_TTL_MS, + maxTotalBytes: TTS_CACHE_TOTAL_MAX_BYTES, + // 不同 TTS 供应商的分片/码率差异较大,单文件上限统一改由配置驱动。 + maxFileBytes: config.tts.cacheFileMaxBytes + }); + this.semaphore = new AsyncSemaphore(TTS_PROVIDER_CONCURRENCY); + this.inflight = new Map(); + this.failures = new Map(); + this.inlineWaitMs = Math.max(0, Math.round(Number(options?.inlineWaitMs) || TTS_SYNTHESIZE_INLINE_WAIT_MS)); + } + + private ticketSecret(): string { + const secret = `${config.sync.secretCurrent}:${config.gatewayToken}`; + if (!config.sync.secretCurrent) { + throw new TtsServiceError("TTS_DISABLED", "同步密钥未配置,无法签发音频票据", 503); + } + return secret; + } + + private ensureEnabled(): void { + if (!config.tts.enabled) { + throw new TtsServiceError("TTS_DISABLED", "TTS 服务未配置", 503); + } + } + + private buildAudioAccessUrls(baseUrl: string, uid: string, cacheKey: string) { + const exp = Date.now() + TTS_AUDIO_TICKET_TTL_MS; + const ticket = createTtsAudioTicket(this.ticketSecret(), { uid, cacheKey, exp }); + return { + audioUrl: `${baseUrl}/api/miniprogram/tts/audio/${cacheKey}?ticket=${encodeURIComponent(ticket)}`, + statusUrl: `${baseUrl}/api/miniprogram/tts/status/${cacheKey}?ticket=${encodeURIComponent(ticket)}`, + expiresAt: new Date(exp).toISOString() + }; + } + + private getRecentFailure(cacheKey: string): TtsBackgroundFailure | null { + const row = this.failures.get(cacheKey); + if (!row) { + return null; + } + if (row.expiresAt <= Date.now()) { + this.failures.delete(cacheKey); + return null; + } + return row; + } + + private rememberFailure(cacheKey: string, error: unknown): void { + const failure = + error instanceof TtsServiceError + ? { + code: error.code, + message: error.message, + status: error.status, + expiresAt: Date.now() + TTS_BACKGROUND_FAILURE_TTL_MS + } + : { + code: "TTS_INTERNAL_ERROR", + message: error instanceof Error && error.message ? error.message : "语音生成失败", + status: 500, + expiresAt: Date.now() + TTS_BACKGROUND_FAILURE_TTL_MS + }; + this.failures.set(cacheKey, failure); + } + + private async synthesizeCacheMiss(normalized: TtsNormalizedRequest): Promise { + const result = await this.semaphore.use(async () => { + return await this.provider.synthesize({ + text: normalized.normalizedText, + voice: normalized.voice, + speed: normalized.speed, + traceId: randomUUID() + }); + }, TTS_PROVIDER_QUEUE_TIMEOUT_MS); + await this.cache.put(normalized.cacheKey, result.audio, result.contentType); + } + + private ensureBackgroundSynthesis(normalized: TtsNormalizedRequest): void { + if (this.inflight.has(normalized.cacheKey)) { + return; + } + this.failures.delete(normalized.cacheKey); + const job = (async () => { + try { + await this.synthesizeCacheMiss(normalized); + } catch (error) { + this.rememberFailure(normalized.cacheKey, error); + logger.warn( + { + scene: normalized.scene, + textHash: normalized.textHash, + textLength: normalized.normalizedText.length, + cacheKey: normalized.cacheKey, + provider: this.provider.providerName, + err: error + }, + "小程序 TTS 后台合成失败" + ); + } finally { + this.inflight.delete(normalized.cacheKey); + } + })(); + job.catch(() => { + // 后台任务的错误已经在内部收口到日志与 failure map,这里只防止未处理拒绝。 + }); + this.inflight.set(normalized.cacheKey, job); + } + + /** + * 首次 miss 时短暂等待后台任务: + * 1. 短文本常在 1 秒内就能合成完成,直接返回 ready 可省掉一轮轮询; + * 2. 等待窗口很短,慢请求仍按原有 pending 模式异步完成; + * 3. 这里复用同一个 inflight 任务,不会增加上游并发。 + */ + private async waitInlineForReady(cacheKey: string): Promise { + if (this.inlineWaitMs <= 0) { + return; + } + const inflight = this.inflight.get(cacheKey); + if (!inflight) { + return; + } + await Promise.race([ + inflight.catch(() => { + // 失败状态仍交给后续 status 查询和 failure map 处理。 + }), + sleep(this.inlineWaitMs) + ]); + } + + async synthesizeForUser(baseUrl: string, uid: string, input: TtsSynthesizeInput) { + this.ensureEnabled(); + const normalized = normalizeTtsRequest(input); + let cached = await this.cache.get(normalized.cacheKey); + if (!cached) { + this.ensureBackgroundSynthesis(normalized); + await this.waitInlineForReady(normalized.cacheKey); + cached = await this.cache.get(normalized.cacheKey); + } + const ticketResult = this.buildAudioAccessUrls(baseUrl, uid, normalized.cacheKey); + const status = cached ? "ready" : "pending"; + logger.info( + { + uid, + scene: normalized.scene, + textHash: normalized.textHash, + textLength: normalized.normalizedText.length, + cacheKey: normalized.cacheKey, + provider: this.provider.providerName, + cacheHit: !!cached, + synthStatus: status + }, + cached ? "小程序 TTS 合成完成" : "小程序 TTS 合成任务已提交" + ); + return { + ok: true, + cacheKey: normalized.cacheKey, + cached: !!cached, + status, + audioUrl: ticketResult.audioUrl, + statusUrl: ticketResult.statusUrl, + expiresAt: ticketResult.expiresAt + }; + } + + async getSynthesisStatus(cacheKey: string): Promise { + // 先看内存态,再碰磁盘,避免轮询刚好撞在 cache.put 的写入窗口里把半成品误删。 + if (this.inflight.has(cacheKey)) { + return { state: "pending" }; + } + const cached = await this.cache.get(cacheKey); + if (cached) { + return { state: "ready" }; + } + const failure = this.getRecentFailure(cacheKey); + if (failure) { + return { + state: "error", + code: failure.code, + message: failure.message, + status: failure.status + }; + } + return { state: "missing" }; + } + + verifyAudioAccess(cacheKey: string, ticket: string): TtsAudioAccess { + const payload = verifyTtsAudioTicket(this.ticketSecret(), ticket); + if (payload.cacheKey !== cacheKey) { + throw new TtsServiceError("TTS_TICKET_INVALID", "音频票据无效", 403); + } + return payload; + } + + async resolveCachedAudio(cacheKey: string) { + return await this.cache.get(cacheKey); + } +} diff --git a/apps/gateway/src/tts/ticket.test.ts b/apps/gateway/src/tts/ticket.test.ts new file mode 100644 index 0000000..a088d4c --- /dev/null +++ b/apps/gateway/src/tts/ticket.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; + +import { createTtsAudioTicket, verifyTtsAudioTicket } from "./ticket"; + +describe("tts ticket", () => { + it("应能签发并校验短时音频票据", () => { + const ticket = createTtsAudioTicket("ticket-secret", { + uid: "user-1", + cacheKey: "cache-1", + exp: Date.now() + 60_000 + }); + + expect(verifyTtsAudioTicket("ticket-secret", ticket)).toMatchObject({ + uid: "user-1", + cacheKey: "cache-1" + }); + }); + + it("签名不一致时应拒绝通过", () => { + const ticket = createTtsAudioTicket("ticket-secret", { + uid: "user-1", + cacheKey: "cache-1", + exp: Date.now() + 60_000 + }); + + expect(() => verifyTtsAudioTicket("other-secret", ticket)).toThrow(/signature invalid/); + }); +}); diff --git a/apps/gateway/src/tts/ticket.ts b/apps/gateway/src/tts/ticket.ts new file mode 100644 index 0000000..5b74379 --- /dev/null +++ b/apps/gateway/src/tts/ticket.ts @@ -0,0 +1,64 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; + +export interface TtsTicketPayload { + uid: string; + cacheKey: string; + exp: number; +} + +function base64UrlEncode(input: string): string { + return Buffer.from(input, "utf8") + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); +} + +function base64UrlDecode(input: string): string { + const normalized = String(input || "") + .replace(/-/g, "+") + .replace(/_/g, "/"); + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "="); + return Buffer.from(padded, "base64").toString("utf8"); +} + +function signPayload(secret: string, payload: string): string { + return createHmac("sha256", secret).update(payload, "utf8").digest("base64url"); +} + +export function createTtsAudioTicket(secret: string, payload: TtsTicketPayload): string { + const normalizedPayload = JSON.stringify({ + uid: String(payload.uid || ""), + cacheKey: String(payload.cacheKey || ""), + exp: Math.max(0, Math.round(Number(payload.exp) || 0)) + }); + const encodedPayload = base64UrlEncode(normalizedPayload); + const signature = signPayload(secret, encodedPayload); + return `${encodedPayload}.${signature}`; +} + +export function verifyTtsAudioTicket(secret: string, ticket: string): TtsTicketPayload { + const [encodedPayload, signature] = String(ticket || "").split("."); + if (!encodedPayload || !signature) { + throw new Error("ticket malformed"); + } + const expected = signPayload(secret, encodedPayload); + const signatureBuffer = Buffer.from(signature, "utf8"); + const expectedBuffer = Buffer.from(expected, "utf8"); + if (signatureBuffer.length !== expectedBuffer.length || !timingSafeEqual(signatureBuffer, expectedBuffer)) { + throw new Error("ticket signature invalid"); + } + const payload = JSON.parse(base64UrlDecode(encodedPayload)) as Partial; + const exp = Math.max(0, Math.round(Number(payload.exp) || 0)); + if (!payload.uid || !payload.cacheKey || !exp) { + throw new Error("ticket payload invalid"); + } + if (Date.now() >= exp) { + throw new Error("ticket expired"); + } + return { + uid: String(payload.uid), + cacheKey: String(payload.cacheKey), + exp + }; +} diff --git a/apps/gateway/src/voice/asrText.test.ts b/apps/gateway/src/voice/asrText.test.ts new file mode 100644 index 0000000..c6d1e00 --- /dev/null +++ b/apps/gateway/src/voice/asrText.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { extractAsrText } from "./asrText"; + +describe("extractAsrText", () => { + it("支持 result.text 结构", () => { + expect( + extractAsrText({ + result: { text: "你好世界" } + }) + ).toBe("你好世界"); + }); + + it("支持 result 数组结构", () => { + expect( + extractAsrText({ + result: [{ text: "数组文本" }] + }) + ).toBe("数组文本"); + }); + + it("支持 utterances 结构", () => { + expect( + extractAsrText({ + result: { utterances: [{ text: "分句一" }, { text: "分句二" }] } + }) + ).toBe("分句一"); + }); + + it("支持 payload_msg.result 结构", () => { + expect( + extractAsrText({ + payload_msg: { + result: [{ text: "包装字段文本" }] + } + }) + ).toBe("包装字段文本"); + }); + + it("支持 alternatives.transcript 结构", () => { + expect( + extractAsrText({ + result: { + alternatives: [{ transcript: "候选转写文本" }] + } + }) + ).toBe("候选转写文本"); + }); + + it("支持嵌套 data.result.sentence 结构", () => { + expect( + extractAsrText({ + data: { + result: { + sentence: "嵌套句子文本" + } + } + }) + ).toBe("嵌套句子文本"); + }); + + it("无可识别文本时返回空串", () => { + expect(extractAsrText({ result: [{ start_time: 1 }] })).toBe(""); + }); +}); diff --git a/apps/gateway/src/voice/asrText.ts b/apps/gateway/src/voice/asrText.ts new file mode 100644 index 0000000..325a4cf --- /dev/null +++ b/apps/gateway/src/voice/asrText.ts @@ -0,0 +1,114 @@ +function asRecord(input: unknown): Record | null { + if (!input || typeof input !== "object" || Array.isArray(input)) { + return null; + } + return input as Record; +} + +function firstNonEmpty(items: unknown[]): string { + for (const item of items) { + const text = pickText(item); + if (text) { + return text; + } + } + return ""; +} + +/** + * 从未知结构中提取识别文本: + * 兼容 result.text / result[] / utterances[] / payload_msg 等常见形态。 + */ +function pickText(input: unknown): string { + if (typeof input === "string") { + return input.trim() ? input : ""; + } + + if (Array.isArray(input)) { + return firstNonEmpty(input); + } + + const record = asRecord(input); + if (!record) { + return ""; + } + + const directText = record.text; + if (typeof directText === "string" && directText.trim()) { + return directText; + } + + const aliasTextKeys = ["transcript", "sentence", "content", "utterance", "final_text", "display_text"]; + for (const key of aliasTextKeys) { + const candidate = record[key]; + if (typeof candidate === "string" && candidate.trim()) { + return candidate; + } + } + + const utterances = record.utterances; + if (Array.isArray(utterances)) { + const utterText = firstNonEmpty(utterances); + if (utterText) { + return utterText; + } + } + + const arrayLikeKeys = ["alternatives", "results", "hypotheses", "nbest", "sentences", "segments", "list"]; + for (const key of arrayLikeKeys) { + const candidateList = record[key]; + if (Array.isArray(candidateList)) { + const text = firstNonEmpty(candidateList); + if (text) { + return text; + } + } + } + + const nestedKeys = ["result", "payload_msg", "data", "value"]; + for (const key of nestedKeys) { + const nested = record[key]; + const text = pickText(nested); + if (text) { + return text; + } + } + + return ""; +} + +export function extractAsrText(payload: unknown): string { + if (!payload) { + return ""; + } + + if (Buffer.isBuffer(payload)) { + const text = payload.toString("utf8"); + return text.trim() ? text : ""; + } + + const root = asRecord(payload); + if (!root) { + return pickText(payload); + } + + // 按优先级尝试常见字段,命中即返回。 + const candidates: unknown[] = [ + root.result, + asRecord(root.result)?.result, + root.payload_msg, + asRecord(root.payload_msg)?.result, + root.data, + asRecord(root.data)?.result, + root + ]; + + for (const candidate of candidates) { + const text = pickText(candidate); + if (text) { + return text; + } + } + + return ""; +} diff --git a/apps/gateway/src/voice/clientProtocol.test.ts b/apps/gateway/src/voice/clientProtocol.test.ts new file mode 100644 index 0000000..1f8d1f2 --- /dev/null +++ b/apps/gateway/src/voice/clientProtocol.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { parseVoiceClientFrame } from "./clientProtocol"; + +describe("parseVoiceClientFrame", () => { + it("文本控制帧即使以 Buffer 形式到达也能解析为 start", () => { + const raw = Buffer.from( + JSON.stringify({ + type: "start", + payload: { + audio: { format: "pcm", rate: 16000, bits: 16, channel: 1 } + } + }), + "utf8" + ); + const frame = parseVoiceClientFrame(raw, false); + expect(frame.type).toBe("start"); + }); + + it("二进制帧应解析为 audio", () => { + const raw = Buffer.from([1, 2, 3, 4]); + const frame = parseVoiceClientFrame(raw, true); + expect(frame.type).toBe("audio"); + if (frame.type !== "audio") { + return; + } + expect(frame.payload.length).toBe(4); + }); +}); diff --git a/apps/gateway/src/voice/clientProtocol.ts b/apps/gateway/src/voice/clientProtocol.ts new file mode 100644 index 0000000..90d545f --- /dev/null +++ b/apps/gateway/src/voice/clientProtocol.ts @@ -0,0 +1,68 @@ +import { z } from "zod"; +import type { RawData } from "ws"; + +const startPayloadSchema = z + .object({ + user: z.record(z.string(), z.unknown()).optional(), + audio: z + .object({ + format: z.enum(["pcm", "wav", "ogg", "mp3"]).optional(), + codec: z.enum(["raw", "opus"]).optional(), + rate: z.number().int().positive().optional(), + bits: z.number().int().positive().optional(), + channel: z.number().int().positive().optional(), + language: z.string().min(2).max(16).optional() + }) + .optional(), + request: z.record(z.string(), z.unknown()).optional() + }) + .optional(); + +const controlFrameSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal("start"), payload: startPayloadSchema }), + z.object({ type: z.literal("stop") }), + z.object({ type: z.literal("cancel") }), + z.object({ type: z.literal("ping") }) +]); + +export type VoiceClientControlFrame = z.infer; +export type VoiceClientFrame = VoiceClientControlFrame | { type: "audio"; payload: Buffer }; + +function rawToBuffer(raw: RawData): Buffer { + if (Buffer.isBuffer(raw)) { + return raw; + } + if (raw instanceof ArrayBuffer) { + return Buffer.from(raw); + } + if (Array.isArray(raw)) { + const chunks = raw.map((chunk) => (Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); + return Buffer.concat(chunks); + } + return Buffer.from(raw); +} + +/** + * 前端协议: + * - 文本帧:JSON 控制消息(start/stop/cancel/ping) + * - 二进制帧:原始音频分片(PCM16LE) + */ +export function parseVoiceClientFrame(raw: RawData, isBinary: boolean): VoiceClientFrame { + if (!isBinary) { + const text = typeof raw === "string" ? raw : rawToBuffer(raw).toString("utf8"); + return controlFrameSchema.parse(JSON.parse(text)); + } + + const asBuffer = rawToBuffer(raw); + if (asBuffer.length === 0) { + throw new Error("audio frame is empty"); + } + return { + type: "audio", + payload: asBuffer + }; +} + +export function safeSendVoiceFrame(ws: { send: (data: string) => void }, frame: unknown): void { + ws.send(JSON.stringify(frame)); +} diff --git a/apps/gateway/src/voice/upstreamPayload.test.ts b/apps/gateway/src/voice/upstreamPayload.test.ts new file mode 100644 index 0000000..ac8003a --- /dev/null +++ b/apps/gateway/src/voice/upstreamPayload.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { inferAsrJsonFinal, parseLooseJsonPayloads } from "./upstreamPayload"; + +describe("parseLooseJsonPayloads", () => { + it("支持标准 JSON 文本", () => { + expect(parseLooseJsonPayloads('{"result":{"text":"你好"}}')).toEqual([{ result: { text: "你好" } }]); + }); + + it("支持 NDJSON 文本", () => { + expect(parseLooseJsonPayloads('{"result":{"text":"a"}}\n{"result":{"text":"b"}}')).toEqual([ + { result: { text: "a" } }, + { result: { text: "b" } } + ]); + }); + + it("支持 JSON 粘包文本", () => { + expect(parseLooseJsonPayloads('{"result":{"text":"a"}}{"result":{"text":"b"}}')).toEqual([ + { result: { text: "a" } }, + { result: { text: "b" } } + ]); + }); + + it("对明显非 JSON 内容返回空数组", () => { + expect(parseLooseJsonPayloads("not-a-json-frame")).toEqual([]); + }); +}); + +describe("inferAsrJsonFinal", () => { + it("识别根节点 final 标记", () => { + expect(inferAsrJsonFinal({ is_final: true })).toBe(true); + }); + + it("识别嵌套状态完成标记", () => { + expect(inferAsrJsonFinal({ result: { status: "completed" } })).toBe(true); + }); + + it("无完成标记时返回 false", () => { + expect(inferAsrJsonFinal({ result: { text: "partial" } })).toBe(false); + }); +}); diff --git a/apps/gateway/src/voice/upstreamPayload.ts b/apps/gateway/src/voice/upstreamPayload.ts new file mode 100644 index 0000000..cbeb71a --- /dev/null +++ b/apps/gateway/src/voice/upstreamPayload.ts @@ -0,0 +1,175 @@ +const JSON_FALLBACK_MAX_SCAN_CHARS = 512 * 1024; + +function isTruthyFlag(value: unknown): boolean { + if (value === true) { + return true; + } + if (typeof value === "number") { + return value === 1; + } + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + return ["1", "true", "yes", "on", "final", "finished", "done", "end", "completed"].includes(normalized); + } + return false; +} + +/** + * 兼容上游偶发“非标准文本帧”: + * 1) 标准 JSON; + * 2) NDJSON(一行一个 JSON); + * 3) 多个 JSON 粘包({"a":1}{"b":2})。 + * + * 额外约束: + * - 对超大文本直接放弃兼容扫描,避免 CPU 被异常帧拖垮; + * - 仅从首个 `{` / `[` 开始扫描,跳过前缀噪音(例如日志前缀)。 + */ +export function parseLooseJsonPayloads(rawText: string): unknown[] { + const trimmed = rawText.trim(); + if (!trimmed || trimmed.length > JSON_FALLBACK_MAX_SCAN_CHARS) { + return []; + } + + const firstJsonTokenIndex = trimmed.search(/[{[]/); + if (firstJsonTokenIndex < 0) { + return []; + } + const text = trimmed.slice(firstJsonTokenIndex); + if (!text) { + return []; + } + + try { + return [JSON.parse(text)]; + } catch { + // 继续尝试 line-delimited / 拼接 JSON 形态。 + } + + const linePayloads: unknown[] = []; + const lines = text + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + if (lines.length > 1) { + for (const line of lines) { + if (!line.startsWith("{") && !line.startsWith("[")) { + continue; + } + try { + linePayloads.push(JSON.parse(line)); + } catch { + // 某一行不是 JSON 时忽略,继续尝试其他行。 + } + } + if (linePayloads.length > 0) { + return linePayloads; + } + } + + const chunkPayloads: unknown[] = []; + let start = -1; + let depth = 0; + let quote: '"' | "'" | null = null; + let escaped = false; + + for (let i = 0; i < text.length; i += 1) { + const ch = text[i] ?? ""; + + if (start < 0) { + if (ch === "{" || ch === "[") { + start = i; + depth = 1; + quote = null; + escaped = false; + } + continue; + } + + if (quote) { + if (escaped) { + escaped = false; + continue; + } + if (ch === "\\") { + escaped = true; + continue; + } + if (ch === quote) { + quote = null; + } + continue; + } + + if (ch === '"' || ch === "'") { + quote = ch as '"' | "'"; + continue; + } + if (ch === "{" || ch === "[") { + depth += 1; + continue; + } + if (ch === "}" || ch === "]") { + depth -= 1; + if (depth === 0 && start >= 0) { + const segment = text.slice(start, i + 1); + start = -1; + try { + chunkPayloads.push(JSON.parse(segment)); + } catch { + // 片段不是有效 JSON 时忽略。 + } + } + } + } + + return chunkPayloads; +} + +export function inferAsrJsonFinal(payload: unknown): boolean { + const queue: unknown[] = [payload]; + const visited = new Set(); + const finalKeys = ["is_final", "isFinal", "final", "finished", "end", "is_end", "isEnd", "complete", "completed"]; + + while (queue.length > 0) { + const current = queue.shift(); + if (!current || typeof current !== "object") { + continue; + } + if (visited.has(current)) { + continue; + } + visited.add(current); + + if (Array.isArray(current)) { + queue.push(...current); + continue; + } + + const record = current as Record; + for (const key of finalKeys) { + if (isTruthyFlag(record[key])) { + return true; + } + } + + if (typeof record.status === "string" && isTruthyFlag(record.status)) { + return true; + } + if (typeof record.type === "string" && isTruthyFlag(record.type)) { + return true; + } + + queue.push( + record.result, + record.payload_msg, + record.data, + record.payload, + record.message, + record.messages, + record.utterances, + record.alternatives + ); + } + + return false; +} diff --git a/apps/gateway/src/voice/volcAsrProtocol.test.ts b/apps/gateway/src/voice/volcAsrProtocol.test.ts new file mode 100644 index 0000000..d39d438 --- /dev/null +++ b/apps/gateway/src/voice/volcAsrProtocol.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import { gzipSync } from "node:zlib"; +import { + VolcCompression, + VolcMessageFlags, + VolcMessageType, + VolcSerialization, + buildAudioOnlyRequestFrame, + buildVolcHeader, + isFinalServerResponse, + parseVolcServerFrame +} from "./volcAsrProtocol"; + +describe("volcAsrProtocol", () => { + it("audio-only 最后一包应携带 LAST_PACKAGE flag", () => { + const frame = buildAudioOnlyRequestFrame(Buffer.from([1, 2, 3, 4]), true); + const flag = (frame[1] ?? 0) & 0x0f; + expect(flag).toBe(VolcMessageFlags.LAST_PACKAGE); + }); + + it("可解析 gzip + json 的 full server response", () => { + const payloadObj = { + result: { + text: "测试文本" + } + }; + const payload = gzipSync(Buffer.from(JSON.stringify(payloadObj), "utf8")); + const header = buildVolcHeader({ + messageType: VolcMessageType.FULL_SERVER_RESPONSE, + flags: VolcMessageFlags.POSITIVE_SEQUENCE, + serialization: VolcSerialization.JSON, + compression: VolcCompression.GZIP + }); + const sequence = Buffer.alloc(4); + sequence.writeInt32BE(2, 0); + const payloadSize = Buffer.alloc(4); + payloadSize.writeUInt32BE(payload.length, 0); + + const parsed = parseVolcServerFrame(Buffer.concat([header, sequence, payloadSize, payload])); + expect(parsed.kind).toBe("server_response"); + if (parsed.kind !== "server_response") { + return; + } + expect(parsed.sequence).toBe(2); + expect((parsed.payload as { result: { text: string } }).result.text).toBe("测试文本"); + }); + + it("flags=LAST_PACKAGE 且包含 sequence 时可兼容解析", () => { + const payloadObj = { + result: { + text: "结束包" + } + }; + const payload = gzipSync(Buffer.from(JSON.stringify(payloadObj), "utf8")); + const header = buildVolcHeader({ + messageType: VolcMessageType.FULL_SERVER_RESPONSE, + flags: VolcMessageFlags.LAST_PACKAGE, + serialization: VolcSerialization.JSON, + compression: VolcCompression.GZIP + }); + const sequence = Buffer.alloc(4); + sequence.writeInt32BE(7, 0); + const payloadSize = Buffer.alloc(4); + payloadSize.writeUInt32BE(payload.length, 0); + + const parsed = parseVolcServerFrame(Buffer.concat([header, sequence, payloadSize, payload])); + expect(parsed.kind).toBe("server_response"); + if (parsed.kind !== "server_response") { + return; + } + expect(parsed.sequence).toBe(7); + expect((parsed.payload as { result: { text: string } }).result.text).toBe("结束包"); + }); + + it("可解析 error response", () => { + const errorPayload = Buffer.from("invalid request", "utf8"); + const header = buildVolcHeader({ + messageType: VolcMessageType.ERROR_RESPONSE, + flags: VolcMessageFlags.NONE, + serialization: VolcSerialization.NONE, + compression: VolcCompression.NONE + }); + const errorCode = Buffer.alloc(4); + errorCode.writeUInt32BE(45000001, 0); + const payloadSize = Buffer.alloc(4); + payloadSize.writeUInt32BE(errorPayload.length, 0); + + const parsed = parseVolcServerFrame(Buffer.concat([header, errorCode, payloadSize, errorPayload])); + expect(parsed.kind).toBe("error"); + if (parsed.kind !== "error") { + return; + } + expect(parsed.errorCode).toBe(45000001); + expect(Buffer.isBuffer(parsed.payload)).toBe(true); + expect((parsed.payload as Buffer).toString("utf8")).toBe("invalid request"); + }); + + it("final flag 判定覆盖 LAST_PACKAGE 与 NEGATIVE_SEQUENCE", () => { + expect(isFinalServerResponse(VolcMessageFlags.LAST_PACKAGE)).toBe(true); + expect(isFinalServerResponse(VolcMessageFlags.NEGATIVE_SEQUENCE)).toBe(true); + expect(isFinalServerResponse(VolcMessageFlags.POSITIVE_SEQUENCE)).toBe(false); + }); +}); diff --git a/apps/gateway/src/voice/volcAsrProtocol.ts b/apps/gateway/src/voice/volcAsrProtocol.ts new file mode 100644 index 0000000..4a7ae56 --- /dev/null +++ b/apps/gateway/src/voice/volcAsrProtocol.ts @@ -0,0 +1,265 @@ +import { gunzipSync, gzipSync } from "node:zlib"; + +export const VOLC_PROTOCOL_VERSION = 0b0001; +export const VOLC_HEADER_SIZE_WORDS = 0b0001; // 1 * 4 bytes + +export const enum VolcMessageType { + FULL_CLIENT_REQUEST = 0b0001, + AUDIO_ONLY_REQUEST = 0b0010, + FULL_SERVER_RESPONSE = 0b1001, + ERROR_RESPONSE = 0b1111 +} + +export const enum VolcMessageFlags { + NONE = 0b0000, + POSITIVE_SEQUENCE = 0b0001, + LAST_PACKAGE = 0b0010, + NEGATIVE_SEQUENCE = 0b0011 +} + +export const enum VolcSerialization { + NONE = 0b0000, + JSON = 0b0001 +} + +export const enum VolcCompression { + NONE = 0b0000, + GZIP = 0b0001 +} + +export interface VolcFullClientRequestPayload { + user?: Record; + audio: { + format: "pcm" | "wav" | "ogg" | "mp3"; + codec?: "raw" | "opus"; + rate?: number; + bits?: number; + channel?: number; + language?: string; + }; + request: Record & { + model_name: string; + }; +} + +export interface ParsedVolcServerResponse { + kind: "server_response"; + flags: number; + sequence: number | null; + payload: unknown; +} + +export interface ParsedVolcServerError { + kind: "error"; + flags: number; + errorCode: number; + payload: unknown; +} + +export interface ParsedVolcUnknownFrame { + kind: "unknown"; + messageType: number; + flags: number; + payload: unknown; +} + +export type ParsedVolcServerFrame = ParsedVolcServerResponse | ParsedVolcServerError | ParsedVolcUnknownFrame; + +function decodePayload(serialization: number, compression: number, payload: Buffer): unknown { + const inflated = compression === VolcCompression.GZIP ? gunzipSync(payload) : payload; + if (serialization === VolcSerialization.NONE) { + return inflated; + } + if (serialization === VolcSerialization.JSON) { + const rawText = inflated.toString("utf8"); + return JSON.parse(rawText); + } + throw new Error(`unsupported serialization method: ${serialization}`); +} + +function encodeJsonPayload(payload: unknown, compression: VolcCompression): Buffer { + const raw = Buffer.from(JSON.stringify(payload), "utf8"); + if (compression === VolcCompression.GZIP) { + return gzipSync(raw); + } + return raw; +} + +function encodeBinaryPayload(payload: Buffer, compression: VolcCompression): Buffer { + if (compression === VolcCompression.GZIP) { + return gzipSync(payload); + } + return payload; +} + +export function buildVolcHeader(params: { + messageType: number; + flags: number; + serialization: number; + compression: number; +}): Buffer { + const header = Buffer.alloc(4); + header[0] = ((VOLC_PROTOCOL_VERSION & 0x0f) << 4) | (VOLC_HEADER_SIZE_WORDS & 0x0f); + header[1] = ((params.messageType & 0x0f) << 4) | (params.flags & 0x0f); + header[2] = ((params.serialization & 0x0f) << 4) | (params.compression & 0x0f); + header[3] = 0; + return header; +} + +/** + * 构造 full client request: + * 1) JSON 序列化; + * 2) 使用 GZIP 压缩; + * 3) payload size 使用 4 字节大端无符号整数。 + */ +export function buildFullClientRequestFrame(payload: VolcFullClientRequestPayload): Buffer { + const compressedPayload = encodeJsonPayload(payload, VolcCompression.GZIP); + const header = buildVolcHeader({ + messageType: VolcMessageType.FULL_CLIENT_REQUEST, + flags: VolcMessageFlags.NONE, + serialization: VolcSerialization.JSON, + compression: VolcCompression.GZIP + }); + const payloadSize = Buffer.alloc(4); + payloadSize.writeUInt32BE(compressedPayload.length, 0); + return Buffer.concat([header, payloadSize, compressedPayload]); +} + +/** + * 构造 audio-only request: + * - payload 直接是二进制音频(PCM16LE); + * - 与 full request 保持一致,启用 GZIP 压缩; + * - final=true 时设置 LAST_PACKAGE 标记。 + */ +export function buildAudioOnlyRequestFrame(audioPayload: Buffer, final: boolean): Buffer { + const compressedPayload = encodeBinaryPayload(audioPayload, VolcCompression.GZIP); + const header = buildVolcHeader({ + messageType: VolcMessageType.AUDIO_ONLY_REQUEST, + flags: final ? VolcMessageFlags.LAST_PACKAGE : VolcMessageFlags.NONE, + serialization: VolcSerialization.NONE, + compression: VolcCompression.GZIP + }); + const payloadSize = Buffer.alloc(4); + payloadSize.writeUInt32BE(compressedPayload.length, 0); + return Buffer.concat([header, payloadSize, compressedPayload]); +} + +/** + * 根据文档约定判断“服务端是否为最后一包结果”。 + */ +export function isFinalServerResponse(flags: number): boolean { + return flags === VolcMessageFlags.LAST_PACKAGE || flags === VolcMessageFlags.NEGATIVE_SEQUENCE; +} + +/** + * 解析服务端二进制帧: + * - FULL_SERVER_RESPONSE: [header][sequence?][payload_size][payload] + * - ERROR_RESPONSE: [header][error_code][payload_size][payload] + */ +export function parseVolcServerFrame(frame: Buffer): ParsedVolcServerFrame { + if (frame.length < 8) { + throw new Error("invalid volc frame: too short"); + } + const headerByte0 = frame[0] ?? 0; + const headerByte1 = frame[1] ?? 0; + const headerByte2 = frame[2] ?? 0; + const headerSizeWords = headerByte0 & 0x0f; + const headerSizeBytes = headerSizeWords * 4; + if (headerSizeWords < 1 || frame.length < headerSizeBytes + 4) { + throw new Error("invalid volc frame: bad header size"); + } + + const messageType = (headerByte1 & 0xf0) >> 4; + const flags = headerByte1 & 0x0f; + const serialization = (headerByte2 & 0xf0) >> 4; + const compression = headerByte2 & 0x0f; + + if (messageType === VolcMessageType.FULL_SERVER_RESPONSE) { + const parseServerResponseVariant = ( + withSequence: boolean + ): { ok: true; value: ParsedVolcServerResponse } | { ok: false; reason: string } => { + let offset = headerSizeBytes; + let sequence: number | null = null; + if (withSequence) { + if (frame.length < offset + 4) { + return { ok: false, reason: "invalid volc frame: missing sequence" }; + } + sequence = frame.readInt32BE(offset); + offset += 4; + } + if (frame.length < offset + 4) { + return { ok: false, reason: "invalid volc frame: missing payload size" }; + } + const payloadSize = frame.readUInt32BE(offset); + offset += 4; + if (frame.length < offset + payloadSize) { + return { ok: false, reason: "invalid volc frame: payload truncated" }; + } + const payloadBuffer = frame.subarray(offset, offset + payloadSize); + try { + return { + ok: true, + value: { + kind: "server_response", + flags, + sequence, + payload: decodePayload(serialization, compression, payloadBuffer) + } + }; + } catch (error) { + return { + ok: false, + reason: `invalid volc frame: ${(error as Error).message}` + }; + } + }; + + const preferSequenceFirst = flags === VolcMessageFlags.POSITIVE_SEQUENCE || flags === VolcMessageFlags.NEGATIVE_SEQUENCE; + const firstTry = parseServerResponseVariant(preferSequenceFirst); + if (firstTry.ok) { + return firstTry.value; + } + const secondTry = parseServerResponseVariant(!preferSequenceFirst); + if (secondTry.ok) { + return secondTry.value; + } + throw new Error(secondTry.reason || firstTry.reason); + } + + if (messageType === VolcMessageType.ERROR_RESPONSE) { + let offset = headerSizeBytes; + if (frame.length < offset + 8) { + throw new Error("invalid volc frame: bad error frame"); + } + const errorCode = frame.readUInt32BE(offset); + offset += 4; + const payloadSize = frame.readUInt32BE(offset); + offset += 4; + if (frame.length < offset + payloadSize) { + throw new Error("invalid volc frame: error payload truncated"); + } + const payloadBuffer = frame.subarray(offset, offset + payloadSize); + return { + kind: "error", + flags, + errorCode, + payload: decodePayload(serialization, compression, payloadBuffer) + }; + } + + let payload: unknown = Buffer.alloc(0); + if (frame.length >= headerSizeBytes + 4) { + const payloadSize = frame.readUInt32BE(headerSizeBytes); + const payloadOffset = headerSizeBytes + 4; + if (frame.length >= payloadOffset + payloadSize) { + payload = decodePayload(serialization, compression, frame.subarray(payloadOffset, payloadOffset + payloadSize)); + } + } + + return { + kind: "unknown", + messageType, + flags, + payload + }; +} diff --git a/apps/gateway/src/ws/assistTxnDeduper.test.ts b/apps/gateway/src/ws/assistTxnDeduper.test.ts new file mode 100644 index 0000000..bae54d1 --- /dev/null +++ b/apps/gateway/src/ws/assistTxnDeduper.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from "vitest"; +import { createAssistTxnDeduper } from "./assistTxnDeduper"; + +describe("assistTxnDeduper", () => { + it("仅对 assist + txnId 生效,其它输入不去重", () => { + const dedupe = createAssistTxnDeduper({ ttlMs: 1000, cacheLimit: 4 }); + + expect(dedupe("keyboard", "a")).toBe(false); + expect(dedupe("assist", undefined)).toBe(false); + expect(dedupe(undefined, "a")).toBe(false); + }); + + it("相同 txnId 在 ttl 内应判定为重复", () => { + const dedupe = createAssistTxnDeduper({ ttlMs: 1000, cacheLimit: 4 }); + + expect(dedupe("assist", "txn-1")).toBe(false); + expect(dedupe("assist", "txn-1")).toBe(true); + }); + + it("超过 ttl 后同一 txnId 可重新通过", () => { + vi.useFakeTimers(); + try { + const dedupe = createAssistTxnDeduper({ ttlMs: 1000, cacheLimit: 4 }); + expect(dedupe("assist", "txn-1")).toBe(false); + + vi.advanceTimersByTime(1001); + expect(dedupe("assist", "txn-1")).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it("超过 cacheLimit 时应淘汰最旧记录", () => { + const dedupe = createAssistTxnDeduper({ ttlMs: 60_000, cacheLimit: 2 }); + + expect(dedupe("assist", "txn-1")).toBe(false); + expect(dedupe("assist", "txn-2")).toBe(false); + expect(dedupe("assist", "txn-3")).toBe(false); + + expect(dedupe("assist", "txn-2")).toBe(true); + expect(dedupe("assist", "txn-3")).toBe(true); + expect(dedupe("assist", "txn-1")).toBe(false); + }); +}); diff --git a/apps/gateway/src/ws/assistTxnDeduper.ts b/apps/gateway/src/ws/assistTxnDeduper.ts new file mode 100644 index 0000000..9180e77 --- /dev/null +++ b/apps/gateway/src/ws/assistTxnDeduper.ts @@ -0,0 +1,35 @@ +export interface AssistTxnDeduperOptions { + ttlMs: number; + cacheLimit: number; +} + +export function createAssistTxnDeduper(options: AssistTxnDeduperOptions): (source?: string, txnId?: string) => boolean { + const seenAt = new Map(); + + return (source?: string, txnId?: string): boolean => { + if (source !== "assist" || !txnId) { + return false; + } + + const now = Date.now(); + for (const [id, at] of seenAt.entries()) { + if (now - at > options.ttlMs) { + seenAt.delete(id); + } + } + + if (seenAt.has(txnId)) { + return true; + } + + seenAt.set(txnId, now); + if (seenAt.size > options.cacheLimit) { + const oldestId = seenAt.keys().next().value as string | undefined; + if (oldestId) { + seenAt.delete(oldestId); + } + } + + return false; + }; +} diff --git a/apps/gateway/src/ws/protocol.test.ts b/apps/gateway/src/ws/protocol.test.ts new file mode 100644 index 0000000..0e69bfe --- /dev/null +++ b/apps/gateway/src/ws/protocol.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { parseInboundFrame } from "./protocol"; + +describe("gateway protocol", () => { + it("解析 init 帧", () => { + const parsed = parseInboundFrame( + JSON.stringify({ + type: "init", + payload: { + host: "127.0.0.1", + port: 22, + username: "root", + resumeGraceMs: 900000, + credential: { type: "password", password: "x" }, + pty: { cols: 80, rows: 24 } + } + }) + ); + expect(parsed.type).toBe("init"); + if (parsed.type === "init") { + expect(parsed.payload.resumeGraceMs).toBe(900000); + } + }); + + it("解析带跳板机的 init 帧", () => { + const parsed = parseInboundFrame( + JSON.stringify({ + type: "init", + payload: { + host: "10.0.0.10", + port: 22, + username: "deploy", + credential: { type: "privateKey", privateKey: "TARGET_KEY" }, + jumpHost: { + host: "10.0.0.1", + port: 2222, + username: "bastion", + credential: { type: "password", password: "secret" } + }, + pty: { cols: 120, rows: 32 } + } + }) + ); + expect(parsed.type).toBe("init"); + if (parsed.type === "init") { + expect(parsed.payload.jumpHost?.host).toBe("10.0.0.1"); + expect(parsed.payload.jumpHost?.port).toBe(2222); + expect(parsed.payload.jumpHost?.username).toBe("bastion"); + } + }); + + it("解析带 meta 的 stdin 帧", () => { + const parsed = parseInboundFrame( + JSON.stringify({ + type: "stdin", + payload: { + data: "测试", + meta: { + source: "assist", + txnId: "assist-1" + } + } + }) + ); + expect(parsed.type).toBe("stdin"); + if (parsed.type === "stdin") { + expect(parsed.payload.meta?.source).toBe("assist"); + expect(parsed.payload.meta?.txnId).toBe("assist-1"); + } + }); + + it("解析带原因的 disconnect 控制帧", () => { + const parsed = parseInboundFrame( + JSON.stringify({ + type: "control", + payload: { + action: "disconnect", + reason: "manual" + } + }) + ); + expect(parsed.type).toBe("control"); + if (parsed.type === "control") { + expect(parsed.payload.action).toBe("disconnect"); + expect(parsed.payload.reason).toBe("manual"); + } + }); +}); diff --git a/apps/gateway/src/ws/protocol.ts b/apps/gateway/src/ws/protocol.ts new file mode 100644 index 0000000..ddb91dc --- /dev/null +++ b/apps/gateway/src/ws/protocol.ts @@ -0,0 +1,94 @@ +import { z } from "zod"; + +const initPayloadSchema = z.object({ + host: z.string().min(1), + port: z.number().int().positive().max(65535), + username: z.string().min(1), + clientSessionKey: z.string().min(1).max(128).optional(), + /** + * 续接驻留窗口(毫秒): + * - 允许客户端按连接声明“离开页面后保留 SSH 会话多久”; + * - 服务器侧仍会做最小/最大值裁剪。 + */ + resumeGraceMs: z + .number() + .int() + .positive() + .max(24 * 60 * 60 * 1000) + .optional(), + credential: z.union([ + z.object({ type: z.literal("password"), password: z.string().min(1) }), + z.object({ + type: z.literal("privateKey"), + privateKey: z.string().min(1), + passphrase: z.string().optional() + }), + z.object({ + type: z.literal("certificate"), + privateKey: z.string().min(1), + passphrase: z.string().optional(), + certificate: z.string().min(1) + }) + ]), + jumpHost: z + .object({ + host: z.string().min(1), + port: z.number().int().positive().max(65535), + username: z.string().min(1), + credential: z.union([ + z.object({ type: z.literal("password"), password: z.string().min(1) }), + z.object({ + type: z.literal("privateKey"), + privateKey: z.string().min(1), + passphrase: z.string().optional() + }), + z.object({ + type: z.literal("certificate"), + privateKey: z.string().min(1), + passphrase: z.string().optional(), + certificate: z.string().min(1) + }) + ]), + knownHostFingerprint: z.string().optional() + }) + .optional(), + knownHostFingerprint: z.string().optional(), + pty: z.object({ cols: z.number().int().positive(), rows: z.number().int().positive() }) +}); + +const stdinMetaSchema = z.object({ + source: z.enum(["keyboard", "assist"]), + txnId: z.string().min(1).max(128).optional() +}); + +const inboundFrameSchema = z.union([ + z.object({ type: z.literal("init"), payload: initPayloadSchema }), + z.object({ + type: z.literal("stdin"), + payload: z.object({ + data: z.string(), + meta: stdinMetaSchema.optional() + }) + }), + z.object({ + type: z.literal("resize"), + payload: z.object({ cols: z.number().int().positive(), rows: z.number().int().positive() }) + }), + z.object({ + type: z.literal("control"), + payload: z.object({ + action: z.enum(["ping", "pong", "disconnect"]), + reason: z.string().min(1).max(128).optional() + }) + }) +]); + +export type InboundFrame = z.infer; + +export function parseInboundFrame(raw: string): InboundFrame { + return inboundFrameSchema.parse(JSON.parse(raw)); +} + +export function safeSend(socket: { send: (data: string) => void }, frame: unknown): void { + socket.send(JSON.stringify(frame)); +} diff --git a/apps/gateway/tsconfig.json b/apps/gateway/tsconfig.json new file mode 100644 index 0000000..be4ab7f --- /dev/null +++ b/apps/gateway/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "baseUrl": ".", + "paths": { + "@remoteconn/shared": ["../../packages/shared/src/index.ts"] + } + }, + "include": ["src/**/*.ts"] +} diff --git a/apps/miniprogram/.env.example b/apps/miniprogram/.env.example new file mode 100644 index 0000000..192a191 --- /dev/null +++ b/apps/miniprogram/.env.example @@ -0,0 +1,12 @@ +# MiniProgram 运维配置(不对用户展示) +GATEWAY_URL=wss://gateway.example.com +GATEWAY_TOKEN=replace-with-strong-random-token + +# 可选:其余参数不填则使用代码默认值 +HOST_KEY_POLICY=strict +CREDENTIAL_MEMORY_POLICY=remember +GATEWAY_CONNECT_TIMEOUT_MS=12000 +WAIT_FOR_CONNECTED_TIMEOUT_MS=15000 +TERMINAL_BUFFER_MAX_ENTRIES=5000 +TERMINAL_BUFFER_MAX_BYTES=4194304 +MASK_SECRETS=true diff --git a/apps/miniprogram/README.md b/apps/miniprogram/README.md new file mode 100644 index 0000000..c91209e --- /dev/null +++ b/apps/miniprogram/README.md @@ -0,0 +1,82 @@ +# RemoteConn MiniProgram(稳定基线 v3.0.1) + +状态:`已对齐核心功能,当前稳定基线为 v3.0.1` +目标版本:`v3.0.1` + +本次 `v3.0.1` 主要收口小程序使用手册与主题口径:使用手册补成图文版,正文前置“为什么需要这个APP?”,并接入 `guide-mobile-*` 配图;主题预设补齐到与 Web 一致的 `21` 套,主题名称支持随语言切换且尽量压短为单词短名。当前版本不引入新的同步协议、终端协议或配置字段,继续沿用 `v3.0.0` 已明确的能力边界。 + +## 1. 当前已完成范围 + +1. 页面骨架:`connect`、`server settings`、`terminal`、`logs`、`records`、`settings`、`plugins`。 +2. 数据基础层:服务器/设置/闪念以“本地 storage + Gateway 同步”双层持久化,日志与插件运行记录仍保留本地存储;已补齐 `updatedAt / category / contextLabel / processed / discarded`。 +3. 传输:`wx.connectSocket` 网关连接、心跳、收发、断开。 +4. 分页:日志与闪念均为 15 条/页。 +5. 语音区:Frame2256 交互 + `/ws/asr` 实时采集上行 + 结果回填,支持分类选择、未连接可记录闪念、写入 `服务器名称-项目名` 快照。 +6. 闪念页:搜索、分类过滤、左滑废弃 / 已处理 / 复制 / 删除、正文编辑、分类快速改写、导出。 +7. 配置页:`记录 -> 闪念分类` 管理,支持新增、设默认、删除、按住拖动排序。 +8. 服务器:配置页支持自定义标签(逗号分隔),服务器卡片底部展示项目标签与自定义标签,并支持左滑复制 / 删除单台服务器。 +9. 插件:运行时启停/重载/移除、JSON 导入导出、命令执行、运行日志。 +10. 资源:图标路径统一版本 query(`v=2026022701`)。 +11. 终端:已按 xterm 风格完成 cell 光标模型重构,补齐真实 `cols/rows`、宽字符 continuation、自绘 caret、固定列宽渲染与双 probe 宽度测量,当前中文/英文混排输入已稳定。 +12. 会话续接:终端页返回其他页面后默认保活 `15` 分钟,可在设置页调到 `1~60` 分钟;回到同一服务器会优先复用原会话并恢复尾部缓冲。首次恢复优先使用保存时的 `lines + bufferCols / bufferRows` 还原当前屏幕,仅在后续真实几何变化时再使用 `replayText` 重建,避免顶部空白与裸露 `5;2H` 一类控制串回归。 +13. 连接反馈:服务器列表“连接”按钮与底栏 `shell` 按钮在活动连接态统一切到高饱和实底高亮,便于快速识别当前连接状态。 +14. 跳转主机:服务器配置页已支持跳板机主机、端口、用户名、认证方式与独立凭据;目录选择与终端连接链路均可透传第一跳/第二跳配置。 +15. AI 快速启动:服务器列表 `AI` 按钮和终端左上角 AI 按钮都会按“全局配置 -> 连接 -> AI连接”的默认 AI 直接启动;终端页支持 Codex 目录预检、二进制预检与连接后自动启动。当前同一条终端会话内新增了 AI 前台互斥保护,AI 运行期间重复点击会直接提示而不再把启动命令写进前台 TUI;当 Codex / Copilot 正常退出回到 shell 后,保护会自动解除。 +16. 终端语音与主题视觉已继续收口:展开语音区按钮默认全透明、分类胶囊改为贴文字高度、录音中输入框上方新增脉冲提示;终端顶栏与语音区按钮继续按 UI / Shell 双域规则渲染。 + +## 2. 当前说明 + +1. 机读对齐清单已更新为 `done`,见 `apps/miniprogram/parity.v2.6.0.json`。 +2. 小程序终端 VT 能力已进入 P0 基线:双缓冲、备用屏幕切换、`DSR / CPR / DA1 / DA2 / DECSTR`、基础局部重绘和移动端常用原始按键编码已接入。 +3. normal buffer 的 live tail 与最大滚动值已统一到同一口径,回到底部后不会再继续把当前命令行往上推。 +4. 当前仍建议通过微信开发者工具和真机做交互验收,尤其是服务器卡片左滑复制 / 删除、闪念左滑废弃 / 已处理 / 复制 / 删除、分类切换、语音记录链路,以及 `top / less / vim` 一类终端程序的抽样验证。 +5. 终端字号修改后仍存在偶发“吃字/显示不完整”遗留问题,当前版本在设置页增加了“修改字号后建议断开重连”的提示,作为临时规避方案。 +6. 当前业务数据采用“双层持久化”: + 服务器配置、用户设置与闪念记录会先写入小程序本地 storage,并在同步配置完整时通过 Gateway + SQLite 同步到服务端; + 日志、插件运行记录与终端会话缓冲仍仅保留在本地。 + 通过 `npm run mini` 生成的 preview 预览二维码,不作为正式版跨设备同步与本地缓存连续性的验证依据。 +7. Gateway 的同步 SQLite 目前启用 `WAL` 模式: + `data/remoteconn-sync.db-wal` 是写前日志,文件体积不直接等于当前有效同步数据量; + 需要结合 checkpoint 后的主库体积、`user_settings / user_servers / user_records` 行数与字段大小一起判断。 +8. 当前版本最重要的交互修复是:小程序终端在 `Codex` 持续输出期间,底部提示块缺行、状态行被裁掉与区域反复闪动的问题已收口;normal buffer viewport 会保留光标行之后仍真实存在的 footer,`CSI ? 2026 h/l` 同步刷新窗口也已做兼容。 +9. 同一轮交互修复还收口了高亮块透底细线问题:像 `> Use /skills to list available skills` 和代码块这类整行统一背景区域,背景优先提升到 line 层绘制,不再在行与行之间露出底色细缝,也没有新增渲染节点。 +10. 此前点击 Codex 连接选项后的首回显迟滞与等待期间按钮阻塞问题已收敛,当前版本不再将其作为已知遗留问题保留。 +11. 当前另记录一个低频连接时序遗留问题:偶发新连接后首屏只显示光标、提示符稍后才出现;初步定位为 `connected` 状态与光标可见时机早于首个可见 `stdout / prompt`,后续再继续优化。 +12. 会话续接恢复口径已明确:首次回到终端页时,以挂起前屏幕快照和当时终端几何作为权威来源;被裁剪过的 `replayText` 不再参与首次恢复,避免历史区顶部空白和定位参数残片再次出现。 +13. 时延诊断浮窗已收口成单张双轴平滑曲线图:左轴显示网关响应,右轴显示网络时延;顶部只保留两张摘要卡,面板配色跟随终端主题反相推导,深色终端会自动切到更深的蓝橙曲线与指标色;同一服务器断开重连后会尽量延续最近 `30` 个采样点。 +14. 当前小程序终端语音播报仍属于待优化能力:播报文本提取与轮次稳定判定还不够准确,长时间 `Codex` 交互时也会放大小程序端响应压力;现阶段暂不建议默认使用,先作为遗留问题保留。 +15. 当前另记录一个 AI 交互期输入遗留问题:在 `Codex` 等 AI 持续输出期间,点击 shell 激活区弹出软键盘后,输入框/激活区仍可能发生跳跃,导致无法稳定连续输入;该问题尚未完成稳定修复,先按已知遗留问题登记。 + +## 3. 对齐清单 + +机读清单:`apps/miniprogram/parity.v2.6.0.json` + +## 4. 实施依据 + +1. `docs/records-enhancement-plan-2026-03-06.md` +2. `docs/miniprogram-config-implementation-plan-2026-02-28.md` +3. `docs/xterm-cursor-algorithm-2026-03-07.md` +4. `docs/miniprogram-terminal-cursor-gap-analysis-2026-03-07.md` +5. `docs/ssh-jump-encryption-diagram-2026-03-07.md` +6. `docs/miniprogram-terminal-vt-guardrails-2026-03-08.md` +7. `docs/miniprogram-terminal-vt-implementation-plan-2026-03-08.md` +8. `docs/miniprogram-codex-footer-flicker-optimization-plan-2026-03-11.md` + +## 5. 本地调试 + +1. 使用微信开发者工具打开 `apps/miniprogram` 目录。 +2. 如需连通网关,在 `apps/miniprogram/.env` 中配置运维配置:至少包含 `GATEWAY_URL` 和 `GATEWAY_TOKEN`(可参考 `apps/miniprogram/.env.example`,该配置不对最终用户展示)。 +3. 回到“服务器”页创建服务器并进入终端测试连通性。 + +## 6. 命令行预览 + +在仓库根目录可直接使用以下脚本: + +1. `npm run mini` + 在终端输出小程序预览二维码;若终端二维码首次渲染失败,会自动在 shell 中重画一次。 + +说明: + +1. 该命令会先读取 `apps/miniprogram/.env`,并生成 `apps/miniprogram/utils/opsEnv.js` 供小程序运行时使用。 +2. 默认使用仓库根目录私钥:`./private.wxa0e7e5a27599cf6c.key`。 +3. 执行前,需要在微信公众平台完成上传密钥下载和 IP 白名单配置。 diff --git a/apps/miniprogram/app.js b/apps/miniprogram/app.js new file mode 100644 index 0000000..3e63f79 --- /dev/null +++ b/apps/miniprogram/app.js @@ -0,0 +1,23 @@ +/* global App, console, require */ + +const { ensureSyncBootstrap } = require("./utils/syncService"); + +/** + * 微信小程序应用入口。 + * 说明: + * 1. 当前对外版本口径统一为 v3.0.0; + * 2. 服务器、设置与闪念采用“本地 storage + Gateway 同步”双层持久化; + * 3. 日志、插件运行记录与终端运行态仍仅保留本地。 + */ +App({ + globalData: { + appVersion: "3.0.0", + platform: "wechat-miniprogram" + }, + + onLaunch() { + // 启动阶段仅记录版本,用于后续灰度/埋点标记。 + console.info("[RemoteConn MiniProgram] launch", this.globalData); + ensureSyncBootstrap(); + } +}); diff --git a/apps/miniprogram/app.json b/apps/miniprogram/app.json new file mode 100644 index 0000000..48668c7 --- /dev/null +++ b/apps/miniprogram/app.json @@ -0,0 +1,27 @@ +{ + "pages": [ + "pages/connect/index", + "pages/server-settings/index", + "pages/terminal/index", + "pages/logs/index", + "pages/records/index", + "pages/settings/index", + "pages/plugins/index", + "pages/about/index", + "pages/about-manual/index", + "pages/about-feedback/index", + "pages/about-privacy/index", + "pages/about-changelog/index", + "pages/about-app/index" + ], + "window": { + "navigationBarTitleText": "RemoteConn", + "navigationBarBackgroundColor": "#192b4d", + "navigationBarTextStyle": "white", + "backgroundColor": "#192b4d", + "backgroundTextStyle": "light" + }, + "style": "v2", + "lazyCodeLoading": "requiredComponents", + "sitemapLocation": "sitemap.json" +} diff --git a/apps/miniprogram/app.wxss b/apps/miniprogram/app.wxss new file mode 100644 index 0000000..751dc10 --- /dev/null +++ b/apps/miniprogram/app.wxss @@ -0,0 +1,391 @@ +/** + * 小程序全局样式: + * 1. 颜色变量与当前小程序基线 v2.9.1 保持一致; + * 2. 抽取页面骨架、工具栏、面板、按钮、底栏等通用样式; + * 3. 页面只做局部差异,尽量复用这里的语义类。 + */ +page { + --bg: #192b4d; + --shell-bg: #192b4d; + --shell-text: #e6f0ff; + --shell-accent: #9ca9bf; + --shell-font-family: JetBrains Mono, "SFMono-Regular", Menlo, monospace; + --shell-font-size: 15px; + --shell-line-height: 1.4; + --surface: rgba(20, 32, 56, 0.64); + --surface-border: rgba(118, 156, 213, 0.2); + --surface-shadow: rgba(91, 210, 255, 0.18); + --text: #e6f0ff; + --muted: #9cb1cf; + --accent: #5bd2ff; + --btn: #adb9cd; + --btn-border: #7e8ca4; + --btn-border-strong: #95a2b9; + --btn-bg: #374767; + --btn-bg-strong: #4b5b79; + --btn-bg-active: #3f506e; + --btn-text: #dce6f6; + --btn-danger-border: #4eb1db; + --btn-danger-bg: #285074; + --chip-bg: #2b5a7f; + --chip-text: #e6f0ff; + --accent-divider: rgba(91, 210, 255, 0.6); + --switch-on-bg: #6f7d97; + --switch-off-bg: #4a5a78; + --switch-knob: #f1f7ff; + --icon-btn-bg: #2e3f5f; + --icon-btn-bg-strong: #3d4d6c; + --accent-bg: #25496d; + --accent-bg-strong: #2e6086; + --accent-border: #49a3cd; + --accent-ring: rgba(91, 210, 255, 0.22); + --accent-shadow: rgba(91, 210, 255, 0.28); + --shell-btn-bg: #50607d; + --shell-btn-text: #c1cddf; + --shell-accent-bg: #314262; + --shell-accent-bg-strong: #435371; + --shell-accent-border: #7a88a1; + --shell-accent-ring: rgba(156, 169, 191, 0.24); + --shell-accent-shadow: rgba(156, 169, 191, 0.3); + --terminal-touch-tools-bg: rgba(25, 43, 77, 0.8); + --danger: #ff7f92; + background: var(--bg); + color: var(--text); + font-size: 26rpx; + font-family: "PingFang SC", "SF Pro Text", "Microsoft YaHei", sans-serif; +} + +view, +text, +input, +textarea, +button { + box-sizing: border-box; +} + +button::after { + border: none; +} + +button { + position: relative; +} + +button::before { + content: ""; + position: absolute; + top: -8px; + right: -8px; + bottom: -8px; + left: -8px; +} + +.page-root { + height: 100vh; + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--bg); + color: var(--text); +} + +.page-toolbar { + flex: 0 0 72rpx; + height: 72rpx; + background: var(--bg); + border-bottom: 1rpx solid var(--accent-divider); + display: flex; + align-items: center; + gap: 16rpx; + padding: 0 32rpx; +} + +.toolbar-left, +.toolbar-right { + display: inline-flex; + align-items: center; + gap: 12rpx; +} + +.toolbar-spacer { + flex: 1; +} + +.page-title { + margin: 0; + font-size: 32rpx; + line-height: 1; + font-weight: 600; + color: var(--text); +} + +.settings-save-status { + font-size: 22rpx; + color: var(--muted); +} + +.page-content { + flex: 1; + min-height: 0; + overflow: hidden; + display: flex; + flex-direction: column; + gap: 0; + padding: 16rpx 16rpx 0; +} + +.surface-panel { + background: var(--bg); + border-top: 1rpx solid transparent; + border-bottom: 1rpx solid transparent; + min-height: 0; + display: flex; + flex-direction: column; + gap: 12rpx; + overflow: hidden; +} + +.surface-scroll { + min-height: 0; + overflow: auto; +} + +.list-stack { + display: flex; + flex-direction: column; + gap: 12rpx; +} + +.card { + border: 1rpx solid var(--surface-border); + background: var(--surface); + border-radius: 20rpx; + padding: 18rpx; +} + +.row { + display: flex; + align-items: center; +} + +.row.between { + justify-content: space-between; +} + +.actions { + display: flex; + align-items: center; + gap: 12rpx; + flex-wrap: wrap; +} + +.icon-btn { + width: 48rpx !important; + height: 48rpx !important; + min-width: 0 !important; + margin: 0 !important; + border: 0 !important; + border-radius: 0 !important; + background: transparent !important; + background-color: transparent !important; + color: inherit !important; + padding: 0 !important; + line-height: 1 !important; + font-size: 0 !important; + display: inline-flex !important; + overflow: visible !important; + align-items: center; + justify-content: center; + opacity: 0.95; +} + +.icon-btn.disabled, +.icon-btn.wx-button-disabled { + opacity: 0.45 !important; +} + +.icon-img { + width: 44rpx; + height: 44rpx; + display: block; +} + +/** + * 统一 SVG 按钮按压反馈: + * 1. 所有页面共用同一套 hover-class / touch 状态,不再各页散写重复动画; + * 2. 默认只负责过渡和图标缩放,不强行改常态背景,具体底板/阴影由各页面覆写变量; + * 3. 带文字按钮也能复用,只需覆写圆角、缩放和 hover 背景变量。 + */ +.svg-press-btn { + --svg-press-active-radius: 999rpx; + --svg-press-active-bg: transparent; + --svg-press-active-shadow: none; + --svg-press-active-scale: 0.92; + --svg-press-icon-opacity: 1; + --svg-press-icon-active-opacity: 0.7; + --svg-press-icon-active-scale: 0.9; + transition: + border-radius 160ms ease, + transform 140ms ease, + background-color 140ms ease, + box-shadow 140ms ease, + opacity 140ms ease; + will-change: transform; +} + +.svg-press-btn .svg-press-icon { + opacity: var(--svg-press-icon-opacity); + transform: translateY(0) scale(1); + transition: + transform 140ms ease, + opacity 140ms ease; +} + +.svg-press-btn:active, +.svg-press-btn-hover { + border-radius: var(--svg-press-active-radius) !important; + background: var(--svg-press-active-bg) !important; + background-color: var(--svg-press-active-bg) !important; + box-shadow: var(--svg-press-active-shadow) !important; + transform: scale(var(--svg-press-active-scale)); +} + +.svg-press-btn:active .svg-press-icon, +.svg-press-btn-hover .svg-press-icon { + opacity: var(--svg-press-icon-active-opacity); + transform: translateY(1rpx) scale(var(--svg-press-icon-active-scale)); +} + +.svg-press-btn.disabled, +.svg-press-btn.wx-button-disabled { + transform: none !important; +} + +.btn { + width: auto !important; + min-width: 0 !important; + margin: 0 !important; + border: 1rpx solid var(--btn-border) !important; + background: var(--btn-bg) !important; + background-color: var(--btn-bg) !important; + color: var(--btn-text) !important; + border-radius: 16rpx; + padding: 8rpx 14rpx; + font-size: 24rpx; + line-height: 1.4; +} + +.btn.primary { + background: var(--btn-bg-strong); + border-color: var(--btn-border-strong); +} + +.btn.danger { + border-color: var(--btn-danger-border); + background: var(--btn-danger-bg); +} + +.input, +.textarea { + width: 100%; + border-radius: 16rpx; + border: 1rpx solid rgba(141, 187, 255, 0.3); + background: rgba(255, 255, 255, 0.08); + color: var(--text); + padding: 14rpx 16rpx; + font-size: 24rpx; +} + +.input { + height: 64rpx; +} + +.field-grid { + display: flex; + flex-wrap: wrap; + gap: 12rpx; +} + +.field { + display: flex; + flex-direction: column; + gap: 8rpx; + width: calc((100% - 12rpx) / 2); + min-width: 0; +} + +.field.wide { + width: 100%; +} + +.field text { + color: var(--muted); + font-size: 22rpx; +} + +.state-chip { + border-radius: 999rpx; + padding: 4rpx 10rpx; + border: 1rpx solid var(--btn-border); + background: var(--btn-bg); + font-size: 20rpx; + color: var(--btn-text); +} + +.state-connected { + border-color: var(--accent-border); + background: var(--accent-bg); + color: var(--text); +} + +.state-error { + border-color: var(--danger); + background: var(--btn-danger-bg); + color: var(--text); +} + +.muted { + color: var(--muted); +} + +.empty { + color: var(--muted); + text-align: center; + padding: 28rpx 0; +} + +.bottom-bar { + flex: 0 0 104rpx; + height: 104rpx; + background: var(--bg); + border-top: 1rpx solid var(--accent-divider); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 64rpx 0 32rpx; +} + +.bottom-right-actions { + display: inline-flex; + align-items: center; + gap: 24rpx; +} + +.bottom-nav-btn.active { + background: var(--btn-bg-active); +} + +.records-pagination { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 12rpx; + padding-top: 8rpx; +} + +.records-pagination-text { + min-width: 180rpx; + text-align: center; + font-size: 22rpx; + color: var(--muted); +} diff --git a/apps/miniprogram/assets/guide/guide-mobile-01-server-list.jpg b/apps/miniprogram/assets/guide/guide-mobile-01-server-list.jpg new file mode 100644 index 0000000..cf4d8f6 Binary files /dev/null and b/apps/miniprogram/assets/guide/guide-mobile-01-server-list.jpg differ diff --git a/apps/miniprogram/assets/guide/guide-mobile-02-server-config.jpg b/apps/miniprogram/assets/guide/guide-mobile-02-server-config.jpg new file mode 100644 index 0000000..08146e9 Binary files /dev/null and b/apps/miniprogram/assets/guide/guide-mobile-02-server-config.jpg differ diff --git a/apps/miniprogram/assets/guide/guide-mobile-03-terminal-status.jpg b/apps/miniprogram/assets/guide/guide-mobile-03-terminal-status.jpg new file mode 100644 index 0000000..7a96442 Binary files /dev/null and b/apps/miniprogram/assets/guide/guide-mobile-03-terminal-status.jpg differ diff --git a/apps/miniprogram/assets/guide/guide-mobile-04-voice-shortcuts.jpg b/apps/miniprogram/assets/guide/guide-mobile-04-voice-shortcuts.jpg new file mode 100644 index 0000000..947ad0f Binary files /dev/null and b/apps/miniprogram/assets/guide/guide-mobile-04-voice-shortcuts.jpg differ diff --git a/apps/miniprogram/assets/guide/guide-mobile-05-settings-ui.jpg b/apps/miniprogram/assets/guide/guide-mobile-05-settings-ui.jpg new file mode 100644 index 0000000..353db2a Binary files /dev/null and b/apps/miniprogram/assets/guide/guide-mobile-05-settings-ui.jpg differ diff --git a/apps/miniprogram/assets/guide/guide-mobile-06-settings-terminal.jpg b/apps/miniprogram/assets/guide/guide-mobile-06-settings-terminal.jpg new file mode 100644 index 0000000..55f80e5 Binary files /dev/null and b/apps/miniprogram/assets/guide/guide-mobile-06-settings-terminal.jpg differ diff --git a/apps/miniprogram/assets/guide/guide-mobile-07-settings-connection.jpg b/apps/miniprogram/assets/guide/guide-mobile-07-settings-connection.jpg new file mode 100644 index 0000000..c8d3ac7 Binary files /dev/null and b/apps/miniprogram/assets/guide/guide-mobile-07-settings-connection.jpg differ diff --git a/apps/miniprogram/assets/guide/guide-mobile-08-settings-records.jpg b/apps/miniprogram/assets/guide/guide-mobile-08-settings-records.jpg new file mode 100644 index 0000000..f18582a Binary files /dev/null and b/apps/miniprogram/assets/guide/guide-mobile-08-settings-records.jpg differ diff --git a/apps/miniprogram/assets/guide/guide-mobile-09-records.jpg b/apps/miniprogram/assets/guide/guide-mobile-09-records.jpg new file mode 100644 index 0000000..b2c1cd0 Binary files /dev/null and b/apps/miniprogram/assets/guide/guide-mobile-09-records.jpg differ diff --git a/apps/miniprogram/assets/guide/guide-mobile-10-about.jpg b/apps/miniprogram/assets/guide/guide-mobile-10-about.jpg new file mode 100644 index 0000000..d2ef4f7 Binary files /dev/null and b/apps/miniprogram/assets/guide/guide-mobile-10-about.jpg differ diff --git a/apps/miniprogram/assets/icons/about.svg b/apps/miniprogram/assets/icons/about.svg new file mode 100644 index 0000000..14a1fd2 --- /dev/null +++ b/apps/miniprogram/assets/icons/about.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/miniprogram/assets/icons/add.svg b/apps/miniprogram/assets/icons/add.svg new file mode 100644 index 0000000..75956f4 --- /dev/null +++ b/apps/miniprogram/assets/icons/add.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/miniprogram/assets/icons/ai.svg b/apps/miniprogram/assets/icons/ai.svg new file mode 100644 index 0000000..868f8da --- /dev/null +++ b/apps/miniprogram/assets/icons/ai.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/miniprogram/assets/icons/ai矩连.svg b/apps/miniprogram/assets/icons/ai矩连.svg new file mode 100644 index 0000000..3925532 --- /dev/null +++ b/apps/miniprogram/assets/icons/ai矩连.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/miniprogram/assets/icons/back.svg b/apps/miniprogram/assets/icons/back.svg new file mode 100644 index 0000000..a85cc1a --- /dev/null +++ b/apps/miniprogram/assets/icons/back.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/miniprogram/assets/icons/backspace.svg b/apps/miniprogram/assets/icons/backspace.svg new file mode 100644 index 0000000..00f2563 --- /dev/null +++ b/apps/miniprogram/assets/icons/backspace.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/miniprogram/assets/icons/cancel.svg b/apps/miniprogram/assets/icons/cancel.svg new file mode 100644 index 0000000..ee8e1c5 --- /dev/null +++ b/apps/miniprogram/assets/icons/cancel.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/miniprogram/assets/icons/clear-input.svg b/apps/miniprogram/assets/icons/clear-input.svg new file mode 100644 index 0000000..a6fdd26 --- /dev/null +++ b/apps/miniprogram/assets/icons/clear-input.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/miniprogram/assets/icons/clear.svg b/apps/miniprogram/assets/icons/clear.svg new file mode 100644 index 0000000..95d7152 --- /dev/null +++ b/apps/miniprogram/assets/icons/clear.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/miniprogram/assets/icons/codex.svg b/apps/miniprogram/assets/icons/codex.svg new file mode 100644 index 0000000..e2c526f --- /dev/null +++ b/apps/miniprogram/assets/icons/codex.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/miniprogram/assets/icons/config.svg b/apps/miniprogram/assets/icons/config.svg new file mode 100644 index 0000000..6d65cb1 --- /dev/null +++ b/apps/miniprogram/assets/icons/config.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/miniprogram/assets/icons/connect.svg b/apps/miniprogram/assets/icons/connect.svg new file mode 100644 index 0000000..11176fa --- /dev/null +++ b/apps/miniprogram/assets/icons/connect.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/apps/miniprogram/assets/icons/copy.svg b/apps/miniprogram/assets/icons/copy.svg new file mode 100644 index 0000000..1ffeee6 --- /dev/null +++ b/apps/miniprogram/assets/icons/copy.svg @@ -0,0 +1,10 @@ + + + + diff --git a/apps/miniprogram/assets/icons/create.svg b/apps/miniprogram/assets/icons/create.svg new file mode 100644 index 0000000..75956f4 --- /dev/null +++ b/apps/miniprogram/assets/icons/create.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/miniprogram/assets/icons/ctrlc.svg b/apps/miniprogram/assets/icons/ctrlc.svg new file mode 100644 index 0000000..52a20db --- /dev/null +++ b/apps/miniprogram/assets/icons/ctrlc.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/miniprogram/assets/icons/delete.svg b/apps/miniprogram/assets/icons/delete.svg new file mode 100644 index 0000000..36e5e54 --- /dev/null +++ b/apps/miniprogram/assets/icons/delete.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/miniprogram/assets/icons/down.svg b/apps/miniprogram/assets/icons/down.svg new file mode 100644 index 0000000..fc44a14 --- /dev/null +++ b/apps/miniprogram/assets/icons/down.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/miniprogram/assets/icons/enter.svg b/apps/miniprogram/assets/icons/enter.svg new file mode 100644 index 0000000..2128956 --- /dev/null +++ b/apps/miniprogram/assets/icons/enter.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/miniprogram/assets/icons/esc.svg b/apps/miniprogram/assets/icons/esc.svg new file mode 100644 index 0000000..235f7da --- /dev/null +++ b/apps/miniprogram/assets/icons/esc.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/miniprogram/assets/icons/home.svg b/apps/miniprogram/assets/icons/home.svg new file mode 100644 index 0000000..769dbcf --- /dev/null +++ b/apps/miniprogram/assets/icons/home.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/miniprogram/assets/icons/keyboard.svg b/apps/miniprogram/assets/icons/keyboard.svg new file mode 100644 index 0000000..2ed957f --- /dev/null +++ b/apps/miniprogram/assets/icons/keyboard.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/miniprogram/assets/icons/left.svg b/apps/miniprogram/assets/icons/left.svg new file mode 100644 index 0000000..1c47156 --- /dev/null +++ b/apps/miniprogram/assets/icons/left.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/miniprogram/assets/icons/log.svg b/apps/miniprogram/assets/icons/log.svg new file mode 100644 index 0000000..74aa10e --- /dev/null +++ b/apps/miniprogram/assets/icons/log.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/miniprogram/assets/icons/logo.svg b/apps/miniprogram/assets/icons/logo.svg new file mode 100644 index 0000000..4faf448 --- /dev/null +++ b/apps/miniprogram/assets/icons/logo.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/apps/miniprogram/assets/icons/move.svg b/apps/miniprogram/assets/icons/move.svg new file mode 100644 index 0000000..d46daeb --- /dev/null +++ b/apps/miniprogram/assets/icons/move.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/miniprogram/assets/icons/paste.svg b/apps/miniprogram/assets/icons/paste.svg new file mode 100644 index 0000000..d0c2693 --- /dev/null +++ b/apps/miniprogram/assets/icons/paste.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/miniprogram/assets/icons/plugins.svg b/apps/miniprogram/assets/icons/plugins.svg new file mode 100644 index 0000000..c2b0f39 --- /dev/null +++ b/apps/miniprogram/assets/icons/plugins.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/miniprogram/assets/icons/record.svg b/apps/miniprogram/assets/icons/record.svg new file mode 100644 index 0000000..7efa35d --- /dev/null +++ b/apps/miniprogram/assets/icons/record.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/miniprogram/assets/icons/recordmanager.svg b/apps/miniprogram/assets/icons/recordmanager.svg new file mode 100644 index 0000000..ff174ce --- /dev/null +++ b/apps/miniprogram/assets/icons/recordmanager.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/miniprogram/assets/icons/remoteconn.svg b/apps/miniprogram/assets/icons/remoteconn.svg new file mode 100644 index 0000000..e8c6b28 --- /dev/null +++ b/apps/miniprogram/assets/icons/remoteconn.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/miniprogram/assets/icons/right.svg b/apps/miniprogram/assets/icons/right.svg new file mode 100644 index 0000000..42e8cbb --- /dev/null +++ b/apps/miniprogram/assets/icons/right.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/miniprogram/assets/icons/save.svg b/apps/miniprogram/assets/icons/save.svg new file mode 100644 index 0000000..6a92f2a --- /dev/null +++ b/apps/miniprogram/assets/icons/save.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/miniprogram/assets/icons/search.svg b/apps/miniprogram/assets/icons/search.svg new file mode 100644 index 0000000..06c1ab9 --- /dev/null +++ b/apps/miniprogram/assets/icons/search.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/miniprogram/assets/icons/selectall.svg b/apps/miniprogram/assets/icons/selectall.svg new file mode 100644 index 0000000..5175d01 --- /dev/null +++ b/apps/miniprogram/assets/icons/selectall.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/miniprogram/assets/icons/sent.svg b/apps/miniprogram/assets/icons/sent.svg new file mode 100644 index 0000000..8d75505 --- /dev/null +++ b/apps/miniprogram/assets/icons/sent.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/miniprogram/assets/icons/serverlist.svg b/apps/miniprogram/assets/icons/serverlist.svg new file mode 100644 index 0000000..3ef636d --- /dev/null +++ b/apps/miniprogram/assets/icons/serverlist.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/miniprogram/assets/icons/share.svg b/apps/miniprogram/assets/icons/share.svg new file mode 100644 index 0000000..a894d0b --- /dev/null +++ b/apps/miniprogram/assets/icons/share.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/miniprogram/assets/icons/shell.svg b/apps/miniprogram/assets/icons/shell.svg new file mode 100644 index 0000000..6707d36 --- /dev/null +++ b/apps/miniprogram/assets/icons/shell.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/miniprogram/assets/icons/shift.svg b/apps/miniprogram/assets/icons/shift.svg new file mode 100644 index 0000000..5ea80d9 --- /dev/null +++ b/apps/miniprogram/assets/icons/shift.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/miniprogram/assets/icons/tab.svg b/apps/miniprogram/assets/icons/tab.svg new file mode 100644 index 0000000..5128e9b --- /dev/null +++ b/apps/miniprogram/assets/icons/tab.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/miniprogram/assets/icons/terminal-key-ctrlc.svg b/apps/miniprogram/assets/icons/terminal-key-ctrlc.svg new file mode 100644 index 0000000..9e3d881 --- /dev/null +++ b/apps/miniprogram/assets/icons/terminal-key-ctrlc.svg @@ -0,0 +1,4 @@ + + ctrl + C + diff --git a/apps/miniprogram/assets/icons/terminal-key-delete.svg b/apps/miniprogram/assets/icons/terminal-key-delete.svg new file mode 100644 index 0000000..2a9cb17 --- /dev/null +++ b/apps/miniprogram/assets/icons/terminal-key-delete.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/miniprogram/assets/icons/terminal-key-down.svg b/apps/miniprogram/assets/icons/terminal-key-down.svg new file mode 100644 index 0000000..82923fe --- /dev/null +++ b/apps/miniprogram/assets/icons/terminal-key-down.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/miniprogram/assets/icons/terminal-key-enter.svg b/apps/miniprogram/assets/icons/terminal-key-enter.svg new file mode 100644 index 0000000..321b156 --- /dev/null +++ b/apps/miniprogram/assets/icons/terminal-key-enter.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/miniprogram/assets/icons/terminal-key-esc.svg b/apps/miniprogram/assets/icons/terminal-key-esc.svg new file mode 100644 index 0000000..569c1e6 --- /dev/null +++ b/apps/miniprogram/assets/icons/terminal-key-esc.svg @@ -0,0 +1,3 @@ + + esc + diff --git a/apps/miniprogram/assets/icons/terminal-key-left.svg b/apps/miniprogram/assets/icons/terminal-key-left.svg new file mode 100644 index 0000000..5547223 --- /dev/null +++ b/apps/miniprogram/assets/icons/terminal-key-left.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/miniprogram/assets/icons/terminal-key-paste.svg b/apps/miniprogram/assets/icons/terminal-key-paste.svg new file mode 100644 index 0000000..bbc6ea4 --- /dev/null +++ b/apps/miniprogram/assets/icons/terminal-key-paste.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/miniprogram/assets/icons/terminal-key-right.svg b/apps/miniprogram/assets/icons/terminal-key-right.svg new file mode 100644 index 0000000..f83b18e --- /dev/null +++ b/apps/miniprogram/assets/icons/terminal-key-right.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/miniprogram/assets/icons/terminal-key-tab.svg b/apps/miniprogram/assets/icons/terminal-key-tab.svg new file mode 100644 index 0000000..505b512 --- /dev/null +++ b/apps/miniprogram/assets/icons/terminal-key-tab.svg @@ -0,0 +1,3 @@ + + tab + diff --git a/apps/miniprogram/assets/icons/terminal-key-up.svg b/apps/miniprogram/assets/icons/terminal-key-up.svg new file mode 100644 index 0000000..8cb004d --- /dev/null +++ b/apps/miniprogram/assets/icons/terminal-key-up.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/miniprogram/assets/icons/terminal-keyboard.svg b/apps/miniprogram/assets/icons/terminal-keyboard.svg new file mode 100644 index 0000000..f065aad --- /dev/null +++ b/apps/miniprogram/assets/icons/terminal-keyboard.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/miniprogram/assets/icons/up.svg b/apps/miniprogram/assets/icons/up.svg new file mode 100644 index 0000000..1935582 --- /dev/null +++ b/apps/miniprogram/assets/icons/up.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/miniprogram/assets/icons/voice.svg b/apps/miniprogram/assets/icons/voice.svg new file mode 100644 index 0000000..45b2b9f --- /dev/null +++ b/apps/miniprogram/assets/icons/voice.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/miniprogram/components/bottom-nav/index.js b/apps/miniprogram/components/bottom-nav/index.js new file mode 100644 index 0000000..2520e7f --- /dev/null +++ b/apps/miniprogram/components/bottom-nav/index.js @@ -0,0 +1,351 @@ +/* global Component, getCurrentPages, wx, require, console */ + +const { getSettings, listServers } = require("../../utils/storage"); +const { getTerminalSessionSnapshot } = require("../../utils/terminalSession"); +const { buildButtonIconThemeMaps, resolveButtonIcon } = require("../../utils/themedIcons"); +const { resolvePageNavigationMethod } = require("../../utils/navigationPolicy"); +const { + hasActiveTerminalSession, + openTerminalPage, + resolveActiveTerminalServerId +} = require("../../utils/terminalNavigation"); +const { buildPageCopy, normalizeUiLanguage, t } = require("../../utils/i18n"); +const { subscribeLocaleChange } = require("../../utils/localeBus"); +const { subscribeSyncConfigApplied } = require("../../utils/syncConfigBus"); +const { buildSvgButtonPressData, createSvgButtonPressMethods } = require("../../utils/svgButtonFeedback"); + +const SHELL_BUTTON_ACTION = "open-terminal-shell"; +const SHELL_BUTTON_ITEM = Object.freeze({ + action: SHELL_BUTTON_ACTION, + path: "/pages/terminal/index", + icon: "/assets/icons/shell.svg", + isTab: false +}); + +const loggedRenderProbeKeys = new Set(); + +function shouldUseTextIcons() { + if (!wx || typeof wx.getAppBaseInfo !== "function") return false; + try { + const info = wx.getAppBaseInfo() || {}; + return ( + String(info.platform || "") + .trim() + .toLowerCase() === "devtools" + ); + } catch { + return false; + } +} + +function inspectRenderPayload(payload) { + const stats = { + stringCount: 0, + dataImageCount: 0, + svgPathCount: 0, + urlCount: 0, + maxLength: 0, + samples: [] + }; + const walk = (value, path, depth) => { + if (depth > 5) return; + if (typeof value === "string") { + stats.stringCount += 1; + if (value.includes("data:image")) stats.dataImageCount += 1; + if (value.includes(".svg")) stats.svgPathCount += 1; + if (value.includes("url(")) stats.urlCount += 1; + if (value.length > stats.maxLength) stats.maxLength = value.length; + if ( + stats.samples.length < 6 && + (value.includes("data:image") || + value.includes(".svg") || + value.includes("url(") || + value.length >= 120) + ) { + stats.samples.push({ + path, + length: value.length, + preview: value.slice(0, 120) + }); + } + return; + } + if (!value || typeof value !== "object") return; + if (Array.isArray(value)) { + value.forEach((item, index) => walk(item, `${path}[${index}]`, depth + 1)); + return; + } + Object.keys(value).forEach((key) => walk(value[key], path ? `${path}.${key}` : key, depth + 1)); + }; + walk(payload, "", 0); + return stats; +} + +function logRenderProbeOnce(key, label, payload) { + const normalizedKey = String(key || label || ""); + if (!normalizedKey || loggedRenderProbeKeys.has(normalizedKey)) return; + loggedRenderProbeKeys.add(normalizedKey); + console.warn(`[render_probe] ${label}`, inspectRenderPayload(payload)); +} + +function prependShellItem(page, items) { + if (String(page || "").trim() === "terminal") { + return items; + } + return [SHELL_BUTTON_ITEM, ...items]; +} + +/** + * 全局底部工具条组件: + * 1. 复刻 Web 底栏语义:左侧返回,右侧按页面上下文展示图标按钮; + * 2. 图标顺序按 Figma 页面 annotation 固定,不做自动重排; + * 3. records 页面自身不展示 records 入口,避免重复高亮。 + */ +Component({ + properties: { + page: { + type: String, + value: "" + } + }, + + data: { + ...buildSvgButtonPressData(), + canGoBack: false, + backIcon: "/assets/icons/back.svg", + backPressedIcon: "/assets/icons/back.svg", + backLabel: t("zh-Hans", "bottomNav.backText"), + textIconMode: false, + items: [] + }, + + lifetimes: { + attached() { + this.syncCanGoBack(); + this.syncItems(); + this.localeUnsub = subscribeLocaleChange(() => { + this.syncItems(); + }); + this.syncConfigUnsub = subscribeSyncConfigApplied(() => { + this.syncCanGoBack(); + this.syncItems(); + }); + }, + + detached() { + if (typeof this.localeUnsub === "function") { + this.localeUnsub(); + this.localeUnsub = null; + } + if (typeof this.syncConfigUnsub === "function") { + this.syncConfigUnsub(); + this.syncConfigUnsub = null; + } + } + }, + + pageLifetimes: { + show() { + this.syncCanGoBack(); + this.syncItems(); + } + }, + + observers: { + page() { + this.syncItems(); + } + }, + + methods: { + currentPathByPage(page) { + const name = String(page || "").trim(); + if (!name) return ""; + if (name === "about" || name.indexOf("about-") === 0) { + return "/pages/about/index"; + } + return `/pages/${name}/index`; + }, + + syncCanGoBack() { + const pages = getCurrentPages(); + this.setData({ canGoBack: pages.length > 1 }); + }, + + rawItemsByPage(page) { + const aboutItem = { path: "/pages/about/index", icon: "/assets/icons/about.svg", isTab: false }; + if (page === "about" || String(page || "").indexOf("about-") === 0) { + return prependShellItem(page, [ + { path: "/pages/connect/index", icon: "/assets/icons/serverlist.svg", isTab: false }, + { path: "/pages/logs/index", icon: "/assets/icons/log.svg", isTab: false }, + { path: "/pages/settings/index", icon: "/assets/icons/config.svg", isTab: false } + ]); + } + if (page === "connect") { + return prependShellItem(page, [ + { path: "/pages/logs/index", icon: "/assets/icons/log.svg", isTab: false }, + { + path: "/pages/records/index", + icon: "/assets/icons/recordmanager.svg", + isTab: false + }, + { path: "/pages/settings/index", icon: "/assets/icons/config.svg", isTab: false }, + aboutItem + ]); + } + if (page === "settings") { + return prependShellItem(page, [ + { path: "/pages/connect/index", icon: "/assets/icons/serverlist.svg", isTab: false }, + { path: "/pages/logs/index", icon: "/assets/icons/log.svg", isTab: false }, + { path: "/pages/plugins/index", icon: "/assets/icons/plugins.svg", isTab: false }, + { path: "/pages/records/index", icon: "/assets/icons/recordmanager.svg", isTab: false }, + aboutItem + ]); + } + if (page === "plugins") { + return prependShellItem(page, [ + { path: "/pages/connect/index", icon: "/assets/icons/serverlist.svg", isTab: false }, + { + path: "/pages/records/index", + icon: "/assets/icons/recordmanager.svg", + isTab: false + }, + { path: "/pages/settings/index", icon: "/assets/icons/config.svg", isTab: false }, + aboutItem + ]); + } + if (page === "terminal") { + return [ + { path: "/pages/connect/index", icon: "/assets/icons/serverlist.svg", isTab: false }, + { path: "/pages/logs/index", icon: "/assets/icons/log.svg", isTab: false }, + { + path: "/pages/records/index", + icon: "/assets/icons/recordmanager.svg", + isTab: false + }, + { path: "/pages/settings/index", icon: "/assets/icons/config.svg", isTab: false }, + aboutItem + ]; + } + if (page === "records") { + return prependShellItem(page, [ + { path: "/pages/connect/index", icon: "/assets/icons/serverlist.svg", isTab: false }, + { path: "/pages/logs/index", icon: "/assets/icons/log.svg", isTab: false }, + { path: "/pages/settings/index", icon: "/assets/icons/config.svg", isTab: false }, + aboutItem + ]); + } + if (page === "logs") { + return prependShellItem(page, [ + { path: "/pages/connect/index", icon: "/assets/icons/serverlist.svg", isTab: false }, + { + path: "/pages/records/index", + icon: "/assets/icons/recordmanager.svg", + isTab: false + }, + { path: "/pages/settings/index", icon: "/assets/icons/config.svg", isTab: false }, + aboutItem + ]); + } + return prependShellItem(page, [ + { path: "/pages/connect/index", icon: "/assets/icons/serverlist.svg", isTab: false }, + { path: "/pages/settings/index", icon: "/assets/icons/config.svg", isTab: false }, + aboutItem + ]); + }, + + syncItems() { + const page = this.data.page; + const settings = getSettings(); + const language = normalizeUiLanguage(settings.uiLanguage); + const copy = buildPageCopy(language, "bottomNav"); + const { icons: iconMap, activeIcons: activeIconMap, accentIcons: accentIconMap } = + buildButtonIconThemeMaps(settings); + const currentPath = this.currentPathByPage(page); + const sessionSnapshot = getTerminalSessionSnapshot(); + const shellActive = hasActiveTerminalSession(sessionSnapshot, listServers()); + const list = this.rawItemsByPage(page).map((item) => ({ + ...item, + id: String(item.action || item.path || item.icon || ""), + pressKey: `bottom-nav:${String(item.action || item.path || item.icon || "").trim()}`, + icon: + item.action === SHELL_BUTTON_ACTION && shellActive + ? resolveButtonIcon(item.icon, activeIconMap) + : resolveButtonIcon(item.icon, iconMap), + pressedIcon: resolveButtonIcon( + item.icon, + item.action === SHELL_BUTTON_ACTION && shellActive ? activeIconMap : accentIconMap + ), + textLabel: this.resolveTextLabel(item.action || item.path, copy), + active: item.action === SHELL_BUTTON_ACTION ? shellActive : item.path === currentPath, + connectionActive: item.action === SHELL_BUTTON_ACTION && shellActive + })); + const payload = { + backIcon: iconMap.back || "/assets/icons/back.svg", + backPressedIcon: accentIconMap.back || iconMap.back || "/assets/icons/back.svg", + backLabel: copy.backText || t(language, "bottomNav.backText"), + textIconMode: shouldUseTextIcons() && Object.keys(iconMap).length === 0, + items: list + }; + logRenderProbeOnce("bottom-nav.syncItems", "bottom-nav.syncItems", payload); + this.setData(payload); + }, + + onBack() { + if (!this.data.canGoBack) return; + wx.navigateBack({ + delta: 1, + fail: () => { + wx.redirectTo({ url: "/pages/connect/index" }); + } + }); + }, + + onNavTap(event) { + const action = String(event.currentTarget.dataset.action || "").trim(); + if (action === SHELL_BUTTON_ACTION) { + this.onShellTap(); + return; + } + const path = event.currentTarget.dataset.path; + if (!path) return; + const currentPath = this.currentPathByPage(this.data.page); + const method = resolvePageNavigationMethod(currentPath, path); + if (method === "noop") return; + if (method === "redirectTo") { + wx.redirectTo({ + url: path, + fail: () => { + wx.navigateTo({ url: path }); + } + }); + return; + } + wx.navigateTo({ url: path }); + }, + + onShellTap() { + const sessionSnapshot = getTerminalSessionSnapshot(); + const serverId = resolveActiveTerminalServerId(sessionSnapshot, listServers()); + const language = normalizeUiLanguage(getSettings().uiLanguage); + if (!serverId) { + wx.showModal({ + title: t(language, "bottomNav.modal.noTerminalTitle"), + content: t(language, "bottomNav.modal.noTerminalContent"), + showCancel: false + }); + return; + } + openTerminalPage(serverId, true); + }, + + resolveTextLabel(key, copyInput) { + const copy = copyInput && typeof copyInput === "object" ? copyInput : buildPageCopy("zh-Hans", "bottomNav"); + const pageTextLabels = (copy && copy.pageTextLabels) || {}; + const normalized = String(key || "").trim(); + return pageTextLabels[normalized] || pageTextLabels.default || "页"; + }, + + ...createSvgButtonPressMethods() + } +}); diff --git a/apps/miniprogram/components/bottom-nav/index.json b/apps/miniprogram/components/bottom-nav/index.json new file mode 100644 index 0000000..79f2730 --- /dev/null +++ b/apps/miniprogram/components/bottom-nav/index.json @@ -0,0 +1,4 @@ +{ + "component": true, + "styleIsolation": "apply-shared" +} diff --git a/apps/miniprogram/components/bottom-nav/index.wxml b/apps/miniprogram/components/bottom-nav/index.wxml new file mode 100644 index 0000000..7ce7ea1 --- /dev/null +++ b/apps/miniprogram/components/bottom-nav/index.wxml @@ -0,0 +1,49 @@ + + + + + + + + diff --git a/apps/miniprogram/components/bottom-nav/index.wxss b/apps/miniprogram/components/bottom-nav/index.wxss new file mode 100644 index 0000000..8985f5f --- /dev/null +++ b/apps/miniprogram/components/bottom-nav/index.wxss @@ -0,0 +1,94 @@ +.bottom-bar { + flex: 0 0 104rpx; + height: 104rpx; + background: var(--bg); + border-top: 1rpx solid var(--accent-divider); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 64rpx 0 32rpx; +} + +.bottom-right-actions { + display: inline-flex; + align-items: center; + gap: 24rpx; +} + +.icon-btn { + width: 48rpx !important; + height: 48rpx !important; + min-width: 0 !important; + margin: 0 !important; + border: 0 !important; + border-radius: 0 !important; + background: transparent !important; + background-color: transparent !important; + color: inherit !important; + padding: 0 !important; + line-height: 1 !important; + font-size: 0 !important; + display: inline-flex !important; + overflow: visible !important; + align-items: center; + justify-content: center; + opacity: 0.95; +} + +.bottom-nav-btn { + border-radius: 999rpx !important; + --svg-press-active-radius: 999rpx; + --svg-press-active-bg: var(--icon-btn-bg-strong); + --svg-press-active-shadow: 0 0 0 8rpx var(--accent-ring); + --svg-press-active-scale: 0.9; + --svg-press-icon-opacity: 0.96; + --svg-press-icon-active-opacity: 0.68; + --svg-press-icon-active-scale: 0.88; +} + +.icon-btn::after { + border: none; +} + +.icon-btn.is-disabled { + opacity: 0.45 !important; +} + +.icon-btn.wx-button-disabled { + opacity: 0.45 !important; +} + +.icon-img { + width: 44rpx; + height: 44rpx; + display: block; +} + +.bottom-nav-text { + font-size: 20rpx; + line-height: 1; + font-weight: 600; + color: var(--btn-text); +} + +.bottom-nav-btn.active { + background: var(--icon-btn-bg) !important; + background-color: var(--icon-btn-bg) !important; + box-shadow: inset 0 0 0 1rpx var(--accent-border); +} + +.bottom-nav-btn.connection-active { + background: var(--accent) !important; + background-color: var(--accent) !important; + box-shadow: 0 10rpx 24rpx var(--accent-shadow) !important; + --svg-press-active-bg: var(--accent); + --svg-press-active-shadow: + 0 0 0 8rpx var(--accent-ring), + 0 10rpx 24rpx var(--accent-shadow); + --svg-press-icon-active-opacity: 0.92; + --svg-press-icon-active-scale: 0.94; +} + +.bottom-nav-btn.connection-active .bottom-nav-text { + color: var(--text); +} diff --git a/apps/miniprogram/pages/about-app/index.js b/apps/miniprogram/pages/about-app/index.js new file mode 100644 index 0000000..9fed9fa --- /dev/null +++ b/apps/miniprogram/pages/about-app/index.js @@ -0,0 +1,115 @@ +/* global Page, wx, require */ + +const { getSettings } = require("../../utils/storage"); +const { buildThemeStyle, applyNavigationBarTheme } = require("../../utils/themeStyle"); +const { + getAboutBrand, + getAboutDetailContent, + getAboutFooterLinks, + getAboutUiCopy +} = require("../../utils/aboutContent"); +const { normalizeUiLanguage } = require("../../utils/i18n"); +const { buildButtonIconThemeMaps } = require("../../utils/themedIcons"); +const { buildSvgButtonPressData, createSvgButtonPressMethods } = require("../../utils/svgButtonFeedback"); + +// “关于”页分享出去后应回到小程序首页,而不是再次落到 about 详情页。 +const ABOUT_APP_SHARE_HOME_PATH = "/pages/connect/index"; + +/** + * 将“关于”详情页按 Figma Frame 2223 落地: + * 1. 保留关于首页的 5 个入口结构不变; + * 2. 当前页只重排品牌区、信息卡、分享按钮和底部跳转; + * 3. 中间信息继续复用统一数据源,避免文案在多个页面分叉。 + */ +function buildInfoRows(section) { + const bullets = Array.isArray(section && section.bullets) ? section.bullets : []; + return bullets.map((line, index) => { + const text = String(line || "").trim(); + const matched = text.match(/^([^::]+)[::]\s*(.+)$/); + if (!matched) { + return { + key: `row-${index}`, + label: "", + value: text + }; + } + return { + key: `row-${index}`, + label: `${matched[1]}:`, + value: matched[2] + }; + }); +} + +Page({ + data: { + ...buildSvgButtonPressData(), + brand: getAboutBrand("zh-Hans"), + pageContent: getAboutDetailContent("app", "zh-Hans"), + infoRows: [], + versionLine: "", + themeStyle: "", + footerLinks: getAboutFooterLinks("zh-Hans"), + uiCopy: getAboutUiCopy("zh-Hans"), + icons: {}, + accentIcons: {} + }, + + onLoad() { + const brand = getAboutBrand("zh-Hans"); + const pageContent = getAboutDetailContent("app", "zh-Hans"); + const primarySection = Array.isArray(pageContent.sections) ? pageContent.sections[0] : null; + wx.setNavigationBarTitle({ title: pageContent.title || "关于" }); + this.setData({ + brand, + pageContent, + infoRows: buildInfoRows(primarySection), + versionLine: `${brand.version}·wechat·${brand.updatedAtCompact}`, + footerLinks: getAboutFooterLinks("zh-Hans"), + uiCopy: getAboutUiCopy("zh-Hans") + }); + this.applyThemeStyle(); + }, + + onShow() { + this.applyThemeStyle(); + }, + + applyThemeStyle() { + const settings = getSettings(); + const language = normalizeUiLanguage(settings.uiLanguage); + const brand = getAboutBrand(language); + const pageContent = getAboutDetailContent("app", language); + const primarySection = Array.isArray(pageContent.sections) ? pageContent.sections[0] : null; + const { icons, accentIcons } = buildButtonIconThemeMaps(settings); + applyNavigationBarTheme(settings); + wx.setNavigationBarTitle({ title: pageContent.title || "关于" }); + this.setData({ + brand, + pageContent, + infoRows: buildInfoRows(primarySection), + versionLine: `${brand.version}·wechat·${brand.updatedAtCompact}`, + footerLinks: getAboutFooterLinks(language), + uiCopy: getAboutUiCopy(language), + icons, + accentIcons, + themeStyle: buildThemeStyle(settings) + }); + }, + + onOpenLink(event) { + const path = String(event.currentTarget.dataset.path || "").trim(); + if (!path) return; + wx.navigateTo({ url: path }); + }, + + onShareAppMessage() { + const brand = this.data.brand || getAboutBrand("zh-Hans"); + return { + title: `${brand.productName} ${brand.version}`, + path: ABOUT_APP_SHARE_HOME_PATH + }; + }, + + ...createSvgButtonPressMethods() +}); diff --git a/apps/miniprogram/pages/about-app/index.json b/apps/miniprogram/pages/about-app/index.json new file mode 100644 index 0000000..b77168b --- /dev/null +++ b/apps/miniprogram/pages/about-app/index.json @@ -0,0 +1,9 @@ +{ + "navigationBarTitleText": "关于", + "navigationBarBackgroundColor": "#f4f3ef", + "navigationBarTextStyle": "black", + "disableScroll": true, + "usingComponents": { + "bottom-nav": "/components/bottom-nav/index" + } +} diff --git a/apps/miniprogram/pages/about-app/index.test.ts b/apps/miniprogram/pages/about-app/index.test.ts new file mode 100644 index 0000000..fb506ec --- /dev/null +++ b/apps/miniprogram/pages/about-app/index.test.ts @@ -0,0 +1,59 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +type SharePayload = { + path: string; + title: string; +}; + +type AboutAppPageOptions = { + onShareAppMessage?: () => SharePayload; +}; + +type MiniprogramGlobals = typeof globalThis & { + Page?: (options: AboutAppPageOptions) => void; + wx?: { + setNavigationBarTitle?: (options: { title: string }) => void; + }; +}; + +describe("about-app page", () => { + const globalState = globalThis as MiniprogramGlobals; + const originalPage = globalState.Page; + const originalWx = globalState.wx; + let capturedPageOptions: AboutAppPageOptions | null = null; + + beforeEach(() => { + capturedPageOptions = null; + vi.resetModules(); + globalState.Page = vi.fn((options: AboutAppPageOptions) => { + capturedPageOptions = options; + }); + globalState.wx = { + setNavigationBarTitle: vi.fn() + }; + }); + + afterEach(() => { + if (originalPage) { + globalState.Page = originalPage; + } else { + delete globalState.Page; + } + if (originalWx) { + globalState.wx = originalWx; + } else { + delete globalState.wx; + } + }); + + it("分享后应落到首页而不是 about 详情页", () => { + require("./index.js"); + + expect(capturedPageOptions).toBeTruthy(); + expect(capturedPageOptions?.onShareAppMessage).toBeTypeOf("function"); + expect(capturedPageOptions?.onShareAppMessage?.()).toEqual({ + title: "RemoteConn v3.0.0", + path: "/pages/connect/index" + }); + }); +}); diff --git a/apps/miniprogram/pages/about-app/index.wxml b/apps/miniprogram/pages/about-app/index.wxml new file mode 100644 index 0000000..4513d7a --- /dev/null +++ b/apps/miniprogram/pages/about-app/index.wxml @@ -0,0 +1,61 @@ + + + + + + + + + + + {{versionLine}} + + + + + {{pageContent.sections[0].title}} + {{pageContent.lead}} + + + + {{item.label}} + {{item.value}} + + + + + + + + + + {{item.title}} + + + + + + diff --git a/apps/miniprogram/pages/about-app/index.wxss b/apps/miniprogram/pages/about-app/index.wxss new file mode 100644 index 0000000..5cc3ad7 --- /dev/null +++ b/apps/miniprogram/pages/about-app/index.wxss @@ -0,0 +1,185 @@ +@import "../about/common.wxss"; + +/** + * 当前页沿用 about 共用色板,只保留“关于”详情页自身的布局差异, + * 避免再维护第二套颜色常量。 + */ + +.about-app-page { + height: 100vh; + background: var(--bg); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.about-app-scroll { + flex: 1; + min-height: 0; +} + +.about-app-shell { + position: relative; + min-height: 100vh; + padding: 40rpx 28rpx 136rpx; + box-sizing: border-box; + overflow: hidden; +} + +.about-app-brand { + position: relative; + z-index: 1; + padding: 0 0 34rpx 16rpx; +} + +.about-app-logo { + width: 96rpx; + height: 96rpx; + display: block; +} + +.about-app-wordmark { + width: 563rpx; + display: block; + margin-top: -96rpx; + margin-left: 112rpx; +} + +.about-app-submark { + width: 88rpx; + display: block; + margin-top: 14rpx; + margin-left: 112rpx; +} + +.about-app-version { + display: block; + margin-top: 14rpx; + margin-left: 112rpx; + font-size: 20rpx; + line-height: 1; + font-weight: 700; + color: var(--about-text); +} + +.about-app-card { + position: relative; + z-index: 1; + width: 100%; + min-height: 622rpx; + border-radius: 28rpx; + background: var(--about-surface); + border: 1rpx solid var(--about-surface-border); + box-shadow: 0 16rpx 38rpx var(--about-glow); + box-sizing: border-box; +} + +.about-app-card-inner { + padding: 30rpx 28rpx 34rpx; +} + +.about-app-card-title { + display: block; + font-size: 32rpx; + line-height: 1.2; + font-weight: 700; + color: var(--about-text-strong); +} + +.about-app-card-lead { + display: block; + margin-top: 14rpx; + font-size: 22rpx; + line-height: 1.65; + color: var(--about-text); +} + +.about-app-info-list { + display: flex; + flex-direction: column; + gap: 16rpx; + margin-top: 26rpx; +} + +.about-app-info-row { + display: flex; + align-items: flex-start; + gap: 8rpx; +} + +.about-app-info-label { + flex: 0 0 auto; + font-size: 22rpx; + line-height: 1.6; + font-weight: 700; + color: var(--about-text-strong); +} + +.about-app-info-value { + flex: 1; + min-width: 0; + font-size: 22rpx; + line-height: 1.6; + color: var(--about-text); +} + +.about-app-share { + width: auto !important; + min-width: 0 !important; + position: relative; + z-index: 1; + align-self: flex-start; + margin: 30rpx 0 0 16rpx !important; + padding: 18rpx 24rpx !important; + border: 1rpx solid var(--about-action-border) !important; + border-radius: 999rpx !important; + background: var(--about-action-bg) !important; + display: inline-flex; + align-items: center; + gap: 12rpx; + box-shadow: 0 10rpx 24rpx var(--about-glow); + --svg-press-active-radius: 999rpx; + --svg-press-active-bg: var(--about-action-bg); + --svg-press-active-shadow: + 0 14rpx 28rpx var(--about-glow), + inset 0 0 0 1rpx var(--about-action-border); + --svg-press-active-scale: 0.96; + --svg-press-icon-opacity: 0.94; + --svg-press-icon-active-opacity: 1; + --svg-press-icon-active-scale: 1.08; +} + +.about-app-share::after { + border: 0 !important; +} + +.about-app-share-icon { + width: 31rpx; + height: 31rpx; + flex: 0 0 auto; +} + +.about-app-share-text { + font-size: 24rpx; + line-height: 1; + font-weight: 700; + color: var(--about-action-text); +} + +.about-app-footer { + position: relative; + z-index: 1; + margin-top: 34rpx; + display: flex; + justify-content: flex-end; + gap: 24rpx; + padding-right: 10rpx; +} + +.about-app-footer-link { + padding: 10rpx 0; + font-size: 20rpx; + line-height: 1; + font-weight: 700; + color: var(--about-accent); +} diff --git a/apps/miniprogram/pages/about-changelog/index.js b/apps/miniprogram/pages/about-changelog/index.js new file mode 100644 index 0000000..a7c4d98 --- /dev/null +++ b/apps/miniprogram/pages/about-changelog/index.js @@ -0,0 +1,3 @@ +const { createAboutDetailPage } = require("../../utils/aboutPageFactory"); + +Page(createAboutDetailPage("changelog")); diff --git a/apps/miniprogram/pages/about-changelog/index.json b/apps/miniprogram/pages/about-changelog/index.json new file mode 100644 index 0000000..0224a18 --- /dev/null +++ b/apps/miniprogram/pages/about-changelog/index.json @@ -0,0 +1,9 @@ +{ + "navigationBarTitleText": "变更记录", + "navigationBarBackgroundColor": "#f4f3ef", + "navigationBarTextStyle": "black", + "disableScroll": true, + "usingComponents": { + "bottom-nav": "/components/bottom-nav/index" + } +} diff --git a/apps/miniprogram/pages/about-changelog/index.wxml b/apps/miniprogram/pages/about-changelog/index.wxml new file mode 100644 index 0000000..126b194 --- /dev/null +++ b/apps/miniprogram/pages/about-changelog/index.wxml @@ -0,0 +1,28 @@ + + + + + + + + {{brand.chineseName}} + + {{pageContent.title}} + {{pageContent.lead}} + + + + {{item.title}} + {{item}} + + + + {{item}} + + + + + + + + diff --git a/apps/miniprogram/pages/about-changelog/index.wxss b/apps/miniprogram/pages/about-changelog/index.wxss new file mode 100644 index 0000000..2c9b695 --- /dev/null +++ b/apps/miniprogram/pages/about-changelog/index.wxss @@ -0,0 +1 @@ +@import "../about/common.wxss"; diff --git a/apps/miniprogram/pages/about-feedback/index.js b/apps/miniprogram/pages/about-feedback/index.js new file mode 100644 index 0000000..791421e --- /dev/null +++ b/apps/miniprogram/pages/about-feedback/index.js @@ -0,0 +1,3 @@ +const { createAboutDetailPage } = require("../../utils/aboutPageFactory"); + +Page(createAboutDetailPage("feedback")); diff --git a/apps/miniprogram/pages/about-feedback/index.json b/apps/miniprogram/pages/about-feedback/index.json new file mode 100644 index 0000000..b75af44 --- /dev/null +++ b/apps/miniprogram/pages/about-feedback/index.json @@ -0,0 +1,9 @@ +{ + "navigationBarTitleText": "问题反馈", + "navigationBarBackgroundColor": "#f4f3ef", + "navigationBarTextStyle": "black", + "disableScroll": true, + "usingComponents": { + "bottom-nav": "/components/bottom-nav/index" + } +} diff --git a/apps/miniprogram/pages/about-feedback/index.wxml b/apps/miniprogram/pages/about-feedback/index.wxml new file mode 100644 index 0000000..a65812b --- /dev/null +++ b/apps/miniprogram/pages/about-feedback/index.wxml @@ -0,0 +1,35 @@ + + + + + + + + {{brand.chineseName}} + + {{pageContent.title}} + {{pageContent.lead}} + + + + + {{item.title}} + + + {{item}} + + + + {{item}} + + + + + + + + diff --git a/apps/miniprogram/pages/about-feedback/index.wxss b/apps/miniprogram/pages/about-feedback/index.wxss new file mode 100644 index 0000000..2c9b695 --- /dev/null +++ b/apps/miniprogram/pages/about-feedback/index.wxss @@ -0,0 +1 @@ +@import "../about/common.wxss"; diff --git a/apps/miniprogram/pages/about-manual/index.js b/apps/miniprogram/pages/about-manual/index.js new file mode 100644 index 0000000..d33267d --- /dev/null +++ b/apps/miniprogram/pages/about-manual/index.js @@ -0,0 +1,3 @@ +const { createAboutDetailPage } = require("../../utils/aboutPageFactory"); + +Page(createAboutDetailPage("manual")); diff --git a/apps/miniprogram/pages/about-manual/index.json b/apps/miniprogram/pages/about-manual/index.json new file mode 100644 index 0000000..df521bf --- /dev/null +++ b/apps/miniprogram/pages/about-manual/index.json @@ -0,0 +1,9 @@ +{ + "navigationBarTitleText": "使用手册", + "navigationBarBackgroundColor": "#f4f3ef", + "navigationBarTextStyle": "black", + "disableScroll": true, + "usingComponents": { + "bottom-nav": "/components/bottom-nav/index" + } +} diff --git a/apps/miniprogram/pages/about-manual/index.wxml b/apps/miniprogram/pages/about-manual/index.wxml new file mode 100644 index 0000000..20698f3 --- /dev/null +++ b/apps/miniprogram/pages/about-manual/index.wxml @@ -0,0 +1,39 @@ + + + + + + + + {{brand.chineseName}} + + {{pageContent.title}} + {{pageContent.lead}} + + + + {{item.title}} + + + + + + {{item}} + + + + {{item}} + + + + + + + + diff --git a/apps/miniprogram/pages/about-manual/index.wxss b/apps/miniprogram/pages/about-manual/index.wxss new file mode 100644 index 0000000..2c9b695 --- /dev/null +++ b/apps/miniprogram/pages/about-manual/index.wxss @@ -0,0 +1 @@ +@import "../about/common.wxss"; diff --git a/apps/miniprogram/pages/about-privacy/index.js b/apps/miniprogram/pages/about-privacy/index.js new file mode 100644 index 0000000..423ddcf --- /dev/null +++ b/apps/miniprogram/pages/about-privacy/index.js @@ -0,0 +1,3 @@ +const { createAboutDetailPage } = require("../../utils/aboutPageFactory"); + +Page(createAboutDetailPage("privacy")); diff --git a/apps/miniprogram/pages/about-privacy/index.json b/apps/miniprogram/pages/about-privacy/index.json new file mode 100644 index 0000000..6a551f0 --- /dev/null +++ b/apps/miniprogram/pages/about-privacy/index.json @@ -0,0 +1,9 @@ +{ + "navigationBarTitleText": "隐私政策", + "navigationBarBackgroundColor": "#f4f3ef", + "navigationBarTextStyle": "black", + "disableScroll": true, + "usingComponents": { + "bottom-nav": "/components/bottom-nav/index" + } +} diff --git a/apps/miniprogram/pages/about-privacy/index.wxml b/apps/miniprogram/pages/about-privacy/index.wxml new file mode 100644 index 0000000..589adc1 --- /dev/null +++ b/apps/miniprogram/pages/about-privacy/index.wxml @@ -0,0 +1,28 @@ + + + + + + + + {{brand.chineseName}} + + {{pageContent.title}} + {{pageContent.lead}} + + + + {{item.title}} + {{item}} + + + + {{item}} + + + + + + + + diff --git a/apps/miniprogram/pages/about-privacy/index.wxss b/apps/miniprogram/pages/about-privacy/index.wxss new file mode 100644 index 0000000..2c9b695 --- /dev/null +++ b/apps/miniprogram/pages/about-privacy/index.wxss @@ -0,0 +1 @@ +@import "../about/common.wxss"; diff --git a/apps/miniprogram/pages/about/common.wxss b/apps/miniprogram/pages/about/common.wxss new file mode 100644 index 0000000..251698d --- /dev/null +++ b/apps/miniprogram/pages/about/common.wxss @@ -0,0 +1,332 @@ +/** + * About 页仍保留独立编排,但配色必须走界面配置推导出的 token: + * 1. 顶层背景、文字、卡片、强调色全部由 themeStyle 下发; + * 2. 不再覆写 page 级固定米白主题,避免和主流程页脱节; + * 3. 光斑只做氛围层,颜色同样从当前主题推导。 + */ + +.about-page { + position: relative; + height: 100vh; + background: var(--bg); + color: var(--text); + overflow: hidden; + display: flex; + flex-direction: column; +} + +.about-scroll { + flex: 1; + min-height: 0; +} + +.about-shell { + position: relative; + min-height: 100vh; + padding: 36rpx 28rpx 56rpx; + overflow: hidden; +} + +.about-bg-orb { + position: absolute; + border-radius: 999rpx; + pointer-events: none; + animation: about-orb-float 8.8s ease-in-out infinite; +} + +.about-bg-orb-left { + width: 630rpx; + height: 630rpx; + left: -150rpx; + bottom: -240rpx; + background: radial-gradient( + circle at 35% 35%, + var(--about-orb-left-start) 0%, + var(--about-orb-left-end) 72%, + transparent 100% + ); + opacity: 0.52; + animation-duration: 9.4s; +} + +.about-bg-orb-right { + width: 840rpx; + height: 840rpx; + right: -390rpx; + bottom: -500rpx; + background: radial-gradient( + circle at 35% 35%, + var(--about-orb-right-start) 0%, + var(--about-orb-right-end) 62%, + transparent 100% + ); + opacity: 0.46; + animation-duration: 7.6s; + animation-delay: -2.2s; +} + +.about-stack { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + gap: 22rpx; +} + +.about-hero { + display: flex; + flex-direction: column; + gap: 10rpx; + padding: 8rpx 8rpx 24rpx; +} + +.about-brand-en { + font-size: 86rpx; + line-height: 0.96; + font-weight: 700; + color: var(--about-text-strong); +} + +.about-brand-version { + font-size: 28rpx; + line-height: 1.2; + font-weight: 700; + color: var(--about-text); +} + +.about-brand-cn { + font-size: 22rpx; + line-height: 1.4; + color: var(--about-text-muted); +} + +.about-intro { + font-size: 24rpx; + line-height: 1.7; + color: var(--about-text); +} + +.about-card-list { + display: flex; + flex-direction: column; + gap: 18rpx; +} + +.about-entry { + width: 100% !important; + min-width: 0 !important; + margin: 0 !important; + padding: 0 !important; + border: 1rpx solid var(--about-surface-border) !important; + border-radius: 28rpx !important; + background: var(--about-surface) !important; + box-shadow: 0 16rpx 38rpx var(--about-glow); + overflow: hidden; +} + +.about-entry.svg-press-btn { + --svg-press-active-radius: 36rpx; + --svg-press-active-bg: var(--about-surface); + --svg-press-active-shadow: 0 22rpx 42rpx var(--about-glow), inset 0 0 0 1rpx var(--about-surface-border); + --svg-press-active-scale: 0.985; + --svg-press-icon-opacity: 0.92; + --svg-press-icon-active-opacity: 1; + --svg-press-icon-active-scale: 1.08; +} + +.about-entry::after, +.about-copy-btn::after { + border: 0 !important; +} + +.about-entry-inner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20rpx; + padding: 28rpx 26rpx; +} + +.about-entry-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 8rpx; +} + +.about-entry-title { + font-size: 30rpx; + line-height: 1.2; + font-weight: 700; + color: var(--about-text-strong); +} + +.about-entry-subtitle { + font-size: 22rpx; + line-height: 1.5; + color: var(--about-text-muted); +} + +.about-entry-arrow { + width: 30rpx; + height: 30rpx; + flex: 0 0 auto; +} + +.detail-chip { + align-self: flex-start; + padding: 8rpx 16rpx; + border-radius: 999rpx; + background: var(--about-accent-soft); + color: var(--about-accent); + font-size: 20rpx; + line-height: 1; +} + +.detail-card { + border-radius: 28rpx; + background: var(--about-surface); + border: 1rpx solid var(--about-surface-border); + box-shadow: 0 16rpx 38rpx var(--about-glow); + padding: 28rpx 26rpx; +} + +.detail-title { + font-size: 38rpx; + line-height: 1.12; + font-weight: 700; + color: var(--about-text-strong); +} + +.detail-lead { + margin-top: 16rpx; + display: block; + font-size: 24rpx; + line-height: 1.7; + color: var(--about-text); +} + +.detail-section-list { + display: flex; + flex-direction: column; + gap: 18rpx; +} + +.detail-section-title { + font-size: 28rpx; + line-height: 1.2; + font-weight: 700; + color: var(--about-text-strong); +} + +.detail-section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20rpx; +} + +.detail-paragraph { + display: block; + margin-top: 14rpx; + font-size: 24rpx; + line-height: 1.7; + color: var(--about-text); +} + +.detail-media-list { + display: flex; + flex-direction: column; + gap: 16rpx; + margin-top: 6rpx; +} + +.detail-media-card { + overflow: hidden; + border-radius: 24rpx; + border: 1rpx solid var(--about-surface-border); + background: var(--about-surface); + box-shadow: 0 12rpx 24rpx var(--about-glow); +} + +.detail-media-image { + display: block; + width: 100%; +} + +.detail-bullet-list { + display: flex; + flex-direction: column; + gap: 12rpx; + margin-top: 16rpx; +} + +.detail-bullet-row { + display: flex; + align-items: flex-start; + gap: 12rpx; +} + +.detail-bullet-dot { + flex: 0 0 auto; + font-size: 24rpx; + line-height: 1.6; + color: var(--about-text-strong); +} + +.detail-bullet-text { + flex: 1; + min-width: 0; + font-size: 24rpx; + line-height: 1.65; + color: var(--about-text); +} + +.about-copy-btn { + width: auto !important; + min-width: 0 !important; + align-self: flex-start; + margin: 0 !important; + padding: 16rpx 22rpx !important; + border-radius: 999rpx !important; + border: 1rpx solid var(--about-action-border) !important; + background: var(--about-action-bg) !important; + color: var(--about-action-text) !important; + font-size: 22rpx !important; + line-height: 1 !important; +} + +.detail-bubble-action { + width: 92rpx !important; + height: 92rpx !important; + min-width: 92rpx !important; + margin: 0 !important; + padding: 0 !important; + border: 1rpx solid var(--about-action-border) !important; + border-radius: 999rpx !important; + background: var(--about-action-bg) !important; + color: var(--about-action-text) !important; + font-size: 22rpx !important; + line-height: 92rpx !important; + text-align: center !important; + flex: 0 0 auto; +} + +.detail-bubble-action::after { + border: 0 !important; +} + +@keyframes about-orb-float { + 0% { + transform: translate3d(0, 0, 0) scale(1); + } + + 50% { + transform: translate3d(14rpx, -18rpx, 0) scale(1.03); + } + + 100% { + transform: translate3d(0, 0, 0) scale(1); + } +} diff --git a/apps/miniprogram/pages/about/index.js b/apps/miniprogram/pages/about/index.js new file mode 100644 index 0000000..4b5fd2a --- /dev/null +++ b/apps/miniprogram/pages/about/index.js @@ -0,0 +1,60 @@ +/* global Page, wx, require */ + +const { getSettings } = require("../../utils/storage"); +const { buildThemeStyle, applyNavigationBarTheme } = require("../../utils/themeStyle"); +const { getAboutBrand, getAboutDetailContent, getAboutHomeItems } = require("../../utils/aboutContent"); +const { normalizeUiLanguage } = require("../../utils/i18n"); +const { buildButtonIconThemeMaps } = require("../../utils/themedIcons"); +const { buildSvgButtonPressData, createSvgButtonPressMethods } = require("../../utils/svgButtonFeedback"); + +Page({ + data: { + ...buildSvgButtonPressData(), + brand: getAboutBrand("zh-Hans"), + items: [], + icons: {}, + accentIcons: {}, + themeStyle: "", + // 首页头部只保留纯版本号,不叠加平台和时间戳。 + homeVersionLine: getAboutBrand("zh-Hans").version + }, + + onLoad() { + this.applyThemeStyle(); + }, + + onShow() { + this.applyThemeStyle(); + }, + + applyThemeStyle() { + const settings = getSettings(); + const language = normalizeUiLanguage(settings.uiLanguage); + const brand = getAboutBrand(language); + const { icons, accentIcons } = buildButtonIconThemeMaps(settings); + const items = getAboutHomeItems(language).map((item) => ({ + ...item, + // about 首页入口目前固定 5 项,用业务 key 生成 press key,后续增删项也无需改模板判断。 + pressKey: `about:${item.key || item.path || item.title || "entry"}` + })); + const homeTitle = getAboutDetailContent("app", language).title || "About"; + applyNavigationBarTheme(settings); + wx.setNavigationBarTitle({ title: homeTitle }); + this.setData({ + brand, + items, + icons, + accentIcons, + themeStyle: buildThemeStyle(settings), + homeVersionLine: brand.version + }); + }, + + onOpenItem(event) { + const path = String(event.currentTarget.dataset.path || "").trim(); + if (!path) return; + wx.navigateTo({ url: path }); + }, + + ...createSvgButtonPressMethods() +}); diff --git a/apps/miniprogram/pages/about/index.json b/apps/miniprogram/pages/about/index.json new file mode 100644 index 0000000..b77168b --- /dev/null +++ b/apps/miniprogram/pages/about/index.json @@ -0,0 +1,9 @@ +{ + "navigationBarTitleText": "关于", + "navigationBarBackgroundColor": "#f4f3ef", + "navigationBarTextStyle": "black", + "disableScroll": true, + "usingComponents": { + "bottom-nav": "/components/bottom-nav/index" + } +} diff --git a/apps/miniprogram/pages/about/index.wxml b/apps/miniprogram/pages/about/index.wxml new file mode 100644 index 0000000..c614a05 --- /dev/null +++ b/apps/miniprogram/pages/about/index.wxml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + {{homeVersionLine}} + + + + + + + + + + + diff --git a/apps/miniprogram/pages/about/index.wxss b/apps/miniprogram/pages/about/index.wxss new file mode 100644 index 0000000..eab8d74 --- /dev/null +++ b/apps/miniprogram/pages/about/index.wxss @@ -0,0 +1,36 @@ +@import "./common.wxss"; + +.about-home-brand { + position: relative; + padding: 0 0 10rpx 8rpx; +} + +.about-home-logo { + width: 96rpx; + height: 96rpx; + display: block; +} + +.about-home-wordmark { + width: 563rpx; + display: block; + margin-top: -96rpx; + margin-left: 112rpx; +} + +.about-home-submark { + width: 88rpx; + display: block; + margin-top: 14rpx; + margin-left: 112rpx; +} + +.about-home-version { + display: block; + margin-top: 14rpx; + margin-left: 112rpx; + font-size: 28rpx; + line-height: 1.2; + font-weight: 700; + color: var(--about-text); +} diff --git a/apps/miniprogram/pages/connect/index.js b/apps/miniprogram/pages/connect/index.js new file mode 100644 index 0000000..8b4c8f2 --- /dev/null +++ b/apps/miniprogram/pages/connect/index.js @@ -0,0 +1,925 @@ +/* global Page, wx, require, console, module */ + +const { + createServerSeed, + listServers, + saveServers, + upsertServer, + removeServer, + markServerConnected, + appendLog, + getSettings +} = require("../../utils/storage"); +const { getTerminalSessionSnapshot } = require("../../utils/terminalSession"); +const { + isTerminalSessionAiHighlighted, + isTerminalSessionConnecting, + isTerminalSessionHighlighted +} = require("../../utils/terminalSessionState"); +const { buildThemeStyle, applyNavigationBarTheme } = require("../../utils/themeStyle"); +const { buildButtonIconThemeMaps, resolveButtonIcon } = require("../../utils/themedIcons"); +const { openTerminalPage: navigateTerminalPage } = require("../../utils/terminalNavigation"); +const { buildPageCopy, formatTemplate, normalizeUiLanguage } = require("../../utils/i18n"); +const { subscribeSyncConfigApplied } = require("../../utils/syncConfigBus"); +const { buildSvgButtonPressData, createSvgButtonPressMethods } = require("../../utils/svgButtonFeedback"); +const { getWindowMetrics } = require("../../utils/systemInfoCompat"); + +const SWIPE_AXIS_LOCK_THRESHOLD_PX = 8; +const SERVER_SWIPE_ACTION_WIDTH_RPX = 240; +const SERVER_SWIPE_FALLBACK_WIDTH_PX = 120; + +function resolveTouchClientPoint(event) { + const point = + (event && event.touches && event.touches[0]) || + (event && event.changedTouches && event.changedTouches[0]) || + null; + if (!point) return null; + const x = Number(point.clientX); + const y = Number(point.clientY); + if (!Number.isFinite(x) || !Number.isFinite(y)) return null; + return { x, y }; +} + +function resolveTouchClientY(event) { + const point = resolveTouchClientPoint(event); + return point ? point.y : null; +} + +/** + * 服务器左滑动作区露出“复制 + 删除”两个按钮,整体宽度按设计稿 `240rpx` 换算: + * 1. 统一在 JS 内转成 px,便于与 touch `clientX` 直接比较; + * 2. 老环境拿不到窗口宽度时回退到保守值,避免手势完全失效。 + */ +function resolveServerSwipeRevealPx(windowWidth) { + const width = Number(windowWidth); + if (!Number.isFinite(width) || width <= 0) { + return SERVER_SWIPE_FALLBACK_WIDTH_PX; + } + return Math.round((width * SERVER_SWIPE_ACTION_WIDTH_RPX) / 750); +} + +function clampServerSwipeOffset(offset, revealPx) { + const numeric = Number(offset); + if (!Number.isFinite(numeric)) return 0; + if (numeric < -revealPx) return -revealPx; + if (numeric > 0) return 0; + return numeric; +} + +function shouldOpenServerSwipe(offset, revealPx) { + return clampServerSwipeOffset(offset, revealPx) <= -revealPx * 0.45; +} + +const loggedRenderProbeKeys = new Set(); + +function shouldUseTextIcons() { + if (!wx || typeof wx.getAppBaseInfo !== "function") return false; + try { + const info = wx.getAppBaseInfo() || {}; + return ( + String(info.platform || "") + .trim() + .toLowerCase() === "devtools" + ); + } catch { + return false; + } +} + +function inspectRenderPayload(payload) { + const stats = { + stringCount: 0, + dataImageCount: 0, + svgPathCount: 0, + urlCount: 0, + maxLength: 0, + samples: [] + }; + const walk = (value, path, depth) => { + if (depth > 5) return; + if (typeof value === "string") { + stats.stringCount += 1; + if (value.includes("data:image")) stats.dataImageCount += 1; + if (value.includes(".svg")) stats.svgPathCount += 1; + if (value.includes("url(")) stats.urlCount += 1; + if (value.length > stats.maxLength) stats.maxLength = value.length; + if ( + stats.samples.length < 6 && + (value.includes("data:image") || + value.includes(".svg") || + value.includes("url(") || + value.length >= 120) + ) { + stats.samples.push({ + path, + length: value.length, + preview: value.slice(0, 120) + }); + } + return; + } + if (!value || typeof value !== "object") return; + if (Array.isArray(value)) { + value.forEach((item, index) => walk(item, `${path}[${index}]`, depth + 1)); + return; + } + Object.keys(value).forEach((key) => walk(value[key], path ? `${path}.${key}` : key, depth + 1)); + }; + walk(payload, "", 0); + return stats; +} + +function logRenderProbeOnce(key, label, payload) { + const normalizedKey = String(key || label || ""); + if (!normalizedKey || loggedRenderProbeKeys.has(normalizedKey)) return; + loggedRenderProbeKeys.add(normalizedKey); + console.warn(`[render_probe] ${label}`, inspectRenderPayload(payload)); +} + +/** + * 服务器列表页(对齐 Web ConnectView): + * 1. 顶部三图标:新增、删除已选、全选/取消全选; + * 2. 搜索框 + 单层列表; + * 3. 每行保留 ai/connect 图标位,排序改为长按拖拽。 + */ +const connectPageOptions = { + data: { + ...buildSvgButtonPressData(), + themeStyle: "", + icons: {}, + activeIcons: {}, + accentIcons: {}, + copy: buildPageCopy("zh-Hans", "connect"), + textIconMode: false, + query: "", + servers: [], + filteredServers: [], + selectedServerIds: [], + isAllSelected: false, + activeServerId: "", + connectingServerId: "", + dragActive: false, + dragServerId: "" + }, + + dragRuntime: null, + dragTapLockUntil: 0, + syncConfigUnsub: null, + swipeRuntime: null, + swipeOffsets: null, + serverSwipeRevealPx: 0, + + onLoad() { + /** + * 首次启动时,云端 bootstrap 可能晚于首页首帧完成: + * 1. 首页先按旧本地快照渲染是正常的; + * 2. 一旦 bootstrap 合并回 storage,需要立刻重读服务器列表和主题; + * 3. 否则用户会误以为同步没生效,必须手动重进页面才看到更新。 + */ + this.syncConfigUnsub = subscribeSyncConfigApplied(() => { + this.applyThemeStyle(); + this.reloadServers(); + }); + }, + + onShow() { + this.applyThemeStyle(); + this.reloadServers(); + }, + + onHide() { + this.swipeRuntime = null; + this.closeAllServerRows(); + if (this.data.dragActive) { + this.clearDragState(); + } + }, + + onUnload() { + if (typeof this.syncConfigUnsub === "function") { + this.syncConfigUnsub(); + this.syncConfigUnsub = null; + } + }, + + applyThemeStyle() { + const settings = getSettings(); + const language = normalizeUiLanguage(settings.uiLanguage); + const copy = buildPageCopy(language, "connect"); + const { icons, activeIcons, accentIcons } = buildButtonIconThemeMaps(settings); + applyNavigationBarTheme(settings); + wx.setNavigationBarTitle({ title: copy.navTitle || "服务器" }); + const payload = { + themeStyle: buildThemeStyle(settings), + icons, + activeIcons, + accentIcons, + copy, + textIconMode: shouldUseTextIcons() && Object.keys(icons).length === 0 + }; + logRenderProbeOnce("connect.applyThemeStyle", "connect.applyThemeStyle", payload); + this.setData(payload); + }, + + reloadServers() { + const rows = listServers(); + this.reconcileServerSwipeState(rows); + this.setData({ servers: rows }, () => { + this.applyFilter(this.data.query); + this.syncSelectState(); + }); + }, + + applyFilter(query) { + const text = String(query || "") + .trim() + .toLowerCase(); + const selected = new Set(this.data.selectedServerIds); + const sessionSnapshot = getTerminalSessionSnapshot(); + const fallbackThemeMaps = buildButtonIconThemeMaps(getSettings()); + const iconMap = + this.data.icons && Object.keys(this.data.icons).length ? this.data.icons : fallbackThemeMaps.icons; + const activeIconMap = + this.data.activeIcons && Object.keys(this.data.activeIcons).length + ? this.data.activeIcons + : fallbackThemeMaps.activeIcons; + const accentIconMap = + this.data.accentIcons && Object.keys(this.data.accentIcons).length + ? this.data.accentIcons + : fallbackThemeMaps.accentIcons; + this.reconcileServerSwipeState(this.data.servers); + const next = this.data.servers + .filter((item) => { + if (!text) return true; + return [ + item.name, + item.host, + item.username, + String(item.port), + item.authType, + this.resolveDisplayTags(item) + .map((tag) => tag.label) + .join(" ") + ] + .join(" ") + .toLowerCase() + .includes(text); + }) + .map((item) => { + const tags = this.resolveTags(item); + const displayTags = this.resolveDisplayTags(item); + const isConnected = isTerminalSessionHighlighted(sessionSnapshot, item.id); + const isAiConnected = isTerminalSessionAiHighlighted(sessionSnapshot, item.id); + return { + ...item, + selected: selected.has(item.id), + swipeOffsetX: + this.swipeOffsets && Number.isFinite(Number(this.swipeOffsets[item.id])) + ? this.clampServerSwipeOffset(this.swipeOffsets[item.id]) + : 0, + tags, + displayTags, + lastConnectedText: this.formatLastConnected(item.lastConnectedAt), + authTypeLabel: + (this.data.copy && + this.data.copy.authTypeLabels && + this.data.copy.authTypeLabels[item.authType]) || + item.authType || + "-", + isConnected, + isAiConnected, + isConnecting: + this.data.connectingServerId === item.id || isTerminalSessionConnecting(sessionSnapshot, item.id), + aiPressKey: `connect-ai:${item.id}`, + connectPressKey: `connect-open:${item.id}`, + aiIcon: resolveButtonIcon("/assets/icons/ai.svg", isAiConnected ? activeIconMap : iconMap), + aiPressedIcon: resolveButtonIcon( + "/assets/icons/ai.svg", + isAiConnected ? activeIconMap : accentIconMap + ), + connectIcon: resolveButtonIcon("/assets/icons/connect.svg", isConnected ? activeIconMap : iconMap), + connectPressedIcon: resolveButtonIcon( + "/assets/icons/connect.svg", + isConnected ? activeIconMap : accentIconMap + ), + dragOffsetY: 0, + dragging: false + }; + }); + const payload = { query, filteredServers: next }; + logRenderProbeOnce("connect.applyFilter", "connect.applyFilter", payload); + this.setData(payload, () => this.refreshDragVisual()); + }, + + syncSelectState() { + const ids = this.data.servers.map((item) => item.id); + const selected = this.data.selectedServerIds.filter((id) => ids.includes(id)); + const isAllSelected = ids.length > 0 && selected.length === ids.length; + this.setData({ selectedServerIds: selected, isAllSelected }, () => this.applyFilter(this.data.query)); + }, + + onQueryInput(event) { + this.closeAllServerRows(); + this.applyFilter(event.detail.value || ""); + }, + + onSearchTap() { + this.closeAllServerRows(); + this.applyFilter(this.data.query); + }, + + onCreateServer() { + this.closeAllServerRows(); + const seed = createServerSeed(); + const prefix = + (this.data.copy && this.data.copy.fallback && this.data.copy.fallback.newServerPrefix) || "server"; + upsertServer({ ...seed, name: `${prefix}-${this.data.servers.length + 1}` }); + this.reloadServers(); + wx.navigateTo({ url: `/pages/server-settings/index?id=${seed.id}` }); + }, + + onToggleServerSelect(event) { + this.closeAllServerRows(); + const id = event.currentTarget.dataset.id; + if (!id) return; + const selected = new Set(this.data.selectedServerIds); + if (selected.has(id)) { + selected.delete(id); + } else { + selected.add(id); + } + this.setData({ selectedServerIds: [...selected] }, () => this.syncSelectState()); + }, + + onToggleSelectAll() { + this.closeAllServerRows(); + if (this.data.isAllSelected) { + this.setData({ selectedServerIds: [] }, () => this.syncSelectState()); + return; + } + const all = this.data.servers.map((item) => item.id); + this.setData({ selectedServerIds: all }, () => this.syncSelectState()); + }, + + onRemoveSelected() { + this.closeAllServerRows(); + const targets = this.data.selectedServerIds; + if (!targets.length) return; + const copy = this.data.copy || {}; + wx.showModal({ + title: copy?.modal?.removeTitle || "删除服务器", + content: formatTemplate(copy?.modal?.removeContent, { count: targets.length }), + success: (res) => { + if (!res.confirm) return; + targets.forEach((id) => removeServer(id)); + this.setData({ selectedServerIds: [] }); + this.reloadServers(); + } + }); + }, + + onOpenSettings(event) { + if (this.data.dragActive || Date.now() < this.dragTapLockUntil) return; + this.closeAllServerRows(); + const serverId = event.currentTarget.dataset.id; + this.setData({ activeServerId: serverId || "" }); + wx.navigateTo({ url: `/pages/server-settings/index?id=${serverId}` }); + }, + + onConnect(event) { + if (this.data.dragActive || Date.now() < this.dragTapLockUntil) return; + this.closeAllServerRows(); + const serverId = event.currentTarget.dataset.id; + if (!serverId) return; + + const sessionSnapshot = getTerminalSessionSnapshot(); + if ( + isTerminalSessionHighlighted(sessionSnapshot, serverId) || + isTerminalSessionConnecting(sessionSnapshot, serverId) + ) { + this.setData({ activeServerId: serverId }, () => this.applyFilter(this.data.query)); + this.openTerminalPage(serverId, true); + return; + } + + this.setData({ activeServerId: serverId, connectingServerId: serverId }, () => + this.applyFilter(this.data.query) + ); + markServerConnected(serverId); + appendLog({ + serverId, + status: "connecting", + summary: (this.data.copy && this.data.copy.summary && this.data.copy.summary.connectFromList) || "" + }); + this.setData({ connectingServerId: "" }); + this.openTerminalPage(serverId, false); + }, + + /** + * 若当前页下方已经是终端页,优先直接返回,避免重复压入新的终端实例。 + */ + openTerminalPage(serverId, reuseExisting, options) { + navigateTerminalPage(serverId, reuseExisting, options); + }, + + onAiTap(event) { + if (this.data.dragActive || Date.now() < this.dragTapLockUntil) return; + this.closeAllServerRows(); + const serverId = event.currentTarget.dataset.id; + if (!serverId) return; + + const server = this.data.servers.find((item) => item.id === serverId); + if (!server) { + wx.showToast({ title: this.data.copy?.toast?.serverNotFound || "服务器不存在", icon: "none" }); + return; + } + + const sessionSnapshot = getTerminalSessionSnapshot(); + this.setData({ activeServerId: serverId }, () => this.applyFilter(this.data.query)); + + if ( + isTerminalSessionHighlighted(sessionSnapshot, serverId) || + isTerminalSessionConnecting(sessionSnapshot, serverId) + ) { + this.openTerminalPage(serverId, true, { openCodex: true }); + return; + } + + this.setData({ connectingServerId: serverId }, () => this.applyFilter(this.data.query)); + markServerConnected(serverId); + appendLog({ + serverId, + status: "connecting", + summary: (this.data.copy && this.data.copy.summary && this.data.copy.summary.aiFromList) || "" + }); + this.setData({ connectingServerId: "" }); + this.openTerminalPage(serverId, false, { openCodex: true }); + }, + + /** + * 复制服务器配置(含认证信息): + * 1. 基于当前服务器快照复制全部字段; + * 2. 重新生成唯一 ID,避免覆盖原记录; + * 3. 名称按“原服务器名+copy”落库,便于用户二次编辑。 + */ + onCopyServer(event) { + if (this.data.dragActive || Date.now() < this.dragTapLockUntil) return; + this.closeAllServerRows(); + const serverId = event.currentTarget.dataset.id; + if (!serverId) return; + + const source = this.data.servers.find((item) => item.id === serverId); + if (!source) { + wx.showToast({ + title: this.data.copy?.toast?.serverToCopyNotFound || "未找到待复制服务器", + icon: "none" + }); + return; + } + + const seed = createServerSeed(); + const copySuffix = String(this.data.copy?.labels?.copyNameSuffix || " copy"); + const copied = { + ...source, + id: seed.id, + name: `${String(source.name || this.data.copy?.unnamedServer || "未命名服务器")}${copySuffix}`, + sortOrder: Date.now(), + lastConnectedAt: "" + }; + upsertServer(copied); + this.setData({ activeServerId: copied.id }, () => this.reloadServers()); + wx.showToast({ title: this.data.copy?.toast?.serverCopied || "服务器已复制", icon: "none" }); + }, + + onStartDrag(event) { + this.closeAllServerRows(); + this.swipeRuntime = null; + const serverId = event.currentTarget.dataset.id; + if (!serverId) return; + if (this.data.dragActive) return; + if (String(this.data.query || "").trim()) { + wx.showToast({ + title: this.data.copy?.toast?.clearSearchBeforeSort || "请清空搜索后再调整顺序", + icon: "none" + }); + return; + } + if (this.data.filteredServers.length <= 1) return; + const fromIndex = this.data.filteredServers.findIndex((item) => item.id === serverId); + if (fromIndex < 0) return; + + const query = wx.createSelectorQuery().in(this); + query.selectAll(".server-list-row").boundingClientRect((rects) => { + if (!Array.isArray(rects) || rects.length !== this.data.filteredServers.length) return; + const startRect = rects[fromIndex]; + if (!startRect) return; + const startY = resolveTouchClientY(event) || startRect.top + startRect.height / 2; + this.dragRuntime = { + serverId, + fromIndex, + toIndex: fromIndex, + startY, + offsetY: 0, + orderIds: this.data.filteredServers.map((item) => item.id), + rects: rects.map((item) => ({ + top: Number(item.top) || 0, + height: Number(item.height) || 0, + center: (Number(item.top) || 0) + (Number(item.height) || 0) / 2 + })) + }; + this.setData( + { + dragActive: true, + dragServerId: serverId + }, + () => this.refreshDragVisual() + ); + }); + query.exec(); + }, + + onDragTouchMove(event) { + if (!this.data.dragActive || !this.dragRuntime) return; + const touchY = resolveTouchClientY(event); + if (touchY == null) return; + + const runtime = this.dragRuntime; + runtime.offsetY = touchY - runtime.startY; + + const sourceRect = runtime.rects[runtime.fromIndex]; + if (!sourceRect) return; + const center = sourceRect.center + runtime.offsetY; + let targetIndex = runtime.fromIndex; + let minDistance = Number.POSITIVE_INFINITY; + for (let i = 0; i < runtime.rects.length; i += 1) { + const distance = Math.abs(center - runtime.rects[i].center); + if (distance < minDistance) { + minDistance = distance; + targetIndex = i; + } + } + runtime.toIndex = targetIndex; + this.refreshDragVisual(); + }, + + onDragTouchEnd() { + if (!this.data.dragActive || !this.dragRuntime) return; + const runtime = this.dragRuntime; + const moved = runtime.toIndex !== runtime.fromIndex; + const dragServerId = runtime.serverId; + this.clearDragState(); + + if (!moved || !dragServerId) return; + + const rows = this.data.servers.slice(); + const fromIndex = rows.findIndex((item) => item.id === dragServerId); + if (fromIndex < 0) return; + const [current] = rows.splice(fromIndex, 1); + if (!current) return; + const targetIndex = Math.max(0, Math.min(rows.length, runtime.toIndex)); + rows.splice(targetIndex, 0, current); + this.persistServerOrder(rows, dragServerId); + }, + + clearDragState() { + this.dragRuntime = null; + this.dragTapLockUntil = Date.now() + 240; + const next = this.data.filteredServers.map((item) => { + if (!item.dragOffsetY && !item.dragging) return item; + return { + ...item, + dragOffsetY: 0, + dragging: false + }; + }); + this.setData({ + dragActive: false, + dragServerId: "", + filteredServers: next + }); + }, + + buildDragOffsetMap() { + if (!this.data.dragActive || !this.dragRuntime) return {}; + const runtime = this.dragRuntime; + const from = runtime.fromIndex; + const to = runtime.toIndex; + const offsets = { + [runtime.serverId]: runtime.offsetY + }; + if (to > from) { + for (let i = from + 1; i <= to; i += 1) { + const id = runtime.orderIds[i]; + if (!id) continue; + const prev = runtime.rects[i - 1]; + const current = runtime.rects[i]; + offsets[id] = (prev ? prev.top : 0) - (current ? current.top : 0); + } + } else if (to < from) { + for (let i = to; i < from; i += 1) { + const id = runtime.orderIds[i]; + if (!id) continue; + const current = runtime.rects[i]; + const next = runtime.rects[i + 1]; + offsets[id] = (next ? next.top : 0) - (current ? current.top : 0); + } + } + return offsets; + }, + + refreshDragVisual() { + if (!this.data.dragActive || !this.dragRuntime) return; + const offsets = this.buildDragOffsetMap(); + const dragId = this.data.dragServerId; + const next = this.data.filteredServers.map((item) => { + const dragOffsetY = Number(offsets[item.id] || 0); + const dragging = item.id === dragId; + if (item.dragOffsetY === dragOffsetY && item.dragging === dragging) { + return item; + } + return { + ...item, + dragOffsetY, + dragging + }; + }); + this.setData({ filteredServers: next }); + }, + + persistServerOrder(rows, activeServerId) { + const base = Date.now(); + const next = rows.map((item, index) => ({ + ...item, + sortOrder: base + index + })); + saveServers(next); + this.setData( + { + servers: next, + activeServerId: activeServerId || this.data.activeServerId + }, + () => { + this.applyFilter(this.data.query); + this.syncSelectState(); + } + ); + }, + + resolveTouchPoint(event) { + return resolveTouchClientPoint(event); + }, + + getServerSwipeRevealPx() { + if (Number.isFinite(this.serverSwipeRevealPx) && this.serverSwipeRevealPx > 0) { + return this.serverSwipeRevealPx; + } + const metrics = getWindowMetrics(wx); + this.serverSwipeRevealPx = resolveServerSwipeRevealPx(metrics.windowWidth); + return this.serverSwipeRevealPx; + }, + + clampServerSwipeOffset(offset) { + return clampServerSwipeOffset(offset, this.getServerSwipeRevealPx()); + }, + + reconcileServerSwipeState(rows) { + const list = Array.isArray(rows) ? rows : []; + const validIds = new Set(list.map((item) => item.id)); + const nextOffsets = {}; + Object.keys(this.swipeOffsets || {}).forEach((id) => { + if (!validIds.has(id)) return; + nextOffsets[id] = this.clampServerSwipeOffset(this.swipeOffsets[id]); + }); + this.swipeOffsets = nextOffsets; + if (this.swipeRuntime && !validIds.has(this.swipeRuntime.id)) { + this.swipeRuntime = null; + } + }, + + findFilteredServerIndexById(id) { + return this.data.filteredServers.findIndex((item) => item.id === id); + }, + + updateServerSwipeOffset(id, offset) { + const index = this.findFilteredServerIndexById(id); + if (index < 0) return; + const normalized = this.clampServerSwipeOffset(offset); + this.swipeOffsets = this.swipeOffsets || {}; + this.swipeOffsets[id] = normalized; + this.setData({ [`filteredServers[${index}].swipeOffsetX`]: normalized }); + }, + + closeOtherServerRows(exceptId) { + this.swipeOffsets = this.swipeOffsets || {}; + const updates = {}; + this.data.filteredServers.forEach((item, index) => { + if (item.id === exceptId) return; + if (item.swipeOffsetX === 0) return; + this.swipeOffsets[item.id] = 0; + updates[`filteredServers[${index}].swipeOffsetX`] = 0; + }); + if (Object.keys(updates).length > 0) { + this.setData(updates); + } + }, + + closeAllServerRows() { + this.swipeOffsets = this.swipeOffsets || {}; + const updates = {}; + this.data.filteredServers.forEach((item, index) => { + if (item.swipeOffsetX === 0) return; + this.swipeOffsets[item.id] = 0; + updates[`filteredServers[${index}].swipeOffsetX`] = 0; + }); + if (Object.keys(updates).length > 0) { + this.setData(updates); + } + }, + + onListTap() { + this.closeAllServerRows(); + }, + + /** + * 服务器列表沿用闪念页的横向手势模型: + * 1. `touchstart` 仅记录起点和当前开合态; + * 2. `touchmove` 再做横纵轴锁定,避免和列表纵向滚动打架; + * 3. 只允许向左露出删除按钮,不支持向右拖出正偏移。 + */ + onServerTouchStart(event) { + if (this.data.dragActive) return; + const id = String(event.currentTarget.dataset.id || ""); + if (!id) return; + const point = this.resolveTouchPoint(event); + if (!point) return; + this.closeOtherServerRows(id); + this.swipeOffsets = this.swipeOffsets || {}; + this.swipeRuntime = { + id, + startX: point.x, + startY: point.y, + startOffsetX: this.clampServerSwipeOffset(this.swipeOffsets[id] || 0), + dragging: false, + blocked: false + }; + }, + + onServerTouchMove(event) { + if (this.data.dragActive) return; + const runtime = this.swipeRuntime; + if (!runtime || runtime.blocked) return; + const id = String(event.currentTarget.dataset.id || ""); + if (!id || id !== runtime.id) return; + const point = this.resolveTouchPoint(event); + if (!point) return; + + const deltaX = point.x - runtime.startX; + const deltaY = point.y - runtime.startY; + if (!runtime.dragging) { + if ( + Math.abs(deltaX) < SWIPE_AXIS_LOCK_THRESHOLD_PX && + Math.abs(deltaY) < SWIPE_AXIS_LOCK_THRESHOLD_PX + ) { + return; + } + if (Math.abs(deltaY) > Math.abs(deltaX)) { + runtime.blocked = true; + return; + } + runtime.dragging = true; + } + + this.updateServerSwipeOffset(id, runtime.startOffsetX + deltaX); + }, + + onServerTouchEnd(event) { + if (this.data.dragActive) return; + const runtime = this.swipeRuntime; + this.swipeRuntime = null; + if (!runtime || runtime.blocked) return; + const id = String(event.currentTarget.dataset.id || ""); + if (!id || id !== runtime.id) return; + const current = this.clampServerSwipeOffset((this.swipeOffsets && this.swipeOffsets[id]) || 0); + const shouldOpen = shouldOpenServerSwipe(current, this.getServerSwipeRevealPx()); + this.updateServerSwipeOffset(id, shouldOpen ? -this.getServerSwipeRevealPx() : 0); + }, + + onSwipeDeleteServer(event) { + const serverId = String(event.currentTarget.dataset.id || ""); + if (!serverId) return; + const server = this.data.servers.find((item) => item.id === serverId); + const serverName = String( + (server && server.name) || this.data.copy?.unnamedServer || event.currentTarget.dataset.name || "" + ).trim(); + const copy = this.data.copy || {}; + wx.showModal({ + title: copy?.modal?.removeTitle || "删除服务器", + content: formatTemplate(copy?.modal?.removeSingleContent, { + name: serverName || copy?.unnamedServer || "未命名服务器" + }), + success: (res) => { + if (!res.confirm) return; + removeServer(serverId); + if (this.swipeOffsets) { + delete this.swipeOffsets[serverId]; + } + this.setData( + { + activeServerId: this.data.activeServerId === serverId ? "" : this.data.activeServerId, + selectedServerIds: this.data.selectedServerIds.filter((id) => id !== serverId) + }, + () => { + this.reloadServers(); + wx.showToast({ + title: copy?.toast?.serverDeleted || "服务器已删除", + icon: "success" + }); + } + ); + } + }); + }, + + /** + * 服务器标签对齐 Web 语义: + * 1. 只展示用户明确配置的 tags; + * 2. 不再按服务器名称猜测标签,避免与真实标签混淆。 + */ + resolveTags(server) { + return Array.isArray(server && server.tags) + ? server.tags.map((item) => String(item || "").trim()).filter((item) => !!item) + : []; + }, + + /** + * 提取项目目录最后一级名称: + * 1. 先清理空白与尾部斜杠; + * 2. 同时兼容 Unix/Windows 路径; + * 3. 列表里只展示短目录名,避免胶囊过长。 + */ + resolveProjectDirectoryName(projectPath) { + const normalized = String(projectPath || "") + .trim() + .replace(/[\\/]+$/g, ""); + if (!normalized) return ""; + const segments = normalized.split(/[\\/]+/).filter(Boolean); + if (!segments.length) { + return normalized === "~" ? "~" : ""; + } + return segments[segments.length - 1] || ""; + }, + + /** + * 卡片底部标签组装: + * 1. project 胶囊固定排在最前面; + * 2. 原始 tags 继续跟在后面; + * 3. 返回对象数组,便于模板按类型切换底色。 + */ + resolveDisplayTags(server) { + const displayTags = []; + const projectDirectoryName = this.resolveProjectDirectoryName(server && server.projectPath); + if (projectDirectoryName) { + displayTags.push({ + type: "project", + label: `${(this.data.copy && this.data.copy.display && this.data.copy.display.projectPrefix) || "pro"}:${projectDirectoryName}` + }); + } + this.resolveTags(server).forEach((tag) => { + displayTags.push({ + type: "tag", + label: tag + }); + }); + return displayTags; + }, + + /** + * 最近连接时间统一格式: + * - 空值显示“无连接”; + * - 非法时间显示“无连接”; + * - 合法时间输出 YYYY-MM-DD HH:mm:ss。 + */ + formatLastConnected(input) { + if (!input) return this.data.copy?.fallback?.noConnection || "无连接"; + const date = new Date(input); + if (Number.isNaN(+date)) return this.data.copy?.fallback?.noConnection || "无连接"; + const pad = (n) => String(n).padStart(2, "0"); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; + }, + + ...createSvgButtonPressMethods() +}; + +Page(connectPageOptions); + +module.exports = { + __test__: { + pageOptions: connectPageOptions, + SWIPE_AXIS_LOCK_THRESHOLD_PX, + SERVER_SWIPE_ACTION_WIDTH_RPX, + SERVER_SWIPE_FALLBACK_WIDTH_PX, + resolveServerSwipeRevealPx, + clampServerSwipeOffset, + shouldOpenServerSwipe + } +}; diff --git a/apps/miniprogram/pages/connect/index.json b/apps/miniprogram/pages/connect/index.json new file mode 100644 index 0000000..6399ac1 --- /dev/null +++ b/apps/miniprogram/pages/connect/index.json @@ -0,0 +1,7 @@ +{ + "navigationBarTitleText": "服务器", + "disableScroll": true, + "usingComponents": { + "bottom-nav": "/components/bottom-nav/index" + } +} diff --git a/apps/miniprogram/pages/connect/index.test.ts b/apps/miniprogram/pages/connect/index.test.ts new file mode 100644 index 0000000..054340d --- /dev/null +++ b/apps/miniprogram/pages/connect/index.test.ts @@ -0,0 +1,150 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +type ConnectPageOptions = { + data: Record; + onServerTouchStart?: (event: Record) => void; + onServerTouchMove?: (event: Record) => void; + onServerTouchEnd?: (event: Record) => void; + closeOtherServerRows?: (exceptId: string) => void; + findFilteredServerIndexById?: (id: string) => number; + updateServerSwipeOffset?: (id: string, offset: number) => void; + clampServerSwipeOffset?: (offset: number) => number; + resolveTouchPoint?: (event: Record) => { x: number; y: number } | null; +}; + +type ConnectTestHelpers = { + pageOptions: ConnectPageOptions; + resolveServerSwipeRevealPx: (windowWidth: number) => number; + clampServerSwipeOffset: (offset: number, revealPx: number) => number; + shouldOpenServerSwipe: (offset: number, revealPx: number) => boolean; +}; + +type MiniprogramGlobals = typeof globalThis & { + Page?: (options: ConnectPageOptions) => void; + wx?: { + getWindowInfo?: () => { windowWidth: number; windowHeight: number }; + }; +}; + +function createTouchEvent(id: string, x: number, y: number) { + return { + currentTarget: { + dataset: { id } + }, + touches: [{ clientX: x, clientY: y }], + changedTouches: [{ clientX: x, clientY: y }] + }; +} + +/** + * 测试里只需要覆盖当前页面真正会写入的 `setData` 路径: + * 1. 直接字段,如 `dragActive`; + * 2. `filteredServers[0].swipeOffsetX` 这类列表项字段。 + */ +function applySetData(target: Record, updates: Record) { + Object.entries(updates).forEach(([path, value]) => { + const itemMatch = path.match(/^filteredServers\[(\d+)\]\.([a-zA-Z0-9_]+)$/); + if (itemMatch) { + const index = Number(itemMatch[1]); + const key = itemMatch[2]; + const rows = target.filteredServers as Array>; + rows[index][key] = value; + return; + } + target[path] = value; + }); +} + +function createFakePage(options: ConnectPageOptions) { + const page = { + ...options, + data: { + ...options.data, + dragActive: false, + filteredServers: [ + { + id: "srv-1", + swipeOffsetX: 0 + } + ] + }, + swipeOffsets: { "srv-1": 0 }, + swipeRuntime: null as Record | null, + serverSwipeRevealPx: 120, + setData(updates: Record) { + applySetData(this.data as Record, updates); + }, + getServerSwipeRevealPx() { + return 120; + } + }; + return page; +} + +describe("connect page swipe", () => { + const globalState = globalThis as MiniprogramGlobals; + const originalPage = globalState.Page; + const originalWx = globalState.wx; + let testHelpers: ConnectTestHelpers | null = null; + + beforeEach(() => { + testHelpers = null; + vi.resetModules(); + globalState.Page = vi.fn((options: ConnectPageOptions) => { + return options; + }); + globalState.wx = { + getWindowInfo: vi.fn(() => ({ windowWidth: 375, windowHeight: 812 })) + }; + testHelpers = require("./index.js").__test__; + }); + + afterEach(() => { + if (originalPage) { + globalState.Page = originalPage; + } else { + delete globalState.Page; + } + if (originalWx) { + globalState.wx = originalWx; + } else { + delete globalState.wx; + } + }); + + it("应按窗口宽度把删除动作区从 rpx 换算成 px", () => { + expect(testHelpers).toBeTruthy(); + expect(testHelpers?.resolveServerSwipeRevealPx(375)).toBe(120); + expect(testHelpers?.resolveServerSwipeRevealPx(0)).toBeGreaterThan(0); + }); + + it("横向左滑超过阈值后应展开删除按钮", () => { + expect(testHelpers?.pageOptions).toBeTruthy(); + const page = createFakePage(testHelpers?.pageOptions as ConnectPageOptions); + + testHelpers?.pageOptions.onServerTouchStart?.call(page, createTouchEvent("srv-1", 220, 100)); + testHelpers?.pageOptions.onServerTouchMove?.call(page, createTouchEvent("srv-1", 160, 103)); + expect((page.data.filteredServers as Array>)[0].swipeOffsetX).toBe(-60); + + testHelpers?.pageOptions.onServerTouchEnd?.call(page, createTouchEvent("srv-1", 160, 103)); + expect((page.data.filteredServers as Array>)[0].swipeOffsetX).toBe(-120); + }); + + it("纵向滚动手势不应误展开删除按钮", () => { + expect(testHelpers?.pageOptions).toBeTruthy(); + const page = createFakePage(testHelpers?.pageOptions as ConnectPageOptions); + + testHelpers?.pageOptions.onServerTouchStart?.call(page, createTouchEvent("srv-1", 220, 100)); + testHelpers?.pageOptions.onServerTouchMove?.call(page, createTouchEvent("srv-1", 214, 136)); + testHelpers?.pageOptions.onServerTouchEnd?.call(page, createTouchEvent("srv-1", 214, 136)); + + expect((page.data.filteredServers as Array>)[0].swipeOffsetX).toBe(0); + }); + + it("开合阈值应与当前动作区宽度一致", () => { + expect(testHelpers?.clampServerSwipeOffset(-160, 120)).toBe(-120); + expect(testHelpers?.clampServerSwipeOffset(12, 120)).toBe(0); + expect(testHelpers?.shouldOpenServerSwipe(-60, 120)).toBe(true); + expect(testHelpers?.shouldOpenServerSwipe(-40, 120)).toBe(false); + }); +}); diff --git a/apps/miniprogram/pages/connect/index.wxml b/apps/miniprogram/pages/connect/index.wxml new file mode 100644 index 0000000..2caf6a6 --- /dev/null +++ b/apps/miniprogram/pages/connect/index.wxml @@ -0,0 +1,220 @@ + + + + + + + + + {{copy.pageTitle}} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{item.name || copy.unnamedServer}} + + + + + + + + {{item.username || '-'}}@{{item.host || '-'}}:{{item.port || 22}} + {{item.authTypeLabel || '-'}} + + + {{copy.recentConnectionPrefix}}: {{item.lastConnectedText}} + + + {{item.label}} + + + + + + + {{copy.emptyTip}} + + + + + + diff --git a/apps/miniprogram/pages/connect/index.wxss b/apps/miniprogram/pages/connect/index.wxss new file mode 100644 index 0000000..029481a --- /dev/null +++ b/apps/miniprogram/pages/connect/index.wxss @@ -0,0 +1,499 @@ +.server-manager-page { + -webkit-user-select: none; + user-select: none; +} + +.server-manager-content { + flex: 1; + min-height: 0; + padding: 16rpx 16rpx 32rpx; + display: flex; + flex-direction: column; + gap: 0; + overflow: hidden; +} + +.server-search-wrap { + flex: 0 0 auto; + padding: 0 0 16rpx; +} + +.server-manager-toolbar .icon-btn { + border-radius: 999rpx !important; + background: var(--icon-btn-bg) !important; + background-color: var(--icon-btn-bg) !important; + box-shadow: inset 0 0 0 1rpx var(--btn-border); +} + +.server-manager-toolbar .svg-press-btn { + --svg-press-active-radius: 999rpx; + --svg-press-active-bg: var(--icon-btn-bg-strong); + --svg-press-active-shadow: inset 0 0 0 1rpx var(--accent-border), 0 0 0 8rpx var(--accent-ring); + --svg-press-active-scale: 0.92; + --svg-press-icon-opacity: 0.96; + --svg-press-icon-active-opacity: 0.68; + --svg-press-icon-active-scale: 0.88; +} + +.server-manager-toolbar .icon-btn:active { + background: var(--icon-btn-bg-strong) !important; + background-color: var(--icon-btn-bg-strong) !important; +} + +.server-manager-toolbar .toolbar-plain-btn, +.server-manager-toolbar .toolbar-plain-btn:active { + border-radius: 0 !important; + background: transparent !important; + background-color: transparent !important; + box-shadow: none !important; +} + +.server-search-shell { + display: flex; + align-items: center; + width: 100%; + height: 64rpx; + border: 1rpx solid var(--btn-border); + border-radius: 54rpx; + overflow: hidden; +} + +.server-search-input { + flex: 1; + min-width: 0; + height: 100%; + border: 0; + border-radius: 0; + background: transparent; + color: var(--text); + font-size: 22rpx; + line-height: normal; + padding: 0 16rpx; +} + +.server-search-btn { + width: 68rpx !important; + min-width: 68rpx !important; + height: 100% !important; + margin: 0 !important; + border: 0 !important; + border-left: 1rpx solid var(--btn-border-strong); + border-radius: 0 54rpx 54rpx 0; + background: var(--btn-bg-strong) !important; + background-color: var(--btn-bg-strong) !important; + color: inherit !important; + padding: 0 !important; + line-height: 1 !important; + font-size: 0 !important; + display: inline-flex !important; + align-items: center; + justify-content: center; +} + +.server-search-btn.svg-press-btn { + --svg-press-active-radius: 54rpx; + --svg-press-active-bg: var(--btn-bg-active); + --svg-press-active-shadow: none; + --svg-press-active-scale: 1; + --svg-press-icon-opacity: 0.96; + --svg-press-icon-active-opacity: 0.72; + --svg-press-icon-active-scale: 0.92; +} + +.server-search-icon { + width: 26rpx; + height: 26rpx; +} + +.server-search-text, +.debug-icon-text { + font-size: 20rpx; + line-height: 1; + font-weight: 600; + color: var(--btn-text); +} + +.debug-icon-text-small { + font-size: 18rpx; +} + +.server-list-scroll { + flex: 1; + min-height: 0; +} + +.server-list-stack { + display: flex; + flex-direction: column; + gap: 32rpx; + padding-bottom: 16rpx; + position: relative; +} + +.server-list-stack.dragging { + overflow: visible; +} + +.server-list-row { + display: flex; + align-items: flex-start; + padding-bottom: 32rpx; + border-bottom: 1rpx solid rgba(141, 187, 255, 0.35); + position: relative; + transition: + transform 180ms ease, + box-shadow 180ms ease, + opacity 180ms ease; + will-change: transform; + isolation: isolate; +} + +.server-list-row::before { + content: ""; + position: absolute; + top: -8rpx; + left: -10rpx; + right: -10rpx; + bottom: 10rpx; + border-radius: 18rpx; + background: transparent; + box-shadow: none; + transition: + background 180ms ease, + box-shadow 180ms ease; + z-index: 0; +} + +.server-list-row.is-dragging { + transition: none; + opacity: 0.5; +} + +.server-list-row.is-dragging::before { + background: rgba(103, 209, 255, 0.22); + box-shadow: 0 18rpx 34rpx rgba(0, 0, 0, 0.26); +} + +.server-list-row.active { + border-bottom-color: rgba(103, 209, 255, 0.75); +} + +.server-row-check-wrap { + flex: 0 0 24rpx; + width: 24rpx; + height: 44rpx; + margin-right: 16rpx; + position: relative; + z-index: 2; + overflow: visible; +} + +.server-row-content-shell { + flex: 1; + min-width: 0; + position: relative; + overflow: hidden; + border-radius: 18rpx; + z-index: 1; +} + +.server-row-swipe-actions { + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 240rpx; + display: flex; + align-items: stretch; + justify-content: flex-end; + opacity: 0; + pointer-events: none; + transition: opacity 160ms ease; + z-index: 0; +} + +.server-row-swipe-actions.opened { + opacity: 1; + pointer-events: auto; +} + +.server-swipe-copy-btn, +.server-swipe-delete-btn { + width: 50% !important; + min-width: 0 !important; + height: 100% !important; + margin: 0 !important; + border: 0 !important; + color: #f7fbff !important; + padding: 0 !important; + display: inline-flex !important; + align-items: center; + justify-content: center; + line-height: 1 !important; + font-size: 0 !important; +} + +.server-swipe-copy-btn { + border-radius: 18rpx 0 0 18rpx !important; + background: rgba(101, 130, 149, 0.84) !important; +} + +.server-swipe-delete-btn { + border-radius: 0 18rpx 18rpx 0 !important; + background: rgba(164, 118, 118, 0.86) !important; +} + +.server-swipe-btn-text { + font-size: 24rpx; + line-height: 1; + font-weight: 600; + letter-spacing: 2rpx; +} + +.server-row-track { + position: relative; + z-index: 1; + transition: transform 160ms ease; + will-change: transform; +} + +.server-row-check-hitbox { + /* 透明热区浮在勾选框上方,并向右覆盖一部分卡片左边缘。 */ + position: absolute; + left: -20rpx; + top: 50%; + width: 64rpx; + height: 64rpx; + transform: translateY(-50%); + display: inline-flex; + align-items: center; + justify-content: center; + z-index: 3; +} + +.server-row-check { + width: 24rpx; + height: 44rpx; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.server-check-input { + width: 24rpx; + height: 24rpx; + border: 1rpx solid var(--btn-border); + border-radius: 8rpx; + background: var(--icon-btn-bg); + position: relative; +} + +.server-check-input.checked { + border-color: var(--accent-border); + background: var(--accent); +} + +.server-check-input.checked::after { + content: ""; + position: absolute; + left: 9rpx; + top: 3rpx; + width: 5rpx; + height: 12rpx; + border: solid #ffffff; + border-width: 0 2rpx 2rpx 0; + transform: rotate(45deg); +} + +.server-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 16rpx; + min-width: 0; + position: relative; + z-index: 1; +} + +.server-info-top { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 16rpx; + padding-right: 0; +} + +.server-row-actions { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + gap: 12rpx; + margin-left: auto; +} + +.server-copy-btn, +.server-ai-btn, +.connect-icon-btn { + width: 44rpx !important; + height: 44rpx !important; + min-width: 0 !important; + margin: 0 !important; + border: 0 !important; + background: transparent !important; + background-color: transparent !important; + color: inherit !important; + padding: 0 !important; + line-height: 1 !important; + font-size: 0 !important; + display: inline-flex !important; + overflow: visible !important; + align-items: center; + justify-content: center; +} + +.server-copy-btn { + border-radius: 999rpx !important; + background: var(--btn-bg) !important; + background-color: var(--btn-bg) !important; + box-shadow: inset 0 0 0 2rpx var(--btn-border-strong); +} + +.server-copy-icon { + width: 24rpx; + height: 24rpx; +} + +.server-ai-btn, +.server-ai-icon { + width: 44rpx; + height: 44rpx; +} + +.connect-icon-btn { + border-radius: 999rpx !important; + background: var(--icon-btn-bg) !important; + background-color: var(--icon-btn-bg) !important; + box-shadow: inset 0 0 0 2rpx var(--btn-border); +} + +.server-ai-btn { + border-radius: 999rpx !important; + background: var(--icon-btn-bg) !important; + background-color: var(--icon-btn-bg) !important; + box-shadow: inset 0 0 0 2rpx var(--btn-border); +} + +.server-copy-btn.svg-press-btn, +.server-ai-btn.svg-press-btn, +.connect-icon-btn.svg-press-btn { + --svg-press-active-radius: 999rpx; + --svg-press-active-bg: var(--icon-btn-bg-strong); + --svg-press-active-shadow: 0 0 0 8rpx var(--accent-ring); + --svg-press-active-scale: 0.92; + --svg-press-icon-opacity: 0.96; + --svg-press-icon-active-opacity: 0.68; + --svg-press-icon-active-scale: 0.88; +} + +.server-copy-btn.svg-press-btn { + --svg-press-active-bg: var(--btn-bg-active); +} + +.server-ai-btn.is-connected, +.connect-icon-btn.is-connected { + background: var(--accent) !important; + background-color: var(--accent) !important; + box-shadow: 0 10rpx 24rpx var(--accent-shadow) !important; +} + +.server-ai-btn.is-connected.svg-press-btn, +.connect-icon-btn.is-connected.svg-press-btn { + --svg-press-active-bg: var(--accent); + --svg-press-active-shadow: 0 0 0 8rpx var(--accent-ring), 0 10rpx 24rpx var(--accent-shadow); + --svg-press-icon-active-opacity: 0.92; + --svg-press-icon-active-scale: 0.94; +} + +.server-ai-btn.is-connected .debug-icon-text, +.connect-icon-btn.is-connected .debug-icon-text { + color: var(--text); +} + +.connect-icon-btn.is-connecting { + opacity: 0.45; +} + +.server-copy-btn.wx-button-disabled, +.server-ai-btn.wx-button-disabled, +.connect-icon-btn.wx-button-disabled { + opacity: 0.45 !important; +} + +.server-info-meta { + display: flex; + align-items: center; + gap: 15rpx; + color: var(--text); +} + +.server-name { + flex: 1; + min-width: 0; + margin-right: 8rpx; + font-size: 32rpx; + font-weight: 600; + line-height: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.server-main, +.server-auth, +.server-recent { + font-size: 28rpx; + color: var(--text); + line-height: 1; +} + +.server-main { + max-width: 360rpx; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.server-auth { + font-size: 24rpx; + opacity: 0.95; +} + +.server-tags { + display: flex; + align-items: center; + gap: 16rpx; + overflow: hidden; +} + +.server-tag { + height: 32rpx; + padding: 0 12rpx; + border-radius: 16rpx; + background: rgba(91, 210, 255, 0.6); + color: var(--text); + font-size: 20rpx; + line-height: 32rpx; + white-space: nowrap; +} + +.server-tag-project { + background: rgba(103, 209, 255, 1); +} + +.server-empty-tip { + margin: 0; + font-size: 28rpx; + color: var(--muted); + text-align: center; + padding: 24rpx 0; +} diff --git a/apps/miniprogram/pages/logs/index.js b/apps/miniprogram/pages/logs/index.js new file mode 100644 index 0000000..0447417 --- /dev/null +++ b/apps/miniprogram/pages/logs/index.js @@ -0,0 +1,88 @@ +/* global Page, wx, require, getCurrentPages */ + +const { listLogs, getSettings } = require("../../utils/storage"); +const { pageOf } = require("../../utils/pagination"); +const { buildThemeStyle, applyNavigationBarTheme } = require("../../utils/themeStyle"); +const { buildPageCopy, formatTemplate, normalizeUiLanguage, t } = require("../../utils/i18n"); + +const PAGE_SIZE = 15; + +/** + * 日志页(对齐 Web LogsView): + * 1. 顶部工具栏保留返回语义; + * 2. 主体为导出 + 列表 + 分页。 + */ +Page({ + data: { + themeStyle: "", + canGoBack: false, + copy: buildPageCopy("zh-Hans", "logs"), + page: 1, + total: 0, + totalPages: 1, + rows: [], + totalCountText: t("zh-Hans", "logs.totalCount", { total: 0 }), + pageIndicatorText: t("zh-Hans", "common.pageIndicator", { page: 1, total: 1 }) + }, + + onShow() { + const settings = getSettings(); + const language = normalizeUiLanguage(settings.uiLanguage); + const copy = buildPageCopy(language, "logs"); + applyNavigationBarTheme(settings); + wx.setNavigationBarTitle({ title: copy.navTitle || "日志" }); + this.setData({ themeStyle: buildThemeStyle(settings), copy }); + this.syncCanGoBack(); + this.reload(); + }, + + syncCanGoBack() { + const pages = getCurrentPages(); + this.setData({ canGoBack: pages.length > 1 }); + }, + + goBack() { + if (!this.data.canGoBack) return; + wx.navigateBack({ delta: 1 }); + }, + + reload() { + const logs = listLogs(); + const paged = pageOf(logs, this.data.page, PAGE_SIZE); + const settings = getSettings(); + const language = normalizeUiLanguage(settings.uiLanguage); + this.setData({ + page: paged.page, + total: paged.total, + totalPages: paged.totalPages, + rows: paged.rows, + totalCountText: formatTemplate(this.data.copy.totalCount, { total: paged.total }), + pageIndicatorText: t(language, "common.pageIndicator", { + page: paged.page, + total: paged.totalPages + }) + }); + }, + + onPrev() { + this.setData({ page: Math.max(1, this.data.page - 1) }, () => this.reload()); + }, + + onNext() { + this.setData({ page: Math.min(this.data.totalPages, this.data.page + 1) }, () => this.reload()); + }, + + onExport() { + const rows = listLogs(); + const content = rows + .map( + (item) => + `[${item.startAt || "--"}] ${item.serverId || "-"} ${item.status || "-"}\n${item.summary || ""}` + ) + .join("\n\n"); + wx.setClipboardData({ + data: content || "", + success: () => wx.showToast({ title: this.data.copy?.toast?.copied || "日志已复制", icon: "success" }) + }); + } +}); diff --git a/apps/miniprogram/pages/logs/index.json b/apps/miniprogram/pages/logs/index.json new file mode 100644 index 0000000..5648cec --- /dev/null +++ b/apps/miniprogram/pages/logs/index.json @@ -0,0 +1,7 @@ +{ + "navigationBarTitleText": "日志", + "disableScroll": true, + "usingComponents": { + "bottom-nav": "/components/bottom-nav/index" + } +} diff --git a/apps/miniprogram/pages/logs/index.wxml b/apps/miniprogram/pages/logs/index.wxml new file mode 100644 index 0000000..4105fd5 --- /dev/null +++ b/apps/miniprogram/pages/logs/index.wxml @@ -0,0 +1,29 @@ + + + + + + {{totalCountText}} + + + + + + {{item.serverId || '-'}} · {{item.status || '-'}} + {{item.startAt || '--'}} -> {{item.endAt || '--'}} + {{item.summary || '--'}} + + {{copy.empty}} + + + + + + {{pageIndicatorText}} + + + + + + + diff --git a/apps/miniprogram/pages/logs/index.wxss b/apps/miniprogram/pages/logs/index.wxss new file mode 100644 index 0000000..2dbc082 --- /dev/null +++ b/apps/miniprogram/pages/logs/index.wxss @@ -0,0 +1,34 @@ +.logs-content { + padding-top: 16rpx; +} + +.logs-panel { + padding: 0 0 16rpx; +} + +.logs-actions { + justify-content: space-between; +} + +.logs-list-scroll { + flex: 1; + min-height: 0; +} + +.logs-list { + min-height: 0; +} + +.log-item { + border-radius: 20rpx; +} + +.item-title { + font-weight: 600; + margin-bottom: 6rpx; +} + +.item-sub { + font-size: 22rpx; + color: var(--muted); +} diff --git a/apps/miniprogram/pages/plugins/index.js b/apps/miniprogram/pages/plugins/index.js new file mode 100644 index 0000000..e135b14 --- /dev/null +++ b/apps/miniprogram/pages/plugins/index.js @@ -0,0 +1,196 @@ +/* global Page, wx, require, getCurrentPages, console */ + +const pluginRuntime = require("../../utils/pluginRuntime"); +const { onSessionEvent, getSessionState } = require("../../utils/sessionBus"); +const { getSettings } = require("../../utils/storage"); +const { buildThemeStyle, applyNavigationBarTheme } = require("../../utils/themeStyle"); +const { buildPageCopy, formatTemplate, getRuntimeStateLabel, normalizeUiLanguage } = require("../../utils/i18n"); + +/** + * 插件页: + * 1. 对齐 Web 的“插件运行时管理”能力; + * 2. 支持启用/禁用/重载/移除、JSON 导入导出、命令执行与运行日志。 + */ +Page({ + data: { + themeStyle: "", + canGoBack: false, + pluginJson: "", + records: [], + commands: [], + runtimeLogs: [], + sessionState: "disconnected", + sessionStateLabel: "Disconnected", + copy: buildPageCopy("zh-Hans", "plugins") + }, + + async onShow() { + const pages = getCurrentPages(); + const settings = getSettings(); + const language = normalizeUiLanguage(settings.uiLanguage); + const copy = buildPageCopy(language, "plugins"); + applyNavigationBarTheme(settings); + wx.setNavigationBarTitle({ title: copy.navTitle || "插件" }); + this.setData({ + canGoBack: pages.length > 1, + sessionState: getSessionState(), + sessionStateLabel: getRuntimeStateLabel(language, getSessionState()), + copy, + themeStyle: buildThemeStyle(settings) + }); + if (!Array.isArray(this.sessionUnsubs) || this.sessionUnsubs.length === 0) { + this.sessionUnsubs = [ + onSessionEvent("connected", () => { + const nextLanguage = normalizeUiLanguage(getSettings().uiLanguage); + this.setData( + { + sessionState: "connected", + sessionStateLabel: getRuntimeStateLabel(nextLanguage, "connected") + }, + () => this.reloadRuntime() + ); + }), + onSessionEvent("disconnected", () => { + const nextLanguage = normalizeUiLanguage(getSettings().uiLanguage); + this.setData( + { + sessionState: "disconnected", + sessionStateLabel: getRuntimeStateLabel(nextLanguage, "disconnected") + }, + () => this.reloadRuntime() + ); + }) + ]; + } + await this.reloadRuntime(); + }, + + onUnload() { + if (Array.isArray(this.sessionUnsubs)) { + this.sessionUnsubs.forEach((off) => { + try { + off(); + } catch (error) { + console.warn("[plugins.sessionUnsubs]", error); + } + }); + } + this.sessionUnsubs = null; + }, + + async reloadRuntime() { + try { + await pluginRuntime.ensureBootstrapped(); + const records = pluginRuntime.listRecords(); + const commands = pluginRuntime.listCommands(this.data.sessionState); + const runtimeLogs = pluginRuntime.listRuntimeLogs(); + this.setData({ records, commands, runtimeLogs }); + } catch (error) { + wx.showToast({ title: (error && error.message) || this.data.copy?.toast?.bootstrapFailed || "插件初始化失败", icon: "none" }); + } + }, + + goBack() { + if (!this.data.canGoBack) return; + wx.navigateBack({ delta: 1 }); + }, + + onPluginJsonInput(event) { + this.setData({ pluginJson: event.detail.value || "" }); + }, + + async onImportJson() { + if (!String(this.data.pluginJson || "").trim()) { + wx.showToast({ title: this.data.copy?.toast?.pastePluginJsonFirst || "请先粘贴插件 JSON", icon: "none" }); + return; + } + try { + await pluginRuntime.importJson(this.data.pluginJson); + this.setData({ pluginJson: "" }); + await this.reloadRuntime(); + wx.showToast({ title: this.data.copy?.toast?.importSuccess || "导入成功", icon: "success" }); + } catch (error) { + wx.showToast({ title: (error && error.message) || this.data.copy?.toast?.importFailed || "导入失败", icon: "none" }); + } + }, + + async onExportJson() { + try { + const raw = await pluginRuntime.exportJson(); + wx.setClipboardData({ + data: raw, + success: () => { + wx.showToast({ title: this.data.copy?.toast?.exportSuccess || "插件 JSON 已复制", icon: "success" }); + } + }); + } catch (error) { + wx.showToast({ title: (error && error.message) || this.data.copy?.toast?.exportFailed || "导出失败", icon: "none" }); + } + }, + + async onEnable(event) { + const id = String(event.currentTarget.dataset.id || ""); + if (!id) return; + try { + await pluginRuntime.enable(id); + await this.reloadRuntime(); + wx.showToast({ title: this.data.copy?.toast?.enabled || "已启用", icon: "success" }); + } catch (error) { + wx.showToast({ title: (error && error.message) || this.data.copy?.toast?.enableFailed || "启用失败", icon: "none" }); + } + }, + + async onDisable(event) { + const id = String(event.currentTarget.dataset.id || ""); + if (!id) return; + try { + await pluginRuntime.disable(id); + await this.reloadRuntime(); + wx.showToast({ title: this.data.copy?.toast?.disabled || "已禁用", icon: "success" }); + } catch (error) { + wx.showToast({ title: (error && error.message) || this.data.copy?.toast?.disableFailed || "禁用失败", icon: "none" }); + } + }, + + async onReload(event) { + const id = String(event.currentTarget.dataset.id || ""); + if (!id) return; + try { + await pluginRuntime.reload(id); + await this.reloadRuntime(); + wx.showToast({ title: this.data.copy?.toast?.reloaded || "已重载", icon: "success" }); + } catch (error) { + wx.showToast({ title: (error && error.message) || this.data.copy?.toast?.reloadFailed || "重载失败", icon: "none" }); + } + }, + + async onRemove(event) { + const id = String(event.currentTarget.dataset.id || ""); + if (!id) return; + wx.showModal({ + title: this.data.copy?.modal?.removeTitle || "移除插件", + content: formatTemplate(this.data.copy?.modal?.removeContent, { id }), + success: async (res) => { + if (!res.confirm) return; + try { + await pluginRuntime.remove(id); + await this.reloadRuntime(); + wx.showToast({ title: this.data.copy?.toast?.removed || "已移除", icon: "success" }); + } catch (error) { + wx.showToast({ title: (error && error.message) || this.data.copy?.toast?.removeFailed || "移除失败", icon: "none" }); + } + } + }); + }, + + async onRunCommand(event) { + const commandId = String(event.currentTarget.dataset.commandId || ""); + if (!commandId) return; + try { + await pluginRuntime.runCommand(commandId); + wx.showToast({ title: this.data.copy?.toast?.commandExecuted || "命令已执行", icon: "success" }); + } catch (error) { + wx.showToast({ title: (error && error.message) || this.data.copy?.toast?.commandExecuteFailed || "命令执行失败", icon: "none" }); + } + } +}); diff --git a/apps/miniprogram/pages/plugins/index.json b/apps/miniprogram/pages/plugins/index.json new file mode 100644 index 0000000..c65e054 --- /dev/null +++ b/apps/miniprogram/pages/plugins/index.json @@ -0,0 +1,7 @@ +{ + "navigationBarTitleText": "插件", + "disableScroll": true, + "usingComponents": { + "bottom-nav": "/components/bottom-nav/index" + } +} diff --git a/apps/miniprogram/pages/plugins/index.wxml b/apps/miniprogram/pages/plugins/index.wxml new file mode 100644 index 0000000..4ab397a --- /dev/null +++ b/apps/miniprogram/pages/plugins/index.wxml @@ -0,0 +1,72 @@ + + + + + {{copy.runtimeStatePrefix}}{{sessionStateLabel}} + {{copy.summary}} + + + + + + {{copy.sections.pluginList}} + + + {{item.id}} · {{item.status}} + errorCount: {{item.errorCount}} + + {{item.lastError || '-'}} + + + + + + + + {{copy.empty.noPlugins}} + + + + {{copy.sections.importJson}} + + +
+
+ + +
+
+ + +
+
+
+
+
+ +
+
+
+ + + + + + diff --git a/apps/web/src/content/about.test.ts b/apps/web/src/content/about.test.ts new file mode 100644 index 0000000..cd9969b --- /dev/null +++ b/apps/web/src/content/about.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { + ABOUT_BRAND, + ABOUT_HOME_ITEMS, + buildAboutInfoRows, + getAboutDetailContent, + isAboutDetailKey +} from "./about"; + +describe("about content", () => { + it("首页入口与小程序结构一致", () => { + expect(ABOUT_HOME_ITEMS.map((item) => item.key)).toEqual([ + "manual", + "feedback", + "privacy", + "changelog", + "app" + ]); + }); + + it("可识别合法详情 key", () => { + expect(isAboutDetailKey("manual")).toBe(true); + expect(isAboutDetailKey("unknown")).toBe(false); + }); + + it("非法 key 会回退到关于详情", () => { + expect(getAboutDetailContent("unknown").title).toBe("关于"); + }); + + it("产品信息可拆成标签和值", () => { + const rows = buildAboutInfoRows(getAboutDetailContent("app")); + expect(rows[0]).toEqual({ + key: "row-0", + label: "产品名称:", + value: ABOUT_BRAND.productName + }); + }); +}); diff --git a/apps/web/src/content/about.ts b/apps/web/src/content/about.ts new file mode 100644 index 0000000..cce53be --- /dev/null +++ b/apps/web/src/content/about.ts @@ -0,0 +1,497 @@ +export type AboutDetailKey = "manual" | "feedback" | "privacy" | "changelog" | "app"; + +export type AboutSection = { + title: string; + paragraphs?: string[]; + bullets?: string[]; + actionLabel?: string; +}; + +export type AboutDetailContent = { + title: string; + lead: string; + sections: AboutSection[]; +}; + +export type AboutHomeItem = { + key: AboutDetailKey; + title: string; + subtitle: string; +}; + +export type AboutInfoRow = { + key: string; + label: string; + value: string; +}; + +/** + * Web 端先对齐小程序关于页文案。 + * 这里保留独立 TS 副本,避免把小程序 CommonJS 运行时直接耦合进 Web 构建链。 + */ +export const ABOUT_BRAND = { + productName: "RemoteConn", + chineseName: "AI矩连", + platformLabel: "AI矩连小程序版", + version: "v3.0.0", + updatedAt: "2026-03-18", + updatedAtCompact: "20260318", + feedbackEmail: "douboer@gmail.com", + summary: "管理服务器并在移动端执行 AI 终端操作。" +} as const; + +export const ABOUT_HOME_ITEMS: AboutHomeItem[] = [ + { + key: "manual", + title: "使用手册", + subtitle: "查看各模块的实际操作说明与推荐使用顺序" + }, + { + key: "feedback", + title: "问题反馈", + subtitle: "查看反馈方式与建议附带的信息" + }, + { + key: "privacy", + title: "隐私政策", + subtitle: "查看信息收集、使用与存储说明" + }, + { + key: "changelog", + title: "变更记录", + subtitle: "查看完整版本历史与当前遗留问题" + }, + { + key: "app", + title: "关于", + subtitle: "查看产品简介、版本信息与联系信息" + } +]; + +const ABOUT_DETAIL_CONTENT: Record = { + manual: { + title: "使用手册", + lead: "本手册按当前小程序页面结构说明实际操作路径。建议按照“服务器列表 -> 服务器配置 -> 终端 -> 闪念 / 日志 -> 设置 / 插件”的顺序使用,先完成一台服务器配置,再逐步扩展到 AI、语音和插件能力。", + sections: [ + { + title: "1. 服务器列表:新增、整理和进入连接", + bullets: [ + "打开首页“服务器”,左上角第一个按钮用于新增服务器,第二个按钮删除已勾选服务器,第三个按钮全选或取消全选。", + "顶部搜索框会按名称、主机、用户名、端口和标签过滤服务器;搜索状态下建议先清空关键词再调整顺序。", + "点击服务器卡片主体会进入“服务器配置”;长按卡片可拖动排序,便于把常用服务器放到前面。", + "每张卡片右侧有三个快捷按钮:复制、AI、连接。复制会生成一份同配置的新服务器;AI 会直接进入终端并自动打开 AI 面板;连接会进入该服务器终端。", + "连接中的服务器会在列表中高亮显示;如果该服务器已有可复用会话,再次点击“连接”会直接回到当前终端,而不是重新创建一条会话。" + ] + }, + { + title: "2. 服务器配置:把一台服务器配到可连接状态", + bullets: [ + "基础信息至少填写名称、主机、端口和用户名;“标签”支持逗号分隔,保存后会显示在服务器卡片底部,便于按环境或项目区分。", + "认证信息按实际方式填写:密码模式填写密码;私钥模式填写私钥和口令;证书模式再补证书内容。认证方式切换后,请确认对应字段已经完整填写。", + "连接参数里的“AI 工作目录”决定 AI 快速启动时进入的目录。可以手输,也可以点“选择目录”打开远程目录树,展开目录后点“应用”写回表单。", + "如需跳板机,打开“跳转主机”开关,再分别填写跳板机主机、端口、用户名和独立认证信息;该配置会用于目录选择和终端连接的整条链路。", + "底部按钮从左到右分别是返回、连接、保存、闪念、关于。建议先点“保存”,确认无误后再点“连接”;如果只是临时修改配置未保存,直接连接前页面也会先尝试落库。" + ] + }, + { + title: "3. 终端:输入、重连、清屏和 AI 快速启动", + bullets: [ + "进入终端后,顶部会显示服务器名、连接状态和延迟。右侧开关在已连接时用于断开,在断开后用于重连;左上角按钮分别是打开 AI 面板和清屏;当 Codex 正在当前会话前台运行时,清屏按钮会禁用。", + "点击终端输出区域会激活输入焦点并弹出系统键盘;输入框使用原生键盘代理,回车会把当前内容发送到远端终端。", + "右下角键盘按钮可展开触摸工具区,里面提供方向键、Enter、Paste 和常用快捷键,适合在手机上执行命令行导航、补全和翻页操作。", + "“Paste”会读取系统剪贴板并发送到当前会话;如果未连接或剪贴板为空,页面会直接提示,不会静默失败。", + "点击左上角 Codex 图标可打开“AI 快速启动”面板。这里可以直接执行 `codex --sandbox ...` 或 copilot 命令;前提是服务器配置里的 AI 工作目录存在,且远端已安装对应命令。" + ] + }, + { + title: "4. 语音与闪念:先记录,再决定发送还是存档", + bullets: [ + "终端右下角悬浮麦克风可拖动位置;点开后会出现语音输入面板,文本框里会显示当前草稿内容。", + "按住主语音按钮开始录音,松开后把识别结果写入草稿;下方分类胶囊可以选择本次闪念记录要归入的分类。", + "录音完成后,中间的“记录”按钮会把草稿保存到闪念列表;“发送”按钮会把草稿直接发送到终端执行;右侧两个按钮分别用于清空草稿和关闭面板。", + "如果你暂时不想把内容发到服务器,优先使用“记录”而不是“发送”;这样可以先在闪念页整理,再决定是否执行。", + "未连接终端时仍可记录闪念,但不能发送到会话;适合先记操作思路、排查步骤和待执行命令。" + ] + }, + { + title: "5. 日志:看连接过程,导出排查信息", + bullets: [ + "日志页按时间倒序展示连接相关记录,每条会显示服务器标识、状态、开始结束时间和摘要。", + "顶部“导出脱敏日志”会把当前全部日志整理成文本并复制到剪贴板,适合发给开发者做排查;这里导出的是脱敏后的摘要,不是原始敏感凭据。", + "日志页默认每页 15 条,可用“上一页 / 下一页”翻页;如果列表为空,说明当前设备上还没有产生连接日志或日志已被清理。" + ] + }, + { + title: "6. 闪念:搜索、改分类、编辑和导出", + bullets: [ + "闪念页顶部输入框用于全文搜索;右侧下拉按钮可以按分类过滤,方便只看某一类记录。", + "每条记录左滑后可直接“复制”或“删除”;适合快速把某条命令、报错或笔记重新贴回终端或外部沟通工具。", + "点击分类标签会弹出快速改分类面板,可把记录移动到其他分类;点击记录卡片主体会打开编辑弹层,支持修改正文和分类。", + "底部“导出闪念”会把当前闪念内容复制到剪贴板;如果你需要先做归档,再批量整理,这是最直接的导出方式。" + ] + }, + { + title: "7. 设置:统一调整外观、连接默认值和分类规则", + bullets: [ + "设置页分为“界面 / 终端 / 连接 / 记录”四个标签。界面标签调整应用主题、背景色、文本色和按钮色;终端标签调整终端配色、字体、字号、行高以及宽字符支持。", + "如果终端显示密度不合适,可在“终端”标签调整字号和行高;当前版本修改字号后仍建议断开重连一次,避免出现显示未完全刷新。", + "“连接”标签用于设置 SSH 非主动断开后的自动重连、重连次数上限、后台保活时长,以及默认认证方式、默认端口、默认项目路径、默认超时和心跳。", + "“记录”标签除了设置记录保留天数,还可以新增闪念分类、设默认分类、删除分类,并通过长按拖动调整分类顺序。" + ] + }, + { + title: "8. 插件与常见使用顺序", + bullets: [ + "插件页支持粘贴 JSON 导入插件、导出全部插件、启用 / 禁用 / 重载 / 移除插件,并在会话连接后执行插件注册的命令。", + "如果插件页里没有可执行命令,通常表示当前没有连接中的会话,或插件本身没有注册任何命令;先回终端建立连接,再回插件页刷新。", + "推荐的新用户使用顺序是:先在“服务器”新增一台机器,进入“服务器配置”填完连接信息,保存并连接;连接稳定后,再尝试语音、闪念、AI 和插件能力。", + "请仅连接你拥有合法权限的服务器;若出现连接失败、目录加载失败、AI 启动失败或显示异常,先查看日志和闪念记录,再把版本号、复现步骤、截图与日志摘要一并反馈。" + ] + } + ] + }, + feedback: { + title: "问题反馈", + lead: "如果你在使用过程中遇到问题或有改进建议,可通过邮箱反馈联系我们。", + sections: [ + { + title: "反馈邮箱", + paragraphs: ["douboer@gmail.com"], + actionLabel: "复制邮箱" + }, + { + title: "建议附带的信息", + bullets: ["机型与系统版本", "小程序版本号", "复现步骤", "截图或日志摘要"] + }, + { + title: "反馈范围", + bullets: ["连接失败", "终端显示异常", "记录或设置异常", "建议与体验反馈"] + } + ] + }, + privacy: { + title: "隐私政策", + lead: "我们重视你的个人信息与数据安全。为支持同一账号在多设备间同步配置,系统会在服务器侧保存必要的设置、服务器配置与闪念记录;其中 SSH 密码、私钥、证书等敏感凭据不会以明文形式保存,而会在服务端加密后存储。", + sections: [ + { + title: "我们会处理的信息", + bullets: [ + "你主动保存的用户设置,例如界面主题、终端显示偏好、默认连接参数、闪念分类与记录保留策略。", + "你主动保存的服务器配置,例如名称、标签、主机、端口、用户名、项目路径、跳转主机配置,以及为跨设备同步所需的认证信息。", + "你主动保存的闪念记录,例如正文、分类、上下文标签和更新时间。", + "其中 SSH 密码、私钥、口令、证书及跳转主机对应凭据属于受保护字段,服务端仅以加密形式保存,用于在你已认证的设备间同步取回。" + ] + }, + { + title: "信息用途", + bullets: [ + "用于在同一账号下同步设置、服务器配置和闪念记录。", + "用于在你更换设备或重新登录时恢复必要的连接配置与使用偏好。", + "用于系统排查、稳定性分析和故障定位;此类处理仅限必要范围,不用于画像、营销或其他无关用途。" + ] + }, + { + title: "信息存储方式", + bullets: [ + "用户设置、服务器配置和闪念记录会同时保存在当前设备本地,并同步保存到服务端存储中,用于跨设备恢复。", + "SSH 密码、私钥、口令、证书等敏感凭据不会以明文形式写入服务端数据库,而会在应用层加密后保存。", + "系统错误日志、异常堆栈、基础版本信息、设备环境信息和连接状态信息可能在必要的排查场景下被处理。", + "我们不会因为展示“关于”页面而额外收集与当前功能无关的个人信息。" + ] + }, + { + title: "你的权利", + bullets: [ + "你可以在应用内修改或删除自行保存的服务器配置、闪念记录与设置项;相关变更会同步到服务端。", + "如需删除已同步的服务端配置数据,可通过后续提供的数据删除能力,或按“问题反馈”页提供的方式联系我们。", + "如对隐私说明或数据处理方式有疑问,可通过“问题反馈”页提供的方式联系我们。" + ] + } + ] + }, + changelog: { + title: "变更记录", + lead: "当前页面完整同步仓库 `CHANGELOG.md` 的版本历史,便于直接查看连续版本演进。", + sections: [ + { + title: "索引说明", + bullets: [ + "详细发布说明见 release.md。", + "若 release.md 未单列某个 vx.y.0,则按 history.md 与 git 历史按连续版本规则补齐。", + "x.y.z 的 patch 空档不额外补齐;历史补齐版本只用于保持序列连续。" + ] + }, + { + title: "v3.0.0(2026-03-18)", + bullets: [ + "当前版本新增两项终端交互稳定性修复:高频 stdout 期间的 caret 稳定窗口,以及软键盘仍可见时 shell 输入被动 blur 保护。", + "发布说明、README、关于页、当前基线文档、多语言文案与工程描述已统一升级到 v3.0.0。", + "当前版本不新增新的同步协议、终端协议或配置字段,继续沿用 v2.9.6 已明确的能力边界。" + ] + }, + { + title: "v2.9.6(2026-03-13)", + bullets: [ + "当前版本仅同步文档与对外口径,不引入新的功能、协议行为或交互变更。", + "发布说明、README、关于页、当前基线文档、多语言文案与工程描述已统一升级到 v2.9.6。", + "当前版本继续沿用 v2.9.5 已完成的现有终端、同步与时延诊断能力,以及当前语音播报边界口径。" + ] + }, + { + title: "v2.9.5(2026-03-13)", + bullets: [ + "当前版本仅同步文档与对外口径,不引入新的功能、协议行为或交互变更。", + "发布说明、README、关于页、当前基线文档、多语言文案与工程描述已统一升级到 v2.9.5。", + "新登记一条小程序终端语音播报遗留问题:当前播报文本提取与轮次稳定判定仍不够准确,长时间 Codex 交互时也会额外放大小程序端响应压力,现阶段暂不建议默认使用。" + ] + }, + { + title: "v2.9.4(2026-03-12)", + bullets: [ + "当前版本仅同步文档与对外口径,不引入新的功能、协议行为或交互变更。", + "发布说明、README、关于页、当前基线文档、多语言文案与问题闭环记录已统一升级到 v2.9.4。", + "当前 Web 与小程序基线继续沿用 v2.9.3 已完成的时延诊断面板与主题对比度收口结果。" + ] + }, + { + title: "v2.9.3(2026-03-11)", + bullets: [ + "小程序时延诊断浮窗已改成单张双轴平滑曲线图,左轴显示网关响应,右轴显示网络时延。", + "原“诊断信息”文字卡已删除,顶部改为两张两行摘要卡;同一服务器断开重连后会优先延续最近 30 个采样点。", + "时延面板现已跟随终端主题做反相配色,深色终端会额外切换一套更深的蓝橙曲线与指标色,保证浅底上的文字和曲线可读性。", + "发布说明、README、关于页、当前基线文档与问题闭环记录统一更新到 v2.9.3。" + ] + }, + { + title: "v2.9.1(2026-03-11)", + bullets: [ + "小程序终端会话续接首次恢复已改为以 `lines + bufferCols / bufferRows` 为准,避免返回服务器列表后再次进入时出现历史区顶部空白与裸露 `5;2H`。", + "启动阶段 `bootstrap` 合并配置完成后,首页服务器列表与底栏会立即刷新,不再需要重新进入页面才能看到同步后的配置。", + "小程序用户隐私政策与 about 隐私页已按最新审核口径同步,补齐录音用途、处理范围与用户权利说明。", + "发布说明、README、关于页、当前基线文档与问题闭环记录统一更新到 v2.9.1。" + ] + }, + { + title: "v2.9.0(2026-03-11)", + bullets: [ + "小程序终端已修复 Codex 持续输出期间底部提示块缺行、状态行被裁掉与区域反复闪动的问题。", + "normal buffer viewport 会保留光标行之后仍真实存在的 footer,`CSI ? 2026 h/l` 同步刷新窗口也已完成兼容收口。", + "整行统一高亮背景会优先提升到 line 层绘制,`> Use /skills to list available skills` 与代码块不再透出行间底色细线。", + "会话续接恢复口径已修正:首次恢复优先使用 `lines + bufferCols / bufferRows` 还原当前屏幕,避免返回服务器列表后再次进入时出现历史区顶部空白与裸露 `5;2H`。", + "当前版本最重要的变更聚焦在交互问题修复;发布说明、README、关于页、当前基线文档与问题闭环记录统一更新到 v2.9.0。" + ] + }, + { + title: "v2.8.2(2026-03-10)", + bullets: [ + "服务器列表 connect 按钮与底栏 shell 按钮在活动连接态统一改为高饱和实底高亮,不再依赖描边反馈。", + "连接态 SVG 前景色改为运行时跟随界面前景色,保证高亮底色上的图标对比度。", + "about 首页、详情页与 about-app 改为按界面配置推导配色;Web about 页同步切到同一套主题变量策略。", + "新登记一条 about 反馈遗留问题:当前“反馈”按钮仍只复制邮箱地址,暂不支持直接拉起系统发送邮件组件。", + "发布说明、根 README、小程序 README、关于页与当前基线文档统一更新到 v2.8.2。" + ] + }, + { + title: "v2.8.1(2026-03-10)", + bullets: [ + "终端语音区展开按钮默认改为全透明,仅保留 SVG 本体;分类胶囊改为贴文字高度,选中态背景切到更明显的实色。", + "录音中的输入框上方新增更显眼的双环脉冲提示,文案更新为“正在收音,松开后发送或记录闪念”。", + "终端 VT 当前基线继续收口:OSC 10 / 11 / 12 返回真实 shell 主题色,备用屏与擦除空白位统一继承当前擦除背景。", + "此前点击 Codex 连接选项后的首回显迟滞与等待期间按钮阻塞问题已收敛,当前版本不再将其列为遗留问题。", + "另记录一个低频终端遗留问题:偶发新连接后首屏只显示光标、提示符稍后才出现;后续再优化连接就绪判定与 prompt 首屏兜底。", + "发布说明、根 README、小程序 README、关于页与当前基线文档统一更新到 v2.8.1。" + ] + }, + { + title: "v2.7.1(2026-03-10)", + bullets: [ + "文档与关于页版本口径统一更新到 v2.7.1,并补齐同步 SQLite 的当前边界说明。", + "明确 remoteconn-sync.db-wal 是 SQLite 写前日志,文件体积不直接等于当前有效同步数据量。", + "当前跨设备同步仍仅覆盖 settings / servers / records;日志、插件运行时日志与终端会话缓冲继续保留本地。", + "当前小程序终端仍存在明显交互卡顿:点击 Codex 连接选项后约 10 秒才出现回显,等待期间除上下滑动外其余按钮基本阻塞。" + ] + }, + { + title: "v2.7.0(2026-03-09)", + bullets: [ + "小程序设置、服务器配置与闪念记录接入 Gateway + SQLite 双层持久化,支持同一账号跨设备恢复。", + "同步链路补齐 bootstrap 与 settings / servers / records 增量推送;服务器和闪念删除支持 tombstone 合并。", + "SSH 密码、私钥、口令与证书等受保护字段改为服务端加密保存;终端会话缓冲仍不纳入第一阶段同步。" + ] + }, + { + title: "v2.6.6(2026-03-09)", + bullets: [ + "文档口径统一更新到 v2.6.6。", + "将“通过 npm run mini 生成的 preview 预览包,不作为正式版本地缓存连续性验证依据”记录为当前遗留问题。", + "服务器配置、用户设置与闪念记录改为本地 storage + Gateway 同步双层持久化;SSH 密码、私钥、口令与证书等受保护字段在服务端加密保存。" + ] + }, + { + title: "v2.6.5(2026-03-08)", + bullets: [ + "小程序终端 VT P0 基线收口,补齐双缓冲、备用屏幕切换、DSR / CPR / DA1 / DA2 / DECSTR 与基础局部重绘。", + "修复 normal buffer 的 live tail 与滚动边界问题,并同步更新当前文档基线到 v2.6.5。" + ] + }, + { + title: "v2.6.1(2026-03-08)", + bullets: [ + "将“修改字号后偶发吃字/显示不完整”收口为已知遗留问题,并在设置页增加“修改字号后建议断开重连”提示。", + "收敛数字输入、字体回退与设置页容错逻辑,移除排查阶段的 terminal.wrap 调试输出。" + ] + }, + { + title: "v2.6.0(2026-03-07)", + bullets: [ + "小程序补齐跳转主机、AI 快速启动、后台续接与连接反馈等主链路能力。", + "小程序继续补齐终端可用性细节:光标位置计算修复,AI 启动链路正式落地到小程序端。", + "SSH relay / 跳板机链路从“可配置”推进到“可经 A 主机直接转发连接 B 主机”。", + "文档口径统一升级到 v2.6.0,并将历史 v2.4.0 说明并入当前对外版本。" + ] + }, + { + title: "v2.5.0(历史补齐,2026-03-07)", + bullets: [ + "依据 v2.3.0 -> v2.6.0 之间的历史记录,按 3 小时一个 minor 版本的规则补齐。", + "该阶段主要是 v2.6.0 之前的小程序终端链路收口与 AI、跳板机场景串联,详细内容已并入 v2.6.0。" + ] + }, + { + title: "v2.4.0(历史补齐,2026-03-07)", + bullets: [ + "history.md 中明确出现版本号 v2.4.0,后续在 v2.6.0 中并档处理。", + "该阶段集中于小程序终端光标、列宽与 cell 模型重构,以及相关分析文档沉淀,详细内容已并入 v2.6.0。" + ] + }, + { + title: "v2.3.0(2026-03-06)", + bullets: [ + "小程序完成一轮大范围能力对齐,补齐 connect / server settings / terminal / logs / records / settings / plugins 主页面链路。", + "Web 端继续精修交互与稳定性,文档口径统一到 v2.3.0。" + ] + }, + { + title: "v2.2.0(2026-03-06)", + bullets: [ + "闪念增强定稿,补齐分类、编辑、搜索、过滤、快速改分类与上下文快照。", + "全局配置补齐闪念分类治理、默认分类与拖拽排序。" + ] + }, + { + title: "v2.1.0(历史补齐,2026-03-01)", + bullets: [ + "依据 v2.0.0 -> v2.2.0 之间的历史记录,按 3 小时一个 minor 版本的规则补齐。", + "该阶段主要完成小程序连接页、服务器配置页、远程目录选择与基础对齐链路,为 v2.2.0 的记录增强和 v2.3.0 的大范围对齐打底。" + ] + }, + { + title: "v2.0.0(2026-02-27)", + bullets: [ + "语音输入交互对齐 Figma,闪念记录闭环上线。", + "日志与记录分页、资源缓存治理、文档与发布规范同步统一。", + "语音层与键盘工具层的命中区继续收敛,修复工具栏被误判为外部点击后自动折叠的问题。" + ] + }, + { + title: "v1.1.0(2026-02-26)", + bullets: [ + "终端语音输入全链路、Gateway ASR 代理与 Web 稳定性增强。", + "终端输入增强为“原生 textarea + 语音输入面板 + 发送确认”组合,并补入 TAB 辅助键。", + "路由懒加载失败增加自动恢复,缩放防护补齐到 double-tap / dblclick 等路径。" + ] + }, + { + title: "v1.0.9(2026-02-25)", + bullets: [ + "连接体验治理、服务器列表拖拽排序与移动端防误触优化。", + "连接与服务器管理继续收敛:支持连接重试次数与分组;新增服务器改为先进入配置页,未改动不落库。", + "导航与配置页体验统一:返回语义按历史栈处理,终端配置页增加预览块,配置页 UI 完成一轮重构。", + "终端工具与视觉细节补齐:新增 paste 辅助按钮,修复字体选择,补齐夜间模式与重连提示样式。", + "会话恢复体验补强:同连接历史可延续,刷新“我的服务器”页默认不主动断开现有会话。" + ] + }, + { + title: "v1.0.8(2026-02-24)", + bullets: [ + "配置中心稳定性、深浅色模式与网关 runtime 配置落地。", + "配置中心结构重组:服务器基础/认证绑定到当前服务器,终端/主题/安全切到全局配置。", + "配置与工具区交互继续收敛:点击外部区域可折叠右下角键盘工具条,Figma 配置区交互并入当前基线。", + "终端提示文案与初始化输出治理:连接提示更友好,中文输入初始化命令回显默认不展示。" + ] + }, + { + title: "v1.0.6(2026-02-24)", + bullets: ["Console 触摸工具区可点击性与误触防护优化。"] + }, + { + title: "v1.0.5(2026-02-23)", + bullets: ["iPhone 触摸滚动动量阶段性优化。"] + }, + { + title: "v1.0.3(2026-02-23)", + bullets: ["iOS 触摸焦点状态机稳定性修复。"] + }, + { + title: "v1.0.1(2026-02-23)", + bullets: [ + "生产架构版稳定性更新,确立 Web、Gateway 与插件运行时的多包工程基线。", + "补齐多服务器与多认证方式管理,最近连接日志可追溯。", + "Codex 模式主链路可用:连接后自动切目录并启动 codex。", + "主题与界面自定义基础能力上线,含字体、配色与基础配置中心。" + ] + } + ] + }, + app: { + title: "关于", + lead: "", + sections: [ + { + title: "产品信息", + bullets: [ + "产品名称:RemoteConn", + "中文名称:AI矩连", + "平台标识:AI矩连小程序版", + "当前版本:v3.0.0", + "修改时间:20260318", + "数据口径:设置、服务器配置与闪念支持跨设备同步,敏感凭据服务端加密保存", + "反馈邮箱:douboer@gmail.com", + "更新时间:2026-03-18" + ] + } + ] + } +}; + +export function isAboutDetailKey(value: string): value is AboutDetailKey { + return value in ABOUT_DETAIL_CONTENT; +} + +export function getAboutDetailContent(key: string): AboutDetailContent { + if (isAboutDetailKey(key)) { + return ABOUT_DETAIL_CONTENT[key]; + } + return ABOUT_DETAIL_CONTENT.app; +} + +export function buildAboutInfoRows(content: AboutDetailContent): AboutInfoRow[] { + const primarySection = content.sections[0]; + const bullets = Array.isArray(primarySection?.bullets) ? primarySection.bullets : []; + return bullets.map((line, index) => { + const text = String(line || "").trim(); + const matched = text.match(/^([^::]+)[::]\s*(.+)$/); + if (!matched) { + return { + key: `row-${index}`, + label: "", + value: text + }; + } + return { + key: `row-${index}`, + label: `${matched[1]}:`, + value: String(matched[2] ?? "") + }; + }); +} diff --git a/apps/web/src/env.d.ts b/apps/web/src/env.d.ts new file mode 100644 index 0000000..e09a6fe --- /dev/null +++ b/apps/web/src/env.d.ts @@ -0,0 +1,11 @@ +/// + +interface ImportMetaEnv { + readonly VITE_GATEWAY_URL?: string; + readonly VITE_GATEWAY_TOKEN?: string; + readonly VITE_ENABLE_PLUGIN_RUNTIME?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts new file mode 100644 index 0000000..0daaad3 --- /dev/null +++ b/apps/web/src/main.ts @@ -0,0 +1,87 @@ +import { createApp } from "vue"; +import { createPinia } from "pinia"; +import { createRouter, createWebHistory } from "vue-router"; +import App from "./App.vue"; +import { routes } from "./routes"; +import { installDynamicImportRecovery } from "./utils/dynamicImportGuard"; +import "./styles/main.css"; + +/** + * 全局禁止双指缩放: + * - iOS Safari: 拦截 gesturestart/gesturechange/gestureend; + * - 触屏浏览器: 双触点 touchmove 时阻止默认缩放手势; + * - 桌面触控板: Ctrl + wheel 缩放时阻止默认行为。 + */ +function installPinchZoomGuard(): void { + const options: AddEventListenerOptions = { passive: false }; + + const preventDefault = (event: Event): void => { + event.preventDefault(); + }; + + const onTouchMove = (event: TouchEvent): void => { + if (event.touches.length > 1) { + event.preventDefault(); + } + }; + + const onWheel = (event: WheelEvent): void => { + if (event.ctrlKey) { + event.preventDefault(); + } + }; + + document.addEventListener("gesturestart", preventDefault, options); + document.addEventListener("gesturechange", preventDefault, options); + document.addEventListener("gestureend", preventDefault, options); + document.addEventListener("touchmove", onTouchMove, options); + document.addEventListener("wheel", onWheel, options); +} + +/** + * 全局禁止双击放大: + * - 移动端:拦截短时间内连续 touchend(双击手势); + * - 桌面端:拦截 dblclick 默认缩放行为。 + */ +function installDoubleTapZoomGuard(): void { + const options: AddEventListenerOptions = { passive: false }; + let lastTouchEndAt = 0; + const DOUBLE_TAP_WINDOW_MS = 320; + + document.addEventListener( + "touchend", + (event: TouchEvent): void => { + const now = Date.now(); + if (now - lastTouchEndAt <= DOUBLE_TAP_WINDOW_MS) { + event.preventDefault(); + } + lastTouchEndAt = now; + }, + options + ); + + document.addEventListener( + "dblclick", + (event: MouseEvent): void => { + event.preventDefault(); + }, + options + ); +} + +installPinchZoomGuard(); +installDoubleTapZoomGuard(); + +const app = createApp(App); +const pinia = createPinia(); +const router = createRouter({ + history: createWebHistory(), + routes +}); + +installDynamicImportRecovery(router); + +app.use(pinia); +app.use(router); + +app.mount("#app"); diff --git a/apps/web/src/routes.test.ts b/apps/web/src/routes.test.ts new file mode 100644 index 0000000..739cc3c --- /dev/null +++ b/apps/web/src/routes.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from "vitest"; +import { routes } from "./routes"; + +describe("routes", () => { + it("包含关于页路由", () => { + expect(routes.some((route) => route.path === "/about/:section?")).toBe(true); + }); +}); diff --git a/apps/web/src/routes.ts b/apps/web/src/routes.ts new file mode 100644 index 0000000..53b4c29 --- /dev/null +++ b/apps/web/src/routes.ts @@ -0,0 +1,46 @@ +import type { RouteRecordRaw } from "vue-router"; + +const pluginRuntimeEnabled = import.meta.env.VITE_ENABLE_PLUGIN_RUNTIME !== "false"; + +export const routes: RouteRecordRaw[] = [ + { + path: "/", + redirect: "/connect" + }, + { + path: "/connect", + component: () => import("./views/ConnectView.vue") + }, + { + path: "/server/:id/settings", + component: () => import("./views/ServerSettingsView.vue") + }, + { + path: "/terminal", + component: () => import("./views/TerminalView.vue") + }, + { + path: "/logs", + component: () => import("./views/LogsView.vue") + }, + { + path: "/records", + component: () => import("./views/RecordsView.vue") + }, + { + path: "/settings", + component: () => import("./views/SettingsView.vue") + }, + { + path: "/about/:section?", + component: () => import("./views/AboutView.vue") + }, + ...(pluginRuntimeEnabled + ? [ + { + path: "/plugins", + component: () => import("./views/PluginsView.vue") + } + ] + : []) +]; diff --git a/apps/web/src/services/security/credentialVault.ts b/apps/web/src/services/security/credentialVault.ts new file mode 100644 index 0000000..01134bd --- /dev/null +++ b/apps/web/src/services/security/credentialVault.ts @@ -0,0 +1,94 @@ +import type { EncryptedCredentialPayload } from "@/types/app"; +import { getSettings } from "@/services/storage/db"; + +const SESSION_KEY_STORAGE = "remoteconn_crypto_key_session_v2"; +const PERSIST_KEY_STORAGE = "remoteconn_crypto_key_persist_v2"; +const LEGACY_SESSION_KEY_STORAGE = "remoteconn_crypto_key_v1"; + +/** + * Web 端无法达到系统 Keychain 等级,这里采用会话密钥 + AES-GCM 做“受限存储”。 + * 重点是避免明文直接落盘,并在 UI 中持续提示风险。 + */ +async function getOrCreateSessionKey(): Promise { + const remember = await shouldRememberCredentialKey(); + const encoded = readEncodedKey(); + + if (encoded) { + // 统一迁移到新 key 名,并按策略决定是否持久化。 + sessionStorage.setItem(SESSION_KEY_STORAGE, encoded); + if (remember) { + localStorage.setItem(PERSIST_KEY_STORAGE, encoded); + } else { + localStorage.removeItem(PERSIST_KEY_STORAGE); + } + + const raw = Uint8Array.from(atob(encoded), (s) => s.charCodeAt(0)); + return await crypto.subtle.importKey("raw", raw, "AES-GCM", true, ["encrypt", "decrypt"]); + } + + const raw = crypto.getRandomValues(new Uint8Array(32)); + const nextEncoded = btoa(String.fromCharCode(...raw)); + sessionStorage.setItem(SESSION_KEY_STORAGE, nextEncoded); + if (remember) { + localStorage.setItem(PERSIST_KEY_STORAGE, nextEncoded); + } + return await crypto.subtle.importKey("raw", raw, "AES-GCM", true, ["encrypt", "decrypt"]); +} + +/** + * 读取当前凭据密钥保存策略:remember 时允许跨刷新/重开保留密钥。 + * 若读取设置失败,默认走 remember,避免凭据“看似丢失”。 + */ +async function shouldRememberCredentialKey(): Promise { + try { + const settings = await getSettings(); + return (settings?.credentialMemoryPolicy ?? "remember") === "remember"; + } catch { + return true; + } +} + +function readEncodedKey(): string | null { + return ( + sessionStorage.getItem(SESSION_KEY_STORAGE) ?? + sessionStorage.getItem(LEGACY_SESSION_KEY_STORAGE) ?? + localStorage.getItem(PERSIST_KEY_STORAGE) + ); +} + +function encodeBase64(source: Uint8Array): string { + return btoa(String.fromCharCode(...source)); +} + +function decodeBase64ToArrayBuffer(source: string): ArrayBuffer { + const bytes = Uint8Array.from(atob(source), (s) => s.charCodeAt(0)); + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); +} + +export async function encryptCredential(refId: string, value: unknown): Promise { + const key = await getOrCreateSessionKey(); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const payload = new TextEncoder().encode(JSON.stringify(value)); + + const encrypted = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, payload)); + + const now = new Date().toISOString(); + return { + id: `cred-${crypto.randomUUID()}`, + refId, + encrypted: encodeBase64(encrypted), + iv: encodeBase64(iv), + createdAt: now, + updatedAt: now + }; +} + +export async function decryptCredential(payload: EncryptedCredentialPayload): Promise { + const key = await getOrCreateSessionKey(); + const decrypted = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: decodeBase64ToArrayBuffer(payload.iv) }, + key, + decodeBase64ToArrayBuffer(payload.encrypted) + ); + return JSON.parse(new TextDecoder().decode(new Uint8Array(decrypted))) as T; +} diff --git a/apps/web/src/services/sessionEventBus.ts b/apps/web/src/services/sessionEventBus.ts new file mode 100644 index 0000000..90e65dd --- /dev/null +++ b/apps/web/src/services/sessionEventBus.ts @@ -0,0 +1,21 @@ +type EventName = "connected" | "disconnected" | "stdout" | "stderr" | "latency"; + +type Handler = (payload: unknown) => void; + +const listeners = new Map>(); + +export function onSessionEvent(eventName: EventName, handler: Handler): () => void { + if (!listeners.has(eventName)) { + listeners.set(eventName, new Set()); + } + listeners.get(eventName)!.add(handler); + return () => listeners.get(eventName)?.delete(handler); +} + +export function emitSessionEvent(eventName: EventName, payload: unknown): void { + const set = listeners.get(eventName); + if (!set) return; + for (const handler of set) { + handler(payload); + } +} diff --git a/apps/web/src/services/storage/db.ts b/apps/web/src/services/storage/db.ts new file mode 100644 index 0000000..58603ae --- /dev/null +++ b/apps/web/src/services/storage/db.ts @@ -0,0 +1,119 @@ +import Dexie, { type EntityTable } from "dexie"; +import type { CredentialRef, ServerProfile, SessionLog } from "@remoteconn/shared"; +import type { EncryptedCredentialPayload, GlobalSettings, VoiceRecord } from "@/types/app"; +import type { PluginPackage, PluginRecord } from "@remoteconn/plugin-runtime"; + +interface KnownHostEntity { + key: string; + fingerprint: string; + updatedAt: string; +} + +interface SettingEntity { + key: string; + value: GlobalSettings; +} + +interface PluginDataEntity { + key: string; + value: unknown; +} + +class RemoteConnDb extends Dexie { + public servers!: EntityTable; + public credentialRefs!: EntityTable; + public credentials!: EntityTable; + public sessionLogs!: EntityTable; + public knownHosts!: EntityTable; + public settings!: EntityTable; + public pluginPackages!: EntityTable; + public pluginRecords!: EntityTable; + public pluginData!: EntityTable; + public voiceRecords!: EntityTable; + + public constructor() { + super("remoteconn_db"); + + this.version(2).stores({ + servers: "id, name, host, lastConnectedAt", + credentialRefs: "id, type, updatedAt", + credentials: "id, refId, updatedAt", + sessionLogs: "sessionId, serverId, startAt, status", + knownHosts: "key, updatedAt", + settings: "key", + pluginPackages: "id", + pluginRecords: "id, status", + pluginData: "key" + }); + + this.version(3).stores({ + servers: "id, name, host, lastConnectedAt", + credentialRefs: "id, type, updatedAt", + credentials: "id, refId, updatedAt", + sessionLogs: "sessionId, serverId, startAt, status", + knownHosts: "key, updatedAt", + settings: "key", + pluginPackages: "id", + pluginRecords: "id, status", + pluginData: "key", + voiceRecords: "id, createdAt, serverId" + }); + + this.version(4) + .stores({ + servers: "id, name, host, lastConnectedAt", + credentialRefs: "id, type, updatedAt", + credentials: "id, refId, updatedAt", + sessionLogs: "sessionId, serverId, startAt, status", + knownHosts: "key, updatedAt", + settings: "key", + pluginPackages: "id", + pluginRecords: "id, status", + pluginData: "key", + voiceRecords: "id, createdAt, updatedAt, serverId, category, contextLabel" + }) + .upgrade(async (tx) => { + await tx + .table("voiceRecords") + .toCollection() + .modify((row: VoiceRecord & Partial>) => { + row.updatedAt = String(row.updatedAt || row.createdAt || new Date().toISOString()); + row.category = String(row.category || "未分类"); + row.contextLabel = String(row.contextLabel || ""); + }); + }); + } +} + +export const db = new RemoteConnDb(); +async function ensureDbOpen(): Promise { + if (!db.isOpen()) { + await db.open(); + } +} + +export async function getSettings(): Promise { + await ensureDbOpen(); + const row = await db.settings.get("global"); + return row?.value ?? null; +} + +export async function setSettings(value: GlobalSettings): Promise { + await ensureDbOpen(); + await db.settings.put({ key: "global", value }); +} + +export async function getKnownHosts(): Promise> { + await ensureDbOpen(); + const rows = await db.knownHosts.toArray(); + return Object.fromEntries(rows.map((row) => [row.key, row.fingerprint])); +} + +export async function upsertKnownHost(key: string, fingerprint: string): Promise { + await ensureDbOpen(); + await db.knownHosts.put({ + key, + fingerprint, + updatedAt: new Date().toISOString() + }); +} diff --git a/apps/web/src/services/storage/pluginFsAdapter.ts b/apps/web/src/services/storage/pluginFsAdapter.ts new file mode 100644 index 0000000..1e9502b --- /dev/null +++ b/apps/web/src/services/storage/pluginFsAdapter.ts @@ -0,0 +1,43 @@ +import type { PluginFsAdapter, PluginPackage } from "@remoteconn/plugin-runtime"; +import { db } from "./db"; + +/** + * Web 端插件存储适配: + * - 插件包与记录保存在 IndexedDB + * - 提供与插件运行时一致的读写接口 + */ +export class WebPluginFsAdapter implements PluginFsAdapter { + public async listPackages(): Promise { + const rows = await db.pluginPackages.toArray(); + return rows.map(({ id: _id, ...pkg }) => pkg); + } + + public async getPackage(pluginId: string): Promise { + const row = await db.pluginPackages.get(pluginId); + if (!row) { + return null; + } + const { id: _id, ...pkg } = row; + return pkg; + } + + public async upsertPackage(pluginPackage: PluginPackage): Promise { + await db.pluginPackages.put({ id: pluginPackage.manifest.id, ...pluginPackage }); + } + + public async removePackage(pluginId: string): Promise { + await db.pluginPackages.delete(pluginId); + } + + public async readStore(key: string, fallback: T): Promise { + const row = await db.pluginData.get(key); + if (!row) { + return fallback; + } + return row.value as T; + } + + public async writeStore(key: string, value: T): Promise { + await db.pluginData.put({ key, value }); + } +} diff --git a/apps/web/src/services/transport/factory.ts b/apps/web/src/services/transport/factory.ts new file mode 100644 index 0000000..096e89c --- /dev/null +++ b/apps/web/src/services/transport/factory.ts @@ -0,0 +1,16 @@ +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/apps/web/src/services/transport/gatewayTransport.ts b/apps/web/src/services/transport/gatewayTransport.ts new file mode 100644 index 0000000..1b01996 --- /dev/null +++ b/apps/web/src/services/transport/gatewayTransport.ts @@ -0,0 +1,341 @@ +import type { GatewayFrame, SessionState, StdinMeta } from "@remoteconn/shared"; +import type { ConnectParams, TerminalTransport, TransportEvent } from "./terminalTransport"; + +/** + * 网关传输实现:Web/小程序共用。 + */ +export class GatewayTransport implements TerminalTransport { + 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 state: SessionState = "idle"; + + public constructor( + private readonly gatewayUrl: string, + private readonly token: string + ) {} + + public async connect(params: ConnectParams): Promise { + if (this.socket && this.socket.readyState === WebSocket.OPEN) { + throw new Error("会话已连接"); + } + + this.state = "connecting"; + + this.socket = await new Promise((resolve, reject) => { + const endpoints = this.buildEndpoints(); + const reasons: string[] = []; + let index = 0; + const candidateHint = `候选地址: ${endpoints.join(", ")}`; + + const tryConnect = (): void => { + const endpoint = endpoints[index]; + if (!endpoint) { + reject(new Error(`无法连接网关: ${reasons.join(" | ") || "无可用网关地址"} | ${candidateHint}`)); + return; + } + let settled = false; + let socket: WebSocket; + let timeoutTimer: number | null = null; + + try { + socket = new WebSocket(endpoint); + } catch { + 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; + settled = true; + clearTimer(); + reasons.push(`${reason}: ${endpoint}`); + try { + socket.close(); + } catch { + // 忽略关闭阶段异常,继续下一个候选地址。 + } + + if (index < endpoints.length - 1) { + index += 1; + tryConnect(); + return; + } + + reject(new Error(`无法连接网关: ${reasons.join(" | ")} | ${candidateHint}`)); + }; + + socket.onopen = () => { + if (settled) return; + settled = true; + clearTimer(); + resolve(socket); + }; + socket.onerror = () => fail("网络或协议错误"); + socket.onclose = (event) => { + if (!settled) { + fail(`连接关闭 code=${event.code}`); + } + }; + }; + + tryConnect(); + }); + + this.socket.onmessage = (event) => { + const frame = JSON.parse(event.data as string) as GatewayFrame; + this.handleFrame(frame); + }; + + this.socket.onclose = () => { + this.stopHeartbeat(); + this.state = "disconnected"; + this.emit({ type: "disconnect", reason: "ws_closed" }); + }; + + this.socket.onerror = () => { + this.stopHeartbeat(); + this.state = "error"; + this.emit({ type: "error", code: "WS_ERROR", message: "WebSocket 异常" }); + }; + + const initFrame: GatewayFrame = { + type: "init", + payload: { + host: params.host, + port: params.port, + username: params.username, + ...(params.clientSessionKey ? { clientSessionKey: params.clientSessionKey } : {}), + credential: params.credential, + ...(params.jumpHost ? { jumpHost: params.jumpHost } : {}), + knownHostFingerprint: params.knownHostFingerprint, + pty: { cols: params.cols, rows: params.rows } + } + }; + + this.sendRaw(initFrame); + this.startHeartbeat(); + this.state = "auth_pending"; + } + + public async send(data: string, meta?: StdinMeta): Promise { + this.sendRaw({ + type: "stdin", + payload: { + data, + ...(meta ? { meta } : {}) + } + }); + } + + public async resize(cols: number, rows: number): Promise { + this.sendRaw({ type: "resize", payload: { cols, rows } }); + } + + public async disconnect(reason = "manual"): Promise { + this.stopHeartbeat(); + if (this.socket && this.socket.readyState === WebSocket.OPEN) { + this.sendRaw({ type: "control", payload: { action: "disconnect", reason } }); + this.socket.close(); + } + this.socket = null; + 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 sendRaw(frame: GatewayFrame): void { + if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { + throw new Error("网关连接未建立"); + } + this.socket.send(JSON.stringify(frame)); + } + + private handleFrame(frame: GatewayFrame): void { + if (frame.type === "stdout") { + this.state = "connected"; + this.emit({ type: "stdout", data: frame.payload.data }); + return; + } + + if (frame.type === "stderr") { + this.emit({ type: "stderr", data: frame.payload.data }); + return; + } + + if (frame.type === "error") { + this.state = "error"; + this.emit({ type: "error", code: frame.payload.code, message: frame.payload.message }); + return; + } + + if (frame.type === "control") { + if (frame.payload.action === "ping") { + this.sendRaw({ type: "control", payload: { action: "pong" } }); + return; + } + + if (frame.payload.action === "pong") { + if (this.pingAt > 0) { + this.emit({ type: "latency", data: Date.now() - this.pingAt }); + } + return; + } + + if (frame.payload.action === "connected") { + this.state = "connected"; + this.emit({ + type: "connected", + fingerprint: frame.payload.fingerprint, + fingerprintHostPort: frame.payload.fingerprintHostPort, + resumed: frame.payload.resumed === true + }); + return; + } + + if (frame.payload.action === "disconnect") { + this.state = "disconnected"; + this.stopHeartbeat(); + this.emit({ type: "disconnect", reason: frame.payload.reason ?? "unknown" }); + } + } + } + + private emit(event: TransportEvent): void { + for (const listener of this.listeners) { + listener(event); + } + } + + private startHeartbeat(): void { + this.stopHeartbeat(); + this.heartbeatTimer = window.setInterval(() => { + if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { + return; + } + this.pingAt = Date.now(); + this.sendRaw({ type: "control", payload: { action: "ping" } }); + }, 10000); + } + + private stopHeartbeat(): void { + if (this.heartbeatTimer) { + window.clearInterval(this.heartbeatTimer); + this.heartbeatTimer = null; + } + } + + /** + * 统一网关地址构造(含容错候选): + * 1) 自动将 http/https 转换为 ws/wss; + * 2) 页面非本机访问时,避免把 localhost 误连到客户端本机; + * 3) https 页面下,补充 wss 与去端口候选,适配反向代理场景; + * 4) 统一补全 /ws/terminal?token=... + */ + private buildEndpoints(): string[] { + const pageIsHttps = window.location.protocol === "https:"; + const pageHost = window.location.hostname; + const pageProtocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const rawInput = this.gatewayUrl.trim(); + const fallback = `${pageProtocol}//${pageHost}`; + const input = rawInput.length > 0 ? rawInput : fallback; + const candidates: string[] = []; + const pushCandidate = (next: URL): void => { + if (pageIsHttps && next.protocol === "ws:") { + return; + } + candidates.push(finalizeEndpoint(next)); + }; + + let url: URL; + try { + const maybeUrl = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(input) ? input : `${pageProtocol}//${input}`; + url = new URL(maybeUrl); + } catch { + url = new URL(fallback); + } + + if (url.protocol === "http:") url.protocol = "ws:"; + if (url.protocol === "https:") url.protocol = "wss:"; + + const localHosts = new Set(["localhost", "127.0.0.1", "::1"]); + const pageIsLocal = localHosts.has(pageHost); + const targetIsLocal = localHosts.has(url.hostname); + if (!pageIsLocal && targetIsLocal) { + url.hostname = pageHost; + } + + 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(); + }; + + // 1) 优先使用用户配置原始地址。 + pushCandidate(url); + + // 2) 补充同主机不同协议候选(ws <-> wss)。 + // HTTPS 页面禁止 ws://(混合内容会被浏览器直接拦截)。 + if (!pageIsHttps && url.protocol === "ws:") { + const tlsUrl = new URL(url.toString()); + tlsUrl.protocol = "wss:"; + pushCandidate(tlsUrl); + } else if (url.protocol === "wss:") { + const plainUrl = new URL(url.toString()); + if (!pageIsHttps) { + plainUrl.protocol = "ws:"; + pushCandidate(plainUrl); + } + } + + // 3) 远端主机时,始终补充“去端口走反向代理(80/443)”候选。 + // 适配公网仅开放 443、Nginx 反代到内网端口的部署。 + 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:") { + if (!pageIsHttps) { + const noPortPlain = new URL(noPort.toString()); + noPortPlain.protocol = "ws:"; + pushCandidate(noPortPlain); + } + } + } + + return [...new Set(candidates)]; + } +} diff --git a/apps/web/src/services/transport/iosNativeTransport.ts b/apps/web/src/services/transport/iosNativeTransport.ts new file mode 100644 index 0000000..85e55c9 --- /dev/null +++ b/apps/web/src/services/transport/iosNativeTransport.ts @@ -0,0 +1,170 @@ +import type { SessionState, StdinMeta } from "@remoteconn/shared"; +import type { ConnectParams, TerminalTransport, TransportEvent } from "./terminalTransport"; + +declare global { + interface Window { + Capacitor?: { + Plugins?: { + RemoteConnSSH?: { + connect(options: ConnectParams): 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; +} + +/** + * 将 ConnectParams 规整为仅包含 JSON 原始类型的对象。 + * 目的:Capacitor Bridge 在 iOS 侧会对参数做克隆/序列化,`undefined` 或代理对象可能触发 DataCloneError。 + */ +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) } : {}) + } + }; +} + +/** + * iOS 原生 SSH 传输适配。 + */ +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: (payload as { data: string }).data }); + }); + this.disposers.push(() => onStdout.remove()); + + const onStderr = await plugin.addListener("stderr", (payload) => { + this.emit({ type: "stderr", data: (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: (payload as { reason: string }).reason }); + }); + this.disposers.push(() => onDisconnect.remove()); + + const onLatency = await plugin.addListener("latency", (payload) => { + this.emit({ type: "latency", data: (payload as { latency: number }).latency }); + }); + 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: error.code, message: error.message }); + }); + this.disposers.push(() => onError.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/apps/web/src/services/transport/terminalTransport.ts b/apps/web/src/services/transport/terminalTransport.ts new file mode 100644 index 0000000..b8e36e3 --- /dev/null +++ b/apps/web/src/services/transport/terminalTransport.ts @@ -0,0 +1,36 @@ +import type { ResolvedCredential, SessionState, StdinMeta } from "@remoteconn/shared"; + +export type TransportEvent = + | { type: "stdout"; data: string } + | { type: "stderr"; data: string } + | { type: "latency"; data: number } + | { type: "disconnect"; reason: string } + | { type: "connected"; fingerprint?: string; fingerprintHostPort?: string; resumed?: boolean } + | { type: "error"; code: string; message: string }; + +export interface ConnectParams { + host: string; + port: number; + username: string; + clientSessionKey?: string; + credential: ResolvedCredential; + jumpHost?: { + host: string; + port: number; + username: string; + credential: ResolvedCredential; + knownHostFingerprint?: string; + }; + knownHostFingerprint?: string; + cols: number; + rows: number; +} + +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/apps/web/src/stores/appStore.ts b/apps/web/src/stores/appStore.ts new file mode 100644 index 0000000..790cbda --- /dev/null +++ b/apps/web/src/stores/appStore.ts @@ -0,0 +1,27 @@ +import { defineStore } from "pinia"; +import { ref } from "vue"; +import type { AppToast } from "@/types/app"; + +/** + * 全局消息中心。 + */ +export const useAppStore = defineStore("app", () => { + const toasts = ref([]); + + function notify(level: AppToast["level"], message: string): void { + const item: AppToast = { + id: crypto.randomUUID(), + 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/apps/web/src/stores/logStore.ts b/apps/web/src/stores/logStore.ts new file mode 100644 index 0000000..30635a6 --- /dev/null +++ b/apps/web/src/stores/logStore.ts @@ -0,0 +1,117 @@ +import { defineStore } from "pinia"; +import { computed, toRaw, ref } from "vue"; +import { maskHost, maskSensitive, type CommandMarker, type SessionLog } from "@remoteconn/shared"; +import { db } from "@/services/storage/db"; +import { nowIso } from "@/utils/time"; + +/** + * 会话日志存储与导出。 + */ +export const useLogStore = defineStore("log", () => { + const logs = ref([]); + const loaded = ref(false); + let bootstrapPromise: Promise | null = null; + + const latest = computed(() => [...logs.value].sort((a, b) => +new Date(b.startAt) - +new Date(a.startAt)).slice(0, 50)); + + async function ensureBootstrapped(): Promise { + if (loaded.value) return; + if (bootstrapPromise) { + await bootstrapPromise; + return; + } + bootstrapPromise = (async () => { + logs.value = await db.sessionLogs.toArray(); + loaded.value = true; + })(); + + try { + await bootstrapPromise; + } finally { + bootstrapPromise = null; + } + } + + async function bootstrap(): Promise { + await ensureBootstrapped(); + } + + async function startLog(serverId: string): Promise { + const log: SessionLog = { + sessionId: `sess-${crypto.randomUUID()}`, + serverId, + startAt: nowIso(), + status: "connecting", + commandMarkers: [] + }; + logs.value.unshift(log); + await db.sessionLogs.put(log); + return log.sessionId; + } + + /** + * Dexie/IndexedDB 使用结构化克隆写入数据,Vue 响应式代理对象会触发 DataCloneError。 + * 这里统一做实体快照,确保入库对象仅包含可序列化的普通 JSON 数据。 + */ + function toSessionLogEntity(log: SessionLog): SessionLog { + const raw = toRaw(log); + return { + ...raw, + commandMarkers: raw.commandMarkers.map((marker) => ({ ...marker })) + }; + } + + async function markStatus(sessionId: string, status: SessionLog["status"], error?: string): Promise { + const target = logs.value.find((item) => item.sessionId === sessionId); + if (!target) return; + target.status = status; + if (status === "disconnected" || status === "error") { + target.endAt = nowIso(); + } + if (error) { + target.error = error; + } + await db.sessionLogs.put(toSessionLogEntity(target)); + } + + async function addMarker(sessionId: string, marker: Omit): Promise { + const target = logs.value.find((item) => item.sessionId === sessionId); + if (!target) return; + target.commandMarkers.push({ ...marker, at: nowIso() }); + await db.sessionLogs.put(toSessionLogEntity(target)); + } + + function exportLogs(mask = true): string { + const rows = logs.value.map((log) => { + const commands = log.commandMarkers + .map((marker) => { + const cmd = mask ? maskSensitive(marker.command) : marker.command; + return ` - [${marker.at}] ${cmd} => code:${marker.code}`; + }) + .join("\n"); + return [ + `## ${log.sessionId} [${log.status}]`, + `- server: ${log.serverId}`, + `- start: ${log.startAt}`, + `- end: ${log.endAt ?? "--"}`, + `- error: ${mask ? maskSensitive(log.error ?? "") : log.error ?? ""}`, + `- host: ${mask ? maskHost(log.serverId) : log.serverId}`, + "- commands:", + commands || " - 无" + ].join("\n"); + }); + + return [`# RemoteConn Session Export ${nowIso()}`, "", ...rows].join("\n\n"); + } + + return { + logs, + latest, + ensureBootstrapped, + bootstrap, + startLog, + markStatus, + addMarker, + exportLogs + }; +}); diff --git a/apps/web/src/stores/pluginStore.ts b/apps/web/src/stores/pluginStore.ts new file mode 100644 index 0000000..f270678 --- /dev/null +++ b/apps/web/src/stores/pluginStore.ts @@ -0,0 +1,189 @@ +import { defineStore } from "pinia"; +import { computed, ref } from "vue"; +import { PluginManager, type PluginPackage } from "@remoteconn/plugin-runtime"; +import { WebPluginFsAdapter } from "@/services/storage/pluginFsAdapter"; +import { onSessionEvent } from "@/services/sessionEventBus"; +import { useSessionStore } from "./sessionStore"; +import { useAppStore } from "./appStore"; + +/** + * 插件运行时管理。 + */ +export const usePluginStore = defineStore("plugin", () => { + const runtimeLogs = ref([]); + const initialized = ref(false); + let bootstrapPromise: Promise | null = null; + + const fsAdapter = new WebPluginFsAdapter(); + const eventUnsubscribers: Array<() => void> = []; + + const manager = new PluginManager(fsAdapter, { + getAppMeta() { + return { version: "2.4.0", platform: "web" as const }; + }, + session: { + async send(input) { + const sessionStore = useSessionStore(); + await sessionStore.sendCommand(input, "plugin", "manual"); + }, + on(eventName, handler) { + return onSessionEvent(eventName, handler); + } + }, + showNotice(message, level) { + const appStore = useAppStore(); + appStore.notify(level, message); + } + }, { + appVersion: "2.4.0", + mountStyle(pluginId, css) { + const style = document.createElement("style"); + style.dataset.pluginId = pluginId; + style.textContent = css; + document.head.append(style); + return () => style.remove(); + }, + logger(level, pluginId, message) { + runtimeLogs.value.unshift(`[${new Date().toLocaleTimeString("zh-CN", { hour12: false })}] [${level}] [${pluginId}] ${message}`); + if (runtimeLogs.value.length > 300) { + runtimeLogs.value.splice(300); + } + } + }); + + const records = computed(() => manager.listRecords()); + + const commands = computed(() => { + const session = useSessionStore(); + return manager.listCommands(session.connected ? "connected" : "disconnected"); + }); + + async function ensureBootstrapped(): Promise { + if (initialized.value) return; + if (bootstrapPromise) { + await bootstrapPromise; + return; + } + + bootstrapPromise = (async () => { + + await manager.bootstrap(); + await ensureSamplePlugin(); + + eventUnsubscribers.push( + onSessionEvent("connected", () => { + // 保持 computed 触发 + runtimeLogs.value = [...runtimeLogs.value]; + }) + ); + + initialized.value = true; + })(); + + try { + await bootstrapPromise; + } finally { + bootstrapPromise = null; + } + } + + async function bootstrap(): Promise { + await ensureBootstrapped(); + } + + async function ensureSamplePlugin(): Promise { + const packages = await fsAdapter.listPackages(); + if (packages.length > 0) return; + + await importPackages([ + { + manifest: { + id: "codex-shortcuts", + name: "Codex Shortcuts", + version: "0.1.0", + minAppVersion: "0.1.0", + description: "提供常用 Codex 快捷命令", + entry: "main.js", + style: "styles.css", + permissions: ["commands.register", "session.write", "ui.notice"] + }, + mainJs: ` +module.exports = { + onload(ctx) { + ctx.commands.register({ + id: "codex-doctor", + title: "Codex Doctor", + when: "connected", + async handler() { + await ctx.session.send("codex --doctor"); + } + }); + ctx.ui.showNotice("插件 codex-shortcuts 已加载", "info"); + } +}; + `.trim(), + stylesCss: `.plugin-chip[data-plugin-id="codex-shortcuts"] { border-color: rgba(95,228,255,0.7); }` + } + ]); + } + + async function importPackages(payload: PluginPackage[]): Promise { + for (const pkg of payload) { + await manager.installPackage(pkg); + } + } + + async function importJson(raw: string): Promise { + const parsed = JSON.parse(raw) as PluginPackage | PluginPackage[]; + const items = Array.isArray(parsed) ? parsed : [parsed]; + await importPackages(items); + } + + async function exportJson(): Promise { + const packages = await fsAdapter.listPackages(); + return JSON.stringify(packages, null, 2); + } + + async function enable(pluginId: string): Promise { + await manager.enable(pluginId); + } + + async function disable(pluginId: string): Promise { + await manager.disable(pluginId); + } + + async function reload(pluginId: string): Promise { + await manager.reload(pluginId); + } + + async function remove(pluginId: string): Promise { + await manager.remove(pluginId); + } + + async function runCommand(commandId: string): Promise { + await manager.runCommand(commandId); + } + + function dispose(): void { + for (const off of eventUnsubscribers) { + off(); + } + eventUnsubscribers.length = 0; + } + + return { + runtimeLogs, + records, + commands, + ensureBootstrapped, + bootstrap, + importJson, + exportJson, + enable, + disable, + reload, + remove, + runCommand, + dispose + }; +}); diff --git a/apps/web/src/stores/serverStore.test.ts b/apps/web/src/stores/serverStore.test.ts new file mode 100644 index 0000000..9ee2dd7 --- /dev/null +++ b/apps/web/src/stores/serverStore.test.ts @@ -0,0 +1,164 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createPinia, setActivePinia } from "pinia"; +import type { ServerProfile } from "@/types/app"; + +const { dbState, dbMock } = vi.hoisted(() => { + const state = { + servers: [] as ServerProfile[] + }; + + const cloneServer = (server: ServerProfile): ServerProfile => ({ + ...server, + projectPresets: [...server.projectPresets], + tags: [...server.tags], + jumpHost: server.jumpHost ? { ...server.jumpHost } : undefined + }); + + const upsertServer = (server: ServerProfile): void => { + const index = state.servers.findIndex((item) => item.id === server.id); + if (index >= 0) { + state.servers[index] = cloneServer(server); + } else { + state.servers.push(cloneServer(server)); + } + }; + + const db = { + servers: { + toArray: vi.fn(async () => state.servers.map((item) => cloneServer(item))), + add: vi.fn(async (server: ServerProfile) => { + state.servers.push(cloneServer(server)); + }), + put: vi.fn(async (server: ServerProfile) => { + upsertServer(server); + }), + bulkPut: vi.fn(async (servers: ServerProfile[]) => { + servers.forEach((server) => upsertServer(server)); + }), + delete: vi.fn(async (serverId: string) => { + state.servers = state.servers.filter((item) => item.id !== serverId); + }) + }, + credentialRefs: { + toArray: vi.fn(async () => []) + }, + credentials: { + where: vi.fn(() => ({ + equals: vi.fn(() => ({ + first: vi.fn(async () => null), + delete: vi.fn(async () => {}) + })) + })), + put: vi.fn(async () => {}) + } + }; + + return { + dbState: state, + dbMock: db + }; +}); + +vi.mock("@/services/storage/db", () => ({ + db: dbMock +})); + +vi.mock("@/services/security/credentialVault", () => ({ + decryptCredential: vi.fn(async () => ({})), + encryptCredential: vi.fn(async () => ({ + id: "enc-1", + refId: "enc-1", + encrypted: "", + iv: "", + createdAt: "", + updatedAt: "" + })) +})); + +import { useServerStore } from "./serverStore"; + +function makeServer(id: string, sortOrder?: number): ServerProfile { + return { + id, + name: id, + host: "127.0.0.1", + port: 22, + username: "root", + authType: "password", + projectPath: "~", + projectPresets: [], + tags: [], + timeoutSeconds: 20, + heartbeatSeconds: 15, + transportMode: "gateway", + jumpHost: { + enabled: false, + host: "", + port: 22, + username: "", + authType: "password" + }, + ...(sortOrder !== undefined ? { sortOrder } : {}) + }; +} + +describe("serverStore", () => { + beforeEach(() => { + setActivePinia(createPinia()); + dbState.servers = []; + dbMock.servers.toArray.mockClear(); + dbMock.servers.add.mockClear(); + dbMock.servers.put.mockClear(); + dbMock.servers.bulkPut.mockClear(); + dbMock.servers.delete.mockClear(); + dbMock.credentialRefs.toArray.mockClear(); + }); + + it("启动时按 sortOrder 恢复顺序并回填连续排序值", async () => { + dbState.servers = [makeServer("srv-b", 2), makeServer("srv-a"), makeServer("srv-c", 0)]; + + const store = useServerStore(); + await store.ensureBootstrapped(); + + expect(store.servers.map((item) => item.id)).toEqual(["srv-c", "srv-b", "srv-a"]); + expect(dbMock.servers.bulkPut).toHaveBeenCalledTimes(1); + expect(store.servers.map((item) => item.sortOrder)).toEqual([0, 1, 2]); + }); + + it("支持服务器上下移动并持久化顺序", async () => { + dbState.servers = [makeServer("srv-1", 0), makeServer("srv-2", 1), makeServer("srv-3", 2)]; + + const store = useServerStore(); + await store.ensureBootstrapped(); + expect(dbMock.servers.bulkPut).toHaveBeenCalledTimes(0); + + const movedDown = await store.moveServerDown("srv-1"); + expect(movedDown).toBe(true); + expect(store.servers.map((item) => item.id)).toEqual(["srv-2", "srv-1", "srv-3"]); + expect(store.servers.map((item) => item.sortOrder)).toEqual([0, 1, 2]); + + const movedUp = await store.moveServerUp("srv-1"); + expect(movedUp).toBe(true); + expect(store.servers.map((item) => item.id)).toEqual(["srv-1", "srv-2", "srv-3"]); + + const topBoundary = await store.moveServerUp("srv-1"); + const bottomBoundary = await store.moveServerDown("srv-3"); + expect(topBoundary).toBe(false); + expect(bottomBoundary).toBe(false); + }); + + it("支持按指定 id 顺序重排", async () => { + dbState.servers = [makeServer("srv-1", 0), makeServer("srv-2", 1), makeServer("srv-3", 2)]; + + const store = useServerStore(); + await store.ensureBootstrapped(); + + const changed = await store.applyServerOrder(["srv-3", "srv-1", "srv-2"]); + expect(changed).toBe(true); + expect(store.servers.map((item) => item.id)).toEqual(["srv-3", "srv-1", "srv-2"]); + expect(store.servers.map((item) => item.sortOrder)).toEqual([0, 1, 2]); + + const unchanged = await store.applyServerOrder(["srv-3", "srv-1", "srv-2"]); + expect(unchanged).toBe(false); + }); +}); diff --git a/apps/web/src/stores/serverStore.ts b/apps/web/src/stores/serverStore.ts new file mode 100644 index 0000000..33b2420 --- /dev/null +++ b/apps/web/src/stores/serverStore.ts @@ -0,0 +1,471 @@ +import { defineStore } from "pinia"; +import { computed, toRaw, ref } from "vue"; +import { DEFAULT_JUMP_HOST } from "@remoteconn/shared"; +import type { CredentialRef, JumpHostProfile, ResolvedCredential, ServerProfile } from "@/types/app"; +import { db } from "@/services/storage/db"; +import { decryptCredential, encryptCredential } from "@/services/security/credentialVault"; +import { nowIso } from "@/utils/time"; + +interface ServerCredentialInput { + type: CredentialRef["type"]; + password?: string; + privateKey?: string; + passphrase?: string; + certificate?: string; +} + +interface ServerCredentialBundleInput { + target: ServerCredentialInput; + jump?: ServerCredentialInput | null; +} + +/** + * 服务器与凭据管理。 + */ +export const useServerStore = defineStore("server", () => { + const servers = ref([]); + const credentialRefs = ref([]); + const selectedServerId = ref(""); + const loaded = ref(false); + let bootstrapPromise: Promise | null = null; + + function cloneJumpHost(input?: Partial | null): JumpHostProfile { + return { + ...DEFAULT_JUMP_HOST, + ...(input ?? {}), + enabled: input?.enabled === true, + host: String(input?.host ?? "").trim(), + port: Number(input?.port ?? DEFAULT_JUMP_HOST.port) || DEFAULT_JUMP_HOST.port, + username: String(input?.username ?? "").trim(), + authType: + input?.authType === "privateKey" || input?.authType === "certificate" ? input.authType : DEFAULT_JUMP_HOST.authType + }; + } + + function normalizeCredentialInput( + value: Partial | null | undefined, + fallbackType: CredentialRef["type"] = "password" + ): ServerCredentialInput { + const type = value?.type === "privateKey" || value?.type === "certificate" || value?.type === "password" ? value.type : fallbackType; + return { + type, + password: String(value?.password ?? ""), + privateKey: String(value?.privateKey ?? ""), + passphrase: String(value?.passphrase ?? ""), + certificate: String(value?.certificate ?? "") + }; + } + + /** + * 兼容两类密文结构: + * 1. 历史版本:直接保存单份 `ServerCredentialInput`; + * 2. 新版本:保存 `{ target, jump }` 凭据包。 + */ + function normalizeCredentialBundle( + value: unknown, + fallbackType: CredentialRef["type"], + jumpAuthType: CredentialRef["type"] | null = null + ): ServerCredentialBundleInput { + const source = value && typeof value === "object" ? (value as Partial) : {}; + const hasTarget = source && typeof source === "object" && "target" in source; + const target = hasTarget + ? normalizeCredentialInput(source.target, fallbackType) + : normalizeCredentialInput(source as Partial, fallbackType); + const jumpRaw = hasTarget ? source.jump : null; + return { + target, + jump: jumpAuthType ? normalizeCredentialInput(jumpRaw, jumpAuthType) : null + }; + } + + function toResolvedCredential(input: ServerCredentialInput): ResolvedCredential { + if (input.type === "password") { + return { + type: "password", + password: input.password ?? "" + }; + } + + if (input.type === "privateKey") { + return { + type: "privateKey", + privateKey: input.privateKey ?? "", + passphrase: input.passphrase + }; + } + + return { + type: "certificate", + privateKey: input.privateKey ?? "", + passphrase: input.passphrase, + certificate: input.certificate ?? "" + }; + } + + const selectedServer = computed(() => servers.value.find((item) => item.id === selectedServerId.value)); + + /** + * 规范化排序值: + * - 非数字、NaN、负值都视为“缺失排序”; + * - 仅保留非负整数,避免浮点或异常值污染排序稳定性。 + */ + function normalizeSortOrder(value: unknown): number | null { + if (typeof value !== "number" || !Number.isFinite(value)) { + return null; + } + const normalized = Math.floor(value); + if (normalized < 0) { + return null; + } + return normalized; + } + + /** + * 按持久化排序字段恢复列表顺序: + * - 优先按 sortOrder 升序; + * - 缺失 sortOrder 的历史数据保留原始读取顺序; + * - 排序值冲突时回退到原始顺序,保证稳定排序。 + */ + function sortServersByStoredOrder(input: ServerProfile[]): ServerProfile[] { + return input + .map((server, index) => ({ + server, + index, + sortOrder: normalizeSortOrder(server.sortOrder) + })) + .sort((a, b) => { + if (a.sortOrder === null && b.sortOrder === null) { + return a.index - b.index; + } + if (a.sortOrder === null) { + return 1; + } + if (b.sortOrder === null) { + return -1; + } + if (a.sortOrder !== b.sortOrder) { + return a.sortOrder - b.sortOrder; + } + return a.index - b.index; + }) + .map((entry) => entry.server); + } + + /** + * 将当前数组顺序重写为连续 sortOrder,并回写到数据库。 + * 约束: + * - 不改变入参数组的相对顺序; + * - 所有项强制回填 sortOrder,保证刷新后顺序稳定可恢复。 + */ + async function persistServerOrder(nextServers: ServerProfile[]): Promise { + const ordered = nextServers.map((server, index) => { + const entity = toServerEntity(server); + return { + ...entity, + sortOrder: index + }; + }); + servers.value = ordered; + await db.servers.bulkPut(ordered.map((item) => toServerEntity(item))); + } + + async function ensureBootstrapped(): Promise { + if (loaded.value) return; + if (bootstrapPromise) { + await bootstrapPromise; + return; + } + + bootstrapPromise = (async () => { + const storedServers = await db.servers.toArray(); + credentialRefs.value = await db.credentialRefs.toArray(); + + if (storedServers.length === 0) { + const sample = buildDefaultServer(); + await persistServerOrder([sample]); + } else { + const sortedServers = sortServersByStoredOrder(storedServers); + const needsPersist = sortedServers.some((server, index) => { + const current = storedServers[index]; + return server.id !== current?.id || normalizeSortOrder(server.sortOrder) !== index; + }); + if (needsPersist) { + await persistServerOrder(sortedServers); + } else { + servers.value = sortedServers.map((server) => toServerEntity(server)); + } + } + + selectedServerId.value = servers.value[0]?.id ?? ""; + loaded.value = true; + })(); + + try { + await bootstrapPromise; + } finally { + bootstrapPromise = null; + } + } + + async function bootstrap(): Promise { + await ensureBootstrapped(); + } + + function buildDefaultServer(): ServerProfile { + return { + id: `srv-${crypto.randomUUID()}`, + name: "新服务器", + host: "", + port: 22, + username: "root", + authType: "password", + projectPath: "~/workspace", + projectPresets: ["~/workspace"], + tags: [], + timeoutSeconds: 20, + heartbeatSeconds: 15, + transportMode: "gateway", + jumpHost: cloneJumpHost(), + sortOrder: 0, + lastConnectedAt: "" + }; + } + + /** + * 仅创建“新服务器草稿”快照,不写入列表与数据库。 + * 用于“新增服务器先进入配置页,保存后再落库”的流程。 + */ + function createServerDraft(): ServerProfile { + return buildDefaultServer(); + } + + /** + * 将服务器对象转换为可安全写入 IndexedDB 的纯数据实体。 + * 目的:避免 Vue Proxy 透传到 Dexie 触发 DataCloneError。 + */ + function toServerEntity(server: ServerProfile): ServerProfile { + const raw = toRaw(server); + return { + ...raw, + projectPresets: [...raw.projectPresets], + tags: [...raw.tags], + jumpHost: cloneJumpHost(raw.jumpHost) + }; + } + + async function createServer(): Promise { + const sample = createServerDraft(); + await persistServerOrder([sample, ...servers.value]); + selectedServerId.value = sample.id; + } + + async function saveServer(server: ServerProfile): Promise { + const nextServers = [...servers.value]; + const index = servers.value.findIndex((item) => item.id === server.id); + if (index >= 0) { + nextServers[index] = server; + } else { + nextServers.unshift(server); + } + await persistServerOrder(nextServers); + } + + async function deleteServer(serverId: string): Promise { + const nextServers = servers.value.filter((item) => item.id !== serverId); + await db.servers.delete(serverId); + await persistServerOrder(nextServers); + if (selectedServerId.value === serverId) { + selectedServerId.value = servers.value[0]?.id ?? ""; + } + } + + /** + * 将指定服务器上移一位。 + * 返回: + * - true: 已成功移动并持久化; + * - false: 不存在或已在顶部,无需移动。 + */ + async function moveServerUp(serverId: string): Promise { + const index = servers.value.findIndex((item) => item.id === serverId); + if (index <= 0) { + return false; + } + const nextServers = [...servers.value]; + const previous = nextServers[index - 1]; + const current = nextServers[index]; + if (!previous || !current) { + return false; + } + nextServers[index - 1] = current; + nextServers[index] = previous; + await persistServerOrder(nextServers); + return true; + } + + /** + * 将指定服务器下移一位。 + * 返回: + * - true: 已成功移动并持久化; + * - false: 不存在或已在底部,无需移动。 + */ + async function moveServerDown(serverId: string): Promise { + const index = servers.value.findIndex((item) => item.id === serverId); + if (index < 0 || index >= servers.value.length - 1) { + return false; + } + const nextServers = [...servers.value]; + const current = nextServers[index]; + const next = nextServers[index + 1]; + if (!current || !next) { + return false; + } + nextServers[index] = next; + nextServers[index + 1] = current; + await persistServerOrder(nextServers); + return true; + } + + /** + * 按传入 ID 顺序重排服务器列表并持久化。 + * 规则: + * - `orderedIds` 中不存在/重复的项会被忽略; + * - 未出现在 `orderedIds` 的服务器按原顺序追加到末尾; + * - 若顺序无变化,返回 false。 + */ + async function applyServerOrder(orderedIds: string[]): Promise { + const byId = new Map(servers.value.map((server) => [server.id, server] as const)); + const seen = new Set(); + const head: ServerProfile[] = []; + + for (const id of orderedIds) { + if (!id || seen.has(id)) { + continue; + } + const matched = byId.get(id); + if (!matched) { + continue; + } + seen.add(id); + head.push(matched); + } + + const tail = servers.value.filter((server) => !seen.has(server.id)); + const nextServers = [...head, ...tail]; + + if ( + nextServers.length === servers.value.length && + nextServers.every((server, index) => server.id === servers.value[index]?.id) + ) { + return false; + } + + await persistServerOrder(nextServers); + return true; + } + + async function saveCredential( + refId: string, + payload: ServerCredentialInput, + jumpPayload?: ServerCredentialInput | null + ): Promise { + const exists = credentialRefs.value.find((item) => item.id === refId); + const now = nowIso(); + + const ref: CredentialRef = { + id: refId, + type: payload.type, + secureStoreKey: `web:credential:${refId}`, + createdAt: exists?.createdAt ?? now, + updatedAt: now + }; + + await db.credentialRefs.put(ref); + await db.credentials.where("refId").equals(refId).delete(); + const encrypted = await encryptCredential(refId, { + target: normalizeCredentialInput(payload, payload.type), + jump: jumpPayload ? normalizeCredentialInput(jumpPayload, jumpPayload.type) : null + } satisfies ServerCredentialBundleInput); + await db.credentials.put(encrypted); + + const idx = credentialRefs.value.findIndex((item) => item.id === refId); + if (idx >= 0) { + credentialRefs.value[idx] = ref; + } else { + credentialRefs.value.push(ref); + } + + return ref; + } + + async function resolveCredential(refId: string): Promise { + const bundle = await resolveCredentialBundle(refId); + return bundle.target; + } + + async function resolveCredentialBundle( + refId: string, + jumpAuthType: CredentialRef["type"] | null = null + ): Promise<{ target: ResolvedCredential; jump: ResolvedCredential | null }> { + const ref = credentialRefs.value.find((item) => item.id === refId); + if (!ref) { + throw new Error("凭据引用不存在"); + } + + const payload = await db.credentials.where("refId").equals(refId).first(); + if (!payload) { + throw new Error("未找到凭据内容"); + } + + const decrypted = await decryptCredential(payload); + const bundle = normalizeCredentialBundle(decrypted, ref.type, jumpAuthType); + return { + target: toResolvedCredential(bundle.target), + jump: bundle.jump ? toResolvedCredential(bundle.jump) : null + }; + } + + async function getCredentialInput(refId: string): Promise { + const bundle = await getCredentialBundleInput(refId); + return bundle?.target ?? null; + } + + async function getCredentialBundleInput(refId: string): Promise { + const payload = await db.credentials.where("refId").equals(refId).first(); + if (!payload) { + return null; + } + const ref = credentialRefs.value.find((item) => item.id === refId); + const fallbackType = ref?.type ?? "password"; + const decrypted = await decryptCredential(payload); + return normalizeCredentialBundle(decrypted, fallbackType, "password"); + } + + async function markConnected(serverId: string): Promise { + const target = servers.value.find((item) => item.id === serverId); + if (!target) return; + target.lastConnectedAt = nowIso(); + await db.servers.put(toServerEntity(target)); + } + + return { + servers, + credentialRefs, + selectedServerId, + selectedServer, + ensureBootstrapped, + bootstrap, + createServerDraft, + createServer, + saveServer, + deleteServer, + moveServerUp, + moveServerDown, + applyServerOrder, + saveCredential, + resolveCredential, + resolveCredentialBundle, + getCredentialInput, + getCredentialBundleInput, + markConnected + }; +}); diff --git a/apps/web/src/stores/sessionStore.test.ts b/apps/web/src/stores/sessionStore.test.ts new file mode 100644 index 0000000..ac997a8 --- /dev/null +++ b/apps/web/src/stores/sessionStore.test.ts @@ -0,0 +1,788 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createPinia, setActivePinia } from "pinia"; +import type { ServerProfile } from "@/types/app"; + +type MockTransportEvent = { + type: string; + [key: string]: unknown; +}; + +type MockResolvedCredential = + | { type: "password"; password: string } + | { type: "privateKey"; privateKey: string; passphrase?: string } + | { type: "certificate"; privateKey: string; certificate: string; passphrase?: string }; + +type MockResolvedCredentialBundle = { + target: MockResolvedCredential; + jump: MockResolvedCredential | null; +}; + +const { + settingsStoreMock, + serverStoreMock, + logStoreMock, + appStoreMock, + createTransportMock, + transportMock, + emitSessionEventMock, + listeners, + sessionStorageState +} = vi.hoisted(() => { + const listenersRef: { value: ((event: MockTransportEvent) => Promise | void) | null } = { + value: null + }; + const sessionStorageMap = new Map(); + + const transport = { + on: vi.fn((handler: (event: MockTransportEvent) => Promise | void) => { + listenersRef.value = handler; + return () => { + listenersRef.value = null; + }; + }), + connect: vi.fn(async () => {}), + send: vi.fn(async () => {}), + disconnect: vi.fn(async () => {}), + resize: vi.fn(async () => {}) + }; + + return { + settingsStoreMock: { + settings: { + autoReconnect: true, + reconnectLimit: 2, + terminalBufferMaxEntries: 5000, + terminalBufferMaxBytes: 4 * 1024 * 1024, + gatewayUrl: "ws://127.0.0.1:8787/ws/terminal", + gatewayToken: "dev-token" + }, + gatewayUrl: "ws://127.0.0.1:8787/ws/terminal", + gatewayToken: "dev-token", + knownHosts: {}, + verifyAndPersistHostFingerprint: vi.fn(async () => true) + }, + serverStoreMock: { + servers: [] as ServerProfile[], + resolveCredential: vi.fn(async () => ({ type: "password", password: "secret" })), + resolveCredentialBundle: vi.fn( + async (): Promise => ({ + target: { type: "password", password: "secret" }, + jump: null + }) + ), + markConnected: vi.fn(async () => {}) + }, + logStoreMock: { + startLog: vi.fn(async () => "session-log-1"), + markStatus: vi.fn(async () => {}), + addMarker: vi.fn(async () => {}) + }, + appStoreMock: { + notify: vi.fn() + }, + createTransportMock: vi.fn(() => transport), + transportMock: transport, + emitSessionEventMock: vi.fn(), + listeners: listenersRef, + sessionStorageState: { + map: sessionStorageMap, + clear: () => sessionStorageMap.clear(), + getItem: (key: string) => sessionStorageMap.get(key) ?? null, + setItem: (key: string, value: string) => { + sessionStorageMap.set(key, value); + } + } + }; +}); + +vi.mock("@remoteconn/shared", () => ({ + allStates: () => [ + "idle", + "connecting", + "auth_pending", + "connected", + "reconnecting", + "disconnected", + "error" + ], + buildCdCommand: (projectPath: string) => `cd ${projectPath}`, + buildCodexPlan: (options: { + projectPath: string; + sandbox: "read-only" | "workspace-write" | "danger-full-access"; + resumeLast?: boolean; + }) => [ + { + step: "cd", + command: `cd ${options.projectPath}`, + markerType: "cd" + }, + { + step: "check", + command: "command -v codex", + markerType: "check" + }, + { + step: "run", + command: options.resumeLast + ? `codex resume --last --sandbox ${options.sandbox}` + : `codex --sandbox ${options.sandbox}`, + markerType: "run" + } + ] +})); + +vi.mock("./settingsStore", () => ({ + useSettingsStore: () => settingsStoreMock +})); + +vi.mock("./serverStore", () => ({ + useServerStore: () => serverStoreMock +})); + +vi.mock("./logStore", () => ({ + useLogStore: () => logStoreMock +})); + +vi.mock("./appStore", () => ({ + useAppStore: () => appStoreMock +})); + +vi.mock("@/services/transport/factory", () => ({ + createTransport: createTransportMock +})); + +vi.mock("@/services/sessionEventBus", () => ({ + emitSessionEvent: emitSessionEventMock +})); + +vi.mock("@/utils/feedback", () => ({ + formatActionError: (_prefix: string, error: unknown) => String(error), + toFriendlyDisconnectReason: (reason: string) => reason, + toFriendlyError: (message: string) => message +})); + +import { useSessionStore } from "./sessionStore"; + +function setupWindowSessionStorage(): void { + const sessionStorage = { + getItem: (key: string) => sessionStorageState.getItem(key), + setItem: (key: string, value: string) => sessionStorageState.setItem(key, value) + }; + + const windowMock = { + sessionStorage, + setTimeout, + clearTimeout, + addEventListener: vi.fn(), + removeEventListener: vi.fn() + }; + + const documentMock = { + visibilityState: "visible", + addEventListener: vi.fn(), + removeEventListener: vi.fn() + }; + + Object.defineProperty(globalThis, "window", { + configurable: true, + value: windowMock + }); + + Object.defineProperty(globalThis, "document", { + configurable: true, + value: documentMock + }); +} + +describe("sessionStore", () => { + beforeEach(() => { + setActivePinia(createPinia()); + sessionStorageState.clear(); + listeners.value = null; + + transportMock.on.mockClear(); + transportMock.connect.mockClear(); + transportMock.send.mockClear(); + transportMock.disconnect.mockClear(); + transportMock.resize.mockClear(); + + createTransportMock.mockClear(); + emitSessionEventMock.mockClear(); + + appStoreMock.notify.mockClear(); + logStoreMock.startLog.mockClear(); + logStoreMock.markStatus.mockClear(); + serverStoreMock.resolveCredential.mockClear(); + serverStoreMock.resolveCredentialBundle.mockClear(); + serverStoreMock.markConnected.mockClear(); + settingsStoreMock.settings.autoReconnect = true; + settingsStoreMock.settings.reconnectLimit = 2; + settingsStoreMock.knownHosts = {}; + serverStoreMock.servers = []; + + setupWindowSessionStorage(); + }); + + it("启动时恢复快照并自动重连", async () => { + const server: ServerProfile = { + id: "srv-1", + name: "mini", + host: "127.0.0.1", + port: 22, + username: "gavin", + authType: "password", + projectPath: "~", + projectPresets: [], + tags: [], + timeoutSeconds: 20, + heartbeatSeconds: 15, + transportMode: "gateway" + }; + + serverStoreMock.servers = [server]; + + sessionStorageState.setItem( + "remoteconn_session_snapshot_v1", + JSON.stringify({ + version: 2, + savedAt: Date.now(), + activeConnectionKey: "srv-1::snapshot", + lines: ["restored-line"], + currentServerId: "srv-1", + reconnectServerId: "srv-1" + }) + ); + + const store = useSessionStore(); + await store.ensureBootstrapped(); + + expect(createTransportMock).toHaveBeenCalledTimes(1); + expect(transportMock.connect).toHaveBeenCalledTimes(1); + expect(store.currentServerId).toBe("srv-1"); + expect(store.lines).toContain("restored-line"); + }); + + it("刷新恢复连接不受 autoReconnect 开关影响", async () => { + const server: ServerProfile = { + id: "srv-reload", + name: "reload", + host: "127.0.0.1", + port: 22, + username: "gavin", + authType: "password", + projectPath: "~", + projectPresets: [], + tags: [], + timeoutSeconds: 20, + heartbeatSeconds: 15, + transportMode: "gateway" + }; + + serverStoreMock.servers = [server]; + settingsStoreMock.settings.autoReconnect = false; + + sessionStorageState.setItem( + "remoteconn_session_snapshot_v1", + JSON.stringify({ + version: 2, + savedAt: Date.now(), + activeConnectionKey: "srv-reload::snapshot", + lines: ["reloaded"], + currentServerId: "srv-reload", + reconnectServerId: "srv-reload" + }) + ); + + const store = useSessionStore(); + await store.ensureBootstrapped(); + + expect(transportMock.connect).toHaveBeenCalledTimes(1); + expect(store.currentServerId).toBe("srv-reload"); + }); + + it("刷新后若旧 SSH 未续上且快照记得 Codex 前台,应自动执行 codex resume --last", async () => { + const server: ServerProfile = { + id: "srv-codex-resume", + name: "codex-resume", + host: "127.0.0.1", + port: 22, + username: "gavin", + authType: "password", + projectPath: "~/workspace/remoteconn", + projectPresets: [], + tags: [], + timeoutSeconds: 20, + heartbeatSeconds: 15, + transportMode: "gateway" + }; + + serverStoreMock.servers = [server]; + sessionStorageState.setItem( + "remoteconn_session_snapshot_v1", + JSON.stringify({ + version: 2, + savedAt: Date.now(), + activeConnectionKey: "srv-codex-resume::snapshot", + lines: ["codex-running"], + currentServerId: "srv-codex-resume", + reconnectServerId: "srv-codex-resume", + activeAiProvider: "codex", + codexSandboxMode: "danger-full-access" + }) + ); + + const store = useSessionStore(); + await store.ensureBootstrapped(); + await listeners.value?.({ type: "connected" }); + + expect( + transportMock.send.mock.calls.some((args: unknown[]) => + String(args.at(0) ?? "").includes("codex resume --last --sandbox danger-full-access") + ) + ).toBe(true); + expect(appStoreMock.notify).toHaveBeenCalledWith("info", "检测到上次 Codex 会话,正在尝试恢复"); + expect(store.currentServerId).toBe("srv-codex-resume"); + }); + + it("网关已续上旧 SSH 时,不应重复执行 codex resume --last", async () => { + const server: ServerProfile = { + id: "srv-codex-resumed", + name: "codex-resumed", + host: "127.0.0.1", + port: 22, + username: "gavin", + authType: "password", + projectPath: "~/workspace/remoteconn", + projectPresets: [], + tags: [], + timeoutSeconds: 20, + heartbeatSeconds: 15, + transportMode: "gateway" + }; + + serverStoreMock.servers = [server]; + sessionStorageState.setItem( + "remoteconn_session_snapshot_v1", + JSON.stringify({ + version: 2, + savedAt: Date.now(), + activeConnectionKey: "srv-codex-resumed::snapshot", + lines: ["codex-running"], + currentServerId: "srv-codex-resumed", + reconnectServerId: "srv-codex-resumed", + activeAiProvider: "codex", + codexSandboxMode: "danger-full-access" + }) + ); + + const store = useSessionStore(); + await store.ensureBootstrapped(); + await listeners.value?.({ type: "connected", resumed: true }); + + expect( + transportMock.send.mock.calls.some((args: unknown[]) => + String(args.at(0) ?? "").includes("codex resume --last --sandbox") + ) + ).toBe(false); + expect(store.currentServerId).toBe("srv-codex-resumed"); + }); + + it("Copilot 前台态会点亮 AI 按钮,并在退出标记到达后自动解除", async () => { + const server: ServerProfile = { + id: "srv-copilot", + name: "copilot", + host: "127.0.0.1", + port: 22, + username: "gavin", + authType: "password", + projectPath: "~/workspace/remoteconn", + projectPresets: [], + tags: [], + timeoutSeconds: 20, + heartbeatSeconds: 15, + transportMode: "gateway" + }; + + serverStoreMock.servers = [server]; + + const store = useSessionStore(); + await store.connect(server); + await listeners.value?.({ type: "connected" }); + + await store.runCopilot(server.projectPath, "copilot --allow-all"); + + expect( + transportMock.send.mock.calls.some((args: unknown[]) => { + const command = String(args.at(0) ?? ""); + return command.includes("copilot --allow-all") && command.includes("ai-exit=copilot"); + }) + ).toBe(true); + expect(store.activeAiProvider).toBe("copilot"); + expect(store.isServerAiActive("srv-copilot")).toBe(true); + + await listeners.value?.({ type: "stdout", data: "\u001b]633;RemoteConn;ai-exit=copilot\u0007" }); + + expect(store.activeAiProvider).toBe(""); + expect(store.isServerAiActive("srv-copilot")).toBe(false); + }); + + it("ios-native 已完成兼容初始化后,中文输入不重复注入兼容命令", async () => { + const server: ServerProfile = { + id: "srv-2", + name: "ios", + host: "127.0.0.1", + port: 22, + username: "gavin", + authType: "password", + projectPath: "~", + projectPresets: [], + tags: [], + timeoutSeconds: 20, + heartbeatSeconds: 15, + transportMode: "ios-native" + }; + + serverStoreMock.servers = [server]; + + const store = useSessionStore(); + await store.connect(server); + + expect(listeners.value).toBeTypeOf("function"); + await listeners.value?.({ type: "connected" }); + + const shellCompatCalls = transportMock.send.mock.calls.filter((args: unknown[]) => + String(args.at(0) ?? "").includes("setopt MULTIBYTE PRINT_EIGHT_BIT") + ); + expect(shellCompatCalls).toHaveLength(1); + + await store.sendInput("中文"); + + const shellCompatCallsAfterInput = transportMock.send.mock.calls.filter((args: unknown[]) => + String(args.at(0) ?? "").includes("setopt MULTIBYTE PRINT_EIGHT_BIT") + ); + expect(shellCompatCallsAfterInput).toHaveLength(1); + expect(transportMock.send).toHaveBeenLastCalledWith("中文", undefined); + }); + + it("启用跳转主机后应先连接基础信息服务器,再跳转到目标主机", async () => { + const server: ServerProfile = { + id: "srv-jump", + name: "jump", + host: "base.example.com", + port: 22, + username: "base-user", + authType: "password", + projectPath: "~", + projectPresets: [], + tags: [], + timeoutSeconds: 20, + heartbeatSeconds: 15, + transportMode: "gateway", + jumpHost: { + enabled: true, + host: "target.example.com", + port: 2200, + username: "target-user", + authType: "privateKey" + } + }; + + serverStoreMock.servers = [server]; + settingsStoreMock.knownHosts = { + "base.example.com:22": "base-fingerprint", + "target.example.com:2200": "target-fingerprint" + }; + serverStoreMock.resolveCredentialBundle.mockResolvedValueOnce({ + target: { type: "password", password: "base-secret" }, + jump: { type: "privateKey", privateKey: "target-key" } + }); + + const store = useSessionStore(); + await store.connect(server); + + expect(transportMock.connect).toHaveBeenCalledTimes(1); + expect(transportMock.connect).toHaveBeenCalledWith( + expect.objectContaining({ + host: "target.example.com", + port: 2200, + username: "target-user", + credential: { type: "privateKey", privateKey: "target-key" }, + knownHostFingerprint: "target-fingerprint", + jumpHost: { + host: "base.example.com", + port: 22, + username: "base-user", + credential: { type: "password", password: "base-secret" }, + knownHostFingerprint: "base-fingerprint" + } + }) + ); + }); + + it("同服务器手动重连应保留输出历史;切换服务器应隔离历史", async () => { + const serverA: ServerProfile = { + id: "srv-a", + name: "A", + host: "10.0.0.1", + port: 22, + username: "gavin", + authType: "password", + projectPath: "~", + projectPresets: [], + tags: [], + timeoutSeconds: 20, + heartbeatSeconds: 15, + transportMode: "gateway" + }; + const serverB = { + ...serverA, + id: "srv-b", + name: "B", + host: "10.0.0.2" + }; + + serverStoreMock.servers = [serverA, serverB]; + + const store = useSessionStore(); + await store.connect(serverA); + await listeners.value?.({ type: "connected" }); + await listeners.value?.({ type: "stdout", data: "history-from-a\r\n" }); + expect(store.lines.join("")).toContain("history-from-a"); + + await store.disconnect("manual", true); + await store.connect(serverA); + await listeners.value?.({ type: "connected" }); + expect(store.lines.join("")).toContain("history-from-a"); + + await store.connect(serverB); + await listeners.value?.({ type: "connected" }); + expect(store.lines.join("")).not.toContain("history-from-a"); + }); + + it("ws_closed 断开后应进入可续接态,并在手动断开时清除", async () => { + const server: ServerProfile = { + id: "srv-resume", + name: "resume", + host: "127.0.0.1", + port: 22, + username: "gavin", + authType: "password", + projectPath: "~", + projectPresets: [], + tags: [], + timeoutSeconds: 20, + heartbeatSeconds: 15, + transportMode: "gateway" + }; + + serverStoreMock.servers = [server]; + settingsStoreMock.settings.autoReconnect = false; + + const store = useSessionStore(); + await store.connect(server); + await listeners.value?.({ type: "connected" }); + expect(store.isServerResumable(server.id)).toBe(false); + + await listeners.value?.({ type: "disconnect", reason: "ws_closed" }); + expect(store.isServerResumable(server.id)).toBe(true); + + await store.disconnect("manual", true); + expect(store.isServerResumable(server.id)).toBe(false); + }); + + it("手动断开时即使底层回报 ws_closed,也不应触发自动重连", async () => { + vi.useFakeTimers(); + setupWindowSessionStorage(); + + try { + const server: ServerProfile = { + id: "srv-manual-no-reconnect", + name: "manual-no-reconnect", + host: "127.0.0.1", + port: 22, + username: "gavin", + authType: "password", + projectPath: "~", + projectPresets: [], + tags: [], + timeoutSeconds: 20, + heartbeatSeconds: 15, + transportMode: "gateway" + }; + + serverStoreMock.servers = [server]; + + const store = useSessionStore(); + await store.connect(server); + await listeners.value?.({ type: "connected" }); + + transportMock.disconnect.mockImplementationOnce(async () => { + await listeners.value?.({ type: "disconnect", reason: "ws_closed" }); + }); + + await store.disconnect("manual", true); + await vi.advanceTimersByTimeAsync(2000); + + expect(transportMock.connect).toHaveBeenCalledTimes(1); + expect(store.state).toBe("disconnected"); + } finally { + vi.useRealTimers(); + } + }); + + it("Codex 预检命令回显包含 token 时不应误报目录不存在或未安装", async () => { + const server: ServerProfile = { + id: "srv-codex-ok", + name: "codex-ok", + host: "127.0.0.1", + port: 22, + username: "gavin", + authType: "password", + projectPath: "~/workspace", + projectPresets: [], + tags: [], + timeoutSeconds: 20, + heartbeatSeconds: 15, + transportMode: "gateway" + }; + + serverStoreMock.servers = [server]; + + const store = useSessionStore(); + await store.connect(server); + await listeners.value?.({ type: "connected" }); + + const launchedPromise = store.runCodex(server.projectPath, "workspace-write"); + const bootstrapCommand = String((transportMock.send.mock.calls.at(-1) ?? []).join(" ")); + expect(bootstrapCommand.startsWith('sh -lc "')).toBe(true); + + // 模拟 shell 回显“整条 bootstrap 命令”(包含 token 字面量),随后输出 READY。 + await listeners.value?.({ + type: "stdout", + data: + "__rc_codex_path_ok=1; __rc_codex_bin_ok=1; [ \"$__rc_codex_path_ok\" -eq 1 ] || printf '__RC_CODEX_DIR_MISSING__\\n'; " + + "[ \"$__rc_codex_bin_ok\" -eq 1 ] || printf '__RC_CODEX_BIN_MISSING__\\n';\r\n" + + "__RC_CODEX_READY__\r\nCodex started\r\n" + }); + + const launched = await launchedPromise; + expect(launched).toBe(true); + + const warnMessages = appStoreMock.notify.mock.calls + .filter((args: unknown[]) => args[0] === "warn") + .map((args: unknown[]) => String(args[1] ?? "")); + + expect(warnMessages.some((message) => message.includes("codex工作目录"))).toBe(false); + expect(warnMessages.some((message) => message.includes("服务器未装codex"))).toBe(false); + }); + + it("Codex 预检收到失败 token 行时应返回失败并提示原因", async () => { + const server: ServerProfile = { + id: "srv-codex-missing", + name: "codex-missing", + host: "127.0.0.1", + port: 22, + username: "gavin", + authType: "password", + projectPath: "~/workspace", + projectPresets: [], + tags: [], + timeoutSeconds: 20, + heartbeatSeconds: 15, + transportMode: "gateway" + }; + + serverStoreMock.servers = [server]; + + const store = useSessionStore(); + await store.connect(server); + await listeners.value?.({ type: "connected" }); + + const launchedPromise = store.runCodex(server.projectPath, "workspace-write"); + const bootstrapCommand = String((transportMock.send.mock.calls.at(-1) ?? []).join(" ")); + expect(bootstrapCommand.startsWith('sh -lc "')).toBe(true); + await listeners.value?.({ type: "stdout", data: "__RC_CODEX_BIN_MISSING__\r\n" }); + + const launched = await launchedPromise; + expect(launched).toBe(false); + expect(appStoreMock.notify).toHaveBeenCalledWith("warn", "服务器未装codex"); + }); + + it("Codex 前台态时 clearTerminal 不应清空当前缓冲", async () => { + const server: ServerProfile = { + id: "srv-codex-clear-guard", + name: "codex-clear-guard", + host: "127.0.0.1", + port: 22, + username: "gavin", + authType: "password", + projectPath: "~/workspace", + projectPresets: [], + tags: [], + timeoutSeconds: 20, + heartbeatSeconds: 15, + transportMode: "gateway" + }; + + serverStoreMock.servers = [server]; + + const store = useSessionStore(); + await store.connect(server); + await listeners.value?.({ type: "connected" }); + await listeners.value?.({ type: "stdout", data: "before-clear\r\n" }); + + const launchedPromise = store.runCodex(server.projectPath, "workspace-write"); + await listeners.value?.({ type: "stdout", data: "__RC_CODEX_READY__\r\n" }); + + const launched = await launchedPromise; + expect(launched).toBe(true); + expect(store.activeAiProvider).toBe("codex"); + + const previousLines = [...store.lines]; + const previousRevision = store.outputRevision; + + store.clearTerminal(); + + expect(store.lines).toEqual(previousLines); + expect(store.outputRevision).toBe(previousRevision); + }); + + it("命令回显包含 READY 字面量但无 READY token 行时,不应提前判定成功", async () => { + const server: ServerProfile = { + id: "srv-codex-ready-literal", + name: "codex-ready-literal", + host: "127.0.0.1", + port: 22, + username: "gavin", + authType: "password", + projectPath: "~", + projectPresets: [], + tags: [], + timeoutSeconds: 20, + heartbeatSeconds: 15, + transportMode: "gateway" + }; + + serverStoreMock.servers = [server]; + + const store = useSessionStore(); + await store.connect(server); + await listeners.value?.({ type: "connected" }); + + const launchedPromise = store.runCodex(server.projectPath, "workspace-write"); + + // 仅回显脚本字面量(包含 READY token 文本,但不是独立 token 行)。 + await listeners.value?.({ + type: "stdout", + data: + '__rc_codex_path_ok=1; __rc_codex_bin_ok=1; if [ "$__rc_codex_path_ok" -eq 1 ] && [ "$__rc_codex_bin_ok" -eq 1 ]; ' + + "then printf '__RC_CODEX_READY__\\n'; codex --sandbox workspace-write; fi\r\n" + }); + // 随后给出真实失败 token 行,应返回失败并提示未安装。 + await listeners.value?.({ type: "stdout", data: "__RC_CODEX_BIN_MISSING__\r\n" }); + + const launched = await launchedPromise; + expect(launched).toBe(false); + expect(appStoreMock.notify).toHaveBeenCalledWith("warn", "服务器未装codex"); + }); +}); diff --git a/apps/web/src/stores/sessionStore.ts b/apps/web/src/stores/sessionStore.ts new file mode 100644 index 0000000..4f4ea7c --- /dev/null +++ b/apps/web/src/stores/sessionStore.ts @@ -0,0 +1,1436 @@ +import { defineStore } from "pinia"; +import { computed, ref } from "vue"; +import type { SessionState, ServerProfile } from "@/types/app"; +import { allStates, buildCdCommand, buildCodexPlan } from "@remoteconn/shared"; +import type { StdinMeta } from "@remoteconn/shared"; +import { useSettingsStore } from "./settingsStore"; +import { useServerStore } from "./serverStore"; +import { useLogStore } from "./logStore"; +import { useAppStore } from "./appStore"; +import { createTransport } from "@/services/transport/factory"; +import type { TerminalTransport } from "@/services/transport/terminalTransport"; +import { emitSessionEvent } from "@/services/sessionEventBus"; +import { formatActionError, toFriendlyDisconnectReason, toFriendlyError } from "@/utils/feedback"; + +interface StoredSessionSnapshotV1 { + version: 1; + savedAt: number; + lines: string[]; + currentServerId: string; + reconnectServerId: string; + activeAiProvider?: string; + codexSandboxMode?: string; +} + +interface TerminalBufferBucket { + lines: string[]; + chunkBytes: number[]; + bufferedBytes: number; + updatedAt: number; +} + +interface CodexBootstrapGuard { + active: boolean; + connectionKey: string; + projectPath: string; + buffer: string; + notifiedDirMissing: boolean; + notifiedCodexMissing: boolean; + releaseTimer: number | null; + timeoutTimer: number | null; + settleResult: (result: boolean) => void; + settleError: (error: Error) => void; +} + +interface StoredSessionSnapshotV2 { + version: 2; + savedAt: number; + activeConnectionKey: string; + lines: string[]; + currentServerId: string; + reconnectServerId: string; + activeAiProvider?: string; + codexSandboxMode?: string; +} + +type StoredSessionSnapshot = StoredSessionSnapshotV1 | StoredSessionSnapshotV2; +type CodexSandboxMode = "" | "read-only" | "workspace-write" | "danger-full-access"; +type ActiveAiProvider = "" | "codex" | "copilot"; +type CopilotCommand = "copilot" | "copilot --experimental" | "copilot --allow-all"; + +/** + * 会话生命周期管理:连接、命令执行、重连、断开、Codex 编排。 + */ +export const useSessionStore = defineStore("session", () => { + const SESSION_SNAPSHOT_STORAGE_KEY = "remoteconn_session_snapshot_v1"; + const SESSION_SNAPSHOT_VERSION = 2; + const SESSION_SNAPSHOT_PERSIST_DELAY_MS = 120; + const LATENCY_SAMPLE_WINDOW = 6; + const MAX_BUFFER_BUCKETS = 10; + const DEFAULT_CONNECTION_KEY = "session::default"; + const RESUME_HIGHLIGHT_WINDOW_MS = 20_000; + + const settingsStore = useSettingsStore(); + const state = ref("idle"); + const activeConnectionKey = ref(DEFAULT_CONNECTION_KEY); + const resumableServerId = ref(""); + const resumableExpiresAt = ref(0); + const buffersByKey = ref>({}); + const lines = computed(() => getOrCreateBucket(activeConnectionKey.value).lines); + const outputRevision = ref(0); + const latencyMs = ref(0); + const reconnectAttempts = ref(0); + const currentSessionId = ref(""); + const currentServerId = ref(""); + + let transport: TerminalTransport | null = null; + let offTransport: (() => void) | null = null; + let reconnectTimer: number | null = null; + let pendingLfAfterCr = false; + let shellCompatBootstrapped = false; + let ensureShellCompatibility: (() => Promise) | null = null; + const utf8Encoder = new TextEncoder(); + const MIN_TERMINAL_BUFFER_MAX_ENTRIES = 200; + const MIN_TERMINAL_BUFFER_MAX_BYTES = 64 * 1024; + const CODEX_BOOTSTRAP_TOKEN_DIR_MISSING = "__RC_CODEX_DIR_MISSING__"; + const CODEX_BOOTSTRAP_TOKEN_CODEX_MISSING = "__RC_CODEX_BIN_MISSING__"; + const CODEX_BOOTSTRAP_TOKEN_READY = "__RC_CODEX_READY__"; + const CODEX_BOOTSTRAP_WAIT_TIMEOUT_MS = 6000; + const CODEX_BOOTSTRAP_RELEASE_DELAY_MS = 260; + const CODEX_BOOTSTRAP_BUFFER_MAX_CHARS = 8192; + const AI_RUNTIME_EXIT_OSC_IDENT = 633; + const CODEX_RESUME_DEFAULT_SANDBOX = "workspace-write"; + const AI_RUNTIME_EXIT_MARKERS: Record, string> = { + codex: `\u001b]${AI_RUNTIME_EXIT_OSC_IDENT};RemoteConn;ai-exit=codex\u0007`, + copilot: `\u001b]${AI_RUNTIME_EXIT_OSC_IDENT};RemoteConn;ai-exit=copilot\u0007` + }; + let snapshotPersistTimer: number | null = null; + let resumableExpireTimer: number | null = null; + let sessionBootstrapped = false; + let autoReconnectInFlight = false; + /** + * 自动重连抑制标记: + * 1. 用户手动断开; + * 2. 切换服务器导致的本地断开; + * 3. 指纹拒绝等本地明确终止场景。 + * 上述情况都不应再被后续 transport disconnect 事件反向触发重连。 + */ + let autoReconnectSuppressed = false; + let onPageLifecyclePersist: (() => void) | null = null; + let onVisibilityPersist: (() => void) | null = null; + let bootstrapPromise: Promise | null = null; + const latencySamples: number[] = []; + let codexBootstrapGuard: CodexBootstrapGuard | null = null; + let lastSentCols = 0; + let lastSentRows = 0; + /** 最近一次连接是否为续接(session resume),用于跳过不必要的 resize。 */ + let lastConnectWasResume = false; + const activeAiProvider = ref(""); + const activeCodexSandboxMode = ref(""); + let aiRuntimeExitCarry = ""; + let pendingCodexResumeAfterReconnect = false; + const AUTO_RECONNECT_IGNORED_REASONS = new Set(["manual", "switch", "host_key_rejected", "ws_peer_normal_close"]); + + /** + * 转义正则元字符,避免 token 文本参与正则语义。 + */ + function escapeRegExpLiteral(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + } + + /** + * Web 端当前允许识别 Codex / Copilot 两档 AI 前台态: + * - Codex 需要参与断线恢复与 sandbox 保留; + * - Copilot 主要用于 UI 高亮与退出解锁; + * - 其它值统一视为“无 AI 前台态”,避免把历史脏值误恢复。 + */ + function normalizeActiveAiProvider(value: unknown): ActiveAiProvider { + const normalized = String(value || "").trim(); + if (normalized === "codex" || normalized === "copilot") { + return normalized; + } + return ""; + } + + /** + * Web 端仅保留 Codex 支持的三档 sandbox: + * - 空值表示当前没有 Codex 前台态; + * - 脏值统一回退到 `workspace-write`,避免恢复时权限漂移到未知状态。 + */ + function normalizeCodexSandboxMode(value: unknown): CodexSandboxMode { + const normalized = String(value || "").trim(); + if (!normalized) { + return ""; + } + if ( + normalized === "read-only" || + normalized === "workspace-write" || + normalized === "danger-full-access" + ) { + return normalized; + } + return CODEX_RESUME_DEFAULT_SANDBOX; + } + + /** + * 同步当前会话内的 AI 前台态。 + * 清空时同时丢弃退出标记残片与自动恢复意图,避免旧状态污染新连接。 + */ + function syncActiveAiProvider(provider: unknown): ActiveAiProvider { + const normalized = normalizeActiveAiProvider(provider); + activeAiProvider.value = normalized; + if (!normalized) { + activeCodexSandboxMode.value = ""; + aiRuntimeExitCarry = ""; + pendingCodexResumeAfterReconnect = false; + } + if (["connecting", "auth_pending", "connected", "reconnecting"].includes(state.value)) { + persistSnapshotLater(); + } + return normalized; + } + + /** + * 仅匹配“独立一行”的 bootstrap token。 + * 说明:预检命令回显里会包含 token 字符串(如 printf '__RC_CODEX_*'), + * 若用 includes 全文匹配会误判为失败。这里改为“按行精确匹配”。 + */ + function hasCodexBootstrapTokenLine(source: string, token: string): boolean { + const pattern = new RegExp(`(^|\\r?\\n)${escapeRegExpLiteral(token)}(?=\\r?\\n|$)`); + return pattern.test(source); + } + + /** + * 删除独立 token 行,避免其残留到后续缓冲。 + */ + function stripCodexBootstrapTokenLine(source: string, token: string): string { + const pattern = new RegExp(`(^|\\r?\\n)${escapeRegExpLiteral(token)}(?=\\r?\\n|$)`, "g"); + return source.replace(pattern, "$1"); + } + + /** + * 提取“首个 token 行”之后的内容: + * - 用于 READY 判定,避免命令回显中的 token 字面量触发“提前成功”; + * - 仅当 token 作为独立一行出现时才视为真实信号。 + */ + function extractAfterFirstCodexBootstrapTokenLine( + source: string, + token: string + ): { found: true; after: string } | { found: false } { + const pattern = new RegExp(`(^|\\r?\\n)${escapeRegExpLiteral(token)}(\\r?\\n|$)`); + const match = pattern.exec(source); + if (!match) { + return { found: false }; + } + const prefix = match[1] ?? ""; + const suffix = match[2] ?? ""; + const tokenStart = match.index + prefix.length; + const tokenEnd = tokenStart + token.length + suffix.length; + return { found: true, after: source.slice(tokenEnd) }; + } + + /** + * 仅在“当前活跃连接”仍保持 connected 时提示 bootstrap 失败原因。 + * 避免旧连接残留输出或状态切换期间触发误报 toast。 + */ + function shouldNotifyCodexBootstrapIssue(guard: CodexBootstrapGuard): boolean { + return state.value === "connected" && guard.connectionKey === activeConnectionKey.value; + } + + /** + * 将脚本文本安全嵌入到 `sh -lc "..."` 的双引号参数中: + * - 统一转义双引号、反斜杠、变量符、反引号与 csh 历史展开符; + * - 目标是让“当前默认 shell(可能是 csh/tcsh)”只负责转发,不参与脚本语义。 + */ + function escapeForDoubleQuotedShellArg(script: string): string { + return script + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"') + .replace(/\$/g, "\\$") + .replace(/`/g, "\\`") + .replace(/!/g, "\\!"); + } + + /** + * AI 退出后打印一次未知 OSC: + * - Web 端据此解除“当前会话仍有 AI 前台”的判断; + * - Codex / Copilot 共享同一协议,只是 provider 名称不同; + * - 标记本身不会污染终端可见内容。 + */ + function buildAiExitPrintfCommand(provider: Exclude): string { + return `printf '\\033]${AI_RUNTIME_EXIT_OSC_IDENT};RemoteConn;ai-exit=${provider}\\a'`; + } + + /** + * 从 stdout/stderr 中剥离 AI 退出标记。 + * 若标记跨 chunk 到达,则只缓存尾巴,不阻塞普通文本渲染。 + */ + function consumeAiRuntimeOutput(data: string): string { + let working = `${aiRuntimeExitCarry}${String(data || "")}`; + if (!working) { + return working; + } + + const exitedProviders: Exclude[] = []; + let changed = true; + while (changed) { + changed = false; + for (const provider of Object.keys(AI_RUNTIME_EXIT_MARKERS) as Exclude[]) { + const marker = AI_RUNTIME_EXIT_MARKERS[provider]; + const index = working.indexOf(marker); + if (index < 0) { + continue; + } + exitedProviders.push(provider); + working = `${working.slice(0, index)}${working.slice(index + marker.length)}`; + changed = true; + } + } + + aiRuntimeExitCarry = ""; + const lastEscIndex = working.lastIndexOf("\u001b"); + if (lastEscIndex >= 0) { + const suffix = working.slice(lastEscIndex); + const mayBeMarkerTail = (Object.values(AI_RUNTIME_EXIT_MARKERS) as string[]).some((marker) => + marker.startsWith(suffix) + ); + if (mayBeMarkerTail) { + aiRuntimeExitCarry = suffix; + working = working.slice(0, lastEscIndex); + } + } + + if (exitedProviders.some((provider) => !activeAiProvider.value || activeAiProvider.value === provider)) { + syncActiveAiProvider(""); + } + return working; + } + + /** + * 用于修复 zsh 中文输入回显乱码的会话初始化命令。 + * 目标: + * 1) 强制 UTF-8 locale(LANG/LC_CTYPE/LC_ALL); + * 2) 开启 `stty iutf8`,让行编辑按 UTF-8 处理退格/宽字符; + * 3) 开启 zsh `MULTIBYTE` + `PRINT_EIGHT_BIT`; + * 4) 保留此前已验证的 `%` 行尾标记抑制。 + */ + const shellCompatInitCommand = + 'if [ -n "$ZSH_VERSION" ]; then export LANG="${LANG:-zh_CN.UTF-8}"; export LC_CTYPE="${LC_CTYPE:-$LANG}"; if [ -z "$LC_ALL" ]; then export LC_ALL="$LANG"; fi; stty iutf8 2>/dev/null; setopt MULTIBYTE PRINT_EIGHT_BIT 2>/dev/null; unsetopt PROMPT_SP 2>/dev/null; PROMPT_EOL_MARK=\'\'; fi\r'; + + const connected = computed(() => state.value === "connected"); + + function createBufferBucket(initialLines: string[] = []): TerminalBufferBucket { + const chunkBytes = initialLines.map((chunk) => utf8Encoder.encode(chunk).byteLength); + const bufferedBytes = chunkBytes.reduce((sum, size) => sum + size, 0); + return { + lines: [...initialLines], + chunkBytes, + bufferedBytes, + updatedAt: Date.now() + }; + } + + /** + * 清理“可续接态”标记: + * - 用于手动断开、切换连接、续接成功后等场景; + * - 避免按钮长期停留在强调色造成状态误导。 + */ + function clearResumableState(): void { + if (resumableExpireTimer !== null && typeof window !== "undefined") { + window.clearTimeout(resumableExpireTimer); + resumableExpireTimer = null; + } + resumableServerId.value = ""; + resumableExpiresAt.value = 0; + } + + /** + * 标记“可续接态”窗口: + * - 与网关侧驻留窗口保持一致(默认 20s); + * - 仅在 WS 断开但可能仍可续接 SSH 的场景使用。 + */ + function markResumableState(serverId: string): void { + if (!serverId) { + clearResumableState(); + return; + } + clearResumableState(); + const expiresAt = Date.now() + RESUME_HIGHLIGHT_WINDOW_MS; + resumableServerId.value = serverId; + resumableExpiresAt.value = expiresAt; + if (typeof window !== "undefined") { + resumableExpireTimer = window.setTimeout(() => { + clearResumableState(); + }, RESUME_HIGHLIGHT_WINDOW_MS); + } + } + + /** + * 某服务器当前是否处于“可续接”窗口。 + */ + function isServerResumable(serverId: string): boolean { + return Boolean(serverId) && resumableServerId.value === serverId && resumableExpiresAt.value > Date.now(); + } + + /** + * AI 高亮态与连接态保持一致: + * 1. 当前服务器已连接且存在 AI 前台 provider; + * 2. 或处于短暂可续接窗口,且快照仍记得 AI 前台态; + * 3. 这样连接页的 AI 按钮就能和连接按钮一样表达“这台机器上还有活跃 AI 会话”。 + */ + function isServerAiActive(serverId: string): boolean { + if (!serverId || !activeAiProvider.value) { + return false; + } + if (state.value === "connected" && currentServerId.value === serverId) { + return true; + } + return isServerResumable(serverId); + } + + function getOrCreateBucket(key: string): TerminalBufferBucket { + const existing = buffersByKey.value[key]; + if (existing) { + return existing; + } + const next = createBufferBucket(); + buffersByKey.value[key] = next; + return next; + } + + function touchBucket(key: string): void { + const bucket = getOrCreateBucket(key); + bucket.updatedAt = Date.now(); + } + + function trimBucketCount(): void { + const keys = Object.keys(buffersByKey.value); + if (keys.length <= MAX_BUFFER_BUCKETS) { + return; + } + const candidates = keys + .filter((key) => key !== activeConnectionKey.value) + .sort((a, b) => (buffersByKey.value[a]?.updatedAt ?? 0) - (buffersByKey.value[b]?.updatedAt ?? 0)); + + while (Object.keys(buffersByKey.value).length > MAX_BUFFER_BUCKETS && candidates.length > 0) { + const removed = candidates.shift(); + if (removed) { + delete buffersByKey.value[removed]; + } + } + } + + function createConnectionKey(serverId: string): string { + return `${serverId}::${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + } + + function findLatestConnectionKeyForServer(serverId: string): string | null { + const prefix = `${serverId}::`; + const keys = Object.keys(buffersByKey.value) + .filter((key) => key.startsWith(prefix)) + .sort((a, b) => (buffersByKey.value[b]?.updatedAt ?? 0) - (buffersByKey.value[a]?.updatedAt ?? 0)); + return keys[0] ?? null; + } + + /** + * 解析本次连接应使用的缓冲 key: + * 1) 自动重连:始终复用该服务器最近 key,保持上下文连续; + * 2) 手动连接同一服务器:复用最近 key,避免“重连后历史丢失”; + * 3) 手动连接不同服务器:创建新 key,保持跨服务器隔离。 + */ + function resolveConnectionKeyForConnect( + serverId: string, + isReconnectAttempt: boolean, + previousServerId: string + ): string { + const latest = findLatestConnectionKeyForServer(serverId); + const canReuseLatest = + Boolean(latest) && + (isReconnectAttempt || previousServerId === serverId || previousServerId.length === 0); + if (canReuseLatest && latest) { + return latest; + } + return createConnectionKey(serverId); + } + + function assertState(next: SessionState): void { + if (!allStates().includes(next)) { + throw new Error(`未知状态: ${next}`); + } + state.value = next; + } + + function canUseStorage(): boolean { + return typeof window !== "undefined" && typeof window.sessionStorage !== "undefined"; + } + + function shouldReconnectAfterReload(): boolean { + return ( + ["connecting", "auth_pending", "connected", "reconnecting"].includes(state.value) && + Boolean(currentServerId.value) + ); + } + + function normalizeBufferLimit(value: number, fallback: number, min: number): number { + if (!Number.isFinite(value)) { + return fallback; + } + return Math.max(min, Math.round(value)); + } + + /** + * 从全局设置读取终端缓冲阈值: + * - 字节上限用于稳定控制内存占用; + * - 条目上限作为碎片化输出兜底; + * - 均做最小值收敛,避免异常配置导致“缓冲失控”。 + */ + function resolveTerminalBufferLimits(): { maxEntries: number; maxBytes: number } { + const maxEntries = normalizeBufferLimit( + settingsStore.settings.terminalBufferMaxEntries, + 5000, + MIN_TERMINAL_BUFFER_MAX_ENTRIES + ); + const maxBytes = normalizeBufferLimit( + settingsStore.settings.terminalBufferMaxBytes, + 4 * 1024 * 1024, + MIN_TERMINAL_BUFFER_MAX_BYTES + ); + return { maxEntries, maxBytes }; + } + + function trimByEntries(bucket: TerminalBufferBucket, maxEntries: number): void { + if (bucket.lines.length <= maxEntries) { + return; + } + const removeCount = bucket.lines.length - maxEntries; + const removedBytes = bucket.chunkBytes.splice(0, removeCount).reduce((sum, size) => sum + size, 0); + bucket.lines.splice(0, removeCount); + bucket.bufferedBytes = Math.max(0, bucket.bufferedBytes - removedBytes); + } + + function trimByBytes(bucket: TerminalBufferBucket, maxBytes: number): void { + if (bucket.bufferedBytes <= maxBytes || bucket.lines.length <= 1) { + return; + } + let removeCount = 0; + let removedBytes = 0; + // 至少保留最新一条,避免在“单条超大输出”场景下出现空白闪烁。 + while (bucket.bufferedBytes - removedBytes > maxBytes && removeCount < bucket.chunkBytes.length - 1) { + removedBytes += bucket.chunkBytes[removeCount] ?? 0; + removeCount += 1; + } + if (removeCount <= 0) { + return; + } + bucket.chunkBytes.splice(0, removeCount); + bucket.lines.splice(0, removeCount); + bucket.bufferedBytes = Math.max(0, bucket.bufferedBytes - removedBytes); + } + + /** + * 终端原始输出缓冲:保持字节流语义,避免逐字符被当成“行”导致每键一换行。 + * 裁剪策略: + * 1) 优先按条目上限兜底; + * 2) 再按 UTF-8 字节上限收敛内存; + * 3) 两个阈值均来自全局配置,可在设置页调整。 + */ + function appendTerminal(text: string): void { + const bucket = getOrCreateBucket(activeConnectionKey.value); + const { maxEntries, maxBytes } = resolveTerminalBufferLimits(); + const chunkBytes = utf8Encoder.encode(text).byteLength; + bucket.lines.push(text); + bucket.chunkBytes.push(chunkBytes); + bucket.bufferedBytes += chunkBytes; + bucket.updatedAt = Date.now(); + trimByEntries(bucket, maxEntries); + trimByBytes(bucket, maxBytes); + trimBucketCount(); + outputRevision.value += 1; + persistSnapshotLater(); + } + + function buildSnapshot( + linesForPersist = getOrCreateBucket(activeConnectionKey.value).lines + ): StoredSessionSnapshotV2 { + return { + version: SESSION_SNAPSHOT_VERSION, + savedAt: Date.now(), + activeConnectionKey: activeConnectionKey.value, + lines: [...linesForPersist], + currentServerId: currentServerId.value, + reconnectServerId: shouldReconnectAfterReload() ? currentServerId.value : "", + activeAiProvider: activeAiProvider.value, + codexSandboxMode: activeAiProvider.value === "codex" ? activeCodexSandboxMode.value : "" + }; + } + + /** + * 写入会话快照: + * - 优先完整保存; + * - 若命中浏览器配额,自动退化到“后半段输出”重试,保证刷新后至少可恢复最近上下文。 + */ + function persistSnapshotNow(): void { + if (!canUseStorage()) { + return; + } + if (snapshotPersistTimer) { + window.clearTimeout(snapshotPersistTimer); + snapshotPersistTimer = null; + } + + let candidateLines = getOrCreateBucket(activeConnectionKey.value).lines; + while (true) { + const snapshot = buildSnapshot(candidateLines); + try { + window.sessionStorage.setItem(SESSION_SNAPSHOT_STORAGE_KEY, JSON.stringify(snapshot)); + return; + } catch { + if (candidateLines.length <= 200) { + return; + } + candidateLines = candidateLines.slice(Math.floor(candidateLines.length / 2)); + } + } + } + + function persistSnapshotLater(): void { + if (!canUseStorage()) { + return; + } + if (snapshotPersistTimer) { + window.clearTimeout(snapshotPersistTimer); + } + snapshotPersistTimer = window.setTimeout(() => { + persistSnapshotNow(); + }, SESSION_SNAPSHOT_PERSIST_DELAY_MS); + } + + /** + * 恢复刷新前的终端上下文: + * - 输出缓冲(lines); + * - 当前服务器 ID; + * - 自动重连意图(reconnectServerId)。 + */ + function restoreSnapshot(): string { + if (!canUseStorage()) { + return ""; + } + try { + const raw = window.sessionStorage.getItem(SESSION_SNAPSHOT_STORAGE_KEY); + if (!raw) { + return ""; + } + const parsed = JSON.parse(raw) as Partial; + if (parsed.version !== 1 && parsed.version !== 2) { + return ""; + } + const restoredLines = Array.isArray(parsed.lines) + ? parsed.lines.filter((item): item is string => typeof item === "string") + : []; + const restoredKey = + parsed.version === 2 && typeof parsed.activeConnectionKey === "string" && parsed.activeConnectionKey + ? parsed.activeConnectionKey + : DEFAULT_CONNECTION_KEY; + + activeConnectionKey.value = restoredKey; + buffersByKey.value[restoredKey] = createBufferBucket(restoredLines); + trimBucketCount(); + if (restoredLines.length > 0) { + outputRevision.value += 1; + } + if (typeof parsed.currentServerId === "string") { + currentServerId.value = parsed.currentServerId; + } + syncActiveAiProvider(parsed.activeAiProvider); + activeCodexSandboxMode.value = + activeAiProvider.value === "codex" ? normalizeCodexSandboxMode(parsed.codexSandboxMode) : ""; + pendingCodexResumeAfterReconnect = + typeof parsed.reconnectServerId === "string" && parsed.reconnectServerId + ? activeAiProvider.value === "codex" + : false; + if (typeof parsed.reconnectServerId === "string") { + return parsed.reconnectServerId; + } + } catch { + // 快照损坏时静默跳过,避免阻塞主流程。 + } + return ""; + } + + /** + * 刷新恢复重连: + * - `fromReload=true` 时表示“页面刷新后的会话恢复”,不受 autoReconnect 开关影响; + * - 断线后的常规自动重连仍由 autoReconnect 开关控制(见 disconnect 事件分支)。 + */ + async function tryAutoReconnect(reconnectServerId: string, fromReload = false): Promise { + if (!reconnectServerId || autoReconnectInFlight) { + return; + } + if (!fromReload && !settingsStore.settings.autoReconnect) { + return; + } + if (!["idle", "disconnected", "error"].includes(state.value)) { + return; + } + const serverStore = useServerStore(); + const appStore = useAppStore(); + const target = serverStore.servers.find((item) => item.id === reconnectServerId); + if (!target) { + return; + } + + autoReconnectInFlight = true; + try { + appStore.notify( + "info", + `检测到页面刷新,正在自动重连:${target.username}@${target.host}:${target.port}` + ); + await connect( + { + ...target, + projectPresets: [...target.projectPresets], + tags: [...target.tags] + }, + true + ); + } catch (error) { + appStore.notify("warn", formatActionError("刷新后自动重连失败", error)); + } finally { + autoReconnectInFlight = false; + } + } + + /** + * 会话层启动: + * 1) 恢复 sessionStorage 中的输出上下文; + * 2) 注册页面生命周期持久化(beforeunload/pagehide/hidden); + * 3) 根据快照自动发起重连。 + */ + async function ensureBootstrapped(): Promise { + if (sessionBootstrapped) { + return; + } + if (bootstrapPromise) { + await bootstrapPromise; + return; + } + + bootstrapPromise = (async () => { + sessionBootstrapped = true; + const reconnectServerId = restoreSnapshot(); + if (reconnectServerId) { + markResumableState(reconnectServerId); + } else { + clearResumableState(); + } + persistSnapshotLater(); + if (typeof window !== "undefined") { + onPageLifecyclePersist = () => { + persistSnapshotNow(); + }; + onVisibilityPersist = () => { + if (document.visibilityState === "hidden") { + persistSnapshotNow(); + } + }; + window.addEventListener("beforeunload", onPageLifecyclePersist, { capture: true }); + window.addEventListener("pagehide", onPageLifecyclePersist, { capture: true }); + document.addEventListener("visibilitychange", onVisibilityPersist, { capture: true }); + } + await tryAutoReconnect(reconnectServerId, true); + })(); + + try { + await bootstrapPromise; + } finally { + bootstrapPromise = null; + } + } + + async function bootstrap(): Promise { + await ensureBootstrapped(); + } + + /** + * 统一用户输入中的换行语义: + * - 终端交互协议更稳妥的是 CR(`\r`)作为回车; + * - 移动端/输入法可能产生 `\n` 或 `\r\n`,这里统一折叠为 `\r`, + * 避免在部分 shell + pty 组合下出现“按一次回车多一个空行”。 + */ + function normalizeEnter(input: string): string { + if (!input) return input; + + let output = ""; + let index = 0; + + if (pendingLfAfterCr && input.startsWith("\n")) { + index = 1; + } + pendingLfAfterCr = false; + + for (; index < input.length; index += 1) { + const ch = input[index]; + if (ch === "\r") { + output += "\r"; + pendingLfAfterCr = true; + continue; + } + + if (ch === "\n") { + if (pendingLfAfterCr) { + pendingLfAfterCr = false; + continue; + } + output += "\r"; + continue; + } + + pendingLfAfterCr = false; + output += ch; + } + + return output; + } + + function clearCodexBootstrapGuard(target?: CodexBootstrapGuard): void { + const guard = target ?? codexBootstrapGuard; + if (!guard) return; + if (guard.releaseTimer !== null) { + window.clearTimeout(guard.releaseTimer); + guard.releaseTimer = null; + } + if (guard.timeoutTimer !== null) { + window.clearTimeout(guard.timeoutTimer); + guard.timeoutTimer = null; + } + if (codexBootstrapGuard === guard) { + codexBootstrapGuard = null; + } + } + + function settleCodexBootstrapGuardAsResult(result: boolean, target?: CodexBootstrapGuard): void { + const guard = target ?? codexBootstrapGuard; + if (!guard) return; + clearCodexBootstrapGuard(guard); + guard.settleResult(result); + } + + function settleCodexBootstrapGuardAsError(error: Error, target?: CodexBootstrapGuard): void { + const guard = target ?? codexBootstrapGuard; + if (!guard) return; + clearCodexBootstrapGuard(guard); + guard.settleError(error); + } + + function scheduleCodexBootstrapGuardRelease(target?: CodexBootstrapGuard): void { + const guard = target ?? codexBootstrapGuard; + if (!guard || guard.releaseTimer !== null) return; + guard.releaseTimer = window.setTimeout(() => { + clearCodexBootstrapGuard(guard); + }, CODEX_BOOTSTRAP_RELEASE_DELAY_MS); + } + + /** + * Codex 启动阶段输出拦截: + * 1) 在预检阶段吞掉命令回显与探测细节,避免终端出现“cd/command -v/codex not found”等噪音; + * 2) 仅通过“独立 token 行”触发业务提示(目录不存在、服务器未装 codex); + * 3) 收到 READY token 后解除拦截,并透传后续真实 Codex 输出。 + */ + function consumeCodexBootstrapOutput(data: string): string { + const guard = codexBootstrapGuard; + if (!guard?.active) { + return data; + } + + guard.buffer = `${guard.buffer}${data}`; + if (guard.buffer.length > CODEX_BOOTSTRAP_BUFFER_MAX_CHARS) { + guard.buffer = guard.buffer.slice(-CODEX_BOOTSTRAP_BUFFER_MAX_CHARS); + } + + let working = guard.buffer; + const hasDirMissing = hasCodexBootstrapTokenLine(working, CODEX_BOOTSTRAP_TOKEN_DIR_MISSING); + const hasCodexMissing = hasCodexBootstrapTokenLine(working, CODEX_BOOTSTRAP_TOKEN_CODEX_MISSING); + const shouldNotify = shouldNotifyCodexBootstrapIssue(guard); + + if (hasDirMissing && !guard.notifiedDirMissing) { + guard.notifiedDirMissing = true; + if (shouldNotify) { + const appStore = useAppStore(); + appStore.notify("warn", `codex工作目录${guard.projectPath}不存在`); + } + } + + if (hasCodexMissing && !guard.notifiedCodexMissing) { + guard.notifiedCodexMissing = true; + if (shouldNotify) { + const appStore = useAppStore(); + appStore.notify("warn", "服务器未装codex"); + } + } + + if (hasDirMissing) { + working = stripCodexBootstrapTokenLine(working, CODEX_BOOTSTRAP_TOKEN_DIR_MISSING); + } + if (hasCodexMissing) { + working = stripCodexBootstrapTokenLine(working, CODEX_BOOTSTRAP_TOKEN_CODEX_MISSING); + } + + const readyLine = extractAfterFirstCodexBootstrapTokenLine(working, CODEX_BOOTSTRAP_TOKEN_READY); + if (readyLine.found) { + const afterReady = readyLine.after.replace(/^\r?\n/, ""); + settleCodexBootstrapGuardAsResult(true, guard); + return afterReady; + } + + // 任一失败 token 出现后即判定本次启动失败,并短暂维持拦截吞掉尾部提示符。 + if (guard.notifiedDirMissing || guard.notifiedCodexMissing) { + guard.buffer = ""; + guard.settleResult(false); + scheduleCodexBootstrapGuardRelease(guard); + return ""; + } + + guard.buffer = working; + return ""; + } + + function startCodexBootstrapGuard(projectPath: string): Promise { + if (codexBootstrapGuard?.active) { + throw new Error("Codex 正在启动中"); + } + + return new Promise((resolve, reject) => { + let settled = false; + let guardRef: CodexBootstrapGuard | null = null; + + const settleResult = (result: boolean): void => { + if (settled) return; + settled = true; + resolve(result); + }; + const settleError = (error: Error): void => { + if (settled) return; + settled = true; + reject(error); + }; + + const timeoutTimer = window.setTimeout(() => { + settleCodexBootstrapGuardAsError(new Error("等待 Codex 启动结果超时"), guardRef ?? undefined); + }, CODEX_BOOTSTRAP_WAIT_TIMEOUT_MS); + + const guard: CodexBootstrapGuard = { + active: true, + connectionKey: activeConnectionKey.value, + projectPath, + buffer: "", + notifiedDirMissing: false, + notifiedCodexMissing: false, + releaseTimer: null, + timeoutTimer, + settleResult, + settleError + }; + guardRef = guard; + codexBootstrapGuard = guard; + }); + } + + /** + * 构造“恢复最近一次 Codex 会话”的命令: + * 1. 先切到项目目录,确保 CLI 的 cwd 过滤命中当前仓库; + * 2. 失败或正常退出时都打印退出标记,避免前台锁残留。 + */ + function buildCodexResumeCommand(projectPath: string): string { + const plan = buildCodexPlan({ + projectPath, + sandbox: (activeCodexSandboxMode.value || CODEX_RESUME_DEFAULT_SANDBOX) as Exclude< + CodexSandboxMode, + "" + >, + resumeLast: true + }); + const cdStep = plan.find((step) => step.step === "cd"); + const runStep = plan.find((step) => step.step === "run"); + if (!cdStep || !runStep) { + throw new Error("Codex 恢复计划不完整"); + } + + const script = + `${cdStep.command} && ${runStep.command}; ` + + `__rc_ai_exit_code=$?; ${buildAiExitPrintfCommand("codex")}; exit "$__rc_ai_exit_code"`; + return `sh -lc "${escapeForDoubleQuotedShellArg(script)}"`; + } + + /** + * 新 shell 建立后,如断线前 Codex 仍在前台,则尝试恢复最近一次 CLI 会话。 + * 只在“未命中网关旧 SSH 续接”时触发,避免对已恢复的 TUI 再注入命令。 + */ + async function resumeCodexAfterReconnect(projectPath: string): Promise { + if (state.value !== "connected" || activeAiProvider.value !== "codex") { + pendingCodexResumeAfterReconnect = false; + return; + } + pendingCodexResumeAfterReconnect = false; + const appStore = useAppStore(); + appStore.notify("info", "检测到上次 Codex 会话,正在尝试恢复"); + try { + await sendCommand(buildCodexResumeCommand(projectPath), "codex", "run"); + } catch (error) { + syncActiveAiProvider(""); + appStore.notify("warn", formatActionError("Codex 自动恢复失败", error)); + } + } + + async function connect(server: ServerProfile, isReconnectAttempt = false): Promise { + const serverStore = useServerStore(); + const logStore = useLogStore(); + const appStore = useAppStore(); + const previousServerId = currentServerId.value; + const previousAiProvider = activeAiProvider.value; + const previousCodexSandboxMode = activeCodexSandboxMode.value; + const previousPendingCodexResume = pendingCodexResumeAfterReconnect; + const shouldCarryAiState = previousServerId === server.id && !!previousAiProvider; + + clearResumableState(); + await disconnect("switch", false); + if (shouldCarryAiState) { + activeAiProvider.value = previousAiProvider; + activeCodexSandboxMode.value = previousCodexSandboxMode; + pendingCodexResumeAfterReconnect = previousPendingCodexResume || previousAiProvider === "codex"; + } + assertState("connecting"); + + if (!isReconnectAttempt) { + reconnectAttempts.value = 0; + autoReconnectSuppressed = false; + } + activeConnectionKey.value = resolveConnectionKeyForConnect( + server.id, + isReconnectAttempt, + previousServerId + ); + getOrCreateBucket(activeConnectionKey.value); + + touchBucket(activeConnectionKey.value); + trimBucketCount(); + latencyMs.value = 0; + latencySamples.length = 0; + currentServerId.value = server.id; + persistSnapshotLater(); + + const sessionId = await logStore.startLog(server.id); + currentSessionId.value = sessionId; + + let credentials: Awaited>; + try { + credentials = await serverStore.resolveCredentialBundle( + server.id, + server.jumpHost?.enabled ? server.jumpHost.authType : null + ); + } catch (error) { + const reason = `凭据读取失败,请在服务器设置页重新保存凭据: ${(error as Error).message}`; + assertState("error"); + await logStore.markStatus(sessionId, "error", reason); + throw new Error(reason); + } + + transport = createTransport(server.transportMode, { + gatewayUrl: settingsStore.gatewayUrl, + gatewayToken: settingsStore.gatewayToken + }); + let markedConnected = false; + /** + * gateway 模式由网关侧静默初始化 shell 兼容项,不再由前端注入; + * ios-native 仍保留前端兜底注入。 + */ + shellCompatBootstrapped = server.transportMode === "gateway"; + + /** + * 终端连接建立后,自动执行一次 zsh 兼容初始化: + * 1) `MULTIBYTE` 确保 zle 以多字节模式处理中文输入; + * 2) `PRINT_EIGHT_BIT` 避免把高位字节渲染成 `\M-^X`; + * 3) 继续保留此前验证有效的 `%` 行尾标记抑制设置。 + * + * 说明:该命令带 shell 条件判断,bash/fish 等非 zsh 环境会直接跳过。 + */ + ensureShellCompatibility = async (): Promise => { + if (!transport || shellCompatBootstrapped) { + return; + } + shellCompatBootstrapped = true; + try { + await transport.send(shellCompatInitCommand); + } catch { + // 不阻塞主连接:失败时仅回退为“不注入兼容命令”。 + } + }; + + const markConnectedState = async (): Promise => { + if (markedConnected) return; + markedConnected = true; + clearResumableState(); + reconnectAttempts.value = 0; + assertState("connected"); + await logStore.markStatus(sessionId, "connected"); + await serverStore.markConnected(server.id); + appStore.notify("info", "SSH 通道已建立"); + emitSessionEvent("connected", { serverId: server.id, serverName: server.name }); + await ensureShellCompatibility?.(); + }; + + offTransport = transport.on(async (event) => { + if (event.type === "stdout") { + await markConnectedState(); + const nextData = consumeAiRuntimeOutput(consumeCodexBootstrapOutput(event.data)); + if (nextData) { + appendTerminal(nextData); + emitSessionEvent("stdout", { data: nextData, serverId: server.id }); + } + } + + if (event.type === "stderr") { + const nextData = consumeAiRuntimeOutput(consumeCodexBootstrapOutput(event.data)); + if (nextData) { + appendTerminal(nextData); + emitSessionEvent("stderr", { data: nextData, serverId: server.id }); + } + } + + if (event.type === "latency") { + latencySamples.push(event.data); + if (latencySamples.length > LATENCY_SAMPLE_WINDOW) { + latencySamples.shift(); + } + const average = Math.round( + latencySamples.reduce((sum, sample) => sum + sample, 0) / latencySamples.length + ); + latencyMs.value = average; + emitSessionEvent("latency", { latency: average, serverId: server.id }); + } + + if (event.type === "disconnect") { + settleCodexBootstrapGuardAsResult(false); + assertState("disconnected"); + latencyMs.value = 0; + latencySamples.length = 0; + if (event.reason === "ws_closed" && currentServerId.value) { + markResumableState(currentServerId.value); + pendingCodexResumeAfterReconnect = activeAiProvider.value === "codex"; + } else { + clearResumableState(); + syncActiveAiProvider(""); + } + appStore.notify("warn", toFriendlyDisconnectReason(event.reason)); + await logStore.markStatus(sessionId, "disconnected", event.reason); + emitSessionEvent("disconnected", { reason: event.reason, serverId: server.id }); + + if ( + settingsStore.settings.autoReconnect && + !autoReconnectSuppressed && + !AUTO_RECONNECT_IGNORED_REASONS.has(event.reason) && + reconnectAttempts.value < settingsStore.settings.reconnectLimit + ) { + scheduleReconnect(server); + } else { + persistSnapshotLater(); + } + } + + if (event.type === "connected") { + if (event.fingerprint) { + const trusted = await settingsStore.verifyAndPersistHostFingerprint( + event.fingerprintHostPort || `${server.host}:${server.port}`, + event.fingerprint + ); + if (!trusted) { + await disconnect("host_key_rejected", false); + const appStore = useAppStore(); + appStore.notify("error", "主机指纹未被信任,连接已断开"); + return; + } + return; + } + // 无指纹的 connected 事件表示网关侧 shell 已就绪。 + lastConnectWasResume = event.resumed === true; + await markConnectedState(); + if (lastConnectWasResume) { + pendingCodexResumeAfterReconnect = false; + } else if (pendingCodexResumeAfterReconnect && activeAiProvider.value === "codex") { + void resumeCodexAfterReconnect(server.projectPath); + } + } + + if (event.type === "error") { + settleCodexBootstrapGuardAsResult(false); + assertState("error"); + latencyMs.value = 0; + latencySamples.length = 0; + clearResumableState(); + syncActiveAiProvider(""); + appStore.notify("error", `连接错误:${toFriendlyError(event.message || event.code)}`); + await logStore.markStatus(sessionId, "error", event.message); + persistSnapshotLater(); + } + }); + + try { + assertState("auth_pending"); + await transport.connect({ + host: server.jumpHost?.enabled ? server.jumpHost.host : server.host, + port: server.jumpHost?.enabled ? server.jumpHost.port : server.port, + username: server.jumpHost?.enabled ? server.jumpHost.username : server.username, + clientSessionKey: activeConnectionKey.value, + credential: server.jumpHost?.enabled && credentials.jump ? credentials.jump : credentials.target, + ...(server.jumpHost?.enabled && credentials.jump + ? { + jumpHost: { + host: server.host, + port: server.port, + username: server.username, + credential: credentials.target, + knownHostFingerprint: settingsStore.knownHosts[`${server.host}:${server.port}`] + } + } + : {}), + knownHostFingerprint: server.jumpHost?.enabled + ? settingsStore.knownHosts[`${server.jumpHost.host}:${server.jumpHost.port}`] + : settingsStore.knownHosts[`${server.host}:${server.port}`], + cols: 140, + rows: 40 + }); + persistSnapshotLater(); + } catch (error) { + assertState("error"); + await logStore.markStatus(sessionId, "error", (error as Error).message); + persistSnapshotLater(); + throw error; + } + } + + async function disconnect(reason = "manual", userInitiated = true): Promise { + if (reconnectTimer) { + window.clearTimeout(reconnectTimer); + reconnectTimer = null; + } + + /** + * 本地明确发起的断开不应进入“SSH 非主动断开自动重连”分支。 + * 下一次用户显式连接时会在 connect() 开头重置该标记。 + */ + autoReconnectSuppressed = true; + if (transport) { + await transport.disconnect(reason); + transport = null; + } + pendingLfAfterCr = false; + shellCompatBootstrapped = false; + ensureShellCompatibility = null; + lastSentCols = 0; + lastSentRows = 0; + lastConnectWasResume = false; + + offTransport?.(); + offTransport = null; + settleCodexBootstrapGuardAsResult(false); + clearResumableState(); + syncActiveAiProvider(""); + + if (userInitiated) { + assertState("disconnected"); + const appStore = useAppStore(); + appStore.notify("info", "已断开连接"); + } + if (userInitiated && reason === "manual") { + currentServerId.value = ""; + } + persistSnapshotLater(); + } + + async function sendCommand( + command: string, + source: "manual" | "codex" | "copilot" | "plugin" = "manual", + markerType: "manual" | "cd" | "check" | "run" = "manual" + ): Promise { + if (!transport || state.value !== "connected") { + throw new Error("会话未连接"); + } + + const startedAt = performance.now(); + await transport.send(`${command}\r`); + const elapsed = Math.round(performance.now() - startedAt); + /** + * 不再在客户端本地追加“$ 命令”行: + * 1) 远端 shell 本身会回显命令; + * 2) 本地注入额外文本会打乱“终端显示状态”与“远端 shell 认知状态”的一致性, + * 在 zsh 下可能表现为每次回车后出现额外 `%` 行尾标记。 + * 命令审计信息仍通过 logStore.addMarker 保留,不影响日志能力。 + */ + + const logStore = useLogStore(); + if (currentSessionId.value) { + await logStore.addMarker(currentSessionId.value, { + command, + source, + markerType, + code: 0, + elapsedMs: elapsed + }); + } + } + + async function sendInput(input: string, meta?: StdinMeta): Promise { + if (!transport || state.value !== "connected") { + throw new Error("会话未连接"); + } + /** + * 仅在包含非 ASCII 字符时,再次确保 shell UTF-8 初始化已执行。 + * 这样可以覆盖“初次 connected 时机过早,兼容命令尚未生效”的场景, + * 尤其是输入法空格选词触发 composition commit 的路径。 + */ + if (/[^\p{ASCII}]/u.test(input)) { + await ensureShellCompatibility?.(); + } + await transport.send(normalizeEnter(input), meta); + } + + async function runCodex( + projectPath: string, + sandbox: "read-only" | "workspace-write" | "danger-full-access" + ): Promise { + const plan = buildCodexPlan({ + projectPath, + sandbox + }); + const cdStep = plan.find((step) => step.step === "cd"); + const runStep = plan.find((step) => step.step === "run"); + if (!cdStep || !runStep) { + throw new Error("Codex 启动计划不完整"); + } + + const normalizedPath = String(projectPath || "~").trim() || "~"; + const bootstrapResultPromise = startCodexBootstrapGuard(normalizedPath); + + const bootstrapScript = + `__rc_codex_path_ok=1; __rc_codex_bin_ok=1; ${cdStep.command} >/dev/null 2>&1 || __rc_codex_path_ok=0; ` + + `command -v codex >/dev/null 2>&1 || __rc_codex_bin_ok=0; ` + + `[ "$__rc_codex_path_ok" -eq 1 ] || printf '${CODEX_BOOTSTRAP_TOKEN_DIR_MISSING}\\n'; ` + + `[ "$__rc_codex_bin_ok" -eq 1 ] || printf '${CODEX_BOOTSTRAP_TOKEN_CODEX_MISSING}\\n'; ` + + `if [ "$__rc_codex_path_ok" -eq 1 ] && [ "$__rc_codex_bin_ok" -eq 1 ]; then printf '${CODEX_BOOTSTRAP_TOKEN_READY}\\n'; ${runStep.command}; __rc_ai_exit_code=$?; ${buildAiExitPrintfCommand("codex")}; exit "$__rc_ai_exit_code"; fi`; + /** + * 强制在 POSIX sh 下执行 bootstrap: + * - 远端默认 shell 可能是 csh/tcsh,`>/dev/null 2>&1` 等重定向语法会报 + * “Ambiguous output redirect.”,导致 token 无法产出; + * - 统一走 `sh -lc`,让预检与 token 协议稳定可解析。 + */ + const bootstrapCommand = `sh -lc "${escapeForDoubleQuotedShellArg(bootstrapScript)}"`; + + try { + await sendCommand(bootstrapCommand, "codex", "run"); + } catch (error) { + settleCodexBootstrapGuardAsError(error instanceof Error ? error : new Error(String(error))); + throw error; + } + + const launched = await bootstrapResultPromise; + if (launched) { + pendingCodexResumeAfterReconnect = false; + activeCodexSandboxMode.value = normalizeCodexSandboxMode(sandbox); + syncActiveAiProvider("codex"); + } else { + syncActiveAiProvider(""); + } + return launched; + } + + /** + * Copilot 没有 Codex 的 bootstrap token,但仍需: + * 1. 先切换到服务器配置的项目目录; + * 2. 退出时打印 AI 退出标记,确保前台锁和按钮高亮能自动回落; + * 3. 仅接受固定命令枚举,避免命令注入。 + */ + async function runCopilot(projectPath: string, command: CopilotCommand): Promise { + const script = + `${buildCdCommand(projectPath)} && ${command}; ` + + `__rc_ai_exit_code=$?; ${buildAiExitPrintfCommand("copilot")}; exit "$__rc_ai_exit_code"`; + const wrappedCommand = `sh -lc "${escapeForDoubleQuotedShellArg(script)}"`; + await sendCommand(wrappedCommand, "copilot", "run"); + syncActiveAiProvider("copilot"); + return true; + } + + function clearTerminal(): void { + /** + * Codex 前台态下保留当前屏幕: + * 1. 用户此时处于 AI 交互上下文,手动清空本地缓冲没有实际价值; + * 2. UI 会同步把 Clear 按钮置灰,这里再兜底一次,避免漏判后误清。 + */ + if (state.value === "connected" && activeAiProvider.value === "codex") { + return; + } + buffersByKey.value[activeConnectionKey.value] = createBufferBucket(); + outputRevision.value += 1; + persistSnapshotLater(); + } + + async function resize(cols: number, rows: number): Promise { + if (!transport || state.value !== "connected") return; + if (cols === lastSentCols && rows === lastSentRows) return; + lastSentCols = cols; + lastSentRows = rows; + await transport.resize(cols, rows); + } + + function scheduleReconnect(server: ServerProfile): void { + reconnectAttempts.value += 1; + assertState("reconnecting"); + persistSnapshotLater(); + const delay = Math.min(5000, reconnectAttempts.value * 1200); + reconnectTimer = window.setTimeout(() => { + connect(server, true).catch((error) => { + const appStore = useAppStore(); + appStore.notify("error", formatActionError("自动重连失败", error)); + }); + }, delay); + } + + function cancelReconnect(reason = "route_leave"): void { + autoReconnectSuppressed = true; + if (reconnectTimer) { + window.clearTimeout(reconnectTimer); + reconnectTimer = null; + } + + if (["reconnecting", "connecting", "auth_pending"].includes(state.value)) { + assertState("disconnected"); + const appStore = useAppStore(); + appStore.notify("info", `已停止自动重连:${reason}`); + persistSnapshotLater(); + } + } + + return { + ensureBootstrapped, + bootstrap, + state, + activeAiProvider, + lines, + outputRevision, + latencyMs, + connected, + currentServerId, + isServerAiActive, + isServerResumable, + get lastConnectWasResume() { + return lastConnectWasResume; + }, + connect, + disconnect, + sendInput, + sendCommand, + runCodex, + runCopilot, + clearTerminal, + resize, + cancelReconnect + }; +}); diff --git a/apps/web/src/stores/settingsStore.ts b/apps/web/src/stores/settingsStore.ts new file mode 100644 index 0000000..733682e --- /dev/null +++ b/apps/web/src/stores/settingsStore.ts @@ -0,0 +1,91 @@ +import { defineStore } from "pinia"; +import { computed, ref } from "vue"; +import { verifyHostKey } from "@remoteconn/shared"; +import type { GlobalSettings } from "@/types/app"; +import { defaultSettings, normalizeGlobalSettings, resolveGatewayUrl, resolveGatewayToken } from "@/utils/defaults"; +import { getKnownHosts, getSettings, setSettings, upsertKnownHost } from "@/services/storage/db"; + +/** + * 设置与主题管理。 + */ +export const useSettingsStore = defineStore("settings", () => { + const settings = ref(normalizeGlobalSettings(defaultSettings)); + const knownHosts = ref>({}); + const loaded = ref(false); + let bootstrapPromise: Promise | null = null; + + const themeVars = computed(() => ({ + "--bg": settings.value.uiBgColor, + "--accent": settings.value.uiAccentColor, + "--text": settings.value.uiTextColor, + "--btn": settings.value.uiBtnColor, + "--shell-bg": settings.value.shellBgColor, + "--shell-text": settings.value.shellTextColor, + "--shell-accent": settings.value.shellAccentColor + })); + + async function ensureBootstrapped(): Promise { + if (loaded.value) return; + if (bootstrapPromise) { + await bootstrapPromise; + return; + } + bootstrapPromise = (async () => { + settings.value = normalizeGlobalSettings(await getSettings()); + knownHosts.value = await getKnownHosts(); + loaded.value = true; + })(); + + try { + await bootstrapPromise; + } finally { + bootstrapPromise = null; + } + } + + async function bootstrap(): Promise { + await ensureBootstrapped(); + } + + async function save(next: GlobalSettings): Promise { + const normalized = normalizeGlobalSettings(next); + settings.value = normalized; + await setSettings({ ...normalized }); + } + + /** 运行时推导网关 URL,不从持久化设置读取 */ + const gatewayUrl = computed(() => resolveGatewayUrl(settings.value)); + /** 运行时推导网关 Token,不从持久化设置读取 */ + const gatewayToken = computed(() => resolveGatewayToken(settings.value)); + + async function verifyAndPersistHostFingerprint(hostPort: string, incomingFingerprint: string): Promise { + const result = await verifyHostKey({ + hostPort, + incomingFingerprint, + policy: settings.value.hostKeyPolicy, + knownHosts: knownHosts.value, + onConfirm: async ({ hostPort: host, fingerprint, reason }) => { + return window.confirm(`${reason}\n主机: ${host}\n指纹: ${fingerprint}\n是否信任并继续?`); + } + }); + + if (result.accepted && result.updated[hostPort]) { + knownHosts.value = { ...result.updated }; + await upsertKnownHost(hostPort, result.updated[hostPort]); + } + + return result.accepted; + } + + return { + settings, + knownHosts, + themeVars, + gatewayUrl, + gatewayToken, + ensureBootstrapped, + bootstrap, + save, + verifyAndPersistHostFingerprint + }; +}); diff --git a/apps/web/src/stores/voiceRecordStore.test.ts b/apps/web/src/stores/voiceRecordStore.test.ts new file mode 100644 index 0000000..3b97d7f --- /dev/null +++ b/apps/web/src/stores/voiceRecordStore.test.ts @@ -0,0 +1,212 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createPinia, setActivePinia } from "pinia"; +import type { VoiceRecord } from "@/types/app"; + +const { dbState, dbMock } = vi.hoisted(() => { + const state = { + voiceRecords: [] as VoiceRecord[] + }; + + const cloneRecord = (item: VoiceRecord): VoiceRecord => ({ + ...item + }); + + const upsertRecord = (item: VoiceRecord): void => { + const index = state.voiceRecords.findIndex((row) => row.id === item.id); + if (index >= 0) { + state.voiceRecords[index] = cloneRecord(item); + } else { + state.voiceRecords.push(cloneRecord(item)); + } + }; + + const db = { + voiceRecords: { + toArray: vi.fn(async () => state.voiceRecords.map((row) => cloneRecord(row))), + put: vi.fn(async (item: VoiceRecord) => { + upsertRecord(item); + }), + delete: vi.fn(async (id: string) => { + state.voiceRecords = state.voiceRecords.filter((row) => row.id !== id); + }) + } + }; + + return { + dbState: state, + dbMock: db + }; +}); + +vi.mock("@/services/storage/db", () => ({ + db: dbMock +})); + +import { useVoiceRecordStore } from "./voiceRecordStore"; + +describe("voiceRecordStore", () => { + beforeEach(() => { + setActivePinia(createPinia()); + dbState.voiceRecords = []; + dbMock.voiceRecords.toArray.mockClear(); + dbMock.voiceRecords.put.mockClear(); + dbMock.voiceRecords.delete.mockClear(); + }); + + it("启动后按 createdAt 倒序输出 latest", async () => { + dbState.voiceRecords = [ + { + id: "r1", + content: "one", + createdAt: "2026-02-27T00:00:01.000Z", + updatedAt: "2026-02-27T00:00:01.000Z", + serverId: "s1", + category: "问题", + contextLabel: "alpha-demo" + }, + { + id: "r2", + content: "two", + createdAt: "2026-02-27T00:00:03.000Z", + updatedAt: "2026-02-27T00:00:03.000Z", + serverId: "s1", + category: "优化", + contextLabel: "alpha-demo" + }, + { + id: "r3", + content: "three", + createdAt: "2026-02-27T00:00:02.000Z", + updatedAt: "2026-02-27T00:00:02.000Z", + serverId: "s2", + category: "灵感", + contextLabel: "beta-api" + } + ]; + + const store = useVoiceRecordStore(); + await store.ensureBootstrapped(); + + expect(store.latest.map((item) => item.id)).toEqual(["r2", "r3", "r1"]); + }); + + it("addRecord 写入前会 trim,空文本不入库", async () => { + const store = useVoiceRecordStore(); + await store.ensureBootstrapped(); + + const empty = await store.addRecord(" ", "s1"); + expect(empty).toBeNull(); + expect(dbMock.voiceRecords.put).toHaveBeenCalledTimes(0); + + const created = await store.addRecord(" hello world ", "s1", { + category: "新需求", + contextLabel: "alpha-demo" + }); + expect(created).not.toBeNull(); + expect(created?.content).toBe("hello world"); + expect(created?.category).toBe("新需求"); + expect(created?.contextLabel).toBe("alpha-demo"); + expect(dbMock.voiceRecords.put).toHaveBeenCalledTimes(1); + expect(store.latest[0]?.content).toBe("hello world"); + }); + + it("removeRecord 会更新内存并持久化删除", async () => { + dbState.voiceRecords = [ + { + id: "r1", + content: "one", + createdAt: "2026-02-27T00:00:01.000Z", + updatedAt: "2026-02-27T00:00:01.000Z", + serverId: "s1", + category: "问题", + contextLabel: "alpha-demo" + } + ]; + + const store = useVoiceRecordStore(); + await store.ensureBootstrapped(); + + await store.removeRecord("r1"); + expect(dbMock.voiceRecords.delete).toHaveBeenCalledWith("r1"); + expect(store.records.length).toBe(0); + }); + + it("updateRecord 会更新内容、分类和 updatedAt", async () => { + dbState.voiceRecords = [ + { + id: "r1", + content: "old", + createdAt: "2026-02-27T00:00:01.000Z", + updatedAt: "2026-02-27T00:00:01.000Z", + serverId: "s1", + category: "问题", + contextLabel: "alpha-demo" + } + ]; + + const store = useVoiceRecordStore(); + await store.ensureBootstrapped(); + + const updated = await store.updateRecord({ + id: "r1", + content: " new content ", + category: "优化" + }); + + expect(updated?.content).toBe("new content"); + expect(updated?.category).toBe("优化"); + expect(updated?.updatedAt).not.toBe("2026-02-27T00:00:01.000Z"); + expect(dbMock.voiceRecords.put).toHaveBeenCalledTimes(1); + }); + + it("search 会按关键字与分类过滤", async () => { + dbState.voiceRecords = [ + { + id: "r1", + content: "修正连接超时", + createdAt: "2026-02-27T00:00:01.000Z", + updatedAt: "2026-02-27T00:00:01.000Z", + serverId: "s1", + category: "优化", + contextLabel: "alpha-demo" + }, + { + id: "r2", + content: "记录新的终端问题", + createdAt: "2026-02-27T00:00:02.000Z", + updatedAt: "2026-02-27T00:00:02.000Z", + serverId: "s2", + category: "问题", + contextLabel: "beta-api" + } + ]; + + const store = useVoiceRecordStore(); + await store.ensureBootstrapped(); + + expect(store.search({ keyword: "alpha" }).map((item) => item.id)).toEqual(["r1"]); + expect(store.search({ category: "问题" }).map((item) => item.id)).toEqual(["r2"]); + }); + + it("exportRecords 会包含分类、上下文和更新时间", async () => { + dbState.voiceRecords = [ + { + id: "r1", + content: "修正连接超时", + createdAt: "2026-02-27T00:00:01.000Z", + updatedAt: "2026-02-27T00:00:03.000Z", + serverId: "s1", + category: "优化", + contextLabel: "alpha-demo" + } + ]; + + const store = useVoiceRecordStore(); + await store.ensureBootstrapped(); + + const exported = store.exportRecords(); + expect(exported).toContain("updatedAt"); + expect(exported).toContain("category: 优化"); + expect(exported).toContain("contextLabel: alpha-demo"); + }); +}); diff --git a/apps/web/src/stores/voiceRecordStore.ts b/apps/web/src/stores/voiceRecordStore.ts new file mode 100644 index 0000000..70bed9c --- /dev/null +++ b/apps/web/src/stores/voiceRecordStore.ts @@ -0,0 +1,183 @@ +import { defineStore } from "pinia"; +import { computed, ref, toRaw } from "vue"; +import type { VoiceRecord } from "@/types/app"; +import { db } from "@/services/storage/db"; +import { nowIso } from "@/utils/time"; +import { DEFAULT_VOICE_RECORD_CATEGORY_FALLBACK } from "@/utils/defaults"; + +interface AddVoiceRecordInput { + category?: string; + contextLabel?: string; +} + +interface UpdateVoiceRecordInput { + id: string; + content: string; + category: string; +} + +interface SearchVoiceRecordInput { + keyword?: string; + category?: string; +} + +/** + * 闪念记录存储与导出。 + */ +export const useVoiceRecordStore = defineStore("voiceRecord", () => { + const records = ref([]); + const loaded = ref(false); + let bootstrapPromise: Promise | null = null; + + const latest = computed(() => [...records.value].sort((a, b) => +new Date(b.createdAt) - +new Date(a.createdAt))); + + function normalizeCategory(value: string): string { + const normalized = String(value ?? "").trim(); + return normalized || DEFAULT_VOICE_RECORD_CATEGORY_FALLBACK; + } + + /** + * 将历史记录补齐到最新结构,避免 UI 层反复做兼容判断。 + */ + function normalizeRecord(item: VoiceRecord): VoiceRecord { + const raw = toRaw(item); + const createdAt = String(raw.createdAt || nowIso()); + return { + id: String(raw.id), + content: String(raw.content ?? "").trim(), + createdAt, + updatedAt: String(raw.updatedAt || createdAt), + serverId: String(raw.serverId ?? ""), + category: normalizeCategory(raw.category), + contextLabel: String(raw.contextLabel ?? "") + }; + } + + async function ensureBootstrapped(): Promise { + if (loaded.value) return; + if (bootstrapPromise) { + await bootstrapPromise; + return; + } + bootstrapPromise = (async () => { + records.value = (await db.voiceRecords.toArray()).map((item) => normalizeRecord(item)); + loaded.value = true; + })(); + + try { + await bootstrapPromise; + } finally { + bootstrapPromise = null; + } + } + + async function bootstrap(): Promise { + await ensureBootstrapped(); + } + + /** + * 统一做实体快照,避免 Vue Proxy 直接写入 IndexedDB 触发 DataCloneError。 + */ + function toVoiceRecordEntity(item: VoiceRecord): VoiceRecord { + return normalizeRecord(item); + } + + async function addRecord(content: string, serverId = "", options: AddVoiceRecordInput = {}): Promise { + const normalizedContent = String(content ?? "").trim(); + if (!normalizedContent) { + return null; + } + + const timestamp = nowIso(); + const next: VoiceRecord = { + id: `voice-${crypto.randomUUID()}`, + content: normalizedContent, + createdAt: timestamp, + updatedAt: timestamp, + serverId: String(serverId || ""), + category: normalizeCategory(options.category ?? DEFAULT_VOICE_RECORD_CATEGORY_FALLBACK), + contextLabel: String(options.contextLabel ?? "") + }; + records.value.unshift(next); + await db.voiceRecords.put(toVoiceRecordEntity(next)); + return next; + } + + async function updateRecord(payload: UpdateVoiceRecordInput): Promise { + const recordId = String(payload.id || ""); + const normalizedContent = String(payload.content ?? "").trim(); + if (!recordId || !normalizedContent) { + return null; + } + + const index = records.value.findIndex((item) => item.id === recordId); + if (index < 0) { + return null; + } + + const current = records.value[index]; + if (!current) { + return null; + } + + const next: VoiceRecord = { + ...current, + content: normalizedContent, + category: normalizeCategory(payload.category), + updatedAt: nowIso() + }; + records.value[index] = next; + await db.voiceRecords.put(toVoiceRecordEntity(next)); + return next; + } + + async function removeRecord(recordId: string): Promise { + const nextId = String(recordId || ""); + if (!nextId) return; + records.value = records.value.filter((item) => item.id !== nextId); + await db.voiceRecords.delete(nextId); + } + + function search(input: SearchVoiceRecordInput = {}): VoiceRecord[] { + const keyword = String(input.keyword ?? "").trim().toLowerCase(); + const category = String(input.category ?? "").trim(); + return latest.value.filter((item) => { + if (category && item.category !== category) { + return false; + } + if (!keyword) { + return true; + } + const haystack = [item.content, item.category, item.contextLabel, item.createdAt].join(" ").toLowerCase(); + return haystack.includes(keyword); + }); + } + + function exportRecords(): string { + const rows = latest.value.map((item) => { + return [ + `## ${item.id}`, + `- createdAt: ${item.createdAt}`, + `- updatedAt: ${item.updatedAt}`, + `- serverId: ${item.serverId || "--"}`, + `- category: ${item.category}`, + `- contextLabel: ${item.contextLabel || "--"}`, + `- content:`, + item.content + ].join("\n"); + }); + return [`# RemoteConn Voice Records Export ${nowIso()}`, "", ...rows].join("\n\n"); + } + + return { + records, + latest, + ensureBootstrapped, + bootstrap, + addRecord, + updateRecord, + removeRecord, + search, + exportRecords + }; +}); diff --git a/apps/web/src/styles/main.css b/apps/web/src/styles/main.css new file mode 100644 index 0000000..e9f08cd --- /dev/null +++ b/apps/web/src/styles/main.css @@ -0,0 +1,3315 @@ +:root { + --bg: #192b4d; + --shell-bg: #192b4d; + --shell-text: #e6f0ff; + --shell-accent: #9ca9bf; + /* 主界面卡片跟随当前主题推导表面色和描边,避免浅色模式下仍沿用深色基线。 */ + --surface: color-mix(in srgb, var(--bg) 88%, var(--text) 12%); + --surface-border: color-mix(in srgb, var(--text) 18%, transparent); + --bottom-bar: color-mix(in srgb, var(--bg) 92%, var(--text) 8%); + --text: #e6f0ff; + --muted: color-mix(in srgb, var(--text) 58%, var(--bg) 42%); + --accent: #67d1ff; + --danger: #ff7f92; + --success: #79f3bd; + --app-viewport-width: 100vw; + --app-viewport-height: 100dvh; + --app-viewport-offset-top: 0px; + --app-viewport-offset-left: 0px; + --focus-scroll-margin-top: 25vh; +} + +* { + box-sizing: border-box; +} + +html, +body, +#app { + height: 100%; + margin: 0; + touch-action: manipulation; + overscroll-behavior: none; +} + +body { + color: var(--text); + font-family: "PingFang SC", "SF Pro Text", "Microsoft YaHei", sans-serif; + background: var(--bg); + overflow: hidden; +} + +h3, +h4 { + margin: 0; + padding: 1em; +} + +h4 { + border-bottom: 1px solid var(--surface-border); +} + +button { + position: relative; + overflow: visible; +} + +button::before { + content: ""; + position: absolute; + inset: -8px; +} + +.app-shell { + /** + * 让应用根容器始终与 visualViewport 矩形重合: + * - top/left 跟随 offset; + * - width/height 跟随可视区尺寸。 + * 这样键盘弹出时,底部工具栏会稳定贴在键盘上沿。 + */ + position: fixed; + left: var(--app-viewport-offset-left); + top: var(--app-viewport-offset-top); + width: var(--app-viewport-width); + height: var(--app-viewport-height); + overflow: hidden; +} + +.app-canvas { + width: 100%; + height: 100%; + background: var(--bg); + overflow: hidden; + display: grid; + grid-template-rows: minmax(0, 1fr) 52px; +} + +.app-canvas.without-bottom-bar { + grid-template-rows: minmax(0, 1fr); +} + +/* 输入态隐藏底部框架时,强制移除底部层级的任何残留占位/覆盖。 */ +.app-canvas.bottom-frame-hidden { + grid-template-rows: minmax(0, 1fr) !important; +} + +.app-canvas.bottom-frame-hidden .screen-content { + min-height: 100%; +} + +.screen-content { + min-height: 0; + overflow: hidden; + display: flex; + flex-direction: column; + overscroll-behavior: none; +} + +.screen-content > *, +.server-list-scroll { + flex: 1; + min-height: 0; +} + +/* 软键盘输入态:scrollIntoView(start) 时把目标保留在上到下约 1/4 位置。 */ +.viewport-focus-target { + scroll-margin-top: var(--focus-scroll-margin-top); +} + +.bottom-bar { + background: var(--bg); + border-top: 1px solid color-mix(in srgb, var(--accent) 60%, transparent); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 32px 0 16px; +} + +.terminal-bottom-bar { + margin-top: -1px; +} + +.bottom-right-actions { + display: inline-flex; + align-items: center; + gap: 12px; +} + +.icon-btn { + width: 24px; + height: 24px; + border: 0; + border-radius: 999px; + background: transparent; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 0.95; +} + +.icon-btn:hover { + background: rgba(110, 154, 216, 0.2); +} + +.icon-btn:disabled, +.connect-icon-btn:disabled, +.server-tag-order-btn:disabled, +.terminal-connection-switch:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +/* CSS mask icon: 用 --btn 变量染色 SVG 图标 */ +.icon-mask { + display: block; + width: 22px; + height: 22px; + background-color: var(--btn); + -webkit-mask: var(--icon) no-repeat center / contain; + mask: var(--icon) no-repeat center / contain; + flex-shrink: 0; +} + +.bottom-nav-btn.active { + background: rgba(103, 209, 255, 0.2); +} + +.page-root { + display: flex; + flex-direction: column; + min-height: 0; + height: 100%; + gap: 0; + padding-bottom: 0; +} + +.page-toolbar { + background: var(--bg); + border-bottom: 1px solid color-mix(in srgb, var(--accent) 60%, transparent); + display: flex; + align-items: center; + gap: 16px; + min-height: 52px; + height: 52px; + padding: 0 16px; +} + +.page-toolbar .toolbar-left, +.page-toolbar .toolbar-right, +.server-row-actions { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.page-toolbar .toolbar-spacer { + flex: 1; +} + +.settings-save-status { + font-size: 12px; + color: color-mix(in srgb, var(--text) 65%, transparent); +} + +.page-title, +.server-settings-section-title, +.server-name { + margin: 0; + font-size: 16px; + line-height: 1; + font-weight: 600; + color: var(--text); +} + +.page-panels { + flex: 1; + min-height: 0; + display: grid; + gap: 16px; +} + +.page-panels.connect-panels { + grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); +} + +.surface-panel { + background: var(--surface); + border-top: 1px solid var(--surface-border); + border-bottom: 1px solid var(--surface-border); + padding: 0 12px 10px; + min-height: 0; + display: flex; + flex-direction: column; + gap: 10px; + overflow: hidden; +} + +/* 60:45 / 269:881 / 269:916 / 269:1261:页面主体为纯底色,不使用半透明面板。 */ +.settings-page .surface-panel, +.logs-page .surface-panel, +.plugins-page .surface-panel, +.server-settings-page .surface-panel, +.records-page .surface-panel { + background: var(--bg); + border-top-color: transparent; + border-bottom-color: transparent; +} + +.logs-page .surface-panel { + padding-top: 16px; +} + +.surface-scroll { + min-height: 0; + overflow: auto; + -webkit-overflow-scrolling: touch; + overscroll-behavior: contain; +} + +.actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; +} + +.btn { + border: 1px solid rgba(141, 187, 255, 0.4); + background: rgba(255, 255, 255, 0.03); + color: var(--text); + border-radius: 10px; + padding: 6px 10px; + cursor: pointer; +} + +.btn.primary { + background: color-mix(in srgb, var(--btn) 30%, transparent); + border-color: color-mix(in srgb, var(--btn) 85%, transparent); +} + +.btn.danger { + border-color: rgba(255, 122, 151, 0.6); + background: rgba(255, 122, 151, 0.16); +} + +.input, +.textarea, +select.input { + width: 100%; + border-radius: 10px; + border: 1px solid rgba(141, 187, 255, 0.3); + background: color-mix(in srgb, var(--text) 30%, transparent); + color: var(--text); + padding: 8px 10px; +} + +.input, +select.input { + height: 32px; +} + +.field-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.field.wide { + grid-column: span 2; +} + +.field { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 13px; +} + +.field > span { + color: color-mix(in srgb, var(--text) 65%, transparent); +} + +.list-stack { + display: flex; + flex-direction: column; + gap: 8px; + min-height: 0; + overflow: auto; +} + +.list-item, +.log-item, +.plugin-item { + text-align: left; + border: 0; + background: rgba(255, 255, 255, 0.03); + border-radius: 10px; + padding: 10px; + box-shadow: inset 0 0 0 1px var(--surface-border); +} + +.list-item.active { + border-color: rgba(103, 209, 255, 0.8); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 78%, transparent); +} + +.records-page { + min-width: 0; + width: 100%; + max-width: 100%; + overflow-x: hidden; + gap: 0; + padding-bottom: 0; +} + +.records-panel { + min-width: 0; + width: 100%; + max-width: 100%; + padding: 16px 12px 12px; + display: flex; + flex-direction: column; + gap: 0; +} + +.records-search-wrap { + position: relative; + padding-top: 0; + padding-bottom: 0; +} + +.records-search-shell { + grid-template-columns: minmax(0, 1fr) auto; +} + +.records-filter-btn { + width: 34.4px; + min-width: 34.4px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.records-filter-btn.active { + background: rgba(255, 255, 255, 0.08); +} + +.records-filter-arrow { + width: 0; + height: 0; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-top: 7px solid currentcolor; +} + +.records-filter-menu { + position: absolute; + right: 0; + top: calc(100% - 10px); + z-index: 6; + min-width: 132px; + padding: 6px; + border: 1px solid rgba(141, 187, 255, 0.28); + border-radius: 14px; + background: rgba(8, 18, 32, 0.96); + box-shadow: 0 18px 42px rgba(0, 0, 0, 0.28); + display: flex; + flex-direction: column; + gap: 4px; +} + +.records-filter-item { + padding: 8px 10px; + border: 1px solid transparent; + border-radius: 10px; + font-size: 13px; + line-height: 1.2; + text-align: left; +} + +.records-filter-item.active { + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.18); +} + +.records-list-scroll { + flex: 1; + min-height: 0; + min-width: 0; + overflow-x: hidden; +} + +.records-list { + list-style: none; + margin: 0; + padding: 0; + min-width: 0; + width: 100%; + display: flex; + flex-direction: column; + gap: 8px; +} + +.records-item-shell { + position: relative; + display: flex; + align-items: stretch; + gap: 1px; + min-width: 0; + width: 100%; + max-width: 100%; + overflow: hidden; + touch-action: pan-y; +} + +.records-item-actions-mobile { + position: absolute; + right: 0; + top: 0; + bottom: 0; + width: 72px; + display: flex; + flex-direction: column; + gap: 1px; + opacity: 0; + pointer-events: none; + transition: opacity 0.12s ease; +} + +.records-item-shell-delete-visible .records-item-actions-mobile { + opacity: 1; + pointer-events: auto; +} + +.records-item-action-mobile { + flex: 1 1 0; + width: 100%; + border: 0; + border-radius: 0; + color: var(--bg); + font-size: 12px; + line-height: 1; + cursor: pointer; +} + +.records-item-action-mobile:first-child { + border-radius: 16px 16px 0 0; +} + +.records-item-action-mobile:last-child { + border-radius: 0 0 16px 16px; +} + +.records-item-action-mobile-copy { + background: color-mix(in srgb, var(--text) 18%, transparent); +} + +.records-item-action-mobile-delete { + background: color-mix(in srgb, var(--accent) 28%, transparent); +} + +.records-item { + position: relative; + display: flex; + align-items: stretch; + gap: 0; + min-width: 0; + width: 0; + flex: 1 1 0; + border: 1px solid color-mix(in srgb, var(--text) 22%, transparent); + background: color-mix(in srgb, var(--text) 5%, transparent); + border-radius: 16px; + padding: 12px; + transform: translateX(0); + touch-action: pan-y; + transition: transform 0.16s ease; +} + +.records-item-category { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + align-self: stretch; + width: fit-content; + min-width: 28px; + border: 1px solid transparent; + border-radius: 14px; + font-size: 12px; + line-height: 1; + font-weight: 700; + padding: 10px 6px; + text-align: center; + writing-mode: vertical-rl; + text-orientation: upright; + letter-spacing: 0.08em; + opacity: 0.5; + cursor: pointer; + transition: + opacity 140ms ease, + box-shadow 140ms ease; +} + +.records-item-category:hover { + opacity: 0.82; + box-shadow: 0 10px 22px rgba(15, 35, 68, 0.18); +} + +.records-item-main { + min-width: 0; + flex: 1; + display: flex; + flex-direction: column; + gap: 8px; +} + +.records-item-header { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 8px; + min-width: 0; + flex-wrap: wrap; +} + +.records-item-time { + font-size: 12px; + color: color-mix(in srgb, var(--text) 70%, transparent); +} + +.records-item-context { + min-width: 0; + max-width: 100%; + font-size: 12px; + color: color-mix(in srgb, var(--text) 60%, transparent); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.records-item-content-btn { + padding: 0; + border: 0; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; +} + +.records-item-content { + margin: 0; + font-size: 13px; + line-height: 1.5; + color: var(--text); + white-space: pre-wrap; + word-break: break-word; +} + +.records-empty-tip { + margin: 0; + color: var(--muted); + font-size: 14px; + text-align: center; + padding: 16px 8px; +} + +.records-bottom-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding-top: 8px; +} + +.records-pagination { + display: inline-flex; + align-items: center; + justify-content: flex-start; + gap: 8px; +} + +.records-pagination-text { + min-width: 108px; + text-align: center; + font-size: 12px; + color: color-mix(in srgb, var(--text) 70%, transparent); +} + +.records-dialog { + position: relative; + gap: 12px; +} + +.records-dialog.records-dialog--compact { + width: min(420px, calc(100vw - 32px)); +} + +.records-dialog.records-dialog--quick-category { + position: fixed; + width: auto; + padding: 8px; + background: rgba(8, 18, 32, 0.2); + backdrop-filter: blur(10px); + overflow: visible; +} + +.records-quick-category-list { + width: 100%; + overflow: hidden; +} + +.records-quick-category-list::-webkit-scrollbar { + display: none; +} + +.records-quick-category-cloud { + position: relative; + width: 100%; + height: 100%; +} + +.records-quick-category-pill { + position: absolute; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border: 1px solid transparent; + border-radius: 999px; + font-size: 11px; + line-height: 1.15; + font-weight: 600; + text-align: center; + white-space: normal; + overflow-wrap: anywhere; + box-shadow: 0 10px 22px rgba(15, 35, 68, 0.18); + transition: + border-color 140ms ease, + box-shadow 140ms ease, + opacity 140ms ease, + transform 140ms ease; +} + +.records-quick-category-pill.active { + box-shadow: + 0 0 0 2px rgba(255, 255, 255, 0.18), + 0 10px 22px rgba(42, 92, 182, 0.24); + transform: scale(1.05); +} + +.records-dialog-mask--quick-category { + background: transparent; + padding: 0; +} + +.records-dialog-field { + align-items: flex-start; +} + +.records-dialog-field--category { + padding-right: 36px; +} + +.records-dialog-field > span { + width: auto; + min-width: 0; +} + +.records-dialog-field--textarea .textarea { + min-height: 132px; +} + +.records-dialog-meta { + display: flex; + flex-direction: column; + gap: 4px; + padding-right: 36px; +} + +.records-dialog-cancel-btn { + position: absolute; + right: 16px; + top: 16px; +} + +.pill-option--compact { + min-height: 32px; + padding: 0 12px; + font-size: 12px; +} + +@media (max-width: 720px) { + .records-item-category { + min-width: 24px; + padding-inline: 5px; + } + + .records-bottom-bar { + flex-wrap: wrap; + } +} + +.item-selectors, +.server-tag-order-actions { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.item-selectors input { + accent-color: var(--accent); +} + +/* 32:52 服务器管理页:顶部工具栏 + 搜索框 + 单层服务器列表。 */ +.server-manager-page, +.server-settings-page { + /* + * 服务器管理相关页面必须跟随可视区宽度收缩: + * - min-width: 0 防止子级长内容把父级撑宽; + * - overflow-x: hidden 防止按钮扩展命中区、拖拽位移等产生横向滚动条。 + */ + min-width: 0; + width: 100%; + max-width: 100%; + overflow-x: hidden; + gap: 0; + padding-bottom: 0; +} + +/* 服务器管理页文本禁止选择,避免拖拽与长按时出现选中文字高亮。 */ +.server-manager-page, +.server-manager-page * { + -webkit-user-select: none; + user-select: none; +} + +.server-manager-toolbar, +.server-settings-topbar { + flex: 0 0 auto; + min-height: 52px; +} + +/* 服务器管理 / 日志 / 闪念页:分割线上下各留 16px,避免分割线直接贴内容。 */ +.server-manager-toolbar, +.logs-page .page-toolbar, +.records-toolbar { + padding: 0 16px; +} + +.server-manager-content { + flex: 1; + min-height: 0; + min-width: 0; + width: 100%; + padding: 16px 16px 16px; + display: flex; + flex-direction: column; + gap: 0; + overflow: hidden; +} + +.server-search-wrap { + flex: 0 0 auto; + padding: 0 0 16px; +} + +.server-search-shell { + display: grid; + grid-template-columns: minmax(0, 1fr) 34.4px; + align-items: center; + width: 100%; + height: 32px; + border: 0.8px solid #c5c5c7; + border-radius: 27.2px; + overflow: hidden; +} + +.server-search-input { + width: 100%; + height: 100%; + border: 0; + border-radius: 0; + background: transparent; + color: var(--text); + font-size: 11.2px; + line-height: normal; + padding: 0 8px; +} + +.server-search-input::placeholder { + color: #c5c5c7; +} + +.server-search-input:focus { + outline: none; +} + +.server-search-btn { + width: 100%; + height: 100%; + border: 0; + border-left: 0.8px solid #c5c5c7; + border-radius: 0 27.2px 27.2px 0; + background: var(--btn); + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.server-search-btn .icon-mask { + width: 14px; + height: 14px; + background-color: var(--bg); +} + +.server-list-stack { + display: flex; + flex-direction: column; + gap: 16px; +} + +.server-list-row { + display: grid; + grid-template-columns: 16px minmax(0, 1fr); + gap: 8px; + align-items: start; + padding-bottom: 16px; + border-bottom: 0.5px solid rgba(141, 187, 255, 0.35); + position: relative; + transition: + transform 0.18s ease, + box-shadow 0.18s ease, + background-color 0.18s ease; +} + +.server-list-row.is-drag-over { + background: color-mix(in srgb, var(--accent) 10%, transparent); + border-bottom-color: color-mix(in srgb, var(--accent) 72%, transparent); +} + +.server-list-row.is-dragging { + z-index: 8; + border-radius: 10px; + background: color-mix(in srgb, var(--surface) 85%, var(--accent) 15%); + box-shadow: + 0 16px 36px color-mix(in srgb, #000000 34%, transparent), + inset 0 0 0 1px color-mix(in srgb, var(--accent) 68%, transparent); + transform: translate3d(var(--drag-x, 0px), var(--drag-y, 0px), 0) scale(1.03); + pointer-events: none; +} + +.server-row-check { + width: 16px; + height: 16px; + margin-top: 1px; +} + +.server-check-input { + appearance: none; + width: 14px; + height: 14px; + margin: 0; + border: 1px solid #ffffff; + border-radius: 4px; + background: #ffffff; + position: relative; + display: inline-block; + cursor: pointer; +} + +.server-check-input:checked { + border-color: #5bd2ff; + background: #5bd2ff; +} + +.server-check-input:checked::after { + content: ""; + position: absolute; + left: 5px; + top: 2px; + width: 3px; + height: 7px; + border: solid #ffffff; + border-width: 0 2px 2px 0; + transform: rotate(45deg); +} + +.server-info { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} + +.server-info-clickable { + cursor: pointer; +} + +.server-info-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding-right: 0; +} + +/* connect 与 move 的间距在基础 8px 上增加约 1/3(8 -> 11)。 */ +.server-row-actions .connect-icon-btn + .server-move-btn { + margin-left: 3px; +} + +.server-copy-btn, +.server-ai-btn, +.server-move-btn, +.connect-icon-btn { + width: 22px; + height: 22px; + border: 0; + background: transparent; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: + background-color 0.16s ease, + box-shadow 0.16s ease, + opacity 0.16s ease; +} + +.server-copy-btn { + border-radius: 50%; + background: color-mix(in srgb, var(--accent) 28%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 65%, transparent); +} + +.server-copy-btn .icon-mask { + width: 12px; + height: 12px; + background-color: var(--text); +} + +.server-copy-btn:hover:not(:disabled) { + background: color-mix(in srgb, var(--accent) 36%, transparent); +} + +.server-ai-icon { + width: 22px; + height: 22px; + background-color: var(--btn); +} + +.server-ai-btn.is-connected { + border-radius: 50%; + background: color-mix(in srgb, var(--accent) 28%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 65%, transparent); +} + +.server-ai-btn.is-connected .server-ai-icon { + background-color: var(--accent); +} + +.server-move-btn { + border-radius: 6px; + cursor: grab; + touch-action: none; + user-select: none; + -webkit-user-select: none; +} + +.server-move-btn:active:not(:disabled) { + cursor: grabbing; +} + +.server-move-btn .icon-mask { + width: 8px; + height: 14px; + background-color: var(--accent); +} + +.server-move-btn:hover:not(:disabled) { + background: color-mix(in srgb, var(--accent) 16%, transparent); +} + +.server-copy-btn:disabled, +.server-ai-btn:disabled, +.server-move-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.connect-icon-btn { + border-radius: 50%; +} + +.connect-icon-btn.is-connected { + background: color-mix(in srgb, var(--accent) 28%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 65%, transparent); +} + +.connect-icon-btn.is-connected .icon-mask { + background-color: var(--accent); +} + +.server-info-meta { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 15px; + color: var(--text); +} + +.server-main { + margin: 0; + font-size: 14px; + line-height: 1; + width: 140px; + max-width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.server-auth { + margin: 0; + font-size: 12px; + line-height: 1; + opacity: 0.95; +} + +.server-recent { + margin: 0; + color: var(--text); + font-size: 14px; + line-height: 1; +} + +.server-tags { + display: flex; + align-items: center; + gap: 8px; + overflow: hidden; +} + +.server-tag { + height: 16px; + padding: 0 6px; + border-radius: 8px; + background: rgba(91, 210, 255, 0.6); + color: var(--text); + font-size: 10px; + line-height: 16px; + white-space: nowrap; +} + +.server-tag-project { + background: var(--accent); + color: var(--text); +} + +.server-empty-tip { + margin: 0; + font-size: 14px; + color: var(--muted); +} + +.server-tag-order-list { + margin-top: 8px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.server-tag-order-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + min-height: 26px; + padding: 4px 8px; + border: 1px solid color-mix(in srgb, var(--surface-border) 80%, transparent); + border-radius: 8px; + background: color-mix(in srgb, var(--surface) 55%, transparent); +} + +.server-tag-order-text { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + color: var(--text); +} + +.server-tag-order-btn { + border: 1px solid color-mix(in srgb, var(--surface-border) 85%, transparent); + border-radius: 6px; + background: color-mix(in srgb, var(--bg) 45%, transparent); + color: var(--text); + font-size: 11px; + line-height: 1; + padding: 4px 7px; + cursor: pointer; +} + +.server-settings-bottom { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 32px 0 16px; + height: 52px; + flex: 0 0 auto; +} + +.server-settings-bottom .bottom-right-actions { + gap: 12px; +} + +.server-settings-layout { + flex: 1; + min-height: 0; + width: 100%; + display: flex; + flex-direction: column; + gap: 16px; + padding-bottom: 0; +} + +.server-settings-content { + flex: 1; + min-height: 0; + min-width: 0; + width: 100%; + padding: 0 16px; + overflow-x: hidden; + overflow-y: auto; +} + +.server-list-scroll { + min-width: 0; + overflow-x: hidden; + overflow-y: auto; +} + +.server-settings-form { + min-height: 100%; + display: flex; + flex-direction: column; + gap: 18px; + padding: 4px 0 18px; +} + +.item-title { + font-weight: 600; + margin-bottom: 0; +} + +.item-sub { + font-size: 12px; + opacity: 0.9; +} + +/* 终端页面去掉 page-root 的通用 gap,让工具栏与终端区域无缝衔接 */ +.terminal-page { + gap: 0; +} + +.terminal-surface { + flex: 1; + min-height: 0; + background: transparent; + display: flex; + flex-direction: column; + margin-top: -1px; + overflow: hidden; +} + +.terminal-card { + display: grid; + grid-template-rows: 1fr auto; + gap: 0; + height: 100%; + background: transparent; + border-top-color: transparent; + border-bottom-color: transparent; + padding: 0; +} + +.terminal-loading { + display: flex; + align-items: center; + justify-content: center; + min-height: 0; + height: 100%; + font-size: 14px; + color: var(--muted); + user-select: none; +} + +.terminal-wrapper { + position: relative; + min-height: 0; + border: 0; + border-radius: 0; + background: rgba(0, 0, 0, 0); + /* + * xterm 会在右侧预留滚动条槽位。 + * 为保证左右“视觉等效 8px”: + * - 左侧用容器 padding-left: 8px; + * - 右侧不再额外加容器 padding,交由 xterm 的滚动槽位承担。 + */ + padding: 0 0 0 8px; + overflow: hidden; +} + +.terminal-disconnected-hint { + position: absolute; + left: 50%; + bottom: calc(10px + env(safe-area-inset-bottom, 0px)); + transform: translateX(-50%); + z-index: 3; + max-width: calc(100% - 24px); + margin: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + pointer-events: none; + user-select: none; + font-size: 12px; + line-height: 1.3; + color: var(--bg); + border-radius: 8px; + padding: 4px; + /* 文案反色:文字=背景色,底框=文字色,透明度 80%。 */ + background: color-mix(in srgb, var(--text) 80%, transparent); +} + +.terminal-touch-tools { + position: absolute; + right: 12px; + bottom: 0; + width: 22px; + height: 22px; + padding: 0; + box-sizing: border-box; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 12px; + z-index: 4; + opacity: 0.8; +} + +.terminal-touch-tools.is-expanded { + width: 97px; + /* + * 展开态工具项新增“粘贴”按钮后,总高度增加了 24px 按钮 + 12px 间距。 + * 若仍为 205px,会把底部键盘切换按钮挤出可视区,导致“展开后看不到键盘按钮”。 + */ + height: 241px; + padding: 10px 12px 5px 9px; +} + +.terminal-touch-tools-body { + width: 76px; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 12px; +} + +.terminal-touch-arrows { + width: 76px; + height: 48px; + position: relative; +} + +.terminal-touch-arrows .icon-mask { + width: 100%; + height: 100%; + display: block; +} + +.terminal-touch-arrows .icon-mask { + background-color: var(--shell-accent); +} + +/* 方向键命中区覆盖在 Figma 图标上,保持视觉与交互一致。 */ +.terminal-touch-arrow { + position: absolute; + width: 20px; + height: 20px; + border: 0; + border-radius: 50%; + background: transparent; + padding: 0; + touch-action: manipulation; +} + +.terminal-touch-arrow-up { + left: 28px; + top: 0; +} + +.terminal-touch-arrow-right { + left: 50px; + top: 14px; +} + +.terminal-touch-arrow-left { + left: 6px; + top: 14px; +} + +.terminal-touch-arrow-down { + left: 28px; + top: 28px; +} + +.terminal-touch-shortcut-btn, +.terminal-touch-enter-btn, +.terminal-touch-paste-btn { + width: 24px; + height: 24px; + border: 0; + border-radius: 6px; + /* 终端工具栏按钮统一使用终端强调色,确保和终端主题一致。 */ + background: color-mix(in srgb, var(--shell-accent) 35%, transparent); + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + touch-action: manipulation; +} + +.terminal-touch-shortcut-btn { + flex-direction: column; + color: var(--shell-accent); + font-family: "Play", "SF Pro Text", "PingFang SC", sans-serif; + font-size: 10px; + line-height: 0.8; +} + +/* enter / paste 图标统一走 mask 染色,和终端强调色联动。 */ +.terminal-touch-icon { + background-color: var(--shell-accent); +} + +.terminal-touch-shortcut-line { + display: block; + line-height: 0.8; +} + +.terminal-touch-tab-btn { + font-size: 10px; +} + +.terminal-touch-toggle-btn { + width: 22px; + height: 22px; + border: 0; + background: transparent; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + touch-action: manipulation; + color: var(--shell-accent); +} + +.terminal-touch-toggle-icon { + width: 22px; + height: 22px; + display: block; + background-color: currentcolor; + -webkit-mask: url("/icons/keyboard.svg") no-repeat center / contain; + mask: url("/icons/keyboard.svg") no-repeat center / contain; +} + +.terminal-voice-layer { + position: absolute; + inset: 0; + z-index: 5; + pointer-events: none; + -webkit-user-select: none; + user-select: none; + -webkit-touch-callout: none; +} + +.terminal-voice-hitbox { + position: absolute; + pointer-events: auto; + background: transparent; + z-index: 0; + touch-action: none; + -webkit-user-select: none; + user-select: none; + -webkit-touch-callout: none; +} + +.terminal-voice-button { + position: absolute; + width: 28px; + height: 36px; + border: 0; + padding: 0; + background: transparent; + color: var(--shell-accent); + pointer-events: auto; + touch-action: none; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: grab; + opacity: 0.95; + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.28)); + transition: + color 0.16s ease, + opacity 0.16s ease, + transform 0.16s ease; + z-index: 2; +} + +.terminal-voice-button.panel-visible { + color: var(--shell-bg); + opacity: 1; +} + +.terminal-voice-button::before { + /* + * 折叠态仅保护 voice.svg 本体,避免把周边 shell 区域也变成“不可点击保护区”。 + * 这里覆盖全局 button::before 的命中扩展,把语音按钮命中区收回到自身尺寸。 + */ + inset: 0; +} + +.terminal-voice-button:active { + cursor: grabbing; +} + +.terminal-voice-button.is-recording:not(.panel-visible) { + color: color-mix(in srgb, var(--shell-accent) 92%, #ffffff 8%); + opacity: 1; +} + +.terminal-voice-button-icon { + width: 27px; + height: 36px; + display: block; + opacity: 0.9; + background-color: currentcolor; + -webkit-mask: url("/assets/icons/voice.svg") no-repeat center / contain; + mask: url("/assets/icons/voice.svg") no-repeat center / contain; +} + +.terminal-voice-panel { + position: absolute; + width: 408px; + height: 220px; + box-sizing: border-box; + pointer-events: auto; + background: transparent; + z-index: 1; +} + +.terminal-voice-frame2256-bg { + position: absolute; + border-radius: 16px; + background: var(--shell-text); + opacity: 0.4; + pointer-events: none; + z-index: 1; +} + +.terminal-voice-panel.is-recording .terminal-voice-frame2256-bg { + opacity: 1; +} + +.terminal-voice-input-wrap { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: var(--voice-input-height, 156px); + border-radius: 16px; + overflow: hidden; + isolation: isolate; + background: #fff; + box-shadow: + 0 10px 20px rgba(0, 0, 0, 0.2), + inset 0 0 0 1px rgba(255, 255, 255, 0.65); + z-index: 2; +} + +.terminal-voice-input-wrap::before { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + pointer-events: none; + opacity: 0; + transition: opacity 0.18s ease; +} + +.terminal-voice-panel.is-recording .terminal-voice-input-wrap { + box-shadow: + 0 12px 24px rgba(0, 0, 0, 0.22), + 0 0 24px color-mix(in srgb, var(--accent) 48%, rgba(255, 255, 255, 0.38)), + inset 0 1px 0 rgba(255, 255, 255, 0.9), + inset 0 -12px 28px color-mix(in srgb, var(--accent) 28%, rgba(255, 255, 255, 0.9)); + animation: terminal-voice-liquid-border-pulse 1.2s ease-in-out infinite; +} + +.terminal-voice-panel.is-recording .terminal-voice-input-wrap::before { + opacity: 1; + inset: 0; + border: 2px solid color-mix(in srgb, var(--accent) 72%, #ffffff 28%); + box-shadow: + 0 0 16px color-mix(in srgb, var(--accent) 48%, rgba(255, 255, 255, 0.38)), + inset 0 0 12px color-mix(in srgb, var(--accent) 22%, rgba(255, 255, 255, 0.52)); +} + +@keyframes terminal-voice-liquid-border-pulse { + 0% { + transform: translateZ(0) scale(1); + } + 50% { + transform: translateZ(0) scale(1.006); + } + 100% { + transform: translateZ(0) scale(1); + } +} + +.terminal-voice-input { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + min-height: 100%; + border: 0; + resize: none; + outline: none; + padding: 14px 16px; + box-sizing: border-box; + border-radius: 16px; + background: #fff; + color: #4e5969; + font-size: 14px; + line-height: 1.4; + font-family: "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace; + -webkit-user-select: text; + user-select: text; + -webkit-touch-callout: default; +} + +.terminal-voice-input::placeholder { + color: rgba(78, 89, 105, 0.6); +} + +.terminal-voice-input:read-only { + opacity: 0.86; +} + +.terminal-voice-actions { + position: absolute; + left: 0; + right: 0; + top: var(--voice-actions-top, 164px); + height: 24px; + z-index: 3; +} + +.terminal-voice-actions-left, +.terminal-voice-actions-right { + position: absolute; + top: 0; + display: inline-flex; + align-items: center; +} + +.terminal-voice-actions-left { + gap: 15px; +} + +.terminal-voice-actions-right { + right: 0; + width: 62px; + justify-content: space-between; +} + +.terminal-voice-action-btn { + min-width: 24px; + height: 24px; + border: 0; + padding: 0; + background: transparent; + color: var(--shell-bg); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + touch-action: manipulation; + opacity: 1; + transition: + opacity 0.16s ease, + color 0.16s ease; +} + +.terminal-voice-action-btn::before { + inset: -10px; +} + +.terminal-voice-action-btn:disabled { + opacity: 0.42; + cursor: not-allowed; +} + +.terminal-voice-action-btn:not(:disabled):hover { + color: var(--shell-bg); +} + +.terminal-voice-action-icon { + display: block; + background-color: currentcolor; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-position: center; + mask-position: center; + -webkit-mask-size: contain; + mask-size: contain; +} + +.terminal-voice-action-icon-clear { + width: 22px; + height: 24px; + -webkit-mask-image: url("/assets/icons/clear-input.svg"); + mask-image: url("/assets/icons/clear-input.svg"); +} + +.terminal-voice-action-icon-cancel { + width: 24px; + height: 24px; + -webkit-mask-image: url("/assets/icons/cancel.svg"); + mask-image: url("/assets/icons/cancel.svg"); +} + +.terminal-voice-action-icon-record { + width: 24px; + height: 24px; + -webkit-mask-image: url("/assets/icons/record.svg?v=20260227-3"); + mask-image: url("/assets/icons/record.svg?v=20260227-3"); +} + +.terminal-voice-action-icon-send { + width: 24px; + height: 24px; + -webkit-mask-image: url("/assets/icons/sent.svg"); + mask-image: url("/assets/icons/sent.svg"); +} + +.terminal-voice-categories { + position: absolute; + top: var(--voice-categories-top, 198px); + right: 72px; + z-index: 3; +} + +.terminal-voice-category-scroll { + max-width: 100%; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; +} + +.terminal-voice-category-scroll::-webkit-scrollbar { + display: none; +} + +.terminal-voice-category-row { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: max-content; +} + +.terminal-voice-category-pill { + min-height: 24px; + padding: 0 10px; + border: 1px solid rgba(255, 255, 255, 0.34); + border-radius: 999px; + background: rgba(255, 255, 255, 0.12); + color: rgba(248, 252, 255, 0.78); + font-size: 12px; + line-height: 1; + font-weight: 600; + white-space: nowrap; + transition: + background 140ms ease, + border-color 140ms ease, + color 140ms ease, + box-shadow 140ms ease, + transform 140ms ease; +} + +.terminal-voice-category-pill.active { + border-color: rgba(255, 255, 255, 0.96); + background: linear-gradient(140deg, rgba(255, 255, 255, 0.34), rgba(91, 210, 255, 0.36)); + color: #fff; + font-weight: 700; + box-shadow: + 0 0 0 2px rgba(255, 255, 255, 0.18), + 0 8px 18px rgba(61, 134, 255, 0.22); + transform: translateY(-1px); +} + +.terminal-voice-arrow { + position: absolute; + left: var(--voice-arrow-left, 26px); + top: var(--voice-arrow-top, 141px); + width: 20px; + height: 20px; + box-sizing: border-box; + border: 1px solid transparent; + background: #fff; + transform: rotate(45deg); + pointer-events: none; + z-index: 1; +} + +.terminal-container { + width: 100%; + height: 100%; + /* 移动端性能优化 */ + -webkit-transform: translateZ(0); + transform: translateZ(0); + will-change: scroll-position; +} + +/* 触屏端启用原生文本选择:支持 iOS 长按工具条与双端拖拽手柄。 */ +.terminal-container.native-touch-selection .xterm, +.terminal-container.native-touch-selection .xterm * { + -webkit-user-select: text; + user-select: text; + -webkit-touch-callout: default; +} + +/* 规避 iOS 触摸事件吞噬问题: + 当目标是 span 等特定内联元素时,iOS 容易在滑动中没收 touchmove。 + 将其鼠标事件穿透到 div (行) 上可以绕过引发吞没的 WebKit 内核特定文本触摸启发式逻辑, + 同时并不影响基于坐标的文本选择。 */ +.terminal-container.native-touch-selection .xterm-screen span { + pointer-events: none; +} + +/* 移动端字体渲染优化 */ +@media (pointer: coarse) { + .terminal-container .xterm { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeSpeed; + } +} + +/* 强制保持 xterm helper textarea 为隐藏输入锚点,避免浏览器把它渲染成左上角可见文本框。 */ +.terminal-wrapper .xterm .xterm-helper-textarea { + position: fixed !important; + left: -9999px !important; + top: 0 !important; + width: 1px !important; + height: 1px !important; + opacity: 0 !important; + color: transparent !important; + caret-color: transparent !important; + border: 0 !important; + padding: 0 !important; + margin: 0 !important; + resize: none !important; +} + +/* 终端滚动条默认隐藏,仅在鼠标悬停或聚焦终端时显示。 */ +.terminal-wrapper .xterm .xterm-viewport { + overflow-y: auto !important; + scrollbar-width: thin; + scrollbar-color: transparent transparent; + -webkit-overflow-scrolling: touch; + overscroll-behavior: contain; +} + +.terminal-wrapper .xterm .xterm-viewport::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.terminal-wrapper .xterm .xterm-viewport::-webkit-scrollbar-thumb { + border-radius: 999px; + background: transparent; +} + +.terminal-wrapper:hover .xterm .xterm-viewport, +.terminal-wrapper:focus-within .xterm .xterm-viewport { + scrollbar-color: rgba(141, 187, 255, 0.45) transparent; +} + +.terminal-wrapper:hover .xterm .xterm-viewport::-webkit-scrollbar-thumb, +.terminal-wrapper:focus-within .xterm .xterm-viewport::-webkit-scrollbar-thumb { + background: rgba(141, 187, 255, 0.45); +} + +.plugin-chips { + display: flex; + gap: 8px; + flex-wrap: wrap; + padding: 8px 12px 0; +} + +.terminal-toolbar { + gap: 12px; +} + +.terminal-toolbar-actions { + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + min-width: 0; +} + +.terminal-title { + max-width: 88px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.terminal-toolbar-ai-btn.is-connected { + background: color-mix(in srgb, var(--accent) 28%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 65%, transparent); +} + +.terminal-toolbar-ai-btn.is-connected .icon-mask { + background-color: var(--accent); +} + +.terminal-toolbar-actions .state-chip { + white-space: nowrap; + padding: 2px 8px; + font-size: 10px; +} + +.terminal-connection-switch { + height: 20px; + min-width: 56px; + border: 0; + border-radius: 999px; + padding: 0 6px 0 5px; + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + overflow: hidden; + font-size: 11px; + line-height: 1; + color: #ffffff; + cursor: pointer; + transition: + background-color 0.18s ease, + color 0.18s ease; +} + +.terminal-connection-switch-label { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 15px 0 0; + white-space: nowrap; + position: relative; + z-index: 1; + pointer-events: none; + transition: padding 0.18s ease; +} + +.terminal-connection-switch-knob { + width: 14px; + height: 14px; + border-radius: 50%; + background: #ffffff; + position: absolute; + top: 3px; + left: 3px; + z-index: 2; + transition: left 0.18s ease; +} + +.terminal-connection-switch.is-disconnect { + background: #00b42a; +} + +.terminal-connection-switch.is-disconnect .terminal-connection-switch-knob { + left: calc(100% - 17px); +} + +.terminal-connection-switch.is-reconnect { + background: rgba(91, 210, 255, 0.24); + color: #e6f0ff; +} + +.terminal-connection-switch.is-reconnect .terminal-connection-switch-label { + padding: 0 0 0 15px; +} + +.terminal-toolbar-divider { + width: 1px; + height: 16px; + background: rgba(141, 187, 255, 0.55); +} + +.plugin-chip { + border: 1px solid rgba(141, 187, 255, 0.3); + background: rgba(255, 255, 255, 0.03); + color: var(--btn); + border-radius: 999px; + padding: 6px 12px; + cursor: pointer; +} + +/* Settings tab 按钮 */ +.settings-tabs { + display: flex; + gap: 8px; + padding: 8px 16px 0; + flex-shrink: 0; + overflow-x: auto; + scrollbar-width: none; +} + +.settings-tabs::-webkit-scrollbar { + display: none; +} + +.settings-tab-btn { + border: 1px solid color-mix(in srgb, var(--surface-border) 90%, transparent); + background: + linear-gradient(180deg, rgb(255 255 255 / 0.045), rgb(255 255 255 / 0.02)), + color-mix(in srgb, var(--bg) 74%, transparent); + color: color-mix(in srgb, var(--text) 84%, transparent); + border-radius: 999px; + padding: 8px 16px; + font-size: 13px; + cursor: pointer; + opacity: 0.75; + white-space: nowrap; + box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.05); + transition: + opacity 0.15s, + background 0.15s, + color 0.15s, + border-color 0.15s, + box-shadow 0.15s, + transform 0.15s; +} + +.settings-tab-btn:hover { + opacity: 0.92; + transform: translateY(-1px); + border-color: color-mix(in srgb, var(--accent) 50%, transparent); +} + +.settings-tab-btn.active { + background: linear-gradient(140deg, rgba(91, 210, 255, 0.42), rgba(61, 134, 255, 0.5)); + border-color: color-mix(in srgb, var(--accent) 82%, transparent); + color: #f6fbff; + opacity: 1; + box-shadow: + 0 0 0 2px rgba(91, 210, 255, 0.16), + 0 10px 22px rgba(20, 46, 96, 0.32); +} + +.shell-style-preview { + margin-bottom: 14px; + display: block; + flex: 0 0 auto; + min-height: 0; + font-variant-ligatures: none; + border-radius: 16px; + padding: 14px 16px; + /* 当终端背景与页面背景接近时,用极轻纹理与内阴影保留“面板存在感”。 */ + background-image: + linear-gradient(to bottom, rgb(255 255 255 / 0.025), rgb(255 255 255 / 0)), + repeating-linear-gradient( + to bottom, + rgb(255 255 255 / 0.012) 0, + rgb(255 255 255 / 0.012) 1px, + transparent 1px, + transparent 24px + ); + box-shadow: 0 16px 36px rgb(7 16 32 / 0.18); +} + +.shell-style-preview-content { + margin: 0; + font-family: inherit; + font-size: inherit; + line-height: inherit; + color: inherit; + white-space: pre-wrap; + word-break: break-word; + overflow-wrap: anywhere; + overflow: visible; + text-overflow: clip; +} + +.settings-page, +.server-settings-page { + position: relative; + min-width: 0; + width: 100%; + max-width: 100%; + overflow-x: hidden; + background: + radial-gradient(circle at top left, rgb(103 209 255 / 0.12), transparent 30%), + radial-gradient(circle at top right, rgb(61 134 255 / 0.1), transparent 28%), + linear-gradient(180deg, rgb(255 255 255 / 0.015), rgb(255 255 255 / 0)); +} + +.settings-page { + overflow: hidden; +} + +/* 设置页改为仅内容区滚动,顶部工具条与底部栏不再跟随整页回弹。 */ +.settings-page > .settings-card, +.settings-page > .settings-card-stack { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + overscroll-behavior: contain; +} + +.settings-page .page-toolbar, +.server-settings-page .page-toolbar { + padding: 0 16px; +} + +.settings-page .page-title, +.server-settings-page .page-title { + letter-spacing: 0.02em; +} + +.settings-save-ops { + min-width: 0; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; +} + +.settings-save-ops .btn.primary { + min-width: 92px; + border-radius: 999px; + padding-inline: 14px; +} + +.settings-save-status { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 0 10px; + border-radius: 999px; + background: rgb(255 255 255 / 0.05); + color: color-mix(in srgb, var(--text) 78%, transparent); +} + +.settings-page .surface-panel, +.server-settings-content.surface-scroll, +.server-settings-page .surface-panel { + position: relative; +} + +.settings-card { + margin: 8px 16px 0; + padding: 16px; + min-width: 0; + overflow-x: hidden; + overflow-y: visible; + border: 0; + border-radius: 24px; + background: + linear-gradient(180deg, rgb(255 255 255 / 0.035), rgb(255 255 255 / 0.018)), + color-mix(in srgb, var(--bg) 93%, transparent); + box-shadow: + inset 0 0 0 1px var(--surface-border), + inset 0 1px 0 rgb(255 255 255 / 0.03), + 0 18px 40px rgb(8 18 36 / 0.22); +} + +.server-settings-content.surface-scroll { + margin: 0; + padding: 0 16px 8px; + min-width: 0; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; +} + +.settings-card-stack { + min-height: 0; + margin: 8px 16px 0; + padding: 0 0 8px; + display: flex; + flex-direction: column; + gap: 16px; + overflow: visible; +} + +.settings-card-stack .settings-card { + margin: 0; +} + +.settings-page .surface-panel h4, +.server-settings-page .surface-panel h3 { + border: 0; + padding: 0; +} + +.settings-section-headline { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 12px; + margin-bottom: 14px; + min-width: 0; + flex-wrap: nowrap; +} + +.settings-section-copy { + margin: 0; + min-width: 0; + flex: 1; + font-size: 12px; + line-height: 1.2; + color: color-mix(in srgb, var(--text) 68%, transparent); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.server-settings-switch { + position: relative; + flex: 0 0 auto; + margin-left: auto; + width: 52px; + height: 30px; + border: 0; + border-radius: 999px; + background: rgba(141, 187, 255, 0.22); + cursor: pointer; + transition: background 140ms ease; +} + +.server-settings-switch.active { + background: linear-gradient(140deg, rgba(91, 210, 255, 0.52), rgba(61, 134, 255, 0.62)); +} + +.server-settings-switch-knob { + position: absolute; + top: 4px; + left: 4px; + width: 22px; + height: 22px; + border-radius: 50%; + background: #fff; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.22); + transition: transform 140ms ease; +} + +.server-settings-switch.active .server-settings-switch-knob { + transform: translateX(22px); +} + +.settings-subsection-headline { + display: flex; + align-items: center; + gap: 12px; + margin: 2px 0 12px; + min-width: 0; +} + +.settings-subsection-title { + margin: 0; + font-size: 13px; + line-height: 1.2; + font-weight: 700; + color: var(--text); +} + +.settings-subsection-copy { + margin: 0; + font-size: 12px; + line-height: 1.4; + color: color-mix(in srgb, var(--text) 66%, transparent); +} + +.settings-page .field-grid, +.server-settings-page .field-grid { + grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr)); + gap: 14px; +} + +.settings-page .field, +.server-settings-page .field { + display: flex; + align-items: center; + gap: 14px; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; + min-height: 32px; + min-width: 0; +} + +.settings-page .field.wide, +.server-settings-page .field.wide { + align-items: flex-start; +} + +.settings-page .field > span, +.server-settings-page .field > span { + flex: 0 0 132px; + width: 132px; + min-width: 132px; + font-size: 12px; + line-height: 1.45; + color: color-mix(in srgb, var(--text) 66%, transparent); +} + +.settings-page .field.wide > span, +.server-settings-page .field.wide > span { + padding-top: 8px; +} + +.settings-page .field.settings-toggle-field { + align-items: center; + justify-content: space-between; + gap: 16px; + grid-column: 1 / -1; +} + +.settings-toggle-copy { + flex: 1 1 auto; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.settings-toggle-title { + font-size: 13px; + line-height: 1.35; + font-weight: 600; + color: var(--text); +} + +.settings-toggle-desc { + margin: 0; + font-size: 12px; + line-height: 1.45; + color: color-mix(in srgb, var(--text) 66%, transparent); +} + +.field-control { + --settings-control-height: 32px; + flex: 1 1 0; + width: auto; + max-width: 100%; + min-width: 0; + display: flex; + align-items: center; + gap: 8px; + overflow: hidden; +} + +.field-control--stack { + flex-direction: column; + align-items: stretch; + overflow: visible; +} + +.settings-page .input, +.settings-page .textarea, +.settings-page select.input, +.server-settings-page .input, +.server-settings-page .textarea, +.server-settings-page select.input { + border-color: color-mix(in srgb, var(--surface-border) 95%, transparent); + background: rgb(255 255 255 / 0.045); + box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.025); +} + +.settings-page .input, +.settings-page select.input, +.server-settings-page .input, +.server-settings-page select.input { + height: 32px; +} + +.settings-page .field > .input, +.settings-page .field > .textarea, +.server-settings-page .field > .input, +.server-settings-page .field > .textarea, +.field-control > .input, +.field-control > .textarea { + flex: 1 1 0; + width: auto; + min-width: 0; +} + +.settings-page .input:focus, +.settings-page .textarea:focus, +.settings-page select.input:focus, +.server-settings-page .input:focus, +.server-settings-page .textarea:focus, +.server-settings-page select.input:focus { + outline: none; + border-color: color-mix(in srgb, var(--accent) 82%, transparent); + box-shadow: + 0 0 0 3px rgb(91 210 255 / 0.14), + inset 0 1px 0 rgb(255 255 255 / 0.025); +} + +.segmented-control { + flex: 1 1 0; + width: auto; + max-width: 100%; + min-width: 0; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px; + border: 1px solid rgba(141, 187, 255, 0.26); + border-radius: 999px; + background: rgba(13, 24, 42, 0.72); + min-height: var(--settings-control-height, 32px); +} + +.segmented-option { + flex: 1; + min-width: 0; + min-height: calc(var(--settings-control-height, 32px) - 6px); + border: 0; + border-radius: 999px; + background: transparent; + color: rgba(230, 240, 255, 0.66); + font-size: 13px; + font-weight: 500; + line-height: 1; + padding: 0 12px; + cursor: pointer; + transition: + background 140ms ease, + color 140ms ease, + box-shadow 140ms ease; +} + +.segmented-option.active { + background: linear-gradient(140deg, rgba(91, 210, 255, 0.45), rgba(61, 134, 255, 0.55)); + color: #f7fcff; + font-weight: 700; + box-shadow: 0 6px 18px rgba(42, 92, 182, 0.26); +} + +.pill-scroll { + flex: 1 1 0; + width: auto; + max-width: 100%; + min-width: 0; + overflow-x: auto; + overflow-y: hidden; + overscroll-behavior-x: contain; + scrollbar-width: none; +} + +.pill-scroll::-webkit-scrollbar { + display: none; +} + +.pill-row { + display: inline-flex; + align-items: center; + gap: 8px; + width: max-content; + min-width: 100%; + padding: 0; +} + +.pill-option { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: var(--settings-control-height, 32px); + border: 1px solid rgba(141, 187, 255, 0.32); + border-radius: 999px; + background: rgba(18, 30, 52, 0.62); + color: rgba(230, 240, 255, 0.68); + font-size: 13px; + line-height: 1.1; + font-weight: 500; + padding: 0 14px; + white-space: nowrap; + cursor: pointer; + transition: + background 140ms ease, + border-color 140ms ease, + color 140ms ease, + box-shadow 140ms ease; +} + +.pill-option.active { + border-color: var(--accent); + background: linear-gradient(140deg, rgba(91, 210, 255, 0.42), rgba(61, 134, 255, 0.5)); + color: #f6fbff; + font-weight: 700; + box-shadow: + 0 0 0 2px rgba(91, 210, 255, 0.18), + 0 10px 22px rgba(42, 92, 182, 0.24); +} + +.pill-option--font { + max-width: 240px; + overflow: hidden; + text-overflow: ellipsis; +} + +.color-control { + justify-content: flex-start; + flex-wrap: nowrap; +} + +.color-input { + flex: 0 0 auto; + width: 48px; + min-width: 48px; + height: 32px; + padding: 3px; +} + +.field-value { + display: inline-flex; + align-items: center; + flex: 0 1 112px; + min-width: 0; + max-width: 112px; + min-height: 32px; + padding: 0 10px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.05); + color: color-mix(in srgb, var(--text) 74%, transparent); + font-size: 12px; + line-height: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.settings-section-card { + border: 0; + border-radius: 20px; + padding: 16px; + background: + linear-gradient(180deg, rgb(255 255 255 / 0.03), rgb(255 255 255 / 0.015)), + color-mix(in srgb, var(--bg) 90%, transparent); + box-shadow: + inset 0 0 0 1px var(--surface-border), + inset 0 1px 0 rgb(255 255 255 / 0.02), + 0 14px 30px rgb(6 14 28 / 0.14); +} + +.server-tag-order-list { + margin-top: 10px; + gap: 8px; +} + +.server-tag-order-item { + min-height: 34px; + padding: 8px 10px; + border-color: color-mix(in srgb, var(--accent) 24%, transparent); + border-radius: 12px; + background: + linear-gradient(180deg, rgb(255 255 255 / 0.032), rgb(255 255 255 / 0.015)), + color-mix(in srgb, var(--surface) 72%, transparent); +} + +.server-tag-order-btn { + border-radius: 999px; + padding: 6px 10px; + background: color-mix(in srgb, var(--bg) 42%, transparent); +} + +.settings-inline-create { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + min-width: 0; +} + +.settings-inline-create .input { + flex: 1 1 0; +} + +.settings-inline-create .btn { + flex: 0 0 auto; + min-width: 68px; + min-height: 36px; + border-radius: 999px; +} + +.settings-field-block { + align-items: flex-start; + flex-wrap: wrap; +} + +.settings-page .field.settings-field-block > span.settings-field-title { + flex: 0 0 100%; + width: 100%; + min-width: 0; +} + +.settings-page .field.settings-field-block > .field-control { + width: 100%; +} + +.settings-field-hint { + margin: 0; + font-size: 12px; + line-height: 1.4; + color: color-mix(in srgb, var(--text) 64%, transparent); +} + +.settings-category-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + align-items: start; +} + +.settings-category-card { + min-width: 0; + min-height: 0; + height: auto; + padding: 12px; + outline: none; + border: 0; + border-radius: 16px; + background: + linear-gradient(180deg, rgb(255 255 255 / 0.03), rgb(255 255 255 / 0.015)), + color-mix(in srgb, var(--surface) 78%, transparent); + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + text-align: left; + cursor: grab; + appearance: none; + -webkit-tap-highlight-color: transparent; + user-select: none; + transition: + border-color 140ms ease, + box-shadow 140ms ease, + background 140ms ease; + box-shadow: inset 0 0 0 1px var(--surface-border); +} + +.settings-category-card-head { + min-width: 0; + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.settings-category-card.active { + border-color: transparent; + box-shadow: + inset 0 0 0 1px color-mix(in srgb, var(--accent) 88%, transparent), + 0 0 0 2px rgba(91, 210, 255, 0.14); +} + +.settings-category-card.is-dragging { + opacity: 0.58; + cursor: grabbing; +} + +.settings-category-card.is-drag-over { + border-color: transparent; + box-shadow: + inset 0 0 0 1px color-mix(in srgb, var(--accent) 92%, transparent), + 0 0 0 2px rgba(91, 210, 255, 0.2); +} + +.settings-category-card:focus, +.settings-category-card:focus-visible { + outline: none; +} + +.settings-category-card.is-default { + background: + linear-gradient(160deg, rgba(91, 210, 255, 0.12), rgba(61, 134, 255, 0.2)), + color-mix(in srgb, var(--surface) 82%, transparent); +} + +.settings-category-card-title { + min-width: 0; + font-size: 14px; + line-height: 1.35; + font-weight: 700; + color: var(--text); + word-break: break-word; +} + +.settings-category-card-badge { + display: inline-flex; + align-items: center; + min-height: 0; + padding: 2px 8px; + border-radius: 999px; + background: rgba(91, 210, 255, 0.16); + color: color-mix(in srgb, var(--text) 88%, transparent); + font-size: 12px; + line-height: 1.2; + flex: 0 0 auto; +} + +.settings-category-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; + width: 100%; +} + +@media (max-width: 720px) { + .settings-category-actions { + flex-wrap: wrap; + } +} + +.server-settings-bottom { + margin: 0 16px 12px; + padding: 12px 16px; + height: auto; + border: 0; + border-radius: 18px; + background: + linear-gradient(180deg, rgb(255 255 255 / 0.045), rgb(255 255 255 / 0.02)), + color-mix(in srgb, var(--surface) 86%, transparent); + box-shadow: + inset 0 0 0 1px var(--surface-border), + inset 0 1px 0 rgb(255 255 255 / 0.03), + 0 16px 34px rgb(6 14 28 / 0.18); +} + +.server-settings-bottom .icon-btn { + width: 34px; + height: 34px; + border-radius: 999px; + background: rgb(255 255 255 / 0.04); +} + +.codex-dialog-mask { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.48); + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + z-index: 30; +} + +.codex-dialog { + width: min(420px, calc(100vw - 32px)); + border: 1px solid rgba(141, 187, 255, 0.35); + border-radius: 12px; + background: rgba(8, 18, 32, 0.95); + box-shadow: 0 18px 48px rgba(0, 0, 0, 0.35); + padding: 14px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.codex-dialog-title { + border-bottom: 0; + margin: 0; + font-size: 16px; + line-height: 1.2; + font-weight: 600; + color: var(--text); +} + +.codex-dialog-hint { + margin: 0; + font-size: 12px; + opacity: 0.82; +} + +.ai-launch-card { + border: 0; + border-radius: 10px; + padding: 10px; + background: rgba(10, 24, 42, 0.6); + display: flex; + flex-direction: column; + gap: 8px; + box-shadow: inset 0 0 0 1px var(--surface-border); +} + +.ai-launch-card-title { + margin: 0; + font-size: 14px; + line-height: 1.25; + font-weight: 600; +} + +.ai-launch-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.ai-launch-actions .btn { + flex: 1 1 160px; +} + +.codex-dialog-actions { + display: flex; + justify-content: center; + gap: 8px; +} + +.log-box { + min-height: 0; + max-height: 220px; + overflow: auto; + background: rgba(8, 18, 32, 0.65); + border: 1px solid rgba(141, 187, 255, 0.25); + border-radius: 10px; + padding: 10px; + white-space: pre-wrap; +} + +.state-chip { + border-radius: 999px; + padding: 4px 10px; + border: 1px solid rgba(141, 187, 255, 0.35); + font-size: 12px; +} + +.state-connected { + border-color: rgba(113, 240, 178, 0.7); +} + +.state-error { + border-color: rgba(255, 122, 151, 0.7); +} + +.toast-stack { + position: fixed; + left: 50%; + bottom: 82px; + transform: translateX(-50%); + display: flex; + flex-direction: column; + gap: 8px; + width: min(360px, calc(100vw - 24px)); + z-index: 20; +} + +.toast-item { + background: var(--text); + border: 1px solid rgba(141, 187, 255, 0.45); + border-left: 4px solid rgba(103, 209, 255, 0.85); + border-radius: 12px; + padding: 10px 12px; + box-shadow: 0 10px 30px rgba(4, 10, 20, 0.35); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} + +.toast-title { + margin: 0; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + color: var(--bg); +} + +.toast-message { + margin: 4px 0 0; + font-size: 13px; + line-height: 1.4; + color: var(--bg); + white-space: pre-wrap; +} + +.toast-info { + border-left-color: rgba(103, 209, 255, 0.9); +} + +.toast-warn { + border-color: rgba(255, 193, 110, 0.7); + border-left-color: rgba(255, 193, 110, 0.9); +} + +.toast-error { + border-color: rgba(255, 122, 151, 0.7); + border-left-color: rgba(255, 122, 151, 0.95); +} + +.toast-stack.terminal-toast .toast-item { + background: var(--shell-text); +} + +.toast-stack.terminal-toast .toast-title, +.toast-stack.terminal-toast .toast-message { + color: var(--shell-bg); +} + +.about-page-web { + --about-bg: var(--bg); + --about-surface: color-mix(in srgb, var(--bg) 88%, var(--text) 12%); + --about-surface-border: color-mix(in srgb, var(--text) 18%, transparent); + --about-shadow: color-mix(in srgb, var(--accent) 22%, transparent); + --about-text-strong: var(--text); + --about-text: color-mix(in srgb, var(--text) 84%, var(--bg) 16%); + --about-text-muted: color-mix(in srgb, var(--text) 62%, var(--bg) 38%); + --about-divider: color-mix(in srgb, var(--accent) 60%, transparent); + --about-accent: var(--accent); + --about-accent-soft: color-mix(in srgb, var(--accent) 24%, transparent); + --about-accent-line: color-mix(in srgb, var(--accent) 46%, transparent); + --about-action-bg: color-mix(in srgb, var(--bg) 64%, var(--btn) 36%); + --about-action-border: color-mix(in srgb, var(--btn) 52%, transparent); + --about-action-text: var(--text); + background: var(--about-bg); + color: var(--about-text-strong); +} + +.about-page-web .icon-mask { + background-color: var(--about-text-strong); +} + +.about-page-web .page-toolbar { + background: var(--about-bg); + border-bottom: 1px solid var(--about-divider); +} + +.about-page-web .page-title { + color: var(--about-text-strong); +} + +.about-toolbar-actions { + min-width: 24px; + justify-content: flex-end; +} + +.about-toolbar-actions:empty, +.page-toolbar .toolbar-left:empty { + min-width: 24px; +} + +.about-scroll-web { + flex: 1; + min-height: 0; + background: var(--about-bg); +} + +.about-shell-web { + position: relative; + min-height: 100%; + padding: 28px 18px 44px; + overflow: hidden; +} + +.about-stack-web, +.about-app-stack-web { + position: relative; + z-index: 1; + width: min(100%, 920px); + margin: 0 auto; +} + +.about-stack-web { + display: flex; + flex-direction: column; + gap: 14px; +} + +.about-bg-orb-web { + position: absolute; + border-radius: 999px; + pointer-events: none; + filter: blur(0.3px); + animation: about-web-orb-float 9s ease-in-out infinite; +} + +.about-bg-orb-web-left { + width: 300px; + height: 300px; + left: -80px; + bottom: -120px; + background: radial-gradient( + circle at 35% 35%, + var(--accent) 0%, + color-mix(in srgb, var(--accent) 56%, var(--btn) 44%) 72%, + transparent 100% + ); + opacity: 0.54; +} + +.about-bg-orb-web-right { + width: 460px; + height: 460px; + right: -180px; + bottom: -240px; + background: radial-gradient( + circle at 35% 35%, + color-mix(in srgb, var(--btn) 68%, var(--bg) 32%) 0%, + color-mix(in srgb, var(--text) 18%, var(--bg) 82%) 62%, + transparent 100% + ); + opacity: 0.48; + animation-delay: -2s; +} + +.about-hero-web, +.about-app-brand-web { + position: relative; + padding: 6px 8px 18px; +} + +.about-home-logo-web { + width: 76px; + height: 76px; + display: block; +} + +.about-home-wordmark-web { + width: min(100%, 360px); + display: block; + margin-top: -72px; + margin-left: 88px; +} + +.about-home-submark-web { + width: 64px; + display: block; + margin-top: 10px; + margin-left: 88px; +} + +.about-home-version-web, +.about-app-version-web { + margin: 10px 0 0 88px; + font-size: 14px; + line-height: 1.3; + font-weight: 700; + color: var(--about-text); +} + +.about-home-summary-web { + max-width: 520px; + margin: 16px 0 0; + font-size: 15px; + line-height: 1.7; + color: var(--about-text); +} + +.about-card-list-web { + display: grid; + gap: 12px; +} + +.about-entry-web, +.detail-card-web, +.about-app-card-web { + border: 0; + border-radius: 24px; + background: var(--about-surface); + box-shadow: + inset 0 0 0 1px var(--about-surface-border), + 0 16px 38px var(--about-shadow); + backdrop-filter: blur(12px); +} + +.about-entry-web { + width: 100%; + padding: 22px 20px; + text-align: left; + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.about-entry-web:hover { + transform: translateY(-1px); +} + +.about-entry-main-web { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.about-entry-title-web { + font-size: 18px; + line-height: 1.25; + font-weight: 700; + color: var(--about-text-strong); +} + +.about-entry-subtitle-web { + font-size: 14px; + line-height: 1.6; + color: var(--about-text-muted); +} + +.about-entry-arrow-web { + font-size: 28px; + line-height: 1; + color: var(--about-text-strong); +} + +.detail-chip-web { + align-self: flex-start; + padding: 8px 14px; + border-radius: 999px; + background: var(--about-accent-soft); + color: var(--about-accent); + font-size: 12px; + line-height: 1; +} + +.detail-card-web, +.about-app-card-web { + padding: 24px 20px; +} + +.detail-section-list-web { + display: flex; + flex-direction: column; + gap: 14px; +} + +.detail-section-head-web { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.detail-title-web, +.about-app-card-title-web { + margin: 0; + font-size: 28px; + line-height: 1.12; + font-weight: 700; + color: var(--about-text-strong); +} + +.detail-section-title-web { + margin: 0; + font-size: 20px; + line-height: 1.3; + font-weight: 700; + color: var(--about-text-strong); +} + +.detail-lead-web, +.about-app-card-lead-web, +.detail-paragraph-web { + margin: 0; + font-size: 15px; + line-height: 1.75; + color: var(--about-text); +} + +.detail-bullet-list-web, +.about-app-info-list-web { + display: flex; + flex-direction: column; + gap: 12px; +} + +.detail-bullet-row-web, +.about-app-info-row-web { + display: flex; + align-items: flex-start; + gap: 10px; +} + +.detail-bullet-dot-web { + flex: 0 0 auto; + font-size: 16px; + line-height: 1.6; + color: var(--about-text-strong); +} + +.detail-bullet-text-web, +.about-app-info-value-web { + flex: 1; + min-width: 0; + font-size: 15px; + line-height: 1.7; + color: var(--about-text); +} + +.about-app-info-list-web { + margin-top: 22px; +} + +.about-app-info-label-web { + flex: 0 0 auto; + font-size: 15px; + line-height: 1.7; + font-weight: 700; + color: var(--about-text-strong); +} + +.detail-action-btn-web, +.about-link-btn-web { + border: 0; + border-radius: 999px; + padding: 10px 16px; + font-size: 13px; + line-height: 1; + cursor: pointer; +} + +.detail-action-btn-web { + background: var(--about-action-bg); + color: var(--about-action-text); + box-shadow: inset 0 0 0 1px var(--about-action-border); +} + +.about-link-btn-web { + background: var(--about-accent-soft); + color: var(--about-accent); + box-shadow: inset 0 0 0 1px var(--about-accent-line); +} + +.about-app-footer-web { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 18px; +} + +@keyframes about-web-orb-float { + 0%, + 100% { + transform: translate3d(0, 0, 0); + } + 50% { + transform: translate3d(0, -10px, 0); + } +} + +@media (max-width: 780px) { + .settings-save-ops { + gap: 10px; + padding: 8px 10px; + } + + .settings-card, + .settings-card-stack, + .server-settings-bottom { + margin-left: 12px; + margin-right: 12px; + } + + .settings-section-card { + padding: 12px; + } + + .settings-page .field, + .server-settings-page .field { + flex-direction: row; + align-items: center; + gap: 10px; + } + + .settings-page .field > span, + .server-settings-page .field > span { + flex: 0 0 80px; + width: 80px; + min-width: 80px; + } + + .settings-page .field.wide > span, + .server-settings-page .field.wide > span { + padding-top: 8px; + } + + .settings-page .field.settings-toggle-field { + align-items: flex-start; + } + + .field-grid { + grid-template-columns: 1fr; + } + + .field.wide { + grid-column: span 1; + } + + .about-page-web .page-toolbar { + padding-inline: 12px; + } + + .about-shell-web { + padding: 20px 14px 34px; + } + + .about-home-wordmark-web { + width: min(100%, 274px); + } + + .detail-title-web, + .about-app-card-title-web { + font-size: 24px; + } +} + +@media (min-width: 781px) { + .about-shell-web { + padding: 36px 28px 56px; + } + + .about-card-list-web { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .about-entry-web:last-child:nth-child(odd) { + grid-column: span 2; + } + + .about-app-card-web { + max-width: 720px; + } +} diff --git a/apps/web/src/types/app.ts b/apps/web/src/types/app.ts new file mode 100644 index 0000000..da6afb2 --- /dev/null +++ b/apps/web/src/types/app.ts @@ -0,0 +1,149 @@ +import type { + CommandMarker, + CredentialRef, + HostKeyPolicy, + JumpHostProfile, + ResolvedCredential, + ServerProfile, + SessionLog, + SessionState, + ThemePreset +} from "@remoteconn/shared"; + +export type { + ServerProfile, + CredentialRef, + SessionLog, + SessionState, + ResolvedCredential, + CommandMarker, + HostKeyPolicy, + JumpHostProfile, + ThemePreset +}; + +/** + * \u5168\u5c40\u8bbe\u7f6e\uff08\u57df\u6536\u655b\u7248\uff09\u3002 + */ +export interface GlobalSettings { + // \u2500\u2500 UI \u5916\u89c2 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 + /** 界面语言仅用于配置保真,本轮 Web 端不消费该字段切换文案 */ + uiLanguage: "zh-Hans" | "zh-Hant" | "en" | "ja" | "ko"; + uiThemePreset: ThemePreset; + /** 界面明暗模式,影响预设色板的 dark/light 变体选择 */ + uiThemeMode: "dark" | "light"; + uiAccentColor: string; + uiBgColor: string; + uiTextColor: string; + uiBtnColor: string; + + // \u2500\u2500 Shell \u663e\u793a \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 + shellThemePreset: ThemePreset; + /** 终端明暗模式,影响终端预设色板的 dark/light 变体选择 */ + shellThemeMode: "dark" | "light"; + shellBgColor: string; + shellTextColor: string; + shellAccentColor: string; + shellFontFamily: string; + shellFontSize: number; + shellLineHeight: number; + unicode11: boolean; + + // \u2500\u2500 \u7ec8\u7aef\u7f13\u51b2 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 + terminalBufferMaxEntries: number; + terminalBufferMaxBytes: number; + + // \u2500\u2500 \u8fde\u63a5\u7b56\u7565 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 + autoReconnect: boolean; + reconnectLimit: number; + hostKeyPolicy: HostKeyPolicy; + credentialMemoryPolicy: "remember" | "forget"; + gatewayConnectTimeoutMs: number; + waitForConnectedTimeoutMs: number; + + // \u2500\u2500 \u670d\u52a1\u5668\u914d\u7f6e\u9884\u586b \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 + defaultAuthType: "password" | "key"; + defaultPort: number; + defaultProjectPath: string; + defaultTimeoutSeconds: number; + defaultHeartbeatSeconds: number; + defaultTransportMode: "gateway" | string; + + // \u2500\u2500 \u65e5\u5fd7 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 + logRetentionDays: number; + maskSecrets: boolean; + voiceRecordCategories: string[]; + voiceRecordDefaultCategory: string; + + // \u2500\u2500 \u5df2\u5e9f\u5f03\u5b57\u6bb5\uff08\u517c\u5bb9\u4fdd\u7559\uff0c\u4e0b\u4e00\u4e2a\u7248\u672c\u7a97\u53e3\u5220\u9664\uff09\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 + /** @deprecated \u8bf7\u4f7f\u7528 shellFontFamily */ + fontFamily?: string; + /** @deprecated \u8bf7\u4f7f\u7528 shellFontSize */ + fontSize?: number; + /** @deprecated \u8bf7\u4f7f\u7528 shellLineHeight */ + lineHeight?: number; + /** @deprecated \u8bf7\u4f7f\u7528 uiThemePreset / shellThemePreset */ + themePreset?: string; + /** @deprecated \u8bf7\u4f7f\u7528 uiAccentColor / shellAccentColor */ + accentColor?: string; + /** @deprecated \u8bf7\u4f7f\u7528 uiBgColor / shellBgColor */ + bgColor?: string; + /** @deprecated \u8bf7\u4f7f\u7528 uiTextColor / shellTextColor */ + textColor?: string; + /** @deprecated UI \u52a8\u6548\u53c2\u6570\u5df2\u79fb\u9664\uff0c\u6682\u4fdd\u7559\u907f\u514d\u65e7\u6570\u636e\u62a5\u9519 */ + liquidAlpha?: number; + /** @deprecated UI \u52a8\u6548\u53c2\u6570\u5df2\u79fb\u9664\uff0c\u6682\u4fdd\u7559\u907f\u514d\u65e7\u6570\u636e\u62a5\u9519 */ + blurRadius?: number; + /** @deprecated UI \u52a8\u6548\u53c2\u6570\u5df2\u79fb\u9664\uff0c\u6682\u4fdd\u7559\u907f\u514d\u65e7\u6570\u636e\u62a5\u9519 */ + motionDuration?: number; + /** @deprecated \u7f51\u5173 URL \u5df2\u4ece\u7528\u6237\u914d\u7f6e\u79fb\u9664\uff0c\u6539\u7531\u6784\u5efa\u65f6\u6ce8\u5165\u6216\u8fd0\u7ef4\u4e0b\u53d1 */ + gatewayUrl?: string; + /** @deprecated \u7f51\u5173 Token \u5df2\u4ece\u7528\u6237\u914d\u7f6e\u79fb\u9664\uff0c\u6539\u7531\u6784\u5efa\u65f6\u6ce8\u5165\u6216\u8fd0\u7ef4\u4e0b\u53d1 */ + gatewayToken?: string; + +} + +/** + * 凭据密文。 + */ +export interface EncryptedCredentialPayload { + id: string; + refId: string; + encrypted: string; + iv: string; + createdAt: string; + updatedAt: string; +} + +export interface AppToast { + id: string; + level: "info" | "warn" | "error"; + message: string; +} + +export interface SessionCommandResult { + code: number; + stdout: string; + stderr: string; +} + +export interface SessionContext { + state: SessionState; + currentServerId?: string; + currentSessionId?: string; + latencyMs?: number; + connectedAt?: string; +} + +/** + * 闪念记录(语音输入区 record 按钮写入)。 + */ +export interface VoiceRecord { + id: string; + content: string; + createdAt: string; + updatedAt: string; + serverId: string; + category: string; + contextLabel: string; +} diff --git a/apps/web/src/utils/defaults.test.ts b/apps/web/src/utils/defaults.test.ts new file mode 100644 index 0000000..5218750 --- /dev/null +++ b/apps/web/src/utils/defaults.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { defaultSettings, normalizeGlobalSettings, resolveGatewayUrl, resolveGatewayToken } from "./defaults"; + +describe("default settings", () => { + it("包含 UI/Shell 域前缀字段、终端缓冲阈值", () => { + expect(defaultSettings.uiLanguage).toBe("zh-Hans"); + expect(defaultSettings.uiBgColor.length).toBeGreaterThan(0); + expect(defaultSettings.uiAccentColor.length).toBeGreaterThan(0); + expect(defaultSettings.shellFontFamily.length).toBeGreaterThan(0); + expect(["dark", "light"]).toContain(defaultSettings.shellThemeMode); + expect(defaultSettings.shellFontSize).toBeGreaterThanOrEqual(12); + expect(defaultSettings.terminalBufferMaxBytes).toBeGreaterThan(0); + expect(defaultSettings.terminalBufferMaxEntries).toBeGreaterThan(0); + expect(defaultSettings.autoReconnect).toBe(true); + expect(defaultSettings.voiceRecordCategories.length).toBeGreaterThan(0); + expect(defaultSettings.voiceRecordDefaultCategory.length).toBeGreaterThan(0); + }); + + it("resolveGatewayUrl 返回非空字符串", () => { + expect(resolveGatewayUrl().length).toBeGreaterThan(0); + }); + + it("resolveGatewayToken 返回非空字符串", () => { + expect(resolveGatewayToken().length).toBeGreaterThan(0); + }); + + it("可将旧版 fontFamily 迁移到 shellFontFamily", () => { + const normalized = normalizeGlobalSettings({ + fontFamily: "Menlo", + terminalBufferMaxBytes: Number.NaN, + terminalBufferMaxEntries: 1 + }); + expect(normalized.shellFontFamily).toBe("Menlo"); + expect(normalized.terminalBufferMaxBytes).toBe(defaultSettings.terminalBufferMaxBytes); + expect(normalized.terminalBufferMaxEntries).toBeGreaterThanOrEqual(200); + }); + + it("可将旧版颜色字段迁移到域前缀字段", () => { + const normalized = normalizeGlobalSettings({ + bgColor: "#112233", + textColor: "#aabbcc", + accentColor: "#ff0000" + }); + expect(normalized.uiBgColor).toBe("#112233"); + expect(normalized.shellBgColor).toBe("#112233"); + expect(normalized.uiTextColor).toBe("#aabbcc"); + expect(normalized.shellTextColor).toBe("#aabbcc"); + expect(normalized.uiAccentColor).toBe("#ff0000"); + expect(normalized.shellAccentColor).toBe("#ff0000"); + }); + + it("可将旧版 credentialMemoryPolicy=session 迁移到 forget", () => { + const normalized = normalizeGlobalSettings({ + credentialMemoryPolicy: "session" as "remember" + }); + expect(normalized.credentialMemoryPolicy).toBe("forget"); + }); + + it("可将旧版 themePreset 映射到新 ThemePreset", () => { + const normalized = normalizeGlobalSettings({ themePreset: "sunrise" }); + expect(normalized.uiThemePreset).toBe("焰岩"); + expect(normalized.shellThemePreset).toBe("焰岩"); + }); + + it("shellThemeMode 非法值会回退到 dark", () => { + const normalized = normalizeGlobalSettings({ shellThemeMode: "invalid" as "dark" }); + expect(normalized.shellThemeMode).toBe("dark"); + }); + + it("会归一化闪念分类并保证默认分类有效", () => { + const normalized = normalizeGlobalSettings({ + voiceRecordCategories: ["", "问题", "问题", "灵感"], + voiceRecordDefaultCategory: "不存在" + }); + expect(normalized.voiceRecordCategories).toEqual(["未分类", "问题", "灵感"]); + expect(normalized.voiceRecordDefaultCategory).toBe("未分类"); + }); + + it("会保留合法的新界面语言并拦截非法值", () => { + expect(normalizeGlobalSettings({ uiLanguage: "ja" }).uiLanguage).toBe("ja"); + expect(normalizeGlobalSettings({ uiLanguage: "ko" }).uiLanguage).toBe("ko"); + expect(normalizeGlobalSettings({ uiLanguage: "invalid" as "ja" }).uiLanguage).toBe("zh-Hans"); + }); +}); diff --git a/apps/web/src/utils/defaults.ts b/apps/web/src/utils/defaults.ts new file mode 100644 index 0000000..aac167f --- /dev/null +++ b/apps/web/src/utils/defaults.ts @@ -0,0 +1,277 @@ +import type { GlobalSettings, ThemePreset } from "@/types/app"; +import { pickShellAccentColor } from "@remoteconn/shared"; + +const MIN_TERMINAL_BUFFER_MAX_ENTRIES = 200; +const MAX_TERMINAL_BUFFER_MAX_ENTRIES = 50_000; +const MIN_TERMINAL_BUFFER_MAX_BYTES = 64 * 1024; +const MAX_TERMINAL_BUFFER_MAX_BYTES = 64 * 1024 * 1024; +const UI_LANGUAGE_VALUES = new Set(["zh-Hans", "zh-Hant", "en", "ja", "ko"]); +const DEFAULT_SHELL_BG_COLOR = "#192b4d"; +const DEFAULT_SHELL_TEXT_COLOR = "#e6f0ff"; +export const DEFAULT_VOICE_RECORD_CATEGORY_FALLBACK = "未分类"; +export const DEFAULT_VOICE_RECORD_CATEGORIES = ["未分类", "优化", "新需求", "问题", "灵感"] as const; +export const DEFAULT_VOICE_RECORD_DEFAULT_CATEGORY = "优化"; + +function normalizeInteger(value: number, fallback: number, min: number, max: number): number { + if (!Number.isFinite(value)) { + return fallback; + } + const normalized = Math.round(value); + if (normalized < min) { + return min; + } + if (normalized > max) { + return max; + } + return normalized; +} + +/** + * 界面语言归一化: + * 1. Web 端当前只负责保真,不负责真正切语言; + * 2. 仍需限制合法枚举,避免无效值在跨端同步中持续扩散。 + */ +function normalizeUiLanguage(value: unknown): GlobalSettings["uiLanguage"] { + const normalized = String(value ?? "").trim() as GlobalSettings["uiLanguage"]; + return UI_LANGUAGE_VALUES.has(normalized) ? normalized : "zh-Hans"; +} + +/** + * 归一化闪念分类列表: + * 1. 去空白、去重、保序; + * 2. 强制保留“未分类”兜底项; + * 3. 限制最多 10 项,避免配置面板无限增长。 + */ +function normalizeVoiceRecordCategories(value: unknown): string[] { + const source = Array.isArray(value) ? value : []; + const seen = new Set(); + const next: string[] = []; + + for (const entry of source) { + const normalized = String(entry ?? "").trim(); + if (!normalized || seen.has(normalized)) { + continue; + } + seen.add(normalized); + next.push(normalized); + if (next.length >= 10) { + break; + } + } + + if (!seen.has(DEFAULT_VOICE_RECORD_CATEGORY_FALLBACK)) { + next.unshift(DEFAULT_VOICE_RECORD_CATEGORY_FALLBACK); + } + + return next.slice(0, 10); +} + +/** + * 归一化默认闪念分类: + * 1. 优先使用合法且存在于分类列表中的配置值; + * 2. 否则回退到预设默认分类; + * 3. 若预设默认分类不在列表中,则回退到分类列表首项。 + */ +function normalizeVoiceRecordDefaultCategory(value: unknown, categories: string[]): string { + const normalized = String(value ?? "").trim(); + if (normalized && categories.includes(normalized)) { + return normalized; + } + if (categories.includes(DEFAULT_VOICE_RECORD_DEFAULT_CATEGORY)) { + return DEFAULT_VOICE_RECORD_DEFAULT_CATEGORY; + } + return categories[0] ?? DEFAULT_VOICE_RECORD_CATEGORY_FALLBACK; +} + +/** + * 推导默认网关地址: + * 1) 若显式配置了 VITE_GATEWAY_URL,优先使用; + * 2) 浏览器环境下根据当前站点自动推导; + * 3) 默认走同域 80/443(由反向代理承接)。 + */ +function resolveDefaultGatewayUrl(): string { + const envGateway = import.meta.env.VITE_GATEWAY_URL?.trim(); + if (envGateway) { + return envGateway; + } + + if (typeof window === "undefined") { + return "ws://localhost:8787"; + } + + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const hostname = window.location.hostname; + return `${protocol}//${hostname}`; +} + +export const defaultSettings: GlobalSettings = { + // ── UI 外观 ────────────────────────────────────────────────────────────── + uiLanguage: "zh-Hans", + uiThemePreset: "tide", + uiThemeMode: "dark" as "dark" | "light", + uiAccentColor: "#5bd2ff", + uiBgColor: "#192b4d", + uiTextColor: "#e6f0ff", + uiBtnColor: "#adb9cd", + + // ── Shell 显示 ──────────────────────────────────────────────────────────── + shellThemePreset: "tide", + shellThemeMode: "dark" as "dark" | "light", + shellBgColor: DEFAULT_SHELL_BG_COLOR, + shellTextColor: DEFAULT_SHELL_TEXT_COLOR, + shellAccentColor: pickShellAccentColor(DEFAULT_SHELL_BG_COLOR, DEFAULT_SHELL_TEXT_COLOR), + shellFontFamily: "JetBrains Mono", + shellFontSize: 15, + shellLineHeight: 1.4, + unicode11: true, + + // ── 终端缓冲 ───────────────────────────────────────────────────────────── + terminalBufferMaxEntries: 5000, + terminalBufferMaxBytes: 4 * 1024 * 1024, + + // ── 连接策略 ───────────────────────────────────────────────────────────── + autoReconnect: true, + reconnectLimit: 3, + hostKeyPolicy: "strict", + credentialMemoryPolicy: "remember", + gatewayConnectTimeoutMs: 12000, + waitForConnectedTimeoutMs: 15000, + + // ── 服务器配置预填 ──────────────────────────────────────────────────────── + defaultAuthType: "password", + defaultPort: 22, + defaultProjectPath: "~/workspace", + defaultTimeoutSeconds: 20, + defaultHeartbeatSeconds: 15, + defaultTransportMode: "gateway", + + // ── 日志 ───────────────────────────────────────────────────────────────── + logRetentionDays: 30, + maskSecrets: true, + voiceRecordCategories: [...DEFAULT_VOICE_RECORD_CATEGORIES], + voiceRecordDefaultCategory: DEFAULT_VOICE_RECORD_DEFAULT_CATEGORY +}; + +/** + * 全局配置归一化: + * - 为历史版本缺失字段补齐默认值; + * - 将旧版废弃字段迁移到新域前缀字段(仅首次,不覆盖已有新字段); + * - 对终端缓冲阈值做边界收敛,避免 NaN/异常值导致缓冲策略失效。 + */ +export function normalizeGlobalSettings(raw: Partial | null | undefined): GlobalSettings { + const r = raw ?? {}; + const merged: GlobalSettings = { + ...defaultSettings, + ...r + }; + + // ── 旧字段迁移(取旧值兜底,不覆盖已存在的新字段)──────────────────────── + // fontFamily → shellFontFamily + if (!r.shellFontFamily && r.fontFamily) { + merged.shellFontFamily = r.fontFamily; + } + // fontSize → shellFontSize + if (!r.shellFontSize && r.fontSize !== undefined) { + merged.shellFontSize = r.fontSize; + } + // lineHeight → shellLineHeight + if (!r.shellLineHeight && r.lineHeight !== undefined) { + merged.shellLineHeight = r.lineHeight; + } + // accentColor → uiAccentColor / shellAccentColor + if (!r.uiAccentColor && r.accentColor) { + merged.uiAccentColor = r.accentColor; + } + if (!r.shellAccentColor && r.accentColor) { + merged.shellAccentColor = r.accentColor; + } + // bgColor → uiBgColor / shellBgColor + if (!r.uiBgColor && r.bgColor) { + merged.uiBgColor = r.bgColor; + } + if (!r.shellBgColor && r.bgColor) { + merged.shellBgColor = r.bgColor; + } + // textColor → uiTextColor / shellTextColor + if (!r.uiTextColor && r.textColor) { + merged.uiTextColor = r.textColor; + } + if (!r.shellTextColor && r.textColor) { + merged.shellTextColor = r.textColor; + } + // themePreset → uiThemePreset / shellThemePreset(仅映射合法值) + const legacyThemeMap: Record = { + tide: "tide", + mint: "tide", // mint 无对应新预设,兜底 tide + sunrise: "焰岩" // sunrise 映射到焰岩暖色系 + }; + if (!r.uiThemePreset && r.themePreset) { + merged.uiThemePreset = legacyThemeMap[r.themePreset] ?? "tide"; + } + if (!r.shellThemePreset && r.themePreset) { + merged.shellThemePreset = legacyThemeMap[r.themePreset] ?? "tide"; + } + // shellThemeMode 非法值兜底 + if (merged.shellThemeMode !== "dark" && merged.shellThemeMode !== "light") { + merged.shellThemeMode = "dark"; + } + // credentialMemoryPolicy: "session" → "forget"(旧枚举值 session 对应 forget) + if ((merged.credentialMemoryPolicy as string) === "session") { + merged.credentialMemoryPolicy = "forget"; + } + // uiThemeMode 非法值兜底 + if (merged.uiThemeMode !== "dark" && merged.uiThemeMode !== "light") { + merged.uiThemeMode = "dark"; + } + merged.uiLanguage = normalizeUiLanguage(merged.uiLanguage); + + // ── 数值边界收敛 ───────────────────────────────────────────────────────── + merged.terminalBufferMaxEntries = normalizeInteger( + merged.terminalBufferMaxEntries, + defaultSettings.terminalBufferMaxEntries, + MIN_TERMINAL_BUFFER_MAX_ENTRIES, + MAX_TERMINAL_BUFFER_MAX_ENTRIES + ); + merged.terminalBufferMaxBytes = normalizeInteger( + merged.terminalBufferMaxBytes, + defaultSettings.terminalBufferMaxBytes, + MIN_TERMINAL_BUFFER_MAX_BYTES, + MAX_TERMINAL_BUFFER_MAX_BYTES + ); + + merged.voiceRecordCategories = normalizeVoiceRecordCategories(merged.voiceRecordCategories); + merged.voiceRecordDefaultCategory = normalizeVoiceRecordDefaultCategory( + merged.voiceRecordDefaultCategory, + merged.voiceRecordCategories + ); + + return merged; +} + +/** + * 根据设置推导 gatewayUrl(运行时,不持久化到用户设置)。 + * 优先级:构建时 VITE_GATEWAY_URL > 旧版持久化数据残留 > 自动推导同域地址。 + */ +export function resolveGatewayUrl(settings?: Pick): string { + // 兼容旧版持久化数据中残留的 gatewayUrl 字段 + if (settings?.gatewayUrl) { + return settings.gatewayUrl; + } + return resolveDefaultGatewayUrl(); +} + +/** + * 根据设置推导 gatewayToken(运行时,不持久化到用户设置)。 + * 优先级:构建时 VITE_GATEWAY_TOKEN > 旧版持久化数据残留 > 开发占位符。 + */ +export function resolveGatewayToken(settings?: Pick): string { + const envToken = import.meta.env.VITE_GATEWAY_TOKEN?.trim(); + if (envToken) { + return envToken; + } + // 兼容旧版持久化数据中残留的 gatewayToken 字段 + if (settings?.gatewayToken) { + return settings.gatewayToken; + } + return "remoteconn-dev-token"; +} diff --git a/apps/web/src/utils/dynamicImportGuard.test.ts b/apps/web/src/utils/dynamicImportGuard.test.ts new file mode 100644 index 0000000..ea33115 --- /dev/null +++ b/apps/web/src/utils/dynamicImportGuard.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { + clearDynamicImportRetryMark, + isDynamicImportFailure, + shouldRetryDynamicImportReload +} from "./dynamicImportGuard"; + +interface MemoryStorage { + map: Map; + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +} + +function createMemoryStorage(): MemoryStorage { + const map = new Map(); + return { + map, + getItem: (key: string) => map.get(key) ?? null, + setItem: (key: string, value: string) => { + map.set(key, value); + }, + removeItem: (key: string) => { + map.delete(key); + } + }; +} + +describe("dynamicImportGuard", () => { + it("可识别 Safari 的模块脚本导入失败文案", () => { + expect(isDynamicImportFailure(new TypeError("Importing a module script failed."))).toBe(true); + }); + + it("可识别 Chromium 的动态模块加载失败文案", () => { + expect(isDynamicImportFailure(new Error("Failed to fetch dynamically imported module"))).toBe(true); + }); + + it("非动态导入错误不应误判", () => { + expect(isDynamicImportFailure(new Error("network timeout"))).toBe(false); + expect(isDynamicImportFailure("")).toBe(false); + }); + + it("重试窗口内仅允许一次自动刷新", () => { + const storage = createMemoryStorage(); + const now = 1_000_000; + + expect(shouldRetryDynamicImportReload(storage, now, 15_000)).toBe(true); + expect(shouldRetryDynamicImportReload(storage, now + 5_000, 15_000)).toBe(false); + expect(shouldRetryDynamicImportReload(storage, now + 16_000, 15_000)).toBe(true); + }); + + it("清理重试标记后可重新允许刷新", () => { + const storage = createMemoryStorage(); + const now = 2_000_000; + + expect(shouldRetryDynamicImportReload(storage, now, 15_000)).toBe(true); + clearDynamicImportRetryMark(storage); + expect(shouldRetryDynamicImportReload(storage, now + 1_000, 15_000)).toBe(true); + }); +}); diff --git a/apps/web/src/utils/dynamicImportGuard.ts b/apps/web/src/utils/dynamicImportGuard.ts new file mode 100644 index 0000000..db6c41b --- /dev/null +++ b/apps/web/src/utils/dynamicImportGuard.ts @@ -0,0 +1,118 @@ +import type { Router } from "vue-router"; + +const RETRY_MARK_KEY = "remoteconn:dynamic-import-retry-at"; +const RETRY_WINDOW_MS = 15_000; + +interface SessionStorageLike { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +} + +/** + * 判断是否属于“动态模块脚本加载失败”: + * - Safari 常见文案:Importing a module script failed; + * - Chromium 常见文案:Failed to fetch dynamically imported module; + * - Firefox 常见文案:error loading dynamically imported module。 + */ +export function isDynamicImportFailure(error: unknown): boolean { + const message = extractErrorMessage(error).toLowerCase(); + if (!message) { + return false; + } + + const patterns = [ + "importing a module script failed", + "failed to fetch dynamically imported module", + "error loading dynamically imported module" + ]; + + return patterns.some((pattern) => message.includes(pattern)); +} + +/** + * 决定是否允许本次自动刷新: + * - 15 秒窗口内仅允许一次,避免 chunk 持续不可用时陷入刷新循环; + * - 超过窗口后允许再次尝试,适配短时发布抖动场景。 + */ +export function shouldRetryDynamicImportReload( + storage: SessionStorageLike, + now = Date.now(), + retryWindowMs = RETRY_WINDOW_MS +): boolean { + const raw = storage.getItem(RETRY_MARK_KEY); + const lastRetryAt = Number(raw); + if (Number.isFinite(lastRetryAt) && now - lastRetryAt < retryWindowMs) { + return false; + } + storage.setItem(RETRY_MARK_KEY, String(now)); + return true; +} + +/** + * 在路由成功就绪后清理重试标记: + * - 让后续真实的新一轮发布故障仍可触发一次自动恢复; + * - 避免旧标记长期驻留导致后续无法自动恢复。 + */ +export function clearDynamicImportRetryMark(storage: SessionStorageLike): void { + storage.removeItem(RETRY_MARK_KEY); +} + +/** + * 安装动态导入失败恢复逻辑: + * - 监听 router.onError; + * - 命中动态模块加载失败时自动刷新当前目标路由; + * - sessionStorage 不可用时降级为仅输出错误,不抛出二次异常。 + */ +export function installDynamicImportRecovery(router: Router): void { + router.onError((error, to) => { + if (!isDynamicImportFailure(error)) { + return; + } + + const storage = safeSessionStorage(); + if (!storage) { + console.error("[router] 动态模块加载失败,且 sessionStorage 不可用", error); + return; + } + + if (!shouldRetryDynamicImportReload(storage)) { + clearDynamicImportRetryMark(storage); + console.error("[router] 动态模块加载失败,已达单次自动恢复上限", error); + return; + } + + const fallbackUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`; + const target = typeof to.fullPath === "string" && to.fullPath.length > 0 ? to.fullPath : fallbackUrl; + window.location.assign(target); + }); + + void router.isReady().then(() => { + const storage = safeSessionStorage(); + if (storage) { + clearDynamicImportRetryMark(storage); + } + }); +} + +function extractErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message ?? ""; + } + if (typeof error === "string") { + return error; + } + if (error && typeof error === "object" && "message" in error) { + const maybeMessage = (error as { message?: unknown }).message; + return typeof maybeMessage === "string" ? maybeMessage : ""; + } + return ""; +} + +function safeSessionStorage(): SessionStorageLike | null { + try { + return window.sessionStorage; + } catch { + return null; + } +} diff --git a/apps/web/src/utils/feedback.ts b/apps/web/src/utils/feedback.ts new file mode 100644 index 0000000..fac587f --- /dev/null +++ b/apps/web/src/utils/feedback.ts @@ -0,0 +1,131 @@ +function asMessage(error: unknown): string { + if (error instanceof Error) { + return String(error.message || ""); + } + if (typeof error === "string") { + return error; + } + try { + return JSON.stringify(error); + } catch { + return ""; + } +} + +function normalizeText(input: string): string { + return input.trim().toLowerCase(); +} + +export function toFriendlyDisconnectReason(reason: string | undefined): string { + const raw = String(reason ?? "").trim(); + if (!raw) return "连接已关闭"; + + const map: Record = { + manual: "你已主动断开连接", + switch: "切换连接目标,已断开当前会话", + host_key_rejected: "主机指纹未被信任,连接已断开", + auth_failed: "认证失败,连接被服务器拒绝", + rate_limit: "连接过于频繁,请稍后重试", + shell_closed: "远端 Shell 已关闭", + connection_closed: "服务器连接已关闭", + ws_error: "网关连接异常", + ws_closed: "网关连接已断开", + ws_peer_normal_close: "客户端已关闭连接", + unknown: "连接已关闭" + }; + + return map[raw] ?? `连接已关闭(${raw})`; +} + +export function toFriendlyConnectionError(error: unknown): string { + const message = asMessage(error); + const lower = normalizeText(message); + + if (lower.includes("rate_limit") || message.includes("连接过于频繁")) { + return "连接过于频繁,请稍后重试。"; + } + + if (lower.includes("auth_failed") || message.includes("token 无效")) { + return "网关鉴权失败,请联系管理员检查网关令牌。"; + } + + if (message.includes("SSH 认证失败")) { + return "SSH 认证失败。请检查账号/凭据,若服务器仅允许公钥认证,请改用私钥方式。"; + } + + if (message.includes("主机指纹") && message.includes("信任")) { + return "主机指纹校验未通过,请确认主机身份后重试。"; + } + + if (message.includes("Timed out while waiting for handshake") || message.includes("连接超时") || lower.includes("timeout")) { + return "连接超时。请检查服务器地址、端口和网络连通性。"; + } + + if (message.includes("无法连接网关") || lower.includes("ws_closed") || lower.includes("websocket")) { + return "无法连接网关,请检查网关地址、服务状态与网络策略。"; + } + + if (message.includes("凭据读取失败")) { + return "凭据读取失败,请在服务器设置页重新保存后重试。"; + } + + if (!message) { + return "连接失败,请稍后重试。"; + } + + return message; +} + +export function toFriendlyError(error: unknown): string { + const message = asMessage(error); + const lower = normalizeText(message); + + if (!message) { + return "操作失败,请稍后重试。"; + } + + if ( + lower.includes("ws_") || + lower.includes("websocket") || + lower.includes("auth_failed") || + lower.includes("rate_limit") || + message.includes("连接") || + message.includes("网关") || + message.includes("SSH") + ) { + return toFriendlyConnectionError(message); + } + + if (message.includes("密码不能为空") || message.includes("私钥内容不能为空") || message.includes("证书模式下")) { + return message; + } + + if (lower.includes("json") && (lower.includes("parse") || lower.includes("unexpected token"))) { + return "配置内容不是有效的 JSON,请检查格式后重试。"; + } + + if (message.includes("会话未连接")) { + return "当前会话未连接,请先建立连接。"; + } + + if (message.includes("凭据读取失败")) { + return "凭据读取失败,请在服务器设置页重新保存后重试。"; + } + + if (message.includes("未找到凭据内容") || message.includes("凭据引用不存在")) { + return "未找到可用凭据,请在服务器设置页重新填写并保存。"; + } + + return message; +} + +export function formatActionError(action: string, error: unknown): string { + const detail = toFriendlyError(error); + return `${action}:${detail}`; +} + +export function toToastTitle(level: "info" | "warn" | "error"): string { + if (level === "error") return "错误"; + if (level === "warn") return "注意"; + return "提示"; +} diff --git a/apps/web/src/utils/rememberedState.test.ts b/apps/web/src/utils/rememberedState.test.ts new file mode 100644 index 0000000..2193457 --- /dev/null +++ b/apps/web/src/utils/rememberedState.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { readRememberedEnum, writeRememberedEnum } from "./rememberedState"; + +describe("rememberedState", () => { + it("可读取允许列表内的持久化值", () => { + const storage = { + getItem: (key: string) => (key === "k" ? "shell" : null) + } as unknown as Storage; + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage + }); + + const value = readRememberedEnum("k", ["ui", "shell", "log"] as const); + expect(value).toBe("shell"); + }); + + it("读取到不在允许列表中的值时返回 null", () => { + const storage = { + getItem: () => "unknown" + } as unknown as Storage; + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage + }); + + const value = readRememberedEnum("k", ["ui", "shell", "log"] as const); + expect(value).toBeNull(); + }); + + it("localStorage 不可用时应静默降级", () => { + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + get() { + throw new Error("storage blocked"); + } + }); + + expect(readRememberedEnum("k", ["ui", "shell"] as const)).toBeNull(); + expect(() => writeRememberedEnum("k", "ui")).not.toThrow(); + }); +}); diff --git a/apps/web/src/utils/rememberedState.ts b/apps/web/src/utils/rememberedState.ts new file mode 100644 index 0000000..c1931bb --- /dev/null +++ b/apps/web/src/utils/rememberedState.ts @@ -0,0 +1,20 @@ +export function readRememberedEnum( + storageKey: string, + allowedValues: readonly T[] +): T | null { + try { + const raw = localStorage.getItem(storageKey); + if (!raw) return null; + return allowedValues.includes(raw as T) ? (raw as T) : null; + } catch { + return null; + } +} + +export function writeRememberedEnum(storageKey: string, value: string): void { + try { + localStorage.setItem(storageKey, value); + } catch { + // 忽略本地存储不可用场景(如隐私模式限制) + } +} diff --git a/apps/web/src/utils/themeChrome.test.ts b/apps/web/src/utils/themeChrome.test.ts new file mode 100644 index 0000000..c7c049b --- /dev/null +++ b/apps/web/src/utils/themeChrome.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { applyThemeChromeColor, resolveThemeChromeColor } from "./themeChrome"; + +type FakeMeta = { + name: string; + content: string; +}; + +type FakeDocument = { + head: { + appendChild: (node: FakeMeta) => void; + }; + querySelector: (selector: string) => FakeMeta | null; + createElement: (tagName: string) => FakeMeta; + metas: FakeMeta[]; +}; + +function createFakeDocument(initialMetas: FakeMeta[] = []): FakeDocument { + const metas = [...initialMetas]; + return { + metas, + head: { + appendChild: (node) => { + metas.push(node); + } + }, + querySelector: (selector) => { + if (selector !== 'meta[name="theme-color"]') { + return null; + } + return metas.find((meta) => meta.name === "theme-color") ?? null; + }, + createElement: (tagName) => { + if (tagName !== "meta") { + throw new Error(`unexpected tag: ${tagName}`); + } + return { name: "", content: "" }; + } + }; +} + +describe("themeChrome", () => { + it("优先使用当前主题背景色作为宿主顶栏颜色", () => { + expect(resolveThemeChromeColor({ "--bg": " #f6fbff " })).toBe("#f6fbff"); + }); + + it("背景色缺失时回退默认值", () => { + expect(resolveThemeChromeColor({})).toBe("#192b4d"); + expect(resolveThemeChromeColor({ "--bg": " " }, "#ffffff")).toBe("#ffffff"); + }); + + it("不存在 theme-color meta 时应自动创建", () => { + const doc = createFakeDocument(); + applyThemeChromeColor("#102030", doc); + + expect(doc.metas).toHaveLength(1); + expect(doc.metas[0]).toEqual({ name: "theme-color", content: "#102030" }); + }); + + it("已存在 theme-color meta 时应更新内容而不是重复插入", () => { + const doc = createFakeDocument([{ name: "theme-color", content: "#000000" }]); + applyThemeChromeColor("#abcdef", doc); + + expect(doc.metas).toHaveLength(1); + expect(doc.metas[0]?.content).toBe("#abcdef"); + }); +}); diff --git a/apps/web/src/utils/themeChrome.ts b/apps/web/src/utils/themeChrome.ts new file mode 100644 index 0000000..a39fee7 --- /dev/null +++ b/apps/web/src/utils/themeChrome.ts @@ -0,0 +1,52 @@ +type MetaLike = { + name: string; + content: string; +}; + +export type ThemeChromeDocument = { + head: { + appendChild: (node: TMeta) => unknown; + }; + querySelector: (selector: string) => TMeta | null; + createElement: (tagName: string) => TMeta; +}; + +/** + * 提取用于浏览器/宿主顶栏的主题色。 + * 约束: + * - 优先使用当前 UI 背景色,保证“顶部工具栏上方区域”跟随界面主题; + * - 若运行时变量缺失,回退到稳定深色默认值,避免写入空字符串。 + */ +export function resolveThemeChromeColor( + themeVars: Record, + fallback = "#192b4d" +): string { + const nextColor = themeVars["--bg"]?.trim(); + return nextColor && nextColor.length > 0 ? nextColor : fallback; +} + +/** + * 同步浏览器/宿主顶栏颜色。 + * 说明: + * - Android Chrome、部分 WebView/PWA 会读取 meta[name="theme-color"] 渲染顶部宿主栏; + * - 若主题切换后不更新这个 meta,用户会看到“工具栏上方颜色锁死”。 + */ +export function applyThemeChromeColor( + themeColor: string, + doc: ThemeChromeDocument +): void { + const nextColor = themeColor.trim(); + if (!nextColor) { + return; + } + const targetDocument = doc; + let themeMeta = targetDocument.querySelector('meta[name="theme-color"]'); + if (!themeMeta) { + themeMeta = targetDocument.createElement("meta"); + themeMeta.name = "theme-color"; + targetDocument.head.appendChild(themeMeta); + } + if (themeMeta.content !== nextColor) { + themeMeta.content = nextColor; + } +} diff --git a/apps/web/src/utils/themePresetLabels.test.ts b/apps/web/src/utils/themePresetLabels.test.ts new file mode 100644 index 0000000..ec0cef0 --- /dev/null +++ b/apps/web/src/utils/themePresetLabels.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { buildThemePresetOptions, getThemePresetLabel } from "./themePresetLabels"; + +describe("themePresetLabels", () => { + it("会为已确认主题输出新的中文展示名", () => { + expect(getThemePresetLabel("tide")).toBe("潮汐"); + expect(getThemePresetLabel("暮砂")).toBe("沙丘"); + expect(getThemePresetLabel("靛雾")).toBe("岚雾"); + }); + + it("未重命名主题继续回退内部值,并保留共享预设顺序", () => { + const options = buildThemePresetOptions(); + + expect(getThemePresetLabel("绛霓")).toBe("绛霓"); + expect(options.slice(0, 7)).toEqual([ + { label: "潮汐", value: "tide" }, + { label: "沙丘", value: "暮砂" }, + { label: "棱光", value: "霓潮" }, + { label: "苔影", value: "苔暮" }, + { label: "余烬", value: "焰岩" }, + { label: "陶土", value: "岩陶" }, + { label: "岚雾", value: "靛雾" } + ]); + }); +}); diff --git a/apps/web/src/utils/themePresetLabels.ts b/apps/web/src/utils/themePresetLabels.ts new file mode 100644 index 0000000..cffb8e5 --- /dev/null +++ b/apps/web/src/utils/themePresetLabels.ts @@ -0,0 +1,34 @@ +import type { ThemePreset } from "@/types/app"; +import { THEME_PRESETS } from "@remoteconn/shared"; + +export type ThemePresetOption = { + label: string; + value: ThemePreset; +}; + +const THEME_PRESET_LABEL_OVERRIDES: Partial> = Object.freeze({ + tide: "潮汐", + 暮砂: "沙丘", + 霓潮: "棱光", + 苔暮: "苔影", + 焰岩: "余烬", + 岩陶: "陶土", + 靛雾: "岚雾" +}); + +/** + * Web 设置页只改展示名,不改持久化值,避免影响既有配置兼容性。 + */ +export function getThemePresetLabel(preset: ThemePreset): string { + return THEME_PRESET_LABEL_OVERRIDES[preset] ?? preset; +} + +/** + * 主题选项顺序直接复用共享预设定义,避免 Web 侧再维护一份平行枚举。 + */ +export function buildThemePresetOptions(): ThemePresetOption[] { + return (Object.keys(THEME_PRESETS) as ThemePreset[]).map((preset) => ({ + value: preset, + label: getThemePresetLabel(preset) + })); +} diff --git a/apps/web/src/utils/time.ts b/apps/web/src/utils/time.ts new file mode 100644 index 0000000..e771423 --- /dev/null +++ b/apps/web/src/utils/time.ts @@ -0,0 +1,17 @@ +export function nowIso(): string { + return new Date().toISOString(); +} + +export function formatDateTime(input: string | Date): string { + const date = input instanceof Date ? input : new Date(input); + return date.toLocaleString("zh-CN", { hour12: false }); +} + +export function formatDurationMs(ms: number): string { + if (!Number.isFinite(ms) || ms <= 0) return "0s"; + const seconds = Math.round(ms / 1000); + const m = Math.floor(seconds / 60); + const s = seconds % 60; + if (m <= 0) return `${s}s`; + return `${m}m ${s}s`; +} diff --git a/apps/web/src/utils/useRememberedEnumRef.ts b/apps/web/src/utils/useRememberedEnumRef.ts new file mode 100644 index 0000000..3313119 --- /dev/null +++ b/apps/web/src/utils/useRememberedEnumRef.ts @@ -0,0 +1,23 @@ +import { onMounted, watch, type Ref } from "vue"; +import { readRememberedEnum, writeRememberedEnum } from "@/utils/rememberedState"; + +interface UseRememberedEnumRefOptions { + storageKey: string; + allowedValues: readonly T[]; + target: Ref; +} + +export function useRememberedEnumRef(options: UseRememberedEnumRefOptions): void { + const { storageKey, allowedValues, target } = options; + + onMounted(() => { + const saved = readRememberedEnum(storageKey, allowedValues); + if (saved) { + target.value = saved; + } + }); + + watch(target, (value) => { + writeRememberedEnum(storageKey, value); + }); +} diff --git a/apps/web/src/views/AboutView.vue b/apps/web/src/views/AboutView.vue new file mode 100644 index 0000000..3238c64 --- /dev/null +++ b/apps/web/src/views/AboutView.vue @@ -0,0 +1,195 @@ + + + diff --git a/apps/web/src/views/ConnectView.vue b/apps/web/src/views/ConnectView.vue new file mode 100644 index 0000000..ef2bb1a --- /dev/null +++ b/apps/web/src/views/ConnectView.vue @@ -0,0 +1,657 @@ + + + diff --git a/apps/web/src/views/LogsView.vue b/apps/web/src/views/LogsView.vue new file mode 100644 index 0000000..633e1d2 --- /dev/null +++ b/apps/web/src/views/LogsView.vue @@ -0,0 +1,108 @@ + + + diff --git a/apps/web/src/views/PluginsView.vue b/apps/web/src/views/PluginsView.vue new file mode 100644 index 0000000..25db604 --- /dev/null +++ b/apps/web/src/views/PluginsView.vue @@ -0,0 +1,113 @@ + + + diff --git a/apps/web/src/views/ServerSettingsView.vue b/apps/web/src/views/ServerSettingsView.vue new file mode 100644 index 0000000..4f512ad --- /dev/null +++ b/apps/web/src/views/ServerSettingsView.vue @@ -0,0 +1,952 @@ +