fix(lock): harden lock screen input paths
CI / typecheck + test + build (windows) (push) Canceled after 0s
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:
1 parent
dd08f8054a
commit
86f51df2e6
11 files changed
+229
-44
No files matched your search
+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])
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user