import { app, BrowserWindow, Menu, clipboard, dialog, ipcMain, protocol, 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 { 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'; // ── App version for API calls ── 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, disableAccelerated2D: false, increaseLimits: false, lowLatency: false, experimentalFlags: false, }; const advancedConfig = { ...advancedDefaults, ...config.get('advanced') }; const perfConfig = { fpsUnlocked: true, ...config.get('performance') }; applyPlatformFlags(platformInfo, advancedConfig, perfConfig); // ── 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);`; // ── 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); } }); 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'); // ── Register swapper file protocol ── registerSwapperFileProtocol(); // ── 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, '')); // ── Resource swapper ── const swapperConfig = config.get('swapper'); const swapDir = swapperConfig.path || join(app.getPath('userData'), 'Krunker Civilian Client', 'swapper'); 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) ── ses.webRequest.onBeforeRequest({ urls: [...BLOCKED_URL_PATTERNS] }, (details, callback) => { if (swapper) { const redirect = swapper.getRedirect(details.url); if (redirect) return callback({ redirectURL: redirect }); } callback({ cancel: true }); }); // Once swapper scan finishes, re-register with swapper patterns included if (swapper) { swapper.waitForReady().then(() => { const filterUrls = [...BLOCKED_URL_PATTERNS, ...swapper.patterns]; ses.webRequest.onBeforeRequest({ urls: filterUrls }, (details, callback) => { const redirect = swapper.getRedirect(details.url); if (redirect) return callback({ redirectURL: redirect }); callback({ cancel: true }); }); electronLog.log(`[KCC] Swapper ready: ${swapper.patterns.length} pattern(s)`); }); } // ── 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'); } } // ── Common output directory (used by folder actions) ── const outputDir = join(app.getPath('documents'), 'Krunker Civilian Client'); // ── Configurable keybinds via before-input-event ── win.webContents.on('before-input-event', (event, input) => { if (input.type !== 'keyDown') return; const binds = getKeybinds(); if (matchesKeybind(input, binds.reload)) { win.reload(); event.preventDefault(); } else if (matchesKeybind(input, binds.newMatch)) { 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 {}; 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.pauseChat)) { win.webContents.send('toggle-chat-pause'); event.preventDefault(); } else if (matchesKeybind(input, binds.fullscreenToggle)) { win.setFullScreen(!win.isFullScreen()); 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)); // ── Open krunker.io sub-pages in a new window ── 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; } } function openSubWindow(url: string): void { const sub = new BrowserWindow({ width: 1280, height: 720, frame: true, backgroundColor: '#000000', autoHideMenuBar: true, webPreferences: { preload: join(__dirname, '..', 'preload', 'index.js'), session: ses, contextIsolation: false, nodeIntegration: false, sandbox: false, spellcheck: false, }, }); sub.removeMenu(); sub.loadURL(url); sub.webContents.on('did-finish-load', () => { sub.webContents.insertCSS(ALL_CLIENT_CSS).catch(() => {}); sub.webContents.send('main_did-finish-load'); }); sub.webContents.setWindowOpenHandler(({ url: subUrl }) => { if (subUrl.includes('krunker.io')) { sub.loadURL(subUrl); } else { setImmediate(() => shell.openExternal(subUrl)); } return { action: 'deny' }; }); sub.webContents.on('will-prevent-unload', (ev) => { const choice = dialog.showMessageBoxSync(sub, { type: 'question', buttons: ['Leave', 'Stay'], defaultId: 1, title: 'Leave page?', message: 'Changes you made may not be saved.', }); if (choice === 0) ev.preventDefault(); }); } // ── 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; } // Intercept in-page navigation (e.g. window.location = '/social.html') win.webContents.on('will-navigate', (event, url) => { if (url.includes('krunker.io') && !isGameURL(url)) { if (getGameConf().socialTabBehaviour === 'New Window') { event.preventDefault(); openSubWindow(url); } } }); // Intercept target="_blank" / window.open links win.webContents.setWindowOpenHandler(({ url }) => { if (url.includes('krunker.io')) { if (isGameURL(url)) { win.loadURL(url); } else { if (getGameConf().socialTabBehaviour === 'New Window') { openSubWindow(url); } else { win.loadURL(url); } } } else { setImmediate(() => shell.openExternal(url)); } return { action: 'deny' }; }); // ── Inject scripts after page loads ── win.webContents.on('did-finish-load', () => { electronLog.log(`[KCC] Page loaded: ${win.webContents.getURL()}`); Promise.all([ win.webContents.insertCSS(HIDE_ADS_CSS), win.webContents.insertCSS(ALL_CLIENT_CSS), ]).catch(() => {}); win.webContents.executeJavaScript(ESCAPE_POINTERLOCK_FIX_JS).catch((err) => electronLog.warn('[KCC] Pointerlock fix inject failed:', err)); win.webContents.executeJavaScript(CONSENT_DISMISS_MAIN_JS).catch((err) => electronLog.warn('[KCC] Consent dismiss inject failed:', err)); // Notify preload to start hooking settings (matches Crankshaft's timing) win.webContents.send('main_did-finish-load'); }); // ── IPC handlers ── ipcMain.handle('get-version', () => appVersion); ipcMain.handle('get-platform', () => platformInfo); ipcMain.handle('get-config', (_e, key: string) => config.get(key as keyof typeof config.store)); ipcMain.handle('get-all-config', (_e, keys: string[]) => { const result: Record = {}; for (const key of keys) result[key] = config.get(key as keyof typeof config.store); return result; }); let configWriteTimer: ReturnType | null = null; const pendingConfigWrites = new Map(); ipcMain.handle('set-config', (_e, key: string, value: unknown) => { // Flush immediately for keys that have side effects if (key === 'keybinds') { config.set(key as any, value); cachedKeybinds = null; return; } pendingConfigWrites.set(key, value); if (!configWriteTimer) { configWriteTimer = setTimeout(() => { for (const [k, v] of pendingConfigWrites) { config.set(k as any, v); } // Invalidate caches for keys that affect runtime behavior if (pendingConfigWrites.has('game')) cachedGameConf = null; pendingConfigWrites.clear(); configWriteTimer = null; }, 300); } }); ipcMain.handle('window-minimize', () => win.minimize()); ipcMain.handle('window-maximize', () => { if (win.isMaximized()) win.unmaximize(); else win.maximize(); }); ipcMain.handle('window-close', () => win.close()); ipcMain.handle('window-is-maximized', () => win.isMaximized()); ipcMain.handle('toggle-devtools', () => win.webContents.toggleDevTools()); ipcMain.handle('inject-game-click', () => { const [width, height] = win.getContentSize(); const x = Math.round(width / 2); const y = Math.round(height / 2); win.webContents.sendInputEvent({ type: 'mouseDown', x, y, button: 'left', clickCount: 1 }); win.webContents.sendInputEvent({ type: 'mouseUp', x, y, button: 'left', clickCount: 1 }); }); ipcMain.handle('get-swap-dir', () => swapDir); ipcMain.handle('open-swap-folder', () => shell.openPath(swapDir)); // ── Ping regions IPC handler (TCP connect timing, cached 60s) ── ipcMain.handle('ping-regions', async () => { if (Object.keys(pingCache).length > 0 && Date.now() - pingCacheTime < 60000) { return pingCache; } try { const data = await new Promise((resolve, reject) => { httpsGet('https://matchmaker.krunker.io/ping-list?hostname=krunker.io', { rejectUnauthorized: false }, (res) => { let body = ''; res.on('data', (chunk: string) => { body += chunk; }); res.on('end', () => resolve(body)); res.on('error', reject); }).on('error', reject); }); const serverIPs: Record = JSON.parse(data); const results: Record = {}; async function pingWithRetry(host: string): Promise { const latency = await osPing(host); if (latency >= 0) return latency; const retry = await osPing(host); return retry >= 0 ? retry : -1; } const promises = Object.entries(serverIPs).map(async ([server, ip]) => { const regionName = SERVER_MAP[server] ?? server; const host = ip.split(':')[0]; const latency = await pingWithRetry(host); if (latency >= 0) { results[regionName] = latency; } }); await Promise.allSettled(promises); pingCache = results; pingCacheTime = Date.now(); return results; } catch (err) { electronLog.error('[KCC] Ping regions error:', err); return pingCache; } }); // ── Discord Rich Presence IPC handler ── ipcMain.on('discord-update', (_e, activity: any) => { discordRpc?.setActivity(activity); }); // ── Verbose log IPC handler (preload forwards logs here) ── ipcMain.on('verbose-log', (_e, level: string, ...args: unknown[]) => { if (level === 'error') electronLog.error(...args); else if (level === 'warn') electronLog.warn(...args); else electronLog.log(...args); }); // ── Userscript IPC handlers ── ipcMain.handle('userscripts-get-dir', () => userscriptManager ? userscriptManager.dir : ''); ipcMain.handle('userscripts-open-folder', () => { if (userscriptManager) shell.openPath(userscriptManager.dir); }); ipcMain.handle('userscripts-scan', async () => { if (!userscriptManager) return { scripts: [], tracker: {} }; const scripts = await userscriptManager.scanScripts(); const tracker = await userscriptManager.loadTracker(scripts); return { scripts, tracker }; }); ipcMain.handle('userscripts-set-tracker', (_e, tracker: Record) => { if (userscriptManager) userscriptManager.saveTracker(tracker); }); ipcMain.handle('userscripts-load-prefs', (_e, filename: string) => { if (!userscriptManager) return {}; return userscriptManager.loadScriptPrefs(filename); }); ipcMain.handle('userscripts-save-prefs', (_e, filename: string, prefs: Record) => { if (userscriptManager) userscriptManager.saveScriptPrefs(filename, prefs); }); // ── Action button IPC handlers ── ipcMain.handle('open-electron-log', () => { shell.openPath(getLogPath('electron')); }); ipcMain.handle('reset-swapper', async () => { try { const entries = await fsp.readdir(swapDir, { withFileTypes: true }); for (const entry of entries) { await fsp.rm(join(swapDir, entry.name), { recursive: true, force: true }); } return true; } catch (err) { electronLog.error('[KCC] Reset swapper failed:', err); return false; } }); ipcMain.handle('restart-client', () => { app.relaunch(); app.quit(); }); ipcMain.handle('reset-options', () => { config.clear(); app.relaunch(); app.quit(); }); ipcMain.handle('delete-all-data', async () => { config.clear(); const userData = app.getPath('userData'); try { await fsp.rm(join(userData, 'logs'), { recursive: true, force: true }); } catch (err) { electronLog.warn('[KCC] Partial data deletion failed (non-fatal):', err); } app.relaunch(); app.quit(); }); // ── Alt manager IPC handlers ── ipcMain.handle('alt-list', () => config.get('accounts') || []); ipcMain.handle('alt-save', (_e, account: SavedAccount) => { const accounts = config.get('accounts') || []; accounts.push(account); config.set('accounts', accounts); return { success: true, index: accounts.length - 1 }; }); ipcMain.handle('alt-remove', (_e, index: number) => { const accounts = config.get('accounts') || []; if (index < 0 || index >= accounts.length) return { success: false }; accounts.splice(index, 1); config.set('accounts', accounts); return { success: true }; }); ipcMain.handle('alt-rename', (_e, index: number, newLabel: string) => { const accounts = config.get('accounts') || []; if (index < 0 || index >= accounts.length) return { success: false }; accounts[index].label = newLabel; config.set('accounts', accounts); return { success: true }; }); // ── Stop page immediately on close to kill audio ── win.on('close', () => { win.webContents.setAudioMuted(true); win.webContents.stop(); }); // ── Shutdown: disconnect Discord, then close log streams ── app.on('will-quit', () => { discordRpc?.disconnect(); electronLog.log('[KCC] Shutting down'); closeLogStreams(); }); electronLog.log('[KCC] Initialization complete — loading game'); // ── Load the game ── win.loadURL('https://krunker.io'); } app.on('window-all-closed', () => { app.quit(); });