Compare commits

...
8 Commits
Author SHA1 Message Date
Bill d3529bb9dc chore(release): v1.0.24
CI / typecheck + test + build (windows) (push) Waiting to run
2026-10-09 15:51:12 +08:00
Bill 12ee779040 test(renderer): 新增 cwd/链接/broadcast 纯函数表驱动测试
CI / typecheck + test + build (windows) (push) Canceled after 0s
渲染层纯逻辑此前零测试覆盖。三个新测试直接 import .ts 源文件(Node
22.18+ 原生类型剥离,不经 esbuild bundle):
- terminal-cwd(69 断言):cdArgument 全部 shell 变体与 conemuCwd 载荷
- terminal-links(92 断言):findUrls 真实合同——尾随标点剥离、重叠
  收敛、裸 host 补 scheme、36 条不变式
- broadcast-store(39 断言):broadcastFanOut 扇出与 pruneOnTargetLoss
  (经 unregisterSession 间接覆盖)
2026-10-09 11:31:26 +08:00
Bill 64982993f3 build(scripts): 发布护栏 fail-closed 与 flag 语义;文档对齐
- release.cjs:assertNoDowngrade 的通道探测原先对网络错误/5xx/解析失败
  一律放行(fail-open),旧分支发旧版本号撞上探测失败会覆盖 latest.yml
  并让 pruneChannel 删掉线上更新版本的 exe。现仅通道真空(404)放行,
  其余抛错中止发布;prune 前的探测保持 best-effort
- 新增 --skip-gitea-release(只跳 Gitea release、通道照常上传);
  --skip-gitea 保持原行为但打印醒目警告;usage 补全 flag 语义
- 文档对齐:STATE.md(v1.0.23、19→22 离线测试、清除 MSI 描述、bundle
  清单补全)、ROADMAP 补 M12/M13、ci.yml 与 AGENTS.md 测试计数、
  AGENTS.md 补录本轮关键不变式(legacy 模板门控、SESSION_STATE/
  pendingOpens、zmodem 背压钩子、主机密钥 TTL);.gitignore 清重复与
  死条目
2026-10-09 11:31:25 +08:00
Bill 6a218ec6c0 fix(renderer): 界面与设置页一轮修补
- FilePanel 上传进度监听器组件卸载时清理(原先只在终态/reject 时 off)
- MonitorPanel:首采即失败时显示中断错误条,不再被 meta 门挡成永远
  '采集中'
- HighlightImportExport:blob 下载延迟 revokeObjectURL(原先同步回收
  会中止下载);FileReader 补 onerror
- HighlightTab 新建规则改 getState() 读法防 stale(对齐 replaceRule)
- ConnectionSidebar/CommandsPanel 三处删除/清空 handler 补 try/catch
  与 message.error(原先失败静默)
- highlightIO 导入信封补 version 校验、kind 缺失不再放行
- i18n:补 settings.highlight.builtin.percent 四语言键(原先唯一缺失
  的内置规则,非中文界面显示中文 note)
2026-10-09 11:31:10 +08:00
Bill 172cce1962 fix(stores): 配置文件形状不符先备份再回退;日志尾部同步落盘
- commands/connections/settings 三个 store 原先只对 JSON.parse 抛错做
  .bak 备份,合法 JSON 但形状不符(如 [1,2,3])会静默回退空/默认,下次
  写盘把命令库/书签凭据/自定义主题整体销毁。现统一为形状不符也先备份
  (与 knownHosts 的 fail-closed 策略对齐),三个 store 测试补对应用例
- sanitizeRules 回退分支返回预设副本而非模块级共享数组引用,并在回退时
  记录 warning(原先连警告都没有)
- 会话日志尾部:logStop 触发的最后一轮 flush 改同步落盘(在途异步写未
  落地时由 drain 循环做保序的最终同步轮),before-quit 不再丢日志尾巴
2026-10-09 11:31:06 +08:00
Bill e23010f950 fix(zmodem): 接收路径写背压;sftp 删除容忍 ENOENT;sysinfo 停止关流
- zmodem 接收:fs WriteStream 缓冲无上限,慢盘 + 快对端可把内存涨到接近
  文件大小。write() 返回 false 时暂停喂入 sentry 的 ssh 流、drain 恢复;
  touchActivity 只在写入被接受或 drain 恢复时计数,stall 看门狗不再把
  '堆在缓冲里'当进展;finalize/detach 解除悬挂暂停
- sftp 删除路径 ENOENT 一律视为已删成功(文件已被他人删除/重复删除/
  传输重跑后目标已消失),不再汇成假 delete failed;真错误仍报错
  (tests/sftp-timeout.mjs 新增 5 断言含防过度吞错用例)
- sysinfo:exec 回调里 state.stopped 分支先 close 到手的 stream
2026-10-09 11:30:51 +08:00
Bill 81ac112dac fix(main): PTY_EXIT 补偿查询、SSH 飞行连接登记与双窗口守卫
- 新增 SESSION_STATE 查询通道(契约先行):PTY_EXIT 只广播一次不重放,
  绑定晚于退出的窗格订阅后补查一次退出状态,快速退出的 shell 不再留下
  永远空白、无死亡遮罩的窗格(退出码登记表上限 512 条 FIFO)
- openSession 在 connectSsh 返回前登记 pendingOpens(owner + onClient
  最早引用),killPtysByOwner/killAllPtys 可中止飞行中的握手,renderer
  崩溃后不再留下无主孤儿 SSH 会话
- whenReady 的 createWindow 加已有窗口守卫,堵住与 second-instance
  showOrCreate 的双窗口竞态
- killAllPtys 补 replayBuffers.clear(),与 killSession 拆卸清单对齐
- promptUser 注释 30s 改为实际 120s;pty.ts 顺带注入 zmodem 背压钩子
  (见下一提交)
2026-10-09 11:30:51 +08:00
Bill 0289dcbd1d fix(workspace): 旧版布局模板不再误杀 SSH 会话
legacy(M6.1 前单 dockview)模板只喂 terminal dockview,成功路径却无条件
rebind 两个 dockview 并 kill 全部 previousSessions,SSH 活面板被降级为本地
终端、会话被静默销毁。成功路径改为按 sshTouched 门控 rebind、按 live 集合
过滤收尾 kill(与错误路径同一语义)。

另:handleApplyTemplate 加在途守卫(并发应用会互杀对方刚恢复的会话);
主机密钥队列加 120s TTL 清扫(主进程超时后不再广播,陈旧弹窗会永远占住
只渲染队头的队列),respondHostKey 移出 setState updater。
2026-10-09 11:30:34 +08:00
47 changed files with 1706 additions and 204 deletions

No files matched your search

