initial commit
Build and Release / build-and-release (push) Has been cancelled

This commit is contained in:
2026-04-03 15:33:20 -07:00
commit aeabddcf3a
41 changed files with 16061 additions and 0 deletions
+683
View File
@@ -0,0 +1,683 @@
// ── Shared CSS theme variables (used by both main page and tab bar) ──
export const THEME_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;
}
`;
// ── Injected CSS for client settings in Krunker's settings panel ──
export const CLIENT_SETTINGS_CSS = `
${THEME_CSS}
/* ── 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:not(.searching) {
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: var(--kpc-red);
}
.matchmakerPopupButton:hover {
cursor: pointer;
border-color: white !important;
transform: scale(0.95);
}
.matchmakerPopupButton:active {
transform: scale(0.85);
}
/* ── Search phase ── */
#matchmakerPopupContainer.searching {
background-image: none !important;
background: var(--kpc-surface-raised);
width: 24em;
aspect-ratio: auto;
padding: 1em 1.5em;
}
#matchmakerPopupContainer.searching #matchmakerPopupTitle,
#matchmakerPopupContainer.searching #matchmakerPopupDescription,
#matchmakerPopupContainer.searching #matchmakerPopupOptions {
display: none;
}
#matchmakerPopupContainer:not(.searching) #matchmakerSearchContainer {
display: none;
}
#matchmakerSearchStatus {
font-size: 1.4em;
color: var(--kpc-blue);
margin-bottom: 0.6em;
text-align: center;
}
#matchmakerSearchFeed {
display: flex;
flex-direction: column;
gap: 0.15em;
overflow: hidden;
min-height: 5.6em;
margin-bottom: 0.6em;
}
@keyframes mmFeedSlideIn {
from { opacity: 0; transform: translateX(1em); }
to { opacity: 1; transform: translateX(0); }
}
.mm-feed-entry {
display: flex;
gap: 0.8em;
padding: 0.2em 0.5em;
font-size: 0.95em;
font-family: 'GameFont', monospace;
border-radius: 0.2em;
animation: mmFeedSlideIn 0.12s ease forwards;
}
.mm-feed-entry.mm-pass { background: rgba(76,175,80,0.1); }
.mm-feed-entry.mm-pass .mm-feed-region { color: var(--kpc-blue); }
.mm-feed-entry.mm-pass .mm-feed-map { color: var(--kpc-text-primary, rgba(255,255,255,0.9)); }
.mm-feed-entry.mm-pass .mm-feed-players { color: var(--kpc-green); }
.mm-feed-entry.mm-fail { background: rgba(255,255,255,0.02); }
.mm-feed-entry.mm-fail .mm-feed-region { color: var(--kpc-text-dim, rgba(255,255,255,0.3)); }
.mm-feed-entry.mm-fail .mm-feed-map { color: var(--kpc-text-muted, rgba(255,255,255,0.5)); }
.mm-feed-entry.mm-fail .mm-feed-players { color: var(--kpc-red); }
.mm-feed-entry:last-child::before {
content: '\\25B8 ';
color: var(--kpc-yellow);
}
.mm-feed-region { min-width: 2.5em; font-weight: bold; }
.mm-feed-map { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mm-feed-players { min-width: 3em; text-align: right; font-weight: 600; }
#matchmakerSearchCounter {
font-size: 0.85em;
color: var(--kpc-yellow);
text-align: center;
margin-bottom: 0.5em;
}
#matchmakerSearchCancel {
text-align: center;
border: 0.2em solid var(--kpc-red);
color: white;
border-radius: 0.3em;
font-size: 1.1em;
background: rgba(0,0,0,0.3);
padding: 0.2em 1.2em;
cursor: pointer;
margin: 0 auto;
width: fit-content;
transition: all 0.08s;
}
#matchmakerSearchCancel:hover {
border-color: white;
transform: scale(0.95);
}
#matchmakerSearchCancel:active {
transform: scale(0.85);
}
`;
export const TRANSLATOR_CSS = `
.kcc-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;
}
`;
// ── HP enemy counter CSS ──
export const HP_COUNTER_CSS = `
.kpc-hp-counter .pointVal {
color: #ff4444; font-size: 15px; font-weight: bold;
}
`;
/** 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}\n${HP_COUNTER_CSS}`;
+228
View File
@@ -0,0 +1,228 @@
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';
cpuThrottleGame: number;
cpuThrottleMenu: number;
processPriority: string;
};
game: {
lastServer: string;
socialTabBehaviour: 'New Window' | 'Same Window';
joinAsSpectator: boolean;
rawInput: boolean;
betterChat: boolean;
chatHistorySize: number;
showPing: boolean;
hpEnemyCounter: boolean;
};
swapper: {
enabled: boolean;
path: string;
};
matchmaker: {
enabled: boolean;
regions: string[];
gamemodes: string[];
maps: string[];
minPlayers: number;
maxPlayers: number;
minRemainingTime: number;
openServerBrowser: boolean;
autoJoin: 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;
cleanerMenu: boolean;
doublePing: boolean;
cssTheme: string;
loadingTheme: string;
backgroundUrl: string;
showChangelog: boolean;
lastSeenVersion: string;
};
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;
verboseLogging: boolean;
};
accounts: SavedAccount[];
tabWindow: {
width: number;
height: number;
x: number | undefined;
y: number | undefined;
maximized: boolean;
};
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',
cpuThrottleGame: 1,
cpuThrottleMenu: 1.5,
processPriority: 'Normal',
},
game: {
lastServer: '',
socialTabBehaviour: 'New Window',
joinAsSpectator: false,
rawInput: true,
betterChat: true,
chatHistorySize: 200,
showPing: true,
hpEnemyCounter: true,
},
swapper: {
enabled: true,
path: '',
},
matchmaker: {
enabled: true,
regions: [],
gamemodes: [],
maps: [],
minPlayers: 1,
maxPlayers: 6,
minRemainingTime: 120,
openServerBrowser: true,
autoJoin: false,
},
keybinds: DEFAULT_KEYBINDS,
userscripts: {
enabled: true,
path: '',
},
ui: {
showExitButton: true,
deathscreenAnimation: true,
hideMenuPopups: false,
cleanerMenu: false,
doublePing: true,
cssTheme: 'disabled',
loadingTheme: 'disabled',
backgroundUrl: '',
showChangelog: true,
lastSeenVersion: '',
},
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',
verboseLogging: false,
},
accounts: [],
tabWindow: {
width: 1280,
height: 720,
x: undefined,
y: undefined,
maximized: true,
},
platform: {
detectedOS: platformInfo.os,
gpuBackend: platformInfo.gpuBackend,
},
},
});
+131
View File
@@ -0,0 +1,131 @@
// ── CSS theme & loading screen background management ──
// Scans swap directory for user CSS themes and loading screen backgrounds.
import { readdirSync, readFileSync } from 'fs';
import { join, extname, basename } from 'path';
export interface ThemeEntry {
id: string;
label: string;
}
export interface LoadingThemeEntry {
id: string;
label: string;
}
export function listThemes(swapDir: string): ThemeEntry[] {
const entries: ThemeEntry[] = [{ id: 'disabled', label: 'Disabled' }];
const themesDir = join(swapDir, 'themes');
try {
const files = readdirSync(themesDir);
for (const file of files) {
if (extname(file).toLowerCase() === '.css') {
entries.push({ id: `user:${file}`, label: basename(file, '.css') });
}
}
} catch { /* themes dir doesn't exist yet — that's fine */ }
return entries;
}
export function getThemeCSS(themeId: string, swapDir: string): string {
if (themeId === 'disabled' || !themeId) return '';
const prefix = 'user:';
if (!themeId.startsWith(prefix)) return '';
const filename = themeId.slice(prefix.length);
try {
return readFileSync(join(swapDir, 'themes', filename), 'utf-8');
} catch { return ''; }
}
const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp']);
export function listLoadingThemes(swapDir: string): LoadingThemeEntry[] {
const entries: LoadingThemeEntry[] = [
{ id: 'disabled', label: 'Disabled (Default)' },
{ id: 'swap:random', label: 'Random (from backgrounds/)' },
];
const bgDir = join(swapDir, 'backgrounds');
try {
const files = readdirSync(bgDir);
for (const file of files) {
if (IMAGE_EXTS.has(extname(file).toLowerCase())) {
entries.push({ id: `swap:${file}`, label: file });
}
}
} catch { /* backgrounds dir doesn't exist yet */ }
return entries;
}
function mimeFromExt(ext: string): string {
switch (ext.toLowerCase()) {
case '.jpg':
case '.jpeg':
return 'image/jpeg';
case '.gif':
return 'image/gif';
case '.webp':
return 'image/webp';
default:
return 'image/png';
}
}
function getBackgroundFiles(swapDir: string): string[] {
const bgDir = join(swapDir, 'backgrounds');
try {
return readdirSync(bgDir).filter(f => IMAGE_EXTS.has(extname(f).toLowerCase()));
} catch { return []; }
}
function fileToDataUri(filePath: string): string {
const data = readFileSync(filePath);
const mime = mimeFromExt(extname(filePath));
return `data:${mime};base64,${data.toString('base64')}`;
}
export function getLoadingScreenCSS(loadingTheme: string, backgroundUrl: string, swapDir: string): string {
let imageUrl = '';
// Explicit URL takes priority
if (backgroundUrl) {
try {
new URL(backgroundUrl);
imageUrl = `url(${backgroundUrl})`;
} catch { /* invalid URL — ignore */ }
}
if (!imageUrl && loadingTheme && loadingTheme !== 'disabled') {
const bgDir = join(swapDir, 'backgrounds');
if (loadingTheme === 'swap:random') {
const files = getBackgroundFiles(swapDir);
if (files.length > 0) {
const pick = files[Math.floor(Math.random() * files.length)];
try {
imageUrl = `url(${fileToDataUri(join(bgDir, pick))})`;
} catch { /* read failed */ }
}
} else if (loadingTheme.startsWith('swap:')) {
const filename = loadingTheme.slice(5);
try {
imageUrl = `url(${fileToDataUri(join(bgDir, filename))})`;
} catch { /* read failed */ }
}
}
if (!imageUrl) return '';
return `
#instructionHolder[style^="display: block"] {
background-image: initial !important;
}
#instructionHolder {
background-image: ${imageUrl} !important;
background-size: cover !important;
background-position: center !important;
}
#instructions {
display: block;
visibility: hidden;
}`;
}
+285
View File
@@ -0,0 +1,285 @@
import { Socket } from 'net';
import { electronLog } from './logger';
const DISCORD_CLIENT_ID = '1477679025248800982';
// 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);
}
}
+843
View File
@@ -0,0 +1,843 @@
import { app, BrowserWindow, Menu, clipboard, ipcMain, safeStorage, 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 * as os from 'os';
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';
import { listThemes, getThemeCSS, listLoadingThemes, getLoadingScreenCSS } from './css-themes';
import { TabManager } from './tab-manager';
// ── App version for API calls ──
// eslint-disable-next-line @typescript-eslint/no-require-imports
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);
// ── App identity (must match electron-builder appId for taskbar pin persistence) ──
app.setAppUserModelId('com.krunkercivilian.client');
// ── 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);`;
// ── Safe external URL opener (only http/https) ──
function safeOpenExternal(url: string): void {
try {
const parsed = new URL(url);
if (parsed.protocol === 'https:' || parsed.protocol === 'http:') {
shell.openExternal(url);
}
} catch { /* malformed URL — ignore */ }
}
// ── Keybind matching ──
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');
// ── 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');
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'), 'Krunker Civilian Client');
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) ──
// The broad *://*.krunker.io/* pattern lets the swapper intercept any krunker asset.
// swapper.getRedirect() returns null before its async scan completes, so swapped
// resources simply pass through until the scan finishes — no re-registration needed.
const requestFilterUrls = swapper
? [...BLOCKED_URL_PATTERNS, '*://*.krunker.io/*']
: [...BLOCKED_URL_PATTERNS];
ses.webRequest.onBeforeRequest({ urls: requestFilterUrls }, (details, callback) => {
// Check swapper first — redirect matching assets to local files
if (swapper) {
const redirect = swapper.getRedirect(details.url);
if (redirect) return callback({ redirectURL: redirect });
}
// Determine if this URL is a krunker.io request (matched by the broad swapper pattern)
// vs an ad-block pattern. krunker.io requests that weren't swapped pass through normally.
try {
if (new URL(details.url).hostname.endsWith('krunker.io')) return callback({});
} catch { /* invalid URL — fall through to cancel */ }
// Matched an ad-block pattern — cancel it
callback({ cancel: true });
});
if (swapper) {
swapper.waitForReady().then(() => {
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');
}
}
// ── Process Priority (Windows only) ──
if (process.platform === 'win32') {
const PRIORITY_MAP: Record<string, number> = {
'High': -14,
'Above Normal': -7,
'Below Normal': 7,
'Low': 19,
};
const prioritySetting = config.get('performance')?.processPriority || 'Normal';
const priorityVal = PRIORITY_MAP[prioritySetting];
if (priorityVal !== undefined) {
try { os.setPriority(process.pid, priorityVal); } catch { /* ignore */ }
// Apply to child processes periodically
setInterval(() => {
for (const m of app.getAppMetrics()) {
if (m.pid !== process.pid) {
try { os.setPriority(m.pid, priorityVal); } catch { /* ignore */ }
}
}
}, 1000);
electronLog.log(`[KCC] Process priority set to ${prioritySetting}`);
}
}
// ── CPU Throttling via Chrome DevTools Protocol ──
const throttledContents = new WeakSet<Electron.WebContents>();
function applyCpuThrottle(wc: Electron.WebContents, rate: number): void {
const clamped = Math.max(1, Math.min(3, rate));
try {
if (!throttledContents.has(wc)) {
wc.debugger.attach('1.3');
throttledContents.add(wc);
}
wc.debugger.sendCommand('Emulation.setCPUThrottlingRate', { rate: clamped });
} catch { /* debugger may already be attached or detached */ }
}
// ── Keybind capture lock (suppresses shortcuts while the keybind dialog is open) ──
let keybindCapturing = false;
ipcMain.on('keybind-capture', (_e, capturing: boolean) => {
keybindCapturing = capturing;
});
// ── Configurable keybinds via before-input-event ──
win.webContents.on('before-input-event', (event, input) => {
if (input.type !== 'keyDown') return;
if (keybindCapturing) return;
const binds = getKeybinds();
if (matchesKeybind(input, binds.reload)) {
win.reload();
event.preventDefault();
} else if (matchesKeybind(input, binds.newMatch)) {
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.joinFromClipboard)) {
const text = clipboard.readText();
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());
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();
} else if (input.key === 't' && input.control && !input.shift && !input.alt) {
tabManager.openTab('https://krunker.io/social.html');
event.preventDefault();
} else if (input.key === 'T' && input.control && input.shift && !input.alt) {
tabManager.reopenTab();
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));
// ── URL classification ──
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; }
}
// ── 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;
}
// ── Tab Manager ──
const preloadPath = join(__dirname, '..', 'preload', 'index.js');
let tabMode: 'same' | 'new' = getGameConf().socialTabBehaviour === 'Same Window' ? 'same' : 'new';
let tabManager = new TabManager(
win, ses, preloadPath, tabMode, isGameURL,
() => config.get('tabWindow'),
(state) => config.set('tabWindow', state),
);
// Intercept in-page navigation (e.g. window.location = '/social.html')
win.webContents.on('will-navigate', (event, url) => {
if (url.includes('krunker.io') && !isGameURL(url)) {
event.preventDefault();
tabManager.openTab(url);
}
});
// Intercept target="_blank" / window.open links
win.webContents.setWindowOpenHandler(({ url }) => {
if (url.includes('krunker.io')) {
if (isGameURL(url)) {
win.loadURL(url);
} else {
setImmediate(() => tabManager.openTab(url));
}
} else {
setImmediate(() => safeOpenExternal(url));
}
return { action: 'deny' };
});
// Right-click context menu on main window with "Open in New Tab"
win.webContents.on('context-menu', (_e, params) => {
if (!params.linkURL) return;
const items: Electron.MenuItemConstructorOptions[] = [];
if (params.linkURL.includes('krunker.io') && !isGameURL(params.linkURL)) {
items.push({ label: 'Open in New Tab', click: () => tabManager.openTab(params.linkURL) });
}
items.push({ label: 'Copy Link', click: () => clipboard.writeText(params.linkURL) });
if (!params.linkURL.includes('krunker.io')) {
items.push({ label: 'Open in Browser', click: () => safeOpenExternal(params.linkURL) });
}
if (items.length) Menu.buildFromTemplate(items).popup();
});
// ── 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(() => {});
const cssInjections = [
win.webContents.insertCSS(HIDE_ADS_CSS),
win.webContents.insertCSS(ALL_CLIENT_CSS),
];
// Inject user CSS theme
const uiConf = config.get('ui');
const themeCSS = getThemeCSS(uiConf?.cssTheme || 'disabled', swapDir);
if (themeCSS) cssInjections.push(win.webContents.insertCSS(themeCSS));
// Inject loading screen background
const loadingCSS = getLoadingScreenCSS(uiConf?.loadingTheme || 'disabled', uiConf?.backgroundUrl || '', swapDir);
if (loadingCSS) cssInjections.push(win.webContents.insertCSS(loadingCSS));
Promise.all(cssInjections).catch(() => {});
// Apply initial CPU throttle (menu state)
const perf = config.get('performance');
applyCpuThrottle(win.webContents, perf?.cpuThrottleMenu ?? 1.5);
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 ──
const ALLOWED_CONFIG_KEYS = new Set<string>([
'window', 'performance', 'game', 'swapper', 'matchmaker',
'keybinds', 'userscripts', 'ui', 'discord', 'translator',
'advanced', 'accounts', 'tabWindow', 'platform',
]);
ipcMain.handle('get-version', () => appVersion);
ipcMain.handle('get-platform', () => platformInfo);
ipcMain.handle('get-config', (_e, key: string) => {
if (!ALLOWED_CONFIG_KEYS.has(key)) return undefined;
return config.get(key as keyof typeof config.store);
});
ipcMain.handle('get-all-config', (_e, keys: string[]) => {
const result: Record<string, unknown> = {};
for (const key of keys) {
if (ALLOWED_CONFIG_KEYS.has(key)) 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) => {
if (!ALLOWED_CONFIG_KEYS.has(key)) return;
// Flush immediately for keys that have side effects
if (key === 'keybinds') {
config.set(key as any, value);
cachedKeybinds = null;
return;
}
// Invalidate caches immediately (not on flush) to prevent stale reads
if (key === 'game') {
cachedGameConf = null;
// Switch tab mode if socialTabBehaviour changed
const newGame = value as any;
if (newGame?.socialTabBehaviour) {
const newMode: 'same' | 'new' = newGame.socialTabBehaviour === 'Same Window' ? 'same' : 'new';
if (newMode !== tabMode) {
tabManager.destroyAll();
tabMode = newMode;
tabManager = new TabManager(
win, ses, preloadPath, tabMode, isGameURL,
() => config.get('tabWindow'),
(state) => config.set('tabWindow', state),
);
}
}
}
pendingConfigWrites.set(key, value);
if (!configWriteTimer) {
configWriteTimer = setTimeout(() => {
for (const [k, v] of pendingConfigWrites) {
config.set(k as any, v);
}
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', (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);
});
// ── CPU throttle IPC handler ──
ipcMain.on('throttle-state', (_e, state: string) => {
const perf = config.get('performance');
const rate = state === 'game' ? (perf?.cpuThrottleGame ?? 1) : (perf?.cpuThrottleMenu ?? 1.5);
applyCpuThrottle(win.webContents, rate);
});
// ── CSS theme & loading background IPC handlers ──
ipcMain.handle('list-themes', () => listThemes(swapDir));
ipcMain.handle('get-theme-css', (_e, themeId: string) => getThemeCSS(themeId, swapDir));
ipcMain.handle('list-loading-themes', () => listLoadingThemes(swapDir));
ipcMain.handle('get-loading-screen-css', (_e, loadingTheme: string, backgroundUrl: string) => {
return getLoadingScreenCSS(loadingTheme, backgroundUrl, swapDir);
});
// ── Changelog IPC handler (fetch release notes from Gitea) ──
ipcMain.handle('changelog-fetch', async (_e, version: string) => {
const tag = version.startsWith('v') ? version : `v${version}`;
try {
const data = await new Promise<string>((resolve, reject) => {
httpsGet(`https://gitea.crjlab.net/api/v1/repos/bigjakk/Krunker-Civilian-Client/releases/tags/${tag}`, (res) => {
let body = '';
res.on('data', (chunk: string) => { body += chunk; });
res.on('end', () => resolve(body));
res.on('error', reject);
}).on('error', reject);
});
const release = JSON.parse(data);
return release.body || '';
} catch {
return '';
}
});
// ── 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 (credentials encrypted via safeStorage) ──
const canEncrypt = safeStorage.isEncryptionAvailable();
if (!canEncrypt) electronLog.warn('[KCC] safeStorage encryption not available — account passwords will use base64 fallback');
function encryptString(plaintext: string): string {
if (canEncrypt) return safeStorage.encryptString(plaintext).toString('base64');
return Buffer.from(plaintext).toString('base64');
}
function decryptString(encrypted: string): string {
if (canEncrypt) return safeStorage.decryptString(Buffer.from(encrypted, 'base64'));
return Buffer.from(encrypted, 'base64').toString();
}
ipcMain.handle('alt-list', () => {
const accounts = config.get('accounts') || [];
// Return only labels to the renderer — never send encrypted credentials
return accounts.map((a: SavedAccount) => ({ label: a.label }));
});
ipcMain.handle('alt-save', (_e, data: { label: string; username: string; password: string }) => {
const accounts = config.get('accounts') || [];
const account: SavedAccount = {
label: data.label,
username: encryptString(data.username),
password: encryptString(data.password),
};
accounts.push(account);
config.set('accounts', accounts);
return { success: true, index: accounts.length - 1 };
});
ipcMain.handle('alt-get-credentials', (_e, index: number) => {
const accounts = config.get('accounts') || [];
if (index < 0 || index >= accounts.length) return null;
const acc = accounts[index];
try {
return {
username: decryptString(acc.username),
password: decryptString(acc.password),
};
} catch (err) {
electronLog.error('[KCC] Failed to decrypt account credentials:', err);
return null;
}
});
ipcMain.handle('alt-remove', (_e, index: number) => {
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();
}
+145
View File
@@ -0,0 +1,145 @@
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');
app.commandLine.appendSwitch('disable-threaded-scrolling');
app.commandLine.appendSwitch('overscroll-history-navigation', '0');
app.commandLine.appendSwitch('pull-to-refresh', '0');
// 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-crash-reporter');
app.commandLine.appendSwitch('disable-crashpad-forwarding');
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');
app.commandLine.appendSwitch('disable-bundled-ppapi-flash');
app.commandLine.appendSwitch('disable-nacl');
app.commandLine.appendSwitch('disable-features', 'NativeNotifications,MediaRouter,PerformanceInterventionUI,HappinessTrackingSurveysForDesktopDemo');
}
// ── 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');
app.commandLine.appendSwitch('disable-software-rasterizer');
app.commandLine.appendSwitch('disable-gpu-driver-bug-workarounds');
}
// ── 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('disable-best-effort-tasks');
app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required');
app.commandLine.appendSwitch('enable-features', 'V8VmFuture,WebAssemblyBaseline,WebAssemblyTiering,WebAssemblyLazyCompilation');
}
// ── 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');
app.commandLine.appendSwitch('enable-quic');
app.commandLine.appendSwitch('quic-max-packet-length', '1460');
app.commandLine.appendSwitch('raise-timer-frequency');
}
// ── 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('disable-gpu-watchdog');
app.commandLine.appendSwitch('ignore-gpu-blocklist');
app.commandLine.appendSwitch('no-pings');
app.commandLine.appendSwitch('no-proxy-server');
app.commandLine.appendSwitch('enable-features', 'BlinkCompositorUseDisplayThreadPriority,GpuUseDisplayThreadPriority');
}
}
+131
View File
@@ -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}`);
}
}
}
+287
View File
@@ -0,0 +1,287 @@
// ── Inline HTML for the tab bar WebContentsView ──
// Rendered as a data URL. Communicates with TabManager via ipcRenderer.
import { THEME_CSS } from './client-ui';
export const TAB_BAR_HTML = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
${THEME_CSS}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--kpc-surface-dialog);
color: var(--kpc-text-primary);
height: 40px;
overflow: hidden;
display: flex;
align-items: center;
gap: 4px;
padding: 0 6px;
user-select: none;
-webkit-app-region: no-drag;
}
/* ── Shared pill style for Game btn, tabs, and New Tab btn ── */
.bar-pill {
flex-shrink: 0;
display: flex;
align-items: center;
border: 1px solid var(--kpc-toggle-off);
border-radius: 6px;
padding: 4px 10px;
cursor: pointer;
font-size: 12px;
font-weight: 500;
transition: background 0.12s, border-color 0.12s;
background: var(--kpc-surface-card);
color: var(--kpc-text-secondary);
}
.bar-pill:hover {
background: var(--kpc-surface-input);
border-color: rgba(255,255,255,0.2);
}
/* ── Game button (green accent) ── */
#gameBtn {
background: rgba(76, 175, 80, 0.12);
color: var(--kpc-green);
border-color: rgba(76, 175, 80, 0.5);
font-weight: 600;
}
#gameBtn:hover {
background: rgba(76, 175, 80, 0.25);
border-color: var(--kpc-green);
}
/* ── Tab strip ── */
#tabStrip {
flex: 1;
display: flex;
gap: 4px;
overflow-x: auto;
overflow-y: hidden;
align-items: center;
height: 100%;
padding: 4px 0;
scrollbar-width: none;
}
#tabStrip::-webkit-scrollbar { display: none; }
/* ── Tab pills ── */
.tab {
position: relative;
gap: 6px;
max-width: 200px;
min-width: 60px;
height: 28px;
}
.tab.dragging {
opacity: 0.4;
}
.tab.drop-before::before {
content: '';
position: absolute;
left: -3px;
top: 2px;
bottom: 2px;
width: 2px;
background: var(--kpc-green);
border-radius: 1px;
}
.tab.drop-after::after {
content: '';
position: absolute;
right: -3px;
top: 2px;
bottom: 2px;
width: 2px;
background: var(--kpc-green);
border-radius: 1px;
}
.tab.active {
background: rgba(76, 175, 80, 0.12);
border-color: rgba(76, 175, 80, 0.5);
color: var(--kpc-text-primary);
}
.tab.active:hover {
background: rgba(76, 175, 80, 0.2);
border-color: var(--kpc-green);
}
.tab-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tab-spinner {
width: 10px;
height: 10px;
border: 1.5px solid var(--kpc-border-medium);
border-top-color: var(--kpc-green);
border-radius: 50%;
animation: spin 0.8s linear infinite;
flex-shrink: 0;
display: none;
}
.tab.loading .tab-spinner { display: block; }
@keyframes spin { to { transform: rotate(360deg); } }
.tab-close {
flex-shrink: 0;
width: 16px;
height: 16px;
line-height: 15px;
text-align: center;
border-radius: 3px;
font-size: 13px;
color: var(--kpc-text-dim);
transition: background 0.1s, color 0.1s;
}
.tab-close:hover {
background: var(--kpc-toggle-off);
color: #fff;
}
/* ── New Tab button ── */
#newTabBtn {
width: 28px;
height: 28px;
justify-content: center;
font-size: 16px;
font-weight: 400;
color: var(--kpc-text-faint);
padding: 0;
border-style: dashed;
}
#newTabBtn:hover {
color: var(--kpc-text-primary);
}
</style>
</head>
<body>
<button id="gameBtn" class="bar-pill">Game</button>
<div id="tabStrip"></div>
<button id="newTabBtn" class="bar-pill" title="New Tab (Ctrl+T)">+</button>
<script>
const { ipcRenderer } = require('electron');
const strip = document.getElementById('tabStrip');
document.getElementById('gameBtn').addEventListener('click', () => {
ipcRenderer.send('tab-back-to-game');
});
document.getElementById('newTabBtn').addEventListener('click', () => {
ipcRenderer.send('tab-new');
});
/* ── Drag state ── */
let dragId = null;
let dragStartX = 0;
let dragging = false;
const DRAG_THRESHOLD = 5;
function clearDropIndicators() {
strip.querySelectorAll('.drop-before,.drop-after').forEach(
el => el.classList.remove('drop-before', 'drop-after')
);
}
function getDropTarget(clientX) {
const tabs = Array.from(strip.querySelectorAll('.tab'));
for (const tab of tabs) {
if (Number(tab.dataset.id) === dragId) continue;
const r = tab.getBoundingClientRect();
const mid = r.left + r.width / 2;
if (clientX < mid) return { id: Number(tab.dataset.id), side: 'before', el: tab };
}
const last = tabs[tabs.length - 1];
if (last && Number(last.dataset.id) !== dragId) {
return { id: Number(last.dataset.id), side: 'after', el: last };
}
return null;
}
document.addEventListener('mousemove', (e) => {
if (dragId === null) return;
if (!dragging && Math.abs(e.clientX - dragStartX) >= DRAG_THRESHOLD) {
dragging = true;
const el = strip.querySelector('.tab[data-id="' + dragId + '"]');
if (el) el.classList.add('dragging');
}
if (!dragging) return;
clearDropIndicators();
const target = getDropTarget(e.clientX);
if (target) target.el.classList.add(target.side === 'before' ? 'drop-before' : 'drop-after');
});
document.addEventListener('mouseup', (e) => {
if (dragId === null) return;
const wasDragging = dragging;
const srcId = dragId;
clearDropIndicators();
const dragEl = strip.querySelector('.tab.dragging');
if (dragEl) dragEl.classList.remove('dragging');
dragId = null;
dragging = false;
if (wasDragging) {
const target = getDropTarget(e.clientX);
if (target) {
ipcRenderer.send('tab-reorder', srcId, target.id, target.side);
}
}
});
ipcRenderer.on('tabs-update', (_e, tabs) => {
strip.innerHTML = '';
for (const t of tabs) {
const el = document.createElement('div');
el.className = 'bar-pill tab' + (t.active ? ' active' : '') + (t.loading ? ' loading' : '');
el.dataset.id = String(t.id);
const spinner = document.createElement('div');
spinner.className = 'tab-spinner';
el.appendChild(spinner);
const title = document.createElement('span');
title.className = 'tab-title';
title.textContent = t.title || 'Loading...';
title.title = t.title || '';
el.appendChild(title);
const close = document.createElement('span');
close.className = 'tab-close';
close.textContent = '\\u00d7';
close.addEventListener('click', (ev) => {
ev.stopPropagation();
ipcRenderer.send('tab-close', t.id);
});
el.appendChild(close);
el.addEventListener('mousedown', (ev) => {
if (ev.target.classList.contains('tab-close')) return;
dragId = t.id;
dragStartX = ev.clientX;
dragging = false;
});
el.addEventListener('click', () => {
if (!dragging) ipcRenderer.send('tab-switch', t.id);
});
strip.appendChild(el);
}
const activeEl = strip.querySelector('.tab.active');
if (activeEl) activeEl.scrollIntoView({ inline: 'nearest', block: 'nearest' });
});
</script>
</body>
</html>`;
export const TAB_BAR_DATA_URL = 'data:text/html;charset=utf-8,' + encodeURIComponent(TAB_BAR_HTML);
+666
View File
@@ -0,0 +1,666 @@
import { BrowserWindow, WebContentsView, View, Menu, clipboard, ipcMain, shell } from 'electron';
import { TAB_BAR_DATA_URL } from './tab-bar-html';
import { ALL_CLIENT_CSS } from './client-ui';
import { electronLog } from './logger';
const KRUNKER_SOCIAL = 'https://krunker.io/social.html';
const TAB_BAR_HEIGHT = 40;
const MAX_TABS = 20;
interface TabInfo {
id: number;
view: WebContentsView;
title: string;
url: string;
loading: boolean;
}
interface TabWindowState {
width: number;
height: number;
x: number | undefined;
y: number | undefined;
maximized: boolean;
}
type TabMode = 'same' | 'new';
export class TabManager {
private tabs: TabInfo[] = [];
private activeTabId: number | null = null;
private tabBarView: WebContentsView;
private containerView: View;
private tabWindow: BrowserWindow | null = null;
private visible = false;
private nextId = 1;
private mode: TabMode;
private mainWin: BrowserWindow;
private ses: Electron.Session;
private preloadPath: string;
private isGameURL: (url: string) => boolean;
private titlePolls = new Map<number, ReturnType<typeof setInterval>>();
private recentlyClosed: { url: string; title: string }[] = [];
private getTabWindowState: () => TabWindowState;
private saveTabWindowState: (state: TabWindowState) => void;
private tabSaveTimer: ReturnType<typeof setTimeout> | null = null;
constructor(
win: BrowserWindow,
ses: Electron.Session,
preloadPath: string,
mode: TabMode,
isGameURL: (url: string) => boolean,
getTabWindowState: () => TabWindowState,
saveTabWindowState: (state: TabWindowState) => void,
) {
this.mainWin = win;
this.ses = ses;
this.preloadPath = preloadPath;
this.mode = mode;
this.isGameURL = isGameURL;
this.getTabWindowState = getTabWindowState;
this.saveTabWindowState = saveTabWindowState;
// ── Tab bar view (shared between both modes) ──
this.tabBarView = new WebContentsView({
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
sandbox: false,
},
});
this.tabBarView.webContents.loadURL(TAB_BAR_DATA_URL);
// ── Container view (holds tab bar + active tab content) ──
this.containerView = new View();
this.containerView.addChildView(this.tabBarView);
// Tab bar keybinds (when tab bar itself is focused)
this.tabBarView.webContents.on('before-input-event', (event, input) => {
if (input.type !== 'keyDown') return;
if (this.handleTabShortcut(event, input)) return;
});
if (mode === 'same') {
this.initSameWindowMode();
}
// 'new' mode: tabWindow created lazily on first openTab()
this.registerIPC();
}
// ── Same Window Mode Setup ──
private initSameWindowMode(): void {
this.mainWin.contentView.addChildView(this.containerView);
this.containerView.setVisible(false);
this.visible = false;
this.mainWin.on('resize', () => this.updateLayout());
}
// ── New Window Mode: create/show the tab window ──
private ensureTabWindow(): void {
if (this.tabWindow && !this.tabWindow.isDestroyed()) return;
const saved = this.getTabWindowState();
this.tabWindow = new BrowserWindow({
width: saved.width,
height: saved.height,
x: saved.x,
y: saved.y,
frame: true,
backgroundColor: '#000000',
autoHideMenuBar: true,
title: 'KCC - Tabs',
show: false,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
},
});
this.tabWindow.removeMenu();
if (saved.maximized) this.tabWindow.maximize();
this.tabWindow.contentView.addChildView(this.containerView);
this.containerView.setVisible(true);
this.tabWindow.on('resize', () => {
this.updateLayout();
this.debounceSaveTabWindow();
});
this.tabWindow.on('move', () => this.debounceSaveTabWindow());
this.tabWindow.on('close', () => {
// Flush pending save before the window is destroyed
if (this.tabSaveTimer) clearTimeout(this.tabSaveTimer);
if (this.tabWindow && !this.tabWindow.isDestroyed()) {
const bounds = this.tabWindow.getBounds();
this.saveTabWindowState({
width: bounds.width,
height: bounds.height,
x: bounds.x,
y: bounds.y,
maximized: this.tabWindow.isMaximized(),
});
}
});
this.tabWindow.on('closed', () => {
this.destroyAllTabs();
this.tabWindow = null;
});
this.tabWindow.show();
}
private debounceSaveTabWindow(): void {
if (this.tabSaveTimer) clearTimeout(this.tabSaveTimer);
this.tabSaveTimer = setTimeout(() => {
if (!this.tabWindow || this.tabWindow.isDestroyed()) return;
const bounds = this.tabWindow.getBounds();
this.saveTabWindowState({
width: bounds.width,
height: bounds.height,
x: bounds.x,
y: bounds.y,
maximized: this.tabWindow.isMaximized(),
});
}, 1000);
}
// ── IPC from tab bar ──
private registerIPC(): void {
ipcMain.on('tab-switch', (_e, id: number) => this.switchToTab(id));
ipcMain.on('tab-close', (_e, id: number) => this.closeTab(id));
ipcMain.on('tab-new', () => this.openTab(KRUNKER_SOCIAL));
ipcMain.on('tab-reorder', (_e, fromId: number, toId: number, side: string) => {
this.reorderTab(fromId, toId, side as 'before' | 'after');
});
ipcMain.on('tab-back-to-game', () => {
if (this.mode === 'same') {
this.hideTabs();
} else {
this.mainWin.focus();
}
});
}
// ── Open a new tab ──
openTab(url: string): number {
if (this.tabs.length >= MAX_TABS) {
const existing = this.tabs.find(t => t.url === url);
if (existing) {
this.switchToTab(existing.id);
return existing.id;
}
electronLog.warn('[KCC-Tabs] Tab limit reached, ignoring openTab');
return -1;
}
const id = this.nextId++;
const view = this.createTabView(id);
const tab: TabInfo = { id, view, title: this.titleFromUrl(url), url, loading: true };
this.tabs.push(tab);
if (this.mode === 'new') {
this.ensureTabWindow();
}
this.switchToTab(id);
this.showTabs();
view.webContents.loadURL(url);
return id;
}
// ── Create a WebContentsView for a tab ──
private createTabView(tabId: number): WebContentsView {
const view = new WebContentsView({
webPreferences: {
preload: this.preloadPath,
session: this.ses,
contextIsolation: false,
nodeIntegration: false,
sandbox: false,
spellcheck: false,
},
});
const wc = view.webContents;
wc.on('did-finish-load', () => {
wc.insertCSS(ALL_CLIENT_CSS).catch(() => {});
wc.send('main_did-finish-load-tab');
ipcMain.emit('throttle-state', { sender: wc } as any, 'menu');
this.updateTabInfo(tabId, { loading: false });
this.startTitleWatcher(tabId, wc);
});
wc.on('did-start-loading', () => {
this.updateTabInfo(tabId, { loading: true });
});
wc.on('did-stop-loading', () => {
this.updateTabInfo(tabId, { loading: false });
});
wc.on('page-title-updated', (_e, title) => {
if (this.isGenericTitle(title)) return;
this.updateTabInfo(tabId, { title });
});
wc.on('did-navigate', (_e, url) => {
this.updateTabInfo(tabId, { url, title: this.titleFromUrl(url) });
});
wc.setWindowOpenHandler(({ url: linkUrl }) => {
if (linkUrl.includes('krunker.io')) {
if (this.isGameURL(linkUrl)) {
this.mainWin.loadURL(linkUrl);
if (this.mode === 'same') this.hideTabs();
else this.mainWin.focus();
} else {
setImmediate(() => this.openTab(linkUrl));
}
} else {
setImmediate(() => shell.openExternal(linkUrl));
}
return { action: 'deny' as const };
});
wc.on('will-navigate', (event, navUrl) => {
if (navUrl.includes('krunker.io') && this.isGameURL(navUrl)) {
event.preventDefault();
this.mainWin.loadURL(navUrl);
if (this.mode === 'same') this.hideTabs();
else this.mainWin.focus();
}
});
wc.on('context-menu', (_e, params) => {
if (!params.linkURL) return;
const items: Electron.MenuItemConstructorOptions[] = [];
if (params.linkURL.includes('krunker.io') && !this.isGameURL(params.linkURL)) {
items.push({ label: 'Open in New Tab', click: () => this.openTab(params.linkURL) });
}
items.push({ label: 'Copy Link', click: () => clipboard.writeText(params.linkURL) });
if (!params.linkURL.includes('krunker.io')) {
items.push({ label: 'Open in Browser', click: () => shell.openExternal(params.linkURL) });
}
if (items.length) Menu.buildFromTemplate(items).popup();
});
wc.on('before-input-event', (event, input) => {
if (input.type !== 'keyDown') return;
if (this.handleTabShortcut(event, input)) return;
if (input.key === 'F12' && !input.control && !input.shift && !input.alt) {
wc.toggleDevTools();
event.preventDefault();
}
});
return view;
}
// ── Switch active tab ──
switchToTab(id: number): void {
const tab = this.tabs.find(t => t.id === id);
if (!tab) return;
if (this.activeTabId !== null) {
const prev = this.tabs.find(t => t.id === this.activeTabId);
if (prev) {
this.containerView.removeChildView(prev.view);
}
}
this.activeTabId = id;
this.containerView.addChildView(tab.view);
this.updateLayout();
this.broadcastTabState();
}
// ── Close a tab ──
closeTab(id: number): void {
const idx = this.tabs.findIndex(t => t.id === id);
if (idx === -1) return;
const tab = this.tabs[idx];
if (this.activeTabId === id) {
this.containerView.removeChildView(tab.view);
this.activeTabId = null;
}
this.recentlyClosed.push({ url: tab.url, title: tab.title });
if (this.recentlyClosed.length > 10) this.recentlyClosed.shift();
this.stopTitleWatcher(id);
tab.view.webContents.close();
this.tabs.splice(idx, 1);
if (this.tabs.length > 0) {
const nextIdx = Math.min(idx, this.tabs.length - 1);
this.switchToTab(this.tabs[nextIdx].id);
} else {
if (this.mode === 'same') {
this.hideTabs();
} else {
if (this.tabWindow && !this.tabWindow.isDestroyed()) {
this.tabWindow.contentView.removeChildView(this.containerView);
this.tabWindow.close();
}
}
}
this.broadcastTabState();
}
// ── Show / hide tabs ──
showTabs(): void {
if (this.mode === 'same') {
this.containerView.setVisible(true);
this.visible = true;
this.updateLayout();
} else {
this.ensureTabWindow();
if (this.tabWindow && !this.tabWindow.isDestroyed()) {
this.tabWindow.show();
this.tabWindow.focus();
}
this.visible = true;
}
}
hideTabs(): void {
if (this.mode === 'same') {
this.containerView.setVisible(false);
this.visible = false;
this.mainWin.focus();
} else {
this.mainWin.focus();
this.visible = false;
}
}
// ── Tab navigation ──
nextTab(): void {
if (this.tabs.length < 2 || this.activeTabId === null) return;
const idx = this.tabs.findIndex(t => t.id === this.activeTabId);
const next = (idx + 1) % this.tabs.length;
this.switchToTab(this.tabs[next].id);
}
prevTab(): void {
if (this.tabs.length < 2 || this.activeTabId === null) return;
const idx = this.tabs.findIndex(t => t.id === this.activeTabId);
const prev = (idx - 1 + this.tabs.length) % this.tabs.length;
this.switchToTab(this.tabs[prev].id);
}
closeCurrentTab(): void {
if (this.activeTabId !== null) this.closeTab(this.activeTabId);
}
// ── Reorder tabs via drag ──
reorderTab(fromId: number, toId: number, side: 'before' | 'after'): void {
const fromIdx = this.tabs.findIndex(t => t.id === fromId);
const toIdx = this.tabs.findIndex(t => t.id === toId);
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return;
const [tab] = this.tabs.splice(fromIdx, 1);
let insertIdx = this.tabs.findIndex(t => t.id === toId);
if (side === 'after') insertIdx++;
this.tabs.splice(insertIdx, 0, tab);
this.broadcastTabState();
}
// ── Jump to tab by position (0-based, -1 = last) ──
switchToTabByIndex(index: number): void {
if (this.tabs.length === 0) return;
if (index < 0 || index >= this.tabs.length) index = this.tabs.length - 1;
this.switchToTab(this.tabs[index].id);
}
// ── Reopen last closed tab ──
reopenTab(): void {
const entry = this.recentlyClosed.pop();
if (entry) this.openTab(entry.url);
}
// ── Shared shortcut handler (returns true if handled) ──
private handleTabShortcut(event: Electron.Event, input: Electron.Input): boolean {
if (input.key === 'Escape' && !input.control && !input.shift && !input.alt) {
if (this.mode === 'same') this.hideTabs();
else this.mainWin.focus();
event.preventDefault();
return true;
} else if (input.key === 'w' && input.control && !input.shift && !input.alt) {
this.closeCurrentTab();
event.preventDefault();
return true;
} else if (input.key === 'Tab' && input.control && !input.shift && !input.alt) {
this.nextTab();
event.preventDefault();
return true;
} else if (input.key === 'Tab' && input.control && input.shift && !input.alt) {
this.prevTab();
event.preventDefault();
return true;
} else if (input.key === 't' && input.control && !input.shift && !input.alt) {
this.openTab(KRUNKER_SOCIAL);
event.preventDefault();
return true;
} else if (input.key === 'T' && input.control && input.shift && !input.alt) {
this.reopenTab();
event.preventDefault();
return true;
} else if (input.key >= '1' && input.key <= '8' && input.control && !input.shift && !input.alt) {
this.switchToTabByIndex(parseInt(input.key) - 1);
event.preventDefault();
return true;
} else if (input.key === '9' && input.control && !input.shift && !input.alt) {
this.switchToTabByIndex(-1);
event.preventDefault();
return true;
}
return false;
}
// ── Cleanup ──
destroyAll(): void {
this.destroyAllTabs();
ipcMain.removeAllListeners('tab-switch');
ipcMain.removeAllListeners('tab-close');
ipcMain.removeAllListeners('tab-new');
ipcMain.removeAllListeners('tab-reorder');
ipcMain.removeAllListeners('tab-back-to-game');
if (this.tabWindow && !this.tabWindow.isDestroyed()) {
this.tabWindow.contentView.removeChildView(this.containerView);
this.tabWindow.close();
this.tabWindow = null;
}
if (this.mode === 'same') {
try { this.mainWin.contentView.removeChildView(this.containerView); } catch { /* may already be removed */ }
}
}
private destroyAllTabs(): void {
for (const tab of this.tabs) {
this.stopTitleWatcher(tab.id);
if (this.activeTabId === tab.id) {
this.containerView.removeChildView(tab.view);
}
if (!tab.view.webContents.isDestroyed()) {
tab.view.webContents.close();
}
}
this.tabs = [];
this.activeTabId = null;
this.broadcastTabState();
}
// ── Layout ──
private updateLayout(): void {
let bounds: { width: number; height: number };
if (this.mode === 'same') {
const [w, h] = this.mainWin.getContentSize();
bounds = { width: w, height: h };
this.containerView.setBounds({ x: 0, y: 0, width: w, height: h });
} else if (this.tabWindow && !this.tabWindow.isDestroyed()) {
const [w, h] = this.tabWindow.getContentSize();
bounds = { width: w, height: h };
this.containerView.setBounds({ x: 0, y: 0, width: w, height: h });
} else {
return;
}
this.tabBarView.setBounds({
x: 0, y: 0,
width: bounds.width,
height: TAB_BAR_HEIGHT,
});
if (this.activeTabId !== null) {
const tab = this.tabs.find(t => t.id === this.activeTabId);
if (tab) {
tab.view.setBounds({
x: 0,
y: TAB_BAR_HEIGHT,
width: bounds.width,
height: bounds.height - TAB_BAR_HEIGHT,
});
}
}
}
// ── Update tab metadata and broadcast ──
private updateTabInfo(id: number, updates: Partial<Pick<TabInfo, 'title' | 'url' | 'loading'>>): void {
const tab = this.tabs.find(t => t.id === id);
if (!tab) return;
if (updates.title !== undefined) tab.title = updates.title;
if (updates.url !== undefined) tab.url = updates.url;
if (updates.loading !== undefined) tab.loading = updates.loading;
this.broadcastTabState();
}
private broadcastTabState(): void {
if (this.tabBarView.webContents.isDestroyed()) return;
const data = this.tabs.map(t => ({
id: t.id,
title: t.title,
active: t.id === this.activeTabId,
loading: t.loading,
}));
this.tabBarView.webContents.send('tabs-update', data);
}
private static readonly GENERIC_TITLES = new Set([
'krunker hub', 'krunker', 'krunker.io', '',
'hub', 'social', 'profile', 'new tab', 'loading...',
]);
private isGenericTitle(title: string): boolean {
return TabManager.GENERIC_TITLES.has(title.toLowerCase().trim());
}
// ── Persistent URL watcher + DOM title extraction ──
private startTitleWatcher(tabId: number, wc: Electron.WebContents): void {
const existing = this.titlePolls.get(tabId);
if (existing) clearInterval(existing);
let lastUrl = '';
let lastDom = '';
const poll = setInterval(() => {
if (wc.isDestroyed()) {
clearInterval(poll);
this.titlePolls.delete(tabId);
return;
}
wc.executeJavaScript(
`(function() {
var url = window.location.href;
var title = '';
var ph = document.getElementById('profileHolder');
if (ph && ph.style.display === 'block') {
var ns = document.getElementById('nameSwitch');
if (ns && ns.innerText) title = ns.innerText;
}
return JSON.stringify({ url: url, dom: title });
})()`
).then((json: string) => {
const { url, dom } = JSON.parse(json);
if (url === lastUrl && dom === lastDom) return;
lastUrl = url;
lastDom = dom;
const tab = this.tabs.find(t => t.id === tabId);
if (!tab) return;
if (dom) {
if (tab.title !== dom) {
this.updateTabInfo(tabId, { url, title: dom });
} else if (tab.url !== url) {
this.updateTabInfo(tabId, { url });
}
return;
}
if (tab.url !== url) {
this.updateTabInfo(tabId, { url, title: this.titleFromUrl(url) });
}
}).catch(() => {});
}, 1000);
this.titlePolls.set(tabId, poll);
}
private stopTitleWatcher(tabId: number): void {
const poll = this.titlePolls.get(tabId);
if (poll) {
clearInterval(poll);
this.titlePolls.delete(tabId);
}
}
// ── Extract a display title from URL ──
private titleFromUrl(url: string): string {
try {
const parsed = new URL(url);
const p = parsed.searchParams.get('p');
const q = parsed.searchParams.get('q');
if (q) return q;
if (p) {
const pageMap: Record<string, string> = {
profile: 'Profile',
leaders: 'Leaderboard',
games: 'Games',
clans: 'Clans',
skins: 'Skins',
mods: 'Mods',
maps: 'Maps',
editor: 'Editor',
market: 'Market',
itemsales: 'Market Item',
inventory: 'Inventory',
settings: 'Settings',
feed: 'Hub',
};
return pageMap[p] || p.charAt(0).toUpperCase() + p.slice(1);
}
const path = parsed.pathname.replace(/\.html$/, '').replace(/^\//, '');
if (path === 'social') return 'Hub';
if (path) return path.charAt(0).toUpperCase() + path.slice(1);
return 'New Tab';
} catch {
return 'New Tab';
}
}
}
+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 };
}
+245
View File
@@ -0,0 +1,245 @@
import { get as httpsGet } 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
checkUrl: 'https://gitea.crjlab.net/api/v1/repos/bigjakk/Krunker-Civilian-Client/releases/latest',
assetPattern: /Setup\.exe$/i,
// Allowed hosts for update check and download (including redirects)
allowedHosts: ['gitea.crjlab.net'],
};
const CHECK_TIMEOUT_MS = 10000;
const DOWNLOAD_TIMEOUT_MS = 300000; // 5 minutes
/**
* Validate that a redirect URL stays on an allowed host.
*/
function isAllowedRedirect(url: string): boolean {
try {
const parsed = new URL(url);
return UPDATE_CONFIG.allowedHosts.some(h => parsed.hostname === h || parsed.hostname.endsWith('.' + h));
} catch {
return false;
}
}
/**
* Simple semver comparison: returns true if a < b.
* 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, {
headers: { 'User-Agent': 'KrunkerCivilianClient/' + currentVersion },
}, (res) => {
electronLog.log('[KCC-Update] Check response status:', res.statusCode);
// Follow redirects (with domain validation)
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
const redirectUrl = res.headers.location;
electronLog.log('[KCC-Update] Redirected to:', redirectUrl);
if (!isAllowedRedirect(redirectUrl)) {
electronLog.error('[KCC-Update] Redirect to untrusted host blocked:', redirectUrl);
resolve(null);
return;
}
httpsGet(redirectUrl, {
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;
}
// Validate the download URL points to an allowed host
if (!isAllowedRedirect(setupAsset.browser_download_url)) {
electronLog.error('[KCC-Update] Download URL points to untrusted host:', setupAsset.browser_download_url);
resolve(null);
return;
}
electronLog.log('[KCC-Update] Update available:', remoteVersion, '| Download:', setupAsset.browser_download_url, '| Size:', setupAsset.size);
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, redirectCount = 0): void {
if (redirectCount > 5) {
reject(new Error('Too many redirects'));
return;
}
electronLog.log('[KCC-Update] Downloading from:', downloadUrl);
const req = httpsGet(downloadUrl, {
headers: { 'User-Agent': 'KrunkerCivilianClient' },
}, (res) => {
// Follow redirects (with domain validation)
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
const redirectUrl = res.headers.location;
electronLog.log('[KCC-Update] Download redirected to:', redirectUrl);
if (!isAllowedRedirect(redirectUrl)) {
electronLog.error('[KCC-Update] Download redirect to untrusted host blocked:', redirectUrl);
reject(new Error('Download redirect to untrusted host: ' + redirectUrl));
return;
}
doDownload(redirectUrl, redirectCount + 1);
return;
}
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 */ }
}
}