Files
Krunker-Civilian-Client-Test/src/main/index.ts
T

918 lines
35 KiB
TypeScript

import { app, BrowserWindow, Menu, clipboard, ipcMain, safeStorage, session, shell } from 'electron';
import { join, dirname } from 'path';
import { existsSync, mkdirSync, promises as fsp } from 'fs';
import { existsSync as origExistsSync, unlinkSync as origUnlinkSync } from 'original-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, applyMinorUpdate } 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<string, string> = {
'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<string, number> = {};
let pingCacheTime = 0;
function osPing(host: string): Promise<number> {
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<string, Keybind> | null = null;
function getKeybinds(): Record<string, Keybind> {
if (!cachedKeybinds) {
cachedKeybinds = { ...DEFAULT_KEYBINDS, ...config.get('keybinds') };
}
return cachedKeybinds;
}
// ── Debounced window state persistence ──
let saveTimer: ReturnType<typeof setTimeout> | 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');
electronLog.log('[KCC] Minor update test — asar swap successful');
// ── Auto-update check (2-stage: minor asar swap or major installer) ──
const isPortable = !!process.env.PORTABLE_EXECUTABLE_DIR;
const isAppImage = !!process.env.APPIMAGE;
const isDev = !app.isPackaged;
const canMajorUpdate = process.platform === 'win32' && !isPortable;
const canMinorUpdate = !isPortable && !isAppImage;
if (isDev || (!canMajorUpdate && !canMinorUpdate)) {
electronLog.log('[KCC] Skipping auto-update');
} else {
// Clean up stale pending asar from a previous failed swap
const resourcesDir = join(dirname(app.getPath('exe')), 'resources');
const stalePending = join(resourcesDir, 'app-pending.asar');
if (origExistsSync(stalePending)) {
try { origUnlinkSync(stalePending); } catch { /* ignore */ }
}
try {
electronLog.log('[KCC] Checking for updates...');
const update = await checkForUpdate(appVersion);
if (update) {
electronLog.log(`[KCC] Update available: v${update.version} (${update.updateType})`);
const { window: updateWin, sendProgress } = showUpdateWindow();
let cancelled = false;
updateWin.on('closed', () => { cancelled = true; });
if (update.updateType === 'minor' && canMinorUpdate) {
// Minor update: download app.asar, swap via external script, restart
sendProgress(`Patch available (v${update.version})`, 0);
const pendingPath = join(resourcesDir, 'app-pending.asar');
try {
await downloadUpdate(update.downloadUrl, pendingPath, (pct) => {
if (!cancelled && !updateWin.isDestroyed()) {
sendProgress(`Downloading patch... ${pct}%`, pct);
}
}, update.sha256 || undefined);
if (!cancelled) {
sendProgress('Applying patch...', 100);
applyMinorUpdate(pendingPath);
return;
}
} catch (err) {
electronLog.error('[KCC] Patch download failed:', err);
// Clean up failed download
if (origExistsSync(pendingPath)) {
try { origUnlinkSync(pendingPath); } catch { /* ignore */ }
}
if (!updateWin.isDestroyed()) updateWin.close();
}
} else if (update.updateType === 'major' && canMajorUpdate) {
// Major update: download Setup.exe, run installer
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`);
try {
await downloadUpdate(update.downloadUrl, installerPath, (pct) => {
if (!cancelled && !updateWin.isDestroyed()) {
sendProgress(`Downloading update... ${pct}%`, pct);
}
}, update.sha256 || undefined);
if (!cancelled) {
sendProgress('Installing update...', 100);
installUpdate(installerPath);
return;
}
} catch (err) {
electronLog.error('[KCC] Update download failed:', err);
if (!updateWin.isDestroyed()) updateWin.close();
}
} else {
electronLog.log('[KCC] Update available but cannot auto-install on this platform');
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<void> {
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: true,
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<string, number> = {
'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<Electron.WebContents>();
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,
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,
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 sessionTabs: string[] = [];
let tabManager = new TabManager(
win, ses, preloadPath, tabMode, isGameURL,
() => config.get('tabWindow'),
(state) => config.set('tabWindow', state),
() => sessionTabs,
(urls) => { sessionTabs = urls; },
() => config.get('game.rememberTabs') ?? false,
);
// 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 <style> tag so @import rules work
const uiConf = config.get('ui');
const themeId = uiConf?.cssTheme || 'disabled';
const themeCSS = getThemeCSS(themeId, swapDir);
electronLog.log(`[KCC] CSS theme: id=${themeId}, css=${themeCSS ? themeCSS.length + ' chars' : 'none'}`);
if (themeCSS) {
// Use <style> tag via executeJavaScript so @import rules work (insertCSS doesn't support them).
// Encode as base64 to avoid any escaping issues with template literals.
const b64 = Buffer.from(themeCSS).toString('base64');
win.webContents.executeJavaScript(`(() => {
const s = document.createElement('style');
s.id = 'kcc-user-theme';
s.textContent = atob('${b64}');
document.head.appendChild(s);
})()`).catch((err) => electronLog.warn('[KCC] Theme inject failed:', err));
}
// Inject loading screen background
const loadingCSS = getLoadingScreenCSS(uiConf?.loadingTheme || 'disabled', uiConf?.backgroundUrl || '', swapDir);
if (loadingCSS) cssInjections.push(win.webContents.insertCSS(loadingCSS));
Promise.all(cssInjections).catch(() => {});
// Apply initial CPU throttle (menu state)
const perf = config.get('performance');
applyCpuThrottle(win.webContents, perf?.cpuThrottleMenu ?? 1.5);
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 ──
const ALLOWED_CONFIG_KEYS = new Set<string>([
'window', 'performance', 'game', 'swapper', 'matchmaker',
'keybinds', 'userscripts', 'ui', 'discord', 'translator',
'advanced', 'accounts', 'tabWindow',
]);
ipcMain.handle('get-version', () => appVersion);
ipcMain.handle('get-platform', () => platformInfo);
ipcMain.handle('get-config', (_e, key: string) => {
if (!ALLOWED_CONFIG_KEYS.has(key)) return undefined;
return config.get(key as keyof typeof config.store);
});
ipcMain.handle('get-all-config', (_e, keys: string[]) => {
const result: Record<string, unknown> = {};
for (const key of keys) {
if (ALLOWED_CONFIG_KEYS.has(key)) result[key] = config.get(key as keyof typeof config.store);
}
return result;
});
let configWriteTimer: ReturnType<typeof setTimeout> | null = null;
const pendingConfigWrites = new Map<string, unknown>();
ipcMain.handle('set-config', (_e, key: string, value: unknown) => {
if (!ALLOWED_CONFIG_KEYS.has(key)) return;
// Flush immediately for keys that have side effects
if (key === 'keybinds') {
config.set(key as any, value);
cachedKeybinds = null;
return;
}
// Invalidate caches immediately (not on flush) to prevent stale reads
if (key === 'game') {
cachedGameConf = null;
// Switch tab mode if socialTabBehaviour changed
const newGame = value as any;
if (newGame?.socialTabBehaviour) {
const newMode: 'same' | 'new' = newGame.socialTabBehaviour === 'Same Window' ? 'same' : 'new';
if (newMode !== tabMode) {
tabManager.destroyAll();
tabMode = newMode;
tabManager = new TabManager(
win, ses, preloadPath, tabMode, isGameURL,
() => config.get('tabWindow'),
(state) => config.set('tabWindow', state),
);
}
}
}
pendingConfigWrites.set(key, value);
if (!configWriteTimer) {
configWriteTimer = setTimeout(() => {
for (const [k, v] of pendingConfigWrites) {
config.set(k as any, v);
}
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));
ipcMain.handle('open-themes-folder', () => shell.openPath(join(swapDir, 'themes')));
ipcMain.handle('open-backgrounds-folder', () => shell.openPath(join(swapDir, 'backgrounds')));
// ── 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<string>((resolve, reject) => {
httpsGet('https://matchmaker.krunker.io/ping-list?hostname=krunker.io', (res) => {
let body = '';
res.on('data', (chunk: string) => { body += chunk; });
res.on('end', () => resolve(body));
res.on('error', reject);
}).on('error', reject);
});
const serverIPs: Record<string, string> = JSON.parse(data);
const results: Record<string, number> = {};
async function pingWithRetry(host: string): Promise<number> {
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;
}
});
// ── Ranked queue IPC handler ──
ipcMain.on('open-ranked-queue', (_e, token: string, region: string, allRegions: boolean) => {
openRankedQueue(token, region, allRegions);
});
// ── 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);
});
// ── CPU throttle IPC handler ──
ipcMain.on('throttle-state', (_e, state: string) => {
const perf = config.get('performance');
const rate = state === 'game' ? (perf?.cpuThrottleGame ?? 1) : (perf?.cpuThrottleMenu ?? 1.5);
applyCpuThrottle(win.webContents, rate);
});
// ── CSS theme & loading background IPC handlers ──
ipcMain.handle('list-themes', () => listThemes(swapDir));
ipcMain.handle('get-theme-css', (_e, themeId: string) => getThemeCSS(themeId, swapDir));
ipcMain.handle('list-loading-themes', () => listLoadingThemes(swapDir));
ipcMain.handle('get-loading-screen-css', (_e, loadingTheme: string, backgroundUrl: string) => {
return getLoadingScreenCSS(loadingTheme, backgroundUrl, swapDir);
});
// ── Changelog IPC handler (fetch release notes from Gitea) ──
ipcMain.handle('changelog-fetch', async (_e, version: string) => {
const tag = version.startsWith('v') ? version : `v${version}`;
try {
const data = await new Promise<string>((resolve, reject) => {
httpsGet(`https://api.github.com/repos/bigjakk/Krunker-Civilian-Client/releases/tags/${tag}`, { headers: { 'User-Agent': 'KCC' } }, (res) => {
let body = '';
res.on('data', (chunk: string) => { body += chunk; });
res.on('end', () => resolve(body));
res.on('error', reject);
}).on('error', reject);
});
const release = JSON.parse(data);
return release.body || '';
} catch {
return '';
}
});
// ── 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<string, boolean>) => {
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<string, unknown>) => {
if (userscriptManager) userscriptManager.saveScriptPrefs(filename, prefs);
});
// ── Action button IPC handlers ──
ipcMain.handle('open-electron-log', () => {
shell.openPath(getLogPath());
});
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 (credentials encrypted via safeStorage) ──
const canEncrypt = safeStorage.isEncryptionAvailable();
if (!canEncrypt) electronLog.warn('[KCC] safeStorage encryption not available — account passwords will use base64 fallback');
function encryptString(plaintext: string): string {
if (canEncrypt) return safeStorage.encryptString(plaintext).toString('base64');
return Buffer.from(plaintext).toString('base64');
}
function decryptString(encrypted: string): string {
if (canEncrypt) return safeStorage.decryptString(Buffer.from(encrypted, 'base64'));
return Buffer.from(encrypted, 'base64').toString();
}
ipcMain.handle('alt-list', () => {
const accounts = config.get('accounts') || [];
// Return only labels to the renderer — never send encrypted credentials
return accounts.map((a: SavedAccount) => ({ label: a.label }));
});
ipcMain.handle('alt-save', (_e, data: { label: string; username: string; password: string }) => {
const accounts = config.get('accounts') || [];
const account: SavedAccount = {
label: data.label,
username: encryptString(data.username),
password: encryptString(data.password),
};
accounts.push(account);
config.set('accounts', accounts);
return { success: true, index: accounts.length - 1 };
});
ipcMain.handle('alt-get-credentials', (_e, index: number) => {
const accounts = config.get('accounts') || [];
if (index < 0 || index >= accounts.length) return null;
const acc = accounts[index];
try {
return {
username: decryptString(acc.username),
password: decryptString(acc.password),
};
} catch (err) {
electronLog.error('[KCC] Failed to decrypt account credentials:', err);
return null;
}
});
ipcMain.handle('alt-remove', (_e, index: number) => {
const accounts = config.get('accounts') || [];
if (index < 0 || index >= accounts.length) return { success: false };
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();
});