+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
# npm test 会先自动跑 pretest(typecheck),再重建 esbuild bundle 并跑 13 个离线测试 # npm test 会先自动跑 pretest(typecheck),再重建 esbuild bundle 并跑 19 个离线测试
- name: Test - name: Test
run: npm test run: npm test
-3
View File
@@ -4,9 +4,7 @@ node_modules/
# build output # build output
out/ out/
dist/ dist/
tests/.session-e2e.cjs
tests/.sftp-svc.* tests/.sftp-svc.*
tests/.hl-smoke.*
# research artifacts (XTerminal reverse-engineering notes, not part of the app) # research artifacts (XTerminal reverse-engineering notes, not part of the app)
research/ research/
@@ -52,7 +50,6 @@ release/
# stray local test artifacts # stray local test artifacts
.min-test.bin .min-test.bin
.min2-test.bin .min2-test.bin
.exact-test.bin
tests/.zm-src-*/ tests/.zm-src-*/
tests/.zm-out-*/ tests/.zm-out-*/
+8 -3
View File
@@ -7,10 +7,10 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
- 开发:`npm run dev`(主进程改动不热重建,需重启) - 开发:`npm run dev`(主进程改动不热重建,需重启)
- dev 实例使用独立用户数据目录 `%APPDATA%\OpenTerminal-dev` 与独立单实例锁(`src/main/index.ts` 顶部 `!app.isPackaged` 分支),窗口标题带 `(dev)`:**可与已安装的正式版同时运行,互不干扰**,也不会把测试设置/会话写进真实配置 - dev 实例使用独立用户数据目录 `%APPDATA%\OpenTerminal-dev` 与独立单实例锁(`src/main/index.ts` 顶部 `!app.isPackaged` 分支),窗口标题带 `(dev)`:**可与已安装的正式版同时运行,互不干扰**,也不会把测试设置/会话写进真实配置
- 类型检查:`npm run typecheck`(tsconfig.node.json + tsconfig.web.json;只看渲染层可单跑 `npx tsc --noEmit -p tsconfig.web.json`) - 类型检查:`npm run typecheck`(tsconfig.node.json + tsconfig.web.json;只看渲染层可单跑 `npx tsc --noEmit -p tsconfig.web.json`)
- 测试:`npm test`(**npm 生命周期先自动跑 `pretest` 做类型检查**,再 `node tests/build-bundles.cjs` 重建 esbuild bundle,然后依次跑可离线运行的 18 个测试:ssh-loopback、commands-store、connections-store、settings-store、local-path-grants、lock-store、lock-controller、lock-shortcuts、hl-split-smoke、hl-rules、reserved-accelerators、ipc-guard、updater-fallback、log-sanitizer、sftp-timeout、terminal-title、zmodem-e2e、ssh-session-e2e、sysinfo-e2e;真实服务器测试需 JD_* 凭据,不在此列) - 测试:`npm test`(**npm 生命周期先自动跑 `pretest` 做类型检查**,再 `node tests/build-bundles.cjs` 重建 esbuild bundle,然后依次跑可离线运行的 22 个测试:ssh-loopback、commands-store、connections-store、settings-store、local-path-grants、lock-store、lock-controller、lock-shortcuts、hl-split-smoke、hl-rules、reserved-accelerators、ipc-guard、updater-fallback、log-sanitizer、sftp-timeout、terminal-title、zmodem-e2e、ssh-session-e2e、sysinfo-e2e、terminal-cwd、terminal-links、broadcast-store;真实服务器测试需 JD_* 凭据,不在此列)。后三个渲染层纯函数测试用 Node 22.18+ 的原生 TS 类型剥离**直接 import `.ts` 源文件**(不经 esbuild bundle),Node 20/22.17 以下会 `ERR_UNKNOWN_FILE_EXTENSION`
- 依赖分类规则:**只有 `src/main/`/`src/preload/` 实际 import 的包才能进 `dependencies`**(node-pty/ssh2/zmodem.js/font-list/electron-updater);纯渲染层依赖一律 devDependencies(Vite 全量打包进 out/renderer,`externalizeDepsPlugin` 不作用渲染层)——这条让 asar 从 98MB 瘦到 8.1MB,加新依赖时别放错边 - 依赖分类规则:**只有 `src/main/`/`src/preload/` 实际 import 的包才能进 `dependencies`**(node-pty/ssh2/zmodem.js/font-list/electron-updater);纯渲染层依赖一律 devDependencies(Vite 全量打包进 out/renderer,`externalizeDepsPlugin` 不作用渲染层)——这条让 asar 从 98MB 瘦到 8.1MB,加新依赖时别放错边
- 下载量统计:`node scripts/download-stats.cjs`(Gitea + GitHub release 资产的 download_count 汇总;GitHub 优先直连、失败自动回退 `HTTPS_PROXY`/本地 7897;更新通道无计数接口不计入) - 下载量统计:`node scripts/download-stats.cjs`(Gitea + GitHub release 资产的 download_count 汇总;GitHub 优先直连、失败自动回退 `HTTPS_PROXY`/本地 7897;更新通道无计数接口不计入)
- 打包:`npm run dist`(**生命周期先自动跑 `predist` → `npm test`,即类型检查 + 18 个离线测试全部通过后才 build/package**,typecheck 全程只跑一次;predist 末尾的 `npm install --package-lock-only` 会把 `package-lock.json` 根版本号对齐 `package.json`,**发布提交必须带上 package-lock.json**),产物在 `release/`(exe + latest.yml + blockmap) - 打包:`npm run dist`(**生命周期先自动跑 `predist` → `npm test`,即类型检查 + 22 个离线测试全部通过后才 build/package**,typecheck 全程只跑一次;predist 末尾的 `npm install --package-lock-only` 会把 `package-lock.json` 根版本号对齐 `package.json`,**发布提交必须带上 package-lock.json**),产物在 `release/`(exe + latest.yml + blockmap)
- GitHub Actions:`.github/workflows/ci.yml` 在 windows-latest + Node 22 上跑 `npm ci` / `npm test`(含 pretest typecheck)/ `npm run build`,只做验证,不打包安装器、不发布 - GitHub Actions:`.github/workflows/ci.yml` 在 windows-latest + Node 22 上跑 `npm ci` / `npm test`(含 pretest typecheck)/ `npm run build`,只做验证,不打包安装器、不发布
- 国内网络需镜像:`ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ npm run dist` - 国内网络需镜像:`ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ npm run dist`
- 依赖一致性闸门:`predist` 开头跑 `node scripts/verify-deps.cjs`(逐条比对 `node_modules` 与 `package-lock.json` 的版本,跨平台 optional 依赖缺失跳过;不一致就 exit 1)——`npm install --package-lock-only` **只重算 lockfile、不碰 node_modules**(本版 npm 还会把 `node_modules/.package-lock.json` 一并重写成理想树,制造「已经装好了」的假象),所以**改动依赖版本后必须真实 `npm install` 或 `npm ci` 再构建**,别指望 lockfile 对齐就等于树里换了包;v1.0.21 的中文输入法回归正是这个坑(`@xterm/xterm` 锁 6.1.0-beta.304,树里还是 6.0.0,打进去的是旧代码) - 依赖一致性闸门:`predist` 开头跑 `node scripts/verify-deps.cjs`(逐条比对 `node_modules` 与 `package-lock.json` 的版本,跨平台 optional 依赖缺失跳过;不一致就 exit 1)——`npm install --package-lock-only` **只重算 lockfile、不碰 node_modules**(本版 npm 还会把 `node_modules/.package-lock.json` 一并重写成理想树,制造「已经装好了」的假象),所以**改动依赖版本后必须真实 `npm install` 或 `npm ci` 再构建**,别指望 lockfile 对齐就等于树里换了包;v1.0.21 的中文输入法回归正是这个坑(`@xterm/xterm` 锁 6.1.0-beta.304,树里还是 6.0.0,打进去的是旧代码)
@@ -37,8 +37,9 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
- Gitea:release(exe 资产)→ Gitea 更新通道(`api/packages/admin/generic/openterminal-update/stable`:exe.blockmap → exe → release-notes.md → **latest.yml 最后**) - Gitea:release(exe 资产)→ Gitea 更新通道(`api/packages/admin/generic/openterminal-update/stable`:exe.blockmap → exe → release-notes.md → **latest.yml 最后**)
- GitHub:release 资产再次随版本同步发布(exe + exe.blockmap + latest.yml),electron-updater 标准 GitHub provider 直接吃 release 资产 - GitHub:release 资产再次随版本同步发布(exe + exe.blockmap + latest.yml),electron-updater 标准 GitHub provider 直接吃 release 资产
- 通道不再先删旧版:新版本文件全部传完、latest.yml 生效后才清掉上一版 exe/blockmap,中途失败不会把通道打空;同一版本可重复运行(release 复用、已传资产跳过) - 通道不再先删旧版:新版本文件全部传完、latest.yml 生效后才清掉上一版 exe/blockmap,中途失败不会把通道打空;同一版本可重复运行(release 复用、已传资产跳过)
- 上传前先比通道版本:`assertNoDowngrade()` 读通道 latest.yml,若线上版本**高于**待发布版本就直接拒绝——三条本地护栏只比本地产物,旧分支发旧版本号会一路通过,覆盖 latest.yml 之后 `pruneChannel` 会把线上新版本的 exe/blockmap 删掉 - 上传前先比通道版本:`assertNoDowngrade()` 读通道 latest.yml,若线上版本**高于**待发布版本就直接拒绝——三条本地护栏只比本地产物,旧分支发旧版本号会一路通过,覆盖 latest.yml 之后 `pruneChannel` 会把线上新版本的 exe/blockmap 删掉。该探测**fail-closed**:网络错误 / 非 404 失败 / 解析不出 version 一律抛错中止发布,只有通道真空(404)才放行;`pruneChannel` 前那次探测是 best-effort(失败只跳过清理并打日志)
- 只补通道:`node scripts/release.cjs <版本号> --channel-only`(不建 release、不发 GitHub) - 只补通道:`node scripts/release.cjs <版本号> --channel-only`(不建 release、不发 GitHub)
- 跳过部分目标:`--skip-github` 只跳 GitHub release;`--skip-gitea-release` **只跳 Gitea release、更新通道照常上传**;`--skip-gitea` 两者都跳(会打印醒目警告——国内用户将收不到该版本,只想跳 release 请用 `--skip-gitea-release`)
- 国内通道全程直连,**不需要设代理**;GitHub 请求走 `HTTPS_PROXY=http://127.0.0.1:7897`(脚本只把它用于 GitHub 请求) - 国内通道全程直连,**不需要设代理**;GitHub 请求走 `HTTPS_PROXY=http://127.0.0.1:7897`(脚本只把它用于 GitHub 请求)
5. 验证更新通道:`curl https://git.codingplan.site/api/packages/admin/generic/openterminal-update/stable/latest.yml` 应返回新版本号 5. 验证更新通道:`curl https://git.codingplan.site/api/packages/admin/generic/openterminal-update/stable/latest.yml` 应返回新版本号
6. `git tag v<版本号>` 并推送两个远程(代码/tag 与 release 资产的镜像保持同步)。注意顺序坑:release.cjs 创建 release 时若远端尚无该 tag,Gitea/GitHub 会在**默认分支 HEAD** 自动建一个指向错误 commit 的 tag,第 6 步推送会被拒——要么先建 tag 推上去再跑 release.cjs,要么事后 `git push -f <远端> v<版本号>` 强制修正到 release commit(v1.0.22 即踩过) 6. `git tag v<版本号>` 并推送两个远程(代码/tag 与 release 资产的镜像保持同步)。注意顺序坑:release.cjs 创建 release 时若远端尚无该 tag,Gitea/GitHub 会在**默认分支 HEAD** 自动建一个指向错误 commit 的 tag,第 6 步推送会被拒——要么先建 tag 推上去再跑 release.cjs,要么事后 `git push -f <远端> v<版本号>` 强制修正到 release commit(v1.0.22 即踩过)
@@ -72,6 +73,8 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
- `keyPath`(SSH 私钥):realpath → stat → 普通文件且 ≤1MB 才读(防设备文件永久阻塞 UI 线程/符号链接逃逸) - `keyPath`(SSH 私钥):realpath → stat → 普通文件且 ≤1MB 才读(防设备文件永久阻塞 UI 线程/符号链接逃逸)
- `src/shared/reservedAccelerators.ts`:设置页录制器与主进程 `applyGlobalShortcut` **共用同一张保留键表**(Ctrl+L、Ctrl+=/-/0/PgUp/PgDn),两处分表曾漂移出洞,加新全局快捷键时两边自动一致 - `src/shared/reservedAccelerators.ts`:设置页录制器与主进程 `applyGlobalShortcut` **共用同一张保留键表**(Ctrl+L、Ctrl+=/-/0/PgUp/PgDn),两处分表曾漂移出洞,加新全局快捷键时两边自动一致
- SFTP 操作有 per-op 超时(`sftp.ts` 的 `bounded()`:元数据 30s / 传输块 60s / open 10s),超时按 transport 错误驱逐半死通道并重试一次;`setSftpTimeouts`/`setUpdateTimeouts` 是**测试缝**,生产无调用者,别接设置项 - SFTP 操作有 per-op 超时(`sftp.ts` 的 `bounded()`:元数据 30s / 传输块 60s / open 10s),超时按 transport 错误驱逐半死通道并重试一次;`setSftpTimeouts`/`setUpdateTimeouts` 是**测试缝**,生产无调用者,别接设置项
- **PTY_EXIT 是一次性广播、不做重放**:绑定晚于退出的窗格靠 `SESSION_STATE` 查询通道(`sessionState()` + 退出码登记表 `sessionExits`,上限 512 条 FIFO)补偿,`TerminalView` 订阅完成后查一次。同理,SSH **飞行中连接**(`connectSsh` 未返回)登记在 `pendingOpens`(带 owner 与 `onClient` 最早引用),`killPtysByOwner`/`killAllPtys` 靠它中止握手,否则 renderer 崩溃后会留下无主孤儿会话
- **zmodem 接收路径的背压靠 `pauseSource`/`resumeSource` 钩子**(`pty.ts` 注入,暂停喂入 sentry 的 ssh 流):fs WriteStream 缓冲无上限,慢盘 + 快对端会 OOM;`touchActivity` 只在写入被接受或 drain 恢复时计数,否则 stall 看门狗会把「堆在缓冲里」当进展。删除路径的 ENOENT 一律视为已删成功(`withSftp` 会整函数重跑,非幂等操作必须自己容忍「目标已消失」)
- `webPreferences` 显式写死 `contextIsolation: true / nodeIntegration: false / webSecurity: true`(`index.ts` 唯一窗口创建点),防默认值被将来改动 - `webPreferences` 显式写死 `contextIsolation: true / nodeIntegration: false / webSecurity: true`(`index.ts` 唯一窗口创建点),防默认值被将来改动
- 终端标签自动标题(「终端 N」)是**存储的显示文本**(进布局模板/会话快照/广播注册),`src/shared/terminalTitle.ts` 负责两个方向:反解用**全部 4 语言**的 pattern(任何语言生成的都能认出编号),渲染用当前语言;语言切换/快照恢复/模板应用时 `retitleAutoTitles` 原地重渲染(SSH 面板标题是用户起的连接名,永不动)。需要指定语言渲染时用 i18n 的 `tFor(lang, key, vars)` - 终端标签自动标题(「终端 N」)是**存储的显示文本**(进布局模板/会话快照/广播注册),`src/shared/terminalTitle.ts` 负责两个方向:反解用**全部 4 语言**的 pattern(任何语言生成的都能认出编号),渲染用当前语言;语言切换/快照恢复/模板应用时 `retitleAutoTitles` 原地重渲染(SSH 面板标题是用户起的连接名,永不动)。需要指定语言渲染时用 i18n 的 `tFor(lang, key, vars)`
@@ -94,6 +97,8 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
- 应用模板(`Workspace.handleApplyTemplate`)读的是**外来 JSON**。`fromJSON` 一旦中途失败(别的版本写的模板、面板组件已不存在),dockview 会**先把目标 dockview 清空再抛错**(`failed to deserialize layout. Reverting changes`)。清空是逐面板走 `onDidRemovePanel` 的,所以**旧会话在抛错之前就已经被 `killSession` 杀掉了**——恢复出来的面板接不回它们,必须换新会话 - 应用模板(`Workspace.handleApplyTemplate`)读的是**外来 JSON**。`fromJSON` 一旦中途失败(别的版本写的模板、面板组件已不存在),dockview 会**先把目标 dockview 清空再抛错**(`failed to deserialize layout. Reverting changes`)。清空是逐面板走 `onDidRemovePanel` 的,所以**旧会话在抛错之前就已经被 `killSession` 杀掉了**——恢复出来的面板接不回它们,必须换新会话
- 失败路径:应用前 `toJSON()` 快照两个 dockview → 抛错时只对**这次真的调用过 `fromJSON` 的** dockview 回灌快照(回灌本身会清空该 dockview;把快照灌进没被碰过的那个会连带杀掉它活着的会话)→ 对恢复出来的面板跑 `rebindRestoredPanels` → 再把「没有任何面板引用的 `previousSessions`」kill 掉 → `message.error`。`JSON.parse` 失败同样要提示,不能静默 return - 失败路径:应用前 `toJSON()` 快照两个 dockview → 抛错时只对**这次真的调用过 `fromJSON` 的** dockview 回灌快照(回灌本身会清空该 dockview;把快照灌进没被碰过的那个会连带杀掉它活着的会话)→ 对恢复出来的面板跑 `rebindRestoredPanels` → 再把「没有任何面板引用的 `previousSessions`」kill 掉 → `message.error`。`JSON.parse` 失败同样要提示,不能静默 return
- 会话计数不靠累加器:`releaseSession` 直接扫 `api.panels` 判断还有没有面板在显示该会话——累加器与「`updateParameters` 原地换会话」「整块布局替换」这类无事件变化脱节,会漏杀或误杀 - 会话计数不靠累加器:`releaseSession` 直接扫 `api.panels` 判断还有没有面板在显示该会话——累加器与「`updateParameters` 原地换会话」「整块布局替换」这类无事件变化脱节,会漏杀或误杀
- **旧版(M6.1 前单 dockview)模板只喂 terminal dockview**:成功路径的 rebind 与收尾 kill 都必须按 `sshTouched` / `live` 集合门控——rebind 会把面板参数改写成新**本地**会话,对没被 fromJSON 触碰的 ssh dockview 跑一遍等于把活 SSH 面板降级成终端再杀会话
- 主机密钥队列只渲染队头,且主进程 `DEFAULT_TIMEOUTS.prompt`(120s)超时后**不再广播**:渲染层用同 TTL 的定时清扫(`HOST_KEY_PROMPT_TTL_MS`)移除过期条目,否则陈旧弹窗永远占住队头挡住后续主机;两处 TTL 必须同步改。`respondHostKey` 等 IPC 调用**不得写进 setState 的 updater**(React 可能重放 updater)
## 关键词高亮 ## 关键词高亮
+15
View File
@@ -1,5 +1,20 @@
# OpenTerminal Changelog # OpenTerminal Changelog
## v1.0.24 - 2026-10-09
## v1.0.23
### SFTP file management
- **The list is now a real table**: Name / Size / Modified / Permissions / Owner / Group are aligned in six columns, with a sticky header that stays visible while scrolling long listings.
- **Click-to-sort headers**: all six columns sort, with arrow indicators for ascending/descending; directories always come before files.
- **Adjustable font size**: use the A- / A+ toolbar buttons or Ctrl+mouse wheel to scale between 0.8x and 1.8x. Row height follows the font size, and the setting is remembered locally, so it survives reopening the panel.
- **Real owner / group names**: previously only numeric UID / GID were shown; the client now parses the real names (`eveuser`, `root`, ...) out of the data returned by the SSH server.
- **Context menu on empty space**: right-click anywhere in the blank area of the list to get New Folder, Upload, Refresh and friends without having to select a row first.
- The modified column now reads `2026/10/08 15:23`, and sizes read `30.7 KB`.
### Fixes
- Fixed the file panel's six columns not actually aligning to the grid (the sort icon's style class was being cloned onto the row element, overriding the row's grid layout).
## v1.0.23 - 2026-10-09 ## v1.0.23 - 2026-10-09
### SFTP file management ### SFTP file management
+15
View File
@@ -1,5 +1,20 @@
# OpenTerminal 更新履歴 # OpenTerminal 更新履歴
## v1.0.24 - 2026-10-09
## v1.0.23
### SFTP ファイル管理
- **一覧が本物の表に**:名前 / サイズ / 更新日時 / パーミッション / 所有者 / グループの 6 列で整列表示。ヘッダーは固定され、長い一覧をスクロールしても常に見えます。
- **ヘッダーをクリックで並べ替え**:6 列すべてが並べ替え可能。昇順・降順は矢印で表示され、ディレクトリは常にファイルより前に来ます。
- **文字サイズの変更**:ツールバーの A- / A+ または Ctrl+ホイールで 0.8〜1.8 倍に調整。行の高さも文字サイズに追従し、設定はローカルに保存されるためパネルを開き直しても維持されます。
- **所有者 / グループを実名表示**:従来は数字の UID / GID のみでしたが、SSH サーバーから返る情報を解析して `eveuser` や `root` といった実際の名前を表示します。
- **空白部分の右クリックメニュー**:一覧の何もない場所を右クリックすると、新規フォルダー・アップロード・更新などを呼び出せます。行を先に選択する必要はありません。
- 更新日時は `2026/10/08 15:23`、サイズは `30.7 KB` といった読みやすい形式になりました。
### 修正
- ファイルパネルの 6 列が実際にはグリッド整列していなかった問題を修正(並べ替えアイコンのスタイルクラスが行要素に複製され、行のグリッドレイアウトを上書きしていました)。
## v1.0.23 - 2026-10-09 ## v1.0.23 - 2026-10-09
### SFTP ファイル管理 ### SFTP ファイル管理
+27
View File
@@ -1,5 +1,32 @@
# OpenTerminal 更新日志 # OpenTerminal 更新日志
## v1.0.24 - 2026-10-09
一轮全面的审查修复:两个数据安全级问题、多处稳定性与内存问题,以及一批界面细节修补。
### 稳定性与数据安全
- **修复应用旧版布局模板会误杀全部 SSH 会话的问题**:应用 v0.8 之前保存的单工作区布局模板时,SSH 工作区的活动会话会被静默断开、面板被错误替换为本地终端;现在未受模板影响的工作区保持原样。
- **配置文件损坏时先备份再重置**:命令历史/命令库、连接书签、设置文件若是「合法 JSON 但内容结构不对」,此前会静默按空数据继续运行并在下次保存时整体覆盖;现在会先把原文件备份为 `.bak` 再重置,不再无声销毁数据。
- **会话日志退出不再丢尾部**:托盘退出/关闭应用时,正在记录的会话日志最后一段也能完整落盘。
### SSH 与传输
- **连接建立期间更稳**:SSH 握手尚未完成时渲染层崩溃或应用退出,不再遗留无法回收的孤儿连接;启动瞬间的双窗口竞态也已修复。
- **快速退出的 shell 不再留下空白终端**:进程在面板绑定前就退出的极端情况下,窗格现在能正确显示退出状态,不再表现为「活着但永远空白」。
- **主机密钥确认弹窗自动过期**:主机密钥确认晾置 2 分钟后(与服务端等待时限一致)自动消失,不再挡住后续主机的确认弹窗。
- **ZMODEM 接收大文件更省内存**:向慢速磁盘保存大文件时增加背压控制,内存占用不再随文件大小无上限增长。
- **SFTP 删除不再误报失败**:删除的目标文件已被其他方式删除(或重复删除)时视为成功,不再报「删除失败」。
### 界面与设置
- 服务器监控首次采样即失败时显示中断提示,不再一直显示「采集中」。
- 高亮规则导入增加格式版本校验,错误文件给出明确原因;内置「百分比」规则的名称在英文/繁中/日文界面下正确本地化。
- 高亮规则导出下载修复偶发保存出空文件的问题。
- 删除连接书签、清空命令历史等操作失败时给出错误提示,不再无响应。
- 应用布局模板期间快速连点不再产生错乱(并发保护)。
### 内部
- 更新通道发布护栏加固:通道版本探测失败时拒绝发布,防止旧版本误覆盖线上新版本。
- 新增 3 组渲染层纯函数测试(共 200 项断言),离线测试增至 22 个。
## v1.0.23 - 2026-10-09 ## v1.0.23 - 2026-10-09
### SFTP 文件管理 ### SFTP 文件管理
+15
View File
@@ -1,5 +1,20 @@
# OpenTerminal 更新日誌 # OpenTerminal 更新日誌
## v1.0.24 - 2026-10-09
## v1.0.23
### SFTP 檔案管理
- **清單改為真正的表格**:名稱 / 大小 / 修改時間 / 權限 / 使用者 / 群組六欄對齊顯示,表頭吸頂,長清單捲動時始終可見
- **點擊表頭排序**:六欄皆可排序,升降序切換有箭頭指示,目錄始終排在檔案之前
- **字型可縮放**:工具列 A- / A+ 或 Ctrl+滾輪在 0.8–1.8 倍之間調整,行高與字號聯動,設定記入本機,重開面板保持
- **使用者 / 群組顯示真實名稱**:先前只顯示數字 UID / GID,現在從 SSH 伺服器回傳的資訊中解析出 `eveuser` / `root` 這樣的真實名稱
- **空白處右鍵選單**:在清單空白區域右鍵即可喚出新增資料夾、上傳、重新整理等操作,不必先點中某一列
- 時間欄改為 `2026/10/08 15:23` 格式,大小欄改為 `30.7 KB` 這類易讀寫法
### 修復
- 修復檔案面板六欄先前並未真正按網格對齊的問題(排序圖示的樣式類別被誤加到列元素上,頂掉了列的網格佈局)
## v1.0.23 - 2026-10-09 ## v1.0.23 - 2026-10-09
### SFTP 檔案管理 ### SFTP 檔案管理
+2
View File
@@ -18,6 +18,8 @@
| M9 | 体验打磨:系统托盘 + 关闭行为 + 单实例 + 主题联动标题栏 + 布局菜单重做 + 补全交互修正 + 应用图标 | ✅ 已完成 | | M9 | 体验打磨:系统托盘 + 关闭行为 + 单实例 + 主题联动标题栏 + 布局菜单重做 + 补全交互修正 + 应用图标 | ✅ 已完成 |
| M10 | 多语言界面(zh-CN / zh-TW / en / ja)+ 离线多语言更新日志 + 快捷键录制 + 可配终端工具条 + 设置健壮性 | ✅ 已完成(v1.0.13) | | M10 | 多语言界面(zh-CN / zh-TW / en / ja)+ 离线多语言更新日志 + 快捷键录制 + 可配终端工具条 + 设置健壮性 | ✅ 已完成(v1.0.13) |
| M11 | 审查修复第二轮:渲染层性能 + asar 瘦身 + 安全纵深 + 测试补全 + 文档刷新 | ✅ 已完成(2026-10,Dev_20261001) | | M11 | 审查修复第二轮:渲染层性能 + asar 瘦身 + 安全纵深 + 测试补全 + 文档刷新 | ✅ 已完成(2026-10,Dev_20261001) |
| M12 | 锁屏:主窗口遮罩 + scrypt 密码 + Ctrl+L 立即锁屏 + 冷却阶梯 + 落盘状态 | ✅ 已完成(v1.0.17–1.0.20;v1.0.18 修输入回归,v1.0.20 摘除菜单加固) |
| M13 | 更新源改为 GitHub 优先 + Gitea 通道兜底;高亮引擎性能优化 | ✅ 已完成(v1.0.19–1.0.20) |
## M2 — SSH 远程会话 ✅ ## M2 — SSH 远程会话 ✅
+19 -9
View File
@@ -4,7 +4,7 @@
## 当前版本与仓库 ## 当前版本与仓库
- v1.0.20,远程 `git.codingplan.site/admin/OpenTerminal.git`(国内仓)+ `github.com/billowliu2/OpenTerminal.git`(GitHub 镜像仓);凭据存于 `.env`(已 git 忽略),凭据助手按 host 自动读取 - v1.0.23,远程 `git.codingplan.site/admin/OpenTerminal.git`(国内仓)+ `github.com/billowliu2/OpenTerminal.git`(GitHub 镜像仓);凭据存于 `.env`(已 git 忽略),凭据助手按 host 自动读取
- 开源协议:MIT(LICENSE) - 开源协议:MIT(LICENSE)
- 更新通道 = `https://git.codingplan.site/api/packages/admin/generic/openterminal-update/stable/`(公网可读,含 latest.yml/exe/blockmap) - 更新通道 = `https://git.codingplan.site/api/packages/admin/generic/openterminal-update/stable/`(公网可读,含 latest.yml/exe/blockmap)
- 技术栈:Electron + electron-vite + React 19 + TS strict + antd 6(全局深色)+ zustand + dockview-react 8 + @xterm/xterm 6 + @lydell/node-pty + ssh2 + zmodem.js + electron-updater + electron-builder - 技术栈:Electron + electron-vite + React 19 + TS strict + antd 6(全局深色)+ zustand + dockview-react 8 + @xterm/xterm 6 + @lydell/node-pty + ssh2 + zmodem.js + electron-updater + electron-builder
@@ -56,15 +56,16 @@
- **广播输入**:渲染层 zustand(broadcastStore)维护 enabled/targets/sessions;TerminalView 全部写路径(onData/补全/粘贴/Workspace runCommand)走 writeBroadcast 扇出;目标 <2 自动禁用;tab 目标圆点 + 按钮计数徽标 - **广播输入**:渲染层 zustand(broadcastStore)维护 enabled/targets/sessions;TerminalView 全部写路径(onData/补全/粘贴/Workspace runCommand)走 writeBroadcast 扇出;目标 <2 自动禁用;tab 目标圆点 + 按钮计数徽标
- **ZMODEM**:主进程引擎(src/main/zmodem.ts,仅 SSH 会话;本地 pty 走 ConPTY 只给 UTF-8 字符串,二进制会损坏——不支持,注释已说明);Sentry 常驻分流非 zmodem 字节;offer→渲染层选文件/目录→respond;传输期抑制 PTY_DATA/replay/日志并丢弃用户键入;offer 120s/传输 90s 看门狗;进度复用 TransferPanel(kind=zmodem-upload/download);测试 tests/zmodem-e2e.mjs 用第二个 zmodem.js Sentry 模拟远端,双向内容一致性断言 - **ZMODEM**:主进程引擎(src/main/zmodem.ts,仅 SSH 会话;本地 pty 走 ConPTY 只给 UTF-8 字符串,二进制会损坏——不支持,注释已说明);Sentry 常驻分流非 zmodem 字节;offer→渲染层选文件/目录→respond;传输期抑制 PTY_DATA/replay/日志并丢弃用户键入;offer 120s/传输 90s 看门狗;进度复用 TransferPanel(kind=zmodem-upload/download);测试 tests/zmodem-e2e.mjs 用第二个 zmodem.js Sentry 模拟远端,双向内容一致性断言
- **快捷键**:Ctrl+=/-/0 字号(main.tsx capture 监听,xterm-helper-textarea 放行——隐藏 textarea 曾被误判为输入框导致终端聚焦时失效,已修);globalShowHide accelerator(globalShortcuts.ts,设置页系统分区可配,注册失败仅 warn);Ctrl+PgUp/PgDn 面板循环(Workspace capture 监听) - **快捷键**:Ctrl+=/-/0 字号(main.tsx capture 监听,xterm-helper-textarea 放行——隐藏 textarea 曾被误判为输入框导致终端聚焦时失效,已修);globalShowHide accelerator(globalShortcuts.ts,设置页系统分区可配,注册失败仅 warn);Ctrl+PgUp/PgDn 面板循环(Workspace capture 监听)
- **打包**:electron-builder.yml(msi 固定 upgradeCode 5ab9f79e-e4eb-4052-9df6-3af3a301ab0a + nsis;asarUnpack @lydell/node-pty + ssh2;npmRebuild false);updater.ts(仅 packaged 启用,OT_UPDATE_URL/OT_UPDATE_TOKEN env,默认 feed=上述 generic package 地址) - **打包**:electron-builder.yml(**NSIS x64 是唯一安装包**——v1.0.22 起不再构建 MSI,electron-updater 本就不支持 MSI 自动更新;asarUnpack @lydell/node-pty + ssh2;npmRebuild false);updater.ts(仅 packaged 启用,OT_UPDATE_URL/OT_UPDATE_TOKEN env,默认 feed=上述 generic package 地址)
## 发布流程(下一版本照抄) ## 发布流程(下一版本照抄)
1. `package.json` version 升位 + 写 `RELEASE_NOTES.md`(可选 `.zh-TW/.en/.ja` 译文)→ `node scripts/sync-changelog.cjs`(**在 dist 之前**:更新日志会打进安装包)→ `npm run dist`(env:ELECTRON_MIRROR + ELECTRON_BUILDER_BINARIES_MIRROR=npmmirror;dist:dir 后先删 release/win-unpacked 避免占用 EPERM)。`predist` 会先跑 `npm test`(typecheck + 12 个离线测试)再 `npm install --package-lock-only` 同步锁文件根版本号,**release 提交要包含 package-lock.json**(否则根版本会漂移,v1.0.15–1.0.19 曾漂了 5 个版本) 1. `package.json` version 升位 + 写 `RELEASE_NOTES.md`(可选 `.zh-TW/.en/.ja` 译文)→ `node scripts/sync-changelog.cjs`(**在 dist 之前**:更新日志会打进安装包)→ `npm run dist`(env:ELECTRON_MIRROR + ELECTRON_BUILDER_BINARIES_MIRROR=npmmirror;dist:dir 后先删 release/win-unpacked 避免占用 EPERM)。`predist` 会先跑 `npm test`(typecheck + 19 个离线测试)再 `npm install --package-lock-only` 同步锁文件根版本号,**release 提交要包含 package-lock.json**(否则根版本会漂移,v1.0.15–1.0.19 曾漂了 5 个版本)
2. `node scripts/release.cjs <版本号>`(**不带 skip 参数**,Gitea 与 GitHub 一起发):脚本自己建 Gitea release(msi/exe 资产)→ 传更新通道 `exe.blockmap → exe → release-notes.md → latest.yml`(**latest.yml 最后**);再把 exe/exe.blockmap/latest.yml 作为 release 资产同步发到 GitHub(electron-updater 标准 GitHub provider 直接吃 release 资产)。不再先删旧版,latest.yml 生效后才清掉上一版 exe/blockmap。上传前 `assertNoDowngrade()` 会读通道 latest.yml,线上版本更高时直接拒绝 2. `node scripts/release.cjs <版本号>`(**不带 skip 参数**,Gitea 与 GitHub 一起发):脚本自己建 Gitea release(exe 资产)→ 传更新通道 `exe.blockmap → exe → release-notes.md → latest.yml`(**latest.yml 最后**);再把 exe/exe.blockmap/latest.yml 作为 release 资产同步发到 GitHub(electron-updater 标准 GitHub provider 直接吃 release 资产)。不再先删旧版,latest.yml 生效后才清掉上一版 exe/blockmap。上传前 `assertNoDowngrade()` 会读通道 latest.yml,线上版本更高时直接拒绝——**该探测已 fail-closed**:网络错误 / 非 404 失败 / 解析不出 version 一律抛错中止发布,只有通道真空(404)才放行;通道探测在 `pruneChannel` 前那次是 best-effort(失败只跳过清理并打日志,不中断已生效的发布)
3. 通道传坏了只补通道:`node scripts/release.cjs <版本号> --channel-only`(不建 release、不发 GitHub;同一版本可重复运行:release 复用、已传资产跳过) 3. 通道传坏了只补通道:`node scripts/release.cjs <版本号> --channel-only`(不建 release、不发 GitHub;同一版本可重复运行:release 复用、已传资产跳过)
4. 校验:无 token `curl .../generic/openterminal-update/stable/latest.yml` 应 200 且 version 正确 4. 跳过部分目标的 flag:`--skip-github` 只跳 GitHub release;`--skip-gitea-release` 只跳 Gitea release,**更新通道照常上传**;`--skip-gitea` 两者都跳(打印醒目警告——国内用户将收不到该版本,只想跳 release 请用 `--skip-gitea-release`)
5. `git tag vX.Y.Z && git push origin main vX.Y.Z`(GitHub 镜像推代码/tag + release 资产,与 Gitea 保持同步) 5. 校验:无 token `curl .../generic/openterminal-update/stable/latest.yml` 应 200 且 version 正确
6. `git tag vX.Y.Z && git push origin main vX.Y.Z`(GitHub 镜像推代码/tag + release 资产,与 Gitea 保持同步)
> GitHub 请求走 `HTTPS_PROXY=http://127.0.0.1:7897`(脚本只把它用于 GitHub);国内通道全程直连,不设代理 > GitHub 请求走 `HTTPS_PROXY=http://127.0.0.1:7897`(脚本只把它用于 GitHub);国内通道全程直连,不设代理
@@ -73,22 +74,31 @@
```bash ```bash
npm run typecheck # tsconfig.node.json + tsconfig.web.json npm run typecheck # tsconfig.node.json + tsconfig.web.json
npm run build npm run build
npm test # = pretest(typecheck) + node tests/build-bundles.cjs + 下面 12 个测试(依次,全部离线可跑) npm test # = pretest(typecheck) + node tests/build-bundles.cjs + 下面 19 个测试(依次,全部离线可跑)
node tests/ssh-loopback.mjs node tests/ssh-loopback.mjs
node tests/commands-store.mjs node tests/commands-store.mjs
node tests/connections-store.mjs # SSH 书签 CRUD / 公开-密文切分 / 损坏文件备份 node tests/connections-store.mjs # SSH 书签 CRUD / 公开-密文切分 / 损坏文件备份
node tests/settings-store.mjs # 设置清洗器(closeAction/高亮规则修复/旧预设升级/告警日志) node tests/settings-store.mjs # 设置清洗器(closeAction/高亮规则修复/旧预设升级/告警日志)
node tests/local-path-grants.mjs # 本地路径准入(对话框授权、realpath+stat、大小写折叠)
node tests/lock-store.mjs # 锁屏密码校验值(scrypt 往返、损坏文件) node tests/lock-store.mjs # 锁屏密码校验值(scrypt 往返、损坏文件)
node tests/lock-controller.mjs # 冷却阶梯、并发串行化、落盘恢复、闲置触发、清除联动 node tests/lock-controller.mjs # 冷却阶梯、并发串行化、落盘恢复、闲置触发、清除联动
node tests/lock-shortcuts.mjs # 锁屏快捷键分类器(表驱动) node tests/lock-shortcuts.mjs # 锁屏快捷键分类器(表驱动)
node tests/reserved-accelerators.mjs # 保留快捷键表(设置页录制器与主进程注册守卫共用一张表)
node tests/.terminal-title.cjs # 终端自动标题多语言反解与原地重渲染
node tests/ipc-guard.mjs # IPC sender guard(走真实注册路径灌伪造帧)
node tests/updater-fallback.mjs # 更新源回退与超时预算(GitHub→Gitea)
node tests/log-sanitizer.mjs # 会话日志纯文本转换(ANSI 状态机 + 字节切分 fuzz)
node tests/sftp-timeout.mjs # SFTP per-op 超时与半死通道驱逐
node tests/.hl-split-smoke.cjs node tests/.hl-split-smoke.cjs
node tests/.hl-rules.cjs # 内置高亮预设(词边界、大小写、负向词、危险命令) node tests/.hl-rules.cjs # 内置高亮预设(词边界、大小写、负向词、危险命令)
node tests/zmodem-e2e.mjs node tests/zmodem-e2e.mjs
node tests/ssh-session-e2e.mjs node tests/ssh-session-e2e.mjs
node tests/sysinfo-e2e.mjs node tests/sysinfo-e2e.mjs
# `node tests/build-bundles.cjs` 单独重建全部 esbuild bundle(别名只存在于该脚本): # `node tests/build-bundles.cjs` 单独重建全部 esbuild bundle(别名只存在于该脚本):
# .session-e2e.cjs(pty.ts) .sftp-svc.mjs(sftp.ts,ESM) .commands-store.cjs .connections-store.cjs # .session-e2e.cjs(pty.ts) .ssh.cjs(ssh.ts) .sftp-svc.mjs(sftp.ts,ESM) .commands-store.cjs
# .known-hosts.cjs .settings-store.cjs .lock-store.cjs .lock-controller.cjs .lock-shortcuts.cjs # .connections-store.cjs .known-hosts.cjs .settings-store.cjs .local-path-grants.cjs
# .lock-store.cjs .lock-controller.cjs .lock-shortcuts.cjs .reserved-accelerators.cjs
# .terminal-title.cjs .updater.cjs .ipc.cjs .log-sanitizer.cjs .ipc-channels.cjs
# .zmodem-e2e.cjs .hl-split-smoke.cjs .hl-rules.cjs —— 少 --alias:@shared=./src/shared 会编译失败 # .zmodem-e2e.cjs .hl-split-smoke.cjs .hl-rules.cjs —— 少 --alias:@shared=./src/shared 会编译失败
# 真实服务器测试(需 JD 环境变量凭据,旧凭据已过期,不在 npm test 内): # 真实服务器测试(需 JD 环境变量凭据,旧凭据已过期,不在 npm test 内):
# JD_HOST=... JD_USER=root JD_PASS=... node tests/sftp-real.mjs / tests/sftp-chmod.mjs # JD_HOST=... JD_USER=root JD_PASS=... node tests/sftp-real.mjs / tests/sftp-chmod.mjs
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "open-terminal", "name": "open-terminal",
"version": "1.0.23", "version": "1.0.24",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "open-terminal", "name": "open-terminal",
"version": "1.0.23", "version": "1.0.24",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@lydell/node-pty": "^1.2.0-beta.15", "@lydell/node-pty": "^1.2.0-beta.15",
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "open-terminal", "name": "open-terminal",
"productName": "OpenTerminal", "productName": "OpenTerminal",
"version": "1.0.23", "version": "1.0.24",
"description": "Open-source terminal with SSH, split panes, themes and fonts", "description": "Open-source terminal with SSH, split panes, themes and fonts",
"main": "out/main/index.js", "main": "out/main/index.js",
"author": "CodingPlan.Site", "author": "CodingPlan.Site",
@@ -13,7 +13,7 @@
"preview": "electron-vite preview", "preview": "electron-vite preview",
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json", "typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json",
"pretest": "npm run typecheck", "pretest": "npm run typecheck",
"test": "node tests/build-bundles.cjs && node tests/ssh-loopback.mjs && node tests/commands-store.mjs && node tests/connections-store.mjs && node tests/settings-store.mjs && node tests/local-path-grants.mjs && node tests/lock-store.mjs && node tests/lock-controller.mjs && node tests/lock-shortcuts.mjs && node tests/reserved-accelerators.mjs && node tests/.terminal-title.cjs && node tests/ipc-guard.mjs && node tests/updater-fallback.mjs && node tests/log-sanitizer.mjs && node tests/sftp-timeout.mjs && node tests/.hl-split-smoke.cjs && node tests/.hl-rules.cjs && node tests/zmodem-e2e.mjs && node tests/ssh-session-e2e.mjs && node tests/sysinfo-e2e.mjs", "test": "node tests/build-bundles.cjs && node tests/ssh-loopback.mjs && node tests/commands-store.mjs && node tests/connections-store.mjs && node tests/settings-store.mjs && node tests/local-path-grants.mjs && node tests/lock-store.mjs && node tests/lock-controller.mjs && node tests/lock-shortcuts.mjs && node tests/reserved-accelerators.mjs && node tests/.terminal-title.cjs && node tests/ipc-guard.mjs && node tests/updater-fallback.mjs && node tests/log-sanitizer.mjs && node tests/sftp-timeout.mjs && node tests/.hl-split-smoke.cjs && node tests/.hl-rules.cjs && node tests/zmodem-e2e.mjs && node tests/ssh-session-e2e.mjs && node tests/sysinfo-e2e.mjs && node tests/terminal-cwd.mjs && node tests/terminal-links.mjs && node tests/broadcast-store.mjs",
"predist": "node scripts/verify-deps.cjs && npm test && npm install --package-lock-only", "predist": "node scripts/verify-deps.cjs && npm test && npm install --package-lock-only",
"dist": "electron-vite build && electron-builder --win nsis", "dist": "electron-vite build && electron-builder --win nsis",
"dist:dir": "electron-vite build && electron-builder --win --dir" "dist:dir": "electron-vite build && electron-builder --win --dir"
+52 -12
View File
@@ -1,7 +1,16 @@
// OpenTerminal release publisher // OpenTerminal release publisher
// Usage: node scripts/release.cjs <version> [--skip-github] [--skip-gitea] [--channel-only] // Usage: node scripts/release.cjs <version> [--skip-github] [--skip-gitea-release] [--skip-gitea] [--channel-only]
// node scripts/release.cjs 1.0.2 // node scripts/release.cjs 1.0.2
// node scripts/release.cjs 1.0.2 --channel-only # update channel only (repair) // node scripts/release.cjs 1.0.2 --channel-only # update channel only (repair)
// Flags:
// --skip-github skip the GitHub release (assets included)
// --skip-gitea-release skip only the Gitea *release*; the Gitea update
// channel still runs (this is what you want if the
// release already exists / is not needed)
// --skip-gitea skip the Gitea release AND the Gitea update channel
// — Chinese users then never see this version; prefer
// --skip-gitea-release unless you really mean both
// --channel-only update channel only (repair); no release, no GitHub
// Reads release notes from RELEASE_NOTES.md (repo root, gitignored). // Reads release notes from RELEASE_NOTES.md (repo root, gitignored).
// Credentials from .env (repo root, gitignored): GIT_TOKEN, GH_TOKEN. // Credentials from .env (repo root, gitignored): GIT_TOKEN, GH_TOKEN.
// If HTTPS_PROXY / HTTP_PROXY (env or .env) is set, traffic goes through it // If HTTPS_PROXY / HTTP_PROXY (env or .env) is set, traffic goes through it
@@ -13,11 +22,16 @@ const path = require('node:path')
const ROOT = path.join(__dirname, '..') const ROOT = path.join(__dirname, '..')
const V = process.argv[2] const V = process.argv[2]
if (!V || !/^\d+\.\d+\.\d+$/.test(V)) { if (!V || !/^\d+\.\d+\.\d+$/.test(V)) {
console.error('usage: node scripts/release.cjs <x.y.z> [--skip-github] [--skip-gitea] [--channel-only]') console.error('usage: node scripts/release.cjs <x.y.z> [--skip-github] [--skip-gitea-release] [--skip-gitea] [--channel-only]')
console.error(' --skip-github skip the GitHub release')
console.error(' --skip-gitea-release skip only the Gitea release; the update channel still runs')
console.error(' --skip-gitea skip the Gitea release AND the update channel (CN users get no update)')
console.error(' --channel-only update channel only (repair)')
process.exit(1) process.exit(1)
} }
const SKIP_GH = process.argv.includes('--skip-github') const SKIP_GH = process.argv.includes('--skip-github')
const SKIP_GITEA = process.argv.includes('--skip-gitea') const SKIP_GITEA = process.argv.includes('--skip-gitea')
const SKIP_GITEA_RELEASE = process.argv.includes('--skip-gitea-release')
const CHANNEL_ONLY = process.argv.includes('--channel-only') const CHANNEL_ONLY = process.argv.includes('--channel-only')
/** Strip one pair of matching quotes. A .env quotes its values for the shell /** Strip one pair of matching quotes. A .env quotes its values for the shell
@@ -179,16 +193,24 @@ async function assertNoDowngrade() {
console.log(`channel currently serves v${live}`) console.log(`channel currently serves v${live}`)
} }
/** Version the update channel serves right now (read from its latest.yml), or /** Version the update channel serves right now (read from its latest.yml).
undefined when the channel is empty/unreachable. */ Fail-closed: only an *empty* channel (HTTP 404) reports "unknown". A network
error, a non-404 failure or a body without a version all throw — otherwise a
probe that fails for any reason would read as "no live version" and let a
downgrade through the guard below. */
async function channelVersion(base) { async function channelVersion(base) {
let resp
try { try {
const resp = await fetch(`${base}/latest.yml`) resp = await fetch(`${base}/latest.yml`)
if (!resp.ok) return undefined } catch (e) {
return (await resp.text()).match(/^version:\s*(\S+)/m)?.[1] throw new Error(`cannot reach update channel (${e.message})`)
} catch {
return undefined
} }
// 404 = nothing published yet; that is a legal empty channel, not an error.
if (resp.status === 404) return undefined
if (!resp.ok) throw new Error(`update channel latest.yml returned HTTP ${resp.status}`)
const version = (await resp.text()).match(/^version:\s*(\S+)/m)?.[1]
if (!version) throw new Error('update channel latest.yml carries no version field')
return version
} }
/** Size of a channel file, or -1 when it is not there. */ /** Size of a channel file, or -1 when it is not there. */
@@ -264,7 +286,17 @@ async function giteaChannel() {
// (ECONNRESET) can no longer leave the channel empty and unrepairable. // (ECONNRESET) can no longer leave the channel empty and unrepairable.
const isLatest = ([f]) => path.basename(f) === 'latest.yml' const isLatest = ([f]) => path.basename(f) === 'latest.yml'
const ordered = [...channelFiles.filter((e) => !isLatest(e)), ...channelFiles.filter(isLatest)] const ordered = [...channelFiles.filter((e) => !isLatest(e)), ...channelFiles.filter(isLatest)]
const previous = await channelVersion(base) // Best effort by design: this probe only decides whether the previous
// version's binaries get pruned afterwards. It now throws on a failed probe
// (fail-closed for the downgrade guard), and by the time we are here the new
// latest.yml is about to be live — a hiccup in a cleanup helper must never
// abort the tail of the publish.
let previous
try {
previous = await channelVersion(base)
} catch (e) {
console.log(` cannot read the live channel version (${e.message}) — skipping prune of the previous release`)
}
for (const [f, name] of ordered) { for (const [f, name] of ordered) {
const stat = fs.statSync(f) const stat = fs.statSync(f)
const url = `${base}/${encodeURIComponent(name)}` const url = `${base}/${encodeURIComponent(name)}`
@@ -407,7 +439,15 @@ async function main() {
console.log('done (channel only)') console.log('done (channel only)')
return return
} }
if (!SKIP_GITEA) { await gitea(); await giteaChannel() } if (SKIP_GITEA) {
console.warn('*** WARNING: --skip-gitea skips the Gitea release AND the Gitea update channel. ***')
console.warn('*** Domestic users will NOT receive this version — it is only on GitHub, which most ***')
console.warn('*** of them cannot reach. To skip just the release, use --skip-gitea-release. ***')
}
// --skip-gitea-release: the release object already exists (or is not wanted),
// but the channel still has to be fed — that is the whole update path at home.
if (!SKIP_GITEA && !SKIP_GITEA_RELEASE) await gitea()
if (!SKIP_GITEA) await giteaChannel()
if (!SKIP_GH) await github() if (!SKIP_GH) await github()
console.log('done') console.log('done')
} }
+87 -7
View File
@@ -20,7 +20,16 @@
import { app, shell } from 'electron' import { app, shell } from 'electron'
import { randomUUID } from 'crypto' import { randomUUID } from 'crypto'
import { mkdirSync, readFileSync, writeFileSync, existsSync, realpathSync, statSync, copyFileSync } from 'fs' import {
appendFileSync,
mkdirSync,
readFileSync,
writeFileSync,
existsSync,
realpathSync,
statSync,
copyFileSync
} from 'fs'
import { appendFile } from 'fs/promises' import { appendFile } from 'fs/promises'
import { basename, dirname, join, resolve, sep } from 'path' import { basename, dirname, join, resolve, sep } from 'path'
import type { CommandItem, SessionLogMeta } from '../shared/commands' import type { CommandItem, SessionLogMeta } from '../shared/commands'
@@ -83,6 +92,15 @@ function backupUnparseableCommands(file: string, err: unknown): void {
} }
} }
/**
* The single exit for an unusable commands.json — parse failure and wrong
* shape both end here, so neither can quietly skip the backup.
*/
function discardCommandsFile(file: string, err: unknown): CommandsFile {
backupUnparseableCommands(file, err)
return emptyFile()
}
export class CommandsStore { export class CommandsStore {
/** <userData>/commands.json */ /** <userData>/commands.json */
private readonly file: string private readonly file: string
@@ -106,6 +124,17 @@ export class CommandsStore {
private readonly logBuffers = new Map<string, string>() private readonly logBuffers = new Map<string, string>()
/** In-flight drain loop per log file; absent when the file is settled. */ /** In-flight drain loop per log file; absent when the file is settled. */
private readonly logDrains = new Map<string, Promise<void>>() private readonly logDrains = new Map<string, Promise<void>>()
/**
* Log files with an appendFile actually in flight. logStop reads this to tell
* whether a synchronous final flush can race an issued write (see logStop).
*/
private readonly logInFlight = new Set<string>()
/**
* Files whose session stopped while a write was in flight: the drain loop
* flushes what is left synchronously instead of issuing another async round,
* which at quit would not land. Cleared when the loop settles.
*/
private readonly logFinalFlush = new Set<string>()
/** Plain-text transformer per actively-logged session (see logSanitizer). */ /** Plain-text transformer per actively-logged session (see logSanitizer). */
private readonly sanitizers = new Map<string, LogSanitizer>() private readonly sanitizers = new Map<string, LogSanitizer>()
@@ -130,11 +159,17 @@ export class CommandsStore {
library: Array.isArray(data.library) ? data.library : [] library: Array.isArray(data.library) ? data.library : []
} }
} }
// Valid JSON of the wrong shape (`[1,2,3]`, `{}`) is not "no commands":
// it is a file we cannot read, and the next write would rewrite it from
// an empty history. Same exit as the parse failure below.
return discardCommandsFile(
this.file,
new Error('commands.json shape mismatch: expected {history,library}')
)
} catch (err) { } catch (err) {
// missing -> start fresh; unreadable / corrupt -> keep the original first // missing -> start fresh; unreadable / corrupt -> keep the original first
backupUnparseableCommands(this.file, err) return discardCommandsFile(this.file, err)
} }
return emptyFile()
} }
private saveCommands(data: CommandsFile): void { private saveCommands(data: CommandsFile): void {
@@ -412,15 +447,34 @@ export class CommandsStore {
const chunk = this.logBuffers.get(file) const chunk = this.logBuffers.get(file)
if (chunk === undefined) break if (chunk === undefined) break
this.logBuffers.delete(file) this.logBuffers.delete(file)
if (this.logFinalFlush.has(file)) {
// The session is gone, so this is the file's last flush and it has to
// be durable when this turn ends: at quit the process can be gone
// before a second async round lands. Reached only after the in-flight
// append above completed, so the order is still the write order.
try {
appendFileSync(file, chunk, 'utf8')
} catch {
// file may have been removed after stop -> ignore
}
continue
}
// Marked around the append itself: logStop's synchronous flush must not
// slip in between an issued write and its landing.
this.logInFlight.add(file)
try { try {
await appendFile(file, chunk, 'utf8') await appendFile(file, chunk, 'utf8')
} catch { } catch {
// file may have been removed after stop -> ignore // file may have been removed after stop -> ignore
} }
this.logInFlight.delete(file)
} }
})() })()
run.finally(() => { run.finally(() => {
if (this.logDrains.get(file) === run) this.logDrains.delete(file) if (this.logDrains.get(file) === run) this.logDrains.delete(file)
// The loop only stops once the buffer is empty, so nothing more can arrive
// for a stopped file: the flag must not outlive the drain.
this.logFinalFlush.delete(file)
}) })
this.logDrains.set(file, run) this.logDrains.set(file, run)
return run return run
@@ -432,10 +486,36 @@ export class CommandsStore {
if (!meta) return if (!meta) return
const tail = this.sanitizers.get(sessionId)?.flush() ?? '' const tail = this.sanitizers.get(sessionId)?.flush() ?? ''
this.sanitizers.delete(sessionId) this.sanitizers.delete(sessionId)
if (tail) { const pending = (this.logBuffers.get(meta.file) ?? '') + tail
// Best effort: the trailing partial line belongs in the file too. if (pending) {
this.logBuffers.set(meta.file, (this.logBuffers.get(meta.file) ?? '') + tail) if (this.logInFlight.has(meta.file)) {
this.drainLog(meta.file) // An appendFile for this file is already issued and cannot be
// cancelled, so a synchronous write now would land *before* it and swap
// the last two pieces of the log. Put the tail back in the buffer
// instead and mark the file: the running loop re-reads the buffer after
// that append lands and flushes it synchronously (see drainLog), so the
// tail is durable and still in write order.
this.logBuffers.set(meta.file, pending)
this.logFinalFlush.add(meta.file)
this.drainLog(meta.file)
} else {
// No async write is in flight, so this one cannot race anything. It
// must be synchronous: the quit path (killAllPtys -> safeStopLog ->
// logStop) has no later sync point, and a fresh appendFile chain may
// never get to run.
//
// Bounded by construction: with nothing in flight the buffer is empty
// (the loop only stops looking once it is), so this is just the
// sanitizer tail — one partial line, plus at most one marker line. The
// sanitizer's only other buffer (a CSI sequence) is capped at 1KB and
// never reaches the output. One line's worth of text, once per stop.
this.logBuffers.delete(meta.file)
try {
appendFileSync(meta.file, pending, 'utf8')
} catch {
// file may have been removed after stop -> ignore
}
}
} }
this.finishLog(meta) this.finishLog(meta)
} }
+17 -2
View File
@@ -167,6 +167,15 @@ function backupUnparseableConnections(file: string, err: unknown): void {
} }
} }
/**
* The single exit for an unusable connections.json — parse failure and wrong
* shape both end here, so neither can quietly skip the backup.
*/
function discardConnectionsFile(file: string, err: unknown): StoredConnection[] {
backupUnparseableConnections(file, err)
return []
}
export class ConnectionsStore { export class ConnectionsStore {
constructor(private readonly filePath: string) {} constructor(private readonly filePath: string) {}
@@ -182,11 +191,17 @@ export class ConnectionsStore {
typeof (x as StoredConnection).host === 'string' typeof (x as StoredConnection).host === 'string'
) )
} }
// Valid JSON of the wrong shape (`{}`) is not "no bookmarks": it is a file
// we cannot read, and the next write would rewrite it from an empty list.
// Same exit as the parse failure below.
return discardConnectionsFile(
this.filePath,
new Error('connections.json shape mismatch: expected an array')
)
} catch (err) { } catch (err) {
// missing -> start fresh; unreadable / corrupt -> keep the original first // missing -> start fresh; unreadable / corrupt -> keep the original first
backupUnparseableConnections(this.filePath, err) return discardConnectionsFile(this.filePath, err)
} }
return []
} }
private save(list: StoredConnection[]): void { private save(list: StoredConnection[]): void {
+4 -1
View File
@@ -100,7 +100,10 @@ if (!gotSingleInstanceLock) {
// so the menu teardown is applied here from the flag itself. // so the menu teardown is applied here from the flag itself.
const lock = initLockController() const lock = initLockController()
applyMenuLockState(lock.isLocked()) applyMenuLockState(lock.isLocked())
createWindow() // A second launch that raced this startup already created (or surfaced) a
// window from its `second-instance` handler; creating another here would
// leave two. Same guard the activate handler below uses.
if (BrowserWindow.getAllWindows().length === 0) createWindow()
initTray(showOrCreate) initTray(showOrCreate)
// Tray labels are resolved from the dictionary at build time, so the menu has // Tray labels are resolved from the dictionary at build time, so the menu has
// to be rebuilt whenever the interface language changes. // to be rebuilt whenever the interface language changes.
+4 -1
View File
@@ -34,7 +34,7 @@ import {
import { broadcast } from './broadcast' import { broadcast } from './broadcast'
import { CommandsStore, defaultCommandsPath } from './commands' import { CommandsStore, defaultCommandsPath } from './commands'
import type { CommandItem } from '../shared/commands' import type { CommandItem } from '../shared/commands'
import { createPty, killPty, resizePty, writePty, openSession, configureSessionRuntime, getSessionReplay, registerLogHooks } from './pty' import { createPty, killPty, resizePty, writePty, openSession, configureSessionRuntime, getSessionReplay, sessionState, registerLogHooks } from './pty'
import { respondZmodem } from './zmodem' import { respondZmodem } from './zmodem'
import type { ZmodemResponse } from '../shared/ipc' import type { ZmodemResponse } from '../shared/ipc'
@@ -153,6 +153,9 @@ export function registerIpc(): void {
ipcMain.handle(Ipc.PTY_CREATE, (event, opts?: PtyCreateOptions) => createPty(opts, event.sender.id)) ipcMain.handle(Ipc.PTY_CREATE, (event, opts?: PtyCreateOptions) => createPty(opts, event.sender.id))
ipcMain.handle(Ipc.SESSION_OPEN, (event, opts: SessionOpenOptions) => openSession(opts, event.sender.id)) ipcMain.handle(Ipc.SESSION_OPEN, (event, opts: SessionOpenOptions) => openSession(opts, event.sender.id))
ipcMain.handle(Ipc.SESSION_REPLAY, (_event, id: string) => getSessionReplay(id)) ipcMain.handle(Ipc.SESSION_REPLAY, (_event, id: string) => getSessionReplay(id))
// Compensates the one-shot PTY_EXIT broadcast: a pane that subscribed after
// its shell died asks here instead of waiting forever.
ipcMain.handle(Ipc.SESSION_STATE, (_event, id: string) => sessionState(id))
ipcMain.on(Ipc.PTY_WRITE, (_event, id: string, data: string) => writePty(id, data)) ipcMain.on(Ipc.PTY_WRITE, (_event, id: string, data: string) => writePty(id, data))
ipcMain.on(Ipc.PTY_RESIZE, (_event, id: string, cols: number, rows: number) => ipcMain.on(Ipc.PTY_RESIZE, (_event, id: string, cols: number, rows: number) =>
resizePty(id, cols, rows) resizePty(id, cols, rows)
+147 -15
View File
@@ -3,7 +3,7 @@ import { spawn, type IPty } from '@lydell/node-pty'
import { randomUUID } from 'crypto' import { randomUUID } from 'crypto'
import { homedir } from 'os' import { homedir } from 'os'
import { StringDecoder } from 'string_decoder' import { StringDecoder } from 'string_decoder'
import { Ipc, type PtyCreateOptions, type PtyCreateResult } from '../shared/ipc' import { Ipc, type PtyCreateOptions, type PtyCreateResult, type SessionStateResult } from '../shared/ipc'
import type { SessionOpenOptions, HostKeyPromptEvent, SshConnection } from '../shared/connections' import type { SessionOpenOptions, HostKeyPromptEvent, SshConnection } from '../shared/connections'
import { broadcast } from './broadcast' import { broadcast } from './broadcast'
import { connectSsh, type SshSessionHandle } from './ssh' import { connectSsh, type SshSessionHandle } from './ssh'
@@ -104,6 +104,68 @@ export function getSessionReplay(id: string): string {
return replayBuffers.get(id) ?? '' return replayBuffers.get(id) ?? ''
} }
/**
* Exit codes of sessions that are already gone. PTY_EXIT is broadcast exactly
* once and never replayed, so a pane that subscribes after its shell died
* would sit blank forever — the renderer compensates by querying SESSION_STATE
* once its subscription is in place. Insertion-ordered: the oldest entry is
* evicted first, and the cap only has to cover the panes that are still
* catching up.
*/
const MAX_SESSION_EXITS = 512
const sessionExits = new Map<string, number>()
function rememberSessionExit(id: string, exitCode: number): void {
// Re-inserting keeps the map in "most recently exited last" order.
sessionExits.delete(id)
sessionExits.set(id, exitCode)
while (sessionExits.size > MAX_SESSION_EXITS) {
const oldest = sessionExits.keys().next().value
if (oldest === undefined) break
sessionExits.delete(oldest)
}
}
/**
* What the main process knows about a session id: alive, exited (with its code),
* or unknown — a temp id from an aborted connect, or an evicted exit record.
*/
export function sessionState(id: string): SessionStateResult {
if (sessions.has(id)) return { exists: true, exited: false, exitCode: null }
const exitCode = sessionExits.get(id)
if (exitCode === undefined) return { exists: false, exited: false, exitCode: null }
return { exists: false, exited: true, exitCode }
}
/**
* In-flight ssh connects, keyed by a temp id. Until `connectSsh` resolves there
* is no entry in `sessions`, so an owner that vanished during the handshake
* (renderer crash) or an app quit used to leave the connect running: it
* completed later and registered a session nobody owns. The attempt is
* registered up front so killPtysByOwner / killAllPtys can abort it — the
* client is ended, and openSession tears down whatever still comes back
* instead of adopting it.
*/
interface PendingOpen {
owner?: number
client?: SshSessionHandle['client']
aborted: boolean
}
const pendingOpens = new Map<string, PendingOpen>()
function abortPendingOpens(matches: (pending: PendingOpen) => boolean): void {
for (const pending of pendingOpens.values()) {
if (!matches(pending)) continue
pending.aborted = true
try {
pending.client?.end()
} catch {
// best effort
}
}
}
/** /**
* Dependencies injected once by ipc.ts (configureSessionRuntime) so pty.ts * Dependencies injected once by ipc.ts (configureSessionRuntime) so pty.ts
* stays free of store / known-hosts imports and the ssh service can remain * stays free of store / known-hosts imports and the ssh service can remain
@@ -230,6 +292,8 @@ export function createPty(opts: PtyCreateOptions = {}, owner?: number): PtyCreat
// The session is gone: drop its replay buffer too (killPty was the only // The session is gone: drop its replay buffer too (killPty was the only
// path that did, so naturally-exiting shells leaked up to 64KB each). // path that did, so naturally-exiting shells leaked up to 64KB each).
replayBuffers.delete(id) replayBuffers.delete(id)
// Remember the code for panes that subscribe after this broadcast.
rememberSessionExit(id, exitCode)
safeStopLog(id) safeStopLog(id)
broadcast(Ipc.PTY_EXIT, { id, exitCode }) broadcast(Ipc.PTY_EXIT, { id, exitCode })
} catch { } catch {
@@ -258,22 +322,61 @@ export async function openSession(opts: SessionOpenOptions, owner?: number): Pro
} }
const conn = deps.getConnection(opts.connectionId) const conn = deps.getConnection(opts.connectionId)
const handle = await connectSsh(conn, opts.secretOverride, { // Register the attempt before the handshake: until it resolves there is no
connections: { // session entry, so this is the only handle an owner-based kill has on it.
getSecret: (c, field) => deps.getSecret(c, field), const tempId = randomUUID()
touch: (id: string) => { const pending: PendingOpen = { owner, aborted: false }
// Successful connect: record lastConnectedAt on the bookmark. pendingOpens.set(tempId, pending)
try {
deps.touch(id) let handle: SshSessionHandle
} catch { try {
// store write failure must not break the session handle = await connectSsh(conn, opts.secretOverride, {
connections: {
getSecret: (c, field) => deps.getSecret(c, field),
touch: (id: string) => {
// Successful connect: record lastConnectedAt on the bookmark.
try {
deps.touch(id)
} catch {
// store write failure must not break the session
}
} }
},
knownHosts: deps.knownHosts,
broadcast: deps.broadcast,
promptHostKey: deps.promptHostKey,
// Earliest reference to the client, so an abort can end it mid-handshake.
onClient: (client) => {
pending.client = client
} }
}, })
knownHosts: deps.knownHosts, } catch (err) {
broadcast: deps.broadcast, // An aborted attempt is our own teardown, not a connect failure: whoever
promptHostKey: deps.promptHostKey // asked for the session is already gone (owner crash / app quit), so the
}) // answer is the temp id — nothing was ever registered under it.
if (!pending.aborted) throw err
return { id: tempId }
} finally {
pendingOpens.delete(tempId)
}
// The owner died while the handshake was in flight. Nothing has been
// registered yet, so the teardown is the transport alone — same order as
// killSession (stream first, then client) and no session-scoped cleanups.
if (pending.aborted) {
try {
handle.stream.close()
} catch {
// best effort
}
try {
handle.client.end()
} catch {
// best effort
}
return { id: tempId }
}
sessions.set(handle.id, { kind: 'ssh', ssh: handle, owner }) sessions.set(handle.id, { kind: 'ssh', ssh: handle, owner })
sshDecoders.set(handle.id, new StringDecoder('utf8')) sshDecoders.set(handle.id, new StringDecoder('utf8'))
@@ -301,6 +404,29 @@ export async function openSession(opts: SessionOpenOptions, owner?: number): Pro
// stream may already be dead // stream may already be dead
} }
} }
},
// Receive-side backpressure: pausing the ssh stream stops the bytes that
// feed the zmodem sentry, so a slow local disk cannot pile the whole
// transfer up in the fs WriteStream's unbounded buffer.
pauseSource: (id) => {
const s = sessions.get(id)
if (s?.kind === 'ssh') {
try {
s.ssh.stream.pause()
} catch {
// stream may already be dead
}
}
},
resumeSource: (id) => {
const s = sessions.get(id)
if (s?.kind === 'ssh') {
try {
s.ssh.stream.resume()
} catch {
// stream may already be dead
}
}
} }
}) })
@@ -333,6 +459,7 @@ export async function openSession(opts: SessionOpenOptions, owner?: number): Pro
sessions.delete(handle.id) sessions.delete(handle.id)
replayBuffers.delete(handle.id) replayBuffers.delete(handle.id)
sshDecoders.delete(handle.id) sshDecoders.delete(handle.id)
rememberSessionExit(handle.id, exitCode)
deps.broadcast(Ipc.PTY_EXIT, { id: handle.id, exitCode }) deps.broadcast(Ipc.PTY_EXIT, { id: handle.id, exitCode })
} catch { } catch {
// never crash the event loop // never crash the event loop
@@ -428,9 +555,13 @@ export function killPtysByOwner(owner: number): void {
if (sessions.get(id)?.owner !== owner) continue if (sessions.get(id)?.owner !== owner) continue
killSession(id) killSession(id)
} }
// A connect that has not resolved yet has no session entry to match on, so it
// would finish later and register an orphan.
abortPendingOpens((pending) => pending.owner === owner)
} }
export function killAllPtys(): void { export function killAllPtys(): void {
abortPendingOpens(() => true)
for (const id of sessions.keys()) { for (const id of sessions.keys()) {
forceStopPolling(id) forceStopPolling(id)
closeSftp(id) closeSftp(id)
@@ -458,5 +589,6 @@ export function killAllPtys(): void {
} }
} }
sessions.clear() sessions.clear()
replayBuffers.clear()
sshDecoders.clear() sshDecoders.clear()
} }
+47 -10
View File
@@ -202,11 +202,23 @@ function refreshBuiltinRules(rules: HighlightRule[], warnings: Warnings): Highli
return [...refreshed, ...missing.map((rule) => ({ ...rule }))].sort((a, b) => a.priority - b.priority) return [...refreshed, ...missing.map((rule) => ({ ...rule }))].sort((a, b) => a.priority - b.priority)
} }
/**
* Fresh copies of the preset rules for a caller that may mutate what it gets
* back. Handing out the module-level array (or its objects) would let one
* store's edit leak into every later load in the process.
*/
function copyDefaultRules(): HighlightRule[] {
return DEFAULT_HIGHLIGHT_RULES.map((rule) => ({ ...rule }))
}
function sanitizeRules(value: unknown, warnings: Warnings): HighlightRule[] { function sanitizeRules(value: unknown, warnings: Warnings): HighlightRule[] {
// An explicit empty array is a valid choice ("no highlighting"); only // An explicit empty array is a valid choice ("no highlighting"); only
// malformed data falls back to the built-in rules. Returning the defaults for // malformed data falls back to the built-in rules. Returning the defaults for
// [] made deleting the last rule look like it silently failed. // [] made deleting the last rule look like it silently failed.
if (!Array.isArray(value)) return DEFAULT_HIGHLIGHT_RULES if (!Array.isArray(value)) {
warnings.push('highlightRules: not an array — built-in rules restored')
return copyDefaultRules()
}
const rules: HighlightRule[] = [] const rules: HighlightRule[] = []
value.forEach((entry, index) => { value.forEach((entry, index) => {
const rule = coerceRule(entry, index, warnings) const rule = coerceRule(entry, index, warnings)
@@ -457,22 +469,47 @@ function backupUnparseableSettings(err: unknown): void {
} }
} }
/**
* The defaults loadSettings falls back to. Fresh objects every call so a caller
* mutating what it got back cannot poison the module-level presets.
*/
function defaultSettings(): AppSettings {
return {
terminal: { ...DEFAULT_SETTINGS.terminal },
customThemes: [...DEFAULT_SETTINGS.customThemes],
highlightRules: copyDefaultRules(),
highlightProfiles: [],
system: { ...DEFAULT_SYSTEM },
lock: { ...DEFAULT_SETTINGS.lock }
}
}
/**
* The single exit for an unusable settings.json — parse failure and wrong shape
* both end here, so neither can quietly skip the backup.
*/
function discardUnparseableSettings(err: unknown): AppSettings {
backupUnparseableSettings(err)
return defaultSettings()
}
export function loadSettings(): AppSettings { export function loadSettings(): AppSettings {
try { try {
const raw: unknown = JSON.parse(readFileSync(settingsPath(), 'utf8')) const raw: unknown = JSON.parse(readFileSync(settingsPath(), 'utf8'))
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
// Valid JSON of the wrong shape (`[1,2,3]`, `"text"`, `null`) is not "no
// settings": it is a file we cannot read, and the next mutation would
// rewrite it from the defaults — taking the custom themes and highlight
// rules with it. Per-field repair below only applies to a real object.
return discardUnparseableSettings(
new Error('settings.json shape mismatch: expected an object')
)
}
const { settings, errors } = deepMerge(raw) const { settings, errors } = deepMerge(raw)
reportWarnings(errors) reportWarnings(errors)
return settings return settings
} catch (err) { } catch (err) {
backupUnparseableSettings(err) return discardUnparseableSettings(err)
return {
terminal: { ...DEFAULT_SETTINGS.terminal },
customThemes: [...DEFAULT_SETTINGS.customThemes],
highlightRules: DEFAULT_HIGHLIGHT_RULES.map((rule) => ({ ...rule })),
highlightProfiles: [],
system: { ...DEFAULT_SYSTEM },
lock: { ...DEFAULT_SETTINGS.lock }
}
} }
} }
+50 -4
View File
@@ -325,12 +325,58 @@ export function renameRemote(sessionId: string, from: string, to: string): Promi
return withSftp(sessionId, sftp => pVoid(cb => sftp.rename(from, to, cb))) return withSftp(sessionId, sftp => pVoid(cb => sftp.rename(from, to, cb)))
} }
/**
* "The file is already gone" — the one server-side failure a delete may treat
* as success. ssh2 reports the SFTP status both ways: `code` carries the numeric
* status (2 = SSH_FX_NO_SUCH_FILE) and `message` the library's own English text,
* so both are checked (builds fill one or the other). Our own translated errors
* never reach this check — they arrive as OperationError, which the caller
* passes through untouched.
*/
function isNoSuchFile(err: unknown): boolean {
if ((err as { code?: unknown } | undefined)?.code === 2) return true
return /no such file/i.test((err as Error | undefined)?.message ?? '')
}
/**
* unlink/rmdir where "already gone" counts as success.
*
* Deleting is not idempotent by nature: the entry may have been removed by
* someone else between the listing and the click, or the same delete may be run
* again after an earlier pass already removed it. Reporting a path that is
* verifiably gone as "delete failed" is a fake error — the end state the user
* asked for already holds.
*/
async function unlinkIfPresent(sftp: SFTPWrapper, path: string): Promise<void> {
try {
await pVoid(cb => sftp.unlink(path, cb))
} catch (err) {
if (!isNoSuchFile(err)) throw err
}
}
async function rmdirIfPresent(sftp: SFTPWrapper, path: string): Promise<void> {
try {
await pVoid(cb => sftp.rmdir(path, cb))
} catch (err) {
if (!isNoSuchFile(err)) throw err
}
}
async function deleteRecursive(sftp: SFTPWrapper, path: string, isDir: boolean): Promise<void> { async function deleteRecursive(sftp: SFTPWrapper, path: string, isDir: boolean): Promise<void> {
if (!isDir) { if (!isDir) {
await pVoid(cb => sftp.unlink(path, cb)) await unlinkIfPresent(sftp, path)
return return
} }
const names = await readdir(sftp, path) let names: string[]
try {
names = await readdir(sftp, path)
} catch (err) {
// The directory is gone (removed by someone else, or by an earlier pass of
// this same delete): everything inside went with it, so the intent holds.
if (isNoSuchFile(err)) return
throw err
}
const base = path.replace(/\/+$/, '') || '/' const base = path.replace(/\/+$/, '') || '/'
for (const name of names) { for (const name of names) {
const child = `${base}/${name}` const child = `${base}/${name}`
@@ -342,7 +388,7 @@ async function deleteRecursive(sftp: SFTPWrapper, path: string, isDir: boolean):
} }
await deleteRecursive(sftp, child, childIsDir) await deleteRecursive(sftp, child, childIsDir)
} }
await pVoid(cb => sftp.rmdir(path, cb)) await rmdirIfPresent(sftp, path)
} }
export function deleteRemote(sessionId: string, paths: string[]): Promise<void> { export function deleteRemote(sessionId: string, paths: string[]): Promise<void> {
@@ -354,7 +400,7 @@ export function deleteRemote(sessionId: string, paths: string[]): Promise<void>
try { try {
isDir = (await lstat(sftp, path)).isDirectory() isDir = (await lstat(sftp, path)).isDirectory()
} catch { } catch {
// stat failed — fall through to unlink // stat failed — fall through to unlink, which tolerates "already gone"
} }
await deleteRecursive(sftp, path, isDir) await deleteRecursive(sftp, path, isDir)
} catch (err) { } catch (err) {
+14 -1
View File
@@ -64,6 +64,13 @@ export interface SshServiceDeps {
fingerprint: string fingerprint: string
reason: 'new' | 'changed' reason: 'new' | 'changed'
}): void }): void
/**
* Called with the ssh2 client as soon as it exists — the earliest moment it
* can be ended. pty.ts keeps the reference so an owner crash / app quit
* during the handshake can abort the connect instead of letting it finish and
* register a session nobody owns.
*/
onClient?: (client: Client) => void
timeoutMs?: { prompt: number; connect: number } timeoutMs?: { prompt: number; connect: number }
} }
@@ -188,6 +195,12 @@ export async function connectSsh(
// failure paths before resolution: `fail` and the connect timer // failure paths before resolution: `fail` and the connect timer
armConnectTimer() armConnectTimer()
// Hand out the client before anything is dialled, so an abort during the
// handshake has a reference to end(). end() is a no-op until connect()
// created the socket, but the executor runs synchronously, so connect()
// below is already under way before openSession regains control.
deps.onClient?.(handshake)
handshake.on('error', (err: Error) => { handshake.on('error', (err: Error) => {
console.error(`[ssh] client error: ${err.message}`) console.error(`[ssh] client error: ${err.message}`)
const message = verifierErr ?? err.message const message = verifierErr ?? err.message
@@ -315,7 +328,7 @@ const pendingPrompts = new Map<string, PendingPrompt>()
/** /**
* Ask the renderer to approve / reject a host key. Resolves `true`/`false`. * Ask the renderer to approve / reject a host key. Resolves `true`/`false`.
* Times out (default 30s) -> treated as reject. * Times out (default 120s) -> treated as reject.
*/ */
function promptUser( function promptUser(
host: string, host: string,
+11 -1
View File
@@ -157,7 +157,17 @@ function pollOnce(id: string, state: PollState): void {
try { try {
client.exec(COLLECT_CMD, (err: Error | undefined, stream) => { client.exec(COLLECT_CMD, (err: Error | undefined, stream) => {
if (state.stopped) return if (state.stopped) {
// Polling stopped while this exec was in flight: the channel is already
// open, so simply dropping the stream would leak it for the rest of the
// ssh session. Close it (best effort — it may be dead already) and go.
try {
stream?.close()
} catch {
// best effort
}
return
}
if (err || !stream) { if (err || !stream) {
handleError(id, state, err?.message || t('main.sysinfo.execFailed')) handleError(id, state, err?.message || t('main.sysinfo.execFailed'))
return return
+108 -5
View File
@@ -65,6 +65,19 @@ export interface ZmodemDeps {
toTerminal(sessionId: string, data: Buffer): void toTerminal(sessionId: string, data: Buffer): void
/** Write bytes out to the ssh stream (zmodem frames + CAN abort sequence). */ /** Write bytes out to the ssh stream (zmodem frames + CAN abort sequence). */
writeStream(sessionId: string, data: Buffer): void writeStream(sessionId: string, data: Buffer): void
/**
* Backpressure over the ssh stream that FEEDS this session's sentry. pty.ts
* owns that channel and injects these two, mirroring writeStream: the engine
* has no handle on it, and pausing the source is the only thing that bounds
* how much a peer can push into a file sink that is over its highWaterMark.
*
* Optional so a harness with no real stream still runs (and so an injector
* that predates this hook keeps working): without it a backed-up sink still
* stops counting progress, which turns a wedged disk into a stall-watchdog
* failure instead of an unbounded queue — but the queue itself stays.
*/
pauseSource?(sessionId: string): void
resumeSource?(sessionId: string): void
} }
interface Engine { interface Engine {
@@ -94,7 +107,18 @@ interface Engine {
offerTimer: NodeJS.Timeout | null offerTimer: NodeJS.Timeout | null
stallTimer: NodeJS.Timeout | null stallTimer: NodeJS.Timeout | null
receiveStream: Writable | null receiveStream: Writable | null
/**
* Octets the current file's sink ACCEPTED. A write that only landed in the
* WriteStream's own queue (write() returned false) is not counted here until
* the queue drains — see pendingBytes and the receive on_input.
*/
bytes: number bytes: number
/** Octets handed to a backed-up sink; folded into `bytes` on drain/teardown. */
pendingBytes: number
/** True while the source is paused because the sink is over its highWaterMark. */
sourcePaused: boolean
/** One-shot: the injector supplied no pauseSource, so the pause is a no-op. */
warnedNoSource: boolean
totalBytes: number totalBytes: number
} }
@@ -176,6 +200,9 @@ function finalize(
// mistaken for a clean finish. // mistaken for a clean finish.
engine.ending = true engine.ending = true
cancelTimers(engine) cancelTimers(engine)
// A pause must never outlive its transfer: the 'drain' that would lift it may
// still be queued, or may never come at all once the sink is ending here.
releaseSource(engine)
const stream = engine.receiveStream const stream = engine.receiveStream
engine.receiveStream = null engine.receiveStream = null
// Clear the slot *before* end(): an errored/destroyed sink must not be ended // Clear the slot *before* end(): an errored/destroyed sink must not be ended
@@ -235,6 +262,58 @@ function touchActivity(engine: Engine): void {
engine.stallTimer = timer engine.stallTimer = timer
} }
/**
* Stop the ssh stream that feeds this engine (receive path only: the send path
* pushes into the sink instead of pulling from the peer).
*
* Idempotent — every subpacket of a blocked window arrives through the same
* on_input, and pausing an already-paused channel is pointless work on the hot
* path. The flag is set even when the injector supplied no hook, so the
* accounting below stays consistent either way.
*/
function pauseSource(engine: Engine): void {
if (engine.sourcePaused) return
engine.sourcePaused = true
if (!engine.deps.pauseSource) {
// Say it once per transfer instead of silently doing nothing: the hook is
// the whole mechanism, so an injector that forgot it still has an unbounded
// sink queue (only the progress accounting below improves).
if (!engine.warnedNoSource) {
engine.warnedNoSource = true
console.warn('[zmodem] receive sink is over its highWaterMark but no pauseSource hook was injected')
}
return
}
try {
engine.deps.pauseSource(engine.id)
} catch {
// the stream may already be gone
}
}
/**
* Undo a backpressure pause, folding the octets that only ever reached the
* sink's queue into the received count.
*
* Called from the sink's 'drain' AND from every teardown path: a pause that
* outlived its transfer would leave the session's ssh stream stopped for good
* (terminal output and the peer both look frozen, with no way back), and
* waiting for a 'drain' that never comes — dead disk, destroyed stream — is
* exactly how that happens. Returns whether a pause was actually outstanding.
*/
function releaseSource(engine: Engine): boolean {
if (!engine.sourcePaused) return false
engine.sourcePaused = false
engine.bytes += engine.pendingBytes
engine.pendingBytes = 0
try {
engine.deps.resumeSource?.(engine.id)
} catch {
// the stream may already be gone
}
return true
}
function wireSession(engine: Engine): void { function wireSession(engine: Engine): void {
const session = engine.session const session = engine.session
if (!session) return if (!session) return
@@ -383,23 +462,40 @@ function handleOffer(engine: Engine, offer: Zmodem.Offer): void {
finalize(engine, false, err instanceof Error ? err.message : t('main.zmodem.receiveFailed')) finalize(engine, false, err instanceof Error ? err.message : t('main.zmodem.receiveFailed'))
}) })
// Backpressure release. The sink's queue is empty again, so the octets that
// were parked there are really written, the peer may push more, and this is
// the ONLY progress signal while blocked — it must therefore also reset the
// stall watchdog (a slow but working disk is not a stalled transfer).
stream.on('drain', () => {
if (engine.receiveStream !== stream) return
if (releaseSource(engine)) touchActivity(engine)
})
offer offer
.accept({ .accept({
on_input: (payload: Uint8Array | number[]) => { on_input: (payload: Uint8Array | number[]) => {
// zmodem.js delivers the payload as a plain octet Array (not a // zmodem.js delivers the payload as a plain octet Array (not a
// Uint8Array); coerce defensively to a Buffer before writing. // Uint8Array); coerce defensively to a Buffer before writing.
const buf = Array.isArray(payload) ? Buffer.from(payload) : Buffer.from(payload.buffer, payload.byteOffset, payload.byteLength) const buf = Array.isArray(payload) ? Buffer.from(payload) : Buffer.from(payload.buffer, payload.byteOffset, payload.byteLength)
if (!stream.destroyed && !stream.closed) { const writable = !stream.destroyed && !stream.closed
stream.write(buf) if (writable && stream.write(buf)) {
engine.bytes += buf.byteLength
// Not throttled: the stall watchdog measures byte progress, not UI events.
touchActivity(engine)
} else if (writable) {
// write() === false: the sink is over its highWaterMark, so these
// octets only sit in Node's queue — for a 100MB file into a slow disk
// the whole file would end up there. Pausing the ssh stream makes the
// peer wait instead; counting the bytes or touching the watchdog now
// would report progress for data that has not reached the disk yet.
engine.pendingBytes += buf.byteLength
pauseSource(engine)
} }
engine.bytes += buf.byteLength
emitProgressThrottled(engine, { emitProgressThrottled(engine, {
file: name, file: name,
bytes: engine.bytes, bytes: engine.bytes,
totalBytes: engine.totalBytes totalBytes: engine.totalBytes
}) })
// Not throttled: the stall watchdog measures byte progress, not UI events.
touchActivity(engine)
} }
}) })
.then(() => { .then(() => {
@@ -521,6 +617,9 @@ export function attachZmodem(sessionId: string, deps: ZmodemDeps): void {
stallTimer: null, stallTimer: null,
receiveStream: null, receiveStream: null,
bytes: 0, bytes: 0,
pendingBytes: 0,
sourcePaused: false,
warnedNoSource: false,
totalBytes: 0 totalBytes: 0
} }
engines.set(sessionId, engine) engines.set(sessionId, engine)
@@ -683,6 +782,10 @@ export function detachZmodem(sessionId: string): void {
if (engine.active) { if (engine.active) {
finalize(engine, false, t('main.zmodem.sessionClosed'), 'cancelled') finalize(engine, false, t('main.zmodem.sessionClosed'), 'cancelled')
} }
// The engine is about to be dropped: a pause still outstanding here would
// never be lifted (finalize already released the active one; this covers the
// path where it was not active).
releaseSource(engine)
engine.active = false engine.active = false
cancelTimers(engine) cancelTimers(engine)
const stream = engine.receiveStream const stream = engine.receiveStream
+1
View File
@@ -19,6 +19,7 @@ const api: AppApi = {
createPty: (opts?: PtyCreateOptions) => ipcRenderer.invoke(Ipc.PTY_CREATE, opts), createPty: (opts?: PtyCreateOptions) => ipcRenderer.invoke(Ipc.PTY_CREATE, opts),
openSession: (opts: SessionOpenOptions) => ipcRenderer.invoke(Ipc.SESSION_OPEN, opts), openSession: (opts: SessionOpenOptions) => ipcRenderer.invoke(Ipc.SESSION_OPEN, opts),
getSessionReplay: (id: string) => ipcRenderer.invoke(Ipc.SESSION_REPLAY, id), getSessionReplay: (id: string) => ipcRenderer.invoke(Ipc.SESSION_REPLAY, id),
getSessionLiveState: (id: string) => ipcRenderer.invoke(Ipc.SESSION_STATE, id),
writePty: (id: string, data: string) => ipcRenderer.send(Ipc.PTY_WRITE, id, data), writePty: (id: string, data: string) => ipcRenderer.send(Ipc.PTY_WRITE, id, data),
resizePty: (id: string, cols: number, rows: number) => resizePty: (id: string, cols: number, rows: number) =>
ipcRenderer.send(Ipc.PTY_RESIZE, id, cols, rows), ipcRenderer.send(Ipc.PTY_RESIZE, id, cols, rows),
+17 -5
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { Button, Modal, Popconfirm, Tabs } from 'antd' import { App, Button, Modal, Popconfirm, Tabs } from 'antd'
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons' import { DeleteOutlined, PlusOutlined } from '@ant-design/icons'
import type { CommandItem } from '@shared/commands' import type { CommandItem } from '@shared/commands'
import { t } from '@shared/i18n' import { t } from '@shared/i18n'
@@ -24,6 +24,7 @@ export function formatClock(ts: number): string {
* runs the command via the parent workspace's `onRun`. * runs the command via the parent workspace's `onRun`.
*/ */
export function CommandsPanel({ onRun }: CommandsPanelProps): React.JSX.Element { export function CommandsPanel({ onRun }: CommandsPanelProps): React.JSX.Element {
const { message } = App.useApp()
const [history, setHistory] = useState<CommandItem[]>([]) const [history, setHistory] = useState<CommandItem[]>([])
const [library, setLibrary] = useState<CommandItem[]>([]) const [library, setLibrary] = useState<CommandItem[]>([])
const [loaded, setLoaded] = useState(false) const [loaded, setLoaded] = useState(false)
@@ -54,16 +55,27 @@ export function CommandsPanel({ onRun }: CommandsPanelProps): React.JSX.Element
}, [refresh]) }, [refresh])
const handleClearHistory = useCallback(async (): Promise<void> => { const handleClearHistory = useCallback(async (): Promise<void> => {
await window.api.clearHistory() try {
await window.api.clearHistory()
} catch (err) {
// Nothing was removed, so a refresh would repaint the same rows.
message.error((err as Error)?.message || t('common.saveFailed'))
return
}
refresh() refresh()
}, [refresh]) }, [refresh, message])
const handleDeleteLibrary = useCallback( const handleDeleteLibrary = useCallback(
async (id: string): Promise<void> => { async (id: string): Promise<void> => {
await window.api.deleteLibraryItem(id) try {
await window.api.deleteLibraryItem(id)
} catch (err) {
message.error((err as Error)?.message || t('common.saveFailed'))
return
}
refresh() refresh()
}, },
[refresh] [refresh, message]
) )
const handleAdd = useCallback((): void => { const handleAdd = useCallback((): void => {
@@ -1,5 +1,5 @@
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react' import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react'
import { Button, Collapse, Popconfirm, Tooltip } from 'antd' import { App, Button, Collapse, Popconfirm, Tooltip } from 'antd'
import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons' import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons'
import type { SshConnection } from '@shared/connections' import type { SshConnection } from '@shared/connections'
import { t } from '@shared/i18n' import { t } from '@shared/i18n'
@@ -61,6 +61,7 @@ export const ConnectionSidebar = forwardRef<ConnectionSidebarHandle, ConnectionS
}: ConnectionSidebarProps, }: ConnectionSidebarProps,
ref ref
): React.JSX.Element { ): React.JSX.Element {
const { message } = App.useApp()
const mode = useWorkspaceModeStore((s) => s.mode) const mode = useWorkspaceModeStore((s) => s.mode)
const isTerminal = mode === 'terminal' const isTerminal = mode === 'terminal'
const [connections, setConnections] = useState<SshConnection[]>([]) const [connections, setConnections] = useState<SshConnection[]>([])
@@ -117,7 +118,14 @@ export const ConnectionSidebar = forwardRef<ConnectionSidebarHandle, ConnectionS
} }
const handleDelete = async (id: string): Promise<void> => { const handleDelete = async (id: string): Promise<void> => {
await window.api.deleteConnection(id) try {
await window.api.deleteConnection(id)
} catch (err) {
// The row is still there, so a refresh would just re-render the same
// list — report the failure and leave it alone.
message.error((err as Error)?.message || t('common.saveFailed'))
return
}
await refresh() await refresh()
} }
+1
View File
@@ -52,6 +52,7 @@ if (typeof window !== 'undefined' && !window.api) {
createPty: async () => ({ id: stubId(), shell: 'stub', cwd: '' }), createPty: async () => ({ id: stubId(), shell: 'stub', cwd: '' }),
openSession: async () => ({ id: stubId() }), openSession: async () => ({ id: stubId() }),
getSessionReplay: async () => '', getSessionReplay: async () => '',
getSessionLiveState: async () => ({ exists: false, exited: false, exitCode: null }),
writePty: noop, writePty: noop,
resizePty: noop, resizePty: noop,
killPty: noop, killPty: noop,
+10
View File
@@ -182,6 +182,16 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
}, [sessionId, workspaceMode]) }, [sessionId, workspaceMode])
if (!meta || !sample) { if (!meta || !sample) {
// `meta` is only broadcast after a first sample succeeds, so a session that
// fails from the start would otherwise spin here forever with its error
// sample hidden behind the meta gate.
if (sample?.error) {
return (
<div className="mm-root mm-empty mm-failed">
<div className="mm-error">{t('ssh.monitor.interrupted', { error: sample.error })}</div>
</div>
)
}
return ( return (
<div className="mm-root mm-empty"> <div className="mm-root mm-empty">
<span className="mm-dots"><span /> <span /> <span /></span> <span className="mm-dots"><span /> <span /> <span /></span>
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Alert, Button, Input, Modal, Popconfirm, Radio, Space } from 'antd' import { Alert, App, Button, Input, Modal, Popconfirm, Radio, Space } from 'antd'
import { t } from '@shared/i18n' import { t } from '@shared/i18n'
import { exportHighlightRules, mergeRules, parseHighlightRules } from '@shared/highlightIO' import { exportHighlightRules, mergeRules, parseHighlightRules } from '@shared/highlightIO'
import type { ImportError } from '@shared/highlightIO' import type { ImportError } from '@shared/highlightIO'
@@ -24,6 +24,7 @@ export function HighlightImportExport({
open: boolean open: boolean
onClose: () => void onClose: () => void
}): React.JSX.Element { }): React.JSX.Element {
const { message } = App.useApp()
const highlightRules = useSettingsStore((s) => s.settings.highlightRules) const highlightRules = useSettingsStore((s) => s.settings.highlightRules)
const setHighlightRules = useSettingsStore((s) => s.setHighlightRules) const setHighlightRules = useSettingsStore((s) => s.setHighlightRules)
@@ -59,18 +60,22 @@ export function HighlightImportExport({
link.href = url link.href = url
link.download = 'openterminal-highlight-rules.json' link.download = 'openterminal-highlight-rules.json'
link.click() link.click()
URL.revokeObjectURL(url) // Chromium reads the blob asynchronously after the click, so revoking the
// URL synchronously aborts the download before it can start.
window.setTimeout(() => URL.revokeObjectURL(url), 0)
} }
const handleFile = (file: File): void => { const handleFile = (file: File): void => {
const reader = new FileReader() const reader = new FileReader()
reader.onload = () => setDraft(typeof reader.result === 'string' ? reader.result : '') reader.onload = () => setDraft(typeof reader.result === 'string' ? reader.result : '')
reader.onerror = () => message.error(t('settings.highlight.importReadFailed'))
reader.readAsText(file) reader.readAsText(file)
} }
const errorText = (error: ImportError): string => { const errorText = (error: ImportError): string => {
if (error === 'not-json') return t('settings.highlight.importBadJson') if (error === 'not-json') return t('settings.highlight.importBadJson')
if (error === 'wrong-kind') return t('settings.highlight.importWrongKind') if (error === 'wrong-kind') return t('settings.highlight.importWrongKind')
if (error === 'wrong-version') return t('settings.highlight.importWrongVersion')
return t('settings.highlight.importEmpty') return t('settings.highlight.importEmpty')
} }
+4 -1
View File
@@ -549,7 +549,10 @@ function HighlightEditor({
...(draft.basic ? { basic: true as const } : {}), ...(draft.basic ? { basic: true as const } : {}),
note: draft.note?.trim() ? draft.note.trim() : undefined note: draft.note?.trim() ? draft.note.trim() : undefined
} }
await setHighlightRules([...highlightRules, rule]) // read at call time: the render-scoped array goes stale inside a React
// batch, and another write in the same batch would silently drop the first
const current = useSettingsStore.getState().settings.highlightRules
await setHighlightRules([...current, rule])
} else if (editing !== undefined) { } else if (editing !== undefined) {
const rule: HighlightRule = { const rule: HighlightRule = {
...editing, ...editing,
+12
View File
@@ -536,6 +536,17 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
const refreshSeqRef = useRef(0) const refreshSeqRef = useRef(0)
/** Mirrors `dir` for callbacks created before a navigation (upload finish). */ /** Mirrors `dir` for callbacks created before a navigation (upload finish). */
const dirRef = useRef(dir) const dirRef = useRef(dir)
/** Unsubscribe for the transfer listener the in-flight upload registered. */
const uploadOffRef = useRef<(() => void) | null>(null)
useEffect(() => {
return () => {
// An upload can still be running — or never deliver a terminal event —
// when the panel unmounts; without this its IPC listener outlives it.
uploadOffRef.current?.()
uploadOffRef.current = null
}
}, [])
const refresh = useCallback( const refresh = useCallback(
async (target: string): Promise<void> => { async (target: string): Promise<void> => {
@@ -602,6 +613,7 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
} }
if (e.transferId === targetId) finish() if (e.transferId === targetId) finish()
}) })
uploadOffRef.current = off
window.api.uploadRemote(sessionId, files, dir).then( window.api.uploadRemote(sessionId, files, dir).then(
(id: string) => { (id: string) => {
targetId = id targetId = id
+26 -5
View File
@@ -1042,6 +1042,13 @@ export function TerminalView({
// Live data is queued until the replay lands so output keeps its order. // Live data is queued until the replay lands so output keeps its order.
let replayDone = false let replayDone = false
const pending: string[] = [] const pending: string[] = []
// Death state, shared by the PTY_EXIT subscription and the SESSION_STATE
// query below: the two paths must land identically.
const markDead = (code: number): void => {
deadRef.current = true
setExitCode(code)
setDead(true)
}
const unsubscribes: (() => void)[] = [ const unsubscribes: (() => void)[] = [
// Routed via the shared dispatcher (one IPC listener for all panes) // Routed via the shared dispatcher (one IPC listener for all panes)
// instead of a per-pane global listener. // instead of a per-pane global listener.
@@ -1053,11 +1060,7 @@ export function TerminalView({
} }
writeHighlighted(data) writeHighlighted(data)
}), }),
subscribePtyExit(sessionId, (code: number) => { subscribePtyExit(sessionId, markDead)
deadRef.current = true
setExitCode(code)
setDead(true)
})
] ]
// Late-subscriber catch-up: output emitted before this subscription // Late-subscriber catch-up: output emitted before this subscription
// (shell banner, template-apply rebind) replays from the main buffer. // (shell banner, template-apply rebind) replays from the main buffer.
@@ -1073,6 +1076,21 @@ export function TerminalView({
pending.length = 0 pending.length = 0
}) })
// PTY_EXIT is broadcast once and never replayed, so a shell that died in
// the gap between openSession and this subscription (or before the pane was
// rebound to it) would leave the pane blank forever. Ask once, now that the
// subscription can no longer miss it. `disposed` rather than deadRef: the
// cleanup below also runs on a rebind, where the pane is alive again under
// another session and this answer is stale.
let disposed = false
void window.api
.getSessionLiveState(sessionId)
.then((state) => {
if (disposed || !state.exited) return
markDead(state.exitCode ?? 0)
})
.catch(() => undefined)
let observer: ResizeObserver | undefined let observer: ResizeObserver | undefined
if (hostRef.current) { if (hostRef.current) {
observer = new ResizeObserver(() => scheduleFit()) observer = new ResizeObserver(() => scheduleFit())
@@ -1114,6 +1132,9 @@ export function TerminalView({
for (const disposable of disposables) disposable.dispose() for (const disposable of disposables) disposable.dispose()
observer?.disconnect() observer?.disconnect()
observerRef.current = null observerRef.current = null
// The pane is unmounted or rebinding to another session: an in-flight
// SESSION_STATE answer must not mark the next session dead.
disposed = true
deadRef.current = true deadRef.current = true
webglRef.current?.dispose() webglRef.current?.dispose()
webglRef.current = null webglRef.current = null
+163 -99
View File
@@ -99,6 +99,22 @@ const DOCKVIEW_TAB_COMPONENTS = {
) )
} }
/** A queued host-key prompt plus the time it was queued — see the TTL sweep. */
interface QueuedHostKeyPrompt extends HostKeyPromptEvent {
enqueuedAt: number
}
/**
* How long a queued host-key prompt is worth answering. Mirrors the main
* process' `DEFAULT_TIMEOUTS.prompt` (src/main/ssh.ts): once that elapses the
* prompt has already been resolved as a reject and dropped there, so a
* decision of ours can no longer reach it.
*/
const HOST_KEY_PROMPT_TTL_MS = 120_000
/** Expired-prompt sweep interval (rendering draws the queue head only). */
const HOST_KEY_SWEEP_MS = 5_000
/** /**
* Electron re-throws a main-process rejection as * Electron re-throws a main-process rejection as
* `Error: Error invoking remote method 'x': Error: <real message>`, so the real * `Error: Error invoking remote method 'x': Error: <real message>`, so the real
@@ -333,8 +349,12 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
/** Re-entrancy guard for `connect` — a second attempt while one is in flight /** Re-entrancy guard for `connect` — a second attempt while one is in flight
* is dropped (openSession cannot be aborted and would orphan a session). */ * is dropped (openSession cannot be aborted and would orphan a session). */
const connectInFlightRef = useRef(0) const connectInFlightRef = useRef(0)
/** Re-entrancy guard for `handleApplyTemplate` — an overlapping second apply
* would collect the sessions the first one just restored as its own
* "previous" set and kill them. */
const applyTemplateInFlightRef = useRef(false)
/** FIFO of pending host-key confirmations — render the head only. */ /** FIFO of pending host-key confirmations — render the head only. */
const [hostKeyQueue, setHostKeyQueue] = useState<HostKeyPromptEvent[]>([]) const [hostKeyQueue, setHostKeyQueue] = useState<QueuedHostKeyPrompt[]>([])
/** B's sidebar handle; refresh() after a successful SSH connect */ /** B's sidebar handle; refresh() after a successful SSH connect */
const sidebarRef = useRef<ConnectionSidebarHandle>(null) const sidebarRef = useRef<ConnectionSidebarHandle>(null)
@@ -647,21 +667,39 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
*/ */
useEffect(() => { useEffect(() => {
return window.api.onHostKeyPrompt((event: HostKeyPromptEvent) => { return window.api.onHostKeyPrompt((event: HostKeyPromptEvent) => {
setHostKeyQueue((prev) => [...prev, event]) setHostKeyQueue((prev) => [...prev, { ...event, enqueuedAt: Date.now() }])
}) })
}, []) }, [])
// A prompt the main process gave up on never gets a decision from the user:
// its own timeout resolves the attempt as a reject and stops tracking the
// prompt, but nothing tells the renderer. Since only the head is rendered,
// such an entry would hold the single visible slot forever and block every
// later host's prompt behind it, so sweep the expired ones out.
useEffect(() => {
const timer = window.setInterval(() => {
const now = Date.now()
setHostKeyQueue((prev) => {
const next = prev.filter((p) => now - p.enqueuedAt < HOST_KEY_PROMPT_TTL_MS)
return next.length === prev.length ? prev : next
})
}, HOST_KEY_SWEEP_MS)
return () => window.clearInterval(timer)
}, [])
const hostKeyHead = hostKeyQueue.length > 0 ? hostKeyQueue[0] : null const hostKeyHead = hostKeyQueue.length > 0 ? hostKeyQueue[0] : null
const handleHostKeyDecision = useCallback((action: 'accept' | 'reject'): void => { const handleHostKeyDecision = useCallback(
// The queue head's decision is final (accept/reject both consume the prompt). (promptId: string, action: 'accept' | 'reject'): void => {
setHostKeyQueue((prev) => { // The head's decision is final (accept/reject both consume the prompt).
const head = prev[0] // The IPC call belongs here, not inside the updater: React may re-run an
if (!head) return prev // updater (it does so deliberately to surface impure ones), which would
window.api.respondHostKey(head.promptId, action) // answer the prompt — and log it as unanswered — more than once.
return prev.slice(1) window.api.respondHostKey(promptId, action)
}) setHostKeyQueue((prev) => prev.filter((p) => p.promptId !== promptId))
}, []) },
[]
)
/** /**
* M6: Ctrl+PgUp / Ctrl+PgDn cycle tabs. Registered on `window` with * M6: Ctrl+PgUp / Ctrl+PgDn cycle tabs. Registered on `window` with
@@ -1032,77 +1070,119 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
const handleApplyTemplate = useCallback( const handleApplyTemplate = useCallback(
async (meta: LayoutMetaLike): Promise<void> => { async (meta: LayoutMetaLike): Promise<void> => {
const raw = await window.api.getLayout(meta.id) // Overlapping applies corrupt each other: the second call would collect
if (raw == null) return // the sessions the first one just restored into `previousSessions` and
// kill them on the way out. Drop the second call instead.
// Sessions currently bound to panels — the template load replaces them. if (applyTemplateInFlightRef.current) return
const previousSessions = new Set<string>() applyTemplateInFlightRef.current = true
for (const api of [terminalApiRef.current, sshApiRef.current]) {
if (!api) continue
for (const panel of api.panels) {
const sid = sessionIdOf(panel)
if (sid) previousSessions.add(sid)
}
}
let parsed: unknown
try { try {
parsed = JSON.parse(raw) const raw = await window.api.getLayout(meta.id)
} catch (error) { if (raw == null) return
console.error('[workspace] template payload is not valid JSON', meta.id, error)
message.error(t('workspace.template.applyFailed'))
return
}
const payload = parsed as { terminal?: unknown; ssh?: unknown }
const hasDualLayout = payload !== null && typeof payload === 'object' && 'terminal' in payload && 'ssh' in payload
// Snapshot both dockviews before they are touched: a payload that // Sessions currently bound to panels — the template load replaces them.
// deserializes half-way (a template written by another build, a panel const previousSessions = new Set<string>()
// whose component no longer exists) makes dockview clear every group and for (const api of [terminalApiRef.current, sshApiRef.current]) {
// panel it built *and* the layout it replaced, and only then rethrow if (!api) continue
// ("failed to deserialize layout. Reverting changes"). The snapshots are for (const panel of api.panels) {
// the way back to the layout the user had. const sid = sessionIdOf(panel)
const terminalSnapshot = terminalApiRef.current?.toJSON() if (sid) previousSessions.add(sid)
const sshSnapshot = sshApiRef.current?.toJSON() }
}
// Which dockview the payload reached — a failed load must not put a let parsed: unknown
// snapshot back into one it never touched: restoring a dockview wipes the try {
// panels that are still alive in it (and, through `onDidRemovePanel`, parsed = JSON.parse(raw)
// kills the sessions behind them). } catch (error) {
let terminalTouched = false console.error('[workspace] template payload is not valid JSON', meta.id, error)
let sshTouched = false message.error(t('workspace.template.applyFailed'))
return
}
const payload = parsed as { terminal?: unknown; ssh?: unknown }
const hasDualLayout = payload !== null && typeof payload === 'object' && 'terminal' in payload && 'ssh' in payload
// Dual layout (M6.1+): restore each dockview from its own payload. // Snapshot both dockviews before they are touched: a payload that
// Legacy layout (pre-M6.1 single toJSON): restore it wholly into the // deserializes half-way (a template written by another build, a panel
// terminal dockview. // whose component no longer exists) makes dockview clear every group and
try { // panel it built *and* the layout it replaced, and only then rethrow
if (hasDualLayout) { // ("failed to deserialize layout. Reverting changes"). The snapshots are
terminalTouched = true // the way back to the layout the user had.
terminalApiRef.current?.fromJSON(payload.terminal as never) const terminalSnapshot = terminalApiRef.current?.toJSON()
sshTouched = true const sshSnapshot = sshApiRef.current?.toJSON()
sshApiRef.current?.fromJSON(payload.ssh as never)
} else { // Which dockview the payload reached — a failed load must not put a
// Legacy single-dockview layout → restore into the terminal workspace. // snapshot back into one it never touched: restoring a dockview wipes the
terminalTouched = true // panels that are still alive in it (and, through `onDidRemovePanel`,
terminalApiRef.current?.fromJSON((payload as unknown) as never) // kills the sessions behind them).
let terminalTouched = false
let sshTouched = false
// Dual layout (M6.1+): restore each dockview from its own payload.
// Legacy layout (pre-M6.1 single toJSON): restore it wholly into the
// terminal dockview.
try {
if (hasDualLayout) {
terminalTouched = true
terminalApiRef.current?.fromJSON(payload.terminal as never)
sshTouched = true
sshApiRef.current?.fromJSON(payload.ssh as never)
} else {
// Legacy single-dockview layout → restore into the terminal workspace.
terminalTouched = true
terminalApiRef.current?.fromJSON((payload as unknown) as never)
}
} catch (error) {
console.error('[workspace] template apply failed', meta.id, error)
// The wipe took the panels down with it — and `onDidRemovePanel` took
// their sessions, so the user is left with an empty workspace and
// nothing to reattach to. Put each touched dockview back from its
// snapshot, then give every pane that comes back a fresh session: the
// one it used to show is gone.
if (terminalTouched && terminalSnapshot) {
restoreLayoutSnapshot(terminalApiRef.current, terminalSnapshot)
if (terminalApiRef.current) await rebindRestoredPanels(terminalApiRef.current)
}
if (sshTouched && sshSnapshot) {
restoreLayoutSnapshot(sshApiRef.current, sshSnapshot)
if (sshApiRef.current) await rebindRestoredPanels(sshApiRef.current)
}
// No session may outlive its panels: the dockview the payload never
// reached still shows its own (they stay), everything else goes.
const live = new Set<string>()
for (const api of [terminalApiRef.current, sshApiRef.current]) {
for (const panel of api?.panels ?? []) {
const sid = sessionIdOf(panel)
if (sid) live.add(sid)
}
}
for (const sid of previousSessions) if (!live.has(sid)) killSession(sid)
// The panel counters and the sidebar list describe the aborted load,
// not the layout that is back; rebuild both from the panels that are
// actually here.
recountAll()
recomputeLocalPanels()
const mode = useWorkspaceModeStore.getState().mode
activePanelRef.current = apiOfMode(mode, terminalApiRef.current, sshApiRef.current)?.activePanel
message.error(t('workspace.template.applyFailedRestored'))
return
} }
} catch (error) {
console.error('[workspace] template apply failed', meta.id, error) // Fresh sessions for every restored terminal panel. Only a dockview the
// The wipe took the panels down with it — and `onDidRemovePanel` took // payload actually reached may be rebound: `rebindRestoredPanels`
// their sessions, so the user is left with an empty workspace and // rewrites each panel's params to a fresh *local* session, so running it
// nothing to reattach to. Put each touched dockview back from its // over a legacy template's untouched ssh workspace would demote its live
// snapshot, then give every pane that comes back a fresh session: the // SSH panes to local terminals.
// one it used to show is gone. if (terminalApiRef.current) await rebindRestoredPanels(terminalApiRef.current)
if (terminalTouched && terminalSnapshot) { if (sshTouched && sshApiRef.current) await rebindRestoredPanels(sshApiRef.current)
restoreLayoutSnapshot(terminalApiRef.current, terminalSnapshot)
if (terminalApiRef.current) await rebindRestoredPanels(terminalApiRef.current) // A template carries the tab titles it was saved with, which may be in
} // another language (or written by a build whose language the user has
if (sshTouched && sshSnapshot) { // since switched away from).
restoreLayoutSnapshot(sshApiRef.current, sshSnapshot) retitleAutoPanels()
if (sshApiRef.current) await rebindRestoredPanels(sshApiRef.current)
} // Old sessions are unreachable after the layout swap — kill them, but
// No session may outlive its panels: the dockview the payload never // only the ones no panel shows any more: a legacy template never reached
// reached still shows its own (they stay), everything else goes. // the ssh dockview, whose panes keep their live SSH sessions (the same
// `live` test the failure path uses).
const live = new Set<string>() const live = new Set<string>()
for (const api of [terminalApiRef.current, sshApiRef.current]) { for (const api of [terminalApiRef.current, sshApiRef.current]) {
for (const panel of api?.panels ?? []) { for (const panel of api?.panels ?? []) {
@@ -1111,32 +1191,13 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
} }
} }
for (const sid of previousSessions) if (!live.has(sid)) killSession(sid) for (const sid of previousSessions) if (!live.has(sid)) killSession(sid)
// The panel counters and the sidebar list describe the aborted load,
// not the layout that is back; rebuild both from the panels that are
// actually here.
recountAll() recountAll()
recomputeLocalPanels() recomputeLocalPanels()
const mode = useWorkspaceModeStore.getState().mode const mode = useWorkspaceModeStore.getState().mode
activePanelRef.current = apiOfMode(mode, terminalApiRef.current, sshApiRef.current)?.activePanel activePanelRef.current = apiOfMode(mode, terminalApiRef.current, sshApiRef.current)?.activePanel
message.error(t('workspace.template.applyFailedRestored')) } finally {
return applyTemplateInFlightRef.current = false
} }
// Fresh sessions for every restored terminal panel in both workspaces.
if (terminalApiRef.current) await rebindRestoredPanels(terminalApiRef.current)
if (sshApiRef.current) await rebindRestoredPanels(sshApiRef.current)
// A template carries the tab titles it was saved with, which may be in
// another language (or written by a build whose language the user has
// since switched away from).
retitleAutoPanels()
// Old sessions are unreachable after the layout swap — kill them.
for (const sid of previousSessions) killSession(sid)
recountAll()
recomputeLocalPanels()
const mode = useWorkspaceModeStore.getState().mode
activePanelRef.current = apiOfMode(mode, terminalApiRef.current, sshApiRef.current)?.activePanel
}, },
[killSession, message, rebindRestoredPanels, recountAll, recomputeLocalPanels, retitleAutoPanels] [killSession, message, rebindRestoredPanels, recountAll, recomputeLocalPanels, retitleAutoPanels]
) )
@@ -1284,7 +1345,10 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
{/* Host-key verification — head of the FIFO queue only. */} {/* Host-key verification — head of the FIFO queue only. */}
{hostKeyHead != null && ( {hostKeyHead != null && (
<HostKeyModal event={hostKeyHead} onDecision={handleHostKeyDecision} /> <HostKeyModal
event={hostKeyHead}
onDecision={(action) => handleHostKeyDecision(hostKeyHead.promptId, action)}
/>
)} )}
<SaveTemplateModal <SaveTemplateModal
+9 -1
View File
@@ -6,7 +6,8 @@ import type {
PtyDataEvent, PtyDataEvent,
PtyExitEvent, PtyExitEvent,
SessionOpenResult, SessionOpenResult,
SessionSnapshot SessionSnapshot,
SessionStateResult
} from './ipc' } from './ipc'
import type { AppSettings } from './settings' import type { AppSettings } from './settings'
import type { ReleaseNote, UpdateState } from './ipc' import type { ReleaseNote, UpdateState } from './ipc'
@@ -36,6 +37,13 @@ export interface AppApi {
openSession(opts: SessionOpenOptions): Promise<SessionOpenResult> openSession(opts: SessionOpenOptions): Promise<SessionOpenResult>
/** output a session produced before this renderer subscribed (capped ring buffer) */ /** output a session produced before this renderer subscribed (capped ring buffer) */
getSessionReplay(id: string): Promise<string> getSessionReplay(id: string): Promise<string>
/**
* Compensating query for a missed PTY_EXIT: that broadcast fires exactly once
* and is never replayed, so a pane subscribing after its shell died would
* wait forever. `exited` (with `exitCode`) is the death state PTY_EXIT would
* have set.
*/
getSessionLiveState(id: string): Promise<SessionStateResult>
writePty(id: string, data: string): void writePty(id: string, data: string): void
resizePty(id: string, cols: number, rows: number): void resizePty(id: string, cols: number, rows: number): void
killPty(id: string): void killPty(id: string): void
+14 -7
View File
@@ -5,10 +5,11 @@ import { isHighlightCategory, type HighlightRule } from './settings'
* a copy before experimenting. * a copy before experimenting.
* *
* The envelope carries a kind + version so pasting the wrong JSON (a layout, a * The envelope carries a kind + version so pasting the wrong JSON (a layout, a
* connection list) fails loudly instead of importing nonsense. Validation here * connection list, a file from a future format) fails loudly instead of
* stays deliberately light: it only rejects what cannot be a rule at all * importing nonsense. Validation here stays deliberately light: past that
* (unparseable JSON, no `rules` array, an unusable regex). Everything else is * envelope it only rejects what cannot be a rule at all (no `rules` array, an
* repaired by the settings store on save, which already owns that job. * unusable regex). Everything else is repaired by the settings store on save,
* which already owns that job.
*/ */
export const HIGHLIGHT_FILE_KIND = 'openterminal.highlight-rules' export const HIGHLIGHT_FILE_KIND = 'openterminal.highlight-rules'
@@ -27,7 +28,7 @@ export interface HighlightFile {
} }
/** Reasons a paste can be refused, as codes the UI turns into a message. */ /** Reasons a paste can be refused, as codes the UI turns into a message. */
export type ImportError = 'not-json' | 'no-rules' | 'wrong-kind' export type ImportError = 'not-json' | 'no-rules' | 'wrong-kind' | 'wrong-version'
export interface ParsedHighlightFile { export interface ParsedHighlightFile {
rules: HighlightRule[] rules: HighlightRule[]
@@ -68,10 +69,16 @@ export function parseHighlightRules(text: string): ParsedHighlightFile {
if (Array.isArray(raw)) { if (Array.isArray(raw)) {
list = raw list = raw
} else if (raw !== null && typeof raw === 'object' && Array.isArray((raw as { rules?: unknown }).rules)) { } else if (raw !== null && typeof raw === 'object' && Array.isArray((raw as { rules?: unknown }).rules)) {
const kind = (raw as { kind?: unknown }).kind // An envelope is only trusted when both stamps match: a missing `kind`
if (typeof kind === 'string' && kind !== HIGHLIGHT_FILE_KIND) { // means the JSON is not ours, and a foreign `version` means it was written
// by a format this build does not know how to read.
const { kind, version } = raw as { kind?: unknown; version?: unknown }
if (kind !== HIGHLIGHT_FILE_KIND) {
return { rules: [], warnings: [], error: 'wrong-kind' } return { rules: [], warnings: [], error: 'wrong-kind' }
} }
if (version !== HIGHLIGHT_FILE_VERSION) {
return { rules: [], warnings: [], error: 'wrong-version' }
}
list = (raw as { rules: unknown[] }).rules list = (raw as { rules: unknown[] }).rules
} else { } else {
return { rules: [], warnings: [], error: 'no-rules' } return { rules: [], warnings: [], error: 'no-rules' }
+3
View File
@@ -205,6 +205,8 @@ const settings: Record<string, string> = {
'settings.highlight.importEmpty': 'No usable rules found', 'settings.highlight.importEmpty': 'No usable rules found',
'settings.highlight.importBadJson': 'Not valid JSON', 'settings.highlight.importBadJson': 'Not valid JSON',
'settings.highlight.importWrongKind': 'This JSON is not a highlight-rule file', 'settings.highlight.importWrongKind': 'This JSON is not a highlight-rule file',
'settings.highlight.importWrongVersion': 'This JSON has an unsupported version',
'settings.highlight.importReadFailed': 'Could not read the file',
'settings.highlight.importSkipped': 'Skipped {n}: {list}', 'settings.highlight.importSkipped': 'Skipped {n}: {list}',
'settings.highlight.imported': 'Imported {n} rule(s)', 'settings.highlight.imported': 'Imported {n} rule(s)',
'settings.highlight.testText': 'Test text', 'settings.highlight.testText': 'Test text',
@@ -268,6 +270,7 @@ const settings: Record<string, string> = {
'settings.highlight.builtin.loglevel': 'Log levels', 'settings.highlight.builtin.loglevel': 'Log levels',
'settings.highlight.builtin.rootat': 'Root prompt (root@)', 'settings.highlight.builtin.rootat': 'Root prompt (root@)',
'settings.highlight.builtin.exitcode': 'Exit codes', 'settings.highlight.builtin.exitcode': 'Exit codes',
'settings.highlight.builtin.percent': 'Percentages & progress',
'settings.highlight.builtin.http': 'HTTP status codes', 'settings.highlight.builtin.http': 'HTTP status codes',
'settings.highlight.builtin.latency': 'Durations (milliseconds)', 'settings.highlight.builtin.latency': 'Durations (milliseconds)',
'settings.highlight.builtin.delop': 'Delete / move / overwrite operations', 'settings.highlight.builtin.delop': 'Delete / move / overwrite operations',
+3
View File
@@ -201,6 +201,8 @@ const settings: Record<string, string> = {
'settings.highlight.importEmpty': '有効なルールが見つかりません', 'settings.highlight.importEmpty': '有効なルールが見つかりません',
'settings.highlight.importBadJson': '有効な JSON ではありません', 'settings.highlight.importBadJson': '有効な JSON ではありません',
'settings.highlight.importWrongKind': 'この JSON はハイライトルールのファイルではありません', 'settings.highlight.importWrongKind': 'この JSON はハイライトルールのファイルではありません',
'settings.highlight.importWrongVersion': 'この JSON のバージョンは対応していません',
'settings.highlight.importReadFailed': 'ファイルを読み込めませんでした',
'settings.highlight.importSkipped': '{n} 件をスキップ:{list}', 'settings.highlight.importSkipped': '{n} 件をスキップ:{list}',
'settings.highlight.imported': '{n} 件のルールを取り込みました', 'settings.highlight.imported': '{n} 件のルールを取り込みました',
'settings.highlight.testText': 'テスト文字列', 'settings.highlight.testText': 'テスト文字列',
@@ -263,6 +265,7 @@ const settings: Record<string, string> = {
'settings.highlight.builtin.loglevel': 'ログレベル', 'settings.highlight.builtin.loglevel': 'ログレベル',
'settings.highlight.builtin.rootat': 'root プロンプト(root@)', 'settings.highlight.builtin.rootat': 'root プロンプト(root@)',
'settings.highlight.builtin.exitcode': '終了コード', 'settings.highlight.builtin.exitcode': '終了コード',
'settings.highlight.builtin.percent': 'パーセントと進捗',
'settings.highlight.builtin.http': 'HTTP ステータスコード', 'settings.highlight.builtin.http': 'HTTP ステータスコード',
'settings.highlight.builtin.latency': '所要時間(ミリ秒)', 'settings.highlight.builtin.latency': '所要時間(ミリ秒)',
'settings.highlight.builtin.delop': '削除・移動・上書きの操作', 'settings.highlight.builtin.delop': '削除・移動・上書きの操作',
+3
View File
@@ -194,6 +194,8 @@ const settings: Record<string, string> = {
'settings.highlight.importEmpty': '没有解析到可用规则', 'settings.highlight.importEmpty': '没有解析到可用规则',
'settings.highlight.importBadJson': '不是有效的 JSON', 'settings.highlight.importBadJson': '不是有效的 JSON',
'settings.highlight.importWrongKind': '这份 JSON 不是高亮规则文件', 'settings.highlight.importWrongKind': '这份 JSON 不是高亮规则文件',
'settings.highlight.importWrongVersion': '这份 JSON 的版本不受支持',
'settings.highlight.importReadFailed': '读取文件失败',
'settings.highlight.importSkipped': '已跳过 {n} 条:{list}', 'settings.highlight.importSkipped': '已跳过 {n} 条:{list}',
'settings.highlight.imported': '已导入 {n} 条规则', 'settings.highlight.imported': '已导入 {n} 条规则',
'settings.highlight.testText': '测试文本', 'settings.highlight.testText': '测试文本',
@@ -252,6 +254,7 @@ const settings: Record<string, string> = {
'settings.highlight.builtin.loglevel': '日志级别', 'settings.highlight.builtin.loglevel': '日志级别',
'settings.highlight.builtin.rootat': '管理员提示符 root@', 'settings.highlight.builtin.rootat': '管理员提示符 root@',
'settings.highlight.builtin.exitcode': '退出码', 'settings.highlight.builtin.exitcode': '退出码',
'settings.highlight.builtin.percent': '百分比与进度',
'settings.highlight.builtin.http': 'HTTP 状态码', 'settings.highlight.builtin.http': 'HTTP 状态码',
'settings.highlight.builtin.latency': '耗时(毫秒)', 'settings.highlight.builtin.latency': '耗时(毫秒)',
'settings.highlight.builtin.delop': '删除/移动/覆盖操作', 'settings.highlight.builtin.delop': '删除/移动/覆盖操作',
+3
View File
@@ -194,6 +194,8 @@ const settings: Record<string, string> = {
'settings.highlight.importEmpty': '沒有解析到可用規則', 'settings.highlight.importEmpty': '沒有解析到可用規則',
'settings.highlight.importBadJson': '不是有效的 JSON', 'settings.highlight.importBadJson': '不是有效的 JSON',
'settings.highlight.importWrongKind': '這份 JSON 不是高亮規則檔案', 'settings.highlight.importWrongKind': '這份 JSON 不是高亮規則檔案',
'settings.highlight.importWrongVersion': '這份 JSON 的版本不受支援',
'settings.highlight.importReadFailed': '讀取檔案失敗',
'settings.highlight.importSkipped': '已略過 {n} 條:{list}', 'settings.highlight.importSkipped': '已略過 {n} 條:{list}',
'settings.highlight.imported': '已匯入 {n} 條規則', 'settings.highlight.imported': '已匯入 {n} 條規則',
'settings.highlight.testText': '測試文字', 'settings.highlight.testText': '測試文字',
@@ -252,6 +254,7 @@ const settings: Record<string, string> = {
'settings.highlight.builtin.loglevel': '日誌等級', 'settings.highlight.builtin.loglevel': '日誌等級',
'settings.highlight.builtin.rootat': '管理員提示字元 root@', 'settings.highlight.builtin.rootat': '管理員提示字元 root@',
'settings.highlight.builtin.exitcode': '結束碼', 'settings.highlight.builtin.exitcode': '結束碼',
'settings.highlight.builtin.percent': '百分比與進度',
'settings.highlight.builtin.http': 'HTTP 狀態碼', 'settings.highlight.builtin.http': 'HTTP 狀態碼',
'settings.highlight.builtin.latency': '耗時(毫秒)', 'settings.highlight.builtin.latency': '耗時(毫秒)',
'settings.highlight.builtin.delop': '刪除/移動/覆蓋操作', 'settings.highlight.builtin.delop': '刪除/移動/覆蓋操作',
+17
View File
@@ -35,6 +35,13 @@ export const Ipc = {
SESSION_OPEN: 'session:open', SESSION_OPEN: 'session:open',
/** main keeps a short replay buffer per session so late subscribers catch up */ /** main keeps a short replay buffer per session so late subscribers catch up */
SESSION_REPLAY: 'session:replay', SESSION_REPLAY: 'session:replay',
/**
* Compensating query for a missed PTY_EXIT: that broadcast fires exactly once
* and is never replayed, so a pane bound to a shell that died before it
* subscribed would wait forever. Distinct from SESSION_STATE_GET/SET, which
* carry the layout snapshot.
*/
SESSION_STATE: 'session:state',
// ---- ssh connections (bookmarks) ---- // ---- ssh connections (bookmarks) ----
CONNECTIONS_LIST: 'connections:list', CONNECTIONS_LIST: 'connections:list',
@@ -216,6 +223,16 @@ export interface PtyExitEvent {
exitCode: number exitCode: number
} }
/** Answer to SESSION_STATE: what the main process still knows about a session. */
export interface SessionStateResult {
/** the session is alive and still accepts data */
exists: boolean
/** the session exited; `exitCode` then carries the status it died with */
exited: boolean
/** null unless `exited` */
exitCode: number | null
}
export interface AppInfo { export interface AppInfo {
platform: NodeJS.Platform | string platform: NodeJS.Platform | string
appVersion: string appVersion: string
+183
View File
@@ -0,0 +1,183 @@
/**
* Broadcast-store self-test (broadcast-store.mjs).
*
* `src/renderer/src/workspace/broadcastStore.ts` decides who receives a keypress
* typed into one pane, and it holds the two rules that are easy to get wrong:
*
* 1. fan-out only happens when broadcast is on AND the originating session is
* itself a target — otherwise the keypress stays in the pane it came from;
* 2. losing a target session wipes the whole selection and turns broadcast off
* (`pruneOnTargetLoss`), while *unchecking* one by hand deliberately keeps
* the remaining targets and only drops `enabled`. The two paths look alike
* and are not.
*
* `pruneOnTargetLoss` is module-private, so its behaviour is pinned through the
* only caller that can reach it (`unregisterSession`) instead of by exporting it.
* The panel registry is module-level and append-only, so each scenario uses its
* own ids and resets `enabled`/`targets` through the store's public `setState`.
* The `window.api.writePty` surface is stubbed for the write-path scenarios.
*
* The import pulls in zustand (a devDependency); no React renderer is involved —
* the store's `getState`/`setState` work on their own, which is why this test can
* import the module directly instead of going through an esbuild bundle.
* Run: node tests/broadcast-store.mjs (must exit 0)
*/
import { broadcastFanOut, useBroadcastStore, writeBroadcast } from '../src/renderer/src/workspace/broadcastStore.ts'
let failed = 0
const ok = (cond, msg) => {
console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`)
if (!cond) failed += 1
}
const store = useBroadcastStore
const state = () => store.getState()
/** Reset only the fields these scenarios drive; the panel registry is shared. */
const reset = () => store.setState({ enabled: false, targets: new Set() })
const open = (panelId, id) => state().registerSession({ id, panelId, title: id, isSsh: false })
const targets = () => [...state().targets]
const openIds = () => state().sessions.map((s) => s.id)
console.log('broadcastFanOut: who receives the keypress')
const fanOutCases = [
['a', [], false, ['a']],
['a', ['a'], false, ['a']],
['a', ['a', 'b'], false, ['a']],
['a', [], true, ['a']],
['a', ['a'], true, ['a']],
['a', ['b', 'c'], true, ['a']],
['a', ['a', 'b'], true, ['a', 'b']],
['b', ['b', 'a'], true, ['b', 'a']],
['c', ['a', 'b', 'c'], true, ['a', 'b', 'c']]
]
for (const [origin, list, enabled, expected] of fanOutCases) {
const got = broadcastFanOut(origin, new Set(list), enabled)
const same = JSON.stringify(got) === JSON.stringify(expected)
ok(
same,
`origin ${origin}, targets [${list}], enabled=${enabled} -> [${got}]${same ? '' : ` (want [${expected}])`}`
)
}
console.log('broadcastFanOut returns a fresh array')
const live = new Set(['a', 'b'])
const out = broadcastFanOut('a', live, true)
ok(Array.isArray(out) && out !== live, 'the result is not the live Set')
out.push('c')
ok(live.size === 2 && !live.has('c'), 'mutating the result does not touch the live target set')
console.log('losing a target session wipes the selection and turns broadcast off')
reset()
open('a-p1', 'a1')
open('a-p2', 'a2')
state().toggleTarget('a1')
state().toggleTarget('a2')
ok(state().enabled === true && targets().join() === 'a1,a2', 'two targets turn broadcast on')
state().unregisterSession('a-p2')
ok(state().enabled === false, 'closing the pane that showed a target turns broadcast off')
ok(targets().length === 0, 'the whole selection is dropped, not just the lost id')
ok(!openIds().includes('a2'), 'the closed session left the registry')
ok(openIds().includes('a1'), 'the session that is still open stays')
console.log('unchecking by hand keeps the rest of the selection')
reset()
open('b-p1', 'b1')
open('b-p2', 'b2')
open('b-p3', 'b3')
for (const id of ['b1', 'b2', 'b3']) state().toggleTarget(id)
ok(state().enabled === true && targets().length === 3, 'three targets turn broadcast on')
state().toggleTarget('b3')
ok(state().enabled === true && targets().join() === 'b1,b2', 'dropping to two targets keeps it on')
state().toggleTarget('b2')
ok(
state().enabled === false && targets().join() === 'b1',
'dropping below the minimum turns it off but keeps the remaining target'
)
console.log('a session mirrored in two panes survives one of them closing')
reset()
open('c-pA', 'c1')
open('c-pB', 'c1')
open('c-pC', 'c2')
ok(openIds().filter((id) => id === 'c1').length === 1, 'two panels showing one session register it once')
state().toggleTarget('c1')
state().toggleTarget('c2')
ok(state().enabled === true && targets().join() === 'c1,c2', 'c1 is mirrored, c2 is not')
state().unregisterSession('c-pA')
ok(openIds().includes('c1'), 'the mirrored session is still open through the other pane')
ok(targets().join() === 'c1,c2' && state().enabled === true, 'and keeps its target slot')
state().unregisterSession('c-pB')
ok(!openIds().includes('c1'), 'the last pane showing it takes the session out of the registry')
ok(state().enabled === false && targets().length === 0, 'losing it prunes the target set')
console.log('broadcast needs two targets to turn on')
reset()
open('d-p1', 'd1')
state().setEnabled(true)
ok(state().enabled === false, 'enabling with no targets is refused')
state().toggleTarget('d1')
ok(state().enabled === false && targets().join() === 'd1', 'a single target stays off')
state().setEnabled(true)
ok(state().enabled === false, 'still refused with one target')
open('d-p2', 'd2')
state().toggleTarget('d2')
ok(state().enabled === true, 'the second target turns it on')
state().setEnabled(false)
ok(state().enabled === false, 'it can always be turned off')
state().setEnabled(true)
ok(state().enabled === true, 'and back on while two targets remain')
console.log('unregistering an unknown or already-dropped panel is a no-op')
reset()
open('e-p1', 'e1')
open('e-p2', 'e2')
state().toggleTarget('e1')
state().toggleTarget('e2')
state().unregisterSession('e-never-registered')
ok(state().enabled === true && targets().join() === 'e1,e2', 'an unknown panel id changes nothing')
ok(openIds().includes('e1') && openIds().includes('e2'), 'and drops no session')
state().unregisterSession('e-p1')
const snapshot = JSON.stringify({ enabled: state().enabled, targets: targets(), sessions: openIds() })
state().unregisterSession('e-p1')
ok(
JSON.stringify({ enabled: state().enabled, targets: targets(), sessions: openIds() }) === snapshot,
'a second unregister of the same panel changes nothing'
)
console.log('writeBroadcast fans out through the live registry')
reset()
open('f-p1', 'f1')
open('f-p2', 'f2')
state().toggleTarget('f1')
state().toggleTarget('f2')
const writes = []
globalThis.window = { api: { writePty: (id, data) => writes.push(`${id}:${data}`) } }
try {
writeBroadcast('f1', 'ls')
ok(writes.join(' ') === 'f1:ls f2:ls', 'an enabled origin that is a target reaches every target')
writes.length = 0
store.setState({ targets: new Set(['f1', 'f2', 'f-ghost']) })
writeBroadcast('f1', 'x')
ok(writes.join(' ') === 'f1:x f2:x', 'a target with no open session is skipped')
writes.length = 0
store.setState({ enabled: false })
writeBroadcast('f2', 'y')
ok(writes.join(' ') === 'f2:y', 'with broadcast off only the origin is written')
writes.length = 0
store.setState({ enabled: true, targets: new Set(['f1', 'f2']) })
open('f-p3', 'f3')
writeBroadcast('f3', 'q')
ok(writes.join(' ') === 'f3:q', 'an open origin that is not a target writes only to itself')
writes.length = 0
store.setState({ enabled: false })
writeBroadcast('f-gone', 'w')
ok(writes.length === 0, 'a closed origin writes nowhere')
} finally {
delete globalThis.window
}
if (failed > 0) {
console.error(`\n[broadcast-store] ${failed} check(s) FAILED`)
process.exit(1)
}
console.log('\n[broadcast-store] ALL CHECKS PASSED')
+45
View File
@@ -236,6 +236,21 @@ const expected =
'partial-tail' 'partial-tail'
ok(readFileSync(startB.file, 'utf8') === expected, 'burst writes + stop tail land in order') ok(readFileSync(startB.file, 'utf8') === expected, 'burst writes + stop tail land in order')
// The stop tail is flushed *synchronously* when no async append is in flight:
// the quit path (killAllPtys → safeStopLog → logStop) has no later sync point,
// so a fresh appendFile chain may never run and the tail would be lost. The
// drain for the committed line below has long settled, so nothing can race it.
const sid3 = 'cccccccc-dddd-eeee-ffff-000000000000'
const startC = store.logStart(sid3)
store.logWrite(sid3, 'settled line\n')
await wait(100)
store.logWrite(sid3, 'no trailing newline')
store.logStop(sid3)
ok(
readFileSync(startC.file, 'utf8') === 'settled line\nno trailing newline',
'the stop tail is on disk when logStop returns (sync flush when the drain is idle)'
)
// ---- 7. index.json path containment (hydrateIndex) ----------------------------- // ---- 7. index.json path containment (hydrateIndex) -----------------------------
// index.json is data, not trust: a tampered `file` value must never turn // index.json is data, not trust: a tampered `file` value must never turn
// logWrite into an arbitrary-path append. Only entries that resolve inside // logWrite into an arbitrary-path append. Only entries that resolve inside
@@ -366,6 +381,36 @@ ok(!existsSync(join(freshDir, 'commands.json.bak')), 'a missing file (ENOENT) is
ok(existsSync(join(freshDir, 'commands.json')), 'and the first write lands normally') ok(existsSync(join(freshDir, 'commands.json')), 'and the first write lands normally')
rmSync(freshDir, { recursive: true, force: true }) rmSync(freshDir, { recursive: true, force: true })
// ---- 10. Valid JSON of the wrong shape is backed up too -------------------------
// `[1,2,3]` parses fine, so the parse-catch never saw it: the file used to be
// treated exactly like a missing one and the next recordCommand replaced it with
// an empty history. A file that is not the store we wrote is unreadable, not
// empty — same backup + empty-state exit as the corrupt case above.
const shapeDir = mkdtempSync(join(tmpdir(), 'm5-cmd-shape-'))
const shapeFile = join(shapeDir, 'commands.json')
const shapeStore = new commandsMod.CommandsStore(shapeDir)
const wrongShape = '[1,2,3]'
writeFileSync(shapeFile, wrongShape, 'utf8')
ok(shapeStore.listHistory().length === 0, 'a wrong-shaped commands.json loads as an empty history')
const shapeBak = `${shapeFile}.bak`
ok(existsSync(shapeBak), 'a wrong-shaped commands.json is backed up to .bak')
ok(readFileSync(shapeBak, 'utf8') === wrongShape, '.bak holds the wrong-shaped original byte for byte')
writeSettings({ historyLimit: 50, historyEnabled: true })
shapeStore.recordCommand('after-shape-mismatch')
const rewrittenShape = JSON.parse(readFileSync(shapeFile, 'utf8'))
ok(
Array.isArray(rewrittenShape.history) &&
rewrittenShape.history.some((h) => h.command === 'after-shape-mismatch'),
'the store recovers with a well-formed file after the backup'
)
// An object without the expected keys is the same class of problem, and a second
// episode must not overwrite the first backup.
writeFileSync(shapeFile, '{"library":[]}', 'utf8')
shapeStore.listHistory()
ok(readFileSync(shapeBak, 'utf8') === wrongShape, 'an existing .bak is kept for a wrong shape too (earliest evidence wins)')
rmSync(shapeDir, { recursive: true, force: true })
// All stores wrote into temp dirs; drop them so repeated runs do not litter. // All stores wrote into temp dirs; drop them so repeated runs do not litter.
rmSync(userData, { recursive: true, force: true }) rmSync(userData, { recursive: true, force: true })
+36
View File
@@ -205,6 +205,42 @@ ok(!existsSync(join(freshDir, 'connections.json.bak')), 'a missing file (ENOENT)
ok(existsSync(join(freshDir, 'connections.json')), 'and the first write lands normally') ok(existsSync(join(freshDir, 'connections.json')), 'and the first write lands normally')
rmSync(freshDir, { recursive: true, force: true }) rmSync(freshDir, { recursive: true, force: true })
// ---- 7b. Valid JSON of the wrong shape is backed up too --------------------------
// `{}` parses fine, so the parse-catch never saw it: the file used to be treated
// exactly like a missing one and the next write replaced every bookmark with an
// empty list. A file that is not the array we wrote is unreadable, not empty —
// same backup + empty-state exit as the corrupt case above.
const shapeDir = mkdtempSync(join(tmpdir(), 'm-conn-shape-'))
const shapeFile = join(shapeDir, 'connections.json')
const shapeStore = new mod.ConnectionsStore(shapeFile)
const wrongShape = '{"connections":[]}'
writeFileSync(shapeFile, wrongShape, 'utf8')
ok(shapeStore.listConnections().length === 0, 'a wrong-shaped connections.json loads as an empty list')
const shapeBak = `${shapeFile}.bak`
ok(existsSync(shapeBak), 'a wrong-shaped connections.json is backed up to .bak')
ok(readFileSync(shapeBak, 'utf8') === wrongShape, '.bak holds the wrong-shaped original byte for byte')
const shapeSaved = shapeStore.saveConnection({
name: 'after-shape-mismatch',
host: 'h',
port: 22,
username: 'u',
auth: 'password',
askPasswordAtConnect: false,
askPassphraseAtConnect: false,
keepaliveIntervalSec: 0
})
const shapeList = JSON.parse(readFileSync(shapeFile, 'utf8'))
ok(
Array.isArray(shapeList) && shapeList.length === 1 && shapeList[0].id === shapeSaved.id,
'the new bookmark is written normally after the backup'
)
// A second wrong-shape episode must not overwrite the first backup.
writeFileSync(shapeFile, '{"nope":true}', 'utf8')
shapeStore.listConnections()
ok(readFileSync(shapeBak, 'utf8') === wrongShape, 'an existing .bak is kept (earliest evidence wins)')
rmSync(shapeDir, { recursive: true, force: true })
// An unreadable path (here: a directory) must still load as empty and never // An unreadable path (here: a directory) must still load as empty and never
// throw — the backup is best effort and may itself fail. // throw — the backup is best effort and may itself fail.
const dirCase = mkdtempSync(join(tmpdir(), 'm-conn-dir-')) const dirCase = mkdtempSync(join(tmpdir(), 'm-conn-dir-'))
+22 -1
View File
@@ -15,7 +15,13 @@
import { compileRules, applyHighlights, __testHooks as hooks } from '../src/renderer/src/terminal/highlightEngine.ts' import { compileRules, applyHighlights, __testHooks as hooks } from '../src/renderer/src/terminal/highlightEngine.ts'
import { previewSpans } from '../src/renderer/src/terminal/highlightEngine.ts' import { previewSpans } from '../src/renderer/src/terminal/highlightEngine.ts'
import { DEFAULT_HIGHLIGHT_RULES } from '../src/shared/settings.ts' import { DEFAULT_HIGHLIGHT_RULES } from '../src/shared/settings.ts'
import { exportHighlightRules, mergeRules, parseHighlightRules } from '../src/shared/highlightIO.ts' import {
exportHighlightRules,
HIGHLIGHT_FILE_KIND,
HIGHLIGHT_FILE_VERSION,
mergeRules,
parseHighlightRules
} from '../src/shared/highlightIO.ts'
import { import {
excludedByProfile, excludedByProfile,
rulesForProfile, rulesForProfile,
@@ -549,11 +555,26 @@ console.log('[import / export]')
parseHighlightRules('{"kind":"openterminal.layout","rules":[]}').error === 'wrong-kind', parseHighlightRules('{"kind":"openterminal.layout","rules":[]}').error === 'wrong-kind',
'a foreign envelope is refused' 'a foreign envelope is refused'
) )
ok(
parseHighlightRules('{"rules":[]}').error === 'wrong-kind',
'an envelope with no kind at all is refused'
)
ok(
parseHighlightRules(JSON.stringify({ kind: HIGHLIGHT_FILE_KIND, version: HIGHLIGHT_FILE_VERSION + 1, rules: [] }))
.error === 'wrong-version',
'an envelope from another version is refused'
)
ok(
parseHighlightRules(JSON.stringify({ kind: HIGHLIGHT_FILE_KIND, rules: [] })).error === 'wrong-version',
'an envelope with no version at all is refused'
)
ok(parseHighlightRules('{"nope":1}').error === 'no-rules', 'JSON without a rules array is refused') ok(parseHighlightRules('{"nope":1}').error === 'no-rules', 'JSON without a rules array is refused')
ok(parseHighlightRules('[]').error === undefined, 'a bare empty array is accepted') ok(parseHighlightRules('[]').error === undefined, 'a bare empty array is accepted')
const partial = parseHighlightRules( const partial = parseHighlightRules(
JSON.stringify({ JSON.stringify({
kind: HIGHLIGHT_FILE_KIND,
version: HIGHLIGHT_FILE_VERSION,
rules: [ rules: [
{ pattern: '\\bOK\\b', priority: 500, color: { fg: 'red' } }, { pattern: '\\bOK\\b', priority: 500, color: { fg: 'red' } },
{ pattern: '[' }, { pattern: '[' },
+21
View File
@@ -94,6 +94,27 @@ console.log('[highlight rules]')
const out = (await roundTrip({ highlightRules: [] })).highlightRules const out = (await roundTrip({ highlightRules: [] })).highlightRules
ok(out.length === 0, 'an explicitly empty rule list stays empty') ok(out.length === 0, 'an explicitly empty rule list stays empty')
} }
{
// A non-array highlightRules is a shape error, not "no rules": the built-in
// rules come back as copies (mutating a loaded list must not poison the
// module-level preset table) and the fallback is recorded in the warnings log.
writeFileSync(
join(userData, 'settings.json'),
JSON.stringify({ highlightRules: { nope: true } }),
'utf8'
)
const first = store.loadSettings().highlightRules
ok(first.length > 0, `a non-array highlightRules loads the built-in rules (got ${first.length})`)
const second = store.loadSettings().highlightRules
ok(first !== second, 'each load hands out its own array, not the shared preset list')
first.length = 0
const afterMutation = store.loadSettings().highlightRules
ok(afterMutation.length > 0, 'emptying a loaded rule list does not affect the next load')
const log = existsSync(join(userData, 'settings-warnings.log'))
? readFileSync(join(userData, 'settings-warnings.log'), 'utf8')
: ''
ok(log.includes('highlightRules: not an array'), 'the fallback is recorded in settings-warnings.log')
}
// ---- 3. value bands --------------------------------------------------------- // ---- 3. value bands ---------------------------------------------------------
console.log('[value bands]') console.log('[value bands]')
+78 -2
View File
@@ -51,8 +51,9 @@ sftp.setLocalPathPolicy({
/** /**
* A fake ssh2 SFTPWrapper whose per-call behavior is scripted. * A fake ssh2 SFTPWrapper whose per-call behavior is scripted.
* `behaviour(op, args)` returns `'silent'` (never calls back — the dead-channel * `behaviour(op, args)` returns `'silent'` (never calls back — the dead-channel
* case), `'error'` (calls back with an error), or `'ok'` (calls back with * case), `'enoent'` (calls back with the server's "no such file"), `'error'`
* `result`). Every call is recorded so the test can assert on what was opened. * (calls back with a generic error), or `'ok'` (calls back with `result`). Every
* call is recorded so the test can assert on what was opened.
*/ */
const defaultStat = () => ({ const defaultStat = () => ({
isDirectory: () => false, isDirectory: () => false,
@@ -63,6 +64,9 @@ const defaultStat = () => ({
gid: 1000 gid: 1000
}) })
/** SSH_FX_NO_SUCH_FILE as ssh2 surfaces it: a numeric status on an Error. */
const noSuchFile = () => Object.assign(new Error('No such file'), { code: 2 })
let calls let calls
let behaviour let behaviour
let openedChannels let openedChannels
@@ -101,6 +105,7 @@ const makeWrapper = () => {
calls.push({ op, args }) calls.push({ op, args })
const verdict = behaviour(op, args) const verdict = behaviour(op, args)
if (verdict === 'silent') return if (verdict === 'silent') return
if (verdict === 'enoent') return cb(noSuchFile())
if (verdict === 'error') return cb(new Error(`${op} failed`)) if (verdict === 'error') return cb(new Error(`${op} failed`))
cb(null, voidResult ? undefined : result) cb(null, voidResult ? undefined : result)
} }
@@ -433,6 +438,77 @@ console.log('default budgets are sane relative to each other')
ok(elapsed < 3000, `the check itself was quick (${elapsed}ms)`) ok(elapsed < 3000, `the check itself was quick (${elapsed}ms)`)
} }
// ---- 7. a delete is idempotent: "already gone" is success ------------------
console.log('deleting an entry that is already gone succeeds instead of reporting a failure')
{
// The file may have been removed by someone else between the listing and the
// click, or the same delete may be run again after an earlier pass already
// removed it. "No such file" means the end state the user asked for already
// holds, so it must not surface as "delete failed".
reset((op) => (op === 'lstat' || op === 'unlink' ? 'enoent' : 'ok'))
sftp.closeSftp('del-gone')
const missing = await expectReject(sftp.deleteRemote('del-gone', ['/remote/gone.txt']), 'deleteRemote')
ok(!missing.rejected, `a missing file is not a delete failure (${missing.message})`)
// The tolerance is for that one status only: a real unlink failure still has
// to reach the user.
reset((op) => (op === 'unlink' ? 'error' : 'ok'))
sftp.closeSftp('del-denied')
const denied = await expectReject(sftp.deleteRemote('del-denied', ['/remote/x.txt']), 'deleteRemote')
ok(denied.rejected, 'a genuine unlink failure is still reported')
// The other shape of the same status: no numeric code, only the library's
// English message.
reset(() => 'ok')
sftp.registerSftpClientProvider(() => ({
sftp: (cb) => {
const w = makeWrapper()
w.unlink = (p, cb) => {
calls.push({ op: 'unlink', args: [p] })
cb(new Error('No such file'))
}
openedChannels.push(w)
cb(null, w)
}
}))
sftp.closeSftp('del-gone-msg')
const byMessage = await expectReject(sftp.deleteRemote('del-gone-msg', ['/remote/gone.txt']), 'deleteRemote')
ok(!byMessage.rejected, `the message-only form is tolerated too (${byMessage.message})`)
// The recursive shapes: the directory itself is gone, and a tree whose entries
// were already removed reports the same status from unlink and rmdir.
reset((op, args) => (op === 'readdir' && args[0] === '/remote/dir' ? 'enoent' : 'ok'))
sftp.registerSftpClientProvider(() => ({
sftp: (cb) => {
const w = makeWrapper()
// Only the top-level path is a directory, or the children would recurse.
w.lstat = (p, cb) => {
calls.push({ op: 'lstat', args: [p] })
cb(null, { ...defaultStat(), isDirectory: () => p === '/remote/dir' })
}
openedChannels.push(w)
cb(null, w)
}
}))
sftp.closeSftp('del-dir-gone')
const dirGone = await expectReject(sftp.deleteRemote('del-dir-gone', ['/remote/dir']), 'deleteRemote')
ok(!dirGone.rejected, `a directory that no longer exists is not a failure (${dirGone.message})`)
reset((op) => (op === 'unlink' || op === 'rmdir' ? 'enoent' : 'ok'))
sftp.closeSftp('del-dir-partial')
const dirPartial = await expectReject(sftp.deleteRemote('del-dir-partial', ['/remote/dir']), 'deleteRemote')
ok(!dirPartial.rejected, `a tree whose entries were already removed is not a failure (${dirPartial.message})`)
// Restore the standard provider for any later section.
sftp.registerSftpClientProvider(() => ({
sftp: (cb) => {
const w = makeWrapper()
openedChannels.push(w)
cb(null, w)
}
}))
}
// ---- helpers ---------------------------------------------------------------- // ---- helpers ----------------------------------------------------------------
function waitFor(pred, timeoutMs) { function waitFor(pred, timeoutMs) {
return new Promise((resolve) => { return new Promise((resolve) => {
+171
View File
@@ -0,0 +1,171 @@
/**
* Working-directory tracking self-test (terminal-cwd.mjs).
*
* `src/renderer/src/terminal/cwdTracker.ts` is the renderer half of cwd memory:
* it turns a submitted command line into a cd argument (or null) and unwraps
* ConEmu's OSC 9 payload. Everything platform-specific — path resolution,
* existence checks, quote stripping, `cd -` rejection — lives in the main
* process (`src/main/cwd.ts`), so the contract pinned here is deliberately
* narrow, and the table says which side owns what:
*
* - the cd family per shell (cd / chdir / sl / Set-Location / pushd /
* Push-Location) and the bare `d:` drive switch;
* - an EMPTY STRING means "bare cd" (home) and is NOT the same answer as
* null ("not a cd line") — the caller only resolves on non-null;
* - quotes and trailing arguments are handed to main verbatim: this module is
* not a shell parser, so `cd "D:\Program Files"` keeps its quotes and
* `cd /tmp && ls` keeps its separator;
* - lookalikes (`cdrom`, `cdx`, `sleep 1`, `VAR=x cd y`) never match, since a
* false positive would move the remembered directory on an unrelated line.
*
* Unlike the esbuild-bundled tests, this one imports the .ts module directly:
* it has no imports and no runtime-only TS syntax, and Node >= 22.18 strips the
* type annotations itself. Run: node tests/terminal-cwd.mjs (must exit 0)
*/
import { cdArgument, conemuCwd } from '../src/renderer/src/terminal/cwdTracker.ts'
let failed = 0
const ok = (cond, msg) => {
console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`)
if (!cond) failed += 1
}
/** Table-driven: [line, expected] — `null` = "not a cd line", `''` = bare cd. */
const table = (label, cases, fn) => {
console.log(label)
for (const [input, expected] of cases) {
const got = fn(input)
ok(
got === expected,
`${JSON.stringify(input)} -> ${JSON.stringify(got)}${got === expected ? '' : ` (want ${JSON.stringify(expected)})`}`
)
}
}
table(
'the cd family returns the raw argument (main does the path work)',
[
['cd /path', '/path'],
['cd\t/tmp', '/tmp'],
['cd\n/tmp', '/tmp'],
['cd ~/x', '~/x'],
['cd ../x', '../x'],
['cd -', '-'],
['cd .', '.'],
['cd D:\\projects', 'D:\\projects'],
['cd "D:\\Program Files"', '"D:\\Program Files"'],
["cd '~/src'", "'~/src'"],
['cd ""', '""']
],
cdArgument
)
table(
'every shell spelling is recognised, case-insensitively',
[
['CD /tmp', '/tmp'],
['Cd /tmp', '/tmp'],
[' cD /tmp ', '/tmp'],
['chdir C:\\x', 'C:\\x'],
['CHDIR ..', '..'],
['sl .', '.'],
['sl', ''],
['Set-Location /a', '/a'],
['set-location /a', '/a'],
['pushd ../b', '../b'],
['Push-Location ~/c', '~/c']
],
cdArgument
)
table(
'bare cd is the empty-string sentinel, not null',
[
['cd', ''],
['cd ', ''],
[' cd ', ''],
['cd\t', '']
],
cdArgument
)
table(
'trailing arguments and separators stay in the argument',
[
['cd /tmp ', '/tmp'],
['cd /a b', '/a b'],
['cd /tmp extra', '/tmp extra'],
['cd /tmp && ls', '/tmp && ls'],
['cd /tmp; ls', '/tmp; ls'],
['cd /tmp | more', '/tmp | more']
],
cdArgument
)
table(
'the bare drive switch resolves to the drive root',
[
['d:', 'D:\\'],
['D:', 'D:\\'],
[' d: ', 'D:\\'],
['a:', 'A:\\'],
['z:', 'Z:\\']
],
cdArgument
)
table(
'lines that are not a cd are null (no false positives)',
[
['', null],
[' ', null],
['cdx /tmp', null],
['cdrom', null],
['cdemo /x', null],
['slack', null],
['sleep 1', null],
['VAR=x cd y', null],
['sudo cd /x', null],
['echo cd /x', null],
['ls -la', null],
['d:\\', null],
['..', null],
['~', null]
],
cdArgument
)
console.log('the empty-string answer is distinct from "not a cd line"')
ok(cdArgument('cd') === '', 'a bare cd answers with the empty string')
ok(cdArgument('cd') !== null, 'and is not null, so the caller still resolves it')
ok(cdArgument('ls') === null, 'a non-cd line answers null')
table(
'ConEmu OSC 9;9 carries a bare path',
[
['9;/home/a', '/home/a'],
['9;C:\\Users\\a', 'C:\\Users\\a'],
['9;C:\\Program Files', 'C:\\Program Files'],
['9;/a\n', '/a'],
['9; /a ', '/a'],
['9; ', null],
['9;', null],
['9', null],
['90;x', null],
['9;;x', ';x'],
['', null],
['7;file://host/p', null],
['8;/a', null]
],
conemuCwd
)
console.log('OSC 9 payloads that are not ConEmu cwd reports are rejected')
ok(conemuCwd('9;1;2') === '1;2', 'the remainder after the convention marker is the path')
ok(conemuCwd('9;file://host/p') === 'file://host/p', 'a file:// body is passed through for main to normalize')
if (failed > 0) {
console.error(`\n[terminal-cwd] ${failed} check(s) FAILED`)
process.exit(1)
}
console.log('\n[terminal-cwd] ALL CHECKS PASSED')
+200
View File
@@ -0,0 +1,200 @@
/**
* Clickable-URL self-test (terminal-links.mjs).
*
* `src/renderer/src/terminal/urlLinks.ts` feeds xterm's link provider: it scans
* one *logical* line (the provider rebuilds it across wrapped rows first) and
* returns half-open `[start, end)` ranges plus a scheme-carrying target. The
* ranges are what the terminal underlines and what a click opens, so the table
* pins the real contract rather than an idealised one:
*
* - `http(s)://` and `ftp://` are handed over as written (the match is
* case-insensitive, the target keeps the original case); `www.…`,
* `localhost:port`, `127.0.0.1:port` and `0.0.0.0:port` get an `http://`
* target added;
* - a bare host WITHOUT a port is not a link, and a bare domain without `www.`
* never is — the two shapes that would otherwise make every `foo.com` in
* build output clickable;
* - trailing sentence punctuation (`. , ; : ! ? ) ] } > ' "` plus the CJK
* forms) is stripped from the URL *and* from `end`, so the closing bracket
* of a sentence is not underlined. Brackets, quotes and whitespace cannot
* appear inside the body at all;
* - ranges never overlap: `http://localhost:5000` also matches the bare-host
* pattern, and the longer, scheme-carrying match wins;
* - the body is greedy up to the next whitespace, so `http://a/http://b` is
* one link — a documented limitation, not an accident to be "fixed" here.
*
* Direct .ts import (no esbuild bundle needed): the module has no imports and no
* runtime-only TS syntax, and Node >= 22.18 strips types itself.
* Run: node tests/terminal-links.mjs (must exit 0)
*/
import { findUrls } from '../src/renderer/src/terminal/urlLinks.ts'
let failed = 0
const ok = (cond, msg) => {
console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`)
if (!cond) failed += 1
}
/** [start, end, url] triples — compact enough to keep the tables readable. */
const triples = (text) => findUrls(text).map((l) => [l.start, l.end, l.url])
const table = (label, cases) => {
console.log(label)
for (const [text, expected] of cases) {
const got = triples(text)
ok(
JSON.stringify(got) === JSON.stringify(expected),
`${JSON.stringify(text)} -> ${JSON.stringify(got)}${JSON.stringify(got) === JSON.stringify(expected) ? '' : ` (want ${JSON.stringify(expected)})`}`
)
}
}
table('fully qualified http/https URLs are handed over as written', [
['https://example.com', [[0, 19, 'https://example.com']]],
['http://example.com/a/b?c=1#frag', [[0, 31, 'http://example.com/a/b?c=1#frag']]],
['see https://example.com/a.', [[4, 25, 'https://example.com/a']]],
['https://user:pass@host.com:8443/x', [[0, 33, 'https://user:pass@host.com:8443/x']]],
['\t\thttps://example.com/x', [[2, 23, 'https://example.com/x']]]
])
table('matching is case-insensitive, the target keeps the original case', [
['HTTP://EXAMPLE.COM', [[0, 18, 'HTTP://EXAMPLE.COM']]],
['Visit HTTPS://Example.COM/Path', [[6, 30, 'HTTPS://Example.COM/Path']]]
])
table('trailing sentence punctuation is not part of the link', [
['go to https://example.com.', [[6, 25, 'https://example.com']]],
['https://example.com, then', [[0, 19, 'https://example.com']]],
['https://example.com/a; ls', [[0, 21, 'https://example.com/a']]],
['https://example.com/a,;:!?', [[0, 21, 'https://example.com/a']]],
['(https://example.com/a)', [[1, 22, 'https://example.com/a']]],
['<https://example.com/a>', [[1, 22, 'https://example.com/a']]],
['"https://example.com/a"', [[1, 22, 'https://example.com/a']]],
["'https://example.com/a'", [[1, 22, 'https://example.com/a']]],
['[https://example.com/a]', [[1, 22, 'https://example.com/a']]],
['https://example.com/a).', [[0, 21, 'https://example.com/a']]]
])
table('CJK punctuation ends the link too', [
['中文 https://example.com/路径。然后', [[3, 25, 'https://example.com/路径']]],
['见 https://example.com/路径,还有', [[2, 24, 'https://example.com/路径']]],
['https://example.com/a、b', [[0, 21, 'https://example.com/a']]]
])
table('several URLs on one line keep their own offsets', [
[
'go to http://a.com and https://b.org/x?y=1!',
[
[6, 18, 'http://a.com'],
[23, 42, 'https://b.org/x?y=1']
]
],
[
'x https://a.com https://a.com y',
[
[2, 15, 'https://a.com'],
[16, 29, 'https://a.com']
]
]
])
table('overlapping matches collapse to the longest one', [
['http://localhost:5000', [[0, 21, 'http://localhost:5000']]],
['http://localhost:5000/x', [[0, 23, 'http://localhost:5000/x']]],
['localhost:3000 http://localhost:3000', [
[0, 14, 'http://localhost:3000'],
[15, 36, 'http://localhost:3000']
]],
['http://127.0.0.1:8080/x', [[0, 23, 'http://127.0.0.1:8080/x']]]
])
table('ftp is recognised as a scheme of its own', [
['ftp://files.example.com/pub', [[0, 27, 'ftp://files.example.com/pub']]],
['ftp://files.example.com/pub.', [[0, 27, 'ftp://files.example.com/pub']]]
])
table('scheme-less dev-server forms get an http:// target', [
['localhost:5000', [[0, 14, 'http://localhost:5000']]],
['localhost:5000/x', [[0, 16, 'http://localhost:5000/x']]],
[' Listening on http://0.0.0.0:3000', [[15, 34, 'http://0.0.0.0:3000']]],
['127.0.0.1:8080/x', [[0, 16, 'http://127.0.0.1:8080/x']]],
['www.example.com/path.', [[0, 20, 'http://www.example.com/path']]],
['WWW.EXAMPLE.COM', [[0, 15, 'http://WWW.EXAMPLE.COM']]],
['see www.example.com:8080', [[4, 24, 'http://www.example.com:8080']]]
])
table('shapes that must NOT become links', [
['no links here', []],
['', []],
['a.com', []],
['localhost', []],
['0.0.0.0', []],
['127.0.0.1', []],
['localhost:', []],
['xhttps://a.com', []],
['https://', []],
['mailto:user@example.com', []],
['file:///c:/x', []],
['ssh://git@host/repo', []],
['C:\\Users\\me', []],
['www.', []],
['www.example', []]
])
table('documented greedy behaviour: the body runs to the next whitespace', [
['http://a.com/http://b.com', [[0, 25, 'http://a.com/http://b.com']]],
['https://a.com/x\tand', [[0, 15, 'https://a.com/x']]],
['https://a.com/x and', [[0, 15, 'https://a.com/x']]]
])
console.log('the optional path group of a bare host needs a body character')
ok(
JSON.stringify(triples('localhost:5000/')) === JSON.stringify([[0, 14, 'http://localhost:5000']]),
'a trailing slash with nothing behind it is left out of the range'
)
console.log('every range is consistent with its own text')
const corpus = [
'go to http://a.com and https://b.org/x?y=1!',
'(https://a.com/a)',
'http://a.com/http://b.com',
'plain output, no links',
'http://localhost:5000/x and www.example.com/y, then https://c.jp/z。',
'ftp://f.io/a https://g.io/b',
'https://example.com/path).',
'localhost:3000/',
'https://'
]
for (const text of corpus) {
const found = findUrls(text)
const ranges = found.map((l) => `${l.start}-${l.end}`)
ok(
found.every((l, i) => i === 0 || found[i - 1].end <= l.start),
`${JSON.stringify(text)}: ranges do not overlap (${ranges.join(' ')})`
)
ok(
found.every((l) => l.start >= 0 && l.end <= text.length && l.end > l.start),
`${JSON.stringify(text)}: every range is inside the line and non-empty`
)
ok(
found.every((l) => l.url.length === l.end - l.start || l.url.endsWith(text.slice(l.start, l.end))),
`${JSON.stringify(text)}: the target matches the range it covers`
)
ok(
found.every((l) => /^[a-z][a-z0-9+.-]*:\/\//i.test(l.url)),
`${JSON.stringify(text)}: every target carries a scheme`
)
}
console.log('the target of a bare host is the matched text behind http://')
ok(findUrls('localhost:5000')[0].url === 'http://localhost:5000', 'the matched text is kept verbatim behind the scheme')
ok(
findUrls('go to https://example.com.')[0].url === 'https://example.com',
'a scheme URL is not prefixed a second time'
)
if (failed > 0) {
console.error(`\n[terminal-links] ${failed} check(s) FAILED`)
process.exit(1)
}
console.log('\n[terminal-links] ALL CHECKS PASSED')