Compare commits

...

2 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
15 changed files with 1067 additions and 113 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:all` | Build + package for all platforms |
| `npm run clean` | Remove `dist/` and `out/` directories |
| `npm run lint` | Run ESLint (typescript-eslint) on `src/` |
## Architecture
@@ -138,7 +139,7 @@ src/
platform.ts OS detection, Chromium GPU/performance flags (per-platform)
config.ts electron-store schema, defaults, DEFAULT_KEYBINDS
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
discord-rpc.ts Discord Rich Presence via raw IPC socket
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 |
|--------|--------|--------|-------|
| `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
@@ -214,7 +215,7 @@ Common flags always applied: `disable-backgrounding-occluded-windows`, `ignore-g
### Core Client
- Unlimited FPS via custom-patched Electron 42 build
- 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)
- 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)
@@ -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
- **Vite** 6 — Fast bundler (2 build targets: main + preload)
- **electron-store** 8 — JSON config persistence (CJS)
- **uuid** 9 — Unique ID generation
- **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
@@ -284,6 +286,7 @@ Krunker-Civilian-Client/
build/ Build resources (icons, .desktop file)
scripts/ Build scripts (Electron patched binary download)
electron-build/ Custom Electron build instructions and patches
eslint.config.mjs
vite.main.config.ts
vite.preload.config.ts
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 -39
View File
File diff suppressed because it is too large Load Diff
+39 -34
View File
@@ -1,34 +1,39 @@
{
"name": "krunker-civilian-client",
"version": "0.5.1",
"description": "Cross-platform Krunker game client",
"main": "dist/main/index.js",
"homepage": "https://gitea.crjlab.net/bigjakk/krunker-civilian-client",
"author": "Krunker Civilian Client <krunker@crjlab.net>",
"license": "MIT",
"scripts": {
"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 .",
"build:main": "vite build --config vite.main.config.ts",
"build:preload": "vite build --config vite.preload.config.ts",
"build": "npm run build:main && npm run build:preload",
"start": "npm run build && electron .",
"download-electron": "node scripts/download-electron.js",
"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"
},
"dependencies": {
"electron-store": "^8.2.0",
"uuid": "^9.0.1"
},
"devDependencies": {
"@types/node": "^22.0.0",
"electron": "npm:electron-nightly@42.0.0-nightly.20260227",
"electron-builder": "^26.0.0",
"rimraf": "^6.0.1",
"typescript": "^5.7.0",
"vite": "^6.0.0"
}
}
{
"name": "krunker-civilian-client",
"version": "0.5.3",
"description": "Cross-platform Krunker game client",
"main": "dist/main/index.js",
"homepage": "https://gitea.crjlab.net/bigjakk/krunker-civilian-client",
"author": "Krunker Civilian Client <krunker@crjlab.net>",
"license": "MIT",
"scripts": {
"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 .",
"build:main": "vite build --config vite.main.config.ts",
"build:preload": "vite build --config vite.preload.config.ts",
"build": "npm run build:main && npm run build:preload",
"start": "npm run build && electron .",
"download-electron": "node scripts/download-electron.js",
"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",
"lint": "eslint src/",
"prepare": "husky"
},
"dependencies": {
"electron-store": "^8.2.0"
},
"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",
"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 = `
.kpc-translation {
.kcc-translation {
color: #88ff88;
font-style: italic;
margin-left: 8px;
+14 -12
View File
@@ -1,4 +1,4 @@
import { app, BrowserWindow, Menu, clipboard, dialog, ipcMain, protocol, safeStorage, 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 ──
@@ -210,14 +211,14 @@ app.whenReady().then(async () => {
async function launchApp(): Promise<void> {
electronLog.log('[KCC] Starting initialization');
// ── Register swapper file protocol ──
registerSwapperFileProtocol();
// ── Session: persistent partition + clean user-agent ──
const ses = session.fromPartition('persist:krunker');
const rawUA = ses.getUserAgent();
ses.setUserAgent(rawUA.replace(/\s*krunker-civilian-client\/\S+/i, ''));
// ── Register swapper file protocol on this session ──
registerSwapperFileProtocol(ses);
// ── Resource swapper ──
const swapperConfig = config.get('swapper');
const swapDir = swapperConfig.path || join(app.getPath('userData'), 'Krunker Civilian Client', 'swapper');
@@ -239,16 +240,17 @@ async function launchApp(): Promise<void> {
: [...BLOCKED_URL_PATTERNS];
ses.webRequest.onBeforeRequest({ urls: requestFilterUrls }, (details, callback) => {
// Check swapper first — redirect matching assets to local files
if (swapper) {
const redirect = swapper.getRedirect(details.url);
if (redirect) return callback({ redirectURL: redirect });
}
// If we got here via the broad krunker.io pattern (not an ad), let it through
// 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 {
const host = new URL(details.url).hostname;
if (host.endsWith('krunker.io')) return callback({});
} catch {}
// Otherwise it matched an ad-block pattern — cancel it
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 });
});
@@ -317,8 +319,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;
@@ -333,7 +333,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());
@@ -461,6 +461,8 @@ async function launchApp(): Promise<void> {
// ── Inject scripts after page loads ──
win.webContents.on('did-finish-load', () => {
electronLog.log(`[KCC] Page loaded: ${win.webContents.getURL()}`);
// Rescan swap directory so new/changed files are picked up on refresh
if (swapper) swapper.rescan().catch(() => {});
Promise.all([
win.webContents.insertCSS(HIDE_ADS_CSS),
win.webContents.insertCSS(ALL_CLIENT_CSS),
+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;
}
+42 -9
View File
@@ -1,27 +1,53 @@
import { existsSync, mkdirSync, promises as fsp } from 'fs';
import { join } from 'path';
import { protocol, net } from 'electron';
import { protocol, net, Session } from 'electron';
const PROTOCOL_NAME = 'kpc-swap';
const TARGET_DOMAIN = 'krunker.io';
/**
* 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.
*/
export function initSwapperProtocol(): void {
protocol.registerSchemesAsPrivileged([{
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 {
protocol.handle(PROTOCOL_NAME, (request) => {
const filePath = decodeURI(request.url.replace(`${PROTOCOL_NAME}:`, ''));
return net.fetch('file://' + filePath);
export function registerSwapperFileProtocol(ses: Session): void {
ses.protocol.handle(PROTOCOL_NAME, (request) => {
const url = new URL(request.url);
// 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}`);
});
}
@@ -47,6 +73,13 @@ export class ResourceSwapper {
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 */
get patterns(): string[] {
return this.swapFiles.size > 0 ? [`*://*.${TARGET_DOMAIN}/*`] : [];
@@ -70,7 +103,7 @@ export class ResourceSwapper {
let pathname = queryStart === -1 ? url.substring(pathStart) : url.substring(pathStart, queryStart);
if (pathname.startsWith('/assets/')) pathname = pathname.substring(7);
const localPath = this.swapFiles.get(pathname);
if (localPath) return `${PROTOCOL_NAME}:/${localPath}`;
if (localPath) return filePathToSwapURL(localPath);
} catch { /* malformed URL — ignore */ }
return null;
}
@@ -87,7 +120,7 @@ export class ResourceSwapper {
this.swapFiles.set(name, join(this.swapDir, name));
}
}
} catch (err) {
} catch {
console.error(`Failed to scan swap directory prefix: ${prefix}`);
}
}
+1 -1
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';
+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 = {}; }
+7 -7
View File
@@ -2,7 +2,7 @@ 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';
@@ -10,7 +10,7 @@ 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[]) => {
@@ -75,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) {
@@ -1115,7 +1115,7 @@ function renderUserscriptsSection(body: HTMLElement): void {
scriptRow.className = 'setting settName safety-0 bool';
const displayName = escapeHtml(inst.meta.name || inst.filename);
let metaParts: string[] = [];
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>' : '';
@@ -1352,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;
@@ -1417,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;
@@ -1426,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) {
+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 };
+1 -1
View File
@@ -13,7 +13,7 @@ export default defineConfig({
outDir: 'dist/preload',
emptyDirBefore: true,
rollupOptions: {
external: ['electron', 'uuid'],
external: ['electron'],
},
target: 'node20',
minify: isProd,