import { app, BrowserWindow, Menu, clipboard, ipcMain, safeStorage, session, shell } from 'electron'; import { join } from 'path'; import { existsSync, mkdirSync, promises as fsp } from 'fs'; import { get as httpsGet } from 'https'; import { execFile } from 'child_process'; import * as os from 'os'; import { detectPlatform, applyPlatformFlags } from './platform'; import { config, Keybind, DEFAULT_KEYBINDS, SavedAccount } from './config'; import { initSwapperProtocol, registerSwapperFileProtocol, ResourceSwapper } from './swapper'; import { UserscriptManager } from './userscripts'; import { ALL_CLIENT_CSS } from './client-ui'; import { electronLog, getLogPath, closeLogStreams } from './logger'; import { checkForUpdate, downloadUpdate, installUpdate } from './updater'; import { showUpdateWindow } from './update-window'; import { DiscordRPC } from './discord-rpc'; import { listThemes, getThemeCSS, listLoadingThemes, getLoadingScreenCSS } from './css-themes'; import { TabManager } from './tab-manager'; import { openRankedQueue } from './ranked-queue'; // ── App version for API calls ── // eslint-disable-next-line @typescript-eslint/no-require-imports const appVersion: string = require('../../package.json').version; // ── Region ping cache ── const SERVER_MAP: Record = { 'us-ca-sv': 'SV', 'jb-hnd': 'TOK', 'de-fra': 'FRA', 'as-mb': 'MBI', 'au-syd': 'SYD', 'sgp': 'SIN', 'us-tx': 'DAL', 'me-bhn': 'BHN', 'brz': 'BRZ', 'us-nj': 'NY', }; let pingCache: Record = {}; let pingCacheTime = 0; function osPing(host: string): Promise { return new Promise((resolve) => { const isWin = process.platform === 'win32'; const args = isWin ? ['-n', '1', '-w', '1500', host] : ['-c', '1', '-W', '2', host]; execFile('ping', args, { timeout: 3000 }, (err, stdout) => { if (err) { resolve(-1); return; } const match = stdout.match(/time[=<]([\d.]+)\s*ms/i); if (match) resolve(Math.round(parseFloat(match[1]))); else resolve(-1); }); }); } // ── Platform flags (must run before app.ready) ── const platformInfo = detectPlatform(); const advancedDefaults = { removeUselessFeatures: true, gpuRasterizing: false, helpfulFlags: true, increaseLimits: false, lowLatency: false, experimentalFlags: false, }; const advancedConfig = { ...advancedDefaults, ...config.get('advanced') }; const perfConfig = { fpsUnlocked: true, ...config.get('performance') }; applyPlatformFlags(platformInfo, advancedConfig, perfConfig); // ── App identity (must match electron-builder appId for taskbar pin persistence) ── app.setAppUserModelId('com.krunkercivilian.client'); // ── Resource swapper protocol (must register before app.ready) ── initSwapperProtocol(); // ── Ad-blocking URL patterns (matched in C++ layer, never hits JS for non-matches) ── const BLOCKED_URL_PATTERNS = [ '*://*.pollfish.com/*', '*://www.paypalobjects.com/*', '*://fran-cdn.frvr.com/*', '*://c.amazon-adsystem.com/*', '*://cdn.frvr.com/fran/*', '*://cookiepro.com/*', '*://*.cookiepro.com/*', '*://www.googletagmanager.com/*', '*://*.doubleclick.net/*', '*://storage.googleapis.com/pollfish_production/*', '*://coeus.frvr.com/*', '*://apis.google.com/js/platform.js', '*://imasdk.googleapis.com/*', ]; // ── CSS to hide ad containers ── const HIDE_ADS_CSS = ` .endAHolder, #aHider, #adCon, #rightABox, #aContainer, #topRightAdHolder, div#aContainer, #braveWarning, #topRightAdHolder { display: none !important; }`; // ── Consent dismiss script (polling only — NO MutationObserver on main frame) ── const CONSENT_DISMISS_MAIN_JS = ` (function dismissConsent() { let attempts = 0; const timer = setInterval(() => { attempts++; const btn = document.querySelector('.fc-cta-consent, [aria-label="Consent"], .css-47sehv'); if (btn) { btn.click(); clearInterval(timer); } if (attempts > 30) clearInterval(timer); }, 500); })();`; // ── Escape pointer lock fix ── const ESCAPE_POINTERLOCK_FIX_JS = ` document.addEventListener('keydown', function(e) { if (e.key === 'Escape' && document.pointerLockElement) { document.exitPointerLock(); } }, true);`; // ── Safe external URL opener (only http/https) ── function safeOpenExternal(url: string): void { try { const parsed = new URL(url); if (parsed.protocol === 'https:' || parsed.protocol === 'http:') { shell.openExternal(url); } } catch { /* malformed URL — ignore */ } } // ── Keybind matching ── function matchesKeybind(input: { key: string; control: boolean; shift: boolean; alt: boolean }, bind: Keybind | undefined): boolean { if (!bind) return false; return input.key === bind.key && input.control === bind.ctrl && input.shift === bind.shift && input.alt === bind.alt; } // ── Cached keybinds (avoid re-reading electron-store on every keypress) ── let cachedKeybinds: Record | null = null; function getKeybinds(): Record { if (!cachedKeybinds) { cachedKeybinds = { ...DEFAULT_KEYBINDS, ...config.get('keybinds') }; } return cachedKeybinds; } // ── Debounced window state persistence ── let saveTimer: ReturnType | null = null; function saveWindowState(win: BrowserWindow): void { if (saveTimer) clearTimeout(saveTimer); saveTimer = setTimeout(() => { if (win.isDestroyed()) return; const bounds = win.getBounds(); config.set('window', { width: bounds.width, height: bounds.height, x: bounds.x, y: bounds.y, maximized: win.isMaximized(), fullscreen: win.isFullScreen(), }); }, 1000); } app.whenReady().then(async () => { electronLog.log('[KCC] App ready'); // ── Auto-update check (mandatory, Windows NSIS install only) ── const isPortable = !!process.env.PORTABLE_EXECUTABLE_DIR; const isAppImage = !!process.env.APPIMAGE; const isDev = !app.isPackaged; if (isDev || process.platform !== 'win32' || isPortable || isAppImage) { electronLog.log('[KCC] Skipping auto-update (portable or non-Windows)'); } else { try { electronLog.log('[KCC] Checking for updates...'); const update = await checkForUpdate(appVersion); if (update) { electronLog.log(`[KCC] Update available: v${update.version}`); const { window: updateWin, sendProgress } = showUpdateWindow(); sendProgress(`Update available (v${update.version})`, 0); const tempDir = join(app.getPath('temp'), 'kcc-update'); if (!existsSync(tempDir)) mkdirSync(tempDir, { recursive: true }); const installerPath = join(tempDir, `KCC-${update.version}-Setup.exe`); let cancelled = false; updateWin.on('closed', () => { cancelled = true; }); try { await downloadUpdate(update.downloadUrl, installerPath, (pct) => { if (!cancelled && !updateWin.isDestroyed()) { sendProgress(`Downloading update... ${pct}%`, pct); } }, update.sha256); if (!cancelled) { sendProgress('Installing update...', 100); installUpdate(installerPath); return; // app.quit() called by installUpdate } } catch (err) { electronLog.error('[KCC] Update download failed:', err); if (!updateWin.isDestroyed()) updateWin.close(); } } else { electronLog.log('[KCC] No updates available'); } } catch (err) { electronLog.error('[KCC] Update check failed:', err); } } await launchApp(); }); async function launchApp(): Promise { electronLog.log('[KCC] Starting initialization'); // ── Session: persistent partition + clean user-agent ── const ses = session.fromPartition('persist:krunker'); const rawUA = ses.getUserAgent(); ses.setUserAgent(rawUA.replace(/\s*krunker-civilian-client\/\S+/i, '')); // ── Register swapper file protocol on this session ── registerSwapperFileProtocol(ses); // ── Resource swapper ── const swapperConfig = config.get('swapper'); const swapDir = swapperConfig.path || join(app.getPath('userData'), 'Krunker Civilian Client', 'swapper'); // Ensure swap subdirectories exist (themes/, backgrounds/) for (const sub of ['themes', 'backgrounds']) { const dir = join(swapDir, sub); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); } const swapper = swapperConfig.enabled ? new ResourceSwapper(swapDir) : null; electronLog.log(`[KCC] Resource swapper: ${swapper ? 'enabled' : 'disabled'} (${swapDir})`); // ── Userscript manager ── const usConfig = config.get('userscripts') || { enabled: true, path: '' }; const usDir = usConfig.path || join(app.getPath('userData'), 'Krunker Civilian Client'); const userscriptManager = usConfig.enabled ? new UserscriptManager(usDir) : null; electronLog.log(`[KCC] Userscripts: ${userscriptManager ? 'enabled' : 'disabled'} (${usDir})`); // ── Ad blocking + resource swapper (single onBeforeRequest — Electron only allows one) ── // The broad *://*.krunker.io/* pattern lets the swapper intercept any krunker asset. // swapper.getRedirect() returns null before its async scan completes, so swapped // resources simply pass through until the scan finishes — no re-registration needed. const requestFilterUrls = swapper ? [...BLOCKED_URL_PATTERNS, '*://*.krunker.io/*'] : [...BLOCKED_URL_PATTERNS]; ses.webRequest.onBeforeRequest({ urls: requestFilterUrls }, (details, callback) => { // Check swapper first — redirect matching assets to local files if (swapper) { const redirect = swapper.getRedirect(details.url); if (redirect) return callback({ redirectURL: redirect }); } // Determine if this URL is a krunker.io request (matched by the broad swapper pattern) // vs an ad-block pattern. krunker.io requests that weren't swapped pass through normally. try { if (new URL(details.url).hostname.endsWith('krunker.io')) return callback({}); } catch { /* invalid URL — fall through to cancel */ } // Matched an ad-block pattern — cancel it callback({ cancel: true }); }); if (swapper) { swapper.waitForReady().then(() => { electronLog.log(`[KCC] Swapper ready: ${swapper.patterns.length} pattern(s)`); }); } // ── CORS fix for swapped resources ── if (swapper) { ses.webRequest.onHeadersReceived(({ responseHeaders }, callback) => { if (!responseHeaders) return callback({}); for (const key in responseHeaders) { const lowercase = key.toLowerCase(); if (lowercase === 'access-control-allow-credentials' && responseHeaders[key][0] === 'true') { return callback({ responseHeaders }); } if (lowercase === 'access-control-allow-origin') { delete responseHeaders[key]; break; } } return callback({ responseHeaders: { ...responseHeaders, 'access-control-allow-origin': ['*'] }, }); }); } // ── Restore saved window bounds ── const savedWindow = config.get('window'); const win = new BrowserWindow({ width: savedWindow.width, height: savedWindow.height, x: savedWindow.x, y: savedWindow.y, frame: true, backgroundColor: '#000000', webPreferences: { preload: join(__dirname, '..', 'preload', 'index.js'), session: ses, contextIsolation: false, nodeIntegration: false, sandbox: false, spellcheck: false, backgroundThrottling: false, }, }); if (savedWindow.fullscreen) win.setFullScreen(true); else if (savedWindow.maximized) win.maximize(); // ── No application menu (prevents Escape/Alt interception) ── Menu.setApplicationMenu(null); // ── Discord Rich Presence ── let discordRpc: DiscordRPC | null = null; { const discordConf = config.get('discord') || { enabled: false }; if (discordConf.enabled) { discordRpc = new DiscordRPC(); discordRpc.connect(); electronLog.log('[KCC] Discord Rich Presence enabled'); } } // ── Process Priority (Windows only) ── if (process.platform === 'win32') { const PRIORITY_MAP: Record = { 'High': -14, 'Above Normal': -7, 'Below Normal': 7, 'Low': 19, }; const prioritySetting = config.get('performance')?.processPriority || 'Normal'; const priorityVal = PRIORITY_MAP[prioritySetting]; if (priorityVal !== undefined) { try { os.setPriority(process.pid, priorityVal); } catch { /* ignore */ } // Apply to child processes periodically setInterval(() => { for (const m of app.getAppMetrics()) { if (m.pid !== process.pid) { try { os.setPriority(m.pid, priorityVal); } catch { /* ignore */ } } } }, 1000); electronLog.log(`[KCC] Process priority set to ${prioritySetting}`); } } // ── CPU Throttling via Chrome DevTools Protocol ── const throttledContents = new WeakSet(); function applyCpuThrottle(wc: Electron.WebContents, rate: number): void { const clamped = Math.max(1, Math.min(3, rate)); try { if (!throttledContents.has(wc)) { wc.debugger.attach('1.3'); throttledContents.add(wc); } wc.debugger.sendCommand('Emulation.setCPUThrottlingRate', { rate: clamped }); } catch { /* debugger may already be attached or detached */ } } // ── Keybind capture lock (suppresses shortcuts while the keybind dialog is open) ── let keybindCapturing = false; ipcMain.on('keybind-capture', (_e, capturing: boolean) => { keybindCapturing = capturing; }); // ── Configurable keybinds via before-input-event ── win.webContents.on('before-input-event', (event, input) => { if (input.type !== 'keyDown') return; if (keybindCapturing) return; const binds = getKeybinds(); if (matchesKeybind(input, binds.reload)) { win.reload(); event.preventDefault(); } else if (matchesKeybind(input, binds.newMatch)) { const mm = config.get('matchmaker'); if (mm.enabled) { win.webContents.send('matchmaker-find', { ...mm, acceptKey: binds.matchmakerAccept, cancelKey: binds.matchmakerCancel, }); } else { win.loadURL('https://krunker.io'); } event.preventDefault(); } else if (matchesKeybind(input, binds.joinFromClipboard)) { const text = clipboard.readText(); try { const u = new URL(text); if (u.protocol === 'https:' && u.hostname.endsWith('krunker.io')) win.loadURL(text); } catch { /* ignore invalid URLs */ } event.preventDefault(); } else if (matchesKeybind(input, binds.copyGameLink)) { clipboard.writeText(win.webContents.getURL()); event.preventDefault(); } else if (matchesKeybind(input, binds.devTools)) { win.webContents.toggleDevTools(); event.preventDefault(); } else if (matchesKeybind(input, binds.matchmaker)) { const mm = config.get('matchmaker'); if (mm.enabled) { win.webContents.send('matchmaker-find', { ...mm, acceptKey: binds.matchmakerAccept, cancelKey: binds.matchmakerCancel, }); } else { win.loadURL('https://krunker.io'); } event.preventDefault(); } else if (matchesKeybind(input, binds.fullscreenToggle)) { win.setFullScreen(!win.isFullScreen()); event.preventDefault(); } else if (input.key === 't' && input.control && !input.shift && !input.alt) { tabManager.openTab('https://krunker.io/social.html'); event.preventDefault(); } else if (input.key === 'T' && input.control && input.shift && !input.alt) { tabManager.reopenTab(); event.preventDefault(); } }); // ── Window state persistence (debounced) ── win.on('resize', () => saveWindowState(win)); win.on('move', () => saveWindowState(win)); win.on('maximize', () => saveWindowState(win)); win.on('unmaximize', () => saveWindowState(win)); win.on('enter-full-screen', () => saveWindowState(win)); win.on('leave-full-screen', () => saveWindowState(win)); // ── URL classification ── const GAME_PAGE_PATHS = ['/', '']; function isGameURL(url: string): boolean { try { const parsed = new URL(url); if (!parsed.hostname.includes('krunker.io')) return false; return GAME_PAGE_PATHS.includes(parsed.pathname); } catch { return false; } } // ── Cached game config (invalidated on set-config writes to 'game') ── const gameDefaults = { lastServer: '', socialTabBehaviour: 'New Window' }; let cachedGameConf: typeof gameDefaults | null = null; function getGameConf(): typeof gameDefaults { if (!cachedGameConf) cachedGameConf = { ...gameDefaults, ...config.get('game') }; return cachedGameConf; } // ── Tab Manager ── const preloadPath = join(__dirname, '..', 'preload', 'index.js'); let tabMode: 'same' | 'new' = getGameConf().socialTabBehaviour === 'Same Window' ? 'same' : 'new'; let tabManager = new TabManager( win, ses, preloadPath, tabMode, isGameURL, () => config.get('tabWindow'), (state) => config.set('tabWindow', state), ); // Intercept in-page navigation (e.g. window.location = '/social.html') win.webContents.on('will-navigate', (event, url) => { try { const parsed = new URL(url); if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { event.preventDefault(); return; } } catch { event.preventDefault(); return; } if (url.includes('krunker.io') && !isGameURL(url)) { event.preventDefault(); tabManager.openTab(url); } }); // Intercept target="_blank" / window.open links win.webContents.setWindowOpenHandler(({ url }) => { if (url.includes('krunker.io')) { if (isGameURL(url)) { win.loadURL(url); } else { setImmediate(() => tabManager.openTab(url)); } } else { setImmediate(() => safeOpenExternal(url)); } return { action: 'deny' }; }); // Right-click context menu on main window with "Open in New Tab" win.webContents.on('context-menu', (_e, params) => { if (!params.linkURL) return; const items: Electron.MenuItemConstructorOptions[] = []; if (params.linkURL.includes('krunker.io') && !isGameURL(params.linkURL)) { items.push({ label: 'Open in New Tab', click: () => tabManager.openTab(params.linkURL) }); } items.push({ label: 'Copy Link', click: () => clipboard.writeText(params.linkURL) }); if (!params.linkURL.includes('krunker.io')) { items.push({ label: 'Open in Browser', click: () => safeOpenExternal(params.linkURL) }); } if (items.length) Menu.buildFromTemplate(items).popup(); }); // ── Inject scripts after page loads ── win.webContents.on('did-finish-load', () => { electronLog.log(`[KCC] Page loaded: ${win.webContents.getURL()}`); // Rescan swap directory so new/changed files are picked up on refresh if (swapper) swapper.rescan().catch(() => {}); const cssInjections = [ win.webContents.insertCSS(HIDE_ADS_CSS), win.webContents.insertCSS(ALL_CLIENT_CSS), ]; // Inject user CSS theme via