Initial commit — Krunker Civilian Client

Cross-platform Krunker.io game client forked from Krunker Police Client
with all KPD/moderator features stripped: no KPD auth, OBS recording,
evidence uploads, yt-dlp, bytenode, or code obfuscation.

Retained: unlimited FPS (custom Electron 42), ad blocking, resource
swapper, matchmaker, userscripts, chat translator, Discord RPC, alt
account manager, configurable keybinds, and advanced Chromium flags.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 06:38:15 -08:00
commit 87ddf1499d
34 changed files with 12411 additions and 0 deletions
+587
View File
@@ -0,0 +1,587 @@
// ── Injected CSS for client settings in Krunker's settings panel ──
export const CLIENT_SETTINGS_CSS = `
:root {
/* ── Surfaces ── */
--kpc-surface-card: rgba(255,255,255,0.04);
--kpc-surface-input: rgba(255,255,255,0.08);
--kpc-surface-hover: rgba(255,255,255,0.1);
--kpc-surface-hover-strong: rgba(255,255,255,0.15);
--kpc-surface-dialog: #1a1a1a;
--kpc-surface-raised: #212121;
/* ── Text ── */
--kpc-text-primary: rgba(255,255,255,0.9);
--kpc-text-secondary: rgba(255,255,255,0.7);
--kpc-text-muted: rgba(255,255,255,0.5);
--kpc-text-faint: rgba(255,255,255,0.35);
--kpc-text-dim: rgba(255,255,255,0.3);
--kpc-text-info: #888;
/* ── Borders ── */
--kpc-border-subtle: rgba(255,255,255,0.06);
--kpc-border-default: rgba(255,255,255,0.1);
--kpc-border-medium: rgba(255,255,255,0.15);
--kpc-border-focus: rgba(255,255,255,0.35);
/* ── Accents ── */
--kpc-green: #4CAF50;
--kpc-green-hover: #66bb6a;
--kpc-red: #ef5350;
--kpc-red-hover: #e57373;
--kpc-blue: #42a5f5;
--kpc-blue-hover: #64b5f6;
--kpc-orange: #ff9800;
--kpc-orange-hover: #ffb74d;
--kpc-yellow: #ffc107;
--kpc-magenta: #fc03ec;
/* ── Controls ── */
--kpc-toggle-off: rgba(255,255,255,0.12);
/* ── Z-index layers ── */
--kpc-z-notification: 100000;
--kpc-z-overlay: 10000000;
--kpc-z-popup: 10000001;
}
/* ── Crankshaft-style settings (Krunker-native classes) ── */
.kpc-settings .settName,
.kpc-settings .settName .setting-title {
color: rgba(255,255,255,.6) !important;
}
.kpc-settings .settName {
display: grid;
grid-auto-columns: 1fr;
grid-template-columns: 0fr 1fr 0fr;
grid-template-areas:
"icon title input"
"desc desc desc";
grid-template-rows: 0fr min-content;
align-items: center;
}
.kpc-settings .settName.multisel {
grid-template-rows: min-content 1fr;
grid-template-columns: 0fr 1fr;
grid-template-areas:
"icon title"
"input input";
}
.kpc-settings .settName.has-button {
grid-template-areas:
"icon title button input"
"desc desc desc desc";
grid-template-columns: 0fr 1fr min-content 0fr;
}
.kpc-settings .settName.has-button .settingsBtn {
grid-area: button;
margin: 0 .5rem;
}
.kpc-settings .settName.kpc-button-holder {
grid-template-columns: 1fr;
grid-auto-columns: min-content;
column-gap: 0.25rem;
grid-template-areas: unset;
grid-template-rows: 0fr;
grid-auto-flow: column;
}
.kpc-settings .kpc-button-holder .buttons-title, .material-icons { color: inherit; }
.kpc-settings .kpc-button-holder .settingsBtn,
.kpc-settings .settName.has-button .settingsBtn {
width: max-content;
}
/* type: num */
.kpc-settings .settName.num .setting-input-wrapper {
display: flex;
}
.kpc-settings .settName.num .setting-input-wrapper .slidecontainer {
margin-top: -8px;
}
/* type: multisel */
.kpc-multisel-parent {
display: grid;
grid-template-columns: repeat(5, 1fr);
grid-auto-rows: 1fr;
gap: .25rem;
background: #232323;
border-radius: 10px;
margin-top: 0.8rem;
}
.kpc-multisel-parent label.hostOpt {
width: 100%;
margin: 0;
box-sizing: border-box;
}
.kpc-settings .settName.multisel label {
font-size: 1.1rem;
}
.kpc-settings .settName.multisel input {
margin-left: .25rem;
}
/* general settings */
.kpc-settings .settName .setting-title {
grid-area: title;
}
.kpc-settings .settName .s-update:disabled,
.kpc-settings .settName .s-update:disabled+.slider.round {
opacity: 0.5;
pointer-events: none;
}
.kpc-settings .setting .switch {
box-sizing: border-box;
}
.kpc-settings .setting .desc-icon {
grid-area: icon;
cursor: pointer;
font-size: 1rem;
width: 2.2rem;
height: 2.2rem;
line-height: 2.2rem;
border-radius: 5px !important;
color: #969696;
background-color: rgba(99, 99, 99, 0.16);
border: 2px solid rgba(78, 78, 78, 0.81);
margin-right: 10px;
display: flex;
justify-content: center;
align-items: center;
}
.kpc-settings .setting .desc-icon.instant {
background-color: rgba(1, 89, 220, 0.16);
border: 2px solid rgba(3, 133, 255, 0.81);
}
.kpc-settings .setting .desc-icon.instant svg path {
color: #0385ff;
fill: currentColor;
}
.kpc-settings .setting.settName .inputGrey2,
.kpc-settings .setting.settName .switch,
.kpc-settings .setting.settName .kpc-multisel-parent,
.kpc-settings .setting.settName .setting-input-wrapper,
.kpc-settings .setting.settName .keyIcon {
grid-area: input;
}
.kpc-settings .setting.safety-1 .desc-icon,
.kpc-settings .setting .desc-icon.refresh-icon,
.kpc-settings .setting .desc-icon.restart-icon {
background-color: rgba(99, 99, 99, 0.16);
border: 2px solid rgba(78, 78, 78, 0.81);
}
.kpc-settings .setting.safety-1 .desc-icon svg path,
.kpc-settings .setting .desc-icon.refresh-icon svg path,
.kpc-settings .setting .desc-icon.restart-icon svg path {
color: #969696;
fill: currentColor;
}
.kpc-settings .setting.safety-2 .desc-icon {
background-color: rgba(220, 180, 1, 0.16);
border: 2px solid rgba(241, 186, 6, 0.81);
}
.kpc-settings .setting.safety-2 .desc-icon svg path {
color: #ffd903;
fill: currentColor;
}
.kpc-settings .setting.safety-3 .desc-icon {
background-color: rgba(220, 118, 1, 0.16);
border: 2px solid rgba(241, 131, 6, 0.81);
}
.kpc-settings .setting.safety-3 .desc-icon svg path {
color: #ff9203;
fill: currentColor;
}
.kpc-settings .setting.safety-4 .desc-icon {
background-color: rgba(220, 17, 1, 0.16);
border: 2px solid rgba(239, 6, 6, 0.81);
}
.kpc-settings .setting.safety-4 .desc-icon svg path {
color: #ff0303;
fill: currentColor;
}
.desc-icon {
position: relative;
}
.setting-desc-new {
display: block;
width: fit-content;
max-width: 50ch;
line-height: 30px;
font-size: 15px;
letter-spacing: 0.5px;
word-wrap: break-word;
color: rgba(255, 255, 255, 0.4) !important;
overflow: hidden;
max-height: 500px;
margin-top: 6px;
grid-area: desc;
}
.setting-desc-new a {
font-size: inherit !important;
font-family: inherit !important;
}
.setting-category-collapsed {
display: none;
}
/* keybind display */
.keyIcon.kpc-keyIcon:hover {
transform: scale(1.25);
cursor: pointer;
}
.keyIcon.kpc-keyIcon {
display: inline-block;
transition: 0s;
}
/* ── KPC action button grid ── */
.kpc-action-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
padding: 0 12px 12px;
}
.kpc-action-btn {
background: var(--kpc-surface-card);
color: var(--kpc-text-primary);
border: 2px solid var(--kpc-border-medium);
padding: 10px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
text-align: center;
transition: background 0.15s, border-color 0.15s;
user-select: none;
}
.kpc-action-btn:hover {
background: var(--kpc-surface-hover);
border-color: var(--kpc-border-focus);
}
.kpc-action-btn:active {
transform: scale(0.97);
}
.kpc-action-btn.full {
grid-column: 1 / -1;
}
.kpc-action-btn.kpc-ab-purple { border-color: #ab47bc; }
.kpc-action-btn.kpc-ab-purple:hover { border-color: #ce93d8; }
.kpc-action-btn.kpc-ab-cyan { border-color: #00bcd4; }
.kpc-action-btn.kpc-ab-cyan:hover { border-color: #4dd0e1; }
.kpc-action-btn.kpc-ab-pink { border-color: #ec407a; }
.kpc-action-btn.kpc-ab-pink:hover { border-color: #f48fb1; }
.kpc-action-btn.kpc-ab-red { border-color: var(--kpc-red); }
.kpc-action-btn.kpc-ab-red:hover { border-color: var(--kpc-red-hover); }
.kpc-action-btn.kpc-ab-orange { border-color: var(--kpc-orange); }
.kpc-action-btn.kpc-ab-orange:hover { border-color: var(--kpc-orange-hover); }
/* floating toasts css that is required */
.kpc-holder-update {
position: absolute;
font-size: 1.125rem !important;
color: rgba(255, 255, 255, 0.7);
display: block !important;
top: 20px;
left: 20px;
background-color: black;
padding: 1rem;
border-radius: 0.5rem;
width: max-content;
z-index: 10;
}
/* settings refresh popup */
.refresh-popup {
height: min-content;
left: 50%;
transform: translateX(-50%);
color: rgba(255,255,255,0.6)
}
.refresh-popup span {
display: flex;
align-items: center;
column-gap: 0.5rem;
color: rgba(255,255,255,0.6);
}
.refresh-popup,
.refresh-popup span,
.refresh-popup a {
vertical-align: middle;
font-size: .8rem;
line-height: .8rem;
z-index: 12;
}
.refresh-popup svg { fill: rgba(255,255,255,0.6); }
.refresh-popup code {
color: white;
font-size: 1.2rem;
line-height: 1.2rem;
font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace;
background-color: #232323;
padding: 0.08rem 0.4rem;
border-radius: 3px;
border: 2px solid #333333
}
/* ── Keybind capture dialog ── */
.kpc-keybind-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: var(--kpc-z-overlay);
background: rgba(0,0,0,0.7);
display: flex;
align-items: center;
justify-content: center;
}
.kpc-keybind-dialog {
background: var(--kpc-surface-dialog);
border: 1px solid var(--kpc-border-medium);
border-radius: 10px;
padding: 24px 32px;
min-width: 400px;
position: relative;
}
.kpc-keybind-dialog-title {
color: var(--kpc-text-primary);
font-size: 18px;
margin-bottom: 6px;
}
.kpc-keybind-dialog-sub {
color: var(--kpc-text-muted);
font-size: 13px;
margin-bottom: 16px;
}
.kpc-keybind-dialog-sub code {
color: #64b5f6;
}
.kpc-keybind-dialog-modifiers {
display: flex;
gap: 8px;
font-size: 14px;
}
.kpc-keybind-modifier {
background: var(--kpc-surface-raised);
color: var(--kpc-text-faint);
flex: 1;
text-align: center;
padding: 10px 0;
border-radius: 6px;
transition: background 0.15s, color 0.15s;
}
.kpc-keybind-modifier.active {
background: #1976d2;
color: #fff;
}
.kpc-keybind-dialog-cancel {
position: absolute;
top: 12px;
right: 16px;
color: #64b5f6;
cursor: pointer;
font-size: 14px;
}
.kpc-keybind-dialog-cancel:hover {
text-decoration: underline;
}
/* ── Preserved: color input, userscript meta ── */
.kpc-color-input {
width: 36px;
height: 28px;
border: 1px solid var(--kpc-border-default);
border-radius: 4px;
background: transparent;
cursor: pointer;
padding: 0;
flex-shrink: 0;
}
.kpc-color-input::-webkit-color-swatch-wrapper {
padding: 2px;
}
.kpc-color-input::-webkit-color-swatch {
border: none;
border-radius: 2px;
}
.kpc-us-meta {
color: var(--kpc-text-dim);
font-size: 11px;
margin-top: 2px;
}
.kpc-us-settings {
padding: 4px 0 4px 20px;
}
#chatList, #chatList * {
user-select: text !important;
cursor: text;
}
#chatList.kpc-chat-paused {
border-left: 2px solid var(--kpc-yellow);
}
`;
// ── Matchmaker popup CSS + settings extras (injected separately) ──
export const MATCHMAKER_SETTINGS_CSS = `
@keyframes matchmakerPopupSlideDown {
0% { transform: translate(-50%, -500%); }
100% { transform: translate(-50%, 0%); }
}
.onGame #matchmakerPopupContainer {
opacity: 0 !important;
}
#matchmakerPopupContainer {
position: absolute;
top: 10em;
left: 50%;
z-index: var(--kpc-z-popup);
box-sizing: border-box;
width: 35em;
aspect-ratio: 2.5/1;
border-radius: 1.2em;
overflow: hidden;
background-size: 100% 100%;
pointer-events: all;
background-color: var(--kpc-surface-raised);
animation: matchmakerPopupSlideDown 0.5s ease forwards;
}
#matchmakerPopupTitle {
font-size: 1.8em;
color: white;
padding: 0.3em 0.7em;
background: rgba(0,0,0,0.5);
margin-bottom: 0.3em;
}
#matchmakerPopupDescription {
background: rgba(0,0,0,0.5);
color: var(--kpc-yellow);
box-sizing: border-box;
padding: 0.6em 1em;
}
#matchmakerPopupOptions {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
display: flex;
}
.matchmakerPopupButton {
text-align: center;
border: 0.3em solid;
box-sizing: border-box;
margin: 0.5em;
color: white;
border-radius: 0.3em;
font-size: 1.3em;
background-color: rgba(0,0,0,0.5);
padding: 0.2em 1.4em;
transition: all 0.08s;
}
#matchmakerConfirmButton {
border-color: var(--kpc-green);
flex-grow: 1;
}
#matchmakerCancelButton {
border-color: #f44336;
}
.matchmakerPopupButton:hover {
cursor: pointer;
border-color: white !important;
transform: scale(0.95);
}
.matchmakerPopupButton:active {
transform: scale(0.85);
}
`;
export const TRANSLATOR_CSS = `
.kpc-translation {
color: #88ff88;
font-style: italic;
margin-left: 8px;
margin-top: 2px;
}
`;
// ── Alt Manager CSS ──
export const ALT_MANAGER_CSS = `
.kpc-acc-form { display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px; }
.kpc-acc-form input {
background: var(--kpc-surface-input); border: 1px solid var(--kpc-border); border-radius: 4px;
color: #fff; padding: 6px 10px; font-size: 13px; outline: none; font-family: inherit;
}
.kpc-acc-form input:focus { border-color: var(--kpc-accent); }
.kpc-acc-form input::placeholder { color: rgba(255,255,255,0.3); }
.kpc-acc-form-buttons { display: flex; gap: 8px; }
.kpc-acc-form-buttons button {
padding: 6px 16px; border: none; border-radius: 4px; cursor: pointer;
font-size: 13px; font-family: inherit;
}
.kpc-acc-form-buttons .kpc-acc-save {
background: var(--kpc-accent); color: #fff;
}
.kpc-acc-form-buttons .kpc-acc-save:hover { filter: brightness(1.2); }
.kpc-acc-form-buttons .kpc-acc-cancel {
background: var(--kpc-surface-hover); color: #fff;
}
.kpc-acc-form-buttons .kpc-acc-cancel:hover { background: var(--kpc-surface-hover-strong); }
.kpc-acc-item {
display: flex; align-items: center; justify-content: space-between;
padding: 8px 12px; background: var(--kpc-surface-card); border-radius: 6px; margin-bottom: 6px;
}
.kpc-acc-item-info { display: flex; align-items: center; gap: 8px; }
.kpc-acc-item-label { color: #fff; font-size: 14px; font-weight: 500; }
.kpc-acc-item-role {
font-size: 11px; padding: 2px 6px; border-radius: 3px;
background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.6);
}
.kpc-acc-item-actions { display: flex; gap: 6px; }
.kpc-acc-item-actions button {
padding: 4px 12px; border: none; border-radius: 4px; cursor: pointer;
font-size: 12px; font-family: inherit;
}
.kpc-acc-switch { background: var(--kpc-accent); color: #fff; }
.kpc-acc-switch:hover { filter: brightness(1.2); }
.kpc-acc-delete { background: rgba(255,80,80,0.2); color: #ff5050; }
.kpc-acc-delete:hover { background: rgba(255,80,80,0.35); }
.kpc-acc-empty { color: rgba(255,255,255,0.4); font-size: 13px; text-align: center; padding: 16px 0; }
.kpc-alt-overlay-backdrop {
position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: 99998;
background: rgba(0,0,0,0.5);
}
.kpc-alt-overlay {
position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
background: var(--kpc-surface-dialog, #1a1a1a); border-radius: 8px;
padding: 16px; min-width: 280px; max-width: 360px; z-index: 99999;
box-shadow: 0 8px 32px rgba(0,0,0,0.6);
}
.kpc-alt-overlay h3 {
margin: 0 0 12px; color: #fff; font-size: 16px; font-weight: 600;
}
`;
/** Pre-concatenated CSS for single-call injection (excludes HIDE_ADS_CSS which is separate) */
export const ALL_CLIENT_CSS = `${CLIENT_SETTINGS_CSS}\n${MATCHMAKER_SETTINGS_CSS}\n${TRANSLATOR_CSS}\n${ALT_MANAGER_CSS}`;
+178
View File
@@ -0,0 +1,178 @@
import Store from 'electron-store';
import { detectPlatform } from './platform';
export interface Keybind {
key: string;
ctrl: boolean;
shift: boolean;
alt: boolean;
}
export interface SavedAccount {
label: string;
username: string;
password: string;
}
export interface AppConfig {
window: {
width: number;
height: number;
x: number | undefined;
y: number | undefined;
maximized: boolean;
fullscreen: boolean;
};
performance: {
fpsUnlocked: boolean;
hardwareAccel: boolean;
gpuPreference: 'high-performance' | 'low-power' | 'default';
};
game: {
lastServer: string;
socialTabBehaviour: 'New Window' | 'Same Window';
joinAsSpectator: boolean;
};
swapper: {
enabled: boolean;
path: string;
};
matchmaker: {
enabled: boolean;
regions: string[];
gamemodes: string[];
minPlayers: number;
maxPlayers: number;
minRemainingTime: number;
openServerBrowser: boolean;
};
keybinds: {
reload: Keybind;
newMatch: Keybind;
copyGameLink: Keybind;
joinFromClipboard: Keybind;
devTools: Keybind;
matchmaker: Keybind;
matchmakerAccept: Keybind;
matchmakerCancel: Keybind;
pauseChat: Keybind;
fullscreenToggle: Keybind;
};
userscripts: {
enabled: boolean;
path: string;
};
ui: {
showExitButton: boolean;
deathscreenAnimation: boolean;
hideMenuPopups: boolean;
};
discord: {
enabled: boolean;
};
translator: {
enabled: boolean;
targetLanguage: string;
showLanguageTag: boolean;
};
advanced: {
removeUselessFeatures: boolean;
gpuRasterizing: boolean;
helpfulFlags: boolean;
disableAccelerated2D: boolean;
increaseLimits: boolean;
lowLatency: boolean;
experimentalFlags: boolean;
angleBackend: string;
};
accounts: SavedAccount[];
platform: {
detectedOS: string;
gpuBackend: string;
};
}
export const DEFAULT_KEYBINDS: AppConfig['keybinds'] = {
reload: { key: 'F5', ctrl: false, shift: false, alt: false },
newMatch: { key: 'F4', ctrl: false, shift: false, alt: false },
copyGameLink: { key: 'l', ctrl: true, shift: false, alt: false },
joinFromClipboard: { key: 'j', ctrl: true, shift: false, alt: false },
devTools: { key: 'F12', ctrl: false, shift: false, alt: false },
matchmaker: { key: 'F6', ctrl: false, shift: false, alt: false },
matchmakerAccept: { key: 'Enter', ctrl: false, shift: false, alt: false },
matchmakerCancel: { key: 'Escape', ctrl: false, shift: false, alt: false },
pauseChat: { key: 'F10', ctrl: false, shift: false, alt: false },
fullscreenToggle: { key: 'F11', ctrl: false, shift: false, alt: false },
};
const platformInfo = detectPlatform();
export const config = new Store<AppConfig>({
name: 'krunker-civilian-config',
defaults: {
window: {
width: 1600,
height: 900,
x: undefined,
y: undefined,
maximized: false,
fullscreen: false,
},
performance: {
fpsUnlocked: true,
hardwareAccel: true,
gpuPreference: 'high-performance',
},
game: {
lastServer: '',
socialTabBehaviour: 'New Window',
joinAsSpectator: false,
},
swapper: {
enabled: true,
path: '',
},
matchmaker: {
enabled: true,
regions: [],
gamemodes: [],
minPlayers: 1,
maxPlayers: 6,
minRemainingTime: 120,
openServerBrowser: true,
},
keybinds: DEFAULT_KEYBINDS,
userscripts: {
enabled: true,
path: '',
},
ui: {
showExitButton: true,
deathscreenAnimation: true,
hideMenuPopups: false,
},
discord: {
enabled: false,
},
translator: {
enabled: true,
targetLanguage: 'en',
showLanguageTag: true,
},
advanced: {
removeUselessFeatures: true,
gpuRasterizing: false,
helpfulFlags: true,
disableAccelerated2D: false,
increaseLimits: false,
lowLatency: false,
experimentalFlags: false,
angleBackend: 'default',
},
accounts: [],
platform: {
detectedOS: platformInfo.os,
gpuBackend: platformInfo.gpuBackend,
},
},
});
+285
View File
@@ -0,0 +1,285 @@
import { Socket } from 'net';
import { electronLog } from './logger';
const DISCORD_CLIENT_ID = '1474451871694323975';
// Discord IPC opcodes
const OP_HANDSHAKE = 0;
const OP_FRAME = 1;
const OP_CLOSE = 2;
// Rate limit: Discord rejects updates faster than 15s
const RATE_LIMIT_MS = 5000;
const RECONNECT_INTERVAL_MS = 30000;
export interface ActivityPayload {
details?: string;
state?: string;
startTimestamp?: number;
largeImageKey?: string;
largeImageText?: string;
}
function getPipePath(id: number): string {
if (process.platform === 'win32') {
return `\\\\?\\pipe\\discord-ipc-${id}`;
}
// Linux/macOS: check XDG_RUNTIME_DIR, TMPDIR, TMP, TEMP, /tmp
const dir = process.env.XDG_RUNTIME_DIR
|| process.env.TMPDIR
|| process.env.TMP
|| process.env.TEMP
|| '/tmp';
return `${dir}/discord-ipc-${id}`;
}
function encodeFrame(opcode: number, payload: object): Buffer {
const json = JSON.stringify(payload);
const jsonBuf = Buffer.from(json);
const header = Buffer.alloc(8);
header.writeUInt32LE(opcode, 0);
header.writeUInt32LE(jsonBuf.length, 4);
return Buffer.concat([header, jsonBuf]);
}
export class DiscordRPC {
private socket: Socket | null = null;
private connected = false;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private lastUpdate = 0;
private nonce = 0;
private destroyed = false;
private recvBuf = Buffer.alloc(0);
private pendingActivity: ActivityPayload | null = null;
private flushTimer: ReturnType<typeof setTimeout> | null = null;
get isConnected(): boolean {
return this.connected;
}
connect(): void {
if (this.destroyed) return;
this.tryConnect(0);
}
private tryConnect(pipeIndex: number): void {
if (this.destroyed || pipeIndex > 9) {
this.scheduleReconnect();
return;
}
const pipePath = getPipePath(pipeIndex);
const sock = new Socket();
let settled = false;
const onError = () => {
if (settled) return;
settled = true;
sock.destroy();
// Try next pipe index
this.tryConnect(pipeIndex + 1);
};
sock.once('error', onError);
sock.connect(pipePath, () => {
if (settled || this.destroyed) {
sock.destroy();
return;
}
settled = true;
this.socket = sock;
this.recvBuf = Buffer.alloc(0);
// Remove the initial error handler and set up persistent ones
sock.removeListener('error', onError);
sock.on('error', (err) => {
electronLog.warn('[KCC-Discord] Socket error:', err.message);
this.handleDisconnect();
});
sock.on('close', () => {
this.handleDisconnect();
});
sock.on('data', (data) => {
this.onData(data);
});
// Send handshake
const handshake = encodeFrame(OP_HANDSHAKE, {
v: 1,
client_id: DISCORD_CLIENT_ID,
});
sock.write(handshake);
});
// Connection timeout — 5s
sock.setTimeout(5000, onError);
}
private onData(data: Buffer): void {
this.recvBuf = Buffer.concat([this.recvBuf, data]);
while (this.recvBuf.length >= 8) {
const opcode = this.recvBuf.readUInt32LE(0);
const length = this.recvBuf.readUInt32LE(4);
if (this.recvBuf.length < 8 + length) break;
const jsonBuf = this.recvBuf.slice(8, 8 + length);
this.recvBuf = this.recvBuf.slice(8 + length);
try {
const payload = JSON.parse(jsonBuf.toString());
this.handleMessage(opcode, payload);
} catch {
// Malformed JSON — ignore
}
}
}
private handleMessage(opcode: number, payload: any): void {
if (opcode === OP_FRAME) {
if (payload.cmd === 'DISPATCH' && payload.evt === 'READY') {
this.connected = true;
electronLog.log('[KCC-Discord] Connected to Discord');
// Flush any activity that was set before connection completed
if (this.pendingActivity) {
this.sendActivity(this.pendingActivity);
this.pendingActivity = null;
}
}
} else if (opcode === OP_CLOSE) {
electronLog.warn('[KCC-Discord] Discord closed connection:', payload.message || '');
this.handleDisconnect();
}
}
private handleDisconnect(): void {
if (!this.connected && !this.socket) return;
this.connected = false;
if (this.flushTimer) {
clearTimeout(this.flushTimer);
this.flushTimer = null;
}
if (this.socket) {
this.socket.destroy();
this.socket = null;
}
this.recvBuf = Buffer.alloc(0);
electronLog.log('[KCC-Discord] Disconnected');
this.scheduleReconnect();
}
private scheduleReconnect(): void {
if (this.destroyed || this.reconnectTimer) return;
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
if (!this.destroyed && !this.connected) {
this.tryConnect(0);
}
}, RECONNECT_INTERVAL_MS);
}
setActivity(activity: ActivityPayload): void {
if (this.destroyed) return;
// Always store latest activity so it can be sent on (re)connect
this.pendingActivity = activity;
if (!this.connected || !this.socket) return;
const now = Date.now();
const elapsed = now - this.lastUpdate;
if (elapsed < RATE_LIMIT_MS) {
// Schedule a flush after the rate limit window expires
if (!this.flushTimer) {
this.flushTimer = setTimeout(() => {
this.flushTimer = null;
if (this.pendingActivity && this.connected && this.socket) {
this.sendActivity(this.pendingActivity);
this.pendingActivity = null;
}
}, RATE_LIMIT_MS - elapsed);
}
return;
}
this.sendActivity(activity);
this.pendingActivity = null;
}
private sendActivity(activity: ActivityPayload): void {
if (!this.socket || this.destroyed) return;
this.lastUpdate = Date.now();
const activityObj: any = {};
if (activity.details) activityObj.details = activity.details;
if (activity.state) activityObj.state = activity.state;
if (activity.startTimestamp) {
activityObj.timestamps = { start: activity.startTimestamp };
}
if (activity.largeImageKey) {
activityObj.assets = {
large_image: activity.largeImageKey,
large_text: activity.largeImageText || 'Krunker Civilian Client',
};
}
const frame = encodeFrame(OP_FRAME, {
cmd: 'SET_ACTIVITY',
args: {
pid: process.pid,
activity: activityObj,
},
nonce: String(++this.nonce),
});
try {
this.socket.write(frame);
} catch (err) {
electronLog.warn('[KCC-Discord] Write error:', (err as Error).message);
}
}
clearActivity(): void {
if (!this.connected || !this.socket || this.destroyed) return;
const frame = encodeFrame(OP_FRAME, {
cmd: 'SET_ACTIVITY',
args: {
pid: process.pid,
activity: null,
},
nonce: String(++this.nonce),
});
try {
this.socket.write(frame);
} catch {
// Silent
}
}
disconnect(): void {
this.destroyed = true;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.flushTimer) {
clearTimeout(this.flushTimer);
this.flushTimer = null;
}
if (this.socket) {
try {
this.clearActivity();
} catch {
// Silent
}
this.socket.destroy();
this.socket = null;
}
this.connected = false;
this.recvBuf = Buffer.alloc(0);
}
}
+670
View File
@@ -0,0 +1,670 @@
import { app, BrowserWindow, Menu, clipboard, dialog, ipcMain, protocol, session, shell } from 'electron';
import { join } from 'path';
import { existsSync, mkdirSync, promises as fsp } from 'fs';
import { get as httpsGet } from 'https';
import { execFile } from 'child_process';
import { detectPlatform, applyPlatformFlags } from './platform';
import { config, Keybind, DEFAULT_KEYBINDS, SavedAccount } from './config';
import { initSwapperProtocol, registerSwapperFileProtocol, ResourceSwapper } from './swapper';
import { UserscriptManager } from './userscripts';
import { ALL_CLIENT_CSS } from './client-ui';
import { electronLog, getLogPath, closeLogStreams } from './logger';
import { checkForUpdate, downloadUpdate, installUpdate } from './updater';
import { showUpdateWindow } from './update-window';
import { DiscordRPC } from './discord-rpc';
// ── App version for API calls ──
const appVersion: string = require('../../package.json').version;
// ── Region ping cache ──
const SERVER_MAP: Record<string, string> = {
'us-ca-sv': 'SV', 'jb-hnd': 'TOK', 'de-fra': 'FRA',
'as-mb': 'MBI', 'au-syd': 'SYD', 'sgp': 'SIN',
'us-tx': 'DAL', 'me-bhn': 'BHN', 'brz': 'BRZ', 'us-nj': 'NY',
};
let pingCache: Record<string, number> = {};
let pingCacheTime = 0;
function osPing(host: string): Promise<number> {
return new Promise((resolve) => {
const isWin = process.platform === 'win32';
const args = isWin ? ['-n', '1', '-w', '1500', host] : ['-c', '1', '-W', '2', host];
execFile('ping', args, { timeout: 3000 }, (err, stdout) => {
if (err) { resolve(-1); return; }
const match = stdout.match(/time[=<]([\d.]+)\s*ms/i);
if (match) resolve(Math.round(parseFloat(match[1])));
else resolve(-1);
});
});
}
// ── Platform flags (must run before app.ready) ──
const platformInfo = detectPlatform();
const advancedDefaults = {
removeUselessFeatures: true,
gpuRasterizing: false,
helpfulFlags: true,
disableAccelerated2D: false,
increaseLimits: false,
lowLatency: false,
experimentalFlags: false,
};
const advancedConfig = { ...advancedDefaults, ...config.get('advanced') };
const perfConfig = { fpsUnlocked: true, ...config.get('performance') };
applyPlatformFlags(platformInfo, advancedConfig, perfConfig);
// ── Resource swapper protocol (must register before app.ready) ──
initSwapperProtocol();
// ── Ad-blocking URL patterns (matched in C++ layer, never hits JS for non-matches) ──
const BLOCKED_URL_PATTERNS = [
'*://*.pollfish.com/*',
'*://www.paypalobjects.com/*',
'*://fran-cdn.frvr.com/*',
'*://c.amazon-adsystem.com/*',
'*://cdn.frvr.com/fran/*',
'*://cookiepro.com/*',
'*://*.cookiepro.com/*',
'*://www.googletagmanager.com/*',
'*://*.doubleclick.net/*',
'*://storage.googleapis.com/pollfish_production/*',
'*://coeus.frvr.com/*',
'*://apis.google.com/js/platform.js',
'*://imasdk.googleapis.com/*',
];
// ── CSS to hide ad containers ──
const HIDE_ADS_CSS = `
.endAHolder,
#aHider,
#adCon,
#rightABox,
#aContainer,
#topRightAdHolder,
div#aContainer,
#braveWarning,
#topRightAdHolder {
display: none !important;
}`;
// ── Consent dismiss script (polling only — NO MutationObserver on main frame) ──
const CONSENT_DISMISS_MAIN_JS = `
(function dismissConsent() {
let attempts = 0;
const timer = setInterval(() => {
attempts++;
const btn = document.querySelector('.fc-cta-consent, [aria-label="Consent"], .css-47sehv');
if (btn) { btn.click(); clearInterval(timer); }
if (attempts > 30) clearInterval(timer);
}, 500);
})();`;
// ── Escape pointer lock fix ──
const ESCAPE_POINTERLOCK_FIX_JS = `
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && document.pointerLockElement) {
document.exitPointerLock();
}
}, true);`;
// ── Keybind matching ──
function matchesKeybind(input: { key: string; control: boolean; shift: boolean; alt: boolean }, bind: Keybind | undefined): boolean {
if (!bind) return false;
return input.key === bind.key
&& input.control === bind.ctrl
&& input.shift === bind.shift
&& input.alt === bind.alt;
}
// ── Cached keybinds (avoid re-reading electron-store on every keypress) ──
let cachedKeybinds: Record<string, Keybind> | null = null;
function getKeybinds(): Record<string, Keybind> {
if (!cachedKeybinds) {
cachedKeybinds = { ...DEFAULT_KEYBINDS, ...config.get('keybinds') };
}
return cachedKeybinds;
}
// ── Debounced window state persistence ──
let saveTimer: ReturnType<typeof setTimeout> | null = null;
function saveWindowState(win: BrowserWindow): void {
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
if (win.isDestroyed()) return;
const bounds = win.getBounds();
config.set('window', {
width: bounds.width,
height: bounds.height,
x: bounds.x,
y: bounds.y,
maximized: win.isMaximized(),
fullscreen: win.isFullScreen(),
});
}, 1000);
}
app.whenReady().then(async () => {
electronLog.log('[KCC] App ready');
// ── Auto-update check (mandatory, Windows NSIS install only) ──
const isPortable = !!process.env.PORTABLE_EXECUTABLE_DIR;
const isAppImage = !!process.env.APPIMAGE;
const isDev = !app.isPackaged;
if (isDev || process.platform !== 'win32' || isPortable || isAppImage) {
electronLog.log('[KCC] Skipping auto-update (portable or non-Windows)');
} else {
try {
electronLog.log('[KCC] Checking for updates...');
const update = await checkForUpdate(appVersion);
if (update) {
electronLog.log(`[KCC] Update available: v${update.version}`);
const { window: updateWin, sendProgress } = showUpdateWindow();
sendProgress(`Update available (v${update.version})`, 0);
const tempDir = join(app.getPath('temp'), 'kcc-update');
if (!existsSync(tempDir)) mkdirSync(tempDir, { recursive: true });
const installerPath = join(tempDir, `KCC-${update.version}-Setup.exe`);
let cancelled = false;
updateWin.on('closed', () => { cancelled = true; });
try {
await downloadUpdate(update.downloadUrl, installerPath, (pct) => {
if (!cancelled && !updateWin.isDestroyed()) {
sendProgress(`Downloading update... ${pct}%`, pct);
}
});
if (!cancelled) {
sendProgress('Installing update...', 100);
installUpdate(installerPath);
return; // app.quit() called by installUpdate
}
} catch (err) {
electronLog.error('[KCC] Update download failed:', err);
if (!updateWin.isDestroyed()) updateWin.close();
}
} else {
electronLog.log('[KCC] No updates available');
}
} catch (err) {
electronLog.error('[KCC] Update check failed:', err);
}
}
await launchApp();
});
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, ''));
// ── Resource swapper ──
const swapperConfig = config.get('swapper');
const swapDir = swapperConfig.path || join(app.getPath('userData'), 'KCCClient', 'swapper');
const swapper = swapperConfig.enabled ? new ResourceSwapper(swapDir) : null;
electronLog.log(`[KCC] Resource swapper: ${swapper ? 'enabled' : 'disabled'} (${swapDir})`);
// ── Userscript manager ──
const usConfig = config.get('userscripts') || { enabled: true, path: '' };
const usDir = usConfig.path || join(app.getPath('userData'), 'KCCClient');
const userscriptManager = usConfig.enabled ? new UserscriptManager(usDir) : null;
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) => {
if (swapper) {
const redirect = swapper.getRedirect(details.url);
if (redirect) return callback({ redirectURL: redirect });
}
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)`);
});
}
// ── CORS fix for swapped resources ──
if (swapper) {
ses.webRequest.onHeadersReceived(({ responseHeaders }, callback) => {
if (!responseHeaders) return callback({});
for (const key in responseHeaders) {
const lowercase = key.toLowerCase();
if (lowercase === 'access-control-allow-credentials' && responseHeaders[key][0] === 'true') {
return callback({ responseHeaders });
}
if (lowercase === 'access-control-allow-origin') {
delete responseHeaders[key];
break;
}
}
return callback({
responseHeaders: { ...responseHeaders, 'access-control-allow-origin': ['*'] },
});
});
}
// ── Restore saved window bounds ──
const savedWindow = config.get('window');
const win = new BrowserWindow({
width: savedWindow.width,
height: savedWindow.height,
x: savedWindow.x,
y: savedWindow.y,
frame: true,
backgroundColor: '#000000',
webPreferences: {
preload: join(__dirname, '..', 'preload', 'index.js'),
session: ses,
contextIsolation: false,
nodeIntegration: false,
sandbox: false,
spellcheck: false,
backgroundThrottling: false,
},
});
if (savedWindow.fullscreen) win.setFullScreen(true);
else if (savedWindow.maximized) win.maximize();
// ── No application menu (prevents Escape/Alt interception) ──
Menu.setApplicationMenu(null);
// ── Discord Rich Presence ──
let discordRpc: DiscordRPC | null = null;
{
const discordConf = config.get('discord') || { enabled: false };
if (discordConf.enabled) {
discordRpc = new DiscordRPC();
discordRpc.connect();
electronLog.log('[KCC] Discord Rich Presence enabled');
}
}
// ── Common output directory (used by folder actions) ──
const outputDir = join(app.getPath('documents'), 'KrunkerCivilianClient');
// ── Configurable keybinds via before-input-event ──
win.webContents.on('before-input-event', (event, input) => {
if (input.type !== 'keyDown') return;
const binds = getKeybinds();
if (matchesKeybind(input, binds.reload)) {
win.reload();
event.preventDefault();
} else if (matchesKeybind(input, binds.newMatch)) {
win.loadURL('https://krunker.io');
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 {};
event.preventDefault();
} else if (matchesKeybind(input, binds.copyGameLink)) {
clipboard.writeText(win.webContents.getURL());
event.preventDefault();
} else if (matchesKeybind(input, binds.devTools)) {
win.webContents.toggleDevTools();
event.preventDefault();
} else if (matchesKeybind(input, binds.matchmaker)) {
const mm = config.get('matchmaker');
if (mm.enabled) {
win.webContents.send('matchmaker-find', {
...mm,
acceptKey: binds.matchmakerAccept,
cancelKey: binds.matchmakerCancel,
});
} else {
win.loadURL('https://krunker.io');
}
event.preventDefault();
} else if (matchesKeybind(input, binds.pauseChat)) {
win.webContents.send('toggle-chat-pause');
event.preventDefault();
} else if (matchesKeybind(input, binds.fullscreenToggle)) {
win.setFullScreen(!win.isFullScreen());
event.preventDefault();
}
});
// ── Window state persistence (debounced) ──
win.on('resize', () => saveWindowState(win));
win.on('move', () => saveWindowState(win));
win.on('maximize', () => saveWindowState(win));
win.on('unmaximize', () => saveWindowState(win));
win.on('enter-full-screen', () => saveWindowState(win));
win.on('leave-full-screen', () => saveWindowState(win));
// ── Open krunker.io sub-pages in a new window ──
const GAME_PAGE_PATHS = ['/', ''];
function isGameURL(url: string): boolean {
try {
const parsed = new URL(url);
if (!parsed.hostname.includes('krunker.io')) return false;
return GAME_PAGE_PATHS.includes(parsed.pathname);
} catch { return false; }
}
function openSubWindow(url: string): void {
const sub = new BrowserWindow({
width: 1280,
height: 720,
frame: true,
backgroundColor: '#000000',
autoHideMenuBar: true,
webPreferences: {
preload: join(__dirname, '..', 'preload', 'index.js'),
session: ses,
contextIsolation: false,
nodeIntegration: false,
sandbox: false,
spellcheck: false,
},
});
sub.removeMenu();
sub.loadURL(url);
sub.webContents.on('did-finish-load', () => {
sub.webContents.insertCSS(ALL_CLIENT_CSS).catch(() => {});
sub.webContents.send('main_did-finish-load');
});
sub.webContents.setWindowOpenHandler(({ url: subUrl }) => {
if (subUrl.includes('krunker.io')) {
sub.loadURL(subUrl);
} else {
setImmediate(() => shell.openExternal(subUrl));
}
return { action: 'deny' };
});
sub.webContents.on('will-prevent-unload', (ev) => {
const choice = dialog.showMessageBoxSync(sub, {
type: 'question',
buttons: ['Leave', 'Stay'],
defaultId: 1,
title: 'Leave page?',
message: 'Changes you made may not be saved.',
});
if (choice === 0) ev.preventDefault();
});
}
// ── Cached game config (invalidated on set-config writes to 'game') ──
const gameDefaults = { lastServer: '', socialTabBehaviour: 'New Window' };
let cachedGameConf: typeof gameDefaults | null = null;
function getGameConf(): typeof gameDefaults {
if (!cachedGameConf) cachedGameConf = { ...gameDefaults, ...config.get('game') };
return cachedGameConf;
}
// Intercept in-page navigation (e.g. window.location = '/social.html')
win.webContents.on('will-navigate', (event, url) => {
if (url.includes('krunker.io') && !isGameURL(url)) {
if (getGameConf().socialTabBehaviour === 'New Window') {
event.preventDefault();
openSubWindow(url);
}
}
});
// Intercept target="_blank" / window.open links
win.webContents.setWindowOpenHandler(({ url }) => {
if (url.includes('krunker.io')) {
if (isGameURL(url)) {
win.loadURL(url);
} else {
if (getGameConf().socialTabBehaviour === 'New Window') {
openSubWindow(url);
} else {
win.loadURL(url);
}
}
} else {
setImmediate(() => shell.openExternal(url));
}
return { action: 'deny' };
});
// ── Inject scripts after page loads ──
win.webContents.on('did-finish-load', () => {
electronLog.log(`[KCC] Page loaded: ${win.webContents.getURL()}`);
Promise.all([
win.webContents.insertCSS(HIDE_ADS_CSS),
win.webContents.insertCSS(ALL_CLIENT_CSS),
]).catch(() => {});
win.webContents.executeJavaScript(ESCAPE_POINTERLOCK_FIX_JS).catch((err) => electronLog.warn('[KCC] Pointerlock fix inject failed:', err));
win.webContents.executeJavaScript(CONSENT_DISMISS_MAIN_JS).catch((err) => electronLog.warn('[KCC] Consent dismiss inject failed:', err));
// Notify preload to start hooking settings (matches Crankshaft's timing)
win.webContents.send('main_did-finish-load');
});
// ── IPC handlers ──
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-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);
return result;
});
let configWriteTimer: ReturnType<typeof setTimeout> | null = null;
const pendingConfigWrites = new Map<string, unknown>();
ipcMain.handle('set-config', (_e, key: string, value: unknown) => {
// Flush immediately for keys that have side effects
if (key === 'keybinds') {
config.set(key as any, value);
cachedKeybinds = null;
return;
}
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);
}
});
ipcMain.handle('window-minimize', () => win.minimize());
ipcMain.handle('window-maximize', () => {
if (win.isMaximized()) win.unmaximize(); else win.maximize();
});
ipcMain.handle('window-close', () => win.close());
ipcMain.handle('window-is-maximized', () => win.isMaximized());
ipcMain.handle('toggle-devtools', () => win.webContents.toggleDevTools());
ipcMain.handle('inject-game-click', () => {
const [width, height] = win.getContentSize();
const x = Math.round(width / 2);
const y = Math.round(height / 2);
win.webContents.sendInputEvent({ type: 'mouseDown', x, y, button: 'left', clickCount: 1 });
win.webContents.sendInputEvent({ type: 'mouseUp', x, y, button: 'left', clickCount: 1 });
});
ipcMain.handle('get-swap-dir', () => swapDir);
ipcMain.handle('open-swap-folder', () => shell.openPath(swapDir));
// ── Ping regions IPC handler (TCP connect timing, cached 60s) ──
ipcMain.handle('ping-regions', async () => {
if (Object.keys(pingCache).length > 0 && Date.now() - pingCacheTime < 60000) {
return pingCache;
}
try {
const data = await new Promise<string>((resolve, reject) => {
httpsGet('https://matchmaker.krunker.io/ping-list?hostname=krunker.io', { rejectUnauthorized: false }, (res) => {
let body = '';
res.on('data', (chunk: string) => { body += chunk; });
res.on('end', () => resolve(body));
res.on('error', reject);
}).on('error', reject);
});
const serverIPs: Record<string, string> = JSON.parse(data);
const results: Record<string, number> = {};
async function pingWithRetry(host: string): Promise<number> {
const latency = await osPing(host);
if (latency >= 0) return latency;
const retry = await osPing(host);
return retry >= 0 ? retry : -1;
}
const promises = Object.entries(serverIPs).map(async ([server, ip]) => {
const regionName = SERVER_MAP[server] ?? server;
const host = ip.split(':')[0];
const latency = await pingWithRetry(host);
if (latency >= 0) {
results[regionName] = latency;
}
});
await Promise.allSettled(promises);
pingCache = results;
pingCacheTime = Date.now();
return results;
} catch (err) {
electronLog.error('[KCC] Ping regions error:', err);
return pingCache;
}
});
// ── Discord Rich Presence IPC handler ──
ipcMain.on('discord-update', (_e, activity: any) => {
discordRpc?.setActivity(activity);
});
// ── Verbose log IPC handler (preload forwards logs here) ──
ipcMain.on('verbose-log', (_e, level: string, ...args: unknown[]) => {
if (level === 'error') electronLog.error(...args);
else if (level === 'warn') electronLog.warn(...args);
else electronLog.log(...args);
});
// ── Userscript IPC handlers ──
ipcMain.handle('userscripts-get-dir', () => userscriptManager ? userscriptManager.dir : '');
ipcMain.handle('userscripts-open-folder', () => {
if (userscriptManager) shell.openPath(userscriptManager.dir);
});
ipcMain.handle('userscripts-scan', async () => {
if (!userscriptManager) return { scripts: [], tracker: {} };
const scripts = await userscriptManager.scanScripts();
const tracker = await userscriptManager.loadTracker(scripts);
return { scripts, tracker };
});
ipcMain.handle('userscripts-set-tracker', (_e, tracker: Record<string, boolean>) => {
if (userscriptManager) userscriptManager.saveTracker(tracker);
});
ipcMain.handle('userscripts-load-prefs', (_e, filename: string) => {
if (!userscriptManager) return {};
return userscriptManager.loadScriptPrefs(filename);
});
ipcMain.handle('userscripts-save-prefs', (_e, filename: string, prefs: Record<string, unknown>) => {
if (userscriptManager) userscriptManager.saveScriptPrefs(filename, prefs);
});
// ── Action button IPC handlers ──
ipcMain.handle('open-electron-log', () => {
shell.openPath(getLogPath('electron'));
});
ipcMain.handle('reset-swapper', async () => {
try {
const entries = await fsp.readdir(swapDir, { withFileTypes: true });
for (const entry of entries) {
await fsp.rm(join(swapDir, entry.name), { recursive: true, force: true });
}
return true;
} catch (err) {
electronLog.error('[KCC] Reset swapper failed:', err);
return false;
}
});
ipcMain.handle('restart-client', () => {
app.relaunch();
app.quit();
});
ipcMain.handle('reset-options', () => {
config.clear();
app.relaunch();
app.quit();
});
ipcMain.handle('delete-all-data', async () => {
config.clear();
const userData = app.getPath('userData');
try {
await fsp.rm(join(userData, 'logs'), { recursive: true, force: true });
} catch (err) {
electronLog.warn('[KCC] Partial data deletion failed (non-fatal):', err);
}
app.relaunch();
app.quit();
});
// ── Alt manager IPC handlers ──
ipcMain.handle('alt-list', () => config.get('accounts') || []);
ipcMain.handle('alt-save', (_e, account: SavedAccount) => {
const accounts = config.get('accounts') || [];
accounts.push(account);
config.set('accounts', accounts);
return { success: true, index: accounts.length - 1 };
});
ipcMain.handle('alt-remove', (_e, index: number) => {
const accounts = config.get('accounts') || [];
if (index < 0 || index >= accounts.length) return { success: false };
accounts.splice(index, 1);
config.set('accounts', accounts);
return { success: true };
});
ipcMain.handle('alt-rename', (_e, index: number, newLabel: string) => {
const accounts = config.get('accounts') || [];
if (index < 0 || index >= accounts.length) return { success: false };
accounts[index].label = newLabel;
config.set('accounts', accounts);
return { success: true };
});
// ── Stop page immediately on close to kill audio ──
win.on('close', () => {
win.webContents.setAudioMuted(true);
win.webContents.stop();
});
// ── Shutdown: disconnect Discord, then close log streams ──
app.on('will-quit', () => {
discordRpc?.disconnect();
electronLog.log('[KCC] Shutting down');
closeLogStreams();
});
electronLog.log('[KCC] Initialization complete — loading game');
// ── Load the game ──
win.loadURL('https://krunker.io');
}
app.on('window-all-closed', () => {
app.quit();
});
+80
View File
@@ -0,0 +1,80 @@
import { app } from 'electron';
import { join } from 'path';
import { existsSync, mkdirSync, readdirSync, unlinkSync, createWriteStream, WriteStream } from 'fs';
const LOG_RETENTION_DAYS = 7;
let electronStream: WriteStream;
let electronPath: string;
let ready = false;
function dateStamp(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function pruneOldLogs(logDir: string): void {
try {
const cutoff = Date.now() - LOG_RETENTION_DAYS * 86400000;
for (const file of readdirSync(logDir)) {
const m = file.match(/^electron-(\d{4}-\d{2}-\d{2})\.log$/);
if (!m) continue;
const fileDate = new Date(m[1] + 'T00:00:00').getTime();
if (fileDate < cutoff) {
try { unlinkSync(join(logDir, file)); } catch { /* ignore */ }
}
}
} catch { /* ignore */ }
}
function init(): void {
if (ready) return;
const logDir = join(app.getPath('userData'), 'logs');
if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });
pruneOldLogs(logDir);
const stamp = dateStamp();
electronPath = join(logDir, `electron-${stamp}.log`);
// Append to today's log — one file per day, multiple sessions
electronStream = createWriteStream(electronPath, { flags: 'a' });
const sep = `\n${'='.repeat(60)}\n Session started ${new Date().toISOString()}\n${'='.repeat(60)}\n`;
electronStream.write(sep);
ready = true;
}
function ts(): string {
return new Date().toISOString();
}
function fmt(...args: unknown[]): string {
return args.map(a => {
if (a instanceof Error) return `${a.message}\n${a.stack}`;
if (typeof a === 'string') return a;
try { return JSON.stringify(a); } catch { return String(a); }
}).join(' ');
}
function makeLogger(getStream: () => WriteStream) {
return {
log: (...args: unknown[]) => { init(); const m = fmt(...args); console.log(m); if (!closed) getStream().write(`[${ts()}] ${m}\n`); },
warn: (...args: unknown[]) => { init(); const m = fmt(...args); console.warn(m); if (!closed) getStream().write(`[${ts()}] WARN: ${m}\n`); },
error: (...args: unknown[]) => { init(); const m = fmt(...args); console.error(m); if (!closed) getStream().write(`[${ts()}] ERROR: ${m}\n`); },
};
}
export const electronLog = makeLogger(() => electronStream);
export function getLogPath(type: 'electron'): string {
init();
return electronPath;
}
let closed = false;
export function closeLogStreams(): void {
closed = true;
if (electronStream) electronStream.end();
}
+128
View File
@@ -0,0 +1,128 @@
import { app } from 'electron';
import type { AppConfig } from './config';
export type Platform = 'win32' | 'linux' | 'darwin';
export type GpuBackend = 'angle' | 'opengl' | 'vulkan' | 'default';
export interface PlatformInfo {
os: Platform;
isWindows: boolean;
isLinux: boolean;
useNativeTitlebar: boolean;
gpuBackend: GpuBackend;
}
export function detectPlatform(): PlatformInfo {
const os = process.platform as Platform;
const isWindows = os === 'win32';
const isLinux = os === 'linux';
return {
os,
isWindows,
isLinux,
useNativeTitlebar: isLinux,
gpuBackend: isWindows ? 'angle' : 'default',
};
}
export function applyPlatformFlags(info: PlatformInfo, advanced: AppConfig['advanced'], performance: AppConfig['performance']): void {
// ── FPS uncap ──
// disable-frame-rate-limit causes compositor CPU spin on Chromium 84+, starving
// input events. On Electron 42 (Chromium 147), this is fixed by a patch to
// cc/scheduler/scheduler.cc in our custom Electron build. The latency recovery
// flags below are no-ops on Chromium 94+ (features were removed), but are
// harmless to keep — Chromium ignores unknown feature flags.
if (performance.fpsUnlocked) {
app.commandLine.appendSwitch('disable-frame-rate-limit');
app.commandLine.appendSwitch('disable-gpu-vsync');
app.commandLine.appendSwitch('max-gum-fps', '9999');
app.commandLine.appendSwitch('enable-features', 'ImplLatencyRecovery,MainLatencyRecovery');
}
// ── Always-on platform flags ──
app.commandLine.appendSwitch('disable-backgrounding-occluded-windows');
// WebGL is mandatory for Krunker — force it past any GPU blocklist.
// On Chromium 134+ the blocklist is stricter and silently disables WebGL on many Linux GPUs.
app.commandLine.appendSwitch('ignore-gpu-blocklist');
// ── ANGLE backend ──
// 'default' means platform default: D3D11 on Windows, no override on Linux
if (advanced.angleBackend && advanced.angleBackend !== 'default') {
app.commandLine.appendSwitch('use-angle', advanced.angleBackend);
} else if (info.isWindows) {
app.commandLine.appendSwitch('use-angle', 'd3d11');
}
if (info.isWindows) {
app.commandLine.appendSwitch('disable-features', 'CalculateNativeWinOcclusion,HardwareMediaKeyHandling');
}
if (info.isLinux) {
app.commandLine.appendSwitch('ozone-platform-hint', 'auto');
// GPU sandbox can fail inside AppImage FUSE mounts and on certain Mesa driver versions,
// causing the GPU process to crash and leaving a black screen.
app.commandLine.appendSwitch('disable-gpu-sandbox');
}
// ── Remove useless features ──
if (advanced.removeUselessFeatures) {
app.commandLine.appendSwitch('disable-breakpad');
app.commandLine.appendSwitch('disable-print-preview');
app.commandLine.appendSwitch('disable-metrics-reporting');
app.commandLine.appendSwitch('disable-metrics');
app.commandLine.appendSwitch('disable-2d-canvas-clip-aa');
app.commandLine.appendSwitch('disable-logging');
app.commandLine.appendSwitch('disable-hang-monitor');
app.commandLine.appendSwitch('disable-component-update');
}
// ── GPU rasterization ──
// OOP rasterization is always-on when GPU rasterization is enabled (Chromium 100+)
if (advanced.gpuRasterizing) {
app.commandLine.appendSwitch('enable-gpu-rasterization');
app.commandLine.appendSwitch('disable-zero-copy');
}
// ── Helpful flags ──
if (advanced.helpfulFlags) {
app.commandLine.appendSwitch('enable-javascript-harmony');
app.commandLine.appendSwitch('enable-future-v8-vm-features');
app.commandLine.appendSwitch('enable-webgl');
app.commandLine.appendSwitch('disable-background-timer-throttling');
app.commandLine.appendSwitch('disable-renderer-backgrounding');
app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required');
}
// ── Disable accelerated 2D canvas ──
if (advanced.disableAccelerated2D) {
app.commandLine.appendSwitch('disable-accelerated-2d-canvas');
}
// ── Increase limits ──
if (advanced.increaseLimits) {
app.commandLine.appendSwitch('renderer-process-limit', '100');
app.commandLine.appendSwitch('max-active-webgl-contexts', '100');
app.commandLine.appendSwitch('webrtc-max-cpu-consumption-percentage', '100');
app.commandLine.appendSwitch('ignore-gpu-blocklist');
}
// ── Low latency ──
// High-res timers and QUIC are default on Chromium 100+. Accelerated 2D canvas
// is default on Chromium 42+. These enable flags were removed from the source.
if (advanced.lowLatency) {
app.commandLine.appendSwitch('force-high-performance-gpu');
}
// ── Experimental flags ──
// Removed dead flags: enable-accelerated-video-decode (default since Chromium 132),
// enable-native-gpu-memory-buffers (Linux-only), high-dpi-support (removed in ~M54,
// HiDPI is default since M108). Renamed ignore-gpu-blacklist → ignore-gpu-blocklist.
if (advanced.experimentalFlags) {
app.commandLine.appendSwitch('disable-low-end-device-mode');
app.commandLine.appendSwitch('ignore-gpu-blocklist');
app.commandLine.appendSwitch('no-pings');
app.commandLine.appendSwitch('no-proxy-server');
}
}
+95
View File
@@ -0,0 +1,95 @@
import { existsSync, mkdirSync, promises as fsp } from 'fs';
import { join } from 'path';
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.
*/
export function initSwapperProtocol(): void {
protocol.registerSchemesAsPrivileged([{
scheme: PROTOCOL_NAME,
privileges: { secure: true, corsEnabled: true, bypassCSP: true },
}]);
}
/**
* Register the file protocol handler. 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);
});
}
/**
* 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;
}
/** 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 `${PROTOCOL_NAME}:/${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 (err) {
console.error(`Failed to scan swap directory prefix: ${prefix}`);
}
}
}
+96
View File
@@ -0,0 +1,96 @@
import { BrowserWindow } from 'electron';
const UPDATE_HTML = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #1a1a2e;
color: #e0e0e0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
padding: 20px;
}
h2 {
font-size: 16px;
font-weight: 600;
margin-bottom: 16px;
color: #fff;
}
#status {
font-size: 13px;
margin-bottom: 12px;
color: #ccc;
text-align: center;
}
.progress-container {
width: 100%;
height: 8px;
background: #16213e;
border-radius: 4px;
overflow: hidden;
}
.progress-bar {
height: 100%;
width: 0%;
background: #0f3460;
border-radius: 4px;
transition: width 0.3s ease;
}
</style>
</head>
<body>
<h2>Krunker Civilian Client</h2>
<div id="status">Checking for updates...</div>
<div class="progress-container">
<div class="progress-bar" id="progressBar"></div>
</div>
<script>
const { ipcRenderer } = require('electron');
const statusEl = document.getElementById('status');
const progressBar = document.getElementById('progressBar');
ipcRenderer.on('update-progress', function(event, message, percent) {
statusEl.textContent = message;
if (typeof percent === 'number') {
progressBar.style.width = percent + '%';
}
});
</script>
</body>
</html>`;
const UPDATE_DATA_URL = 'data:text/html;charset=utf-8,' + encodeURIComponent(UPDATE_HTML);
export function showUpdateWindow(): { window: BrowserWindow; sendProgress: (message: string, percent?: number) => void } {
const win = new BrowserWindow({
width: 450,
height: 180,
resizable: false,
alwaysOnTop: true,
backgroundColor: '#1a1a2e',
autoHideMenuBar: true,
title: 'Krunker Civilian Client - Update',
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
sandbox: false,
},
});
win.removeMenu();
win.loadURL(UPDATE_DATA_URL);
function sendProgress(message: string, percent?: number): void {
if (!win.isDestroyed()) {
win.webContents.send('update-progress', message, percent);
}
}
return { window: win, sendProgress };
}
+212
View File
@@ -0,0 +1,212 @@
import { get as httpsGet, request as httpsRequest } from 'https';
import { createWriteStream, renameSync, unlinkSync, existsSync } from 'fs';
import { spawn } from 'child_process';
import { app } from 'electron';
import { electronLog } from './logger';
export interface UpdateInfo {
version: string;
downloadUrl: string;
fileSize: number;
}
export type ProgressCallback = (percent: number) => void;
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,
};
const CHECK_TIMEOUT_MS = 10000;
const DOWNLOAD_TIMEOUT_MS = 300000; // 5 minutes
/**
* Simple semver comparison: returns true if a < b.
* Handles versions like "0.1.0", "1.2.3".
*/
function versionLessThan(a: string, b: string): boolean {
const pa = a.split('.').map(Number);
const pb = b.split('.').map(Number);
const len = Math.max(pa.length, pb.length);
for (let i = 0; i < len; i++) {
const na = pa[i] || 0;
const nb = pb[i] || 0;
if (na < nb) return true;
if (na > nb) return false;
}
return false;
}
export function checkForUpdate(currentVersion: string): Promise<UpdateInfo | null> {
return new Promise((resolve) => {
electronLog.log('[KCC-Update] Checking for updates at:', UPDATE_CONFIG.checkUrl);
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
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,
headers: { 'User-Agent': 'KrunkerCivilianClient/' + currentVersion },
}, (redirectRes) => {
electronLog.log('[KCC-Update] Redirect response status:', redirectRes.statusCode);
handleResponse(redirectRes);
}).on('error', (err) => {
electronLog.error('[KCC-Update] Redirect error:', err);
resolve(null);
});
return;
}
handleResponse(res);
});
function handleResponse(res: import('http').IncomingMessage): void {
if (res.statusCode !== 200) {
electronLog.error('[KCC-Update] Check returned status', res.statusCode);
resolve(null);
return;
}
let data = '';
res.on('data', (chunk: string) => { data += chunk; });
res.on('end', () => {
try {
const release = JSON.parse(data);
const tagName: string = release.tag_name || '';
const remoteVersion = tagName.replace(/^v/i, '');
electronLog.log('[KCC-Update] Latest release:', remoteVersion, '| Current:', currentVersion);
if (!remoteVersion || !versionLessThan(currentVersion, remoteVersion)) {
electronLog.log('[KCC-Update] Already up to date');
resolve(null);
return;
}
const assets: Array<{ name: string; browser_download_url: string; size: number }> = release.assets || [];
const setupAsset = assets.find((a) => UPDATE_CONFIG.assetPattern.test(a.name));
if (!setupAsset) {
electronLog.error('[KCC-Update] No Setup.exe asset found in release', remoteVersion);
resolve(null);
return;
}
electronLog.log('[KCC-Update] Update available:', remoteVersion, '| Download:', setupAsset.browser_download_url, '| Size:', setupAsset.size);
resolve({
version: remoteVersion,
downloadUrl: setupAsset.browser_download_url,
fileSize: setupAsset.size,
});
} catch (err) {
electronLog.error('[KCC-Update] Failed to parse release data:', err);
resolve(null);
}
});
res.on('error', (err) => {
electronLog.error('[KCC-Update] Response error:', err);
resolve(null);
});
}
req.setTimeout(CHECK_TIMEOUT_MS, () => {
electronLog.error('[KCC-Update] Check timed out after', CHECK_TIMEOUT_MS, 'ms');
req.destroy();
resolve(null);
});
req.on('error', (err) => {
electronLog.error('[KCC-Update] Check error:', err);
resolve(null);
});
});
}
export function downloadUpdate(url: string, destPath: string, onProgress: ProgressCallback): Promise<void> {
return new Promise((resolve, reject) => {
const tmpPath = destPath + '.tmp';
function doDownload(downloadUrl: string): void {
electronLog.log('[KCC-Update] Downloading from:', downloadUrl);
const req = httpsGet(downloadUrl, {
rejectUnauthorized: UPDATE_CONFIG.rejectUnauthorized,
headers: { 'User-Agent': 'KrunkerCivilianClient' },
}, (res) => {
// Follow redirects
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);
return;
}
if (res.statusCode !== 200) {
electronLog.error('[KCC-Update] Download returned status', res.statusCode, 'from:', downloadUrl);
reject(new Error('Download returned status ' + res.statusCode));
return;
}
const total = parseInt(res.headers['content-length'] || '0', 10);
let received = 0;
const file = createWriteStream(tmpPath);
res.on('data', (chunk: Buffer) => {
received += chunk.length;
if (total > 0) {
onProgress(Math.round(100 * received / total));
}
});
res.pipe(file);
file.on('finish', () => {
file.close(() => {
try {
if (existsSync(destPath)) unlinkSync(destPath);
renameSync(tmpPath, destPath);
resolve();
} catch (err) {
reject(err);
}
});
});
file.on('error', (err) => {
try { unlinkSync(tmpPath); } catch { /* ignore */ }
reject(err);
});
res.on('error', (err) => {
try { unlinkSync(tmpPath); } catch { /* ignore */ }
reject(err);
});
});
req.setTimeout(DOWNLOAD_TIMEOUT_MS, () => {
req.destroy();
try { unlinkSync(tmpPath); } catch { /* ignore */ }
reject(new Error('Download timed out'));
});
req.on('error', (err) => {
try { unlinkSync(tmpPath); } catch { /* ignore */ }
reject(err);
});
}
doDownload(url);
});
}
export function installUpdate(installerPath: string): void {
electronLog.log('[KCC-Update] Launching installer:', installerPath);
const child = spawn(installerPath, [], {
detached: true,
stdio: 'ignore',
});
child.unref();
app.quit();
}
+99
View File
@@ -0,0 +1,99 @@
import { mkdirSync, promises as fsp } from 'fs';
import { join, parse } from 'path';
export interface ScriptFile {
filename: string;
content: string;
fullpath: string;
}
export type ScriptTracker = Record<string, boolean>;
/**
* Manages userscript files, tracker state, and per-script preferences.
* Scripts live in a `scripts/` subdirectory; tracker.json records enabled/disabled state;
* per-script preferences are stored in `scripts/preferences/<name>.json`.
*/
export class UserscriptManager {
private scriptsDir: string;
private prefsDir: string;
private trackerPath: string;
constructor(baseDir: string) {
this.scriptsDir = join(baseDir, 'scripts');
this.prefsDir = join(this.scriptsDir, 'preferences');
this.trackerPath = join(this.scriptsDir, 'tracker.json');
mkdirSync(this.scriptsDir, { recursive: true });
mkdirSync(this.prefsDir, { recursive: true });
}
get dir(): string {
return this.scriptsDir;
}
/** Read all .js files from the scripts directory */
async scanScripts(): Promise<ScriptFile[]> {
const scripts: ScriptFile[] = [];
try {
for (const entry of await fsp.readdir(this.scriptsDir, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith('.js')) continue;
const fullpath = join(this.scriptsDir, entry.name);
try {
const content = await fsp.readFile(fullpath, 'utf-8');
scripts.push({ filename: entry.name, content, fullpath });
} catch { /* skip unreadable files */ }
}
} catch { /* directory read failed */ }
return scripts;
}
/** Load tracker.json, add new scripts as disabled, prune deleted scripts */
async loadTracker(scripts: ScriptFile[]): Promise<ScriptTracker> {
let tracker: ScriptTracker = {};
try {
tracker = JSON.parse(await fsp.readFile(this.trackerPath, 'utf-8'));
} catch { tracker = {}; }
const filenames = new Set(scripts.map(s => s.filename));
let dirty = false;
// Add new scripts as disabled
for (const name of filenames) {
if (!(name in tracker)) { tracker[name] = false; dirty = true; }
}
// Prune deleted scripts
for (const name of Object.keys(tracker)) {
if (!filenames.has(name)) { delete tracker[name]; dirty = true; }
}
if (dirty) await this.saveTracker(tracker);
return tracker;
}
/** Write tracker.json */
async saveTracker(tracker: ScriptTracker): Promise<void> {
try {
await fsp.writeFile(this.trackerPath, JSON.stringify(tracker, null, 2), 'utf-8');
} catch { /* write failed */ }
}
/** Load per-script preferences from preferences/<name>.json */
async loadScriptPrefs(filename: string): Promise<Record<string, unknown>> {
const name = parse(filename).name;
const prefsPath = join(this.prefsDir, name + '.json');
try {
return JSON.parse(await fsp.readFile(prefsPath, 'utf-8'));
} catch { /* parse failed or file not found */ }
return {};
}
/** Save per-script preferences to preferences/<name>.json */
async saveScriptPrefs(filename: string, prefs: Record<string, unknown>): Promise<void> {
const name = parse(filename).name;
const prefsPath = join(this.prefsDir, name + '.json');
try {
await fsp.writeFile(prefsPath, JSON.stringify(prefs, null, 2), 'utf-8');
} catch { /* write failed */ }
}
}