From ad6e9ef270cf333f02dace15f1bf0ba87a44cae5 Mon Sep 17 00:00:00 2001 From: bigjakk Date: Fri, 10 Apr 2026 13:20:43 -0700 Subject: [PATCH] fix: escape setting labels in innerHTML for defense-in-depth --- src/preload/index.ts | 4014 +++++++++++++++++++++--------------------- 1 file changed, 1988 insertions(+), 2026 deletions(-) diff --git a/src/preload/index.ts b/src/preload/index.ts index 2206f82..8f64248 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,2026 +1,1988 @@ -import { ipcRenderer } from 'electron'; -import { fetchGame, MATCHMAKER_GAMEMODE_FILTER, MATCHMAKER_REGIONS, MATCHMAKER_REGION_NAMES, MATCHMAKER_MAP_FILTER, MATCHMAKER_MAP_NAMES } from './matchmaker'; -import type { MatchmakerConfig } from './matchmaker'; -import { initUserscripts, getInstances, setScriptEnabled } from './userscripts'; -import type { UserscriptInstance } from './userscripts'; -import { initTranslator, updateTranslatorConfig } from './translator'; -import { setDeathAnimBlock, setMenuTimer, escapeHtml } from './utils'; -import { initChat, setBetterChat, setChatHistorySize } from './chat'; -import { initHPCounter, destroyHPCounter, initRankProgress } from './competitive'; -import { checkChangelog } from './changelog'; -import type { Keybind } from '../main/config'; - - -// ── Save console methods before Krunker overwrites them ── -// Wrapped to forward errors/warnings always, and logs when verbose is enabled -let _verboseLogging = false; - -const _console = { - log: (...args: unknown[]) => { - console.log(...args); - if (_verboseLogging) ipcRenderer.send('verbose-log', 'log', ...args); - }, - warn: (...args: unknown[]) => { - console.warn(...args); - ipcRenderer.send('verbose-log', 'warn', ...args); - }, - error: (...args: unknown[]) => { - console.error(...args); - ipcRenderer.send('verbose-log', 'error', ...args); - }, -}; - -_console.log('[KCC] Preload script loaded'); - -// ── Krunker-native settings styling constants (from Crankshaft) ── -const SAFETY_SVG = ''; -const REFRESH_SVG = ''; -const SAFETY_DESCS = [ - 'This setting is safe/standard', - 'Proceed with caution', - 'This setting is not recommended', - 'This setting is experimental', - 'This setting is experimental and unstable. Use at your own risk.', -]; - -const enum RefreshLevel { none, refresh, restart } -let refreshLevel: number = RefreshLevel.none; -let refreshPopupEl: HTMLElement | null = null; - -function safetyIcon(safety: string): string { - return '' + SAFETY_SVG + ''; -} - -function refreshIcon(mode: 'instant' | 'refresh-icon'): string { - return '' + REFRESH_SVG + ''; -} - -function restartIcon(): string { - return '' + SAFETY_SVG + ''; -} - -function settingIcon(safety: number, instant?: boolean, refreshOnly?: boolean, restart?: boolean): string { - if (safety > 0) return safetyIcon(SAFETY_DESCS[safety]); - if (instant) return refreshIcon('instant'); - if (refreshOnly) return refreshIcon('refresh-icon'); - if (restart) return restartIcon(); - return ''; -} - -function onSettingChanged(level: 'refresh' | 'restart'): void { - const newLevel = level === 'restart' ? RefreshLevel.restart : RefreshLevel.refresh; - if (newLevel > refreshLevel) refreshLevel = newLevel; - updateRefreshNotification(); -} - -function updateRefreshNotification(): void { - if (refreshLevel === RefreshLevel.none) { - if (refreshPopupEl) { refreshPopupEl.remove(); refreshPopupEl = null; } - return; - } - if (refreshPopupEl) { try { refreshPopupEl.remove(); } catch { /* noop */ } } - refreshPopupEl = document.createElement('div'); - refreshPopupEl.className = 'kpc-holder-update refresh-popup'; - if (refreshLevel === RefreshLevel.restart) { - refreshPopupEl.innerHTML = 'Restart client fully to see changes'; - } else { - refreshPopupEl.innerHTML = '' + refreshIcon('refresh-icon') + 'Reload page with F5 or CTRL + R to see changes'; - } - document.body.appendChild(refreshPopupEl); -} - -// ── Tell Krunker this is a client (enables "Client" settings tab) ── -(window as any).OffCliV = true; - -// ── IPC bridge exposed as window.kpc ── -(window as any).kpc = { - platform: { - getInfo: () => ipcRenderer.invoke('get-platform'), - }, - config: { - get: (key: string) => ipcRenderer.invoke('get-config', key), - getAll: (keys: string[]) => ipcRenderer.invoke('get-all-config', keys), - set: (key: string, value: unknown) => ipcRenderer.invoke('set-config', key, value), - }, - window: { - minimize: () => ipcRenderer.invoke('window-minimize'), - maximize: () => ipcRenderer.invoke('window-maximize'), - close: () => ipcRenderer.invoke('window-close'), - isMaximized: () => ipcRenderer.invoke('window-is-maximized'), - }, - dev: { - toggleDevTools: () => ipcRenderer.invoke('toggle-devtools'), - }, - swapper: { - openFolder: () => ipcRenderer.invoke('open-swap-folder'), - getPath: () => ipcRenderer.invoke('get-swap-dir'), - }, - userscripts: { - openFolder: () => ipcRenderer.invoke('userscripts-open-folder'), - getPath: () => ipcRenderer.invoke('userscripts-get-dir'), - }, -}; - -// ── Client settings tab in Krunker's settings ── - -// ── Keybind helpers ── -function keybindDisplayString(bind: Keybind): string { - return (bind.shift ? 'Shift+' : '') + (bind.ctrl ? 'Ctrl+' : '') + (bind.alt ? 'Alt+' : '') + bind.key.toUpperCase(); -} - -// ── Keybind capture dialog (Crankshaft-style) ── -let capturingKeybind: { resolve: (bind: Keybind) => void } | null = null; - -const kbOverlay = document.createElement('div'); -kbOverlay.className = 'kpc-keybind-overlay'; -const kbDialog = document.createElement('div'); -kbDialog.className = 'kpc-keybind-dialog'; -const kbTitle = document.createElement('div'); -kbTitle.className = 'kpc-keybind-dialog-title'; -const kbSub = document.createElement('div'); -kbSub.className = 'kpc-keybind-dialog-sub'; -kbSub.innerHTML = 'Press any key. Press Shift+Escape to cancel.'; -const kbModifiers = document.createElement('div'); -kbModifiers.className = 'kpc-keybind-dialog-modifiers'; -const kbShift = document.createElement('div'); -kbShift.className = 'kpc-keybind-modifier'; -kbShift.textContent = 'Shift'; -const kbCtrl = document.createElement('div'); -kbCtrl.className = 'kpc-keybind-modifier'; -kbCtrl.textContent = 'Control'; -const kbAlt = document.createElement('div'); -kbAlt.className = 'kpc-keybind-modifier'; -kbAlt.textContent = 'Alt'; -const kbCancel = document.createElement('div'); -kbCancel.className = 'kpc-keybind-dialog-cancel'; -kbCancel.textContent = 'Cancel'; -kbCancel.addEventListener('click', dismissKeybindDialog); - -kbModifiers.appendChild(kbShift); -kbModifiers.appendChild(kbCtrl); -kbModifiers.appendChild(kbAlt); -kbDialog.appendChild(kbCancel); -kbDialog.appendChild(kbTitle); -kbDialog.appendChild(kbSub); -kbDialog.appendChild(kbModifiers); -kbOverlay.appendChild(kbDialog); - -function dismissKeybindDialog(): void { - kbShift.classList.remove('active'); - kbCtrl.classList.remove('active'); - kbAlt.classList.remove('active'); - document.removeEventListener('keydown', kbKeydownHandler, true); - document.removeEventListener('keyup', kbKeyupHandler, true); - if (kbOverlay.parentNode) kbOverlay.remove(); - capturingKeybind = null; - ipcRenderer.send('keybind-capture', false); -} - -function kbKeydownHandler(event: KeyboardEvent): void { - event.stopImmediatePropagation(); - event.preventDefault(); - if (event.key === 'Control') kbCtrl.classList.add('active'); - else if (event.key === 'Shift') kbShift.classList.add('active'); - else if (event.key === 'Alt') kbAlt.classList.add('active'); -} - -function kbKeyupHandler(event: KeyboardEvent): void { - event.stopImmediatePropagation(); - event.preventDefault(); - if (!capturingKeybind) return; - - if (event.key === 'Escape' && event.shiftKey) { - dismissKeybindDialog(); - return; - } - - // Modifier-only releases just clear indicators - if (event.key === 'Shift' || event.key === 'Control' || event.key === 'Alt') { - const bind: Keybind = { key: event.key, ctrl: false, shift: false, alt: false }; - capturingKeybind.resolve(bind); - dismissKeybindDialog(); - return; - } - - const bind: Keybind = { - key: event.key, - ctrl: event.ctrlKey, - shift: event.shiftKey, - alt: event.altKey, - }; - capturingKeybind.resolve(bind); - dismissKeybindDialog(); -} - -function openKeybindDialog(title: string): Promise { - return new Promise((resolve) => { - capturingKeybind = { resolve }; - kbTitle.textContent = 'Edit Keybind: ' + title; - kbShift.classList.remove('active'); - kbCtrl.classList.remove('active'); - kbAlt.classList.remove('active'); - ipcRenderer.send('keybind-capture', true); - document.addEventListener('keydown', kbKeydownHandler, true); - document.addEventListener('keyup', kbKeyupHandler, true); - document.body.appendChild(kbOverlay); - }); -} - -function createKeybindRow(label: string, desc: string, currentBind: Keybind, onBind: (bind: Keybind) => void, safety?: number, instant?: boolean): HTMLElement { - const s = safety || 0; - const row = document.createElement('div'); - row.className = 'setting settName safety-' + s + ' keybind'; - row.innerHTML = - settingIcon(s, instant) + - '' + label + '' + - '' + keybindDisplayString(currentBind) + '' + - '
' + desc + '
'; - const keyEl = row.querySelector('.kpc-keyIcon') as HTMLElement; - keyEl.addEventListener('click', () => { - openKeybindDialog(label).then((newBind) => { - keyEl.textContent = keybindDisplayString(newBind); - onBind(newBind); - }); - }); - return row; -} - -function createToggleRow(opts: { - label: string; - desc: string; - checked: boolean; - onChange: (checked: boolean) => void; - restart?: boolean; - disabled?: boolean; - safety?: number; - instant?: boolean; - refreshOnly?: boolean; -}): HTMLElement { - const s = opts.safety || 0; - const row = document.createElement('div'); - row.className = 'setting settName safety-' + s + ' bool'; - row.innerHTML = - settingIcon(s, opts.instant, opts.refreshOnly, opts.restart) + - '' + opts.label + '' + - '' + - '
' + opts.desc + '
'; - if (!opts.disabled) { - const cb = row.querySelector('input[type="checkbox"]') as HTMLInputElement; - cb.addEventListener('change', () => { - opts.onChange(cb.checked); - if (opts.restart) onSettingChanged('restart'); - else if (opts.refreshOnly) onSettingChanged('refresh'); - }); - } - return row; -} - -function createSelectRow(opts: { - label: string; - desc: string; - options: Array<{ value: string; label: string }>; - value: string; - onChange: (value: string) => void; - restart?: boolean; - safety?: number; - instant?: boolean; - refreshOnly?: boolean; -}): HTMLElement { - const s = opts.safety || 0; - const row = document.createElement('div'); - row.className = 'setting settName safety-' + s + ' sel'; - row.innerHTML = - settingIcon(s, opts.instant, opts.refreshOnly, opts.restart) + - '' + opts.label + '' + - '
' + opts.desc + '
'; - const select = document.createElement('select'); - select.className = 's-update inputGrey2'; - for (const o of opts.options) { - const option = document.createElement('option'); - option.value = o.value; - option.textContent = o.label; - if (o.value === opts.value) option.selected = true; - select.appendChild(option); - } - select.addEventListener('change', () => { - opts.onChange(select.value); - if (opts.restart) onSettingChanged('restart'); - else if (opts.refreshOnly) onSettingChanged('refresh'); - }); - row.appendChild(select); - return row; -} - -function createNumberRow(opts: { - label: string; - desc: string; - min: number; - max: number; - value: number; - onChange: (value: number) => void; - safety?: number; - restart?: boolean; - instant?: boolean; - refreshOnly?: boolean; -}): HTMLElement { - const s = opts.safety || 0; - const row = document.createElement('div'); - row.className = 'setting settName safety-' + s + ' num'; - row.innerHTML = - settingIcon(s, opts.instant, opts.refreshOnly, opts.restart) + - '' + opts.label + '' + - '' + - '
' + - '' + - '
' + - '
' + opts.desc + '
'; - const rangeInput = row.querySelector('input[type="range"]') as HTMLInputElement; - const numInput = row.querySelector('input[type="number"]') as HTMLInputElement; - rangeInput.addEventListener('input', () => { - numInput.value = rangeInput.value; - }); - rangeInput.addEventListener('change', () => { - const v = Math.max(opts.min, Math.min(opts.max, parseInt(rangeInput.value) || 0)); - rangeInput.value = String(v); - numInput.value = String(v); - opts.onChange(v); - if (opts.restart) onSettingChanged('restart'); - else if (opts.refreshOnly) onSettingChanged('refresh'); - }); - numInput.addEventListener('change', () => { - const v = Math.max(opts.min, Math.min(opts.max, parseInt(numInput.value) || 0)); - numInput.value = String(v); - rangeInput.value = String(v); - opts.onChange(v); - if (opts.restart) onSettingChanged('restart'); - else if (opts.refreshOnly) onSettingChanged('refresh'); - }); - return row; -} - -function createCheckboxGrid(opts: { - header: string; - items: Array<{ value: string; label: string }>; - selected: string[]; - onChange: (selected: string[]) => void; -}): HTMLElement { - const row = document.createElement('div'); - row.className = 'setting settName safety-0 multisel'; - row.innerHTML = '' + opts.header + ''; - const grid = document.createElement('div'); - grid.className = 'kpc-multisel-parent'; - for (const item of opts.items) { - const label = document.createElement('label'); - label.className = 'hostOpt'; - label.innerHTML = - '' + escapeHtml(item.label) + '' + - '' + - '
'; - const cb = label.querySelector('input') as HTMLInputElement; - cb.addEventListener('change', () => { - if (cb.checked) { - if (!opts.selected.includes(item.value)) opts.selected.push(item.value); - } else { - const idx = opts.selected.indexOf(item.value); - if (idx >= 0) opts.selected.splice(idx, 1); - } - opts.onChange(opts.selected); - }); - grid.appendChild(label); - } - row.appendChild(grid); - return row; -} - -// ── Double Ping Display (Krunker shows half the actual ping) ── -let _doublePingObserver: MutationObserver | null = null; - -function initDoublePing(): void { - function attach(pingEl: HTMLElement): void { - _doublePingObserver = new MutationObserver(() => { - const text = pingEl.textContent; - if (!text) return; - const match = text.match(/(\d+)/); - if (!match) return; - const doubled = parseInt(match[1]) * 2; - _doublePingObserver!.disconnect(); - pingEl.textContent = text.replace(match[1], String(doubled)); - _doublePingObserver!.observe(pingEl, { childList: true, characterData: true, subtree: true }); - }); - _doublePingObserver.observe(pingEl, { childList: true, characterData: true, subtree: true }); - } - - const el = document.getElementById('pingText'); - if (el) { attach(el); return; } - - let attempts = 0; - const poll = setInterval(() => { - if (++attempts > 60) { clearInterval(poll); return; } - const pingEl = document.getElementById('pingText'); - if (pingEl) { clearInterval(poll); attach(pingEl); } - }, 500); -} - -// ── Show Ping in Player List (numeric ms instead of icon) ── -// genList returns an HTML string — parse it, replace icon elements, return modified HTML. -function initShowPing(): void { - const w = window as any; - let attempts = 0; - const poll = setInterval(() => { - const origGenList = w.windows?.[22]?.genList; - if (origGenList && !origGenList.__kpcPingPatched) { - clearInterval(poll); - const patched = function (this: any) { - const html = origGenList.call(this); - const parser = new DOMParser(); - const doc = parser.parseFromString(html, 'text/html'); - for (const icon of doc.querySelectorAll('.pListPing.material-icons')) { - const ping = icon.getAttribute('title'); - icon.classList.remove('pListPing', 'material-icons'); - icon.removeAttribute('title'); - icon.textContent = ping ? ping + ' ' : 'N/A '; - } - return doc.body.innerHTML; - }; - (patched as any).__kpcPingPatched = true; - w.windows[22].genList = patched; - } else if (++attempts > 75) { - clearInterval(poll); - } - }, 200); -} - -function hookSettings(): void { - const w = window as any; - const settingsWindow = w.windows[0]; - let selectedTab: number = settingsWindow.tabIndex; - - function isClientTab(): boolean { - const tabs = settingsWindow.tabs[settingsWindow.settingType]; - return tabs && selectedTab === tabs.length - 1; - } - - function safeRender(): void { - if (isClientTab()) renderSettings(); - } - - const origShowWindow = w.showWindow.bind(w); - const origChangeTab = settingsWindow.changeTab.bind(settingsWindow); - const origSearchList = settingsWindow.searchList.bind(settingsWindow); - - w.showWindow = (...args: unknown[]) => { - const result = origShowWindow(...args); - if (args[0] === 1) { - if (settingsWindow.settingType === 'basic') { - settingsWindow.toggleType({ checked: true }); - } - const advSlider = document.querySelector('.advancedSwitch input#typeBtn') as HTMLInputElement | null; - if (advSlider) { - advSlider.disabled = true; - if (advSlider.nextElementSibling) { - advSlider.nextElementSibling.setAttribute('title', 'Client auto-enables advanced settings mode'); - } - } - - const searchInput = document.getElementById('settSearch') as HTMLInputElement | null; - const searchQuery = searchInput?.value?.trim() ?? ''; - if (searchQuery.length > 0) renderSettings(searchQuery); - else if (isClientTab()) renderSettings(); - } - return result; - }; - - settingsWindow.changeTab = (...args: unknown[]) => { - const result = origChangeTab(...args); - selectedTab = settingsWindow.tabIndex; - safeRender(); - return result; - }; - - settingsWindow.searchList = (...args: unknown[]) => { - const result = origSearchList(...args); - const searchInput = document.getElementById('settSearch') as HTMLInputElement | null; - const query = searchInput?.value?.trim() ?? ''; - if (query.length > 0) { - renderSettings(query); - } else { - const existing = document.querySelector('#settHolder .kpc-settings'); - if (existing && !isClientTab()) existing.remove(); - else if (isClientTab()) renderSettings(); - } - return result; - }; - - safeRender(); -} - -function createSection(title: string, collapsed?: boolean): { section: HTMLElement; body: HTMLElement } { - const section = document.createElement('div'); - const header = document.createElement('div'); - header.className = 'setHed'; - header.innerHTML = '' + (collapsed ? 'keyboard_arrow_right' : 'keyboard_arrow_down') + '' + title; - const body = document.createElement('div'); - body.className = 'setBodH' + (collapsed ? ' setting-category-collapsed' : ''); - header.addEventListener('click', () => { - const isCollapsed = body.classList.toggle('setting-category-collapsed'); - const arrow = header.querySelector('.plusOrMinus'); - if (arrow) arrow.textContent = isCollapsed ? 'keyboard_arrow_right' : 'keyboard_arrow_down'; - }); - section.appendChild(header); - section.appendChild(body); - return { section, body }; -} - -// ── Settings section builders ── - -interface SettingsBag { - binds: Record; - saveBinds: () => void; - isWindows: boolean; -} - -function buildGeneralSection( - body: HTMLElement, gameConf: any, uiConfRaw: any, perfConf: any, bag: SettingsBag, -): void { - const perfDefaults = { fpsUnlocked: true }; - const perf = { ...perfDefaults, ...perfConf }; - - body.appendChild(createToggleRow({ - label: 'Unlimited FPS', - desc: 'Uncap the frame rate (requires restart)', - checked: perf.fpsUnlocked, restart: true, - onChange: (v) => { perf.fpsUnlocked = v; ipcRenderer.invoke('set-config', 'performance', perf); }, - })); - - const gameDefaults = { lastServer: '', socialTabBehaviour: 'New Window' }; - const game = { ...gameDefaults, ...gameConf }; - - body.appendChild(createSelectRow({ - label: 'Social/Hub Tab Behaviour', - desc: 'How social, market, and editor pages open when clicked', - options: [{ value: 'New Window', label: 'Tabs (Separate Window)' }, { value: 'Same Window', label: 'Tabs (Overlay Game)' }], - value: game.socialTabBehaviour, instant: true, - onChange: (v) => { game.socialTabBehaviour = v; ipcRenderer.invoke('set-config', 'game', game); }, - })); - - const uiDefaults = { showExitButton: true, deathscreenAnimation: false, hideMenuPopups: false }; - const ui = { ...uiDefaults, ...uiConfRaw }; - - function saveUI(): void { - ipcRenderer.invoke('set-config', 'ui', ui); - } - - body.appendChild(createToggleRow({ - label: 'Show Exit Button', - desc: 'Show the exit button in the game sidebar', - checked: ui.showExitButton, instant: true, - onChange: (v) => { - ui.showExitButton = v; saveUI(); - const btn = document.getElementById('clientExit'); - if (btn) btn.style.display = v ? 'flex' : 'none'; - }, - })); - - body.appendChild(createToggleRow({ - label: 'Block Death Screen Animation', - desc: 'Disable the slide-in animation on the death screen', - checked: ui.deathscreenAnimation, instant: true, - onChange: (v) => { ui.deathscreenAnimation = v; saveUI(); setDeathAnimBlock(v); }, - })); - - body.appendChild(createToggleRow({ - label: 'Hide Menu Popups', - desc: 'Hide promotional notifications, offers, and streams on the main menu', - checked: ui.hideMenuPopups, instant: true, - onChange: (v) => { - ui.hideMenuPopups = v; saveUI(); - if (v) startHidePopups(); else stopHidePopups(); - }, - })); - - body.appendChild(createToggleRow({ - label: 'Join as Spectator', - desc: 'Automatically enable spectate mode when joining a game', - checked: game.joinAsSpectator, instant: true, - onChange: (v) => { game.joinAsSpectator = v; ipcRenderer.invoke('set-config', 'game', game); }, - })); - - body.appendChild(createToggleRow({ - label: 'Menu Timer', - desc: 'Show the game/spectate timer on the menu screen', - checked: ui.menuTimer ?? true, instant: true, - onChange: (v) => { ui.menuTimer = v; saveUI(); setMenuTimer(v); }, - })); - - body.appendChild(createToggleRow({ - label: 'Double Ping Display', - desc: 'Show the real ping value (Krunker displays half the actual latency)', - checked: ui.doublePing ?? true, refreshOnly: true, - onChange: (v) => { ui.doublePing = v; saveUI(); }, - })); - - body.appendChild(createToggleRow({ - label: 'Show Ping in Player List', - desc: 'Replace the ping icon with numeric millisecond values in the player list', - checked: game.showPing ?? true, refreshOnly: true, - onChange: (v) => { game.showPing = v; ipcRenderer.invoke('set-config', 'game', game); }, - })); - - if (bag.isWindows) { - body.appendChild(createToggleRow({ - label: 'Raw Input', - desc: 'Bypass OS mouse acceleration for direct 1:1 sensor input (Windows only)', - checked: game.rawInput ?? true, refreshOnly: true, - onChange: (v) => { game.rawInput = v; ipcRenderer.invoke('set-config', 'game', game); }, - })); - } - - body.appendChild(createToggleRow({ - label: 'Hardpoint Enemy Counter', - desc: 'Show enemy capture points in Hardpoint mode', - checked: game.hpEnemyCounter ?? true, refreshOnly: true, - onChange: (v) => { - game.hpEnemyCounter = v; ipcRenderer.invoke('set-config', 'game', game); - if (v) initHPCounter(); else destroyHPCounter(); - }, - })); - - body.appendChild(createToggleRow({ - label: 'Show Changelog', - desc: 'Show release notes popup when the client updates', - checked: ui.showChangelog ?? true, instant: true, - onChange: (v) => { ui.showChangelog = v; saveUI(); }, - })); - - if (ui.deathscreenAnimation) setDeathAnimBlock(true); - if (ui.menuTimer ?? true) setMenuTimer(true); - if (ui.hideMenuPopups) startHidePopups(); - - body.appendChild(createKeybindRow('Toggle Fullscreen', 'Fullscreen the game window (default F11)', bag.binds.fullscreenToggle, (b) => { - bag.binds.fullscreenToggle = b; - bag.saveBinds(); - }, undefined, true)); -} - -function buildSwapperSection(body: HTMLElement, swapperConf: any, uiConfRaw: any): void { - const swapEnabled = swapperConf ? swapperConf.enabled : true; - const ui = { cssTheme: 'disabled', loadingTheme: 'disabled', backgroundUrl: '', ...uiConfRaw }; - - function saveUI(): void { - ipcRenderer.invoke('set-config', 'ui', ui); - } - - body.appendChild(createToggleRow({ - label: 'Resource Swapper', - desc: 'Replace game textures, sounds, and models with local files', - checked: swapEnabled, - restart: true, - onChange: (v) => { - ipcRenderer.invoke('get-config', 'swapper').then((conf: any) => { - ipcRenderer.invoke('set-config', 'swapper', { enabled: v, path: conf ? conf.path : '' }); - }); - }, - })); - - const folderRow = document.createElement('div'); - folderRow.className = 'setting settName safety-0 has-button'; - folderRow.innerHTML = - 'Swapper Folder' + - '
Place replacement assets here (textures/, sound/, models/)
'; - const swapFolderBtn = document.createElement('div'); - swapFolderBtn.className = 'settingsBtn'; - swapFolderBtn.title = 'Open Folder'; - swapFolderBtn.innerHTML = 'folder Swapper'; - swapFolderBtn.addEventListener('click', () => ipcRenderer.invoke('open-swap-folder')); - folderRow.appendChild(swapFolderBtn); - body.appendChild(folderRow); - - // ── CSS Theme selector (populated from swap/themes/) ── - const themeRow = document.createElement('div'); - themeRow.className = 'setting settName safety-0 sel has-button'; - themeRow.innerHTML = - 'CSS Theme' + - '
Load a custom CSS theme from swap/themes/
'; - const themeSelect = document.createElement('select'); - themeSelect.className = 's-update inputGrey2'; - themeSelect.innerHTML = ''; - themeRow.appendChild(themeSelect); - const themeFolderBtn = document.createElement('div'); - themeFolderBtn.className = 'settingsBtn'; - themeFolderBtn.title = 'Open Themes Folder'; - themeFolderBtn.innerHTML = 'folder'; - themeFolderBtn.addEventListener('click', () => ipcRenderer.invoke('open-themes-folder')); - themeRow.appendChild(themeFolderBtn); - body.appendChild(themeRow); - - ipcRenderer.invoke('list-themes').then((themes: Array<{ id: string; label: string }>) => { - themeSelect.innerHTML = ''; - for (const t of themes) { - const opt = document.createElement('option'); - opt.value = t.id; - opt.textContent = t.label; - if (t.id === ui.cssTheme) opt.selected = true; - themeSelect.appendChild(opt); - } - }); - - themeSelect.addEventListener('change', () => { - ui.cssTheme = themeSelect.value; - saveUI(); - onSettingChanged('refresh'); - }); - - // ── Loading Screen Background ── - const bgRow = document.createElement('div'); - bgRow.className = 'setting settName safety-0 sel has-button'; - bgRow.innerHTML = - 'Loading Background' + - '
Custom background image for the loading screen (swap/backgrounds/)
'; - const bgSelect = document.createElement('select'); - bgSelect.className = 's-update inputGrey2'; - bgSelect.innerHTML = ''; - bgRow.appendChild(bgSelect); - const bgFolderBtn = document.createElement('div'); - bgFolderBtn.className = 'settingsBtn'; - bgFolderBtn.title = 'Open Backgrounds Folder'; - bgFolderBtn.innerHTML = 'folder'; - bgFolderBtn.addEventListener('click', () => ipcRenderer.invoke('open-backgrounds-folder')); - bgRow.appendChild(bgFolderBtn); - body.appendChild(bgRow); - - ipcRenderer.invoke('list-loading-themes').then((themes: Array<{ id: string; label: string }>) => { - bgSelect.innerHTML = ''; - for (const t of themes) { - const opt = document.createElement('option'); - opt.value = t.id; - opt.textContent = t.label; - if (t.id === ui.loadingTheme) opt.selected = true; - bgSelect.appendChild(opt); - } - }); - - bgSelect.addEventListener('change', () => { - ui.loadingTheme = bgSelect.value; - saveUI(); - onSettingChanged('refresh'); - }); -} - -function buildMatchmakerSection(body: HTMLElement, mmConf: any, bag: SettingsBag): void { - const mm = mmConf || { enabled: true, regions: [], gamemodes: [], minPlayers: 1, maxPlayers: 6, minRemainingTime: 120, openServerBrowser: true, autoJoin: false }; - - function saveMM(): void { - ipcRenderer.invoke('set-config', 'matchmaker', mm); - } - - body.appendChild(createToggleRow({ - label: 'Custom Matchmaker', - desc: 'Use the matchmaker hotkey to find a game matching your criteria', - checked: mm.enabled, instant: true, - onChange: (v) => { mm.enabled = v; saveMM(); }, - })); - - body.appendChild(createToggleRow({ - label: 'Open Server Browser on Cancel', - desc: 'Opens the server browser when no game is found and you cancel', - checked: mm.openServerBrowser, instant: true, - onChange: (v) => { mm.openServerBrowser = v; saveMM(); }, - })); - - body.appendChild(createToggleRow({ - label: 'Auto-Join', - desc: 'Automatically join the best match without showing the popup', - checked: mm.autoJoin ?? false, instant: true, - onChange: (v) => { mm.autoJoin = v; saveMM(); }, - })); - - body.appendChild(createKeybindRow('Matchmaker Hotkey', 'Key to trigger the custom matchmaker', bag.binds.matchmaker, (b) => { - bag.binds.matchmaker = b; - bag.saveBinds(); - }, undefined, true)); - body.appendChild(createKeybindRow('Matchmaker Accept', 'Key to accept a found game', bag.binds.matchmakerAccept, (b) => { - bag.binds.matchmakerAccept = b; - bag.saveBinds(); - }, undefined, true)); - body.appendChild(createKeybindRow('Matchmaker Cancel', 'Key to dismiss the matchmaker popup', bag.binds.matchmakerCancel, (b) => { - bag.binds.matchmakerCancel = b; - bag.saveBinds(); - }, undefined, true)); - - body.appendChild(createNumberRow({ - label: 'Min Players', desc: 'Minimum player count in lobby (0-7)', - min: 0, max: 7, value: mm.minPlayers, instant: true, - onChange: (v) => { mm.minPlayers = v; saveMM(); }, - })); - - body.appendChild(createNumberRow({ - label: 'Max Players', desc: 'Maximum player count in lobby (0-7)', - min: 0, max: 7, value: mm.maxPlayers, instant: true, - onChange: (v) => { mm.maxPlayers = v; saveMM(); }, - })); - - body.appendChild(createNumberRow({ - label: 'Min Remaining Time', desc: 'Minimum seconds remaining in match (0-480)', - min: 0, max: 480, value: mm.minRemainingTime, instant: true, - onChange: (v) => { mm.minRemainingTime = v; saveMM(); }, - })); - - body.appendChild(createCheckboxGrid({ - header: 'Regions (none selected = all)', - items: MATCHMAKER_REGIONS.map(r => ({ value: r, label: MATCHMAKER_REGION_NAMES[r] || r })), - selected: mm.regions, - onChange: () => saveMM(), - })); - - body.appendChild(createCheckboxGrid({ - header: 'Gamemodes (none selected = all)', - items: MATCHMAKER_GAMEMODE_FILTER.map(gm => ({ value: gm, label: gm })), - selected: mm.gamemodes, - onChange: () => saveMM(), - })); - - if (!mm.maps) mm.maps = []; - body.appendChild(createCheckboxGrid({ - header: 'Maps (none selected = all)', - items: MATCHMAKER_MAP_FILTER.map(m => ({ value: m, label: MATCHMAKER_MAP_NAMES[m] || m })), - selected: mm.maps, - onChange: () => saveMM(), - })); -} - -function buildDiscordSection(body: HTMLElement, discordConf: any): void { - const discord = { enabled: false, ...discordConf }; - - body.appendChild(createToggleRow({ - label: 'Discord Rich Presence', - desc: 'Show game activity in your Discord profile', - checked: discord.enabled, - restart: true, - onChange: (v) => { - discord.enabled = v; - ipcRenderer.invoke('set-config', 'discord', discord); - }, - })); -} - -// ── Alt Manager helpers ── -function switchToAccount(account: { username: string; password: string }): void { - const w = window as any; - if (typeof w.loginOrRegister !== 'function') return; - - function doLogin(): void { - w.loginOrRegister(); - queueMicrotask(() => { - const toggleBtn = document.querySelector('.auth-toggle-btn') as HTMLElement; - if (toggleBtn && toggleBtn.textContent?.includes('username')) toggleBtn.click(); - queueMicrotask(() => { - const nameInput = document.querySelector('#accName') as HTMLInputElement; - const passInput = document.querySelector('#accPass') as HTMLInputElement; - if (!nameInput || !passInput) return; - 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; - if (submitBtn) submitBtn.click(); - }); - }); - } - - if (typeof w.logoutAcc === 'function') { - w.logoutAcc(); - setTimeout(doLogin, 500); - } else { - doLogin(); - } -} - -function buildAccountsSection(body: HTMLElement, accountsArr: any[]): void { - const accounts: any[] = accountsArr || []; - - const addBtn = document.createElement('div'); - addBtn.className = 'setting settName safety-0 has-button'; - addBtn.innerHTML = - 'Add Account' + - '' + - '
Save a Krunker account for quick switching
'; - body.appendChild(addBtn); - - const form = document.createElement('div'); - form.className = 'kpc-acc-form'; - form.style.display = 'none'; - form.innerHTML = - '' + - '' + - '' + - '
' + - '' + - '' + - '
'; - body.appendChild(form); - - const labelIn = form.querySelector('.kpc-acc-label') as HTMLInputElement; - 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'; - }); - - form.querySelector('.kpc-acc-cancel')!.addEventListener('click', () => { - form.style.display = 'none'; - }); - - const listEl = document.createElement('div'); - body.appendChild(listEl); - - function renderList(): void { - listEl.innerHTML = ''; - if (accounts.length === 0) { - listEl.innerHTML = '
No saved accounts
'; - return; - } - accounts.forEach((acc, i) => { - const row = document.createElement('div'); - row.className = 'kpc-acc-item'; - row.innerHTML = - '
' + - '' + escapeHtml(acc.label) + '' + - '
' + - '
' + - '' + - '' + - '
'; - 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); - renderList(); - }); - }); - listEl.appendChild(row); - }); - } - renderList(); - - form.querySelector('.kpc-acc-save')!.addEventListener('click', () => { - const label = labelIn.value.trim(); - const user = userIn.value.trim(); - const pass = passIn.value; - if (!label || !user || !pass) return; - const newAcc = { label, username: user, password: pass }; - ipcRenderer.invoke('alt-save', newAcc).then(() => { - accounts.push({ label }); - labelIn.value = ''; - userIn.value = ''; - passIn.value = ''; - form.style.display = 'none'; - renderList(); - }); - }); -} - -function buildChatSection(body: HTMLElement, gameConf: any, translatorConf: any, bag: SettingsBag): void { - const game = { betterChat: true, chatHistorySize: 200, ...gameConf }; - - function saveGame(): void { - ipcRenderer.invoke('set-config', 'game', game); - } - - body.appendChild(createToggleRow({ - label: 'Better Chat', - desc: 'Merge team and all-chat with colored [T]/[M] prefixes', - checked: game.betterChat, instant: true, - onChange: (v) => { game.betterChat = v; saveGame(); setBetterChat(v); }, - })); - - body.appendChild(createNumberRow({ - label: 'Chat History Size', desc: 'Maximum chat messages to keep (0 to disable history preservation)', - min: 0, max: 1000, value: game.chatHistorySize, instant: true, - onChange: (v) => { game.chatHistorySize = v; saveGame(); setChatHistorySize(v); }, - })); - - body.appendChild(createKeybindRow('Pause Chat', 'Freeze chat auto-scroll to read history (default F10)', bag.binds.pauseChat, (b) => { - bag.binds.pauseChat = b; - bag.saveBinds(); - }, undefined, true)); - - // Translator settings inline - const tl = { enabled: true, targetLanguage: 'en', showLanguageTag: true, ...translatorConf }; - - function saveTL(): void { - ipcRenderer.invoke('set-config', 'translator', tl); - } - - body.appendChild(createToggleRow({ - label: 'Chat Translator', - desc: 'Automatically translate non-English chat messages', - checked: tl.enabled, instant: true, - onChange: (v) => { - tl.enabled = v; - saveTL(); - updateTranslatorConfig({ enabled: v }); - }, - })); - - body.appendChild(createSelectRow({ - label: 'Target Language', - desc: 'Language to translate messages into', instant: true, - options: [ - { value: 'en', label: 'English' }, - { value: 'es', label: 'Spanish' }, - { value: 'fr', label: 'French' }, - { value: 'de', label: 'German' }, - { value: 'pt', label: 'Portuguese' }, - { value: 'ru', label: 'Russian' }, - { value: 'ja', label: 'Japanese' }, - { value: 'ko', label: 'Korean' }, - { value: 'zh', label: 'Chinese' }, - { value: 'ar', label: 'Arabic' }, - { value: 'hi', label: 'Hindi' }, - { value: 'tr', label: 'Turkish' }, - { value: 'pl', label: 'Polish' }, - { value: 'it', label: 'Italian' }, - { value: 'nl', label: 'Dutch' }, - ], - value: tl.targetLanguage, - onChange: (v) => { - tl.targetLanguage = v; - saveTL(); - updateTranslatorConfig({ targetLanguage: v }); - }, - })); - - body.appendChild(createToggleRow({ - label: 'Show Language Tag', - desc: 'Show detected language code before translations (e.g. [FR])', - checked: tl.showLanguageTag, instant: true, - onChange: (v) => { - tl.showLanguageTag = v; - saveTL(); - updateTranslatorConfig({ showLanguageTag: v }); - }, - })); -} - -function buildAdvancedSection( - body: HTMLElement, advConf: any, perfConf: any, isWindows: boolean, -): void { - const advDefaults = { - removeUselessFeatures: true, - gpuRasterizing: false, - helpfulFlags: true, - increaseLimits: false, - lowLatency: false, - experimentalFlags: false, - angleBackend: 'default', - verboseLogging: false, - }; - const adv = { ...advDefaults, ...advConf }; - const perf = { cpuThrottleGame: 1, cpuThrottleMenu: 1.5, processPriority: 'Normal', ...perfConf }; - - function savePerf(): void { - ipcRenderer.invoke('set-config', 'performance', perf); - } - - function saveAdv(): void { - ipcRenderer.invoke('set-config', 'advanced', adv); - } - - const angleOptions: Array<{ value: string; label: string }> = isWindows - ? [ - { value: 'default', label: 'Default (D3D11)' }, - { value: 'gl', label: 'OpenGL' }, - { value: 'd3d9', label: 'Direct3D 9' }, - { value: 'd3d11', label: 'Direct3D 11' }, - { value: 'd3d11on12', label: 'D3D11on12' }, - { value: 'vulkan', label: 'Vulkan' }, - ] - : [ - { value: 'default', label: 'Default' }, - { value: 'gl', label: 'OpenGL' }, - { value: 'vulkan', label: 'Vulkan' }, - ]; - - body.appendChild(createSelectRow({ - label: 'ANGLE Backend', - desc: 'Graphics API used for WebGL rendering', - options: angleOptions, - value: adv.angleBackend, restart: true, - onChange: (v) => { adv.angleBackend = v; saveAdv(); }, - })); - - const advToggles: Array<{ key: string; label: string; desc: string; safety: number }> = [ - { key: 'removeUselessFeatures', label: 'Remove Useless Features', desc: 'Disables crash reporting, metrics, print preview, and other unused Chromium features', safety: 1 }, - { key: 'gpuRasterizing', label: 'GPU Rasterization', desc: 'Force GPU rasterization and out-of-process rasterization', safety: 2 }, - { key: 'helpfulFlags', label: 'Useful Flags', desc: 'Enables WebGL, JS harmony, V8 features, background throttle prevention, and autoplay bypass', safety: 3 }, - { key: 'increaseLimits', label: 'Increase Limits', desc: 'Raises renderer process, WebGL context, and WebRTC CPU limits; ignores GPU blocklist', safety: 4 }, - { key: 'lowLatency', label: 'Low Latency Flags', desc: 'Enables high-resolution timer, QUIC protocol, and high-performance GPU', safety: 4 }, - { key: 'experimentalFlags', label: 'Experimental Flags', desc: 'Enables accelerated video decode, native GPU memory buffers, high DPI support, and disables pings/proxy', safety: 4 }, - ]; - - for (const t of advToggles) { - body.appendChild(createToggleRow({ - label: t.label, desc: t.desc, - checked: !!adv[t.key], restart: true, - safety: t.safety, - onChange: (v) => { adv[t.key] = v; saveAdv(); }, - })); - } - - body.appendChild(createNumberRow({ - label: 'CPU Throttle (Game)', desc: 'CPU throttle rate during gameplay (1 = no throttle, 3 = heavy throttle)', - min: 1, max: 3, value: perf.cpuThrottleGame, instant: true, safety: 2, - onChange: (v) => { perf.cpuThrottleGame = v; savePerf(); }, - })); - - body.appendChild(createNumberRow({ - label: 'CPU Throttle (Menu)', desc: 'CPU throttle rate on menu screens (1 = no throttle, 3 = heavy throttle)', - min: 1, max: 3, value: perf.cpuThrottleMenu, instant: true, safety: 1, - onChange: (v) => { perf.cpuThrottleMenu = v; savePerf(); }, - })); - - if (isWindows) { - body.appendChild(createSelectRow({ - label: 'Process Priority', - desc: 'OS-level process priority for the client (Windows only)', - options: [ - { value: 'Normal', label: 'Normal' }, - { value: 'Above Normal', label: 'Above Normal' }, - { value: 'High', label: 'High' }, - { value: 'Below Normal', label: 'Below Normal' }, - { value: 'Low', label: 'Low' }, - ], - value: perf.processPriority, restart: true, safety: 2, - onChange: (v) => { perf.processPriority = v; savePerf(); }, - })); - } - - body.appendChild(createToggleRow({ - label: 'Verbose Logging', - desc: 'Forward all preload console output to the Electron log file', - checked: adv.verboseLogging, instant: true, - onChange: (v) => { - adv.verboseLogging = v; saveAdv(); - _verboseLogging = v; - }, - })); -} - -// ── Search filter + "no settings" cleanup ── -function applySearchFilter(container: HTMLElement, holder: HTMLElement, searchQuery: string): void { - const query = searchQuery.toLowerCase(); - const sections = Array.from(container.children).filter(el => el.querySelector('.setHed')); - sections.forEach(sectionEl => { - const sectionTitle = sectionEl.querySelector('.setHed')?.textContent?.toLowerCase() || ''; - const body = sectionEl.querySelector('.setBodH'); - if (!body) { (sectionEl as HTMLElement).style.display = 'none'; return; } - - if (sectionTitle.includes(query)) { - body.classList.remove('setting-category-collapsed'); - return; - } - - let visibleCount = 0; - Array.from(body.children).forEach(child => { - const el = child as HTMLElement; - const text = el.textContent?.toLowerCase() || ''; - if (text.includes(query)) { - el.style.display = ''; - visibleCount++; - } else { - el.style.display = 'none'; - } - }); - if (visibleCount === 0) { - (sectionEl as HTMLElement).style.display = 'none'; - } else { - body.classList.remove('setting-category-collapsed'); - } - }); - - const hasVisible = sections.find(el => (el as HTMLElement).style.display !== 'none'); - if (hasVisible) { - Array.from(holder.children).forEach(child => { - if ((child as HTMLElement).textContent?.toLowerCase().includes('no settings')) { - (child as HTMLElement).remove(); - } - }); - } -} - -function renderSettings(searchQuery?: string): void { - const holder = document.getElementById('settHolder'); - if (!holder) return; - - refreshLevel = RefreshLevel.none; - if (refreshPopupEl) { refreshPopupEl.remove(); refreshPopupEl = null; } - - if (searchQuery) { - const existing = holder.querySelector('.kpc-settings'); - if (existing) existing.remove(); - } else { - while (holder.firstChild) holder.removeChild(holder.firstChild); - } - - const container = document.createElement('div'); - container.className = 'kpc-settings'; - - // ── Action button grid ── - const actionGrid = document.createElement('div'); - actionGrid.className = 'kpc-action-grid'; - - const actionButtons: Array<{ label: string; color: string; full?: boolean; action: () => void }> = [ - { label: 'Open Resource Swapper', color: 'kpc-ab-pink', action: () => ipcRenderer.invoke('open-swap-folder') }, - { label: 'Reset Resource Swapper', color: 'kpc-ab-pink', action: () => { - if (confirm('Reset resource swapper? This will delete all files in the swapper folder.')) { - ipcRenderer.invoke('reset-swapper'); - } - }}, - { label: 'Open Electron Logs', color: 'kpc-ab-red', action: () => ipcRenderer.invoke('open-electron-log') }, - { label: 'Restart Client', color: 'kpc-ab-orange', full: true, action: () => ipcRenderer.invoke('restart-client') }, - { label: 'Reset Options', color: 'kpc-ab-red', action: () => { - if (confirm('Reset all settings to defaults? The client will restart.')) { - ipcRenderer.invoke('reset-options'); - } - }}, - { label: 'Delete All Data', color: 'kpc-ab-red', action: () => { - if (confirm('Delete all data (config, logs)? Scripts are preserved. The client will restart.')) { - ipcRenderer.invoke('delete-all-data'); - } - }}, - ]; - - for (const ab of actionButtons) { - const btn = document.createElement('button'); - btn.className = 'kpc-action-btn ' + ab.color + (ab.full ? ' full' : ''); - btn.textContent = ab.label; - btn.addEventListener('click', ab.action); - actionGrid.appendChild(btn); - } - container.appendChild(actionGrid); - - // ── Create section shells ── - const genSec = createSection('General'); - container.appendChild(genSec.section); - const swapSec = createSection('Swapper'); - container.appendChild(swapSec.section); - const mmSec = createSection('Matchmaker'); - container.appendChild(mmSec.section); - const chatSec = createSection('Chat'); - container.appendChild(chatSec.section); - const discordSec = createSection('Discord'); - container.appendChild(discordSec.section); - const accSec = createSection('Accounts', true); - container.appendChild(accSec.section); - const advSec = createSection('Advanced'); - container.appendChild(advSec.section); - const usSec = createSection('Userscripts'); - container.appendChild(usSec.section); - - // Load all configs in a single IPC call + platform info - Promise.all([ - ipcRenderer.invoke('get-all-config', ['swapper', 'matchmaker', 'keybinds', 'advanced', 'game', 'ui', 'discord', 'translator', 'accounts', 'performance']), - ipcRenderer.invoke('get-platform'), - ]).then(([allConf, platformInfo]: [any, any]) => { - const swapperConf = allConf.swapper; - const mmConf = allConf.matchmaker; - const keybindsConf = allConf.keybinds; - const advConf = allConf.advanced; - const gameConf = allConf.game; - const uiConfRaw = allConf.ui; - const discordConf = allConf.discord; - const translatorConf = allConf.translator; - const defaultBinds = { - matchmaker: { key: 'F6', ctrl: false, shift: false, alt: false }, - matchmakerAccept: { key: 'Enter', ctrl: false, shift: false, alt: false }, - matchmakerCancel: { key: 'Escape', ctrl: false, shift: false, alt: false }, - pauseChat: { key: 'F10', ctrl: false, shift: false, alt: false }, - fullscreenToggle: { key: 'F11', ctrl: false, shift: false, alt: false }, - }; - const binds = { ...defaultBinds, ...keybindsConf }; - const isWindows = platformInfo && platformInfo.isWindows; - - const bag: SettingsBag = { - binds, - saveBinds: () => ipcRenderer.invoke('set-config', 'keybinds', binds), - isWindows, - }; - - // Populate each section - buildGeneralSection(genSec.body, gameConf, uiConfRaw, allConf.performance, bag); - buildSwapperSection(swapSec.body, swapperConf, uiConfRaw); - buildMatchmakerSection(mmSec.body, mmConf, bag); - buildChatSection(chatSec.body, gameConf, translatorConf, bag); - buildDiscordSection(discordSec.body, discordConf); - buildAccountsSection(accSec.body, allConf.accounts); - buildAdvancedSection(advSec.body, advConf, allConf.performance, isWindows); - renderUserscriptsSection(usSec.body); - - if (searchQuery) applySearchFilter(container, holder, searchQuery); - - holder.appendChild(container); - }).catch((err: any) => { - console.error('[KCC] Settings render error:', err); - }); -} - -// ── Userscripts settings section ── -function renderUserscriptsSection(body: HTMLElement): void { - ipcRenderer.invoke('get-config', 'userscripts').then((usConf: any) => { - const us = usConf || { enabled: true, path: '' }; - - body.appendChild(createToggleRow({ - label: 'Userscripts', - desc: 'Load custom scripts from the scripts folder', - checked: us.enabled, restart: true, - onChange: (v) => { us.enabled = v; ipcRenderer.invoke('set-config', 'userscripts', us); }, - })); - - const usFolderRow = document.createElement('div'); - usFolderRow.className = 'setting settName safety-0 has-button'; - usFolderRow.innerHTML = - 'Scripts Folder' + - '
Place .js userscript files here
'; - const usFolderBtn = document.createElement('div'); - usFolderBtn.className = 'settingsBtn'; - usFolderBtn.title = 'Open Folder'; - usFolderBtn.innerHTML = 'folder Scripts'; - usFolderBtn.addEventListener('click', () => ipcRenderer.invoke('userscripts-open-folder')); - usFolderRow.appendChild(usFolderBtn); - body.appendChild(usFolderRow); - - const scriptInstances = getInstances(); - if (scriptInstances.length === 0) { - const emptyRow = document.createElement('div'); - emptyRow.className = 'setting settName safety-0'; - emptyRow.innerHTML = - '
No userscripts found. Place .js files in the scripts folder and reload.
'; - body.appendChild(emptyRow); - return; - } - - for (const inst of scriptInstances) { - const scriptRow = document.createElement('div'); - scriptRow.className = 'setting settName safety-0 bool'; - - const displayName = escapeHtml(inst.meta.name || inst.filename); - const metaParts: string[] = []; - if (inst.meta.author) metaParts.push('by ' + escapeHtml(inst.meta.author)); - if (inst.meta.version) metaParts.push('v' + escapeHtml(inst.meta.version)); - const metaLine = metaParts.length > 0 ? '' + metaParts.join(' · ') + '' : ''; - const descText = escapeHtml(inst.meta.desc || ''); - - scriptRow.innerHTML = - '' + displayName + '' + - '' + - '
' + descText + (metaLine ? '
' + metaLine : '') + '
'; - body.appendChild(scriptRow); - - const cb = scriptRow.querySelector('input[type="checkbox"]') as HTMLInputElement; - const settingsContainer = document.createElement('div'); - settingsContainer.className = 'kpc-us-settings'; - body.appendChild(settingsContainer); - - if (inst.enabled && inst.settings) { - renderScriptSettings(inst, settingsContainer); - } - - cb.addEventListener('change', () => { - const { needsReload } = setScriptEnabled(inst.filename, cb.checked, _console); - settingsContainer.innerHTML = ''; - if (cb.checked && inst.settings) { - renderScriptSettings(inst, settingsContainer); - } - if (needsReload) { - onSettingChanged('refresh'); - } - }); - } - }); -} - -function renderScriptSettings(inst: UserscriptInstance, container: HTMLElement): void { - if (!inst.settings) return; - - for (const [, setting] of Object.entries(inst.settings)) { - const typeClass = setting.type === 'bool' ? 'bool' : setting.type === 'sel' ? 'sel' : setting.type === 'num' ? 'num' : setting.type === 'keybind' ? 'keybind' : ''; - const row = document.createElement('div'); - row.className = 'setting settName safety-0' + (typeClass ? ' ' + typeClass : ''); - row.innerHTML = - '' + escapeHtml(setting.title) + '' + - (setting.desc ? '
' + escapeHtml(setting.desc) + '
' : ''); - - switch (setting.type) { - case 'bool': { - const label = document.createElement('label'); - label.className = 'switch'; - label.innerHTML = - '' + - '
'; - row.appendChild(label); - const input = label.querySelector('input') as HTMLInputElement; - input.addEventListener('change', () => { - setting.value = input.checked; - if (typeof setting.changed === 'function') setting.changed(setting.value); - saveScriptSetting(inst); - }); - break; - } - case 'num': { - const input = document.createElement('input'); - input.type = 'number'; - input.className = 'rb-input s-update sliderVal'; - input.value = String(setting.value); - if (setting.min !== undefined) input.min = String(setting.min); - if (setting.max !== undefined) input.max = String(setting.max); - if (setting.step !== undefined) input.step = String(setting.step); - row.appendChild(input); - input.addEventListener('change', () => { - setting.value = parseFloat(input.value) || 0; - if (typeof setting.changed === 'function') setting.changed(setting.value); - saveScriptSetting(inst); - }); - break; - } - case 'sel': { - const select = document.createElement('select'); - select.className = 's-update inputGrey2'; - if (setting.opts) { - for (const opt of setting.opts) { - const option = document.createElement('option'); - option.value = String(opt); - option.textContent = String(opt); - if (String(opt) === String(setting.value)) option.selected = true; - select.appendChild(option); - } - } - row.appendChild(select); - select.addEventListener('change', () => { - setting.value = select.value; - if (typeof setting.changed === 'function') setting.changed(setting.value); - saveScriptSetting(inst); - }); - break; - } - case 'color': { - const input = document.createElement('input'); - input.type = 'color'; - input.className = 'kpc-color-input'; - input.value = String(setting.value) || '#ffffff'; - row.appendChild(input); - input.addEventListener('input', () => { - setting.value = input.value; - if (typeof setting.changed === 'function') setting.changed(setting.value); - saveScriptSetting(inst); - }); - break; - } - case 'keybind': { - const bind = setting.value as Keybind; - const keyEl = document.createElement('span'); - keyEl.className = 'keyIcon kpc-keyIcon'; - keyEl.textContent = keybindDisplayString(bind); - keyEl.addEventListener('click', () => { - openKeybindDialog(setting.title).then((newBind) => { - setting.value = newBind; - keyEl.textContent = keybindDisplayString(newBind); - if (typeof setting.changed === 'function') setting.changed(setting.value); - saveScriptSetting(inst); - }); - }); - row.appendChild(keyEl); - break; - } - } - - container.appendChild(row); - } -} - -function saveScriptSetting(inst: UserscriptInstance): void { - if (!inst.settings) return; - const prefs: Record = {}; - for (const [k, s] of Object.entries(inst.settings)) { - prefs[k] = s.value; - } - ipcRenderer.invoke('userscripts-save-prefs', inst.filename, prefs); -} - -// ── Hide menu popups (polling-based, safe per MutationObserver constraint) ── -let _hidePopupsInterval: ReturnType | null = null; -const HIDE_POPUPS_CSS = - '#leftTabsHolder > .youNewDiv:not(#battlepassAd), .webpush-container, ' + - '#homeStoreAd, #streamContainerNew, #bundlePop, #genericPop.claimPop, ' + - '#newsHolder, #streamContainer { display: none !important; }'; -const HIDE_POPUPS_ELS = ['homeStoreAd', 'streamContainerNew']; - -function startHidePopups(): void { - if (_hidePopupsInterval) return; - if (!document.getElementById('kpc-hideMenuPopups')) { - const style = document.createElement('style'); - style.id = 'kpc-hideMenuPopups'; - style.textContent = HIDE_POPUPS_CSS; - document.head.appendChild(style); - } - const w = window as any; - _hidePopupsInterval = setInterval(() => { - for (const id of HIDE_POPUPS_ELS) { - const el = document.getElementById(id); - if (el && el.style.display !== 'none') el.style.display = 'none'; - } - const bundlePop = document.getElementById('bundlePop'); - if (bundlePop && bundlePop.children.length > 0 && bundlePop.style.display !== 'none') { - if (typeof w.clearPops === 'function') w.clearPops(); - } - const genericPop = document.getElementById('genericPop'); - if (genericPop && genericPop.classList.contains('claimPop') && genericPop.style.display !== 'none') { - if (typeof w.clearPops === 'function') w.clearPops(); - } - }, 1000); -} - -function stopHidePopups(): void { - if (_hidePopupsInterval) { clearInterval(_hidePopupsInterval); _hidePopupsInterval = null; } - const style = document.getElementById('kpc-hideMenuPopups'); - if (style) style.remove(); - for (const id of HIDE_POPUPS_ELS) { - const el = document.getElementById(id); - if (el) el.style.display = ''; - } -} - -// ── Matchmaker IPC listener ── -ipcRenderer.on('matchmaker-find', (_e, mmConfig: MatchmakerConfig) => { - fetchGame(mmConfig, _console).catch((err) => _console.error('[KCC] Matchmaker error:', err)); -}); - -// ── Chat pause ── -let chatPaused = false; -let chatSavedScrollTop = 0; - -function onChatWheel(e: WheelEvent): void { - const chatList = document.getElementById('chatList'); - if (!chatList) return; - chatSavedScrollTop = Math.max(0, Math.min( - chatSavedScrollTop + e.deltaY, - chatList.scrollHeight - chatList.clientHeight, - )); - chatList.scrollTop = chatSavedScrollTop; -} - -ipcRenderer.on('toggle-chat-pause', () => { - const chatList = document.getElementById('chatList'); - if (!chatList) return; - - chatPaused = !chatPaused; - - if (chatPaused) { - chatSavedScrollTop = chatList.scrollTop; - chatList.classList.add('kpc-chat-paused'); - chatList.style.overflow = 'hidden'; - chatList.addEventListener('wheel', onChatWheel, { passive: true }); - } else { - chatList.classList.remove('kpc-chat-paused'); - chatList.style.overflow = ''; - chatList.removeEventListener('wheel', onChatWheel); - chatList.scrollTop = chatList.scrollHeight; - } -}); - -// ── Wait for main process to signal page load, then poll for settings window ── -ipcRenderer.on('main_did-finish-load', () => { - _console.log('[KCC] did-finish-load received, waiting to hook settings...'); - - const isGamePage = window.location.pathname === '/' || window.location.pathname === ''; - - // ── Batch all config reads into a single IPC call ── - (window as any).closeClient = () => window.close(); - Promise.all([ - ipcRenderer.invoke('get-all-config', ['ui', 'userscripts', 'game', 'translator', 'keybinds', 'discord', 'advanced', 'performance']), - ipcRenderer.invoke('get-platform'), - ipcRenderer.invoke('get-version'), - ]).then(([allConf, _platformInfo, currentVersion]: [any, any, string]) => { - const uiConf = allConf.ui; - const usConf = allConf.userscripts; - const gameConf = allConf.game; - const translatorConf = allConf.translator; - const discordConf = allConf.discord; - const advConf = allConf.advanced; - - // ── Verbose logging toggle ── - _verboseLogging = advConf?.verboseLogging ?? false; - - // ── Exit button + UI toggles ── - const showExit = uiConf ? (uiConf.showExitButton !== false) : true; - const showExitBtn = () => { - const btn = document.getElementById('clientExit'); - if (btn) { - btn.style.display = showExit ? 'flex' : 'none'; - return true; - } - return false; - }; - if (!showExitBtn()) { - let exitAttempts = 0; - const exitPoll = setInterval(() => { - if (showExitBtn() || ++exitAttempts > 30) clearInterval(exitPoll); - }, 500); - } - - if (uiConf?.deathscreenAnimation) setDeathAnimBlock(true); - if (uiConf?.hideMenuPopups) startHidePopups(); - if (uiConf?.menuTimer ?? true) setMenuTimer(true); - - // ── Double ping display ── - if (isGamePage && (uiConf?.doublePing ?? true)) { - initDoublePing(); - } - - // ── Show ping in player list ── - if (isGamePage && (gameConf?.showPing ?? true)) { - initShowPing(); - } - - // ── Raw input (Windows only — unadjustedMovement) ── - if (isGamePage && process.platform === 'win32' && (gameConf?.rawInput ?? true)) { - const origLock = HTMLCanvasElement.prototype.requestPointerLock; - HTMLCanvasElement.prototype.requestPointerLock = function (opts?: any) { - const promise = origLock.call(this, { ...opts, unadjustedMovement: true }) as any; - if (promise && typeof promise.catch === 'function') { - return promise.catch(() => origLock.call(this, opts)); - } - return promise; - }; - } - - // ── Better chat + Chat history ── - if (isGamePage) { - initChat({ - betterChat: gameConf?.betterChat ?? true, - chatHistorySize: gameConf?.chatHistorySize ?? 200, - }, _console); - } - - // ── Competitive features ── - if (isGamePage && (gameConf?.hpEnemyCounter ?? true)) { - initHPCounter(); - } - if (isGamePage) { - initRankProgress(); - } - - // ── CPU throttle state notifications ── - if (isGamePage) { - let inGame = false; - setInterval(() => { - const uiBase = document.getElementById('uiBase'); - const nowInGame = !!uiBase && uiBase.className !== 'onMenu' && uiBase.className !== ''; - if (nowInGame !== inGame) { - inGame = nowInGame; - ipcRenderer.send('throttle-state', inGame ? 'game' : 'menu'); - } - }, 2000); - } - - // ── Changelog popup ── - if (isGamePage && (uiConf?.showChangelog ?? true)) { - checkChangelog(currentVersion, uiConf?.lastSeenVersion || ''); - } - - // ── Battle Pass Claim All (game page only) ── - // Poll for .bpBotH element — injects button when BP window is visible - if (isGamePage) { - const getClaimable = () => Array.from(document.querySelectorAll('.bpClaimB')).filter( - (el: any) => el.offsetParent !== null && el.textContent?.trim() === 'Claim' - ); - setInterval(() => { - const bar = document.querySelector('.bpBotH') as HTMLElement | null; - if (!bar || bar.offsetParent === null) return; - const existing = document.getElementById('claimAllBtn'); - if (existing) { - // Update state on re-check (rewards may have become claimable) - const claimable = getClaimable(); - if (claimable.length > 0) { - existing.textContent = 'Claim All'; - existing.classList.remove('disabled'); - } else { - existing.textContent = 'Nothing to Claim'; - existing.classList.add('disabled'); - } - return; - } - const claimable = getClaimable(); - const btn = document.createElement('div'); - btn.className = 'bpBtn skip'; - btn.id = 'claimAllBtn'; - btn.style.cssText = 'margin-left: 8px; cursor: pointer; background: #4CAF50;'; - if (claimable.length > 0) { - btn.textContent = 'Claim All'; - } else { - btn.textContent = 'Nothing to Claim'; - btn.classList.add('disabled'); - } - btn.addEventListener('click', async () => { - if (btn.classList.contains('disabled')) return; - (window as any).playSelect?.(0.1); - const items = getClaimable(); - if (items.length === 0) return; - btn.textContent = 'Claiming...'; - btn.classList.add('disabled'); - for (const item of items) { - (item as HTMLElement).click(); - await new Promise(r => setTimeout(r, 200)); - } - const remaining = getClaimable(); - btn.textContent = remaining.length > 0 ? 'Claim All' : 'Nothing to Claim'; - btn.classList.toggle('disabled', remaining.length === 0); - }); - bar.appendChild(btn); - }, 500); - } - - // ── Initialize userscripts ── - const usEnabled = usConf ? usConf.enabled : true; - if (usEnabled) { - initUserscripts(_console).catch(err => _console.error('[KCC] Userscript init error:', err)); - } - - // ── Join as Spectator — auto-enable spectate on regular game join ── - if (isGamePage && gameConf?.joinAsSpectator) { - let attempts = 0; - const poll = setInterval(() => { - if (++attempts > 300) { clearInterval(poll); return; } - const uiBase = document.getElementById('uiBase'); - if (!uiBase || uiBase.className === '') return; - if (uiBase.className === 'onMenu') { - const specBtn = document.querySelector('#spectButton input') as HTMLInputElement; - if (specBtn && !specBtn.checked) { - (window as any).setSpect(1); - } - clearInterval(poll); - } else { - clearInterval(poll); - } - }, 100); - } - - // ── Initialize chat translator (game page only) ── - if (isGamePage) { - const mergedTl = { enabled: true, targetLanguage: 'en', showLanguageTag: true, ...translatorConf }; - initTranslator(_console, mergedTl); - } - - // ── Discord Rich Presence game state polling ── - if (isGamePage && discordConf?.enabled) { - let lastDetails = ''; - let lastState = ''; - let gameStartTimestamp = Math.floor(Date.now() / 1000); - - function pollDiscordState(): void { - let details: string; - let state = ''; - let startTimestamp: number | undefined = undefined; - - const w = window as any; - const spectating = w.spectating; - - let gameActivity: any = null; - if (typeof w.getGameActivity === 'function') { - try { gameActivity = w.getGameActivity(); } catch { /* game API unavailable */ } - } - - if (spectating) { - details = 'Spectating'; - if (gameActivity?.map) { - state = gameActivity.map; - } - } else { - const uiBase = document.getElementById('uiBase'); - if (uiBase && uiBase.className === 'onMenu') { - details = 'In Menus'; - } else { - if (gameActivity?.mode && gameActivity?.map) { - details = gameActivity.mode + ' on ' + gameActivity.map; - } else { - const mapInfo = document.getElementById('mapInfo'); - details = mapInfo?.textContent || 'Playing Krunker'; - } - - if (gameActivity?.class?.name) { - state = gameActivity.class.name; - } else { - const classElem = document.getElementById('menuClassName'); - if (classElem?.textContent) state = classElem.textContent; - } - - startTimestamp = gameStartTimestamp; - } - } - - if (details !== lastDetails || state !== lastState) { - if (startTimestamp && lastDetails !== details) { - gameStartTimestamp = Math.floor(Date.now() / 1000); - startTimestamp = gameStartTimestamp; - } - lastDetails = details; - lastState = state; - ipcRenderer.send('discord-update', { - details, - state: state || undefined, - startTimestamp, - largeImageKey: 'krunker', - largeImageText: 'Krunker Civilian Client', - }); - } - } - - pollDiscordState(); - setInterval(pollDiscordState, 5000); - document.addEventListener('pointerlockchange', pollDiscordState); - } - // ── In-game Accounts quick-switch button ── - if (isGamePage) { - ipcRenderer.invoke('alt-list').then(() => { - const altBtn = document.createElement('div'); - altBtn.id = 'kpcAltBtn'; - altBtn.className = 'menuItem'; - altBtn.setAttribute('onmouseenter', 'playTick()'); - altBtn.innerHTML = - 'people' + - ''; - - function showAltManager(): void { - const windowHolder = document.getElementById('windowHolder') as HTMLElement; - const menuWindow = document.getElementById('menuWindow') as HTMLElement; - const windowHeader = document.getElementById('windowHeader') as HTMLElement; - if (!windowHolder || !menuWindow || !windowHeader) return; - - if (windowHolder.style.display !== 'none' && windowHeader.innerText === 'Alt Manager') { - windowHolder.style.display = 'none'; - return; - } - - windowHolder.className = 'popupWin'; - windowHolder.style.display = 'block'; - menuWindow.classList.value = 'dark'; - menuWindow.style.cssText = 'width:800px;max-height:calc(100% - 330px);overflow-y:auto;top:50%;transform:translate(-50%,-50%);'; - windowHeader.innerText = 'Alt Manager'; - - function renderAccountList(): void { - ipcRenderer.invoke('alt-list').then((accs: any[]) => { - let html = - '
Alt Manager
' + - '
' + - '
Add Account
' + - '
'; - - if (!accs || accs.length === 0) { - html += '
No saved accounts
'; - } else { - accs.forEach((acc, i) => { - html += - '
' + - '' + escapeHtml(acc.label) + '' + - '' + - '
' + - '
' + - 'delete' + - '
' + - '
'; - }); - } - html += '
'; - menuWindow.innerHTML = html; - - const addBtn = document.getElementById('kpcAltAddBtn'); - if (addBtn) addBtn.addEventListener('click', showAddForm); - - menuWindow.querySelectorAll('.kpc-alt-login').forEach((el) => { - el.addEventListener('click', () => { - const idx = parseInt((el as HTMLElement).dataset.idx || '0', 10); - if (accs[idx]) { - windowHolder.style.display = 'none'; - ipcRenderer.invoke('alt-get-credentials', idx).then((creds: { username: string; password: string } | null) => { - if (creds) switchToAccount(creds); - }); - } - }); - }); - - menuWindow.querySelectorAll('.kpc-alt-del').forEach((el) => { - el.addEventListener('click', () => { - const idx = parseInt((el as HTMLElement).dataset.idx || '0', 10); - if (confirm('Delete account "' + (accs[idx]?.label || '') + '"?')) { - ipcRenderer.invoke('alt-remove', idx).then(() => renderAccountList()); - } - }); - }); - }); - } - - function showAddForm(): void { - menuWindow.innerHTML = - '
' + - '
Add Account
' + - '' + - '' + - '' + - '
' + - '
Add Account
' + - '
Back
' + - '
' + - '
'; - - // 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(); - const user = (document.getElementById('kpcAltUser') as HTMLInputElement).value.trim(); - const pass = (document.getElementById('kpcAltPass') as HTMLInputElement).value; - if (!label || !user || !pass) return; - ipcRenderer.invoke('alt-save', { - label, - username: user, - password: pass, - }).then(() => renderAccountList()); - }); - } - - renderAccountList(); - } - - altBtn.addEventListener('click', (e) => { - e.stopPropagation(); - (window as any).playSelect?.(); - showAltManager(); - }); - - function injectAltBtn(): boolean { - if (document.getElementById('kpcAltBtn')) return true; - const menuContainer = document.getElementById('menuItemContainer'); - if (!menuContainer) return false; - const exitBtn = document.getElementById('clientExit'); - if (exitBtn) { - menuContainer.insertBefore(altBtn, exitBtn); - } else { - menuContainer.appendChild(altBtn); - } - return true; - } - - if (!injectAltBtn()) { - let attempts = 0; - const poll = setInterval(() => { - if (injectAltBtn() || ++attempts > 60) clearInterval(poll); - }, 500); - } - }); - } - - }).catch(() => {}); - - const pollInterval = setInterval(() => { - const w = window as any; - if ( - Object.hasOwn(w, 'showWindow') - && typeof w.showWindow === 'function' - && Object.hasOwn(w, 'windows') - && Array.isArray(w.windows) - && w.windows.length >= 0 - && typeof w.windows[0] !== 'undefined' - && typeof w.windows[0].changeTab === 'function' - ) { - clearInterval(pollInterval); - _console.log('[KCC] Settings window found, hooking...'); - hookSettings(); - } - }, 500); -}); - -// ── Lightweight tab page init (skips game-only features) ── -ipcRenderer.on('main_did-finish-load-tab', () => { - _console.log('[KCC] Tab page loaded'); - (window as any).closeClient = () => window.close(); -}); +import { ipcRenderer } from 'electron'; +import { fetchGame, MATCHMAKER_GAMEMODE_FILTER, MATCHMAKER_REGIONS, MATCHMAKER_REGION_NAMES, MATCHMAKER_MAP_FILTER, MATCHMAKER_MAP_NAMES } from './matchmaker'; +import type { MatchmakerConfig } from './matchmaker'; +import { initUserscripts, getInstances, setScriptEnabled } from './userscripts'; +import type { UserscriptInstance } from './userscripts'; +import { initTranslator, updateTranslatorConfig } from './translator'; +import { setDeathAnimBlock, setMenuTimer, escapeHtml } from './utils'; +import { initChat, setBetterChat, setChatHistorySize } from './chat'; +import { initHPCounter, destroyHPCounter, initRankProgress } from './competitive'; +import { checkChangelog } from './changelog'; +import type { Keybind } from '../main/config'; + + +// ── Save console methods before Krunker overwrites them ── +// Wrapped to forward errors/warnings always, and logs when verbose is enabled +let _verboseLogging = false; + +const _console = { + log: (...args: unknown[]) => { + console.log(...args); + if (_verboseLogging) ipcRenderer.send('verbose-log', 'log', ...args); + }, + warn: (...args: unknown[]) => { + console.warn(...args); + ipcRenderer.send('verbose-log', 'warn', ...args); + }, + error: (...args: unknown[]) => { + console.error(...args); + ipcRenderer.send('verbose-log', 'error', ...args); + }, +}; + +_console.log('[KCC] Preload script loaded'); + +// ── Krunker-native settings styling constants (from Crankshaft) ── +const SAFETY_SVG = ''; +const REFRESH_SVG = ''; +const SAFETY_DESCS = [ + 'This setting is safe/standard', + 'Proceed with caution', + 'This setting is not recommended', + 'This setting is experimental', + 'This setting is experimental and unstable. Use at your own risk.', +]; + +const enum RefreshLevel { none, refresh, restart } +let refreshLevel: number = RefreshLevel.none; +let refreshPopupEl: HTMLElement | null = null; + +function safetyIcon(safety: string): string { + return '' + SAFETY_SVG + ''; +} + +function refreshIcon(mode: 'instant' | 'refresh-icon'): string { + return '' + REFRESH_SVG + ''; +} + +function restartIcon(): string { + return '' + SAFETY_SVG + ''; +} + +function settingIcon(safety: number, instant?: boolean, refreshOnly?: boolean, restart?: boolean): string { + if (safety > 0) return safetyIcon(SAFETY_DESCS[safety]); + if (instant) return refreshIcon('instant'); + if (refreshOnly) return refreshIcon('refresh-icon'); + if (restart) return restartIcon(); + return ''; +} + +function onSettingChanged(level: 'refresh' | 'restart'): void { + const newLevel = level === 'restart' ? RefreshLevel.restart : RefreshLevel.refresh; + if (newLevel > refreshLevel) refreshLevel = newLevel; + updateRefreshNotification(); +} + +function updateRefreshNotification(): void { + if (refreshLevel === RefreshLevel.none) { + if (refreshPopupEl) { refreshPopupEl.remove(); refreshPopupEl = null; } + return; + } + if (refreshPopupEl) { try { refreshPopupEl.remove(); } catch { /* noop */ } } + refreshPopupEl = document.createElement('div'); + refreshPopupEl.className = 'kpc-holder-update refresh-popup'; + if (refreshLevel === RefreshLevel.restart) { + refreshPopupEl.innerHTML = 'Restart client fully to see changes'; + } else { + refreshPopupEl.innerHTML = '' + refreshIcon('refresh-icon') + 'Reload page with F5 or CTRL + R to see changes'; + } + document.body.appendChild(refreshPopupEl); +} + +// ── Tell Krunker this is a client (enables "Client" settings tab) ── +(window as any).OffCliV = true; + +// ── IPC bridge exposed as window.kpc ── +(window as any).kpc = { + platform: { + getInfo: () => ipcRenderer.invoke('get-platform'), + }, + config: { + get: (key: string) => ipcRenderer.invoke('get-config', key), + getAll: (keys: string[]) => ipcRenderer.invoke('get-all-config', keys), + set: (key: string, value: unknown) => ipcRenderer.invoke('set-config', key, value), + }, + window: { + minimize: () => ipcRenderer.invoke('window-minimize'), + maximize: () => ipcRenderer.invoke('window-maximize'), + close: () => ipcRenderer.invoke('window-close'), + isMaximized: () => ipcRenderer.invoke('window-is-maximized'), + }, + dev: { + toggleDevTools: () => ipcRenderer.invoke('toggle-devtools'), + }, + swapper: { + openFolder: () => ipcRenderer.invoke('open-swap-folder'), + getPath: () => ipcRenderer.invoke('get-swap-dir'), + }, + userscripts: { + openFolder: () => ipcRenderer.invoke('userscripts-open-folder'), + getPath: () => ipcRenderer.invoke('userscripts-get-dir'), + }, +}; + +// ── Client settings tab in Krunker's settings ── + +// ── Keybind helpers ── +function keybindDisplayString(bind: Keybind): string { + return (bind.shift ? 'Shift+' : '') + (bind.ctrl ? 'Ctrl+' : '') + (bind.alt ? 'Alt+' : '') + bind.key.toUpperCase(); +} + +// ── Keybind capture dialog (Crankshaft-style) ── +let capturingKeybind: { resolve: (bind: Keybind) => void } | null = null; + +const kbOverlay = document.createElement('div'); +kbOverlay.className = 'kpc-keybind-overlay'; +const kbDialog = document.createElement('div'); +kbDialog.className = 'kpc-keybind-dialog'; +const kbTitle = document.createElement('div'); +kbTitle.className = 'kpc-keybind-dialog-title'; +const kbSub = document.createElement('div'); +kbSub.className = 'kpc-keybind-dialog-sub'; +kbSub.innerHTML = 'Press any key. Press Shift+Escape to cancel.'; +const kbModifiers = document.createElement('div'); +kbModifiers.className = 'kpc-keybind-dialog-modifiers'; +const kbShift = document.createElement('div'); +kbShift.className = 'kpc-keybind-modifier'; +kbShift.textContent = 'Shift'; +const kbCtrl = document.createElement('div'); +kbCtrl.className = 'kpc-keybind-modifier'; +kbCtrl.textContent = 'Control'; +const kbAlt = document.createElement('div'); +kbAlt.className = 'kpc-keybind-modifier'; +kbAlt.textContent = 'Alt'; +const kbCancel = document.createElement('div'); +kbCancel.className = 'kpc-keybind-dialog-cancel'; +kbCancel.textContent = 'Cancel'; +kbCancel.addEventListener('click', dismissKeybindDialog); + +kbModifiers.appendChild(kbShift); +kbModifiers.appendChild(kbCtrl); +kbModifiers.appendChild(kbAlt); +kbDialog.appendChild(kbCancel); +kbDialog.appendChild(kbTitle); +kbDialog.appendChild(kbSub); +kbDialog.appendChild(kbModifiers); +kbOverlay.appendChild(kbDialog); + +function dismissKeybindDialog(): void { + kbShift.classList.remove('active'); + kbCtrl.classList.remove('active'); + kbAlt.classList.remove('active'); + document.removeEventListener('keydown', kbKeydownHandler, true); + document.removeEventListener('keyup', kbKeyupHandler, true); + if (kbOverlay.parentNode) kbOverlay.remove(); + capturingKeybind = null; + ipcRenderer.send('keybind-capture', false); +} + +function kbKeydownHandler(event: KeyboardEvent): void { + event.stopImmediatePropagation(); + event.preventDefault(); + if (event.key === 'Control') kbCtrl.classList.add('active'); + else if (event.key === 'Shift') kbShift.classList.add('active'); + else if (event.key === 'Alt') kbAlt.classList.add('active'); +} + +function kbKeyupHandler(event: KeyboardEvent): void { + event.stopImmediatePropagation(); + event.preventDefault(); + if (!capturingKeybind) return; + + if (event.key === 'Escape' && event.shiftKey) { + dismissKeybindDialog(); + return; + } + + // Modifier-only releases just clear indicators + if (event.key === 'Shift' || event.key === 'Control' || event.key === 'Alt') { + const bind: Keybind = { key: event.key, ctrl: false, shift: false, alt: false }; + capturingKeybind.resolve(bind); + dismissKeybindDialog(); + return; + } + + const bind: Keybind = { + key: event.key, + ctrl: event.ctrlKey, + shift: event.shiftKey, + alt: event.altKey, + }; + capturingKeybind.resolve(bind); + dismissKeybindDialog(); +} + +function openKeybindDialog(title: string): Promise { + return new Promise((resolve) => { + capturingKeybind = { resolve }; + kbTitle.textContent = 'Edit Keybind: ' + title; + kbShift.classList.remove('active'); + kbCtrl.classList.remove('active'); + kbAlt.classList.remove('active'); + ipcRenderer.send('keybind-capture', true); + document.addEventListener('keydown', kbKeydownHandler, true); + document.addEventListener('keyup', kbKeyupHandler, true); + document.body.appendChild(kbOverlay); + }); +} + +function createKeybindRow(label: string, desc: string, currentBind: Keybind, onBind: (bind: Keybind) => void, safety?: number, instant?: boolean): HTMLElement { + const s = safety || 0; + const row = document.createElement('div'); + row.className = 'setting settName safety-' + s + ' keybind'; + row.innerHTML = + settingIcon(s, instant) + + '' + escapeHtml(label) + '' + + '' + escapeHtml(keybindDisplayString(currentBind)) + '' + + '
' + escapeHtml(desc) + '
'; + const keyEl = row.querySelector('.kpc-keyIcon') as HTMLElement; + keyEl.addEventListener('click', () => { + openKeybindDialog(label).then((newBind) => { + keyEl.textContent = keybindDisplayString(newBind); + onBind(newBind); + }); + }); + return row; +} + +function createToggleRow(opts: { + label: string; + desc: string; + checked: boolean; + onChange: (checked: boolean) => void; + restart?: boolean; + disabled?: boolean; + safety?: number; + instant?: boolean; + refreshOnly?: boolean; +}): HTMLElement { + const s = opts.safety || 0; + const row = document.createElement('div'); + row.className = 'setting settName safety-' + s + ' bool'; + row.innerHTML = + settingIcon(s, opts.instant, opts.refreshOnly, opts.restart) + + '' + escapeHtml(opts.label) + '' + + '' + + '
' + escapeHtml(opts.desc) + '
'; + if (!opts.disabled) { + const cb = row.querySelector('input[type="checkbox"]') as HTMLInputElement; + cb.addEventListener('change', () => { + opts.onChange(cb.checked); + if (opts.restart) onSettingChanged('restart'); + else if (opts.refreshOnly) onSettingChanged('refresh'); + }); + } + return row; +} + +function createSelectRow(opts: { + label: string; + desc: string; + options: Array<{ value: string; label: string }>; + value: string; + onChange: (value: string) => void; + restart?: boolean; + safety?: number; + instant?: boolean; + refreshOnly?: boolean; +}): HTMLElement { + const s = opts.safety || 0; + const row = document.createElement('div'); + row.className = 'setting settName safety-' + s + ' sel'; + row.innerHTML = + settingIcon(s, opts.instant, opts.refreshOnly, opts.restart) + + '' + escapeHtml(opts.label) + '' + + '
' + escapeHtml(opts.desc) + '
'; + const select = document.createElement('select'); + select.className = 's-update inputGrey2'; + for (const o of opts.options) { + const option = document.createElement('option'); + option.value = o.value; + option.textContent = o.label; + if (o.value === opts.value) option.selected = true; + select.appendChild(option); + } + select.addEventListener('change', () => { + opts.onChange(select.value); + if (opts.restart) onSettingChanged('restart'); + else if (opts.refreshOnly) onSettingChanged('refresh'); + }); + row.appendChild(select); + return row; +} + +function createNumberRow(opts: { + label: string; + desc: string; + min: number; + max: number; + value: number; + onChange: (value: number) => void; + safety?: number; + restart?: boolean; + instant?: boolean; + refreshOnly?: boolean; +}): HTMLElement { + const s = opts.safety || 0; + const row = document.createElement('div'); + row.className = 'setting settName safety-' + s + ' num'; + row.innerHTML = + settingIcon(s, opts.instant, opts.refreshOnly, opts.restart) + + '' + escapeHtml(opts.label) + '' + + '' + + '
' + + '' + + '
' + + '
' + escapeHtml(opts.desc) + '
'; + const rangeInput = row.querySelector('input[type="range"]') as HTMLInputElement; + const numInput = row.querySelector('input[type="number"]') as HTMLInputElement; + rangeInput.addEventListener('input', () => { + numInput.value = rangeInput.value; + }); + rangeInput.addEventListener('change', () => { + const v = Math.max(opts.min, Math.min(opts.max, parseInt(rangeInput.value) || 0)); + rangeInput.value = String(v); + numInput.value = String(v); + opts.onChange(v); + if (opts.restart) onSettingChanged('restart'); + else if (opts.refreshOnly) onSettingChanged('refresh'); + }); + numInput.addEventListener('change', () => { + const v = Math.max(opts.min, Math.min(opts.max, parseInt(numInput.value) || 0)); + numInput.value = String(v); + rangeInput.value = String(v); + opts.onChange(v); + if (opts.restart) onSettingChanged('restart'); + else if (opts.refreshOnly) onSettingChanged('refresh'); + }); + return row; +} + +function createCheckboxGrid(opts: { + header: string; + items: Array<{ value: string; label: string }>; + selected: string[]; + onChange: (selected: string[]) => void; +}): HTMLElement { + const row = document.createElement('div'); + row.className = 'setting settName safety-0 multisel'; + row.innerHTML = '' + escapeHtml(opts.header) + ''; + const grid = document.createElement('div'); + grid.className = 'kpc-multisel-parent'; + for (const item of opts.items) { + const label = document.createElement('label'); + label.className = 'hostOpt'; + label.innerHTML = + '' + escapeHtml(item.label) + '' + + '' + + '
'; + const cb = label.querySelector('input') as HTMLInputElement; + cb.addEventListener('change', () => { + if (cb.checked) { + if (!opts.selected.includes(item.value)) opts.selected.push(item.value); + } else { + const idx = opts.selected.indexOf(item.value); + if (idx >= 0) opts.selected.splice(idx, 1); + } + opts.onChange(opts.selected); + }); + grid.appendChild(label); + } + row.appendChild(grid); + return row; +} + +// ── Double Ping Display (Krunker shows half the actual ping) ── +let _doublePingObserver: MutationObserver | null = null; + +function initDoublePing(): void { + function attach(pingEl: HTMLElement): void { + _doublePingObserver = new MutationObserver(() => { + const text = pingEl.textContent; + if (!text) return; + const match = text.match(/(\d+)/); + if (!match) return; + const doubled = parseInt(match[1]) * 2; + _doublePingObserver!.disconnect(); + pingEl.textContent = text.replace(match[1], String(doubled)); + _doublePingObserver!.observe(pingEl, { childList: true, characterData: true, subtree: true }); + }); + _doublePingObserver.observe(pingEl, { childList: true, characterData: true, subtree: true }); + } + + const el = document.getElementById('pingText'); + if (el) { attach(el); return; } + + let attempts = 0; + const poll = setInterval(() => { + if (++attempts > 60) { clearInterval(poll); return; } + const pingEl = document.getElementById('pingText'); + if (pingEl) { clearInterval(poll); attach(pingEl); } + }, 500); +} + +// ── Show Ping in Player List (numeric ms instead of icon) ── +// genList returns an HTML string — parse it, replace icon elements, return modified HTML. +function initShowPing(): void { + const w = window as any; + let attempts = 0; + const poll = setInterval(() => { + const origGenList = w.windows?.[22]?.genList; + if (origGenList && !origGenList.__kpcPingPatched) { + clearInterval(poll); + const patched = function (this: any) { + const html = origGenList.call(this); + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + for (const icon of doc.querySelectorAll('.pListPing.material-icons')) { + const ping = icon.getAttribute('title'); + icon.classList.remove('pListPing', 'material-icons'); + icon.removeAttribute('title'); + icon.textContent = ping ? ping + ' ' : 'N/A '; + } + return doc.body.innerHTML; + }; + (patched as any).__kpcPingPatched = true; + w.windows[22].genList = patched; + } else if (++attempts > 75) { + clearInterval(poll); + } + }, 200); +} + +function hookSettings(): void { + const w = window as any; + const settingsWindow = w.windows[0]; + let selectedTab: number = settingsWindow.tabIndex; + + function isClientTab(): boolean { + const tabs = settingsWindow.tabs[settingsWindow.settingType]; + return tabs && selectedTab === tabs.length - 1; + } + + function safeRender(): void { + if (isClientTab()) renderSettings(); + } + + const origShowWindow = w.showWindow.bind(w); + const origChangeTab = settingsWindow.changeTab.bind(settingsWindow); + const origSearchList = settingsWindow.searchList.bind(settingsWindow); + + w.showWindow = (...args: unknown[]) => { + const result = origShowWindow(...args); + if (args[0] === 1) { + if (settingsWindow.settingType === 'basic') { + settingsWindow.toggleType({ checked: true }); + } + const advSlider = document.querySelector('.advancedSwitch input#typeBtn') as HTMLInputElement | null; + if (advSlider) { + advSlider.disabled = true; + if (advSlider.nextElementSibling) { + advSlider.nextElementSibling.setAttribute('title', 'Client auto-enables advanced settings mode'); + } + } + + const searchInput = document.getElementById('settSearch') as HTMLInputElement | null; + const searchQuery = searchInput?.value?.trim() ?? ''; + if (searchQuery.length > 0) renderSettings(searchQuery); + else if (isClientTab()) renderSettings(); + } + return result; + }; + + settingsWindow.changeTab = (...args: unknown[]) => { + const result = origChangeTab(...args); + selectedTab = settingsWindow.tabIndex; + safeRender(); + return result; + }; + + settingsWindow.searchList = (...args: unknown[]) => { + const result = origSearchList(...args); + const searchInput = document.getElementById('settSearch') as HTMLInputElement | null; + const query = searchInput?.value?.trim() ?? ''; + if (query.length > 0) { + renderSettings(query); + } else { + const existing = document.querySelector('#settHolder .kpc-settings'); + if (existing && !isClientTab()) existing.remove(); + else if (isClientTab()) renderSettings(); + } + return result; + }; + + safeRender(); +} + +function createSection(title: string, collapsed?: boolean): { section: HTMLElement; body: HTMLElement } { + const section = document.createElement('div'); + const header = document.createElement('div'); + header.className = 'setHed'; + header.innerHTML = '' + (collapsed ? 'keyboard_arrow_right' : 'keyboard_arrow_down') + '' + title; + const body = document.createElement('div'); + body.className = 'setBodH' + (collapsed ? ' setting-category-collapsed' : ''); + header.addEventListener('click', () => { + const isCollapsed = body.classList.toggle('setting-category-collapsed'); + const arrow = header.querySelector('.plusOrMinus'); + if (arrow) arrow.textContent = isCollapsed ? 'keyboard_arrow_right' : 'keyboard_arrow_down'; + }); + section.appendChild(header); + section.appendChild(body); + return { section, body }; +} + +// ── Settings section builders ── + +interface SettingsBag { + binds: Record; + saveBinds: () => void; + isWindows: boolean; +} + +function buildGeneralSection( + body: HTMLElement, gameConf: any, uiConfRaw: any, perfConf: any, bag: SettingsBag, +): void { + const perfDefaults = { fpsUnlocked: true }; + const perf = { ...perfDefaults, ...perfConf }; + + body.appendChild(createToggleRow({ + label: 'Unlimited FPS', + desc: 'Uncap the frame rate (requires restart)', + checked: perf.fpsUnlocked, restart: true, + onChange: (v) => { perf.fpsUnlocked = v; ipcRenderer.invoke('set-config', 'performance', perf); }, + })); + + const gameDefaults = { lastServer: '', socialTabBehaviour: 'New Window' }; + const game = { ...gameDefaults, ...gameConf }; + + body.appendChild(createSelectRow({ + label: 'Social/Hub Tab Behaviour', + desc: 'How social, market, and editor pages open when clicked', + options: [{ value: 'New Window', label: 'Tabs (Separate Window)' }, { value: 'Same Window', label: 'Tabs (Overlay Game)' }], + value: game.socialTabBehaviour, instant: true, + onChange: (v) => { game.socialTabBehaviour = v; ipcRenderer.invoke('set-config', 'game', game); }, + })); + + const uiDefaults = { showExitButton: true, deathscreenAnimation: false, hideMenuPopups: false }; + const ui = { ...uiDefaults, ...uiConfRaw }; + + function saveUI(): void { + ipcRenderer.invoke('set-config', 'ui', ui); + } + + body.appendChild(createToggleRow({ + label: 'Show Exit Button', + desc: 'Show the exit button in the game sidebar', + checked: ui.showExitButton, instant: true, + onChange: (v) => { + ui.showExitButton = v; saveUI(); + const btn = document.getElementById('clientExit'); + if (btn) btn.style.display = v ? 'flex' : 'none'; + }, + })); + + body.appendChild(createToggleRow({ + label: 'Block Death Screen Animation', + desc: 'Disable the slide-in animation on the death screen', + checked: ui.deathscreenAnimation, instant: true, + onChange: (v) => { ui.deathscreenAnimation = v; saveUI(); setDeathAnimBlock(v); }, + })); + + body.appendChild(createToggleRow({ + label: 'Hide Menu Popups', + desc: 'Hide promotional notifications, offers, and streams on the main menu', + checked: ui.hideMenuPopups, instant: true, + onChange: (v) => { + ui.hideMenuPopups = v; saveUI(); + if (v) startHidePopups(); else stopHidePopups(); + }, + })); + + body.appendChild(createToggleRow({ + label: 'Join as Spectator', + desc: 'Automatically enable spectate mode when joining a game', + checked: game.joinAsSpectator, instant: true, + onChange: (v) => { game.joinAsSpectator = v; ipcRenderer.invoke('set-config', 'game', game); }, + })); + + body.appendChild(createToggleRow({ + label: 'Menu Timer', + desc: 'Show the game/spectate timer on the menu screen', + checked: ui.menuTimer ?? true, instant: true, + onChange: (v) => { ui.menuTimer = v; saveUI(); setMenuTimer(v); }, + })); + + body.appendChild(createToggleRow({ + label: 'Double Ping Display', + desc: 'Show the real ping value (Krunker displays half the actual latency)', + checked: ui.doublePing ?? true, refreshOnly: true, + onChange: (v) => { ui.doublePing = v; saveUI(); }, + })); + + body.appendChild(createToggleRow({ + label: 'Show Ping in Player List', + desc: 'Replace the ping icon with numeric millisecond values in the player list', + checked: game.showPing ?? true, refreshOnly: true, + onChange: (v) => { game.showPing = v; ipcRenderer.invoke('set-config', 'game', game); }, + })); + + if (bag.isWindows) { + body.appendChild(createToggleRow({ + label: 'Raw Input', + desc: 'Bypass OS mouse acceleration for direct 1:1 sensor input (Windows only)', + checked: game.rawInput ?? true, refreshOnly: true, + onChange: (v) => { game.rawInput = v; ipcRenderer.invoke('set-config', 'game', game); }, + })); + } + + body.appendChild(createToggleRow({ + label: 'Hardpoint Enemy Counter', + desc: 'Show enemy capture points in Hardpoint mode', + checked: game.hpEnemyCounter ?? true, refreshOnly: true, + onChange: (v) => { + game.hpEnemyCounter = v; ipcRenderer.invoke('set-config', 'game', game); + if (v) initHPCounter(); else destroyHPCounter(); + }, + })); + + body.appendChild(createToggleRow({ + label: 'Show Changelog', + desc: 'Show release notes popup when the client updates', + checked: ui.showChangelog ?? true, instant: true, + onChange: (v) => { ui.showChangelog = v; saveUI(); }, + })); + + if (ui.deathscreenAnimation) setDeathAnimBlock(true); + if (ui.menuTimer ?? true) setMenuTimer(true); + if (ui.hideMenuPopups) startHidePopups(); + + body.appendChild(createKeybindRow('Toggle Fullscreen', 'Fullscreen the game window (default F11)', bag.binds.fullscreenToggle, (b) => { + bag.binds.fullscreenToggle = b; + bag.saveBinds(); + }, undefined, true)); +} + +function buildSwapperSection(body: HTMLElement, swapperConf: any, uiConfRaw: any): void { + const swapEnabled = swapperConf ? swapperConf.enabled : true; + const ui = { cssTheme: 'disabled', loadingTheme: 'disabled', backgroundUrl: '', ...uiConfRaw }; + + function saveUI(): void { + ipcRenderer.invoke('set-config', 'ui', ui); + } + + body.appendChild(createToggleRow({ + label: 'Resource Swapper', + desc: 'Replace game textures, sounds, and models with local files', + checked: swapEnabled, + restart: true, + onChange: (v) => { + ipcRenderer.invoke('get-config', 'swapper').then((conf: any) => { + ipcRenderer.invoke('set-config', 'swapper', { enabled: v, path: conf ? conf.path : '' }); + }); + }, + })); + + const folderRow = document.createElement('div'); + folderRow.className = 'setting settName safety-0 has-button'; + folderRow.innerHTML = + 'Swapper Folder' + + '
Place replacement assets here (textures/, sound/, models/)
'; + const swapFolderBtn = document.createElement('div'); + swapFolderBtn.className = 'settingsBtn'; + swapFolderBtn.title = 'Open Folder'; + swapFolderBtn.innerHTML = 'folder Swapper'; + swapFolderBtn.addEventListener('click', () => ipcRenderer.invoke('open-swap-folder')); + folderRow.appendChild(swapFolderBtn); + body.appendChild(folderRow); + + // ── CSS Theme selector (populated from swap/themes/) ── + const themeRow = document.createElement('div'); + themeRow.className = 'setting settName safety-0 sel has-button'; + themeRow.innerHTML = + 'CSS Theme' + + '
Load a custom CSS theme from swap/themes/
'; + const themeSelect = document.createElement('select'); + themeSelect.className = 's-update inputGrey2'; + themeSelect.innerHTML = ''; + themeRow.appendChild(themeSelect); + const themeFolderBtn = document.createElement('div'); + themeFolderBtn.className = 'settingsBtn'; + themeFolderBtn.title = 'Open Themes Folder'; + themeFolderBtn.innerHTML = 'folder'; + themeFolderBtn.addEventListener('click', () => ipcRenderer.invoke('open-themes-folder')); + themeRow.appendChild(themeFolderBtn); + body.appendChild(themeRow); + + ipcRenderer.invoke('list-themes').then((themes: Array<{ id: string; label: string }>) => { + themeSelect.innerHTML = ''; + for (const t of themes) { + const opt = document.createElement('option'); + opt.value = t.id; + opt.textContent = t.label; + if (t.id === ui.cssTheme) opt.selected = true; + themeSelect.appendChild(opt); + } + }); + + themeSelect.addEventListener('change', () => { + ui.cssTheme = themeSelect.value; + saveUI(); + onSettingChanged('refresh'); + }); + + // ── Loading Screen Background ── + const bgRow = document.createElement('div'); + bgRow.className = 'setting settName safety-0 sel has-button'; + bgRow.innerHTML = + 'Loading Background' + + '
Custom background image for the loading screen (swap/backgrounds/)
'; + const bgSelect = document.createElement('select'); + bgSelect.className = 's-update inputGrey2'; + bgSelect.innerHTML = ''; + bgRow.appendChild(bgSelect); + const bgFolderBtn = document.createElement('div'); + bgFolderBtn.className = 'settingsBtn'; + bgFolderBtn.title = 'Open Backgrounds Folder'; + bgFolderBtn.innerHTML = 'folder'; + bgFolderBtn.addEventListener('click', () => ipcRenderer.invoke('open-backgrounds-folder')); + bgRow.appendChild(bgFolderBtn); + body.appendChild(bgRow); + + ipcRenderer.invoke('list-loading-themes').then((themes: Array<{ id: string; label: string }>) => { + bgSelect.innerHTML = ''; + for (const t of themes) { + const opt = document.createElement('option'); + opt.value = t.id; + opt.textContent = t.label; + if (t.id === ui.loadingTheme) opt.selected = true; + bgSelect.appendChild(opt); + } + }); + + bgSelect.addEventListener('change', () => { + ui.loadingTheme = bgSelect.value; + saveUI(); + onSettingChanged('refresh'); + }); +} + +function buildMatchmakerSection(body: HTMLElement, mmConf: any, bag: SettingsBag): void { + const mm = mmConf || { enabled: true, regions: [], gamemodes: [], minPlayers: 1, maxPlayers: 6, minRemainingTime: 120, openServerBrowser: true, autoJoin: false }; + + function saveMM(): void { + ipcRenderer.invoke('set-config', 'matchmaker', mm); + } + + body.appendChild(createToggleRow({ + label: 'Custom Matchmaker', + desc: 'Use the matchmaker hotkey to find a game matching your criteria', + checked: mm.enabled, instant: true, + onChange: (v) => { mm.enabled = v; saveMM(); }, + })); + + body.appendChild(createToggleRow({ + label: 'Open Server Browser on Cancel', + desc: 'Opens the server browser when no game is found and you cancel', + checked: mm.openServerBrowser, instant: true, + onChange: (v) => { mm.openServerBrowser = v; saveMM(); }, + })); + + body.appendChild(createToggleRow({ + label: 'Auto-Join', + desc: 'Automatically join the best match without showing the popup', + checked: mm.autoJoin ?? false, instant: true, + onChange: (v) => { mm.autoJoin = v; saveMM(); }, + })); + + body.appendChild(createKeybindRow('Matchmaker Hotkey', 'Key to trigger the custom matchmaker', bag.binds.matchmaker, (b) => { + bag.binds.matchmaker = b; + bag.saveBinds(); + }, undefined, true)); + body.appendChild(createKeybindRow('Matchmaker Accept', 'Key to accept a found game', bag.binds.matchmakerAccept, (b) => { + bag.binds.matchmakerAccept = b; + bag.saveBinds(); + }, undefined, true)); + body.appendChild(createKeybindRow('Matchmaker Cancel', 'Key to dismiss the matchmaker popup', bag.binds.matchmakerCancel, (b) => { + bag.binds.matchmakerCancel = b; + bag.saveBinds(); + }, undefined, true)); + + body.appendChild(createNumberRow({ + label: 'Min Players', desc: 'Minimum player count in lobby (0-7)', + min: 0, max: 7, value: mm.minPlayers, instant: true, + onChange: (v) => { mm.minPlayers = v; saveMM(); }, + })); + + body.appendChild(createNumberRow({ + label: 'Max Players', desc: 'Maximum player count in lobby (0-7)', + min: 0, max: 7, value: mm.maxPlayers, instant: true, + onChange: (v) => { mm.maxPlayers = v; saveMM(); }, + })); + + body.appendChild(createNumberRow({ + label: 'Min Remaining Time', desc: 'Minimum seconds remaining in match (0-480)', + min: 0, max: 480, value: mm.minRemainingTime, instant: true, + onChange: (v) => { mm.minRemainingTime = v; saveMM(); }, + })); + + body.appendChild(createCheckboxGrid({ + header: 'Regions (none selected = all)', + items: MATCHMAKER_REGIONS.map(r => ({ value: r, label: MATCHMAKER_REGION_NAMES[r] || r })), + selected: mm.regions, + onChange: () => saveMM(), + })); + + body.appendChild(createCheckboxGrid({ + header: 'Gamemodes (none selected = all)', + items: MATCHMAKER_GAMEMODE_FILTER.map(gm => ({ value: gm, label: gm })), + selected: mm.gamemodes, + onChange: () => saveMM(), + })); + + if (!mm.maps) mm.maps = []; + body.appendChild(createCheckboxGrid({ + header: 'Maps (none selected = all)', + items: MATCHMAKER_MAP_FILTER.map(m => ({ value: m, label: MATCHMAKER_MAP_NAMES[m] || m })), + selected: mm.maps, + onChange: () => saveMM(), + })); +} + +function buildDiscordSection(body: HTMLElement, discordConf: any): void { + const discord = { enabled: false, ...discordConf }; + + body.appendChild(createToggleRow({ + label: 'Discord Rich Presence', + desc: 'Show game activity in your Discord profile', + checked: discord.enabled, + restart: true, + onChange: (v) => { + discord.enabled = v; + ipcRenderer.invoke('set-config', 'discord', discord); + }, + })); +} + +// ── Alt Manager helpers ── +function switchToAccount(account: { username: string; password: string }): void { + const w = window as any; + if (typeof w.loginOrRegister !== 'function') return; + + function doLogin(): void { + w.loginOrRegister(); + queueMicrotask(() => { + const toggleBtn = document.querySelector('.auth-toggle-btn') as HTMLElement; + if (toggleBtn && toggleBtn.textContent?.includes('username')) toggleBtn.click(); + queueMicrotask(() => { + const nameInput = document.querySelector('#accName') as HTMLInputElement; + const passInput = document.querySelector('#accPass') as HTMLInputElement; + if (!nameInput || !passInput) return; + 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; + if (submitBtn) submitBtn.click(); + }); + }); + } + + if (typeof w.logoutAcc === 'function') { + w.logoutAcc(); + setTimeout(doLogin, 500); + } else { + doLogin(); + } +} + +function buildAccountsSection(body: HTMLElement, accountsArr: any[]): void { + const accounts: any[] = accountsArr || []; + + const addBtn = document.createElement('div'); + addBtn.className = 'setting settName safety-0 has-button'; + addBtn.innerHTML = + 'Add Account' + + '' + + '
Save a Krunker account for quick switching
'; + body.appendChild(addBtn); + + const form = document.createElement('div'); + form.className = 'kpc-acc-form'; + form.style.display = 'none'; + form.innerHTML = + '' + + '' + + '' + + '
' + + '' + + '' + + '
'; + body.appendChild(form); + + const labelIn = form.querySelector('.kpc-acc-label') as HTMLInputElement; + 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'; + }); + + form.querySelector('.kpc-acc-cancel')!.addEventListener('click', () => { + form.style.display = 'none'; + }); + + const listEl = document.createElement('div'); + body.appendChild(listEl); + + function renderList(): void { + listEl.innerHTML = ''; + if (accounts.length === 0) { + listEl.innerHTML = '
No saved accounts
'; + return; + } + accounts.forEach((acc, i) => { + const row = document.createElement('div'); + row.className = 'kpc-acc-item'; + row.innerHTML = + '
' + + '' + escapeHtml(acc.label) + '' + + '
' + + '
' + + '' + + '' + + '
'; + 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); + renderList(); + }); + }); + listEl.appendChild(row); + }); + } + renderList(); + + form.querySelector('.kpc-acc-save')!.addEventListener('click', () => { + const label = labelIn.value.trim(); + const user = userIn.value.trim(); + const pass = passIn.value; + if (!label || !user || !pass) return; + const newAcc = { label, username: user, password: pass }; + ipcRenderer.invoke('alt-save', newAcc).then(() => { + accounts.push({ label }); + labelIn.value = ''; + userIn.value = ''; + passIn.value = ''; + form.style.display = 'none'; + renderList(); + }); + }); +} + +function buildChatSection(body: HTMLElement, gameConf: any, translatorConf: any): void { + const game = { betterChat: true, chatHistorySize: 200, ...gameConf }; + + function saveGame(): void { + ipcRenderer.invoke('set-config', 'game', game); + } + + body.appendChild(createToggleRow({ + label: 'Better Chat', + desc: 'Merge team and all-chat with colored [T]/[M] prefixes', + checked: game.betterChat, instant: true, + onChange: (v) => { game.betterChat = v; saveGame(); setBetterChat(v); }, + })); + + body.appendChild(createNumberRow({ + label: 'Chat History Size', desc: 'Maximum chat messages to keep (0 to disable history preservation)', + min: 0, max: 1000, value: game.chatHistorySize, instant: true, + onChange: (v) => { game.chatHistorySize = v; saveGame(); setChatHistorySize(v); }, + })); + + // Translator settings inline + const tl = { enabled: true, targetLanguage: 'en', showLanguageTag: true, ...translatorConf }; + + function saveTL(): void { + ipcRenderer.invoke('set-config', 'translator', tl); + } + + body.appendChild(createToggleRow({ + label: 'Chat Translator', + desc: 'Automatically translate non-English chat messages', + checked: tl.enabled, instant: true, + onChange: (v) => { + tl.enabled = v; + saveTL(); + updateTranslatorConfig({ enabled: v }); + }, + })); + + body.appendChild(createSelectRow({ + label: 'Target Language', + desc: 'Language to translate messages into', instant: true, + options: [ + { value: 'en', label: 'English' }, + { value: 'es', label: 'Spanish' }, + { value: 'fr', label: 'French' }, + { value: 'de', label: 'German' }, + { value: 'pt', label: 'Portuguese' }, + { value: 'ru', label: 'Russian' }, + { value: 'ja', label: 'Japanese' }, + { value: 'ko', label: 'Korean' }, + { value: 'zh', label: 'Chinese' }, + { value: 'ar', label: 'Arabic' }, + { value: 'hi', label: 'Hindi' }, + { value: 'tr', label: 'Turkish' }, + { value: 'pl', label: 'Polish' }, + { value: 'it', label: 'Italian' }, + { value: 'nl', label: 'Dutch' }, + ], + value: tl.targetLanguage, + onChange: (v) => { + tl.targetLanguage = v; + saveTL(); + updateTranslatorConfig({ targetLanguage: v }); + }, + })); + + body.appendChild(createToggleRow({ + label: 'Show Language Tag', + desc: 'Show detected language code before translations (e.g. [FR])', + checked: tl.showLanguageTag, instant: true, + onChange: (v) => { + tl.showLanguageTag = v; + saveTL(); + updateTranslatorConfig({ showLanguageTag: v }); + }, + })); +} + +function buildAdvancedSection( + body: HTMLElement, advConf: any, perfConf: any, isWindows: boolean, +): void { + const advDefaults = { + removeUselessFeatures: true, + gpuRasterizing: false, + helpfulFlags: true, + increaseLimits: false, + lowLatency: false, + experimentalFlags: false, + angleBackend: 'default', + verboseLogging: false, + }; + const adv = { ...advDefaults, ...advConf }; + const perf = { cpuThrottleGame: 1, cpuThrottleMenu: 1.5, processPriority: 'Normal', ...perfConf }; + + function savePerf(): void { + ipcRenderer.invoke('set-config', 'performance', perf); + } + + function saveAdv(): void { + ipcRenderer.invoke('set-config', 'advanced', adv); + } + + const angleOptions: Array<{ value: string; label: string }> = isWindows + ? [ + { value: 'default', label: 'Default (D3D11)' }, + { value: 'gl', label: 'OpenGL' }, + { value: 'd3d9', label: 'Direct3D 9' }, + { value: 'd3d11', label: 'Direct3D 11' }, + { value: 'd3d11on12', label: 'D3D11on12' }, + { value: 'vulkan', label: 'Vulkan' }, + ] + : [ + { value: 'default', label: 'Default' }, + { value: 'gl', label: 'OpenGL' }, + { value: 'vulkan', label: 'Vulkan' }, + ]; + + body.appendChild(createSelectRow({ + label: 'ANGLE Backend', + desc: 'Graphics API used for WebGL rendering', + options: angleOptions, + value: adv.angleBackend, restart: true, + onChange: (v) => { adv.angleBackend = v; saveAdv(); }, + })); + + const advToggles: Array<{ key: string; label: string; desc: string; safety: number }> = [ + { key: 'removeUselessFeatures', label: 'Remove Useless Features', desc: 'Disables crash reporting, metrics, print preview, and other unused Chromium features', safety: 1 }, + { key: 'gpuRasterizing', label: 'GPU Rasterization', desc: 'Force GPU rasterization and out-of-process rasterization', safety: 2 }, + { key: 'helpfulFlags', label: 'Useful Flags', desc: 'Enables WebGL, JS harmony, V8 features, background throttle prevention, and autoplay bypass', safety: 3 }, + { key: 'increaseLimits', label: 'Increase Limits', desc: 'Raises renderer process, WebGL context, and WebRTC CPU limits; ignores GPU blocklist', safety: 4 }, + { key: 'lowLatency', label: 'Low Latency Flags', desc: 'Enables high-resolution timer, QUIC protocol, and high-performance GPU', safety: 4 }, + { key: 'experimentalFlags', label: 'Experimental Flags', desc: 'Enables accelerated video decode, native GPU memory buffers, high DPI support, and disables pings/proxy', safety: 4 }, + ]; + + for (const t of advToggles) { + body.appendChild(createToggleRow({ + label: t.label, desc: t.desc, + checked: !!adv[t.key], restart: true, + safety: t.safety, + onChange: (v) => { adv[t.key] = v; saveAdv(); }, + })); + } + + body.appendChild(createNumberRow({ + label: 'CPU Throttle (Game)', desc: 'CPU throttle rate during gameplay (1 = no throttle, 3 = heavy throttle)', + min: 1, max: 3, value: perf.cpuThrottleGame, instant: true, safety: 2, + onChange: (v) => { perf.cpuThrottleGame = v; savePerf(); }, + })); + + body.appendChild(createNumberRow({ + label: 'CPU Throttle (Menu)', desc: 'CPU throttle rate on menu screens (1 = no throttle, 3 = heavy throttle)', + min: 1, max: 3, value: perf.cpuThrottleMenu, instant: true, safety: 1, + onChange: (v) => { perf.cpuThrottleMenu = v; savePerf(); }, + })); + + if (isWindows) { + body.appendChild(createSelectRow({ + label: 'Process Priority', + desc: 'OS-level process priority for the client (Windows only)', + options: [ + { value: 'Normal', label: 'Normal' }, + { value: 'Above Normal', label: 'Above Normal' }, + { value: 'High', label: 'High' }, + { value: 'Below Normal', label: 'Below Normal' }, + { value: 'Low', label: 'Low' }, + ], + value: perf.processPriority, restart: true, safety: 2, + onChange: (v) => { perf.processPriority = v; savePerf(); }, + })); + } + + body.appendChild(createToggleRow({ + label: 'Verbose Logging', + desc: 'Forward all preload console output to the Electron log file', + checked: adv.verboseLogging, instant: true, + onChange: (v) => { + adv.verboseLogging = v; saveAdv(); + _verboseLogging = v; + }, + })); +} + +// ── Search filter + "no settings" cleanup ── +function applySearchFilter(container: HTMLElement, holder: HTMLElement, searchQuery: string): void { + const query = searchQuery.toLowerCase(); + const sections = Array.from(container.children).filter(el => el.querySelector('.setHed')); + sections.forEach(sectionEl => { + const sectionTitle = sectionEl.querySelector('.setHed')?.textContent?.toLowerCase() || ''; + const body = sectionEl.querySelector('.setBodH'); + if (!body) { (sectionEl as HTMLElement).style.display = 'none'; return; } + + if (sectionTitle.includes(query)) { + body.classList.remove('setting-category-collapsed'); + return; + } + + let visibleCount = 0; + Array.from(body.children).forEach(child => { + const el = child as HTMLElement; + const text = el.textContent?.toLowerCase() || ''; + if (text.includes(query)) { + el.style.display = ''; + visibleCount++; + } else { + el.style.display = 'none'; + } + }); + if (visibleCount === 0) { + (sectionEl as HTMLElement).style.display = 'none'; + } else { + body.classList.remove('setting-category-collapsed'); + } + }); + + const hasVisible = sections.find(el => (el as HTMLElement).style.display !== 'none'); + if (hasVisible) { + Array.from(holder.children).forEach(child => { + if ((child as HTMLElement).textContent?.toLowerCase().includes('no settings')) { + (child as HTMLElement).remove(); + } + }); + } +} + +function renderSettings(searchQuery?: string): void { + const holder = document.getElementById('settHolder'); + if (!holder) return; + + refreshLevel = RefreshLevel.none; + if (refreshPopupEl) { refreshPopupEl.remove(); refreshPopupEl = null; } + + if (searchQuery) { + const existing = holder.querySelector('.kpc-settings'); + if (existing) existing.remove(); + } else { + while (holder.firstChild) holder.removeChild(holder.firstChild); + } + + const container = document.createElement('div'); + container.className = 'kpc-settings'; + + // ── Action button grid ── + const actionGrid = document.createElement('div'); + actionGrid.className = 'kpc-action-grid'; + + const actionButtons: Array<{ label: string; color: string; full?: boolean; action: () => void }> = [ + { label: 'Open Resource Swapper', color: 'kpc-ab-pink', action: () => ipcRenderer.invoke('open-swap-folder') }, + { label: 'Reset Resource Swapper', color: 'kpc-ab-pink', action: () => { + if (confirm('Reset resource swapper? This will delete all files in the swapper folder.')) { + ipcRenderer.invoke('reset-swapper'); + } + }}, + { label: 'Open Electron Logs', color: 'kpc-ab-red', action: () => ipcRenderer.invoke('open-electron-log') }, + { label: 'Restart Client', color: 'kpc-ab-orange', full: true, action: () => ipcRenderer.invoke('restart-client') }, + { label: 'Reset Options', color: 'kpc-ab-red', action: () => { + if (confirm('Reset all settings to defaults? The client will restart.')) { + ipcRenderer.invoke('reset-options'); + } + }}, + { label: 'Delete All Data', color: 'kpc-ab-red', action: () => { + if (confirm('Delete all data (config, logs)? Scripts are preserved. The client will restart.')) { + ipcRenderer.invoke('delete-all-data'); + } + }}, + ]; + + for (const ab of actionButtons) { + const btn = document.createElement('button'); + btn.className = 'kpc-action-btn ' + ab.color + (ab.full ? ' full' : ''); + btn.textContent = ab.label; + btn.addEventListener('click', ab.action); + actionGrid.appendChild(btn); + } + container.appendChild(actionGrid); + + // ── Create section shells ── + const genSec = createSection('General'); + container.appendChild(genSec.section); + const swapSec = createSection('Swapper'); + container.appendChild(swapSec.section); + const mmSec = createSection('Matchmaker'); + container.appendChild(mmSec.section); + const chatSec = createSection('Chat'); + container.appendChild(chatSec.section); + const discordSec = createSection('Discord'); + container.appendChild(discordSec.section); + const accSec = createSection('Accounts', true); + container.appendChild(accSec.section); + const advSec = createSection('Advanced'); + container.appendChild(advSec.section); + const usSec = createSection('Userscripts'); + container.appendChild(usSec.section); + + // Load all configs in a single IPC call + platform info + Promise.all([ + ipcRenderer.invoke('get-all-config', ['swapper', 'matchmaker', 'keybinds', 'advanced', 'game', 'ui', 'discord', 'translator', 'accounts', 'performance']), + ipcRenderer.invoke('get-platform'), + ]).then(([allConf, platformInfo]: [any, any]) => { + const swapperConf = allConf.swapper; + const mmConf = allConf.matchmaker; + const keybindsConf = allConf.keybinds; + const advConf = allConf.advanced; + const gameConf = allConf.game; + const uiConfRaw = allConf.ui; + const discordConf = allConf.discord; + const translatorConf = allConf.translator; + const defaultBinds = { + matchmaker: { key: 'F6', ctrl: false, shift: false, alt: false }, + matchmakerAccept: { key: 'Enter', ctrl: false, shift: false, alt: false }, + matchmakerCancel: { key: 'Escape', ctrl: false, shift: false, alt: false }, + fullscreenToggle: { key: 'F11', ctrl: false, shift: false, alt: false }, + }; + const binds = { ...defaultBinds, ...keybindsConf }; + const isWindows = platformInfo && platformInfo.isWindows; + + const bag: SettingsBag = { + binds, + saveBinds: () => ipcRenderer.invoke('set-config', 'keybinds', binds), + isWindows, + }; + + // Populate each section + buildGeneralSection(genSec.body, gameConf, uiConfRaw, allConf.performance, bag); + buildSwapperSection(swapSec.body, swapperConf, uiConfRaw); + buildMatchmakerSection(mmSec.body, mmConf, bag); + buildChatSection(chatSec.body, gameConf, translatorConf); + buildDiscordSection(discordSec.body, discordConf); + buildAccountsSection(accSec.body, allConf.accounts); + buildAdvancedSection(advSec.body, advConf, allConf.performance, isWindows); + renderUserscriptsSection(usSec.body); + + if (searchQuery) applySearchFilter(container, holder, searchQuery); + + holder.appendChild(container); + }).catch((err: any) => { + console.error('[KCC] Settings render error:', err); + }); +} + +// ── Userscripts settings section ── +function renderUserscriptsSection(body: HTMLElement): void { + ipcRenderer.invoke('get-config', 'userscripts').then((usConf: any) => { + const us = usConf || { enabled: true, path: '' }; + + body.appendChild(createToggleRow({ + label: 'Userscripts', + desc: 'Load custom scripts from the scripts folder', + checked: us.enabled, restart: true, + onChange: (v) => { us.enabled = v; ipcRenderer.invoke('set-config', 'userscripts', us); }, + })); + + const usFolderRow = document.createElement('div'); + usFolderRow.className = 'setting settName safety-0 has-button'; + usFolderRow.innerHTML = + 'Scripts Folder' + + '
Place .js userscript files here
'; + const usFolderBtn = document.createElement('div'); + usFolderBtn.className = 'settingsBtn'; + usFolderBtn.title = 'Open Folder'; + usFolderBtn.innerHTML = 'folder Scripts'; + usFolderBtn.addEventListener('click', () => ipcRenderer.invoke('userscripts-open-folder')); + usFolderRow.appendChild(usFolderBtn); + body.appendChild(usFolderRow); + + const scriptInstances = getInstances(); + if (scriptInstances.length === 0) { + const emptyRow = document.createElement('div'); + emptyRow.className = 'setting settName safety-0'; + emptyRow.innerHTML = + '
No userscripts found. Place .js files in the scripts folder and reload.
'; + body.appendChild(emptyRow); + return; + } + + for (const inst of scriptInstances) { + const scriptRow = document.createElement('div'); + scriptRow.className = 'setting settName safety-0 bool'; + + const displayName = escapeHtml(inst.meta.name || inst.filename); + const metaParts: string[] = []; + if (inst.meta.author) metaParts.push('by ' + escapeHtml(inst.meta.author)); + if (inst.meta.version) metaParts.push('v' + escapeHtml(inst.meta.version)); + const metaLine = metaParts.length > 0 ? '' + metaParts.join(' · ') + '' : ''; + const descText = escapeHtml(inst.meta.desc || ''); + + scriptRow.innerHTML = + '' + displayName + '' + + '' + + '
' + descText + (metaLine ? '
' + metaLine : '') + '
'; + body.appendChild(scriptRow); + + const cb = scriptRow.querySelector('input[type="checkbox"]') as HTMLInputElement; + const settingsContainer = document.createElement('div'); + settingsContainer.className = 'kpc-us-settings'; + body.appendChild(settingsContainer); + + if (inst.enabled && inst.settings) { + renderScriptSettings(inst, settingsContainer); + } + + cb.addEventListener('change', () => { + const { needsReload } = setScriptEnabled(inst.filename, cb.checked, _console); + settingsContainer.innerHTML = ''; + if (cb.checked && inst.settings) { + renderScriptSettings(inst, settingsContainer); + } + if (needsReload) { + onSettingChanged('refresh'); + } + }); + } + }); +} + +function renderScriptSettings(inst: UserscriptInstance, container: HTMLElement): void { + if (!inst.settings) return; + + for (const [, setting] of Object.entries(inst.settings)) { + const typeClass = setting.type === 'bool' ? 'bool' : setting.type === 'sel' ? 'sel' : setting.type === 'num' ? 'num' : setting.type === 'keybind' ? 'keybind' : ''; + const row = document.createElement('div'); + row.className = 'setting settName safety-0' + (typeClass ? ' ' + typeClass : ''); + row.innerHTML = + '' + escapeHtml(setting.title) + '' + + (setting.desc ? '
' + escapeHtml(setting.desc) + '
' : ''); + + switch (setting.type) { + case 'bool': { + const label = document.createElement('label'); + label.className = 'switch'; + label.innerHTML = + '' + + '
'; + row.appendChild(label); + const input = label.querySelector('input') as HTMLInputElement; + input.addEventListener('change', () => { + setting.value = input.checked; + if (typeof setting.changed === 'function') setting.changed(setting.value); + saveScriptSetting(inst); + }); + break; + } + case 'num': { + const input = document.createElement('input'); + input.type = 'number'; + input.className = 'rb-input s-update sliderVal'; + input.value = String(setting.value); + if (setting.min !== undefined) input.min = String(setting.min); + if (setting.max !== undefined) input.max = String(setting.max); + if (setting.step !== undefined) input.step = String(setting.step); + row.appendChild(input); + input.addEventListener('change', () => { + setting.value = parseFloat(input.value) || 0; + if (typeof setting.changed === 'function') setting.changed(setting.value); + saveScriptSetting(inst); + }); + break; + } + case 'sel': { + const select = document.createElement('select'); + select.className = 's-update inputGrey2'; + if (setting.opts) { + for (const opt of setting.opts) { + const option = document.createElement('option'); + option.value = String(opt); + option.textContent = String(opt); + if (String(opt) === String(setting.value)) option.selected = true; + select.appendChild(option); + } + } + row.appendChild(select); + select.addEventListener('change', () => { + setting.value = select.value; + if (typeof setting.changed === 'function') setting.changed(setting.value); + saveScriptSetting(inst); + }); + break; + } + case 'color': { + const input = document.createElement('input'); + input.type = 'color'; + input.className = 'kpc-color-input'; + input.value = String(setting.value) || '#ffffff'; + row.appendChild(input); + input.addEventListener('input', () => { + setting.value = input.value; + if (typeof setting.changed === 'function') setting.changed(setting.value); + saveScriptSetting(inst); + }); + break; + } + case 'keybind': { + const bind = setting.value as Keybind; + const keyEl = document.createElement('span'); + keyEl.className = 'keyIcon kpc-keyIcon'; + keyEl.textContent = keybindDisplayString(bind); + keyEl.addEventListener('click', () => { + openKeybindDialog(setting.title).then((newBind) => { + setting.value = newBind; + keyEl.textContent = keybindDisplayString(newBind); + if (typeof setting.changed === 'function') setting.changed(setting.value); + saveScriptSetting(inst); + }); + }); + row.appendChild(keyEl); + break; + } + } + + container.appendChild(row); + } +} + +function saveScriptSetting(inst: UserscriptInstance): void { + if (!inst.settings) return; + const prefs: Record = {}; + for (const [k, s] of Object.entries(inst.settings)) { + prefs[k] = s.value; + } + ipcRenderer.invoke('userscripts-save-prefs', inst.filename, prefs); +} + +// ── Hide menu popups (polling-based, safe per MutationObserver constraint) ── +let _hidePopupsInterval: ReturnType | null = null; +const HIDE_POPUPS_CSS = + '#leftTabsHolder > .youNewDiv:not(#battlepassAd), .webpush-container, ' + + '#homeStoreAd, #streamContainerNew, #bundlePop, #genericPop.claimPop, ' + + '#newsHolder, #streamContainer { display: none !important; }'; +const HIDE_POPUPS_ELS = ['homeStoreAd', 'streamContainerNew']; + +function startHidePopups(): void { + if (_hidePopupsInterval) return; + if (!document.getElementById('kpc-hideMenuPopups')) { + const style = document.createElement('style'); + style.id = 'kpc-hideMenuPopups'; + style.textContent = HIDE_POPUPS_CSS; + document.head.appendChild(style); + } + const w = window as any; + _hidePopupsInterval = setInterval(() => { + for (const id of HIDE_POPUPS_ELS) { + const el = document.getElementById(id); + if (el && el.style.display !== 'none') el.style.display = 'none'; + } + const bundlePop = document.getElementById('bundlePop'); + if (bundlePop && bundlePop.children.length > 0 && bundlePop.style.display !== 'none') { + if (typeof w.clearPops === 'function') w.clearPops(); + } + const genericPop = document.getElementById('genericPop'); + if (genericPop && genericPop.classList.contains('claimPop') && genericPop.style.display !== 'none') { + if (typeof w.clearPops === 'function') w.clearPops(); + } + }, 1000); +} + +function stopHidePopups(): void { + if (_hidePopupsInterval) { clearInterval(_hidePopupsInterval); _hidePopupsInterval = null; } + const style = document.getElementById('kpc-hideMenuPopups'); + if (style) style.remove(); + for (const id of HIDE_POPUPS_ELS) { + const el = document.getElementById(id); + if (el) el.style.display = ''; + } +} + +// ── Matchmaker IPC listener ── +ipcRenderer.on('matchmaker-find', (_e, mmConfig: MatchmakerConfig) => { + fetchGame(mmConfig, _console).catch((err) => _console.error('[KCC] Matchmaker error:', err)); +}); + + +// ── Wait for main process to signal page load, then poll for settings window ── +ipcRenderer.on('main_did-finish-load', () => { + _console.log('[KCC] did-finish-load received, waiting to hook settings...'); + + const isGamePage = window.location.pathname === '/' || window.location.pathname === ''; + + // ── Batch all config reads into a single IPC call ── + (window as any).closeClient = () => window.close(); + Promise.all([ + ipcRenderer.invoke('get-all-config', ['ui', 'userscripts', 'game', 'translator', 'keybinds', 'discord', 'advanced', 'performance']), + ipcRenderer.invoke('get-platform'), + ipcRenderer.invoke('get-version'), + ]).then(([allConf, _platformInfo, currentVersion]: [any, any, string]) => { + const uiConf = allConf.ui; + const usConf = allConf.userscripts; + const gameConf = allConf.game; + const translatorConf = allConf.translator; + const discordConf = allConf.discord; + const advConf = allConf.advanced; + + // ── Verbose logging toggle ── + _verboseLogging = advConf?.verboseLogging ?? false; + + // ── Exit button + UI toggles ── + const showExit = uiConf ? (uiConf.showExitButton !== false) : true; + const showExitBtn = () => { + const btn = document.getElementById('clientExit'); + if (btn) { + btn.style.display = showExit ? 'flex' : 'none'; + return true; + } + return false; + }; + if (!showExitBtn()) { + let exitAttempts = 0; + const exitPoll = setInterval(() => { + if (showExitBtn() || ++exitAttempts > 30) clearInterval(exitPoll); + }, 500); + } + + if (uiConf?.deathscreenAnimation) setDeathAnimBlock(true); + if (uiConf?.hideMenuPopups) startHidePopups(); + if (uiConf?.menuTimer ?? true) setMenuTimer(true); + + // ── Double ping display ── + if (isGamePage && (uiConf?.doublePing ?? true)) { + initDoublePing(); + } + + // ── Show ping in player list ── + if (isGamePage && (gameConf?.showPing ?? true)) { + initShowPing(); + } + + // ── Raw input (Windows only — unadjustedMovement) ── + if (isGamePage && process.platform === 'win32' && (gameConf?.rawInput ?? true)) { + const origLock = HTMLCanvasElement.prototype.requestPointerLock; + HTMLCanvasElement.prototype.requestPointerLock = function (opts?: any) { + const promise = origLock.call(this, { ...opts, unadjustedMovement: true }) as any; + if (promise && typeof promise.catch === 'function') { + return promise.catch(() => origLock.call(this, opts)); + } + return promise; + }; + } + + // ── Better chat + Chat history ── + if (isGamePage) { + initChat({ + betterChat: gameConf?.betterChat ?? true, + chatHistorySize: gameConf?.chatHistorySize ?? 200, + }, _console); + } + + // ── Competitive features ── + if (isGamePage && (gameConf?.hpEnemyCounter ?? true)) { + initHPCounter(); + } + if (isGamePage) { + initRankProgress(); + } + + // ── CPU throttle state notifications ── + if (isGamePage) { + let inGame = false; + setInterval(() => { + const uiBase = document.getElementById('uiBase'); + const nowInGame = !!uiBase && uiBase.className !== 'onMenu' && uiBase.className !== ''; + if (nowInGame !== inGame) { + inGame = nowInGame; + ipcRenderer.send('throttle-state', inGame ? 'game' : 'menu'); + } + }, 2000); + } + + // ── Changelog popup ── + if (isGamePage && (uiConf?.showChangelog ?? true)) { + checkChangelog(currentVersion, uiConf?.lastSeenVersion || ''); + } + + // ── Battle Pass Claim All (game page only) ── + // Poll for .bpBotH element — injects button when BP window is visible + if (isGamePage) { + const getClaimable = () => Array.from(document.querySelectorAll('.bpClaimB')).filter( + (el: any) => el.offsetParent !== null && el.textContent?.trim() === 'Claim' + ); + setInterval(() => { + const bar = document.querySelector('.bpBotH') as HTMLElement | null; + if (!bar || bar.offsetParent === null) return; + const existing = document.getElementById('claimAllBtn'); + if (existing) { + // Update state on re-check (rewards may have become claimable) + const claimable = getClaimable(); + if (claimable.length > 0) { + existing.textContent = 'Claim All'; + existing.classList.remove('disabled'); + } else { + existing.textContent = 'Nothing to Claim'; + existing.classList.add('disabled'); + } + return; + } + const claimable = getClaimable(); + const btn = document.createElement('div'); + btn.className = 'bpBtn skip'; + btn.id = 'claimAllBtn'; + btn.style.cssText = 'margin-left: 8px; cursor: pointer; background: #4CAF50;'; + if (claimable.length > 0) { + btn.textContent = 'Claim All'; + } else { + btn.textContent = 'Nothing to Claim'; + btn.classList.add('disabled'); + } + btn.addEventListener('click', async () => { + if (btn.classList.contains('disabled')) return; + (window as any).playSelect?.(0.1); + const items = getClaimable(); + if (items.length === 0) return; + btn.textContent = 'Claiming...'; + btn.classList.add('disabled'); + for (const item of items) { + (item as HTMLElement).click(); + await new Promise(r => setTimeout(r, 200)); + } + const remaining = getClaimable(); + btn.textContent = remaining.length > 0 ? 'Claim All' : 'Nothing to Claim'; + btn.classList.toggle('disabled', remaining.length === 0); + }); + bar.appendChild(btn); + }, 500); + } + + // ── Initialize userscripts ── + const usEnabled = usConf ? usConf.enabled : true; + if (usEnabled) { + initUserscripts(_console).catch(err => _console.error('[KCC] Userscript init error:', err)); + } + + // ── Join as Spectator — auto-enable spectate on regular game join ── + if (isGamePage && gameConf?.joinAsSpectator) { + let attempts = 0; + const poll = setInterval(() => { + if (++attempts > 300) { clearInterval(poll); return; } + const uiBase = document.getElementById('uiBase'); + if (!uiBase || uiBase.className === '') return; + if (uiBase.className === 'onMenu') { + const specBtn = document.querySelector('#spectButton input') as HTMLInputElement; + if (specBtn && !specBtn.checked) { + (window as any).setSpect(1); + } + clearInterval(poll); + } else { + clearInterval(poll); + } + }, 100); + } + + // ── Initialize chat translator (game page only) ── + if (isGamePage) { + const mergedTl = { enabled: true, targetLanguage: 'en', showLanguageTag: true, ...translatorConf }; + initTranslator(_console, mergedTl); + } + + // ── Discord Rich Presence game state polling ── + if (isGamePage && discordConf?.enabled) { + let lastDetails = ''; + let lastState = ''; + let gameStartTimestamp = Math.floor(Date.now() / 1000); + + function pollDiscordState(): void { + let details: string; + let state = ''; + let startTimestamp: number | undefined = undefined; + + const w = window as any; + const spectating = w.spectating; + + let gameActivity: any = null; + if (typeof w.getGameActivity === 'function') { + try { gameActivity = w.getGameActivity(); } catch { /* game API unavailable */ } + } + + if (spectating) { + details = 'Spectating'; + if (gameActivity?.map) { + state = gameActivity.map; + } + } else { + const uiBase = document.getElementById('uiBase'); + if (uiBase && uiBase.className === 'onMenu') { + details = 'In Menus'; + } else { + if (gameActivity?.mode && gameActivity?.map) { + details = gameActivity.mode + ' on ' + gameActivity.map; + } else { + const mapInfo = document.getElementById('mapInfo'); + details = mapInfo?.textContent || 'Playing Krunker'; + } + + if (gameActivity?.class?.name) { + state = gameActivity.class.name; + } else { + const classElem = document.getElementById('menuClassName'); + if (classElem?.textContent) state = classElem.textContent; + } + + startTimestamp = gameStartTimestamp; + } + } + + if (details !== lastDetails || state !== lastState) { + if (startTimestamp && lastDetails !== details) { + gameStartTimestamp = Math.floor(Date.now() / 1000); + startTimestamp = gameStartTimestamp; + } + lastDetails = details; + lastState = state; + ipcRenderer.send('discord-update', { + details, + state: state || undefined, + startTimestamp, + largeImageKey: 'krunker', + largeImageText: 'Krunker Civilian Client', + }); + } + } + + pollDiscordState(); + setInterval(pollDiscordState, 5000); + document.addEventListener('pointerlockchange', pollDiscordState); + } + // ── In-game Accounts quick-switch button ── + if (isGamePage) { + ipcRenderer.invoke('alt-list').then(() => { + const altBtn = document.createElement('div'); + altBtn.id = 'kpcAltBtn'; + altBtn.className = 'menuItem'; + altBtn.setAttribute('onmouseenter', 'playTick()'); + altBtn.innerHTML = + 'people' + + ''; + + function showAltManager(): void { + const windowHolder = document.getElementById('windowHolder') as HTMLElement; + const menuWindow = document.getElementById('menuWindow') as HTMLElement; + const windowHeader = document.getElementById('windowHeader') as HTMLElement; + if (!windowHolder || !menuWindow || !windowHeader) return; + + if (windowHolder.style.display !== 'none' && windowHeader.innerText === 'Alt Manager') { + windowHolder.style.display = 'none'; + return; + } + + windowHolder.className = 'popupWin'; + windowHolder.style.display = 'block'; + menuWindow.classList.value = 'dark'; + menuWindow.style.cssText = 'width:800px;max-height:calc(100% - 330px);overflow-y:auto;top:50%;transform:translate(-50%,-50%);'; + windowHeader.innerText = 'Alt Manager'; + + function renderAccountList(): void { + ipcRenderer.invoke('alt-list').then((accs: any[]) => { + let html = + '
Alt Manager
' + + '
' + + '
Add Account
' + + '
'; + + if (!accs || accs.length === 0) { + html += '
No saved accounts
'; + } else { + accs.forEach((acc, i) => { + html += + '
' + + '' + escapeHtml(acc.label) + '' + + '' + + '
' + + '
' + + 'delete' + + '
' + + '
'; + }); + } + html += '
'; + menuWindow.innerHTML = html; + + const addBtn = document.getElementById('kpcAltAddBtn'); + if (addBtn) addBtn.addEventListener('click', showAddForm); + + menuWindow.querySelectorAll('.kpc-alt-login').forEach((el) => { + el.addEventListener('click', () => { + const idx = parseInt((el as HTMLElement).dataset.idx || '0', 10); + if (accs[idx]) { + windowHolder.style.display = 'none'; + ipcRenderer.invoke('alt-get-credentials', idx).then((creds: { username: string; password: string } | null) => { + if (creds) switchToAccount(creds); + }); + } + }); + }); + + menuWindow.querySelectorAll('.kpc-alt-del').forEach((el) => { + el.addEventListener('click', () => { + const idx = parseInt((el as HTMLElement).dataset.idx || '0', 10); + if (confirm('Delete account "' + (accs[idx]?.label || '') + '"?')) { + ipcRenderer.invoke('alt-remove', idx).then(() => renderAccountList()); + } + }); + }); + }); + } + + function showAddForm(): void { + menuWindow.innerHTML = + '
' + + '
Add Account
' + + '' + + '' + + '' + + '
' + + '
Add Account
' + + '
Back
' + + '
' + + '
'; + + // 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(); + const user = (document.getElementById('kpcAltUser') as HTMLInputElement).value.trim(); + const pass = (document.getElementById('kpcAltPass') as HTMLInputElement).value; + if (!label || !user || !pass) return; + ipcRenderer.invoke('alt-save', { + label, + username: user, + password: pass, + }).then(() => renderAccountList()); + }); + } + + renderAccountList(); + } + + altBtn.addEventListener('click', (e) => { + e.stopPropagation(); + (window as any).playSelect?.(); + showAltManager(); + }); + + function injectAltBtn(): boolean { + if (document.getElementById('kpcAltBtn')) return true; + const menuContainer = document.getElementById('menuItemContainer'); + if (!menuContainer) return false; + const exitBtn = document.getElementById('clientExit'); + if (exitBtn) { + menuContainer.insertBefore(altBtn, exitBtn); + } else { + menuContainer.appendChild(altBtn); + } + return true; + } + + if (!injectAltBtn()) { + let attempts = 0; + const poll = setInterval(() => { + if (injectAltBtn() || ++attempts > 60) clearInterval(poll); + }, 500); + } + }); + } + + }).catch(() => {}); + + const pollInterval = setInterval(() => { + const w = window as any; + if ( + Object.hasOwn(w, 'showWindow') + && typeof w.showWindow === 'function' + && Object.hasOwn(w, 'windows') + && Array.isArray(w.windows) + && w.windows.length >= 0 + && typeof w.windows[0] !== 'undefined' + && typeof w.windows[0].changeTab === 'function' + ) { + clearInterval(pollInterval); + _console.log('[KCC] Settings window found, hooking...'); + hookSettings(); + } + }, 500); +}); + +// ── Lightweight tab page init (skips game-only features) ── +ipcRenderer.on('main_did-finish-load-tab', () => { + _console.log('[KCC] Tab page loaded'); + (window as any).closeClient = () => window.close(); +});