Compare commits

..

3 Commits

Author SHA1 Message Date
bigjakk 1eabea195a v0.5.2 — Add ESLint + typescript-eslint, fix lint errors
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>
2026-03-01 09:24:34 -08:00
bigjakk b8bfa2941c v0.5.1 — Escape userscript metadata in settings UI
Fixes XSS via malicious userscript @name, @author, @version, @description
metadata and script setting titles/descriptions. Also escapes checkbox
grid labels. All use existing escapeHtml() helper.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 09:04:50 -08:00
bigjakk 819caea65a 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>
2026-03-01 08:54:52 -08:00
14 changed files with 1179 additions and 160 deletions
+19
View File
@@ -0,0 +1,19 @@
import eslint from "@eslint/js";
import tseslint from "typescript-eslint";
export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.recommended,
{
ignores: ["dist/", "out/", "scripts/"],
},
{
rules: {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
},
}
);
+911 -26
View File
File diff suppressed because it is too large Load Diff
+6 -2
View File
@@ -1,6 +1,6 @@
{
"name": "krunker-civilian-client",
"version": "0.5.0",
"version": "0.5.2",
"description": "Cross-platform Krunker game client",
"main": "dist/main/index.js",
"homepage": "https://gitea.crjlab.net/bigjakk/krunker-civilian-client",
@@ -17,18 +17,22 @@
"dist:win": "npm run build && electron-builder --win",
"dist:linux": "npm run build && electron-builder --linux",
"dist:all": "npm run build && electron-builder --win --linux",
"clean": "rimraf dist out"
"clean": "rimraf dist out",
"lint": "eslint src/"
},
"dependencies": {
"electron-store": "^8.2.0",
"uuid": "^9.0.1"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^22.0.0",
"electron": "npm:electron-nightly@42.0.0-nightly.20260227",
"electron-builder": "^26.0.0",
"eslint": "^10.0.2",
"rimraf": "^6.0.1",
"typescript": "^5.7.0",
"typescript-eslint": "^8.56.1",
"vite": "^6.0.0"
}
}
+1 -1
View File
@@ -518,7 +518,7 @@ export const MATCHMAKER_SETTINGS_CSS = `
`;
export const TRANSLATOR_CSS = `
.kpc-translation {
.kcc-translation {
color: #88ff88;
font-style: italic;
margin-left: 8px;
+86 -22
View File
@@ -1,4 +1,4 @@
import { app, BrowserWindow, Menu, clipboard, dialog, ipcMain, protocol, session, shell } from 'electron';
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';
@@ -14,6 +14,7 @@ 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 ──
@@ -107,6 +108,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 +232,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 { /* ignore invalid URLs */ }
// 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)`);
});
}
@@ -301,8 +318,6 @@ async function launchApp(): Promise<void> {
}
// ── Common output directory (used by folder actions) ──
const outputDir = join(app.getPath('documents'), 'Krunker Civilian Client');
// ── Configurable keybinds via before-input-event ──
win.webContents.on('before-input-event', (event, input) => {
if (input.type !== 'keyDown') return;
@@ -317,7 +332,7 @@ async function launchApp(): Promise<void> {
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 {};
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());
@@ -390,7 +405,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 +452,7 @@ async function launchApp(): Promise<void> {
}
}
} else {
setImmediate(() => shell.openExternal(url));
setImmediate(() => safeOpenExternal(url));
}
return { action: 'deny' };
});
@@ -457,32 +472,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 +539,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 +647,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 };
+1 -1
View File
@@ -67,7 +67,7 @@ function makeLogger(getStream: () => WriteStream) {
export const electronLog = makeLogger(() => electronStream);
export function getLogPath(type: 'electron'): string {
export function getLogPath(_type: 'electron'): string {
init();
return electronPath;
}
+1 -2
View File
@@ -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.
@@ -88,7 +87,7 @@ export class ResourceSwapper {
this.swapFiles.set(name, join(this.swapDir, name));
}
}
} catch (err) {
} catch {
console.error(`Failed to scan swap directory prefix: ${prefix}`);
}
}
+45 -12
View File
@@ -1,4 +1,4 @@
import { get as httpsGet, request as httpsRequest } from 'https';
import { get as httpsGet } from 'https';
import { createWriteStream, renameSync, unlinkSync, existsSync } from 'fs';
import { spawn } from 'child_process';
import { app } from 'electron';
@@ -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;
}
+1 -1
View File
@@ -49,7 +49,7 @@ export class UserscriptManager {
/** Load tracker.json, add new scripts as disabled, prune deleted scripts */
async loadTracker(scripts: ScriptFile[]): Promise<ScriptTracker> {
let tracker: ScriptTracker = {};
let tracker: ScriptTracker;
try {
tracker = JSON.parse(await fsp.readFile(this.trackerPath, 'utf-8'));
} catch { tracker = {}; }
+42 -48
View File
@@ -2,14 +2,15 @@ import { ipcRenderer } from 'electron';
import { fetchGame, MATCHMAKER_GAMEMODES, MATCHMAKER_REGIONS, MATCHMAKER_REGION_NAMES } from './matchmaker';
import type { MatchmakerConfig } from './matchmaker';
import { initUserscripts, getInstances, setScriptEnabled } from './userscripts';
import type { UserscriptInstance, UserscriptSetting } from './userscripts';
import type { UserscriptInstance } 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 ──
// Wrapped to forward errors/warnings always, and logs when verbose is enabled
let _verboseLogging = false;
const _verboseLogging = false;
const _console = {
log: (...args: unknown[]) => {
@@ -74,7 +75,7 @@ function updateRefreshNotification(): void {
if (refreshPopupEl) { refreshPopupEl.remove(); refreshPopupEl = null; }
return;
}
if (refreshPopupEl) { try { refreshPopupEl.remove(); } catch (_e) { /* noop */ } }
if (refreshPopupEl) { try { refreshPopupEl.remove(); } catch { /* noop */ } }
refreshPopupEl = document.createElement('div');
refreshPopupEl.className = 'kpc-holder-update refresh-popup';
if (refreshLevel === RefreshLevel.restart) {
@@ -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();
}
@@ -377,7 +372,7 @@ function createCheckboxGrid(opts: {
const label = document.createElement('label');
label.className = 'hostOpt';
label.innerHTML =
'<span class="optName">' + item.label + '</span>' +
'<span class="optName">' + escapeHtml(item.label) + '</span>' +
'<input type="checkbox"' + (opts.selected.includes(item.value) ? ' checked' : '') + '>' +
'<div class="optCheck"></div>';
const cb = label.querySelector('input') as HTMLInputElement;
@@ -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 = '';
@@ -1127,12 +1114,12 @@ function renderUserscriptsSection(body: HTMLElement): void {
const scriptRow = document.createElement('div');
scriptRow.className = 'setting settName safety-0 bool';
const displayName = inst.meta.name || inst.filename;
let metaParts: string[] = [];
if (inst.meta.author) metaParts.push('by ' + inst.meta.author);
if (inst.meta.version) metaParts.push('v' + inst.meta.version);
const displayName = escapeHtml(inst.meta.name || inst.filename);
const metaParts: string[] = [];
if (inst.meta.author) metaParts.push('by ' + escapeHtml(inst.meta.author));
if (inst.meta.version) metaParts.push('v' + escapeHtml(inst.meta.version));
const metaLine = metaParts.length > 0 ? '<span class="kpc-us-meta">' + metaParts.join(' &middot; ') + '</span>' : '';
const descText = inst.meta.desc || '';
const descText = escapeHtml(inst.meta.desc || '');
scriptRow.innerHTML =
'<span class="setting-title">' + displayName + '</span>' +
@@ -1174,8 +1161,8 @@ function renderScriptSettings(inst: UserscriptInstance, container: HTMLElement):
const row = document.createElement('div');
row.className = 'setting settName safety-0' + (typeClass ? ' ' + typeClass : '');
row.innerHTML =
'<span class="setting-title">' + setting.title + '</span>' +
(setting.desc ? '<div class="setting-desc-new">' + setting.desc + '</div>' : '');
'<span class="setting-title">' + escapeHtml(setting.title) + '</span>' +
(setting.desc ? '<div class="setting-desc-new">' + escapeHtml(setting.desc) + '</div>' : '');
switch (setting.type) {
case 'bool': {
@@ -1365,7 +1352,7 @@ ipcRenderer.on('main_did-finish-load', () => {
Promise.all([
ipcRenderer.invoke('get-all-config', ['ui', 'userscripts', 'game', 'translator', 'keybinds', 'discord']),
ipcRenderer.invoke('get-platform'),
]).then(([allConf, platformInfo]: [any, any]) => {
]).then(([allConf, _platformInfo]: [any, any]) => {
const uiConf = allConf.ui;
const usConf = allConf.userscripts;
const gameConf = allConf.game;
@@ -1430,7 +1417,7 @@ ipcRenderer.on('main_did-finish-load', () => {
let gameStartTimestamp = Math.floor(Date.now() / 1000);
function pollDiscordState(): void {
let details = '';
let details: string;
let state = '';
let startTimestamp: number | undefined = undefined;
@@ -1439,7 +1426,7 @@ ipcRenderer.on('main_did-finish-load', () => {
let gameActivity: any = null;
if (typeof w.getGameActivity === 'function') {
try { gameActivity = w.getGameActivity(); } catch {}
try { gameActivity = w.getGameActivity(); } catch { /* game API unavailable */ }
}
if (spectating) {
@@ -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
View File
@@ -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> {
+1
View File
@@ -138,6 +138,7 @@ const SYSTEM_PATTERNS = [
// ── Pre-translation filtering ──
function isLatinOnly(text: string): boolean {
// eslint-disable-next-line no-control-regex
return /^[\x00-\x7F\u00C0-\u024F\u1E00-\u1EFF\s\d.,!?;:'"()\-/@#$%^&*+=~`[\]{}|\\<>]+$/u.test(text);
}
+3 -3
View File
@@ -108,7 +108,7 @@ function toggleCSS(css: string, identifier: string, value: boolean): void {
function executeScript(
instance: UserscriptInstance,
_console: { log: Function; warn: Function; error: Function },
_console: { log: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void },
): void {
if (instance.executed) return;
@@ -164,7 +164,7 @@ export function getInstances(): UserscriptInstance[] {
}
export async function initUserscripts(
_console: { log: Function; warn: Function; error: Function },
_console: { log: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void },
): Promise<void> {
const { scripts, tracker } = await ipcRenderer.invoke('userscripts-scan');
if (!scripts || scripts.length === 0) {
@@ -219,7 +219,7 @@ export async function initUserscripts(
export function setScriptEnabled(
filename: string,
enabled: boolean,
_console: { log: Function; warn: Function; error: Function },
_console: { log: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void },
): { needsReload: boolean } {
const inst = instances.find(i => i.filename === filename);
if (!inst) return { needsReload: false };
-7
View File
@@ -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> = {