import { existsSync, mkdirSync, promises as fsp } from 'fs'; import { join } from 'path'; 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: { standard: true, secure: true, corsEnabled: true, bypassCSP: true }, }]); } /** * Register the file protocol handler on the given session. * Must be called AFTER app.ready. */ export function registerSwapperFileProtocol(ses: Session): void { ses.protocol.handle(PROTOCOL_NAME, async (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; } try { return await net.fetch(`file://${filePath}`); } catch { return new Response('Not found', { status: 404 }); } }); } /** * Scans a local directory and intercepts matching Krunker asset requests, * redirecting them to local replacement files via a custom protocol. */ export class ResourceSwapper { private swapDir: string; private swapFiles = new Map(); private ready = false; private scanPromise: Promise; constructor(swapDir: string) { this.swapDir = swapDir; if (!existsSync(this.swapDir)) mkdirSync(this.swapDir, { recursive: true }); this.scanPromise = this.scanAsync(''); } /** Wait for the async directory scan to complete */ async waitForReady(): Promise { await this.scanPromise; this.ready = true; } /** Rescan the swap directory to pick up added/removed/changed files */ async rescan(): Promise { 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}/*`] : []; } /** * Returns a redirect URL if the request should be swapped, null otherwise. * Strips /assets/ prefix so both `assets.krunker.io/assets/textures/foo.png` * and `assets.krunker.io/textures/foo.png` resolve to the same local file. */ getRedirect(url: string): string | null { if (!this.ready) return null; try { // Extract pathname from URL using string ops (faster than new URL()) // URLs are like: https://assets.krunker.io/path/file.ext?v=hash const protoEnd = url.indexOf('//'); if (protoEnd === -1) return null; const pathStart = url.indexOf('/', protoEnd + 2); if (pathStart === -1) return null; const queryStart = url.indexOf('?', pathStart); 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 filePathToSwapURL(localPath); } catch { /* malformed URL — ignore */ } return null; } /** Recursively scan the swap directory and build the file map (async) */ private async scanAsync(prefix: string): Promise { try { const entries = await fsp.readdir(join(this.swapDir, prefix), { withFileTypes: true }); for (const dirent of entries) { const name = `${prefix}/${dirent.name}`; if (dirent.isDirectory()) { await this.scanAsync(name); } else { this.swapFiles.set(name, join(this.swapDir, name)); } } } catch { console.error(`Failed to scan swap directory prefix: ${prefix}`); } } }