Compare commits

..
1 Commits
Author SHA1 Message Date
Bill f026400676 fix(release): GitHub publish survives partial runs and flaky proxies
CI / typecheck + test + build (windows) (push) Canceled after 0s
Skip assets a previous partial run already uploaded (the POST 422s on
duplicates, so a retry could never get past them), and retry transient
network errors on large proxied uploads.
2026-09-28 13:58:51 +08:00
18 changed files with 74 additions and 290 deletions

No files matched your search

-4
View File
@@ -27,7 +27,6 @@ tests/.commands-store.cjs
tests/.known-hosts.cjs tests/.known-hosts.cjs
tests/.lock-store.cjs tests/.lock-store.cjs
tests/.lock-controller.cjs tests/.lock-controller.cjs
tests/.lock-shortcuts.cjs
tests/.settings-store.cjs tests/.settings-store.cjs
tests/.session-e2e.cjs tests/.session-e2e.cjs
tests/.hl-split-smoke.cjs tests/.hl-split-smoke.cjs
@@ -47,6 +46,3 @@ RELEASE_NOTES*.md
tmp-test/ tmp-test/
tmp-test* tmp-test*
# agent session scratch (SDD plans/reports/smoke artifacts)
.superpowers/
+5 -5
View File
@@ -7,8 +7,8 @@ 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,然后依次跑可离线运行的 11 个测试:ssh-loopback、commands-store、settings-store、lock-store、lock-controller、lock-shortcuts、hl-split-smoke、hl-rules、zmodem-e2e、ssh-session-e2e、sysinfo-e2e;真实服务器测试需 JD_* 凭据,不在此列) - 测试:`npm test`(**npm 生命周期先自动跑 `pretest` 做类型检查**,再 `node tests/build-bundles.cjs` 重建 esbuild bundle,然后依次跑可离线运行的 10 个测试:ssh-loopback、commands-store、settings-store、lock-store、lock-controller、hl-split-smoke、hl-rules、zmodem-e2e、ssh-session-e2e、sysinfo-e2e;真实服务器测试需 JD_* 凭据,不在此列)
- 打包:`npm run dist`(**生命周期先自动跑 `predist` → `npm test`,即类型检查 + 11 个离线测试全部通过后才 build/package**,typecheck 全程只跑一次;predist 末尾的 `npm install --package-lock-only` 会把 `package-lock.json` 根版本号对齐 `package.json`,**发布提交必须带上 package-lock.json**),产物在 `release/`(msi + exe + latest.yml + blockmap) - 打包:`npm run dist`(**生命周期先自动跑 `predist` → `npm test`,即类型检查 + 10 个离线测试全部通过后才 build/package**,typecheck 全程只跑一次),产物在 `release/`(msi + 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`
@@ -62,10 +62,10 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
- 冷却阶梯 1s→2s→5s→10s→30s,失败计数与冷却同样落盘;`setPassword`/`clearPassword`/`unlock` 走内部串行队列(`serialize`),否则并发调用会同时通过闸门绕过冷却 - 冷却阶梯 1s→2s→5s→10s→30s,失败计数与冷却同样落盘;`setPassword`/`clearPassword`/`unlock` 走内部串行队列(`serialize`),否则并发调用会同时通过闸门绕过冷却
- 闲置锁屏:`powerMonitor.getSystemIdleTime()`,15s 轮询;读不到(无会话/工作站已锁)一律当「不闲置」。`settings.lock.autoLockMinutes` 是白名单 `{0,1,5,15,30,60}`(`src/shared/settings.ts` 的 `LOCK_AUTO_DELAYS`),0 = 从不 - 闲置锁屏:`powerMonitor.getSystemIdleTime()`,15s 轮询;读不到(无会话/工作站已锁)一律当「不闲置」。`settings.lock.autoLockMinutes` 是白名单 `{0,1,5,15,30,60}`(`src/shared/settings.ts` 的 `LOCK_AUTO_DELAYS`),0 = 从不
- **清除密码会一并把 `settings.lock.enabled`/`lockAtStartup` 置 false**(`LockControllerOptions.clearLockPreferences`,默认走 `mutateSettings`):设置页文案承诺「清除后锁屏会一并关闭」,留着会让用户下次设密码时被静默重新武装 - **清除密码会一并把 `settings.lock.enabled`/`lockAtStartup` 置 false**(`LockControllerOptions.clearLockPreferences`,默认走 `mutateSettings`):设置页文案承诺「清除后锁屏会一并关闭」,留着会让用户下次设密码时被静默重新武装
- 锁屏期间主进程在 `win.webContents.on('before-input-event')` 里吞掉 F5/Ctrl+R、Ctrl+±0(含 Shift 拼写)、Ctrl+Shift+I/J/C:遮罩是 DOM 层,拦不住浏览器进程处理的 Electron 默认菜单加速键,而重载会触发 `beforeunload` 把遮罩后面的会话全杀掉。**键盘判定抽在纯函数模块 `src/main/lockShortcuts.ts`(`isLockBlockedShortcut`/`isPanicLockChord`,无 Electron 依赖,表驱动测试 `tests/lock-shortcuts.mjs`)**——v1.0.17 的回归就是死在闭包里没法测。键盘之外还有鼠标路径:默认菜单按 Alt 就能唤出,菜单项点击不走 before-input-event,所以**锁定期间 `src/main/lockMenu.ts` 把整个应用菜单置 null(解锁时按 Electron 默认模板重建)**;菜单摘除挂在 `LockController` 的默认 publish 上,启动恢复锁定不经过 publish,由 `index.ts` 在 `initLockController()` 后按 `isLocked()` 直接补一次。渲染层的 `document.documentElement.dataset.locked` 守卫(字体快捷键、`Ctrl+PgUp/PgDn`)**只允许 `return` 跳过自身逻辑,绝不能 `preventDefault`**——keydown 的默认动作就是「往聚焦输入框插字符」,窗口级 preventDefault 会把锁屏密码框的全部输入杀掉(v1.0.17 就是这么坏的,v1.0.18 修复) - 锁屏期间主进程在 `win.webContents.on('before-input-event')` 里吞掉 F5/Ctrl+R、Ctrl+±0(含 Shift 拼写)、Ctrl+Shift+I/J/C:遮罩是 DOM 层,拦不住浏览器进程处理的 Electron 默认菜单加速键,而重载会触发 `beforeunload` 把遮罩后面的会话全杀掉。渲染层的 `document.documentElement.dataset.locked` 守卫(字体快捷键、`Ctrl+PgUp/PgDn`)**只允许 `return` 跳过自身逻辑,绝不能 `preventDefault`**——keydown 的默认动作就是「往聚焦输入框插字符」,窗口级 preventDefault 会把锁屏密码框的全部输入杀掉(v1.0.17 就是这么坏的,v1.0.18 修复)
- **Ctrl+L = 立即锁屏**(同一 `before-input-event` 里捕获,终端里也生效——这正是它的意义):仅在锁定真的生效时才 `preventDefault`,未设置密码的应用保留 Ctrl+L 给 shell 的清屏;已锁定时不再拦截。长按的自动重复要跳过(`input.isAutoRepeat`),否则未配置密码时每次重复都同步读 lock.json + settings.json。**Ctrl+L 是保留键**:全局唤起快捷键的录制器(`SettingsTabs.tsx` 的 `RESERVED_EXACT_ACCELERATORS`)拒绝它——globalShortcut 在 OS 层拦截,绑上去会让锁屏快捷键静默失效。窗口藏进托盘后 Ctrl+L 无效(before-input-event 只对聚焦窗口触发),这是刻意的取舍:全局注册会从所有应用手里抢走这个组合键 - **Ctrl+L = 立即锁屏**(同一 `before-input-event` 里捕获,终端里也生效——这正是它的意义):仅在锁定真的生效时才 `preventDefault`,未设置密码的应用保留 Ctrl+L 给 shell 的清屏;已锁定时不再拦截
- 启动时**不要**用 `locked: true` 作渲染层初值再直接画锁屏:`App.tsx` 用 `null` 表示「主进程还没答复」,此时只画 `.lock-screen-boot` 纯色层,否则每次启动都会给没设密码的用户闪一帧锁屏。`getLockState()` 失败时要落到「locked 且未配置」的状态,让输入框可达(主进程对无 verifier 的解锁请求直接放行) - 启动时**不要**用 `locked: true` 作渲染层初值再直接画锁屏:`App.tsx` 用 `null` 表示「主进程还没答复」,此时只画 `.lock-screen-boot` 纯色层,否则每次启动都会给没设密码的用户闪一帧锁屏。`getLockState()` 失败时要落到「locked 且未配置」的状态,让输入框可达(主进程对无 verifier 的解锁请求直接放行)
- 相关测试:`node tests/lock-store.mjs`(verifier + 状态存储)、`node tests/lock-controller.mjs`(冷却阶梯、并发串行化、落盘恢复、闲置触发、清除联动)、`node tests/lock-shortcuts.mjs`(键盘分类器表驱动用例) - 相关测试:`node tests/lock-store.mjs`(verifier + 状态存储)、`node tests/lock-controller.mjs`(冷却阶梯、并发串行化、落盘恢复、闲置触发、清除联动)
## 关键词高亮 ## 关键词高亮
-11
View File
@@ -1,16 +1,5 @@
# OpenTerminal Changelog # OpenTerminal Changelog
## v1.0.20 - 2026-10-01
### Input Method Fixes
- **Fixed the Chinese IME candidate window not following the cursor**: typing Chinese in the terminal (especially in fast-repainting TUI apps such as Kimi Code or Claude Code) previously made the candidate window drift far away from the caret; it now appears right next to it. The bundled terminal component was upgraded to a version containing the upstream IME fixes.
### Lock Screen Hardening
- **The application menu is removed while locked**: the Alt-revealed menu exposes Reload / DevTools / Zoom items that mouse clicks could use to bypass keyboard interception, so the whole menu is detached during lock and rebuilt on unlock.
- The Ctrl+L instant-lock key classification was extracted into a dedicated module with 29 table-driven test cases to prevent regressions.
- The global show/hide shortcut recorder now reserves Ctrl+L, so a global registration can no longer silently disable the panic lock at the OS level.
- Fixed: after system suspend or window focus loss, the lock screen cooldown countdown could outlive its real deadline, keeping the password input disabled for longer than necessary.
## v1.0.19 - 2026-09-28 ## v1.0.19 - 2026-09-28
### Update mechanism change ### Update mechanism change
-11
View File
@@ -1,16 +1,5 @@
# OpenTerminal 更新履歴 # OpenTerminal 更新履歴
## v1.0.20 - 2026-10-01
### 入力メソッド修正
- **中国語 IME の候補ウィンドウがカーソルに追従しない問題を修正**:ターミナルで中国語を入力する際(特に Kimi Code や Claude Code など高頻度で再描画される TUI では)、候補ウィンドウがカーソルから大きく離れた位置に表示されていましたが、カーソルのすぐそばに表示されるようになりました。同梱のターミナルコンポーネントを、上流の IME 修正を含むバージョンに更新しました。
### ロック画面の強化
- **ロック中はアプリケーションメニューを除去**:Alt キーで表示されるメニューの「再読み込み / 開発者ツール / ズーム」はマウスクリックでキー拦截を回避できたため、ロック中はメニュー全体を外し、ロック解除後に自動復元します。
- Ctrl+L 即時ロックのキー判定を独立モジュールに切り出し、29 件のテストケースを追加して回帰を防止。
- グローバル表示/非表示ショートカットの録画面で Ctrl+L を予約済みとして使用不可にし、OS レベルでのグローバル登録が緊急ロックを黙って無効化しないようにしました。
- 修正:システム休止やウィンドウフォーカス喪失後、ロック画面のパスワード入力クールダウンが実際の期限を超えて続き、入力可能になるまでの時間が不必要に長くなることがあった問題。
## v1.0.19 - 2026-09-28 ## v1.0.19 - 2026-09-28
### アップデート機構の変更 ### アップデート機構の変更
-11
View File
@@ -1,16 +1,5 @@
# OpenTerminal 更新日志 # OpenTerminal 更新日志
## v1.0.20 - 2026-10-01
### 输入法修复
- **修复中文输入法候选窗不跟随光标**:此前在终端里输入中文时(尤其是 Kimi Code、Claude Code 等高频刷新的 TUI 界面),候选窗会漂到远离光标的位置;现已紧贴光标显示。内置终端组件升级至包含上游输入法修复的版本。
### 锁屏加固
- **锁定期间移除应用菜单**:Alt 键唤出的菜单里「重新加载 / 开发者工具 / 缩放」可通过鼠标点击绕过键盘拦截,锁定期间整个菜单摘除,解锁后自动恢复。
- Ctrl+L 立即锁屏的按键判定重构为独立模块并补充 29 条测试用例,防止回归。
- 全局唤起快捷键的录制器保留 Ctrl+L 不允许占用,避免全局注册在系统层静默禁用紧急锁屏。
- 修复:系统休眠或窗口失焦后,锁屏密码框的冷却倒计时可能超过真实截止时间,导致可输入时间被无谓延长。
## v1.0.19 - 2026-09-28 ## v1.0.19 - 2026-09-28
### 更新机制调整 ### 更新机制调整
-11
View File
@@ -1,16 +1,5 @@
# OpenTerminal 更新日誌 # OpenTerminal 更新日誌
## v1.0.20 - 2026-10-01
### 輸入法修復
- **修復中文輸入法候選窗不跟隨游標**:此前在終端裡輸入中文時(尤其是 Kimi Code、Claude Code 等高頻刷新的 TUI 介面),候選窗會漂到遠離游標的位置;現已緊貼游標顯示。內建終端元件升級至包含上游輸入法修復的版本。
### 鎖屏加固
- **鎖定期間移除應用選單**:Alt 鍵喚出的選單裡「重新載入 / 開發者工具 / 縮放」可透過滑鼠點擊繞過鍵盤攔截,鎖定期間整個選單摘除,解鎖後自動恢復。
- Ctrl+L 立即鎖屏的按鍵判定重構為獨立模組並補充 29 條測試案例,防止回歸。
- 全域喚起快捷鍵的錄製器保留 Ctrl+L 不允許佔用,避免全域註冊在系統層靜默停用緊急鎖屏。
- 修復:系統休眠或視窗失焦後,鎖屏密碼框的冷卻倒計時可能超過真實截止時間,導致可輸入時間被無謂延長。
## v1.0.19 - 2026-09-28 ## v1.0.19 - 2026-09-28
### 更新機制調整 ### 更新機制調整
+3 -5
View File
@@ -4,7 +4,7 @@
## 当前版本与仓库 ## 当前版本与仓库
- v1.0.19,远程 `git.codingplan.site/admin/OpenTerminal.git`(国内仓)+ `github.com/billowliu2/OpenTerminal.git`(GitHub 镜像仓);凭据存于 `.env`(已 git 忽略),凭据助手按 host 自动读取 - v1.0.13,远程 `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
@@ -48,7 +48,7 @@
## 发布流程(下一版本照抄) ## 发布流程(下一版本照抄)
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 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)
2. `node scripts/release.cjs <版本号> --skip-github`:脚本自己建 Gitea release(msi/exe 资产)→ 传更新通道 `exe.blockmap → exe → release-notes.md → latest.yml`(**latest.yml 最后**);不再先删旧版,latest.yml 生效后才清掉上一版 exe/blockmap 2. `node scripts/release.cjs <版本号> --skip-github`:脚本自己建 Gitea release(msi/exe 资产)→ 传更新通道 `exe.blockmap → exe → release-notes.md → latest.yml`(**latest.yml 最后**);不再先删旧版,latest.yml 生效后才清掉上一版 exe/blockmap
3. 通道传坏了只补通道:`node scripts/release.cjs <版本号> --channel-only`(同一版本可重复运行:release 复用、已传资产跳过) 3. 通道传坏了只补通道:`node scripts/release.cjs <版本号> --channel-only`(同一版本可重复运行:release 复用、已传资产跳过)
4. 校验:无 token `curl .../generic/openterminal-update/stable/latest.yml` 应 200 且 version 正确 4. 校验:无 token `curl .../generic/openterminal-update/stable/latest.yml` 应 200 且 version 正确
@@ -88,7 +88,6 @@ node tests/sysinfo-e2e.mjs
8. **electron-updater 不支持 MSI 自动更新** → 更新通道走 NSIS exe;MSI 仅分发 8. **electron-updater 不支持 MSI 自动更新** → 更新通道走 NSIS exe;MSI 仅分发
9. **命令库测试时间戳竞态**:saveLibraryItem 连续保存同一毫秒 createdAt 相同排序不稳 → 测试保存间 wait(2) 9. **命令库测试时间戳竞态**:saveLibraryItem 连续保存同一毫秒 createdAt 相同排序不稳 → 测试保存间 wait(2)
10. **JD 服务器测试凭据已过期**;凭据一律环境变量且不落盘 10. **JD 服务器测试凭据已过期**;凭据一律环境变量且不落盘
11. **`@xterm/xterm` 精确锁定 `6.1.0-beta.304`(不带 ^)**:修中文 IME 候选窗不跟随光标(上游 xtermjs/xterm.js#5759 compositionstart 时同步 textarea 位置 + #5747 composition-view 限宽),stable 6.0.0 未带这两个修复;待 6.1.0 stable 发布后改回 `^` 语义化范围
## 编排约定 ## 编排约定
@@ -99,6 +98,5 @@ node tests/sysinfo-e2e.mjs
## 待办(按优先级) ## 待办(按优先级)
1. ~~应用图标~~(已完成:build/icon.png,程序生成的原创图标) 1. ~~应用图标~~(已完成:build/icon.png,程序生成的原创图标)
2. 用户实测项:Ctrl+PgUp/PgDn 真实键盘(合成键盘无法验证修饰键)、真实服务器 rz/sz 一轮、全局唤起快捷键、中文 IME 候选窗跟随光标(每次发版前手测一遍,CI 无法覆盖) 2. 用户实测项:Ctrl+PgUp/PgDn 真实键盘(合成键盘无法验证修饰键)、真实服务器 rz/sz 一轮、全局唤起快捷键
3. `@xterm/xterm` 6.1.0 stable 发布后把精确锁定的 `6.1.0-beta.304` 改回 `^` 范围并回归验证 IME(背景见已踩坑 11)
3. 小项:autoWrap=false 固定列宽、OSC 标题跟随、内置 OFL 字体打包、WebGL 终端数上限降级、最近命令历史出现两条命令拼接的记录(广播键入时行捕获合并,低优先级修) 3. 小项:autoWrap=false 固定列宽、OSC 标题跟随、内置 OFL 字体打包、WebGL 终端数上限降级、最近命令历史出现两条命令拼接的记录(广播键入时行捕获合并,低优先级修)
+6 -6
View File
@@ -1,12 +1,12 @@
{ {
"name": "open-terminal", "name": "open-terminal",
"version": "1.0.20", "version": "1.0.14",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "open-terminal", "name": "open-terminal",
"version": "1.0.20", "version": "1.0.14",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@lydell/node-pty": "^1.2.0-beta.15", "@lydell/node-pty": "^1.2.0-beta.15",
@@ -14,7 +14,7 @@
"@xterm/addon-search": "^0.16.0", "@xterm/addon-search": "^0.16.0",
"@xterm/addon-serialize": "^0.14.0", "@xterm/addon-serialize": "^0.14.0",
"@xterm/addon-webgl": "^0.19.0", "@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "6.1.0-beta.304", "@xterm/xterm": "^6.0.0",
"antd": "^6.6.2", "antd": "^6.6.2",
"dockview-react": "^8.2.0", "dockview-react": "^8.2.0",
"electron-updater": "^6.8.9", "electron-updater": "^6.8.9",
@@ -2855,9 +2855,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@xterm/xterm": { "node_modules/@xterm/xterm": {
"version": "6.1.0-beta.304", "version": "6.0.0",
"resolved": "https://registry.npmmirror.com/@xterm/xterm/-/xterm-6.1.0-beta.304.tgz", "resolved": "https://registry.npmmirror.com/@xterm/xterm/-/xterm-6.0.0.tgz",
"integrity": "sha512-Wq9d4qFYslYQBIan+riXotdyurtREc6v+HWdAwV6Y2JSDI9eJ1dkWm/fzYb2rCGgGnM3baiYuSK4Z6OLNKLzDw==", "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
"addons/*" "addons/*"
+4 -4
View File
@@ -1,7 +1,7 @@
{ {
"name": "open-terminal", "name": "open-terminal",
"productName": "OpenTerminal", "productName": "OpenTerminal",
"version": "1.0.20", "version": "1.0.19",
"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,8 +13,8 @@
"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/settings-store.mjs && node tests/lock-store.mjs && node tests/lock-controller.mjs && node tests/lock-shortcuts.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/settings-store.mjs && node tests/lock-store.mjs && node tests/lock-controller.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",
"predist": "npm test && npm install --package-lock-only", "predist": "npm test",
"dist": "electron-vite build && electron-builder --win msi nsis", "dist": "electron-vite build && electron-builder --win msi nsis",
"dist:dir": "electron-vite build && electron-builder --win --dir" "dist:dir": "electron-vite build && electron-builder --win --dir"
}, },
@@ -24,7 +24,7 @@
"@xterm/addon-search": "^0.16.0", "@xterm/addon-search": "^0.16.0",
"@xterm/addon-serialize": "^0.14.0", "@xterm/addon-serialize": "^0.14.0",
"@xterm/addon-webgl": "^0.19.0", "@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "6.1.0-beta.304", "@xterm/xterm": "^6.0.0",
"antd": "^6.6.2", "antd": "^6.6.2",
"dockview-react": "^8.2.0", "dockview-react": "^8.2.0",
"electron-updater": "^6.8.9", "electron-updater": "^6.8.9",
+18
View File
@@ -278,8 +278,26 @@ async function github() {
} }
} }
const up = `https://uploads.github.com/repos/billowliu2/OpenTerminal/releases/${rel.id}/assets` const up = `https://uploads.github.com/repos/billowliu2/OpenTerminal/releases/${rel.id}/assets`
// Skip assets a previous partial run already uploaded — the upload POST
// 422s on duplicates, so without this a retry could never get past them.
const assetsResp = await gh(`${api}/releases/${rel.id}/assets?per_page=100`)
const existing = new Set(assetsResp.ok ? (await assetsResp.json()).map((a) => a.name) : [])
for (const f of [...files, path.join(R, `OpenTerminal-${V}-setup.exe.blockmap`), path.join(R, 'latest.yml')]) { for (const f of [...files, path.join(R, `OpenTerminal-${V}-setup.exe.blockmap`), path.join(R, 'latest.yml')]) {
if (existing.has(path.basename(f))) {
console.log(` skip ${path.basename(f)} (already uploaded)`)
continue
}
// Large uploads through a proxy flake; retry transient network errors.
for (let attempt = 1; ; attempt++) {
try {
await upload(up, f, env.GH_TOKEN, 'Bearer', true) await upload(up, f, env.GH_TOKEN, 'Bearer', true)
break
} catch (e) {
if (attempt >= 3) throw e
console.log(` upload ${path.basename(f)} failed (${e.message}), retrying in 5s…`)
await new Promise((r) => setTimeout(r, 5000))
}
}
} }
} }
+37 -11
View File
@@ -7,8 +7,6 @@ import { isTrustedRendererUrl, registerIpc } from './ipc'
import { killAllPtys, killPtysByOwner } from './pty' import { killAllPtys, killPtysByOwner } from './pty'
import { applyStartupSystemSettings, loadSettings } from './settingsStore' import { applyStartupSystemSettings, loadSettings } from './settingsStore'
import { getLockController, initLockController } from './lockController' import { getLockController, initLockController } from './lockController'
import { applyMenuLockState } from './lockMenu'
import { isLockBlockedShortcut, isPanicLockChord } from './lockShortcuts'
import { initTray, markQuitting, onMainWindowClose, refreshTrayMenu } from './tray' import { initTray, markQuitting, onMainWindowClose, refreshTrayMenu } from './tray'
import { configureAutoUpdater, registerUpdateIpc } from './updater' import { configureAutoUpdater, registerUpdateIpc } from './updater'
import { applyWindowChrome } from './windowChrome' import { applyWindowChrome } from './windowChrome'
@@ -94,10 +92,8 @@ if (!gotSingleInstanceLock) {
// The lock state has to exist before the window loads, so a lockAtStartup // The lock state has to exist before the window loads, so a lockAtStartup
// lock is already in place when the renderer asks for it. It also starts the // lock is already in place when the renderer asks for it. It also starts the
// idle watcher, which is why it belongs after ready: powerMonitor cannot be // idle watcher, which is why it belongs after ready: powerMonitor cannot be
// touched before that. A restored startup lock engages before any publish, // touched before that.
// so the menu teardown is applied here from the flag itself. initLockController()
const lock = initLockController()
applyMenuLockState(lock.isLocked())
createWindow() 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
@@ -110,6 +106,31 @@ if (!gotSingleInstanceLock) {
}) })
} }
/**
* Accelerators that must not reach the page while the lock screen is up.
*
* The overlay is a DOM layer inside the window, so everything the browser
* process handles on its own passes straight through it: reloading the renderer
* runs Workspace's beforeunload and kills every local/SSH session behind the
* overlay, and the zoom / DevTools shortcuts would let the locked screen be
* resized or read. These come from Electron's default application menu, whose
* keys are matched before any renderer code runs.
*
* Matching is on `input.key` rather than `input.code`: the key is what the
* layout actually produces (Ctrl+Shift+= arrives as '+', Ctrl+Shift+- as '_'),
* while the code depends on the physical key.
*/
function isLockBlockedShortcut(input: Electron.Input): boolean {
const key = input.key.toLowerCase()
if (key === 'f5') return true
if (!input.control) return false
if (key === 'r') return true
// Zoom: in, out and reset, in both their plain and Shift-shifted spellings.
if (key === '=' || key === '+' || key === '-' || key === '_' || key === '0') return true
// DevTools. Shift is required so that plain Ctrl+C (copy) keeps working.
return input.shift && (key === 'i' || key === 'j' || key === 'c')
}
function createWindow(): void { function createWindow(): void {
// Dev-mode window/taskbar icon; packaged builds inherit the exe icon // Dev-mode window/taskbar icon; packaged builds inherit the exe icon
// (electron-builder embeds build/icon.png), so undefined is fine there. // (electron-builder embeds build/icon.png), so undefined is fine there.
@@ -148,9 +169,7 @@ function createWindow(): void {
// Ctrl+L is the panic lock: it must work from anywhere in the app, terminals // Ctrl+L is the panic lock: it must work from anywhere in the app, terminals
// included, so it is captured here ahead of the page. It only takes the chord // included, so it is captured here ahead of the page. It only takes the chord
// away when a lock actually engages — an unconfigured app keeps Ctrl+L for // away when a lock actually engages — an unconfigured app keeps Ctrl+L for
// the shell's clear-screen. Auto-repeats are skipped: the first keydown // the shell's clear-screen.
// decides, and on an unconfigured app each repeat would otherwise re-read
// lock.json + settings.json from disk (~30 keydown/s while held).
// Swallow the menu accelerators that would otherwise act behind the lock // Swallow the menu accelerators that would otherwise act behind the lock
// overlay (see isLockBlockedShortcut). A throw in here would break typing // overlay (see isLockBlockedShortcut). A throw in here would break typing
// altogether, so the whole guard is defensive. // altogether, so the whole guard is defensive.
@@ -158,8 +177,15 @@ function createWindow(): void {
try { try {
if (input.type !== 'keyDown') return if (input.type !== 'keyDown') return
const lock = getLockController() const lock = getLockController()
if (!lock.isLocked() && isPanicLockChord(input)) { if (
if (!input.isAutoRepeat && lock.lockNow().locked) event.preventDefault() !lock.isLocked() &&
input.control &&
!input.shift &&
!input.alt &&
!input.meta &&
input.key.toLowerCase() === 'l'
) {
if (lock.lockNow().locked) event.preventDefault()
return return
} }
if (!lock.isLocked()) return if (!lock.isLocked()) return
+1 -9
View File
@@ -29,7 +29,6 @@ import {
isValidPassword isValidPassword
} from './lockStore' } from './lockStore'
import { loadSettings, mutateSettings } from './settingsStore' import { loadSettings, mutateSettings } from './settingsStore'
import { applyMenuLockState } from './lockMenu'
/** /**
* Backoff after each failed verification: the nth failure refuses further * Backoff after each failed verification: the nth failure refuses further
@@ -91,14 +90,7 @@ export class LockController {
options.stateStore ?? new LockStateStore(defaultLockStatePath(app.getPath('userData'))) options.stateStore ?? new LockStateStore(defaultLockStatePath(app.getPath('userData')))
this.getLockSettings = options.getLockSettings ?? ((): LockSettings => loadSettings().lock) this.getLockSettings = options.getLockSettings ?? ((): LockSettings => loadSettings().lock)
this.publish = this.publish =
options.publish ?? options.publish ?? ((state): void => broadcast(Ipc.LOCK_STATE_CHANGED, state))
((state): void => {
// The menu teardown rides every published transition; the startup lock
// engages without publishing, so index.ts applies it once from the
// restored flag directly.
applyMenuLockState(state.locked)
broadcast(Ipc.LOCK_STATE_CHANGED, state)
})
this.now = options.now ?? ((): number => Date.now()) this.now = options.now ?? ((): number => Date.now())
this.idleSeconds = options.idleSeconds ?? ((): number => powerMonitor.getSystemIdleTime()) this.idleSeconds = options.idleSeconds ?? ((): number => powerMonitor.getSystemIdleTime())
this.clearLockPreferences = this.clearLockPreferences =
-36
View File
@@ -1,36 +0,0 @@
import { Menu } from 'electron'
/**
* Remove the application menu while the lock screen is up, restore it on unlock.
*
* `before-input-event` only sees the keyboard: with the default menu in place,
* pressing Alt reveals the hidden menu bar (`autoHideMenuBar`) and a mouse click
* on View → Reload / Toggle Developer Tools / Zoom still runs behind — or
* against — the opaque overlay. Reload kills every session behind the mask via
* beforeunload; DevTools makes the hidden DOM readable. Neither is reachable
* once the menu is gone, and the lock's keyboard guard (lockShortcuts.ts) keeps
* covering the chords in dev, where the menu is the developer's tool.
*
* The restore template mirrors Electron's own default menu (default-menu.ts):
* the app never installs a custom one, so this rebuilds exactly what was there.
*/
let menuRemoved = false
export function applyMenuLockState(locked: boolean): void {
if (menuRemoved === locked) return
menuRemoved = locked
if (locked) {
Menu.setApplicationMenu(null)
return
}
const template: Electron.MenuItemConstructorOptions[] = [
...(process.platform === 'darwin'
? [{ role: 'appMenu' as const }]
: []),
{ role: 'fileMenu' },
{ role: 'editMenu' },
{ role: 'viewMenu' },
{ role: 'windowMenu' }
]
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
}
-57
View File
@@ -1,57 +0,0 @@
/**
* Lock-screen keyboard classification, kept free of Electron imports so the
* decision logic can be table-tested under plain Node (tests/lock-shortcuts.mjs).
* `Electron.Input` satisfies this shape structurally.
*/
export interface LockInputEvent {
key: string
control: boolean
shift: boolean
alt: boolean
meta: boolean
isAutoRepeat?: boolean
}
/**
* Accelerators that must not reach the page while the lock screen is up.
*
* The overlay is a DOM layer inside the window, so everything the browser
* process handles on its own passes straight through it: reloading the renderer
* runs Workspace's beforeunload and kills every local/SSH session behind the
* overlay, and the zoom / DevTools shortcuts would let the locked screen be
* resized or read. These chords come from Electron's default application menu,
* whose keys are matched before any renderer code runs (the menu itself is
* removed while locked — see lockMenu.ts — so its mouse-clickable items cannot
* be reached either).
*
* Matching is on `input.key` rather than `input.code`: the key is what the
* layout actually produces (Ctrl+Shift+= arrives as '+', Ctrl+Shift+- as '_'),
* while the code depends on the physical key.
*/
export function isLockBlockedShortcut(input: LockInputEvent): boolean {
const key = input.key.toLowerCase()
if (key === 'f5') return true
if (!input.control) return false
if (key === 'r') return true
// Zoom: in, out and reset, in both their plain and Shift-shifted spellings.
if (key === '=' || key === '+' || key === '-' || key === '_' || key === '0') return true
// DevTools. Shift is required so that plain Ctrl+C (copy) keeps working.
return input.shift && (key === 'i' || key === 'j' || key === 'c')
}
/**
* The panic lock chord: exactly Ctrl+L, no other modifier. Shift is excluded
* because Ctrl+Shift+L is the switch-to-English chord on Chinese and Japanese
* IMEs; Alt is excluded because AltGr arrives as Ctrl+Alt on most European
* layouts. A chord that isn't exactly this must keep its original meaning.
*/
export function isPanicLockChord(input: LockInputEvent): boolean {
return (
input.control &&
!input.shift &&
!input.alt &&
!input.meta &&
input.key.toLowerCase() === 'l'
)
}
-17
View File
@@ -68,23 +68,6 @@ export function LockScreen({ state, onStateChange }: LockScreenProps): React.JSX
return () => window.clearInterval(id) return () => window.clearInterval(id)
}, [cooldownUntil]) }, [cooldownUntil])
// The countdown is renderer-driven, and Chromium may suspend or coalesce
// timers while the window is hidden, minimized or occluded — a suspended
// interval would leave the input disabled (and the "retry in N s" text
// frozen) long past the real deadline. Re-sync the clock the moment the
// page becomes visible or focused again, so recovery is immediate.
useEffect(() => {
const sync = (): void => setNow(Date.now())
document.addEventListener('visibilitychange', sync)
window.addEventListener('focus', sync)
window.addEventListener('pageshow', sync)
return () => {
document.removeEventListener('visibilitychange', sync)
window.removeEventListener('focus', sync)
window.removeEventListener('pageshow', sync)
}
}, [])
const remainingMs = Math.max(0, cooldownUntil - now) const remainingMs = Math.max(0, cooldownUntil - now)
const cooling = remainingMs > 0 const cooling = remainingMs > 0
@@ -500,17 +500,8 @@ function acceleratorFromEvent(e: React.KeyboardEvent<HTMLInputElement>): string
*/ */
const RESERVED_CONTROL_KEYS = new Set(['=', '-', '0', 'PageUp', 'PageDown']) const RESERVED_CONTROL_KEYS = new Set(['=', '-', '0', 'PageUp', 'PageDown'])
/**
* Whole chords the app owns outright, matched exactly (modifier set included).
* Ctrl+L is the panic lock, captured in main's before-input-event: a global
* registration intercepts the key at the OS level even while this window is
* focused, so binding it here would silently disable the lock shortcut.
*/
const RESERVED_EXACT_ACCELERATORS = new Set(['Control+L'])
/** true when `accel` (e.g. "Control+Shift+=") collides with an in-app shortcut */ /** true when `accel` (e.g. "Control+Shift+=") collides with an in-app shortcut */
function isReservedAccelerator(accel: string): boolean { function isReservedAccelerator(accel: string): boolean {
if (RESERVED_EXACT_ACCELERATORS.has(accel)) return true
const parts = accel.split('+') const parts = accel.split('+')
return parts.includes('Control') && RESERVED_CONTROL_KEYS.has(parts[parts.length - 1]) return parts.includes('Control') && RESERVED_CONTROL_KEYS.has(parts[parts.length - 1])
} }
-2
View File
@@ -28,8 +28,6 @@ const BUNDLES = [
// Pulls in settingsStore + broadcast, which is why the electron stub needs // Pulls in settingsStore + broadcast, which is why the electron stub needs
// powerMonitor as well. // powerMonitor as well.
{ entry: 'src/main/lockController.ts', out: 'tests/.lock-controller.cjs' }, { entry: 'src/main/lockController.ts', out: 'tests/.lock-controller.cjs' },
// Lock keyboard classifier: pure, so the bundle needs no electron surface.
{ entry: 'src/main/lockShortcuts.ts', out: 'tests/.lock-shortcuts.cjs' },
// zmodem.js stays bundled (NOT external) — the test drives a second in-process // zmodem.js stays bundled (NOT external) — the test drives a second in-process
// Sentry from the same library. // Sentry from the same library.
{ entry: 'src/main/zmodem.ts', out: 'tests/.zmodem-e2e.cjs', external: ['ssh2'] }, { entry: 'src/main/zmodem.ts', out: 'tests/.zmodem-e2e.cjs', external: ['ssh2'] },
-81
View File
@@ -1,81 +0,0 @@
/**
* Lock-shortcut classifier self-test (lock-shortcuts.mjs).
*
* Table-driven coverage of the two pure predicates the before-input-event guard
* in src/main/index.ts is built from. This is the decision v1.0.17 got wrong
* from inside an untestable closure, so every chord spelling that matters is
* pinned here:
* - the panic lock is exactly Ctrl+L: Shift (IME switch on zh/ja) and Alt
* (AltGr on European layouts) must pass through, as must Caps-Lock 'L'
* variants only when they really are Ctrl+L
* - the locked-window blocklist: reload (F5, Ctrl+R, Ctrl+Shift+R), zoom in
* both plain and Shift-shifted spellings ('='/'+' and '-'/'_'), reset ('0'),
* and DevTools (Ctrl+Shift+I/J/C)
* - what must NOT be blocked: plain typing, Ctrl+C copy, Ctrl+L itself while
* already locked (it falls to the page), and unrelated Ctrl chords
*
* Build: node tests/build-bundles.cjs
* Run: node tests/lock-shortcuts.mjs (must exit 0)
*/
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
const { isLockBlockedShortcut, isPanicLockChord } = require('./.lock-shortcuts.cjs')
let failed = 0
const ok = (cond, msg) => {
console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`)
if (!cond) failed += 1
}
/** Build a classifier input; modifiers default to off. */
const key = (k, mods = {}) => ({
key: k,
control: false,
shift: false,
alt: false,
meta: false,
...mods
})
const ctrl = (k, mods = {}) => key(k, { control: true, ...mods })
// ---- panic lock chord (exactly Ctrl+L) --------------------------------------
console.log('panic lock chord')
ok(isPanicLockChord(ctrl('l')), 'Ctrl+L is the panic chord')
ok(isPanicLockChord(ctrl('L')), 'Ctrl+L with Caps Lock still matches (key is case-folded)')
ok(!isPanicLockChord(ctrl('l', { shift: true })), 'Ctrl+Shift+L passes (IME input-mode switch)')
ok(!isPanicLockChord(ctrl('l', { alt: true })), 'Ctrl+Alt+L passes (AltGr on European layouts)')
ok(!isPanicLockChord(ctrl('l', { meta: true })), 'Ctrl+Meta+L passes')
ok(!isPanicLockChord(key('l')), 'plain L passes (would eat typing otherwise)')
ok(!isPanicLockChord(ctrl('r')), 'Ctrl+R is not the panic chord')
ok(!isPanicLockChord(ctrl('l', { shift: true, alt: true })), 'Ctrl+Shift+Alt+L passes')
// ---- locked-window blocklist -------------------------------------------------
console.log('locked-window blocklist')
ok(isLockBlockedShortcut(key('f5')), 'F5 blocked (reload)')
ok(isLockBlockedShortcut(key('F5')), 'F5 case-folded')
ok(isLockBlockedShortcut(ctrl('r')), 'Ctrl+R blocked (reload)')
ok(isLockBlockedShortcut(ctrl('r', { shift: true })), 'Ctrl+Shift+R blocked (force reload)')
ok(isLockBlockedShortcut(ctrl('=')), 'Ctrl+= blocked (zoom in)')
ok(isLockBlockedShortcut(ctrl('+')), "Ctrl+Shift+= arrives as '+' and is blocked")
ok(isLockBlockedShortcut(ctrl('-')), 'Ctrl+- blocked (zoom out)')
ok(isLockBlockedShortcut(ctrl('_')), "Ctrl+Shift+- arrives as '_' and is blocked")
ok(isLockBlockedShortcut(ctrl('0')), 'Ctrl+0 blocked (zoom reset)')
ok(isLockBlockedShortcut(ctrl('i', { shift: true })), 'Ctrl+Shift+I blocked (DevTools)')
ok(isLockBlockedShortcut(ctrl('j', { shift: true })), 'Ctrl+Shift+J blocked (DevTools console)')
ok(isLockBlockedShortcut(ctrl('c', { shift: true })), 'Ctrl+Shift+C blocked (DevTools inspect)')
// ---- must not be blocked -----------------------------------------------------
console.log('pass-through chords')
ok(!isLockBlockedShortcut(key('r')), 'plain typing passes')
ok(!isLockBlockedShortcut(ctrl('c')), 'Ctrl+C passes (terminal copy)')
ok(!isLockBlockedShortcut(ctrl('l')), 'Ctrl+L passes the blocklist (handled by the panic branch)')
ok(!isLockBlockedShortcut(ctrl('i')), 'Ctrl+I without Shift passes')
ok(!isLockBlockedShortcut(key('f12')), 'F12 passes (Electron binds no default for it)')
ok(!isLockBlockedShortcut(ctrl('l', { shift: true })), 'Ctrl+Shift+L passes the blocklist too')
if (failed > 0) {
console.error(`\n[lock-shortcuts] ${failed} check(s) FAILED`)
process.exit(1)
}
console.log('\n[lock-shortcuts] ALL CHECKS PASSED')