Compare commits

...

4 Commits

Author SHA1 Message Date
bigjakk 467ac95b4e v0.5.3 — Fix resource swapper, add Husky, remove uuid
Fix three Electron 12→42 protocol migration bugs in the resource swapper:
register protocol on the app session instead of default, generate valid
URLs from Windows paths, and prevent non-swapped krunker.io requests from
being cancelled. Swapper now rescans on page refresh to pick up file changes.

Add Husky pre-commit hook to run ESLint. Remove unused uuid dependency.
Update README with lint script, husky, and swapper improvements.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:19:10 -08:00
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
17 changed files with 1286 additions and 222 deletions
+1
View File
@@ -0,0 +1 @@
npm run lint
+7 -4
View File
@@ -28,6 +28,7 @@ npm run dev # Builds in dev mode + launches Electron
| `npm run dist:linux` | Build + package for Linux (AppImage + deb) | | `npm run dist:linux` | Build + package for Linux (AppImage + deb) |
| `npm run dist:all` | Build + package for all platforms | | `npm run dist:all` | Build + package for all platforms |
| `npm run clean` | Remove `dist/` and `out/` directories | | `npm run clean` | Remove `dist/` and `out/` directories |
| `npm run lint` | Run ESLint (typescript-eslint) on `src/` |
## Architecture ## Architecture
@@ -138,7 +139,7 @@ src/
platform.ts OS detection, Chromium GPU/performance flags (per-platform) platform.ts OS detection, Chromium GPU/performance flags (per-platform)
config.ts electron-store schema, defaults, DEFAULT_KEYBINDS config.ts electron-store schema, defaults, DEFAULT_KEYBINDS
client-ui.ts Injected CSS for settings panel, keybind dialog, matchmaker popup client-ui.ts Injected CSS for settings panel, keybind dialog, matchmaker popup
swapper.ts Resource swapper — local asset overrides via custom protocol swapper.ts Resource swapper — local asset overrides via session-aware custom protocol
userscripts.ts Userscript manager — filesystem scanning, tracker.json, preferences userscripts.ts Userscript manager — filesystem scanning, tracker.json, preferences
discord-rpc.ts Discord Rich Presence via raw IPC socket discord-rpc.ts Discord Rich Presence via raw IPC socket
logger.ts File logging with daily rotation and 7-day retention logger.ts File logging with daily rotation and 7-day retention
@@ -159,7 +160,7 @@ Two Vite configs build independent targets:
| Config | Target | Output | Notes | | Config | Target | Output | Notes |
|--------|--------|--------|-------| |--------|--------|--------|-------|
| `vite.main.config.ts` | Main process | `dist/main/index.js` (CJS) | Externalizes `electron`, `electron-store`, and Node builtins. Targets Node 20. | | `vite.main.config.ts` | Main process | `dist/main/index.js` (CJS) | Externalizes `electron`, `electron-store`, and Node builtins. Targets Node 20. |
| `vite.preload.config.ts` | Preload script | `dist/preload/index.js` (CJS) | Externalizes `electron` and `uuid`. Targets Node 20. | | `vite.preload.config.ts` | Preload script | `dist/preload/index.js` (CJS) | Externalizes `electron`. Targets Node 20. |
### Custom Electron Binary ### Custom Electron Binary
@@ -214,7 +215,7 @@ Common flags always applied: `disable-backgrounding-occluded-windows`, `ignore-g
### Core Client ### Core Client
- Unlimited FPS via custom-patched Electron 42 build - Unlimited FPS via custom-patched Electron 42 build
- Ad blocking (network-level URL filter + CSS hiding) - Ad blocking (network-level URL filter + CSS hiding)
- Resource swapper (replace game textures, sounds, models with local files) - Resource swapper (replace game textures, sounds, models with local files — rescans on page refresh)
- Custom matchmaker (filter lobbies by region, gamemode, player count, remaining time) - Custom matchmaker (filter lobbies by region, gamemode, player count, remaining time)
- Userscript system (Tampermonkey-style metadata, custom per-script settings, instant toggle via unload) - Userscript system (Tampermonkey-style metadata, custom per-script settings, instant toggle via unload)
- Chat translator (real-time translation via Google Translate API with language tags) - Chat translator (real-time translation via Google Translate API with language tags)
@@ -267,8 +268,9 @@ At uncapped frame rates (600+ FPS), Krunker's CSS animations (e.g. death screen
- **TypeScript** 5.7 — Type-safe source code - **TypeScript** 5.7 — Type-safe source code
- **Vite** 6 — Fast bundler (2 build targets: main + preload) - **Vite** 6 — Fast bundler (2 build targets: main + preload)
- **electron-store** 8 — JSON config persistence (CJS) - **electron-store** 8 — JSON config persistence (CJS)
- **uuid** 9 — Unique ID generation
- **electron-builder** 26 — Cross-platform packaging (NSIS, portable, AppImage, deb) - **electron-builder** 26 — Cross-platform packaging (NSIS, portable, AppImage, deb)
- **ESLint** 10 + **typescript-eslint** — Linting with recommended rules
- **Husky** 9 — Git hooks (pre-commit lint)
## Project Structure ## Project Structure
@@ -284,6 +286,7 @@ Krunker-Civilian-Client/
build/ Build resources (icons, .desktop file) build/ Build resources (icons, .desktop file)
scripts/ Build scripts (Electron patched binary download) scripts/ Build scripts (Electron patched binary download)
electron-build/ Custom Electron build instructions and patches electron-build/ Custom Electron build instructions and patches
eslint.config.mjs
vite.main.config.ts vite.main.config.ts
vite.preload.config.ts vite.preload.config.ts
electron-builder.yml electron-builder.yml
+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: "^_" },
],
},
}
);
+929 -40
View File
File diff suppressed because it is too large Load Diff
+39 -34
View File
@@ -1,34 +1,39 @@
{ {
"name": "krunker-civilian-client", "name": "krunker-civilian-client",
"version": "0.5.0", "version": "0.5.3",
"description": "Cross-platform Krunker game client", "description": "Cross-platform Krunker game client",
"main": "dist/main/index.js", "main": "dist/main/index.js",
"homepage": "https://gitea.crjlab.net/bigjakk/krunker-civilian-client", "homepage": "https://gitea.crjlab.net/bigjakk/krunker-civilian-client",
"author": "Krunker Civilian Client <krunker@crjlab.net>", "author": "Krunker Civilian Client <krunker@crjlab.net>",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"postinstall": "node scripts/download-electron.js", "postinstall": "node scripts/download-electron.js",
"dev": "vite build --mode development --config vite.main.config.ts && vite build --mode development --config vite.preload.config.ts && electron .", "dev": "vite build --mode development --config vite.main.config.ts && vite build --mode development --config vite.preload.config.ts && electron .",
"build:main": "vite build --config vite.main.config.ts", "build:main": "vite build --config vite.main.config.ts",
"build:preload": "vite build --config vite.preload.config.ts", "build:preload": "vite build --config vite.preload.config.ts",
"build": "npm run build:main && npm run build:preload", "build": "npm run build:main && npm run build:preload",
"start": "npm run build && electron .", "start": "npm run build && electron .",
"download-electron": "node scripts/download-electron.js", "download-electron": "node scripts/download-electron.js",
"dist:win": "npm run build && electron-builder --win", "dist:win": "npm run build && electron-builder --win",
"dist:linux": "npm run build && electron-builder --linux", "dist:linux": "npm run build && electron-builder --linux",
"dist:all": "npm run build && electron-builder --win --linux", "dist:all": "npm run build && electron-builder --win --linux",
"clean": "rimraf dist out" "clean": "rimraf dist out",
}, "lint": "eslint src/",
"dependencies": { "prepare": "husky"
"electron-store": "^8.2.0", },
"uuid": "^9.0.1" "dependencies": {
}, "electron-store": "^8.2.0"
"devDependencies": { },
"@types/node": "^22.0.0", "devDependencies": {
"electron": "npm:electron-nightly@42.0.0-nightly.20260227", "@eslint/js": "^10.0.1",
"electron-builder": "^26.0.0", "@types/node": "^22.0.0",
"rimraf": "^6.0.1", "electron": "npm:electron-nightly@42.0.0-nightly.20260227",
"typescript": "^5.7.0", "electron-builder": "^26.0.0",
"vite": "^6.0.0" "eslint": "^10.0.2",
} "husky": "^9.1.7",
} "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 = ` export const TRANSLATOR_CSS = `
.kpc-translation { .kcc-translation {
color: #88ff88; color: #88ff88;
font-style: italic; font-style: italic;
margin-left: 8px; margin-left: 8px;
+92 -25
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 { join } from 'path';
import { existsSync, mkdirSync, promises as fsp } from 'fs'; import { existsSync, mkdirSync, promises as fsp } from 'fs';
import { get as httpsGet } from 'https'; import { get as httpsGet } from 'https';
@@ -14,6 +14,7 @@ import { showUpdateWindow } from './update-window';
import { DiscordRPC } from './discord-rpc'; import { DiscordRPC } from './discord-rpc';
// ── App version for API calls ── // ── App version for API calls ──
// eslint-disable-next-line @typescript-eslint/no-require-imports
const appVersion: string = require('../../package.json').version; const appVersion: string = require('../../package.json').version;
// ── Region ping cache ── // ── Region ping cache ──
@@ -107,6 +108,16 @@ document.addEventListener('keydown', function(e) {
} }
}, true);`; }, 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 ── // ── Keybind matching ──
function matchesKeybind(input: { key: string; control: boolean; shift: boolean; alt: boolean }, bind: Keybind | undefined): boolean { function matchesKeybind(input: { key: string; control: boolean; shift: boolean; alt: boolean }, bind: Keybind | undefined): boolean {
if (!bind) return false; if (!bind) return false;
@@ -200,14 +211,14 @@ app.whenReady().then(async () => {
async function launchApp(): Promise<void> { async function launchApp(): Promise<void> {
electronLog.log('[KCC] Starting initialization'); electronLog.log('[KCC] Starting initialization');
// ── Register swapper file protocol ──
registerSwapperFileProtocol();
// ── Session: persistent partition + clean user-agent ── // ── Session: persistent partition + clean user-agent ──
const ses = session.fromPartition('persist:krunker'); const ses = session.fromPartition('persist:krunker');
const rawUA = ses.getUserAgent(); const rawUA = ses.getUserAgent();
ses.setUserAgent(rawUA.replace(/\s*krunker-civilian-client\/\S+/i, '')); ses.setUserAgent(rawUA.replace(/\s*krunker-civilian-client\/\S+/i, ''));
// ── Register swapper file protocol on this session ──
registerSwapperFileProtocol(ses);
// ── Resource swapper ── // ── Resource swapper ──
const swapperConfig = config.get('swapper'); const swapperConfig = config.get('swapper');
const swapDir = swapperConfig.path || join(app.getPath('userData'), 'Krunker Civilian Client', 'swapper'); const swapDir = swapperConfig.path || join(app.getPath('userData'), 'Krunker Civilian Client', 'swapper');
@@ -221,23 +232,30 @@ async function launchApp(): Promise<void> {
electronLog.log(`[KCC] Userscripts: ${userscriptManager ? 'enabled' : 'disabled'} (${usDir})`); electronLog.log(`[KCC] Userscripts: ${userscriptManager ? 'enabled' : 'disabled'} (${usDir})`);
// ── Ad blocking + resource swapper (single onBeforeRequest — Electron only allows one) ── // ── 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) => {
// Check swapper first — redirect matching assets to local files
if (swapper) { if (swapper) {
const redirect = swapper.getRedirect(details.url); const redirect = swapper.getRedirect(details.url);
if (redirect) return callback({ redirectURL: redirect }); 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 }); callback({ cancel: true });
}); });
// Once swapper scan finishes, re-register with swapper patterns included
if (swapper) { if (swapper) {
swapper.waitForReady().then(() => { 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)`); electronLog.log(`[KCC] Swapper ready: ${swapper.patterns.length} pattern(s)`);
}); });
} }
@@ -301,8 +319,6 @@ async function launchApp(): Promise<void> {
} }
// ── Common output directory (used by folder actions) ── // ── Common output directory (used by folder actions) ──
const outputDir = join(app.getPath('documents'), 'Krunker Civilian Client');
// ── Configurable keybinds via before-input-event ── // ── Configurable keybinds via before-input-event ──
win.webContents.on('before-input-event', (event, input) => { win.webContents.on('before-input-event', (event, input) => {
if (input.type !== 'keyDown') return; if (input.type !== 'keyDown') return;
@@ -317,7 +333,7 @@ async function launchApp(): Promise<void> {
event.preventDefault(); event.preventDefault();
} else if (matchesKeybind(input, binds.joinFromClipboard)) { } else if (matchesKeybind(input, binds.joinFromClipboard)) {
const text = clipboard.readText(); 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(); event.preventDefault();
} else if (matchesKeybind(input, binds.copyGameLink)) { } else if (matchesKeybind(input, binds.copyGameLink)) {
clipboard.writeText(win.webContents.getURL()); clipboard.writeText(win.webContents.getURL());
@@ -390,7 +406,7 @@ async function launchApp(): Promise<void> {
if (subUrl.includes('krunker.io')) { if (subUrl.includes('krunker.io')) {
sub.loadURL(subUrl); sub.loadURL(subUrl);
} else { } else {
setImmediate(() => shell.openExternal(subUrl)); setImmediate(() => safeOpenExternal(subUrl));
} }
return { action: 'deny' }; return { action: 'deny' };
}); });
@@ -437,7 +453,7 @@ async function launchApp(): Promise<void> {
} }
} }
} else { } else {
setImmediate(() => shell.openExternal(url)); setImmediate(() => safeOpenExternal(url));
} }
return { action: 'deny' }; return { action: 'deny' };
}); });
@@ -445,6 +461,8 @@ async function launchApp(): Promise<void> {
// ── Inject scripts after page loads ── // ── Inject scripts after page loads ──
win.webContents.on('did-finish-load', () => { win.webContents.on('did-finish-load', () => {
electronLog.log(`[KCC] Page loaded: ${win.webContents.getURL()}`); 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(() => {});
Promise.all([ Promise.all([
win.webContents.insertCSS(HIDE_ADS_CSS), win.webContents.insertCSS(HIDE_ADS_CSS),
win.webContents.insertCSS(ALL_CLIENT_CSS), win.webContents.insertCSS(ALL_CLIENT_CSS),
@@ -457,32 +475,44 @@ async function launchApp(): Promise<void> {
}); });
// ── IPC handlers ── // ── 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-version', () => appVersion);
ipcMain.handle('get-platform', () => platformInfo); 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[]) => { ipcMain.handle('get-all-config', (_e, keys: string[]) => {
const result: Record<string, unknown> = {}; 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; return result;
}); });
let configWriteTimer: ReturnType<typeof setTimeout> | null = null; let configWriteTimer: ReturnType<typeof setTimeout> | null = null;
const pendingConfigWrites = new Map<string, unknown>(); const pendingConfigWrites = new Map<string, unknown>();
ipcMain.handle('set-config', (_e, key: string, value: 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 // Flush immediately for keys that have side effects
if (key === 'keybinds') { if (key === 'keybinds') {
config.set(key as any, value); config.set(key as any, value);
cachedKeybinds = null; cachedKeybinds = null;
return; return;
} }
// Invalidate caches immediately (not on flush) to prevent stale reads
if (key === 'game') cachedGameConf = null;
pendingConfigWrites.set(key, value); pendingConfigWrites.set(key, value);
if (!configWriteTimer) { if (!configWriteTimer) {
configWriteTimer = setTimeout(() => { configWriteTimer = setTimeout(() => {
for (const [k, v] of pendingConfigWrites) { for (const [k, v] of pendingConfigWrites) {
config.set(k as any, v); config.set(k as any, v);
} }
// Invalidate caches for keys that affect runtime behavior
if (pendingConfigWrites.has('game')) cachedGameConf = null;
pendingConfigWrites.clear(); pendingConfigWrites.clear();
configWriteTimer = null; configWriteTimer = null;
}, 300); }, 300);
@@ -512,7 +542,7 @@ async function launchApp(): Promise<void> {
} }
try { try {
const data = await new Promise<string>((resolve, reject) => { 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 = ''; let body = '';
res.on('data', (chunk: string) => { body += chunk; }); res.on('data', (chunk: string) => { body += chunk; });
res.on('end', () => resolve(body)); res.on('end', () => resolve(body));
@@ -620,16 +650,53 @@ async function launchApp(): Promise<void> {
app.quit(); app.quit();
}); });
// ── Alt manager IPC handlers ── // ── Alt manager IPC handlers (credentials encrypted via safeStorage) ──
ipcMain.handle('alt-list', () => config.get('accounts') || []); 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') || []; 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); accounts.push(account);
config.set('accounts', accounts); config.set('accounts', accounts);
return { success: true, index: accounts.length - 1 }; 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) => { ipcMain.handle('alt-remove', (_e, index: number) => {
const accounts = config.get('accounts') || []; const accounts = config.get('accounts') || [];
if (index < 0 || index >= accounts.length) return { success: false }; 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 const electronLog = makeLogger(() => electronStream);
export function getLogPath(type: 'electron'): string { export function getLogPath(_type: 'electron'): string {
init(); init();
return electronPath; return electronPath;
} }
+42 -10
View File
@@ -1,10 +1,24 @@
import { existsSync, mkdirSync, promises as fsp } from 'fs'; import { existsSync, mkdirSync, promises as fsp } from 'fs';
import { join } from 'path'; import { join } from 'path';
import { protocol, net } from 'electron'; import { protocol, net, Session } from 'electron';
const PROTOCOL_NAME = 'kpc-swap'; const PROTOCOL_NAME = 'kpc-swap';
const TARGET_DOMAIN = 'krunker.io'; const TARGET_DOMAIN = 'krunker.io';
const STANDARD_ASSET_RE = /^\/(?:models|textures|sound|scares|videos)(?:$|\/)/u;
/**
* Convert a native file path to a proper kpc-swap:// URL.
* Windows paths like C:\foo\bar become kpc-swap://C/foo/bar
*/
function filePathToSwapURL(filePath: string): string {
const forwardSlash = filePath.replace(/\\/g, '/');
// Windows drive letter: C:/foo → kpc-swap://C/foo
const match = forwardSlash.match(/^([A-Za-z]):\/(.*)/);
if (match) {
return `${PROTOCOL_NAME}://${match[1]}/${match[2]}`;
}
// Unix absolute: /home/user/foo → kpc-swap:///home/user/foo
return `${PROTOCOL_NAME}://${forwardSlash}`;
}
/** /**
* Register the custom protocol scheme. Must be called BEFORE app.ready. * Register the custom protocol scheme. Must be called BEFORE app.ready.
@@ -12,17 +26,28 @@ const STANDARD_ASSET_RE = /^\/(?:models|textures|sound|scares|videos)(?:$|\/)/u;
export function initSwapperProtocol(): void { export function initSwapperProtocol(): void {
protocol.registerSchemesAsPrivileged([{ protocol.registerSchemesAsPrivileged([{
scheme: PROTOCOL_NAME, scheme: PROTOCOL_NAME,
privileges: { secure: true, corsEnabled: true, bypassCSP: true }, privileges: { standard: true, secure: true, corsEnabled: true, bypassCSP: true },
}]); }]);
} }
/** /**
* Register the file protocol handler. Must be called AFTER app.ready. * Register the file protocol handler on the given session.
* Must be called AFTER app.ready.
*/ */
export function registerSwapperFileProtocol(): void { export function registerSwapperFileProtocol(ses: Session): void {
protocol.handle(PROTOCOL_NAME, (request) => { ses.protocol.handle(PROTOCOL_NAME, (request) => {
const filePath = decodeURI(request.url.replace(`${PROTOCOL_NAME}:`, '')); const url = new URL(request.url);
return net.fetch('file://' + filePath); // Reconstruct the file path from the URL
// Windows: kpc-swap://C/foo/bar → C:/foo/bar
// Unix: kpc-swap:///home/foo → /home/foo
let filePath: string;
if (url.hostname) {
// Windows drive letter is the hostname
filePath = `${url.hostname}:${url.pathname}`;
} else {
filePath = url.pathname;
}
return net.fetch(`file://${filePath}`);
}); });
} }
@@ -48,6 +73,13 @@ export class ResourceSwapper {
this.ready = true; this.ready = true;
} }
/** Rescan the swap directory to pick up added/removed/changed files */
async rescan(): Promise<void> {
this.swapFiles.clear();
await this.scanAsync('');
this.ready = true;
}
/** URL filter patterns for webRequest.onBeforeRequest — single broad pattern */ /** URL filter patterns for webRequest.onBeforeRequest — single broad pattern */
get patterns(): string[] { get patterns(): string[] {
return this.swapFiles.size > 0 ? [`*://*.${TARGET_DOMAIN}/*`] : []; return this.swapFiles.size > 0 ? [`*://*.${TARGET_DOMAIN}/*`] : [];
@@ -71,7 +103,7 @@ export class ResourceSwapper {
let pathname = queryStart === -1 ? url.substring(pathStart) : url.substring(pathStart, queryStart); let pathname = queryStart === -1 ? url.substring(pathStart) : url.substring(pathStart, queryStart);
if (pathname.startsWith('/assets/')) pathname = pathname.substring(7); if (pathname.startsWith('/assets/')) pathname = pathname.substring(7);
const localPath = this.swapFiles.get(pathname); const localPath = this.swapFiles.get(pathname);
if (localPath) return `${PROTOCOL_NAME}:/${localPath}`; if (localPath) return filePathToSwapURL(localPath);
} catch { /* malformed URL — ignore */ } } catch { /* malformed URL — ignore */ }
return null; return null;
} }
@@ -88,7 +120,7 @@ export class ResourceSwapper {
this.swapFiles.set(name, join(this.swapDir, name)); this.swapFiles.set(name, join(this.swapDir, name));
} }
} }
} catch (err) { } catch {
console.error(`Failed to scan swap directory prefix: ${prefix}`); 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 { createWriteStream, renameSync, unlinkSync, existsSync } from 'fs';
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import { app } from 'electron'; import { app } from 'electron';
@@ -16,12 +16,25 @@ const UPDATE_CONFIG = {
// Gitea provider (swap these for kpdclient.com migration) // Gitea provider (swap these for kpdclient.com migration)
checkUrl: 'https://gitea.crjlab.net/api/v1/repos/bigjakk/Krunker-Civilian-Client/releases/latest', checkUrl: 'https://gitea.crjlab.net/api/v1/repos/bigjakk/Krunker-Civilian-Client/releases/latest',
assetPattern: /Setup\.exe$/i, 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 CHECK_TIMEOUT_MS = 10000;
const DOWNLOAD_TIMEOUT_MS = 300000; // 5 minutes 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. * Simple semver comparison: returns true if a < b.
* Handles versions like "0.1.0", "1.2.3". * 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); electronLog.log('[KCC-Update] Current version:', currentVersion);
const req = httpsGet(UPDATE_CONFIG.checkUrl, { const req = httpsGet(UPDATE_CONFIG.checkUrl, {
rejectUnauthorized: UPDATE_CONFIG.rejectUnauthorized,
headers: { 'User-Agent': 'KrunkerCivilianClient/' + currentVersion }, headers: { 'User-Agent': 'KrunkerCivilianClient/' + currentVersion },
}, (res) => { }, (res) => {
electronLog.log('[KCC-Update] Check response status:', res.statusCode); 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) { if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
electronLog.log('[KCC-Update] Redirected to:', res.headers.location); const redirectUrl = res.headers.location;
httpsGet(res.headers.location, { electronLog.log('[KCC-Update] Redirected to:', redirectUrl);
rejectUnauthorized: UPDATE_CONFIG.rejectUnauthorized, if (!isAllowedRedirect(redirectUrl)) {
electronLog.error('[KCC-Update] Redirect to untrusted host blocked:', redirectUrl);
resolve(null);
return;
}
httpsGet(redirectUrl, {
headers: { 'User-Agent': 'KrunkerCivilianClient/' + currentVersion }, headers: { 'User-Agent': 'KrunkerCivilianClient/' + currentVersion },
}, (redirectRes) => { }, (redirectRes) => {
electronLog.log('[KCC-Update] Redirect response status:', redirectRes.statusCode); electronLog.log('[KCC-Update] Redirect response status:', redirectRes.statusCode);
@@ -97,6 +114,13 @@ export function checkForUpdate(currentVersion: string): Promise<UpdateInfo | nul
return; 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); electronLog.log('[KCC-Update] Update available:', remoteVersion, '| Download:', setupAsset.browser_download_url, '| Size:', setupAsset.size);
resolve({ resolve({
version: remoteVersion, version: remoteVersion,
@@ -131,16 +155,25 @@ export function downloadUpdate(url: string, destPath: string, onProgress: Progre
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const tmpPath = destPath + '.tmp'; 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); electronLog.log('[KCC-Update] Downloading from:', downloadUrl);
const req = httpsGet(downloadUrl, { const req = httpsGet(downloadUrl, {
rejectUnauthorized: UPDATE_CONFIG.rejectUnauthorized,
headers: { 'User-Agent': 'KrunkerCivilianClient' }, headers: { 'User-Agent': 'KrunkerCivilianClient' },
}, (res) => { }, (res) => {
// Follow redirects // Follow redirects (with domain validation)
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
electronLog.log('[KCC-Update] Download redirected to:', res.headers.location); const redirectUrl = res.headers.location;
doDownload(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; return;
} }
+1 -1
View File
@@ -49,7 +49,7 @@ export class UserscriptManager {
/** Load tracker.json, add new scripts as disabled, prune deleted scripts */ /** Load tracker.json, add new scripts as disabled, prune deleted scripts */
async loadTracker(scripts: ScriptFile[]): Promise<ScriptTracker> { async loadTracker(scripts: ScriptFile[]): Promise<ScriptTracker> {
let tracker: ScriptTracker = {}; let tracker: ScriptTracker;
try { try {
tracker = JSON.parse(await fsp.readFile(this.trackerPath, 'utf-8')); tracker = JSON.parse(await fsp.readFile(this.trackerPath, 'utf-8'));
} catch { tracker = {}; } } 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 { fetchGame, MATCHMAKER_GAMEMODES, MATCHMAKER_REGIONS, MATCHMAKER_REGION_NAMES } from './matchmaker';
import type { MatchmakerConfig } from './matchmaker'; import type { MatchmakerConfig } from './matchmaker';
import { initUserscripts, getInstances, setScriptEnabled } from './userscripts'; import { initUserscripts, getInstances, setScriptEnabled } from './userscripts';
import type { UserscriptInstance, UserscriptSetting } from './userscripts'; import type { UserscriptInstance } from './userscripts';
import { initTranslator, updateTranslatorConfig } from './translator'; import { initTranslator, updateTranslatorConfig } from './translator';
import { setDeathAnimBlock, escapeHtml } from './utils'; import { setDeathAnimBlock, escapeHtml } from './utils';
import type { Keybind } from '../main/config';
// ── Save console methods before Krunker overwrites them ── // ── Save console methods before Krunker overwrites them ──
// Wrapped to forward errors/warnings always, and logs when verbose is enabled // Wrapped to forward errors/warnings always, and logs when verbose is enabled
let _verboseLogging = false; const _verboseLogging = false;
const _console = { const _console = {
log: (...args: unknown[]) => { log: (...args: unknown[]) => {
@@ -74,7 +75,7 @@ function updateRefreshNotification(): void {
if (refreshPopupEl) { refreshPopupEl.remove(); refreshPopupEl = null; } if (refreshPopupEl) { refreshPopupEl.remove(); refreshPopupEl = null; }
return; return;
} }
if (refreshPopupEl) { try { refreshPopupEl.remove(); } catch (_e) { /* noop */ } } if (refreshPopupEl) { try { refreshPopupEl.remove(); } catch { /* noop */ } }
refreshPopupEl = document.createElement('div'); refreshPopupEl = document.createElement('div');
refreshPopupEl.className = 'kpc-holder-update refresh-popup'; refreshPopupEl.className = 'kpc-holder-update refresh-popup';
if (refreshLevel === RefreshLevel.restart) { if (refreshLevel === RefreshLevel.restart) {
@@ -119,13 +120,7 @@ function updateRefreshNotification(): void {
// ── Client settings tab in Krunker's settings ── // ── Client settings tab in Krunker's settings ──
function hasOwn(obj: any, key: string): boolean { // ── Keybind helpers ──
return Object.prototype.hasOwnProperty.call(obj, key);
}
// ── Keybind types + helpers ──
interface Keybind { key: string; ctrl: boolean; shift: boolean; alt: boolean; }
function keybindDisplayString(bind: Keybind): string { function keybindDisplayString(bind: Keybind): string {
return (bind.shift ? 'Shift+' : '') + (bind.ctrl ? 'Ctrl+' : '') + (bind.alt ? 'Alt+' : '') + bind.key.toUpperCase(); 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'); const label = document.createElement('label');
label.className = 'hostOpt'; label.className = 'hostOpt';
label.innerHTML = label.innerHTML =
'<span class="optName">' + item.label + '</span>' + '<span class="optName">' + escapeHtml(item.label) + '</span>' +
'<input type="checkbox"' + (opts.selected.includes(item.value) ? ' checked' : '') + '>' + '<input type="checkbox"' + (opts.selected.includes(item.value) ? ' checked' : '') + '>' +
'<div class="optCheck"></div>'; '<div class="optCheck"></div>';
const cb = label.querySelector('input') as HTMLInputElement; const cb = label.querySelector('input') as HTMLInputElement;
@@ -677,19 +672,6 @@ function buildDiscordSection(body: HTMLElement, discordConf: any): void {
} }
// ── Alt Manager helpers ── // ── 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 { function switchToAccount(account: { username: string; password: string }): void {
const w = window as any; const w = window as any;
if (typeof w.loginOrRegister !== 'function') return; 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 nameInput = document.querySelector('#accName') as HTMLInputElement;
const passInput = document.querySelector('#accPass') as HTMLInputElement; const passInput = document.querySelector('#accPass') as HTMLInputElement;
if (!nameInput || !passInput) return; if (!nameInput || !passInput) return;
nameInput.value = decodeCredential(account.username); nameInput.value = account.username;
passInput.value = decodeCredential(account.password); passInput.value = account.password;
nameInput.dispatchEvent(new Event('input', { bubbles: true })); nameInput.dispatchEvent(new Event('input', { bubbles: true }));
passInput.dispatchEvent(new Event('input', { bubbles: true })); passInput.dispatchEvent(new Event('input', { bubbles: true }));
const submitBtn = document.querySelector('.io-button') as HTMLElement; 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 userIn = form.querySelector('.kpc-acc-user') as HTMLInputElement;
const passIn = form.querySelector('.kpc-acc-pass') 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', () => { addBtn.querySelector('button')!.addEventListener('click', () => {
form.style.display = form.style.display === 'none' ? '' : 'none'; 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-switch">Switch</button>' +
'<button class="kpc-acc-delete">Delete</button>' + '<button class="kpc-acc-delete">Delete</button>' +
'</div>'; '</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', () => { row.querySelector('.kpc-acc-delete')!.addEventListener('click', () => {
ipcRenderer.invoke('alt-remove', i).then(() => { ipcRenderer.invoke('alt-remove', i).then(() => {
accounts.splice(i, 1); accounts.splice(i, 1);
@@ -794,13 +785,9 @@ function buildAccountsSection(body: HTMLElement, accountsArr: any[]): void {
const user = userIn.value.trim(); const user = userIn.value.trim();
const pass = passIn.value; const pass = passIn.value;
if (!label || !user || !pass) return; if (!label || !user || !pass) return;
const newAcc = { const newAcc = { label, username: user, password: pass };
label,
username: encodeCredential(user),
password: encodeCredential(pass),
};
ipcRenderer.invoke('alt-save', newAcc).then(() => { ipcRenderer.invoke('alt-save', newAcc).then(() => {
accounts.push(newAcc); accounts.push({ label });
labelIn.value = ''; labelIn.value = '';
userIn.value = ''; userIn.value = '';
passIn.value = ''; passIn.value = '';
@@ -1127,12 +1114,12 @@ function renderUserscriptsSection(body: HTMLElement): void {
const scriptRow = document.createElement('div'); const scriptRow = document.createElement('div');
scriptRow.className = 'setting settName safety-0 bool'; scriptRow.className = 'setting settName safety-0 bool';
const displayName = inst.meta.name || inst.filename; const displayName = escapeHtml(inst.meta.name || inst.filename);
let metaParts: string[] = []; const metaParts: string[] = [];
if (inst.meta.author) metaParts.push('by ' + inst.meta.author); if (inst.meta.author) metaParts.push('by ' + escapeHtml(inst.meta.author));
if (inst.meta.version) metaParts.push('v' + inst.meta.version); 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 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 = scriptRow.innerHTML =
'<span class="setting-title">' + displayName + '</span>' + '<span class="setting-title">' + displayName + '</span>' +
@@ -1174,8 +1161,8 @@ function renderScriptSettings(inst: UserscriptInstance, container: HTMLElement):
const row = document.createElement('div'); const row = document.createElement('div');
row.className = 'setting settName safety-0' + (typeClass ? ' ' + typeClass : ''); row.className = 'setting settName safety-0' + (typeClass ? ' ' + typeClass : '');
row.innerHTML = row.innerHTML =
'<span class="setting-title">' + setting.title + '</span>' + '<span class="setting-title">' + escapeHtml(setting.title) + '</span>' +
(setting.desc ? '<div class="setting-desc-new">' + setting.desc + '</div>' : ''); (setting.desc ? '<div class="setting-desc-new">' + escapeHtml(setting.desc) + '</div>' : '');
switch (setting.type) { switch (setting.type) {
case 'bool': { case 'bool': {
@@ -1365,7 +1352,7 @@ ipcRenderer.on('main_did-finish-load', () => {
Promise.all([ Promise.all([
ipcRenderer.invoke('get-all-config', ['ui', 'userscripts', 'game', 'translator', 'keybinds', 'discord']), ipcRenderer.invoke('get-all-config', ['ui', 'userscripts', 'game', 'translator', 'keybinds', 'discord']),
ipcRenderer.invoke('get-platform'), ipcRenderer.invoke('get-platform'),
]).then(([allConf, platformInfo]: [any, any]) => { ]).then(([allConf, _platformInfo]: [any, any]) => {
const uiConf = allConf.ui; const uiConf = allConf.ui;
const usConf = allConf.userscripts; const usConf = allConf.userscripts;
const gameConf = allConf.game; const gameConf = allConf.game;
@@ -1430,7 +1417,7 @@ ipcRenderer.on('main_did-finish-load', () => {
let gameStartTimestamp = Math.floor(Date.now() / 1000); let gameStartTimestamp = Math.floor(Date.now() / 1000);
function pollDiscordState(): void { function pollDiscordState(): void {
let details = ''; let details: string;
let state = ''; let state = '';
let startTimestamp: number | undefined = undefined; let startTimestamp: number | undefined = undefined;
@@ -1439,7 +1426,7 @@ ipcRenderer.on('main_did-finish-load', () => {
let gameActivity: any = null; let gameActivity: any = null;
if (typeof w.getGameActivity === 'function') { if (typeof w.getGameActivity === 'function') {
try { gameActivity = w.getGameActivity(); } catch {} try { gameActivity = w.getGameActivity(); } catch { /* game API unavailable */ }
} }
if (spectating) { if (spectating) {
@@ -1555,7 +1542,9 @@ ipcRenderer.on('main_did-finish-load', () => {
const idx = parseInt((el as HTMLElement).dataset.idx || '0', 10); const idx = parseInt((el as HTMLElement).dataset.idx || '0', 10);
if (accs[idx]) { if (accs[idx]) {
windowHolder.style.display = 'none'; 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>' +
'</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('kpcAltBackBtn')!.addEventListener('click', renderAccountList);
document.getElementById('kpcAltSaveBtn')!.addEventListener('click', () => { document.getElementById('kpcAltSaveBtn')!.addEventListener('click', () => {
const label = (document.getElementById('kpcAltLabel') as HTMLInputElement).value.trim(); const label = (document.getElementById('kpcAltLabel') as HTMLInputElement).value.trim();
@@ -1592,8 +1586,8 @@ ipcRenderer.on('main_did-finish-load', () => {
if (!label || !user || !pass) return; if (!label || !user || !pass) return;
ipcRenderer.invoke('alt-save', { ipcRenderer.invoke('alt-save', {
label, label,
username: encodeCredential(user), username: user,
password: encodeCredential(pass), password: pass,
}).then(() => renderAccountList()); }).then(() => renderAccountList());
}); });
} }
@@ -1634,9 +1628,9 @@ ipcRenderer.on('main_did-finish-load', () => {
const pollInterval = setInterval(() => { const pollInterval = setInterval(() => {
const w = window as any; const w = window as any;
if ( if (
hasOwn(w, 'showWindow') Object.hasOwn(w, 'showWindow')
&& typeof w.showWindow === 'function' && typeof w.showWindow === 'function'
&& hasOwn(w, 'windows') && Object.hasOwn(w, 'windows')
&& Array.isArray(w.windows) && Array.isArray(w.windows)
&& w.windows.length >= 0 && w.windows.length >= 0
&& typeof w.windows[0] !== 'undefined' && typeof w.windows[0] !== 'undefined'
+62 -35
View File
@@ -39,39 +39,57 @@ function secondsToTimestring(num: number): string {
return `${minutes}m ${seconds}s`; return `${minutes}m ${seconds}s`;
} }
// ── Popup DOM (created once, reused) ── // ── Popup DOM (lazy-initialized on first use) ──
const POPUP_ID = 'matchmakerPopupContainer'; const POPUP_ID = 'matchmakerPopupContainer';
const popupElement = document.createElement('div');
popupElement.id = POPUP_ID;
const popupTitle = document.createElement('div'); interface PopupDOM {
popupTitle.id = 'matchmakerPopupTitle'; element: HTMLDivElement;
popupElement.appendChild(popupTitle); title: HTMLDivElement;
description: HTMLDivElement;
confirmBtn: HTMLDivElement;
cancelBtn: HTMLDivElement;
}
const popupDescription = document.createElement('div'); let _popup: PopupDOM | null = null;
popupDescription.id = 'matchmakerPopupDescription';
popupElement.appendChild(popupDescription);
const popupOptions = document.createElement('div'); function getPopup(): PopupDOM {
popupOptions.id = 'matchmakerPopupOptions'; if (_popup) return _popup;
const popupConfirmBtn = document.createElement('div'); const element = document.createElement('div');
popupConfirmBtn.id = 'matchmakerConfirmButton'; element.id = POPUP_ID;
popupConfirmBtn.className = 'matchmakerPopupButton bigShadowT';
popupConfirmBtn.textContent = 'Join';
popupConfirmBtn.setAttribute('onmouseenter', 'playTick()');
popupConfirmBtn.addEventListener('click', () => decideMatchmakerDecision(true));
const popupCancelBtn = document.createElement('div'); const title = document.createElement('div');
popupCancelBtn.id = 'matchmakerCancelButton'; title.id = 'matchmakerPopupTitle';
popupCancelBtn.className = 'matchmakerPopupButton bigShadowT'; element.appendChild(title);
popupCancelBtn.textContent = 'Cancel';
popupCancelBtn.setAttribute('onmouseenter', 'playTick()');
popupCancelBtn.addEventListener('click', () => decideMatchmakerDecision(false));
popupOptions.appendChild(popupConfirmBtn); const description = document.createElement('div');
popupOptions.appendChild(popupCancelBtn); description.id = 'matchmakerPopupDescription';
popupElement.appendChild(popupOptions); 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 ── // ── State ──
let currentMatch = ''; let currentMatch = '';
@@ -86,7 +104,8 @@ function decideMatchmakerDecision(accept: boolean): void {
if (accept && currentMatch !== 'none') { if (accept && currentMatch !== 'none') {
window.location.href = `https://krunker.io/?game=${currentMatch}`; window.location.href = `https://krunker.io/?game=${currentMatch}`;
} else { } else {
if (popupElement.parentNode) popupElement.remove(); const popup = getPopup();
if (popup.element.parentNode) popup.element.remove();
if (currentMatch === 'none' && openServerBrowser && typeof w.openServerWindow === 'function') { if (currentMatch === 'none' && openServerBrowser && typeof w.openServerWindow === 'function') {
w.openServerWindow(0); w.openServerWindow(0);
} }
@@ -112,24 +131,32 @@ function handleMatchmakerBind(event: KeyboardEvent): void {
} }
function createFetchedGamePopup(game: MatchmakerGame): void { function createFetchedGamePopup(game: MatchmakerGame): void {
const popup = getPopup();
const mapIdx = MAP_ICON_INDICES.indexOf(game.map); 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; currentMatch = game.gameID;
if (game.gameID === 'none') { if (game.gameID === 'none') {
popupTitle.innerText = 'No Games Found...'; popup.title.textContent = 'No Games Found...';
popupDescription.innerHTML = 'Check the server browser to see other lobbies.'; popup.description.textContent = 'Check the server browser to see other lobbies.';
popupConfirmBtn.style.display = 'none'; popup.confirmBtn.style.display = 'none';
} else { } else {
popupTitle.innerText = 'Game Found!'; popup.title.textContent = 'Game Found!';
const regionName = MATCHMAKER_REGION_NAMES[game.region] ?? 'Unknown Region'; 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`; popup.description.textContent = '';
popupConfirmBtn.style.display = 'block'; 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); document.addEventListener('keydown', handleMatchmakerBind, true);
const uiBase = document.getElementById('uiBase'); 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> { export async function fetchGame(mmConfig: MatchmakerConfig, _con?: SavedConsole): Promise<void> {
+1
View File
@@ -138,6 +138,7 @@ const SYSTEM_PATTERNS = [
// ── Pre-translation filtering ── // ── Pre-translation filtering ──
function isLatinOnly(text: string): boolean { function isLatinOnly(text: string): boolean {
// eslint-disable-next-line no-control-regex
return /^[\x00-\x7F\u00C0-\u024F\u1E00-\u1EFF\s\d.,!?;:'"()\-/@#$%^&*+=~`[\]{}|\\<>]+$/u.test(text); 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( function executeScript(
instance: UserscriptInstance, instance: UserscriptInstance,
_console: { log: Function; warn: Function; error: Function }, _console: { log: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void },
): void { ): void {
if (instance.executed) return; if (instance.executed) return;
@@ -164,7 +164,7 @@ export function getInstances(): UserscriptInstance[] {
} }
export async function initUserscripts( 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> { ): Promise<void> {
const { scripts, tracker } = await ipcRenderer.invoke('userscripts-scan'); const { scripts, tracker } = await ipcRenderer.invoke('userscripts-scan');
if (!scripts || scripts.length === 0) { if (!scripts || scripts.length === 0) {
@@ -219,7 +219,7 @@ export async function initUserscripts(
export function setScriptEnabled( export function setScriptEnabled(
filename: string, filename: string,
enabled: boolean, enabled: boolean,
_console: { log: Function; warn: Function; error: Function }, _console: { log: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void },
): { needsReload: boolean } { ): { needsReload: boolean } {
const inst = instances.find(i => i.filename === filename); const inst = instances.find(i => i.filename === filename);
if (!inst) return { needsReload: false }; if (!inst) return { needsReload: false };
-7
View File
@@ -9,13 +9,6 @@ export interface SavedConsole {
error: (...args: unknown[]) => void; error: (...args: unknown[]) => void;
} }
export interface KeybindDef {
key: string;
ctrl: boolean;
shift: boolean;
alt: boolean;
}
// ── HTML escaping ── // ── HTML escaping ──
const HTML_ESCAPE_MAP: Record<string, string> = { const HTML_ESCAPE_MAP: Record<string, string> = {
+1 -1
View File
@@ -13,7 +13,7 @@ export default defineConfig({
outDir: 'dist/preload', outDir: 'dist/preload',
emptyDirBefore: true, emptyDirBefore: true,
rollupOptions: { rollupOptions: {
external: ['electron', 'uuid'], external: ['electron'],
}, },
target: 'node20', target: 'node20',
minify: isProd, minify: isProd,