fix(lock): harden lock screen input paths
CI / typecheck + test + build (windows) (push) Canceled after 0s

- remove the application menu while locked (lockMenu.ts): Alt reveals the
  default menu and its mouse-clickable Reload/DevTools/Zoom items bypass
  before-input-event entirely; menu is rebuilt from the default template on
  unlock, startup-restored locks covered from initLockController
- extract the keyboard classification into pure lockShortcuts.ts
  (isLockBlockedShortcut/isPanicLockChord) with a table-driven test
  (lock-shortcuts.mjs, 29 cases) — the v1.0.17 lockout escaped CI because
  this decision lived inline in createWindow()
- reserve Ctrl+L in the global show/hide shortcut recorder: a global
  registration intercepts the chord at OS level and silently disables the
  panic lock
- skip auto-repeat keydowns in the panic-lock branch (held Ctrl+L on an
  unconfigured app re-read lock.json + settings.json per repeat)
- LockScreen: re-sync the cooldown clock on visibilitychange/focus/pageshow
  so a suspended renderer timer cannot leave the password input disabled
  past the real deadline
This commit is contained in:
Bill committed 2026-09-30 00:38:57 +08:00
1 parent dd08f8054a
commit 86f51df2e6
11 files changed
+229 -44

No files matched your search

+11 -37
View File
@@ -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
+9 -1
View File
@@ -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 =
+36
View File
@@ -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))
}
+57
View File
@@ -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'
)
}
+17
View File
@@ -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])
}