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(); });