Security hardening and codebase cleanup
Security fixes: - Replace Caesar cipher with electron.safeStorage for account credentials - Validate shell.openExternal URLs (allow only http/https protocols) - Remove rejectUnauthorized:false from all HTTPS calls - Add redirect domain validation to auto-updater - Fix XSS in matchmaker popup (innerHTML → textContent/createTextNode) - Add IPC config key whitelist to prevent arbitrary store access - Credentials never sent to renderer; decrypted on-demand via IPC Optimizations and cleanup: - Simplify onBeforeRequest from double-registration to single handler - Lazy-init matchmaker popup DOM (defer until first use) - Invalidate game config cache immediately on write, not on flush - Remove unused STANDARD_ASSET_RE and KeybindDef exports - Deduplicate Keybind type (import from config.ts) - Replace custom hasOwn wrapper with Object.hasOwn Bug fix: - Stop Krunker's global keydown handler from eating keystrokes in alt manager input fields (stopPropagation) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Generated
-1
@@ -5344,7 +5344,6 @@
|
||||
"integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
|
||||
+84
-19
@@ -1,4 +1,4 @@
|
||||
import { app, BrowserWindow, Menu, clipboard, dialog, ipcMain, protocol, session, shell } from 'electron';
|
||||
import { app, BrowserWindow, Menu, clipboard, dialog, ipcMain, protocol, safeStorage, session, shell } from 'electron';
|
||||
import { join } from 'path';
|
||||
import { existsSync, mkdirSync, promises as fsp } from 'fs';
|
||||
import { get as httpsGet } from 'https';
|
||||
@@ -107,6 +107,16 @@ document.addEventListener('keydown', function(e) {
|
||||
}
|
||||
}, 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;
|
||||
@@ -221,23 +231,29 @@ async function launchApp(): Promise<void> {
|
||||
electronLog.log(`[KCC] Userscripts: ${userscriptManager ? 'enabled' : 'disabled'} (${usDir})`);
|
||||
|
||||
// ── Ad blocking + resource swapper (single onBeforeRequest — Electron only allows one) ──
|
||||
ses.webRequest.onBeforeRequest({ urls: [...BLOCKED_URL_PATTERNS] }, (details, callback) => {
|
||||
// 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 {}
|
||||
// Otherwise it matched an ad-block pattern — cancel it
|
||||
callback({ cancel: true });
|
||||
});
|
||||
|
||||
// Once swapper scan finishes, re-register with swapper patterns included
|
||||
if (swapper) {
|
||||
swapper.waitForReady().then(() => {
|
||||
const filterUrls = [...BLOCKED_URL_PATTERNS, ...swapper.patterns];
|
||||
ses.webRequest.onBeforeRequest({ urls: filterUrls }, (details, callback) => {
|
||||
const redirect = swapper.getRedirect(details.url);
|
||||
if (redirect) return callback({ redirectURL: redirect });
|
||||
callback({ cancel: true });
|
||||
});
|
||||
electronLog.log(`[KCC] Swapper ready: ${swapper.patterns.length} pattern(s)`);
|
||||
});
|
||||
}
|
||||
@@ -390,7 +406,7 @@ async function launchApp(): Promise<void> {
|
||||
if (subUrl.includes('krunker.io')) {
|
||||
sub.loadURL(subUrl);
|
||||
} else {
|
||||
setImmediate(() => shell.openExternal(subUrl));
|
||||
setImmediate(() => safeOpenExternal(subUrl));
|
||||
}
|
||||
return { action: 'deny' };
|
||||
});
|
||||
@@ -437,7 +453,7 @@ async function launchApp(): Promise<void> {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setImmediate(() => shell.openExternal(url));
|
||||
setImmediate(() => safeOpenExternal(url));
|
||||
}
|
||||
return { action: 'deny' };
|
||||
});
|
||||
@@ -457,32 +473,44 @@ async function launchApp(): Promise<void> {
|
||||
});
|
||||
|
||||
// ── 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) => config.get(key as keyof typeof config.store));
|
||||
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) result[key] = config.get(key as keyof typeof config.store);
|
||||
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);
|
||||
}
|
||||
// Invalidate caches for keys that affect runtime behavior
|
||||
if (pendingConfigWrites.has('game')) cachedGameConf = null;
|
||||
pendingConfigWrites.clear();
|
||||
configWriteTimer = null;
|
||||
}, 300);
|
||||
@@ -512,7 +540,7 @@ async function launchApp(): Promise<void> {
|
||||
}
|
||||
try {
|
||||
const data = await new Promise<string>((resolve, reject) => {
|
||||
httpsGet('https://matchmaker.krunker.io/ping-list?hostname=krunker.io', { rejectUnauthorized: false }, (res) => {
|
||||
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));
|
||||
@@ -620,16 +648,53 @@ async function launchApp(): Promise<void> {
|
||||
app.quit();
|
||||
});
|
||||
|
||||
// ── Alt manager IPC handlers ──
|
||||
ipcMain.handle('alt-list', () => config.get('accounts') || []);
|
||||
// ── 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');
|
||||
|
||||
ipcMain.handle('alt-save', (_e, account: SavedAccount) => {
|
||||
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 };
|
||||
|
||||
@@ -4,7 +4,6 @@ import { protocol, net } from 'electron';
|
||||
|
||||
const PROTOCOL_NAME = 'kpc-swap';
|
||||
const TARGET_DOMAIN = 'krunker.io';
|
||||
const STANDARD_ASSET_RE = /^\/(?:models|textures|sound|scares|videos)(?:$|\/)/u;
|
||||
|
||||
/**
|
||||
* Register the custom protocol scheme. Must be called BEFORE app.ready.
|
||||
|
||||
+44
-11
@@ -16,12 +16,25 @@ const UPDATE_CONFIG = {
|
||||
// Gitea provider (swap these for kpdclient.com migration)
|
||||
checkUrl: 'https://gitea.crjlab.net/api/v1/repos/bigjakk/Krunker-Civilian-Client/releases/latest',
|
||||
assetPattern: /Setup\.exe$/i,
|
||||
rejectUnauthorized: false,
|
||||
// Allowed hosts for update check and download (including redirects)
|
||||
allowedHosts: ['gitea.crjlab.net'],
|
||||
};
|
||||
|
||||
const CHECK_TIMEOUT_MS = 10000;
|
||||
const DOWNLOAD_TIMEOUT_MS = 300000; // 5 minutes
|
||||
|
||||
/**
|
||||
* Validate that a redirect URL stays on an allowed host.
|
||||
*/
|
||||
function isAllowedRedirect(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return UPDATE_CONFIG.allowedHosts.some(h => parsed.hostname === h || parsed.hostname.endsWith('.' + h));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple semver comparison: returns true if a < b.
|
||||
* Handles versions like "0.1.0", "1.2.3".
|
||||
@@ -45,15 +58,19 @@ export function checkForUpdate(currentVersion: string): Promise<UpdateInfo | nul
|
||||
electronLog.log('[KCC-Update] Current version:', currentVersion);
|
||||
|
||||
const req = httpsGet(UPDATE_CONFIG.checkUrl, {
|
||||
rejectUnauthorized: UPDATE_CONFIG.rejectUnauthorized,
|
||||
headers: { 'User-Agent': 'KrunkerCivilianClient/' + currentVersion },
|
||||
}, (res) => {
|
||||
electronLog.log('[KCC-Update] Check response status:', res.statusCode);
|
||||
// Follow redirects
|
||||
// Follow redirects (with domain validation)
|
||||
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
electronLog.log('[KCC-Update] Redirected to:', res.headers.location);
|
||||
httpsGet(res.headers.location, {
|
||||
rejectUnauthorized: UPDATE_CONFIG.rejectUnauthorized,
|
||||
const redirectUrl = res.headers.location;
|
||||
electronLog.log('[KCC-Update] Redirected to:', redirectUrl);
|
||||
if (!isAllowedRedirect(redirectUrl)) {
|
||||
electronLog.error('[KCC-Update] Redirect to untrusted host blocked:', redirectUrl);
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
httpsGet(redirectUrl, {
|
||||
headers: { 'User-Agent': 'KrunkerCivilianClient/' + currentVersion },
|
||||
}, (redirectRes) => {
|
||||
electronLog.log('[KCC-Update] Redirect response status:', redirectRes.statusCode);
|
||||
@@ -97,6 +114,13 @@ export function checkForUpdate(currentVersion: string): Promise<UpdateInfo | nul
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate the download URL points to an allowed host
|
||||
if (!isAllowedRedirect(setupAsset.browser_download_url)) {
|
||||
electronLog.error('[KCC-Update] Download URL points to untrusted host:', setupAsset.browser_download_url);
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
electronLog.log('[KCC-Update] Update available:', remoteVersion, '| Download:', setupAsset.browser_download_url, '| Size:', setupAsset.size);
|
||||
resolve({
|
||||
version: remoteVersion,
|
||||
@@ -131,16 +155,25 @@ export function downloadUpdate(url: string, destPath: string, onProgress: Progre
|
||||
return new Promise((resolve, reject) => {
|
||||
const tmpPath = destPath + '.tmp';
|
||||
|
||||
function doDownload(downloadUrl: string): void {
|
||||
function doDownload(downloadUrl: string, redirectCount = 0): void {
|
||||
if (redirectCount > 5) {
|
||||
reject(new Error('Too many redirects'));
|
||||
return;
|
||||
}
|
||||
electronLog.log('[KCC-Update] Downloading from:', downloadUrl);
|
||||
const req = httpsGet(downloadUrl, {
|
||||
rejectUnauthorized: UPDATE_CONFIG.rejectUnauthorized,
|
||||
headers: { 'User-Agent': 'KrunkerCivilianClient' },
|
||||
}, (res) => {
|
||||
// Follow redirects
|
||||
// Follow redirects (with domain validation)
|
||||
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
electronLog.log('[KCC-Update] Download redirected to:', res.headers.location);
|
||||
doDownload(res.headers.location);
|
||||
const redirectUrl = res.headers.location;
|
||||
electronLog.log('[KCC-Update] Download redirected to:', redirectUrl);
|
||||
if (!isAllowedRedirect(redirectUrl)) {
|
||||
electronLog.error('[KCC-Update] Download redirect to untrusted host blocked:', redirectUrl);
|
||||
reject(new Error('Download redirect to untrusted host: ' + redirectUrl));
|
||||
return;
|
||||
}
|
||||
doDownload(redirectUrl, redirectCount + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+28
-34
@@ -5,6 +5,7 @@ import { initUserscripts, getInstances, setScriptEnabled } from './userscripts';
|
||||
import type { UserscriptInstance, UserscriptSetting } from './userscripts';
|
||||
import { initTranslator, updateTranslatorConfig } from './translator';
|
||||
import { setDeathAnimBlock, escapeHtml } from './utils';
|
||||
import type { Keybind } from '../main/config';
|
||||
|
||||
|
||||
// ── Save console methods before Krunker overwrites them ──
|
||||
@@ -119,13 +120,7 @@ function updateRefreshNotification(): void {
|
||||
|
||||
// ── Client settings tab in Krunker's settings ──
|
||||
|
||||
function hasOwn(obj: any, key: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(obj, key);
|
||||
}
|
||||
|
||||
// ── Keybind types + helpers ──
|
||||
interface Keybind { key: string; ctrl: boolean; shift: boolean; alt: boolean; }
|
||||
|
||||
// ── Keybind helpers ──
|
||||
function keybindDisplayString(bind: Keybind): string {
|
||||
return (bind.shift ? 'Shift+' : '') + (bind.ctrl ? 'Ctrl+' : '') + (bind.alt ? 'Alt+' : '') + bind.key.toUpperCase();
|
||||
}
|
||||
@@ -677,19 +672,6 @@ function buildDiscordSection(body: HTMLElement, discordConf: any): void {
|
||||
}
|
||||
|
||||
// ── Alt Manager helpers ──
|
||||
function encodeCredential(decoded: string): string {
|
||||
const key = decoded.length;
|
||||
return encodeURIComponent(
|
||||
decoded.split('').map(c => String.fromCharCode(c.charCodeAt(0) + key)).join('')
|
||||
);
|
||||
}
|
||||
|
||||
function decodeCredential(encoded: string): string {
|
||||
const str = decodeURIComponent(encoded);
|
||||
const key = str.length;
|
||||
return str.split('').map(c => String.fromCharCode(c.charCodeAt(0) - key)).join('');
|
||||
}
|
||||
|
||||
function switchToAccount(account: { username: string; password: string }): void {
|
||||
const w = window as any;
|
||||
if (typeof w.loginOrRegister !== 'function') return;
|
||||
@@ -703,8 +685,8 @@ function switchToAccount(account: { username: string; password: string }): void
|
||||
const nameInput = document.querySelector('#accName') as HTMLInputElement;
|
||||
const passInput = document.querySelector('#accPass') as HTMLInputElement;
|
||||
if (!nameInput || !passInput) return;
|
||||
nameInput.value = decodeCredential(account.username);
|
||||
passInput.value = decodeCredential(account.password);
|
||||
nameInput.value = account.username;
|
||||
passInput.value = account.password;
|
||||
nameInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
passInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
const submitBtn = document.querySelector('.io-button') as HTMLElement;
|
||||
@@ -749,6 +731,11 @@ function buildAccountsSection(body: HTMLElement, accountsArr: any[]): void {
|
||||
const userIn = form.querySelector('.kpc-acc-user') as HTMLInputElement;
|
||||
const passIn = form.querySelector('.kpc-acc-pass') as HTMLInputElement;
|
||||
|
||||
// Stop Krunker's global keydown handler from eating keystrokes in our inputs
|
||||
form.querySelectorAll('input').forEach(input => {
|
||||
input.addEventListener('keydown', (e) => e.stopPropagation());
|
||||
});
|
||||
|
||||
addBtn.querySelector('button')!.addEventListener('click', () => {
|
||||
form.style.display = form.style.display === 'none' ? '' : 'none';
|
||||
});
|
||||
@@ -777,7 +764,11 @@ function buildAccountsSection(body: HTMLElement, accountsArr: any[]): void {
|
||||
'<button class="kpc-acc-switch">Switch</button>' +
|
||||
'<button class="kpc-acc-delete">Delete</button>' +
|
||||
'</div>';
|
||||
row.querySelector('.kpc-acc-switch')!.addEventListener('click', () => switchToAccount(acc));
|
||||
row.querySelector('.kpc-acc-switch')!.addEventListener('click', () => {
|
||||
ipcRenderer.invoke('alt-get-credentials', i).then((creds: { username: string; password: string } | null) => {
|
||||
if (creds) switchToAccount(creds);
|
||||
});
|
||||
});
|
||||
row.querySelector('.kpc-acc-delete')!.addEventListener('click', () => {
|
||||
ipcRenderer.invoke('alt-remove', i).then(() => {
|
||||
accounts.splice(i, 1);
|
||||
@@ -794,13 +785,9 @@ function buildAccountsSection(body: HTMLElement, accountsArr: any[]): void {
|
||||
const user = userIn.value.trim();
|
||||
const pass = passIn.value;
|
||||
if (!label || !user || !pass) return;
|
||||
const newAcc = {
|
||||
label,
|
||||
username: encodeCredential(user),
|
||||
password: encodeCredential(pass),
|
||||
};
|
||||
const newAcc = { label, username: user, password: pass };
|
||||
ipcRenderer.invoke('alt-save', newAcc).then(() => {
|
||||
accounts.push(newAcc);
|
||||
accounts.push({ label });
|
||||
labelIn.value = '';
|
||||
userIn.value = '';
|
||||
passIn.value = '';
|
||||
@@ -1555,7 +1542,9 @@ ipcRenderer.on('main_did-finish-load', () => {
|
||||
const idx = parseInt((el as HTMLElement).dataset.idx || '0', 10);
|
||||
if (accs[idx]) {
|
||||
windowHolder.style.display = 'none';
|
||||
switchToAccount(accs[idx]);
|
||||
ipcRenderer.invoke('alt-get-credentials', idx).then((creds: { username: string; password: string } | null) => {
|
||||
if (creds) switchToAccount(creds);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1584,6 +1573,11 @@ ipcRenderer.on('main_did-finish-load', () => {
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
// Stop Krunker's global keydown handler from eating keystrokes in our inputs
|
||||
menuWindow.querySelectorAll('input.accountInput').forEach((input) => {
|
||||
input.addEventListener('keydown', (e) => e.stopPropagation());
|
||||
});
|
||||
|
||||
document.getElementById('kpcAltBackBtn')!.addEventListener('click', renderAccountList);
|
||||
document.getElementById('kpcAltSaveBtn')!.addEventListener('click', () => {
|
||||
const label = (document.getElementById('kpcAltLabel') as HTMLInputElement).value.trim();
|
||||
@@ -1592,8 +1586,8 @@ ipcRenderer.on('main_did-finish-load', () => {
|
||||
if (!label || !user || !pass) return;
|
||||
ipcRenderer.invoke('alt-save', {
|
||||
label,
|
||||
username: encodeCredential(user),
|
||||
password: encodeCredential(pass),
|
||||
username: user,
|
||||
password: pass,
|
||||
}).then(() => renderAccountList());
|
||||
});
|
||||
}
|
||||
@@ -1634,9 +1628,9 @@ ipcRenderer.on('main_did-finish-load', () => {
|
||||
const pollInterval = setInterval(() => {
|
||||
const w = window as any;
|
||||
if (
|
||||
hasOwn(w, 'showWindow')
|
||||
Object.hasOwn(w, 'showWindow')
|
||||
&& typeof w.showWindow === 'function'
|
||||
&& hasOwn(w, 'windows')
|
||||
&& Object.hasOwn(w, 'windows')
|
||||
&& Array.isArray(w.windows)
|
||||
&& w.windows.length >= 0
|
||||
&& typeof w.windows[0] !== 'undefined'
|
||||
|
||||
+62
-35
@@ -39,39 +39,57 @@ function secondsToTimestring(num: number): string {
|
||||
return `${minutes}m ${seconds}s`;
|
||||
}
|
||||
|
||||
// ── Popup DOM (created once, reused) ──
|
||||
// ── Popup DOM (lazy-initialized on first use) ──
|
||||
const POPUP_ID = 'matchmakerPopupContainer';
|
||||
const popupElement = document.createElement('div');
|
||||
popupElement.id = POPUP_ID;
|
||||
|
||||
const popupTitle = document.createElement('div');
|
||||
popupTitle.id = 'matchmakerPopupTitle';
|
||||
popupElement.appendChild(popupTitle);
|
||||
interface PopupDOM {
|
||||
element: HTMLDivElement;
|
||||
title: HTMLDivElement;
|
||||
description: HTMLDivElement;
|
||||
confirmBtn: HTMLDivElement;
|
||||
cancelBtn: HTMLDivElement;
|
||||
}
|
||||
|
||||
const popupDescription = document.createElement('div');
|
||||
popupDescription.id = 'matchmakerPopupDescription';
|
||||
popupElement.appendChild(popupDescription);
|
||||
let _popup: PopupDOM | null = null;
|
||||
|
||||
const popupOptions = document.createElement('div');
|
||||
popupOptions.id = 'matchmakerPopupOptions';
|
||||
function getPopup(): PopupDOM {
|
||||
if (_popup) return _popup;
|
||||
|
||||
const popupConfirmBtn = document.createElement('div');
|
||||
popupConfirmBtn.id = 'matchmakerConfirmButton';
|
||||
popupConfirmBtn.className = 'matchmakerPopupButton bigShadowT';
|
||||
popupConfirmBtn.textContent = 'Join';
|
||||
popupConfirmBtn.setAttribute('onmouseenter', 'playTick()');
|
||||
popupConfirmBtn.addEventListener('click', () => decideMatchmakerDecision(true));
|
||||
const element = document.createElement('div');
|
||||
element.id = POPUP_ID;
|
||||
|
||||
const popupCancelBtn = document.createElement('div');
|
||||
popupCancelBtn.id = 'matchmakerCancelButton';
|
||||
popupCancelBtn.className = 'matchmakerPopupButton bigShadowT';
|
||||
popupCancelBtn.textContent = 'Cancel';
|
||||
popupCancelBtn.setAttribute('onmouseenter', 'playTick()');
|
||||
popupCancelBtn.addEventListener('click', () => decideMatchmakerDecision(false));
|
||||
const title = document.createElement('div');
|
||||
title.id = 'matchmakerPopupTitle';
|
||||
element.appendChild(title);
|
||||
|
||||
popupOptions.appendChild(popupConfirmBtn);
|
||||
popupOptions.appendChild(popupCancelBtn);
|
||||
popupElement.appendChild(popupOptions);
|
||||
const description = document.createElement('div');
|
||||
description.id = 'matchmakerPopupDescription';
|
||||
element.appendChild(description);
|
||||
|
||||
const options = document.createElement('div');
|
||||
options.id = 'matchmakerPopupOptions';
|
||||
|
||||
const confirmBtn = document.createElement('div');
|
||||
confirmBtn.id = 'matchmakerConfirmButton';
|
||||
confirmBtn.className = 'matchmakerPopupButton bigShadowT';
|
||||
confirmBtn.textContent = 'Join';
|
||||
confirmBtn.addEventListener('mouseenter', () => { (window as any).playTick?.(); });
|
||||
confirmBtn.addEventListener('click', () => decideMatchmakerDecision(true));
|
||||
|
||||
const cancelBtn = document.createElement('div');
|
||||
cancelBtn.id = 'matchmakerCancelButton';
|
||||
cancelBtn.className = 'matchmakerPopupButton bigShadowT';
|
||||
cancelBtn.textContent = 'Cancel';
|
||||
cancelBtn.addEventListener('mouseenter', () => { (window as any).playTick?.(); });
|
||||
cancelBtn.addEventListener('click', () => decideMatchmakerDecision(false));
|
||||
|
||||
options.appendChild(confirmBtn);
|
||||
options.appendChild(cancelBtn);
|
||||
element.appendChild(options);
|
||||
|
||||
_popup = { element, title, description, confirmBtn, cancelBtn };
|
||||
return _popup;
|
||||
}
|
||||
|
||||
// ── State ──
|
||||
let currentMatch = '';
|
||||
@@ -86,7 +104,8 @@ function decideMatchmakerDecision(accept: boolean): void {
|
||||
if (accept && currentMatch !== 'none') {
|
||||
window.location.href = `https://krunker.io/?game=${currentMatch}`;
|
||||
} else {
|
||||
if (popupElement.parentNode) popupElement.remove();
|
||||
const popup = getPopup();
|
||||
if (popup.element.parentNode) popup.element.remove();
|
||||
if (currentMatch === 'none' && openServerBrowser && typeof w.openServerWindow === 'function') {
|
||||
w.openServerWindow(0);
|
||||
}
|
||||
@@ -112,24 +131,32 @@ function handleMatchmakerBind(event: KeyboardEvent): void {
|
||||
}
|
||||
|
||||
function createFetchedGamePopup(game: MatchmakerGame): void {
|
||||
const popup = getPopup();
|
||||
const mapIdx = MAP_ICON_INDICES.indexOf(game.map);
|
||||
popupElement.style.backgroundImage = `url(https://assets.krunker.io/img/maps/map_${mapIdx >= 0 ? mapIdx : 0}.png)`;
|
||||
popup.element.style.backgroundImage = `url(https://assets.krunker.io/img/maps/map_${mapIdx >= 0 ? mapIdx : 0}.png)`;
|
||||
|
||||
currentMatch = game.gameID;
|
||||
if (game.gameID === 'none') {
|
||||
popupTitle.innerText = 'No Games Found...';
|
||||
popupDescription.innerHTML = 'Check the server browser to see other lobbies.';
|
||||
popupConfirmBtn.style.display = 'none';
|
||||
popup.title.textContent = 'No Games Found...';
|
||||
popup.description.textContent = 'Check the server browser to see other lobbies.';
|
||||
popup.confirmBtn.style.display = 'none';
|
||||
} else {
|
||||
popupTitle.innerText = 'Game Found!';
|
||||
popup.title.textContent = 'Game Found!';
|
||||
const regionName = MATCHMAKER_REGION_NAMES[game.region] ?? 'Unknown Region';
|
||||
popupDescription.innerHTML = `${game.gamemode} on ${game.map} (${regionName})<br/>${game.playerCount}/${game.playerLimit} Players, ${secondsToTimestring(game.remainingTime)} Left`;
|
||||
popupConfirmBtn.style.display = 'block';
|
||||
popup.description.textContent = '';
|
||||
popup.description.appendChild(document.createTextNode(
|
||||
`${game.gamemode} on ${game.map} (${regionName})`
|
||||
));
|
||||
popup.description.appendChild(document.createElement('br'));
|
||||
popup.description.appendChild(document.createTextNode(
|
||||
`${game.playerCount}/${game.playerLimit} Players, ${secondsToTimestring(game.remainingTime)} Left`
|
||||
));
|
||||
popup.confirmBtn.style.display = 'block';
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleMatchmakerBind, true);
|
||||
const uiBase = document.getElementById('uiBase');
|
||||
if (uiBase) uiBase.appendChild(popupElement);
|
||||
if (uiBase) uiBase.appendChild(popup.element);
|
||||
}
|
||||
|
||||
export async function fetchGame(mmConfig: MatchmakerConfig, _con?: SavedConsole): Promise<void> {
|
||||
|
||||
@@ -9,13 +9,6 @@ export interface SavedConsole {
|
||||
error: (...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
export interface KeybindDef {
|
||||
key: string;
|
||||
ctrl: boolean;
|
||||
shift: boolean;
|
||||
alt: boolean;
|
||||
}
|
||||
|
||||
// ── HTML escaping ──
|
||||
|
||||
const HTML_ESCAPE_MAP: Record<string, string> = {
|
||||
|
||||
Reference in New Issue
Block a user