Compare commits

...
1 Commits
Author SHA1 Message Date
Bill 86f51df2e6 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
2026-09-30 00:38:57 +08:00
11 changed files with 229 additions and 44 deletions

No files matched your search

+1
View File
@@ -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
+5 -5
View File
@@ -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 全程只跑一次),产物在 `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 -1
View File
@@ -13,7 +13,7 @@
"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",
"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",
"dist": "electron-vite build && electron-builder --win msi nsis",
"dist:dir": "electron-vite build && electron-builder --win --dir"
+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])
}
+2
View File
@@ -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'] },
+81
View File
@@ -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')