diff --git a/package-lock.json b/package-lock.json index 019b78d..cda8686 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5344,7 +5344,6 @@ "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, diff --git a/src/main/index.ts b/src/main/index.ts index 9e6f0fe..783041d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, Menu, clipboard, dialog, ipcMain, protocol, session, shell } from 'electron'; +import { app, BrowserWindow, Menu, clipboard, dialog, ipcMain, protocol, safeStorage, session, shell } from 'electron'; import { join } from 'path'; import { existsSync, mkdirSync, promises as fsp } from 'fs'; import { get as httpsGet } from 'https'; @@ -107,6 +107,16 @@ document.addEventListener('keydown', function(e) { } }, true);`; +// ── Safe external URL opener (only http/https) ── +function safeOpenExternal(url: string): void { + try { + const parsed = new URL(url); + if (parsed.protocol === 'https:' || parsed.protocol === 'http:') { + shell.openExternal(url); + } + } catch { /* malformed URL — ignore */ } +} + // ── Keybind matching ── function matchesKeybind(input: { key: string; control: boolean; shift: boolean; alt: boolean }, bind: Keybind | undefined): boolean { if (!bind) return false; @@ -221,23 +231,29 @@ async function launchApp(): Promise { electronLog.log(`[KCC] Userscripts: ${userscriptManager ? 'enabled' : 'disabled'} (${usDir})`); // ── Ad blocking + resource swapper (single onBeforeRequest — Electron only allows one) ── - ses.webRequest.onBeforeRequest({ urls: [...BLOCKED_URL_PATTERNS] }, (details, callback) => { + // The broad *://*.krunker.io/* pattern lets the swapper intercept any krunker asset. + // swapper.getRedirect() returns null before its async scan completes, so swapped + // resources simply pass through until the scan finishes — no re-registration needed. + const requestFilterUrls = swapper + ? [...BLOCKED_URL_PATTERNS, '*://*.krunker.io/*'] + : [...BLOCKED_URL_PATTERNS]; + + ses.webRequest.onBeforeRequest({ urls: requestFilterUrls }, (details, callback) => { if (swapper) { const redirect = swapper.getRedirect(details.url); if (redirect) return callback({ redirectURL: redirect }); } + // If we got here via the broad krunker.io pattern (not an ad), let it through + try { + const host = new URL(details.url).hostname; + if (host.endsWith('krunker.io')) return callback({}); + } catch {} + // Otherwise it matched an ad-block pattern — cancel it callback({ cancel: true }); }); - // Once swapper scan finishes, re-register with swapper patterns included if (swapper) { swapper.waitForReady().then(() => { - const filterUrls = [...BLOCKED_URL_PATTERNS, ...swapper.patterns]; - ses.webRequest.onBeforeRequest({ urls: filterUrls }, (details, callback) => { - const redirect = swapper.getRedirect(details.url); - if (redirect) return callback({ redirectURL: redirect }); - callback({ cancel: true }); - }); electronLog.log(`[KCC] Swapper ready: ${swapper.patterns.length} pattern(s)`); }); } @@ -390,7 +406,7 @@ async function launchApp(): Promise { if (subUrl.includes('krunker.io')) { sub.loadURL(subUrl); } else { - setImmediate(() => shell.openExternal(subUrl)); + setImmediate(() => safeOpenExternal(subUrl)); } return { action: 'deny' }; }); @@ -437,7 +453,7 @@ async function launchApp(): Promise { } } } else { - setImmediate(() => shell.openExternal(url)); + setImmediate(() => safeOpenExternal(url)); } return { action: 'deny' }; }); @@ -457,32 +473,44 @@ async function launchApp(): Promise { }); // ── IPC handlers ── + const ALLOWED_CONFIG_KEYS = new Set([ + 'window', 'performance', 'game', 'swapper', 'matchmaker', + 'keybinds', 'userscripts', 'ui', 'discord', 'translator', + 'advanced', 'accounts', 'platform', + ]); + ipcMain.handle('get-version', () => appVersion); ipcMain.handle('get-platform', () => platformInfo); - ipcMain.handle('get-config', (_e, key: string) => config.get(key as keyof typeof config.store)); + ipcMain.handle('get-config', (_e, key: string) => { + if (!ALLOWED_CONFIG_KEYS.has(key)) return undefined; + return config.get(key as keyof typeof config.store); + }); ipcMain.handle('get-all-config', (_e, keys: string[]) => { const result: Record = {}; - for (const key of keys) result[key] = config.get(key as keyof typeof config.store); + for (const key of keys) { + if (ALLOWED_CONFIG_KEYS.has(key)) result[key] = config.get(key as keyof typeof config.store); + } return result; }); let configWriteTimer: ReturnType | null = null; const pendingConfigWrites = new Map(); ipcMain.handle('set-config', (_e, key: string, value: unknown) => { + if (!ALLOWED_CONFIG_KEYS.has(key)) return; // Flush immediately for keys that have side effects if (key === 'keybinds') { config.set(key as any, value); cachedKeybinds = null; return; } + // Invalidate caches immediately (not on flush) to prevent stale reads + if (key === 'game') cachedGameConf = null; pendingConfigWrites.set(key, value); if (!configWriteTimer) { configWriteTimer = setTimeout(() => { for (const [k, v] of pendingConfigWrites) { config.set(k as any, v); } - // Invalidate caches for keys that affect runtime behavior - if (pendingConfigWrites.has('game')) cachedGameConf = null; pendingConfigWrites.clear(); configWriteTimer = null; }, 300); @@ -512,7 +540,7 @@ async function launchApp(): Promise { } try { const data = await new Promise((resolve, reject) => { - httpsGet('https://matchmaker.krunker.io/ping-list?hostname=krunker.io', { rejectUnauthorized: false }, (res) => { + httpsGet('https://matchmaker.krunker.io/ping-list?hostname=krunker.io', (res) => { let body = ''; res.on('data', (chunk: string) => { body += chunk; }); res.on('end', () => resolve(body)); @@ -620,16 +648,53 @@ async function launchApp(): Promise { app.quit(); }); - // ── Alt manager IPC handlers ── - ipcMain.handle('alt-list', () => config.get('accounts') || []); + // ── Alt manager IPC handlers (credentials encrypted via safeStorage) ── + const canEncrypt = safeStorage.isEncryptionAvailable(); + if (!canEncrypt) electronLog.warn('[KCC] safeStorage encryption not available — account passwords will use base64 fallback'); - ipcMain.handle('alt-save', (_e, account: SavedAccount) => { + function encryptString(plaintext: string): string { + if (canEncrypt) return safeStorage.encryptString(plaintext).toString('base64'); + return Buffer.from(plaintext).toString('base64'); + } + + function decryptString(encrypted: string): string { + if (canEncrypt) return safeStorage.decryptString(Buffer.from(encrypted, 'base64')); + return Buffer.from(encrypted, 'base64').toString(); + } + + ipcMain.handle('alt-list', () => { const accounts = config.get('accounts') || []; + // Return only labels to the renderer — never send encrypted credentials + return accounts.map((a: SavedAccount) => ({ label: a.label })); + }); + + ipcMain.handle('alt-save', (_e, data: { label: string; username: string; password: string }) => { + const accounts = config.get('accounts') || []; + const account: SavedAccount = { + label: data.label, + username: encryptString(data.username), + password: encryptString(data.password), + }; accounts.push(account); config.set('accounts', accounts); return { success: true, index: accounts.length - 1 }; }); + ipcMain.handle('alt-get-credentials', (_e, index: number) => { + const accounts = config.get('accounts') || []; + if (index < 0 || index >= accounts.length) return null; + const acc = accounts[index]; + try { + return { + username: decryptString(acc.username), + password: decryptString(acc.password), + }; + } catch (err) { + electronLog.error('[KCC] Failed to decrypt account credentials:', err); + return null; + } + }); + ipcMain.handle('alt-remove', (_e, index: number) => { const accounts = config.get('accounts') || []; if (index < 0 || index >= accounts.length) return { success: false }; diff --git a/src/main/swapper.ts b/src/main/swapper.ts index c5848f5..4448d6d 100644 --- a/src/main/swapper.ts +++ b/src/main/swapper.ts @@ -4,7 +4,6 @@ import { protocol, net } from 'electron'; const PROTOCOL_NAME = 'kpc-swap'; const TARGET_DOMAIN = 'krunker.io'; -const STANDARD_ASSET_RE = /^\/(?:models|textures|sound|scares|videos)(?:$|\/)/u; /** * Register the custom protocol scheme. Must be called BEFORE app.ready. diff --git a/src/main/updater.ts b/src/main/updater.ts index ca4990c..73ff22a 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -16,12 +16,25 @@ const UPDATE_CONFIG = { // Gitea provider (swap these for kpdclient.com migration) checkUrl: 'https://gitea.crjlab.net/api/v1/repos/bigjakk/Krunker-Civilian-Client/releases/latest', assetPattern: /Setup\.exe$/i, - rejectUnauthorized: false, + // Allowed hosts for update check and download (including redirects) + allowedHosts: ['gitea.crjlab.net'], }; const CHECK_TIMEOUT_MS = 10000; const DOWNLOAD_TIMEOUT_MS = 300000; // 5 minutes +/** + * Validate that a redirect URL stays on an allowed host. + */ +function isAllowedRedirect(url: string): boolean { + try { + const parsed = new URL(url); + return UPDATE_CONFIG.allowedHosts.some(h => parsed.hostname === h || parsed.hostname.endsWith('.' + h)); + } catch { + return false; + } +} + /** * Simple semver comparison: returns true if a < b. * Handles versions like "0.1.0", "1.2.3". @@ -45,15 +58,19 @@ export function checkForUpdate(currentVersion: string): Promise { electronLog.log('[KCC-Update] Check response status:', res.statusCode); - // Follow redirects + // Follow redirects (with domain validation) if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { - electronLog.log('[KCC-Update] Redirected to:', res.headers.location); - httpsGet(res.headers.location, { - rejectUnauthorized: UPDATE_CONFIG.rejectUnauthorized, + const redirectUrl = res.headers.location; + electronLog.log('[KCC-Update] Redirected to:', redirectUrl); + if (!isAllowedRedirect(redirectUrl)) { + electronLog.error('[KCC-Update] Redirect to untrusted host blocked:', redirectUrl); + resolve(null); + return; + } + httpsGet(redirectUrl, { headers: { 'User-Agent': 'KrunkerCivilianClient/' + currentVersion }, }, (redirectRes) => { electronLog.log('[KCC-Update] Redirect response status:', redirectRes.statusCode); @@ -97,6 +114,13 @@ export function checkForUpdate(currentVersion: string): Promise { const tmpPath = destPath + '.tmp'; - function doDownload(downloadUrl: string): void { + function doDownload(downloadUrl: string, redirectCount = 0): void { + if (redirectCount > 5) { + reject(new Error('Too many redirects')); + return; + } electronLog.log('[KCC-Update] Downloading from:', downloadUrl); const req = httpsGet(downloadUrl, { - rejectUnauthorized: UPDATE_CONFIG.rejectUnauthorized, headers: { 'User-Agent': 'KrunkerCivilianClient' }, }, (res) => { - // Follow redirects + // Follow redirects (with domain validation) if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { - electronLog.log('[KCC-Update] Download redirected to:', res.headers.location); - doDownload(res.headers.location); + const redirectUrl = res.headers.location; + electronLog.log('[KCC-Update] Download redirected to:', redirectUrl); + if (!isAllowedRedirect(redirectUrl)) { + electronLog.error('[KCC-Update] Download redirect to untrusted host blocked:', redirectUrl); + reject(new Error('Download redirect to untrusted host: ' + redirectUrl)); + return; + } + doDownload(redirectUrl, redirectCount + 1); return; } diff --git a/src/preload/index.ts b/src/preload/index.ts index aa55d63..9b4487c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -5,6 +5,7 @@ import { initUserscripts, getInstances, setScriptEnabled } from './userscripts'; import type { UserscriptInstance, UserscriptSetting } from './userscripts'; import { initTranslator, updateTranslatorConfig } from './translator'; import { setDeathAnimBlock, escapeHtml } from './utils'; +import type { Keybind } from '../main/config'; // ── Save console methods before Krunker overwrites them ── @@ -119,13 +120,7 @@ function updateRefreshNotification(): void { // ── Client settings tab in Krunker's settings ── -function hasOwn(obj: any, key: string): boolean { - return Object.prototype.hasOwnProperty.call(obj, key); -} - -// ── Keybind types + helpers ── -interface Keybind { key: string; ctrl: boolean; shift: boolean; alt: boolean; } - +// ── Keybind helpers ── function keybindDisplayString(bind: Keybind): string { return (bind.shift ? 'Shift+' : '') + (bind.ctrl ? 'Ctrl+' : '') + (bind.alt ? 'Alt+' : '') + bind.key.toUpperCase(); } @@ -677,19 +672,6 @@ function buildDiscordSection(body: HTMLElement, discordConf: any): void { } // ── Alt Manager helpers ── -function encodeCredential(decoded: string): string { - const key = decoded.length; - return encodeURIComponent( - decoded.split('').map(c => String.fromCharCode(c.charCodeAt(0) + key)).join('') - ); -} - -function decodeCredential(encoded: string): string { - const str = decodeURIComponent(encoded); - const key = str.length; - return str.split('').map(c => String.fromCharCode(c.charCodeAt(0) - key)).join(''); -} - function switchToAccount(account: { username: string; password: string }): void { const w = window as any; if (typeof w.loginOrRegister !== 'function') return; @@ -703,8 +685,8 @@ function switchToAccount(account: { username: string; password: string }): void const nameInput = document.querySelector('#accName') as HTMLInputElement; const passInput = document.querySelector('#accPass') as HTMLInputElement; if (!nameInput || !passInput) return; - nameInput.value = decodeCredential(account.username); - passInput.value = decodeCredential(account.password); + nameInput.value = account.username; + passInput.value = account.password; nameInput.dispatchEvent(new Event('input', { bubbles: true })); passInput.dispatchEvent(new Event('input', { bubbles: true })); const submitBtn = document.querySelector('.io-button') as HTMLElement; @@ -749,6 +731,11 @@ function buildAccountsSection(body: HTMLElement, accountsArr: any[]): void { const userIn = form.querySelector('.kpc-acc-user') as HTMLInputElement; const passIn = form.querySelector('.kpc-acc-pass') as HTMLInputElement; + // Stop Krunker's global keydown handler from eating keystrokes in our inputs + form.querySelectorAll('input').forEach(input => { + input.addEventListener('keydown', (e) => e.stopPropagation()); + }); + addBtn.querySelector('button')!.addEventListener('click', () => { form.style.display = form.style.display === 'none' ? '' : 'none'; }); @@ -777,7 +764,11 @@ function buildAccountsSection(body: HTMLElement, accountsArr: any[]): void { '' + '' + ''; - row.querySelector('.kpc-acc-switch')!.addEventListener('click', () => switchToAccount(acc)); + row.querySelector('.kpc-acc-switch')!.addEventListener('click', () => { + ipcRenderer.invoke('alt-get-credentials', i).then((creds: { username: string; password: string } | null) => { + if (creds) switchToAccount(creds); + }); + }); row.querySelector('.kpc-acc-delete')!.addEventListener('click', () => { ipcRenderer.invoke('alt-remove', i).then(() => { accounts.splice(i, 1); @@ -794,13 +785,9 @@ function buildAccountsSection(body: HTMLElement, accountsArr: any[]): void { const user = userIn.value.trim(); const pass = passIn.value; if (!label || !user || !pass) return; - const newAcc = { - label, - username: encodeCredential(user), - password: encodeCredential(pass), - }; + const newAcc = { label, username: user, password: pass }; ipcRenderer.invoke('alt-save', newAcc).then(() => { - accounts.push(newAcc); + accounts.push({ label }); labelIn.value = ''; userIn.value = ''; passIn.value = ''; @@ -1555,7 +1542,9 @@ ipcRenderer.on('main_did-finish-load', () => { const idx = parseInt((el as HTMLElement).dataset.idx || '0', 10); if (accs[idx]) { windowHolder.style.display = 'none'; - switchToAccount(accs[idx]); + ipcRenderer.invoke('alt-get-credentials', idx).then((creds: { username: string; password: string } | null) => { + if (creds) switchToAccount(creds); + }); } }); }); @@ -1584,6 +1573,11 @@ ipcRenderer.on('main_did-finish-load', () => { '' + ''; + // Stop Krunker's global keydown handler from eating keystrokes in our inputs + menuWindow.querySelectorAll('input.accountInput').forEach((input) => { + input.addEventListener('keydown', (e) => e.stopPropagation()); + }); + document.getElementById('kpcAltBackBtn')!.addEventListener('click', renderAccountList); document.getElementById('kpcAltSaveBtn')!.addEventListener('click', () => { const label = (document.getElementById('kpcAltLabel') as HTMLInputElement).value.trim(); @@ -1592,8 +1586,8 @@ ipcRenderer.on('main_did-finish-load', () => { if (!label || !user || !pass) return; ipcRenderer.invoke('alt-save', { label, - username: encodeCredential(user), - password: encodeCredential(pass), + username: user, + password: pass, }).then(() => renderAccountList()); }); } @@ -1634,9 +1628,9 @@ ipcRenderer.on('main_did-finish-load', () => { const pollInterval = setInterval(() => { const w = window as any; if ( - hasOwn(w, 'showWindow') + Object.hasOwn(w, 'showWindow') && typeof w.showWindow === 'function' - && hasOwn(w, 'windows') + && Object.hasOwn(w, 'windows') && Array.isArray(w.windows) && w.windows.length >= 0 && typeof w.windows[0] !== 'undefined' diff --git a/src/preload/matchmaker.ts b/src/preload/matchmaker.ts index c3c96ed..8c77be8 100644 --- a/src/preload/matchmaker.ts +++ b/src/preload/matchmaker.ts @@ -39,39 +39,57 @@ function secondsToTimestring(num: number): string { return `${minutes}m ${seconds}s`; } -// ── Popup DOM (created once, reused) ── +// ── Popup DOM (lazy-initialized on first use) ── const POPUP_ID = 'matchmakerPopupContainer'; -const popupElement = document.createElement('div'); -popupElement.id = POPUP_ID; -const popupTitle = document.createElement('div'); -popupTitle.id = 'matchmakerPopupTitle'; -popupElement.appendChild(popupTitle); +interface PopupDOM { + element: HTMLDivElement; + title: HTMLDivElement; + description: HTMLDivElement; + confirmBtn: HTMLDivElement; + cancelBtn: HTMLDivElement; +} -const popupDescription = document.createElement('div'); -popupDescription.id = 'matchmakerPopupDescription'; -popupElement.appendChild(popupDescription); +let _popup: PopupDOM | null = null; -const popupOptions = document.createElement('div'); -popupOptions.id = 'matchmakerPopupOptions'; +function getPopup(): PopupDOM { + if (_popup) return _popup; -const popupConfirmBtn = document.createElement('div'); -popupConfirmBtn.id = 'matchmakerConfirmButton'; -popupConfirmBtn.className = 'matchmakerPopupButton bigShadowT'; -popupConfirmBtn.textContent = 'Join'; -popupConfirmBtn.setAttribute('onmouseenter', 'playTick()'); -popupConfirmBtn.addEventListener('click', () => decideMatchmakerDecision(true)); + const element = document.createElement('div'); + element.id = POPUP_ID; -const popupCancelBtn = document.createElement('div'); -popupCancelBtn.id = 'matchmakerCancelButton'; -popupCancelBtn.className = 'matchmakerPopupButton bigShadowT'; -popupCancelBtn.textContent = 'Cancel'; -popupCancelBtn.setAttribute('onmouseenter', 'playTick()'); -popupCancelBtn.addEventListener('click', () => decideMatchmakerDecision(false)); + const title = document.createElement('div'); + title.id = 'matchmakerPopupTitle'; + element.appendChild(title); -popupOptions.appendChild(popupConfirmBtn); -popupOptions.appendChild(popupCancelBtn); -popupElement.appendChild(popupOptions); + const description = document.createElement('div'); + description.id = 'matchmakerPopupDescription'; + element.appendChild(description); + + const options = document.createElement('div'); + options.id = 'matchmakerPopupOptions'; + + const confirmBtn = document.createElement('div'); + confirmBtn.id = 'matchmakerConfirmButton'; + confirmBtn.className = 'matchmakerPopupButton bigShadowT'; + confirmBtn.textContent = 'Join'; + confirmBtn.addEventListener('mouseenter', () => { (window as any).playTick?.(); }); + confirmBtn.addEventListener('click', () => decideMatchmakerDecision(true)); + + const cancelBtn = document.createElement('div'); + cancelBtn.id = 'matchmakerCancelButton'; + cancelBtn.className = 'matchmakerPopupButton bigShadowT'; + cancelBtn.textContent = 'Cancel'; + cancelBtn.addEventListener('mouseenter', () => { (window as any).playTick?.(); }); + cancelBtn.addEventListener('click', () => decideMatchmakerDecision(false)); + + options.appendChild(confirmBtn); + options.appendChild(cancelBtn); + element.appendChild(options); + + _popup = { element, title, description, confirmBtn, cancelBtn }; + return _popup; +} // ── State ── let currentMatch = ''; @@ -86,7 +104,8 @@ function decideMatchmakerDecision(accept: boolean): void { if (accept && currentMatch !== 'none') { window.location.href = `https://krunker.io/?game=${currentMatch}`; } else { - if (popupElement.parentNode) popupElement.remove(); + const popup = getPopup(); + if (popup.element.parentNode) popup.element.remove(); if (currentMatch === 'none' && openServerBrowser && typeof w.openServerWindow === 'function') { w.openServerWindow(0); } @@ -112,24 +131,32 @@ function handleMatchmakerBind(event: KeyboardEvent): void { } function createFetchedGamePopup(game: MatchmakerGame): void { + const popup = getPopup(); const mapIdx = MAP_ICON_INDICES.indexOf(game.map); - popupElement.style.backgroundImage = `url(https://assets.krunker.io/img/maps/map_${mapIdx >= 0 ? mapIdx : 0}.png)`; + popup.element.style.backgroundImage = `url(https://assets.krunker.io/img/maps/map_${mapIdx >= 0 ? mapIdx : 0}.png)`; currentMatch = game.gameID; if (game.gameID === 'none') { - popupTitle.innerText = 'No Games Found...'; - popupDescription.innerHTML = 'Check the server browser to see other lobbies.'; - popupConfirmBtn.style.display = 'none'; + popup.title.textContent = 'No Games Found...'; + popup.description.textContent = 'Check the server browser to see other lobbies.'; + popup.confirmBtn.style.display = 'none'; } else { - popupTitle.innerText = 'Game Found!'; + popup.title.textContent = 'Game Found!'; const regionName = MATCHMAKER_REGION_NAMES[game.region] ?? 'Unknown Region'; - popupDescription.innerHTML = `${game.gamemode} on ${game.map} (${regionName})
${game.playerCount}/${game.playerLimit} Players, ${secondsToTimestring(game.remainingTime)} Left`; - popupConfirmBtn.style.display = 'block'; + popup.description.textContent = ''; + popup.description.appendChild(document.createTextNode( + `${game.gamemode} on ${game.map} (${regionName})` + )); + popup.description.appendChild(document.createElement('br')); + popup.description.appendChild(document.createTextNode( + `${game.playerCount}/${game.playerLimit} Players, ${secondsToTimestring(game.remainingTime)} Left` + )); + popup.confirmBtn.style.display = 'block'; } document.addEventListener('keydown', handleMatchmakerBind, true); const uiBase = document.getElementById('uiBase'); - if (uiBase) uiBase.appendChild(popupElement); + if (uiBase) uiBase.appendChild(popup.element); } export async function fetchGame(mmConfig: MatchmakerConfig, _con?: SavedConsole): Promise { diff --git a/src/preload/utils.ts b/src/preload/utils.ts index 865fbe3..b77dfb8 100644 --- a/src/preload/utils.ts +++ b/src/preload/utils.ts @@ -9,13 +9,6 @@ export interface SavedConsole { error: (...args: unknown[]) => void; } -export interface KeybindDef { - key: string; - ctrl: boolean; - shift: boolean; - alt: boolean; -} - // ── HTML escaping ── const HTML_ESCAPE_MAP: Record = {