From 83dc3f15ccac455cabf508e4f72b6712b51e7530 Mon Sep 17 00:00:00 2001 From: Bill Date: Thu, 24 Sep 2026 22:53:27 +0800 Subject: [PATCH] fix(lock): lock screen swallowed every keystroke; add Ctrl+L to lock The renderer-side guard added in v1.0.17 called preventDefault on every keydown while locked. A keydown's default action IS inserting the character into the focused field, so the lock screen's password box received nothing and a locked app could never be unlocked. Menu accelerators are already stopped in main (before-input-event); the renderer guard now just skips its own logic. Also: Ctrl+L locks the screen from anywhere in the app, terminals included. The chord is only taken when a lock actually engages, so an unconfigured app keeps Ctrl+L for the shell's clear-screen. --- AGENTS.md | 3 ++- src/main/index.ts | 19 ++++++++++++++++++- src/renderer/src/main.tsx | 16 +++++++--------- src/shared/i18n/dicts/en/settings.ts | 2 +- src/shared/i18n/dicts/ja/settings.ts | 2 +- src/shared/i18n/dicts/zh-CN/settings.ts | 2 +- src/shared/i18n/dicts/zh-TW/settings.ts | 2 +- 7 files changed, 31 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0cd7f69..a82e7db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,7 +62,8 @@ 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`) +- 锁屏期间主进程在 `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 的清屏;已锁定时不再拦截 - 启动时**不要**用 `locked: true` 作渲染层初值再直接画锁屏:`App.tsx` 用 `null` 表示「主进程还没答复」,此时只画 `.lock-screen-boot` 纯色层,否则每次启动都会给没设密码的用户闪一帧锁屏。`getLockState()` 失败时要落到「locked 且未配置」的状态,让输入框可达(主进程对无 verifier 的解锁请求直接放行) - 相关测试:`node tests/lock-store.mjs`(verifier + 状态存储)、`node tests/lock-controller.mjs`(冷却阶梯、并发串行化、落盘恢复、闲置触发、清除联动) diff --git a/src/main/index.ts b/src/main/index.ts index da238af..22ec392 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -166,12 +166,29 @@ 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. // 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. win.webContents.on('before-input-event', (event, input) => { try { - if (input.type !== 'keyDown' || !getLockController().isLocked()) return + 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() + return + } + if (!lock.isLocked()) return if (isLockBlockedShortcut(input)) event.preventDefault() } catch { /* an input guard must never take the window down with it */ diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index bce9b74..a4d6f61 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -157,15 +157,13 @@ function FontHotkeyListener(): null { useEffect(() => { const onKeyDown = (e: KeyboardEvent): void => { - // The lock overlay leaves Workspace mounted; window-capture hotkeys must - // not mutate font size on a shell hidden behind it. preventDefault matters - // here: returning alone lets the chord reach Electron's default menu - // accelerators (zoomIn/zoomOut/resetZoom), which rescale the whole UI - // behind the opaque mask and make Chromium persist that zoom per origin. - if (document.documentElement.dataset.locked === 'true') { - e.preventDefault() - return - } + // While locked, skip only this listener's own logic — and NEVER call + // preventDefault here. The default action of a keydown is inserting the + // character into the focused field, and it runs after the whole dispatch: + // preventing it at window level kills every keystroke the lock screen's + // password field would receive (shipped broken in v1.0.17). The menu + // accelerators are stopped in main, on before-input-event, instead. + if (document.documentElement.dataset.locked === 'true') return if (!e.ctrlKey || e.metaKey) return const target = e.target as HTMLElement | null const isXtermHelper = diff --git a/src/shared/i18n/dicts/en/settings.ts b/src/shared/i18n/dicts/en/settings.ts index 1ba6c68..3ebf030 100644 --- a/src/shared/i18n/dicts/en/settings.ts +++ b/src/shared/i18n/dicts/en/settings.ts @@ -134,7 +134,7 @@ const settings: Record = { 'settings.lock.lockAtStartup': 'Lock at startup', 'settings.lock.lockAtStartupDesc': 'Ask for the password right after every launch', 'settings.lock.lockNow': 'Lock now', - 'settings.lock.lockNowDesc': 'Lock the main window immediately (sessions keep running)', + 'settings.lock.lockNowDesc': 'Lock the main window immediately (sessions keep running, Ctrl+L)', 'settings.lock.title': 'Locked', 'settings.lock.screenDesc': 'Enter the lock password to unlock', 'settings.lock.passwordPlaceholder': 'Password', diff --git a/src/shared/i18n/dicts/ja/settings.ts b/src/shared/i18n/dicts/ja/settings.ts index 3d2d129..1e5ae68 100644 --- a/src/shared/i18n/dicts/ja/settings.ts +++ b/src/shared/i18n/dicts/ja/settings.ts @@ -131,7 +131,7 @@ const settings: Record = { 'settings.lock.lockAtStartup': '起動時にロック', 'settings.lock.lockAtStartupDesc': '起動直後にパスワードの入力を求めます', 'settings.lock.lockNow': '今すぐロック', - 'settings.lock.lockNowDesc': 'メインウィンドウをすぐにロックします(セッションは動作を続けます)', + 'settings.lock.lockNowDesc': 'メインウィンドウをすぐにロックします(セッションは動作を続けます、Ctrl+L)', 'settings.lock.title': 'ロック中', 'settings.lock.screenDesc': 'ロックパスワードを入力して解除します', 'settings.lock.passwordPlaceholder': 'パスワード', diff --git a/src/shared/i18n/dicts/zh-CN/settings.ts b/src/shared/i18n/dicts/zh-CN/settings.ts index b2da98f..d5371bc 100644 --- a/src/shared/i18n/dicts/zh-CN/settings.ts +++ b/src/shared/i18n/dicts/zh-CN/settings.ts @@ -126,7 +126,7 @@ const settings: Record = { 'settings.lock.lockAtStartup': '启动时锁屏', 'settings.lock.lockAtStartupDesc': '每次启动后先要求输入密码', 'settings.lock.lockNow': '立即锁屏', - 'settings.lock.lockNowDesc': '马上锁定主窗口(会话保持运行)', + 'settings.lock.lockNowDesc': '马上锁定主窗口(会话保持运行,快捷键 Ctrl+L)', 'settings.lock.title': '已锁定', 'settings.lock.screenDesc': '输入锁屏密码解锁', 'settings.lock.passwordPlaceholder': '密码', diff --git a/src/shared/i18n/dicts/zh-TW/settings.ts b/src/shared/i18n/dicts/zh-TW/settings.ts index 71600ef..50fba30 100644 --- a/src/shared/i18n/dicts/zh-TW/settings.ts +++ b/src/shared/i18n/dicts/zh-TW/settings.ts @@ -126,7 +126,7 @@ const settings: Record = { 'settings.lock.lockAtStartup': '啟動時鎖定', 'settings.lock.lockAtStartupDesc': '每次啟動後先要求輸入密碼', 'settings.lock.lockNow': '立即鎖定', - 'settings.lock.lockNowDesc': '馬上鎖定主視窗(工作階段保持運作)', + 'settings.lock.lockNowDesc': '馬上鎖定主視窗(工作階段保持運作,快捷鍵 Ctrl+L)', 'settings.lock.title': '已鎖定', 'settings.lock.screenDesc': '輸入鎖定密碼以解鎖', 'settings.lock.passwordPlaceholder': '密碼',