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