diff --git a/.gitignore b/.gitignore index 70feaaa..9f7ef06 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 65bfc20..04edf65 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`(键盘分类器表驱动用例) ## 关键词高亮 diff --git a/package.json b/package.json index ae6e010..cddffe8 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/main/index.ts b/src/main/index.ts index 22ec392..d0faf80 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 diff --git a/src/main/lockController.ts b/src/main/lockController.ts index 78fbb78..2c085a0 100644 --- a/src/main/lockController.ts +++ b/src/main/lockController.ts @@ -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 = diff --git a/src/main/lockMenu.ts b/src/main/lockMenu.ts new file mode 100644 index 0000000..03ffe46 --- /dev/null +++ b/src/main/lockMenu.ts @@ -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)) +} diff --git a/src/main/lockShortcuts.ts b/src/main/lockShortcuts.ts new file mode 100644 index 0000000..3482810 --- /dev/null +++ b/src/main/lockShortcuts.ts @@ -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' + ) +} diff --git a/src/renderer/src/lock/LockScreen.tsx b/src/renderer/src/lock/LockScreen.tsx index e8488ed..4c3ca37 100644 --- a/src/renderer/src/lock/LockScreen.tsx +++ b/src/renderer/src/lock/LockScreen.tsx @@ -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 diff --git a/src/renderer/src/settings/SettingsTabs.tsx b/src/renderer/src/settings/SettingsTabs.tsx index 79ed10b..b9f1b0d 100644 --- a/src/renderer/src/settings/SettingsTabs.tsx +++ b/src/renderer/src/settings/SettingsTabs.tsx @@ -500,8 +500,17 @@ function acceleratorFromEvent(e: React.KeyboardEvent): 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]) } diff --git a/tests/build-bundles.cjs b/tests/build-bundles.cjs index 56ff2a7..8331523 100644 --- a/tests/build-bundles.cjs +++ b/tests/build-bundles.cjs @@ -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'] }, diff --git a/tests/lock-shortcuts.mjs b/tests/lock-shortcuts.mjs new file mode 100644 index 0000000..1708e8e --- /dev/null +++ b/tests/lock-shortcuts.mjs @@ -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')