Compare commits
3
Commits
Dev_202609
...
v1.0.20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1376b7d1d | ||
|
|
d04452cd7f | ||
|
|
86f51df2e6 |
No files matched your search
@@ -27,6 +27,7 @@ tests/.commands-store.cjs
|
||||
tests/.known-hosts.cjs
|
||||
tests/.lock-store.cjs
|
||||
tests/.lock-controller.cjs
|
||||
tests/.lock-shortcuts.cjs
|
||||
tests/.settings-store.cjs
|
||||
tests/.session-e2e.cjs
|
||||
tests/.hl-split-smoke.cjs
|
||||
@@ -46,3 +47,6 @@ RELEASE_NOTES*.md
|
||||
|
||||
tmp-test/
|
||||
tmp-test*
|
||||
|
||||
# agent session scratch (SDD plans/reports/smoke artifacts)
|
||||
.superpowers/
|
||||
@@ -7,8 +7,8 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
|
||||
- 开发:`npm run 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 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`,即类型检查 + 10 个离线测试全部通过后才 build/package**,typecheck 全程只跑一次),产物在 `release/`(msi + exe + latest.yml + blockmap)
|
||||
- 测试:`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 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)
|
||||
- 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`
|
||||
|
||||
@@ -62,10 +62,10 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
|
||||
- 冷却阶梯 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 = 从不
|
||||
- **清除密码会一并把 `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` 把遮罩后面的会话全杀掉。渲染层的 `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 的清屏;已锁定时不再拦截
|
||||
- 锁屏期间主进程在 `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 修复)
|
||||
- **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 只对聚焦窗口触发),这是刻意的取舍:全局注册会从所有应用手里抢走这个组合键
|
||||
- 启动时**不要**用 `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-store.mjs`(verifier + 状态存储)、`node tests/lock-controller.mjs`(冷却阶梯、并发串行化、落盘恢复、闲置触发、清除联动)、`node tests/lock-shortcuts.mjs`(键盘分类器表驱动用例)
|
||||
|
||||
## 关键词高亮
|
||||
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# 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
|
||||
|
||||
### Update mechanism change
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# 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
|
||||
|
||||
### アップデート機構の変更
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# OpenTerminal 更新日志
|
||||
|
||||
## v1.0.20 - 2026-10-01
|
||||
|
||||
### 输入法修复
|
||||
- **修复中文输入法候选窗不跟随光标**:此前在终端里输入中文时(尤其是 Kimi Code、Claude Code 等高频刷新的 TUI 界面),候选窗会漂到远离光标的位置;现已紧贴光标显示。内置终端组件升级至包含上游输入法修复的版本。
|
||||
|
||||
### 锁屏加固
|
||||
- **锁定期间移除应用菜单**:Alt 键唤出的菜单里「重新加载 / 开发者工具 / 缩放」可通过鼠标点击绕过键盘拦截,锁定期间整个菜单摘除,解锁后自动恢复。
|
||||
- Ctrl+L 立即锁屏的按键判定重构为独立模块并补充 29 条测试用例,防止回归。
|
||||
- 全局唤起快捷键的录制器保留 Ctrl+L 不允许占用,避免全局注册在系统层静默禁用紧急锁屏。
|
||||
- 修复:系统休眠或窗口失焦后,锁屏密码框的冷却倒计时可能超过真实截止时间,导致可输入时间被无谓延长。
|
||||
|
||||
## v1.0.19 - 2026-09-28
|
||||
|
||||
### 更新机制调整
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# OpenTerminal 更新日誌
|
||||
|
||||
## v1.0.20 - 2026-10-01
|
||||
|
||||
### 輸入法修復
|
||||
- **修復中文輸入法候選窗不跟隨游標**:此前在終端裡輸入中文時(尤其是 Kimi Code、Claude Code 等高頻刷新的 TUI 介面),候選窗會漂到遠離游標的位置;現已緊貼游標顯示。內建終端元件升級至包含上游輸入法修復的版本。
|
||||
|
||||
### 鎖屏加固
|
||||
- **鎖定期間移除應用選單**:Alt 鍵喚出的選單裡「重新載入 / 開發者工具 / 縮放」可透過滑鼠點擊繞過鍵盤攔截,鎖定期間整個選單摘除,解鎖後自動恢復。
|
||||
- Ctrl+L 立即鎖屏的按鍵判定重構為獨立模組並補充 29 條測試案例,防止回歸。
|
||||
- 全域喚起快捷鍵的錄製器保留 Ctrl+L 不允許佔用,避免全域註冊在系統層靜默停用緊急鎖屏。
|
||||
- 修復:系統休眠或視窗失焦後,鎖屏密碼框的冷卻倒計時可能超過真實截止時間,導致可輸入時間被無謂延長。
|
||||
|
||||
## v1.0.19 - 2026-09-28
|
||||
|
||||
### 更新機制調整
|
||||
|
||||
+5
-3
@@ -4,7 +4,7 @@
|
||||
|
||||
## 当前版本与仓库
|
||||
|
||||
- v1.0.13,远程 `git.codingplan.site/admin/OpenTerminal.git`(国内仓)+ `github.com/billowliu2/OpenTerminal.git`(GitHub 镜像仓);凭据存于 `.env`(已 git 忽略),凭据助手按 host 自动读取
|
||||
- v1.0.19,远程 `git.codingplan.site/admin/OpenTerminal.git`(国内仓)+ `github.com/billowliu2/OpenTerminal.git`(GitHub 镜像仓);凭据存于 `.env`(已 git 忽略),凭据助手按 host 自动读取
|
||||
- 开源协议:MIT(LICENSE)
|
||||
- 更新通道 = `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
|
||||
@@ -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)
|
||||
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 个版本)
|
||||
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 复用、已传资产跳过)
|
||||
4. 校验:无 token `curl .../generic/openterminal-update/stable/latest.yml` 应 200 且 version 正确
|
||||
@@ -88,6 +88,7 @@ node tests/sysinfo-e2e.mjs
|
||||
8. **electron-updater 不支持 MSI 自动更新** → 更新通道走 NSIS exe;MSI 仅分发
|
||||
9. **命令库测试时间戳竞态**:saveLibraryItem 连续保存同一毫秒 createdAt 相同排序不稳 → 测试保存间 wait(2)
|
||||
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 发布后改回 `^` 语义化范围
|
||||
|
||||
## 编排约定
|
||||
|
||||
@@ -98,5 +99,6 @@ node tests/sysinfo-e2e.mjs
|
||||
## 待办(按优先级)
|
||||
|
||||
1. ~~应用图标~~(已完成:build/icon.png,程序生成的原创图标)
|
||||
2. 用户实测项:Ctrl+PgUp/PgDn 真实键盘(合成键盘无法验证修饰键)、真实服务器 rz/sz 一轮、全局唤起快捷键
|
||||
2. 用户实测项:Ctrl+PgUp/PgDn 真实键盘(合成键盘无法验证修饰键)、真实服务器 rz/sz 一轮、全局唤起快捷键、中文 IME 候选窗跟随光标(每次发版前手测一遍,CI 无法覆盖)
|
||||
3. `@xterm/xterm` 6.1.0 stable 发布后把精确锁定的 `6.1.0-beta.304` 改回 `^` 范围并回归验证 IME(背景见已踩坑 11)
|
||||
3. 小项:autoWrap=false 固定列宽、OSC 标题跟随、内置 OFL 字体打包、WebGL 终端数上限降级、最近命令历史出现两条命令拼接的记录(广播键入时行捕获合并,低优先级修)
|
||||
Generated
+6
-6
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "open-terminal",
|
||||
"version": "1.0.14",
|
||||
"version": "1.0.20",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "open-terminal",
|
||||
"version": "1.0.14",
|
||||
"version": "1.0.20",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lydell/node-pty": "^1.2.0-beta.15",
|
||||
@@ -14,7 +14,7 @@
|
||||
"@xterm/addon-search": "^0.16.0",
|
||||
"@xterm/addon-serialize": "^0.14.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"@xterm/xterm": "6.1.0-beta.304",
|
||||
"antd": "^6.6.2",
|
||||
"dockview-react": "^8.2.0",
|
||||
"electron-updater": "^6.8.9",
|
||||
@@ -2855,9 +2855,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
|
||||
"version": "6.1.0-beta.304",
|
||||
"resolved": "https://registry.npmmirror.com/@xterm/xterm/-/xterm-6.1.0-beta.304.tgz",
|
||||
"integrity": "sha512-Wq9d4qFYslYQBIan+riXotdyurtREc6v+HWdAwV6Y2JSDI9eJ1dkWm/fzYb2rCGgGnM3baiYuSK4Z6OLNKLzDw==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"addons/*"
|
||||
|
||||
+4
-4
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "open-terminal",
|
||||
"productName": "OpenTerminal",
|
||||
"version": "1.0.19",
|
||||
"version": "1.0.20",
|
||||
"description": "Open-source terminal with SSH, split panes, themes and fonts",
|
||||
"main": "out/main/index.js",
|
||||
"author": "CodingPlan.Site",
|
||||
@@ -13,8 +13,8 @@
|
||||
"preview": "electron-vite preview",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json",
|
||||
"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/.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",
|
||||
"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",
|
||||
"predist": "npm test && npm install --package-lock-only",
|
||||
"dist": "electron-vite build && electron-builder --win msi nsis",
|
||||
"dist:dir": "electron-vite build && electron-builder --win --dir"
|
||||
},
|
||||
@@ -24,7 +24,7 @@
|
||||
"@xterm/addon-search": "^0.16.0",
|
||||
"@xterm/addon-serialize": "^0.14.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"@xterm/xterm": "6.1.0-beta.304",
|
||||
"antd": "^6.6.2",
|
||||
"dockview-react": "^8.2.0",
|
||||
"electron-updater": "^6.8.9",
|
||||
|
||||
+11
-37
@@ -7,6 +7,8 @@ import { isTrustedRendererUrl, registerIpc } from './ipc'
|
||||
import { killAllPtys, killPtysByOwner } from './pty'
|
||||
import { applyStartupSystemSettings, loadSettings } from './settingsStore'
|
||||
import { getLockController, initLockController } from './lockController'
|
||||
import { applyMenuLockState } from './lockMenu'
|
||||
import { isLockBlockedShortcut, isPanicLockChord } from './lockShortcuts'
|
||||
import { initTray, markQuitting, onMainWindowClose, refreshTrayMenu } from './tray'
|
||||
import { configureAutoUpdater, registerUpdateIpc } from './updater'
|
||||
import { applyWindowChrome } from './windowChrome'
|
||||
@@ -92,8 +94,10 @@ if (!gotSingleInstanceLock) {
|
||||
// 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
|
||||
// idle watcher, which is why it belongs after ready: powerMonitor cannot be
|
||||
// touched before that.
|
||||
initLockController()
|
||||
// touched before that. A restored startup lock engages before any publish,
|
||||
// so the menu teardown is applied here from the flag itself.
|
||||
const lock = initLockController()
|
||||
applyMenuLockState(lock.isLocked())
|
||||
createWindow()
|
||||
initTray(showOrCreate)
|
||||
// Tray labels are resolved from the dictionary at build time, so the menu has
|
||||
@@ -106,31 +110,6 @@ 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 {
|
||||
// Dev-mode window/taskbar icon; packaged builds inherit the exe icon
|
||||
// (electron-builder embeds build/icon.png), so undefined is fine there.
|
||||
@@ -169,7 +148,9 @@ function createWindow(): void {
|
||||
// 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
|
||||
// away when a lock actually engages — an unconfigured app keeps Ctrl+L for
|
||||
// the shell's clear-screen.
|
||||
// the shell's clear-screen. Auto-repeats are skipped: the first keydown
|
||||
// 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
|
||||
// overlay (see isLockBlockedShortcut). A throw in here would break typing
|
||||
// altogether, so the whole guard is defensive.
|
||||
@@ -177,15 +158,8 @@ function createWindow(): void {
|
||||
try {
|
||||
if (input.type !== 'keyDown') return
|
||||
const lock = getLockController()
|
||||
if (
|
||||
!lock.isLocked() &&
|
||||
input.control &&
|
||||
!input.shift &&
|
||||
!input.alt &&
|
||||
!input.meta &&
|
||||
input.key.toLowerCase() === 'l'
|
||||
) {
|
||||
if (lock.lockNow().locked) event.preventDefault()
|
||||
if (!lock.isLocked() && isPanicLockChord(input)) {
|
||||
if (!input.isAutoRepeat && lock.lockNow().locked) event.preventDefault()
|
||||
return
|
||||
}
|
||||
if (!lock.isLocked()) return
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
isValidPassword
|
||||
} from './lockStore'
|
||||
import { loadSettings, mutateSettings } from './settingsStore'
|
||||
import { applyMenuLockState } from './lockMenu'
|
||||
|
||||
/**
|
||||
* Backoff after each failed verification: the nth failure refuses further
|
||||
@@ -90,7 +91,14 @@ export class LockController {
|
||||
options.stateStore ?? new LockStateStore(defaultLockStatePath(app.getPath('userData')))
|
||||
this.getLockSettings = options.getLockSettings ?? ((): LockSettings => loadSettings().lock)
|
||||
this.publish =
|
||||
options.publish ?? ((state): void => broadcast(Ipc.LOCK_STATE_CHANGED, state))
|
||||
options.publish ??
|
||||
((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.idleSeconds = options.idleSeconds ?? ((): number => powerMonitor.getSystemIdleTime())
|
||||
this.clearLockPreferences =
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
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))
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* 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'
|
||||
)
|
||||
}
|
||||
@@ -68,6 +68,23 @@ export function LockScreen({ state, onStateChange }: LockScreenProps): React.JSX
|
||||
return () => window.clearInterval(id)
|
||||
}, [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 cooling = remainingMs > 0
|
||||
|
||||
|
||||
@@ -500,8 +500,17 @@ function acceleratorFromEvent(e: React.KeyboardEvent<HTMLInputElement>): string
|
||||
*/
|
||||
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 */
|
||||
function isReservedAccelerator(accel: string): boolean {
|
||||
if (RESERVED_EXACT_ACCELERATORS.has(accel)) return true
|
||||
const parts = accel.split('+')
|
||||
return parts.includes('Control') && RESERVED_CONTROL_KEYS.has(parts[parts.length - 1])
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ const BUNDLES = [
|
||||
// Pulls in settingsStore + broadcast, which is why the electron stub needs
|
||||
// powerMonitor as well.
|
||||
{ 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
|
||||
// Sentry from the same library.
|
||||
{ entry: 'src/main/zmodem.ts', out: 'tests/.zmodem-e2e.cjs', external: ['ssh2'] },
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* 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')
|
||||
Reference in new issue
Block a user