This commit is contained in:
@@ -0,0 +1,843 @@
|
||||
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';
|
||||
|
||||
// ── 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);
|
||||
|
||||
// ── 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');
|
||||
|
||||
// ── 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');
|
||||
|
||||
// ── 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');
|
||||
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<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,
|
||||
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.pauseChat)) {
|
||||
win.webContents.send('toggle-chat-pause');
|
||||
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) => {
|
||||
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
|
||||
const uiConf = config.get('ui');
|
||||
const themeCSS = getThemeCSS(uiConf?.cssTheme || 'disabled', swapDir);
|
||||
if (themeCSS) cssInjections.push(win.webContents.insertCSS(themeCSS));
|
||||
|
||||
// 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', '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;
|
||||
// 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));
|
||||
|
||||
// ── 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);
|
||||
});
|
||||
|
||||
// ── 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://gitea.crjlab.net/api/v1/repos/bigjakk/Krunker-Civilian-Client/releases/tags/${tag}`, (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('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();
|
||||
});
|
||||
Reference in New Issue
Block a user