Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3529bb9dc | ||
|
|
12ee779040 | ||
|
|
64982993f3 | ||
|
|
6a218ec6c0 | ||
|
|
172cce1962 | ||
|
|
e23010f950 | ||
|
|
81ac112dac | ||
|
|
0289dcbd1d |
No files matched your search
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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-*/
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
## 关键词高亮
|
## 关键词高亮
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ファイル管理
|
||||||
|
|||||||
@@ -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 文件管理
|
||||||
|
|||||||
@@ -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 檔案管理
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Generated
+2
-2
@@ -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
@@ -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"
|
||||||
|
|||||||
+51
-11
@@ -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')
|
||||||
}
|
}
|
||||||
|
|||||||
+86
-6
@@ -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)) {
|
||||||
|
// 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)
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
+135
-3
@@ -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,7 +322,15 @@ 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
|
||||||
|
// session entry, so this is the only handle an owner-based kill has on it.
|
||||||
|
const tempId = randomUUID()
|
||||||
|
const pending: PendingOpen = { owner, aborted: false }
|
||||||
|
pendingOpens.set(tempId, pending)
|
||||||
|
|
||||||
|
let handle: SshSessionHandle
|
||||||
|
try {
|
||||||
|
handle = await connectSsh(conn, opts.secretOverride, {
|
||||||
connections: {
|
connections: {
|
||||||
getSecret: (c, field) => deps.getSecret(c, field),
|
getSecret: (c, field) => deps.getSecret(c, field),
|
||||||
touch: (id: string) => {
|
touch: (id: string) => {
|
||||||
@@ -272,8 +344,39 @@ export async function openSession(opts: SessionOpenOptions, owner?: number): Pro
|
|||||||
},
|
},
|
||||||
knownHosts: deps.knownHosts,
|
knownHosts: deps.knownHosts,
|
||||||
broadcast: deps.broadcast,
|
broadcast: deps.broadcast,
|
||||||
promptHostKey: deps.promptHostKey
|
promptHostKey: deps.promptHostKey,
|
||||||
|
// Earliest reference to the client, so an abort can end it mid-handshake.
|
||||||
|
onClient: (client) => {
|
||||||
|
pending.client = client
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
} catch (err) {
|
||||||
|
// An aborted attempt is our own teardown, not a connect failure: whoever
|
||||||
|
// 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
@@ -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 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadSettings(): AppSettings {
|
/**
|
||||||
try {
|
* The defaults loadSettings falls back to. Fresh objects every call so a caller
|
||||||
const raw: unknown = JSON.parse(readFileSync(settingsPath(), 'utf8'))
|
* mutating what it got back cannot poison the module-level presets.
|
||||||
const { settings, errors } = deepMerge(raw)
|
*/
|
||||||
reportWarnings(errors)
|
function defaultSettings(): AppSettings {
|
||||||
return settings
|
|
||||||
} catch (err) {
|
|
||||||
backupUnparseableSettings(err)
|
|
||||||
return {
|
return {
|
||||||
terminal: { ...DEFAULT_SETTINGS.terminal },
|
terminal: { ...DEFAULT_SETTINGS.terminal },
|
||||||
customThemes: [...DEFAULT_SETTINGS.customThemes],
|
customThemes: [...DEFAULT_SETTINGS.customThemes],
|
||||||
highlightRules: DEFAULT_HIGHLIGHT_RULES.map((rule) => ({ ...rule })),
|
highlightRules: copyDefaultRules(),
|
||||||
highlightProfiles: [],
|
highlightProfiles: [],
|
||||||
system: { ...DEFAULT_SYSTEM },
|
system: { ...DEFAULT_SYSTEM },
|
||||||
lock: { ...DEFAULT_SETTINGS.lock }
|
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 {
|
||||||
|
try {
|
||||||
|
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)
|
||||||
|
reportWarnings(errors)
|
||||||
|
return settings
|
||||||
|
} catch (err) {
|
||||||
|
return discardUnparseableSettings(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+50
-4
@@ -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
@@ -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
@@ -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
@@ -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
|
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)
|
||||||
|
}
|
||||||
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
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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> => {
|
||||||
|
try {
|
||||||
await window.api.clearHistory()
|
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> => {
|
||||||
|
try {
|
||||||
await window.api.deleteLibraryItem(id)
|
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> => {
|
||||||
|
try {
|
||||||
await window.api.deleteConnection(id)
|
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,6 +1070,12 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
|||||||
|
|
||||||
const handleApplyTemplate = useCallback(
|
const handleApplyTemplate = useCallback(
|
||||||
async (meta: LayoutMetaLike): Promise<void> => {
|
async (meta: LayoutMetaLike): Promise<void> => {
|
||||||
|
// Overlapping applies corrupt each other: the second call would collect
|
||||||
|
// the sessions the first one just restored into `previousSessions` and
|
||||||
|
// kill them on the way out. Drop the second call instead.
|
||||||
|
if (applyTemplateInFlightRef.current) return
|
||||||
|
applyTemplateInFlightRef.current = true
|
||||||
|
try {
|
||||||
const raw = await window.api.getLayout(meta.id)
|
const raw = await window.api.getLayout(meta.id)
|
||||||
if (raw == null) return
|
if (raw == null) return
|
||||||
|
|
||||||
@@ -1122,21 +1166,38 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fresh sessions for every restored terminal panel in both workspaces.
|
// Fresh sessions for every restored terminal panel. Only a dockview the
|
||||||
|
// payload actually reached may be rebound: `rebindRestoredPanels`
|
||||||
|
// rewrites each panel's params to a fresh *local* session, so running it
|
||||||
|
// over a legacy template's untouched ssh workspace would demote its live
|
||||||
|
// SSH panes to local terminals.
|
||||||
if (terminalApiRef.current) await rebindRestoredPanels(terminalApiRef.current)
|
if (terminalApiRef.current) await rebindRestoredPanels(terminalApiRef.current)
|
||||||
if (sshApiRef.current) await rebindRestoredPanels(sshApiRef.current)
|
if (sshTouched && sshApiRef.current) await rebindRestoredPanels(sshApiRef.current)
|
||||||
|
|
||||||
// A template carries the tab titles it was saved with, which may be in
|
// 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
|
// another language (or written by a build whose language the user has
|
||||||
// since switched away from).
|
// since switched away from).
|
||||||
retitleAutoPanels()
|
retitleAutoPanels()
|
||||||
|
|
||||||
// Old sessions are unreachable after the layout swap — kill them.
|
// Old sessions are unreachable after the layout swap — kill them, but
|
||||||
for (const sid of previousSessions) killSession(sid)
|
// only the ones no panel shows any more: a legacy template never reached
|
||||||
|
// the ssh dockview, whose panes keep their live SSH sessions (the same
|
||||||
|
// `live` test the failure path uses).
|
||||||
|
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)
|
||||||
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
|
||||||
|
} finally {
|
||||||
|
applyTemplateInFlightRef.current = false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[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
@@ -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
|
||||||
|
|||||||
@@ -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' }
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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': '削除・移動・上書きの操作',
|
||||||
|
|||||||
@@ -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': '删除/移动/覆盖操作',
|
||||||
|
|||||||
@@ -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': '刪除/移動/覆蓋操作',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -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 })
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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: '[' },
|
||||||
|
|||||||
@@ -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
@@ -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) => {
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -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')
|
||||||
Reference in new issue
Block a user