1eabea195a
Add flat-config ESLint with typescript-eslint recommended rules, fix all lint errors (unused imports/vars, empty catches, Function types, prefer-const, useless assignments), and rename stale kpc- CSS class to kcc-. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
735 lines
26 KiB
TypeScript
735 lines
26 KiB
TypeScript
import { app, BrowserWindow, Menu, clipboard, dialog, 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 { 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 ──
|
|
// 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,
|
|
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);`;
|
|
|
|
// ── 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');
|
|
|
|
// ── 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<void> {
|
|
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) ──
|
|
// 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) => {
|
|
if (swapper) {
|
|
const redirect = swapper.getRedirect(details.url);
|
|
if (redirect) return callback({ redirectURL: redirect });
|
|
}
|
|
// If we got here via the broad krunker.io pattern (not an ad), let it through
|
|
try {
|
|
const host = new URL(details.url).hostname;
|
|
if (host.endsWith('krunker.io')) return callback({});
|
|
} catch { /* ignore invalid URLs */ }
|
|
// Otherwise it 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');
|
|
}
|
|
}
|
|
|
|
// ── Common output directory (used by folder actions) ──
|
|
// ── 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 { /* 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.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(() => safeOpenExternal(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(() => safeOpenExternal(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 ──
|
|
const ALLOWED_CONFIG_KEYS = new Set<string>([
|
|
'window', 'performance', 'game', 'swapper', 'matchmaker',
|
|
'keybinds', 'userscripts', 'ui', 'discord', 'translator',
|
|
'advanced', 'accounts', 'platform',
|
|
]);
|
|
|
|
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;
|
|
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));
|
|
|
|
// ── 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;
|
|
}
|
|
});
|
|
|
|
// ── 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<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('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 (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();
|
|
});
|