This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
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<string, string>();
|
||||
private ready = false;
|
||||
private scanPromise: Promise<void>;
|
||||
|
||||
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<void> {
|
||||
await this.scanPromise;
|
||||
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}/*`] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user