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 */ }
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,190 @@
|
||||
// ── Custom Matchmaker (ported from Crankshaft) ──
|
||||
// Fetches live lobby list from matchmaker.krunker.io, filters by user criteria,
|
||||
// presents a popup to join a random matching game.
|
||||
|
||||
import type { Keybind } from '../main/config';
|
||||
import type { SavedConsole } from './utils';
|
||||
|
||||
export const MATCHMAKER_GAMEMODES = ['Free for All', 'Team Deathmatch', 'Hardpoint', 'Capture the Flag', 'Parkour', 'Hide & Seek', 'Infected', 'Race', 'Last Man Standing', 'Simon Says', 'Gun Game', 'Prop Hunt', 'Boss Hunt', 'Classic FFA', 'Deposit', 'Stalker', 'King of the Hill', 'One in the Chamber', 'Trade', 'Kill Confirmed', 'Defuse', 'Sharp Shooter', 'Traitor', 'Raid', 'Blitz', 'Domination', 'Squad Deathmatch', 'Kranked FFA', 'Team Defender', 'Deposit FFA', 'Chaos Snipers', 'Bighead FFA'];
|
||||
export const MATCHMAKER_REGIONS = ['MBI', 'NY', 'FRA', 'SIN', 'DAL', 'SYD', 'MIA', 'BHN', 'TOK', 'BRZ', 'AFR', 'LON', 'CHI', 'SV', 'STL', 'MX'];
|
||||
export const MATCHMAKER_REGION_NAMES: Record<string, string> = { MBI: 'Mumbai', NY: 'New York', FRA: 'Frankfurt', SIN: 'Singapore', DAL: 'Dallas', SYD: 'Sydney', MIA: 'Miami', BHN: 'Middle East', TOK: 'Tokyo', BRZ: 'Brazil', AFR: 'South Africa', LON: 'London', CHI: 'China', SV: 'Silicon Valley', STL: 'Seattle', MX: 'Mexico' };
|
||||
const MAP_ICON_INDICES = ['Burg', 'Littletown', 'Sandstorm', 'Subzero', 'Undergrowth', 'Shipment', 'Freight', 'Lostworld', 'Citadel', 'Oasis', 'Kanji', 'Industry', 'Lumber', 'Evacuation', 'Site', 'SkyTemple', 'Lagoon', 'Bureau', 'Tortuga', 'Tropicano', 'Krunk_Plaza', 'Arena', 'Habitat', 'Atomic', 'Old_Burg', 'Throwback', 'Stockade', 'Facility', 'Clockwork', 'Laboratory', 'Shipyard', 'Soul Sanctum', 'Bazaar', 'Erupt', 'HQ', 'Khepri', 'Lush', 'Vivo', 'Slide Moonlight', 'Eterno Sim'];
|
||||
|
||||
interface MatchmakerGame {
|
||||
gameID: string;
|
||||
region: string;
|
||||
playerCount: number;
|
||||
playerLimit: number;
|
||||
map: string;
|
||||
gamemode: string;
|
||||
remainingTime: number;
|
||||
}
|
||||
|
||||
export interface MatchmakerConfig {
|
||||
enabled: boolean;
|
||||
regions: string[];
|
||||
gamemodes: string[];
|
||||
minPlayers: number;
|
||||
maxPlayers: number;
|
||||
minRemainingTime: number;
|
||||
openServerBrowser: boolean;
|
||||
acceptKey: Keybind;
|
||||
cancelKey: Keybind;
|
||||
}
|
||||
|
||||
function secondsToTimestring(num: number): string {
|
||||
const minutes = Math.floor(num / 60);
|
||||
const seconds = num % 60;
|
||||
if (minutes < 1) return `${num}s`;
|
||||
return `${minutes}m ${seconds}s`;
|
||||
}
|
||||
|
||||
// ── Popup DOM (created once, reused) ──
|
||||
const POPUP_ID = 'matchmakerPopupContainer';
|
||||
const popupElement = document.createElement('div');
|
||||
popupElement.id = POPUP_ID;
|
||||
|
||||
const popupTitle = document.createElement('div');
|
||||
popupTitle.id = 'matchmakerPopupTitle';
|
||||
popupElement.appendChild(popupTitle);
|
||||
|
||||
const popupDescription = document.createElement('div');
|
||||
popupDescription.id = 'matchmakerPopupDescription';
|
||||
popupElement.appendChild(popupDescription);
|
||||
|
||||
const popupOptions = document.createElement('div');
|
||||
popupOptions.id = 'matchmakerPopupOptions';
|
||||
|
||||
const popupConfirmBtn = document.createElement('div');
|
||||
popupConfirmBtn.id = 'matchmakerConfirmButton';
|
||||
popupConfirmBtn.className = 'matchmakerPopupButton bigShadowT';
|
||||
popupConfirmBtn.textContent = 'Join';
|
||||
popupConfirmBtn.setAttribute('onmouseenter', 'playTick()');
|
||||
popupConfirmBtn.addEventListener('click', () => decideMatchmakerDecision(true));
|
||||
|
||||
const popupCancelBtn = document.createElement('div');
|
||||
popupCancelBtn.id = 'matchmakerCancelButton';
|
||||
popupCancelBtn.className = 'matchmakerPopupButton bigShadowT';
|
||||
popupCancelBtn.textContent = 'Cancel';
|
||||
popupCancelBtn.setAttribute('onmouseenter', 'playTick()');
|
||||
popupCancelBtn.addEventListener('click', () => decideMatchmakerDecision(false));
|
||||
|
||||
popupOptions.appendChild(popupConfirmBtn);
|
||||
popupOptions.appendChild(popupCancelBtn);
|
||||
popupElement.appendChild(popupOptions);
|
||||
|
||||
// ── State ──
|
||||
let currentMatch = '';
|
||||
let openServerBrowser = true;
|
||||
let confirmKey: Keybind = { key: 'Enter', ctrl: false, shift: false, alt: false };
|
||||
let cancelKey: Keybind = { key: 'Escape', ctrl: false, shift: false, alt: false };
|
||||
|
||||
function decideMatchmakerDecision(accept: boolean): void {
|
||||
const w = window as any;
|
||||
if (typeof w.playSelect === 'function') w.playSelect();
|
||||
|
||||
if (accept && currentMatch !== 'none') {
|
||||
window.location.href = `https://krunker.io/?game=${currentMatch}`;
|
||||
} else {
|
||||
if (popupElement.parentNode) popupElement.remove();
|
||||
if (currentMatch === 'none' && openServerBrowser && typeof w.openServerWindow === 'function') {
|
||||
w.openServerWindow(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function matchesKey(bind: Keybind, event: KeyboardEvent): boolean {
|
||||
if ((document.activeElement as HTMLElement)?.tagName === 'INPUT') return false;
|
||||
return event.key === bind.key
|
||||
&& event.shiftKey === bind.shift
|
||||
&& event.altKey === bind.alt
|
||||
&& event.ctrlKey === bind.ctrl;
|
||||
}
|
||||
|
||||
function handleMatchmakerBind(event: KeyboardEvent): void {
|
||||
if (document.pointerLockElement) return;
|
||||
const isAccept = matchesKey(confirmKey, event);
|
||||
const isCancel = matchesKey(cancelKey, event);
|
||||
if (isAccept || isCancel) {
|
||||
document.removeEventListener('keydown', handleMatchmakerBind, true);
|
||||
decideMatchmakerDecision(isAccept);
|
||||
}
|
||||
}
|
||||
|
||||
function createFetchedGamePopup(game: MatchmakerGame): void {
|
||||
const mapIdx = MAP_ICON_INDICES.indexOf(game.map);
|
||||
popupElement.style.backgroundImage = `url(https://assets.krunker.io/img/maps/map_${mapIdx >= 0 ? mapIdx : 0}.png)`;
|
||||
|
||||
currentMatch = game.gameID;
|
||||
if (game.gameID === 'none') {
|
||||
popupTitle.innerText = 'No Games Found...';
|
||||
popupDescription.innerHTML = 'Check the server browser to see other lobbies.';
|
||||
popupConfirmBtn.style.display = 'none';
|
||||
} else {
|
||||
popupTitle.innerText = 'Game Found!';
|
||||
const regionName = MATCHMAKER_REGION_NAMES[game.region] ?? 'Unknown Region';
|
||||
popupDescription.innerHTML = `${game.gamemode} on ${game.map} (${regionName})<br/>${game.playerCount}/${game.playerLimit} Players, ${secondsToTimestring(game.remainingTime)} Left`;
|
||||
popupConfirmBtn.style.display = 'block';
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleMatchmakerBind, true);
|
||||
const uiBase = document.getElementById('uiBase');
|
||||
if (uiBase) uiBase.appendChild(popupElement);
|
||||
}
|
||||
|
||||
export async function fetchGame(mmConfig: MatchmakerConfig, _con?: SavedConsole): Promise<void> {
|
||||
openServerBrowser = mmConfig.openServerBrowser;
|
||||
confirmKey = mmConfig.acceptKey;
|
||||
cancelKey = mmConfig.cancelKey;
|
||||
|
||||
// Dismiss existing popup if active
|
||||
if (document.getElementById(POPUP_ID)) decideMatchmakerDecision(false);
|
||||
|
||||
_con?.log('[KCC-MM] Fetching game list...');
|
||||
|
||||
const response = await fetch(`https://matchmaker.krunker.io/game-list?hostname=${window.location.hostname}`);
|
||||
const result = await response.json();
|
||||
const games: MatchmakerGame[] = [];
|
||||
|
||||
for (const game of result.games) {
|
||||
const gameID: string = game[0];
|
||||
const region = gameID.split(':')[0];
|
||||
const playerCount: number = game[2];
|
||||
const playerLimit: number = game[3];
|
||||
const map: string = game[4].i;
|
||||
const gamemode = MATCHMAKER_GAMEMODES[game[4].g] ?? 'Unknown Gamemode';
|
||||
const remainingTime: number = game[5];
|
||||
|
||||
// Apply filters — empty arrays mean "all selected" (no filter)
|
||||
if (mmConfig.regions.length > 0 && !mmConfig.regions.includes(region)) continue;
|
||||
if (mmConfig.gamemodes.length > 0 && !mmConfig.gamemodes.includes(gamemode)) continue;
|
||||
if (playerCount < mmConfig.minPlayers) continue;
|
||||
if (playerCount > mmConfig.maxPlayers) continue;
|
||||
if (remainingTime < mmConfig.minRemainingTime) continue;
|
||||
if (playerCount === playerLimit) continue;
|
||||
if (window.location.href.includes(gameID)) continue;
|
||||
if (currentMatch === gameID) continue;
|
||||
|
||||
games.push({ gameID, region, playerCount, playerLimit, map, gamemode, remainingTime });
|
||||
}
|
||||
|
||||
_con?.log('[KCC-MM] Received', result.games?.length ?? 0, 'games,', games.length, 'passed filters');
|
||||
|
||||
if (games.length > 0) {
|
||||
const selected = games[Math.floor(Math.random() * games.length)];
|
||||
_con?.log('[KCC-MM] Selected:', selected.gameID, selected.region, selected.map);
|
||||
createFetchedGamePopup(selected);
|
||||
} else {
|
||||
_con?.log('[KCC-MM] No matching games found');
|
||||
createFetchedGamePopup({
|
||||
gameID: 'none',
|
||||
region: 'none',
|
||||
playerCount: 0,
|
||||
playerLimit: 0,
|
||||
map: MAP_ICON_INDICES[0],
|
||||
gamemode: MATCHMAKER_GAMEMODES[0],
|
||||
remainingTime: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
import type { SavedConsole } from './utils';
|
||||
|
||||
// ── Config ──
|
||||
|
||||
interface TranslatorConfig {
|
||||
enabled: boolean;
|
||||
targetLanguage: string;
|
||||
showLanguageTag: boolean;
|
||||
}
|
||||
|
||||
const DEFAULTS: TranslatorConfig = {
|
||||
enabled: true,
|
||||
targetLanguage: 'en',
|
||||
showLanguageTag: true,
|
||||
};
|
||||
|
||||
// ── Module state ──
|
||||
|
||||
let _con: SavedConsole;
|
||||
let cfg: TranslatorConfig = { ...DEFAULTS };
|
||||
let chatObserver: MutationObserver | null = null;
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// ── Translation cache (sessionStorage, 10-min expiry) ──
|
||||
|
||||
const CACHE_KEY_PREFIX = 'kccTL_';
|
||||
const CACHE_EXPIRY_MS = 10 * 60 * 1000;
|
||||
|
||||
interface CacheEntry {
|
||||
t: string; // translation
|
||||
l: string; // source language
|
||||
ts: number; // timestamp
|
||||
}
|
||||
|
||||
function cacheGet(text: string): CacheEntry | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(CACHE_KEY_PREFIX + text.toLowerCase().trim());
|
||||
if (!raw) return null;
|
||||
const entry: CacheEntry = JSON.parse(raw);
|
||||
if (Date.now() - entry.ts > CACHE_EXPIRY_MS) return null;
|
||||
return entry;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function cacheSet(text: string, translation: string, srcLang: string): void {
|
||||
try {
|
||||
const entry: CacheEntry = { t: translation, l: srcLang, ts: Date.now() };
|
||||
sessionStorage.setItem(CACHE_KEY_PREFIX + text.toLowerCase().trim(), JSON.stringify(entry));
|
||||
} catch { /* sessionStorage full */ }
|
||||
}
|
||||
|
||||
// ── Skip terms (gaming/chat slang — never sent for translation) ──
|
||||
|
||||
const SKIP_TERMS = new Set([
|
||||
// Greetings & basics
|
||||
'hi', 'hey', 'hello', 'yo', 'sup', 'bye', 'cya', 'gn', 'gm',
|
||||
'yes', 'no', 'yep', 'yea', 'yeah', 'nah', 'nope', 'ok', 'okay', 'kk',
|
||||
// Chat abbreviations
|
||||
'lol', 'lmao', 'lmfao', 'rofl', 'omg', 'omfg', 'wtf', 'wth',
|
||||
'bruh', 'bro', 'dude', 'man', 'brb', 'afk', 'gtg', 'g2g',
|
||||
'smh', 'tbh', 'imo', 'imho', 'ngl', 'fr', 'frfr', 'fax',
|
||||
'idk', 'idc', 'idgaf', 'nvm', 'stfu', 'pls', 'plz',
|
||||
'thx', 'ty', 'tysm', 'np', 'yw', 'mb', 'sry', 'sorry',
|
||||
'bet', 'cap', 'nocap', 'sus', 'mid', 'based', 'cringe', 'ratio',
|
||||
'rip', 'oof', 'uwu', 'owo', 'xd', 'xdd', 'xddd', 'lel', 'kek',
|
||||
'damn', 'dang', 'boi', 'fam', 'goat', 'goated',
|
||||
'lit', 'vibe', 'vibes', 'lowkey', 'highkey', 'deadass',
|
||||
'nice', 'cool', 'sick', 'fire', 'trash', 'ass', 'toxic',
|
||||
'wow', 'whoa', 'wha', 'huh', 'wat', 'wut', 'hmm',
|
||||
// Gaming general
|
||||
'gg', 'ggwp', 'ggez', 'wp', 'ez', 'gl', 'hf', 'glhf',
|
||||
'nt', 'ns', 'gj', 'mvp', 'clutch', 'ace', 'carry',
|
||||
'noob', 'newb', 'n00b', 'bot', 'tryhard', 'sweat', 'sweaty',
|
||||
'hack', 'hacks', 'hacker', 'hax', 'cheater', 'cheats',
|
||||
'lag', 'laggy', 'ping', 'fps', 'dc', 'disconnect',
|
||||
'nerf', 'buff', 'op', 'broken', 'meta', 'spam', 'camp', 'camper',
|
||||
'aim', 'aimbot', 'wh', 'wallhack', 'esp',
|
||||
'rush', 'push', 'rotate', 'flank', 'peek', 'hold',
|
||||
'one', 'low', 'dead', 'down', 'res', 'revive',
|
||||
'w', 'l', 'dub', 'win', 'loss', 'f', 'ggs',
|
||||
// Krunker-specific
|
||||
'kr', 'ak', 'smg', 'sniper', 'shotty', 'rev', 'semi',
|
||||
'crossy', 'famas', 'rpg', 'lmg', 'deagle', 'comp',
|
||||
'pub', 'pubs', 'ranked', 'nuke', 'nuked', 'nuking',
|
||||
'kpd', 'bhop', 'bhopping', 'slidehopping', 'slidehop',
|
||||
'krunker', 'krunky', 'yendis', 'krunkitis',
|
||||
'contra', 'relic', 'unob', 'unobtainable', 'spin',
|
||||
'market', 'trade', 'gift', 'drop', 'drops', 'skin', 'skins',
|
||||
'clan', 'verified', 'lvl', 'level',
|
||||
'trig', 'trigger', 'runner', 'det', 'detective',
|
||||
'vince', 'bowman', 'spray', 'agent', 'rocketeer',
|
||||
'streamer', 'ttv',
|
||||
// Emoticons
|
||||
':)', ':(', ':d', ':p', ':o', '<3',
|
||||
]);
|
||||
|
||||
// ── False-positive source languages ──
|
||||
|
||||
const FALSE_POSITIVE_LANGS = new Set([
|
||||
'so', 'cy', 'ht', 'hmn', 'ceb', 'haw', 'la', 'mg', 'mi',
|
||||
'ny', 'sm', 'st', 'su', 'sw', 'tl', 'yo', 'zu', 'sn',
|
||||
'ig', 'rw', 'co', 'fy', 'gd', 'lb', 'mt', 'eo',
|
||||
]);
|
||||
|
||||
// ── Auto-suppression (repeated short phrases) ──
|
||||
|
||||
const suppressionCounts = new Map<string, number>();
|
||||
const SUPPRESS_THRESHOLD = 3;
|
||||
const MIN_LATIN_WORDS = 3;
|
||||
const SHORT_TEXT_THRESHOLD = 15;
|
||||
|
||||
// ── Concurrency control ──
|
||||
|
||||
let activeRequests = 0;
|
||||
const MAX_CONCURRENT = 3;
|
||||
const pendingQueue: Array<() => void> = [];
|
||||
|
||||
function enqueue(fn: () => Promise<void>): void {
|
||||
if (activeRequests < MAX_CONCURRENT) {
|
||||
activeRequests++;
|
||||
fn().finally(() => {
|
||||
activeRequests--;
|
||||
if (pendingQueue.length > 0) pendingQueue.shift()!();
|
||||
});
|
||||
} else {
|
||||
pendingQueue.push(() => enqueue(fn));
|
||||
}
|
||||
}
|
||||
|
||||
// ── System message patterns to skip ──
|
||||
|
||||
const SYSTEM_PATTERNS = [
|
||||
'joined the game', 'left the game', 'has been kicked', 'has been banned',
|
||||
'vote to kick', 'press f1', 'connecting', 'connected', 'was arrested',
|
||||
'started a vote', 'was kicked', 'was banned',
|
||||
];
|
||||
|
||||
// ── Pre-translation filtering ──
|
||||
|
||||
function isLatinOnly(text: string): boolean {
|
||||
return /^[\x00-\x7F\u00C0-\u024F\u1E00-\u1EFF\s\d.,!?;:'"()\-/@#$%^&*+=~`[\]{}|\\<>]+$/u.test(text);
|
||||
}
|
||||
|
||||
function shouldTranslate(text: string): boolean {
|
||||
const cleaned = text.trim();
|
||||
if (cleaned.length < 2) return false;
|
||||
|
||||
// Tokenize for skip-term checking
|
||||
const words = cleaned.replace(/[^a-zA-Z0-9\s]/g, '').toLowerCase().split(/\s+/).filter(w => w.length > 0);
|
||||
if (words.length === 0) return false;
|
||||
if (words.every(w => SKIP_TERMS.has(w))) return false;
|
||||
|
||||
// Auto-suppressed phrases
|
||||
const key = cleaned.toLowerCase();
|
||||
if ((suppressionCounts.get(key) ?? 0) >= SUPPRESS_THRESHOLD) return false;
|
||||
|
||||
// Non-Latin characters = almost certainly needs translation
|
||||
if (!isLatinOnly(cleaned)) return true;
|
||||
|
||||
// Latin-only: require minimum word count (short English slang triggers false positives)
|
||||
if (words.length < MIN_LATIN_WORDS) {
|
||||
// Allow if accented characters suggest non-English
|
||||
if (!/[À-ÿ]/.test(cleaned)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Chat text extraction ──
|
||||
|
||||
interface ChatExtraction {
|
||||
message: string;
|
||||
username: string; // "Username:" prefix or empty
|
||||
}
|
||||
|
||||
function extractChatText(node: HTMLElement): ChatExtraction | null {
|
||||
const text = node.textContent?.trim();
|
||||
if (!text || text.length < 2) return null;
|
||||
|
||||
// Skip nodes with images (kill feed has weapon/skull icons)
|
||||
if (node.querySelector('img')) return null;
|
||||
|
||||
// Skip commands
|
||||
if (text.startsWith('/')) return null;
|
||||
|
||||
// Skip system messages
|
||||
const lower = text.toLowerCase();
|
||||
if (SYSTEM_PATTERNS.some(p => lower.includes(p))) return null;
|
||||
|
||||
// Extract message content after "Username: " prefix
|
||||
const colonIdx = text.indexOf(':');
|
||||
if (colonIdx > 0 && colonIdx < 25) {
|
||||
const username = text.substring(0, colonIdx + 1);
|
||||
const msg = text.substring(colonIdx + 1).trim();
|
||||
return msg.length >= 2 ? { message: msg, username } : null;
|
||||
}
|
||||
|
||||
return { message: text, username: '' };
|
||||
}
|
||||
|
||||
// ── Google Translate API ──
|
||||
|
||||
async function translateText(text: string): Promise<{ translation: string; srcLang: string } | null> {
|
||||
// Check cache
|
||||
const cached = cacheGet(text);
|
||||
if (cached) return { translation: cached.t, srcLang: cached.l };
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
const url = 'https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl='
|
||||
+ cfg.targetLanguage + '&dt=t&q=' + encodeURIComponent(text);
|
||||
|
||||
const response = await fetch(url, { signal: controller.signal });
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!response.ok) {
|
||||
_con.warn('[KCC-TL] HTTP', response.status);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data?.[0]?.[0]) return null;
|
||||
|
||||
const translation = (data[0] as any[]).map((item: any) => item[0]).join('');
|
||||
const srcLang: string = data[2] || 'unknown';
|
||||
|
||||
// Already in target language
|
||||
if (srcLang === cfg.targetLanguage) return null;
|
||||
|
||||
// Identical translation (strip punctuation/whitespace for robust comparison)
|
||||
const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
if (norm(translation) === norm(text)) return null;
|
||||
|
||||
// Post-filter: false-positive languages on short text
|
||||
if (text.length < SHORT_TEXT_THRESHOLD && FALSE_POSITIVE_LANGS.has(srcLang)) {
|
||||
const key = text.toLowerCase().trim();
|
||||
suppressionCounts.set(key, (suppressionCounts.get(key) ?? 0) + 1);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Track short phrases for auto-suppression learning
|
||||
const wordCount = text.trim().split(/\s+/).length;
|
||||
if (wordCount <= 2) {
|
||||
const key = text.toLowerCase().trim();
|
||||
const count = (suppressionCounts.get(key) ?? 0) + 1;
|
||||
suppressionCounts.set(key, count);
|
||||
if (count >= SUPPRESS_THRESHOLD) return null;
|
||||
}
|
||||
|
||||
cacheSet(text, translation, srcLang);
|
||||
return { translation, srcLang };
|
||||
} catch (err: any) {
|
||||
if (err.name !== 'AbortError') _con.warn('[KCC-TL] Error:', err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── DOM manipulation ──
|
||||
|
||||
function appendTranslation(chatNode: HTMLElement, username: string, translation: string, srcLang: string): void {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'kcc-translation';
|
||||
|
||||
const langTag = (cfg.showLanguageTag && srcLang !== 'unknown') ? ' [' + srcLang.toUpperCase() + ']' : '';
|
||||
div.textContent = '\u{1F310} ' + (username ? username + ' ' : '') + translation + langTag;
|
||||
chatNode.appendChild(div);
|
||||
}
|
||||
|
||||
// ── Message processing ──
|
||||
|
||||
function processMessage(node: HTMLElement): void {
|
||||
if (node.hasAttribute('data-kpc-translated')) return;
|
||||
node.setAttribute('data-kpc-translated', '1');
|
||||
|
||||
const extracted = extractChatText(node);
|
||||
if (!extracted) return;
|
||||
if (!shouldTranslate(extracted.message)) return;
|
||||
|
||||
const { message, username } = extracted;
|
||||
enqueue(async () => {
|
||||
const result = await translateText(message);
|
||||
if (result) appendTranslation(node, username, result.translation, result.srcLang);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Observer lifecycle ──
|
||||
|
||||
function startObserver(): void {
|
||||
if (chatObserver) return;
|
||||
|
||||
let attempts = 0;
|
||||
pollTimer = setInterval(() => {
|
||||
attempts++;
|
||||
const chatList = document.getElementById('chatList');
|
||||
if (!chatList) {
|
||||
if (attempts > 60) {
|
||||
clearInterval(pollTimer!);
|
||||
pollTimer = null;
|
||||
_con.warn('[KCC-TL] #chatList not found after 30s, giving up');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
clearInterval(pollTimer!);
|
||||
pollTimer = null;
|
||||
|
||||
chatObserver = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
for (const node of mutation.addedNodes) {
|
||||
if (node.nodeType === 1) processMessage(node as HTMLElement);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
chatObserver.observe(chatList, { childList: true });
|
||||
_con.log('[KCC-TL] Chat observer active');
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function stopObserver(): void {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
if (chatObserver) {
|
||||
chatObserver.disconnect();
|
||||
chatObserver = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ──
|
||||
|
||||
export function initTranslator(savedConsole: SavedConsole, initCfg: TranslatorConfig): void {
|
||||
_con = savedConsole;
|
||||
cfg = {
|
||||
enabled: initCfg.enabled ?? DEFAULTS.enabled,
|
||||
targetLanguage: initCfg.targetLanguage ?? DEFAULTS.targetLanguage,
|
||||
showLanguageTag: initCfg.showLanguageTag ?? DEFAULTS.showLanguageTag,
|
||||
};
|
||||
|
||||
if (!cfg.enabled) {
|
||||
_con.log('[KCC-TL] Translator disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
_con.log('[KCC-TL] Initializing (target: ' + cfg.targetLanguage + ')');
|
||||
startObserver();
|
||||
}
|
||||
|
||||
export function updateTranslatorConfig(update: Partial<TranslatorConfig>): void {
|
||||
if (update.enabled !== undefined) {
|
||||
cfg.enabled = update.enabled;
|
||||
if (update.enabled && !chatObserver) startObserver();
|
||||
if (!update.enabled) stopObserver();
|
||||
}
|
||||
if (update.targetLanguage !== undefined) cfg.targetLanguage = update.targetLanguage;
|
||||
if (update.showLanguageTag !== undefined) cfg.showLanguageTag = update.showLanguageTag;
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { ipcRenderer, webFrame } from 'electron';
|
||||
|
||||
// ── Types ──
|
||||
|
||||
export interface ScriptMetadata {
|
||||
name: string;
|
||||
author: string;
|
||||
version: string;
|
||||
desc: string;
|
||||
src: string;
|
||||
license: string;
|
||||
runAt: 'document-start' | 'document-end';
|
||||
priority: number;
|
||||
}
|
||||
|
||||
export interface UserscriptSetting {
|
||||
title: string;
|
||||
type: 'bool' | 'num' | 'sel' | 'color' | 'keybind';
|
||||
value: unknown;
|
||||
desc?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
opts?: (string | number)[];
|
||||
changed?: (value: unknown) => void;
|
||||
}
|
||||
|
||||
export interface UserscriptInstance {
|
||||
filename: string;
|
||||
content: string;
|
||||
meta: ScriptMetadata;
|
||||
enabled: boolean;
|
||||
executed: boolean;
|
||||
unload: (() => void) | null;
|
||||
settings: Record<string, UserscriptSetting> | null;
|
||||
}
|
||||
|
||||
// ── State ──
|
||||
|
||||
const instances: UserscriptInstance[] = [];
|
||||
const cssHandles = new Map<string, string>(); // identifier -> webFrame CSS key
|
||||
|
||||
// ── Metadata parser ──
|
||||
|
||||
export function parseMetadata(code: string): ScriptMetadata {
|
||||
const meta: ScriptMetadata = {
|
||||
name: '',
|
||||
author: '',
|
||||
version: '',
|
||||
desc: '',
|
||||
src: '',
|
||||
license: '',
|
||||
runAt: 'document-end',
|
||||
priority: 0,
|
||||
};
|
||||
|
||||
const startMatch = code.match(/\/\/\s*==UserScript==/);
|
||||
const endMatch = code.match(/\/\/\s*==\/UserScript==/);
|
||||
if (!startMatch || !endMatch) return meta;
|
||||
|
||||
const block = code.substring(
|
||||
startMatch.index! + startMatch[0].length,
|
||||
endMatch.index!,
|
||||
);
|
||||
|
||||
for (const line of block.split('\n')) {
|
||||
const m = line.match(/\/\/\s*@(\S+)\s+(.*)/);
|
||||
if (!m) continue;
|
||||
const [, tag, val] = m;
|
||||
const v = val.trim();
|
||||
switch (tag) {
|
||||
case 'name': meta.name = v; break;
|
||||
case 'author': meta.author = v; break;
|
||||
case 'version': meta.version = v; break;
|
||||
case 'desc':
|
||||
case 'description': meta.desc = v; break;
|
||||
case 'src': meta.src = v; break;
|
||||
case 'license': meta.license = v; break;
|
||||
case 'run-at':
|
||||
if (v === 'document-start') meta.runAt = 'document-start';
|
||||
else meta.runAt = 'document-end';
|
||||
break;
|
||||
case 'priority':
|
||||
meta.priority = parseInt(v, 10) || 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return meta;
|
||||
}
|
||||
|
||||
// ── CSS injection via webFrame ──
|
||||
|
||||
function toggleCSS(css: string, identifier: string, value: boolean): void {
|
||||
const existing = cssHandles.get(identifier);
|
||||
if (value) {
|
||||
if (existing) return; // already inserted
|
||||
const key = webFrame.insertCSS(css);
|
||||
cssHandles.set(identifier, key);
|
||||
} else {
|
||||
if (!existing) return;
|
||||
webFrame.removeInsertedCSS(existing);
|
||||
cssHandles.delete(identifier);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Script execution ──
|
||||
|
||||
function executeScript(
|
||||
instance: UserscriptInstance,
|
||||
_console: { log: Function; warn: Function; error: Function },
|
||||
): void {
|
||||
if (instance.executed) return;
|
||||
|
||||
const context: Record<string, unknown> = {
|
||||
_console,
|
||||
_css(css: string, identifier: string, value: boolean) {
|
||||
toggleCSS(css, instance.filename + ':' + identifier, value);
|
||||
},
|
||||
unload: null as (() => void) | null,
|
||||
settings: null as Record<string, UserscriptSetting> | null,
|
||||
};
|
||||
|
||||
try {
|
||||
const fn = new Function(instance.content);
|
||||
const result = fn.apply(context);
|
||||
|
||||
// Script returned `this` — capture settings and unload
|
||||
if (result === context) {
|
||||
instance.unload = (typeof context.unload === 'function') ? context.unload as () => void : null;
|
||||
instance.settings = context.settings as Record<string, UserscriptSetting> | null;
|
||||
} else {
|
||||
instance.unload = null;
|
||||
instance.settings = null;
|
||||
}
|
||||
|
||||
instance.executed = true;
|
||||
_console.log('[KCC] Userscript executed:', instance.meta.name || instance.filename);
|
||||
} catch (err) {
|
||||
_console.error('[KCC] Userscript error in', instance.filename, ':', err);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Apply saved preferences ──
|
||||
|
||||
async function applyPreferences(instance: UserscriptInstance): Promise<void> {
|
||||
if (!instance.settings) return;
|
||||
const saved = await ipcRenderer.invoke('userscripts-load-prefs', instance.filename);
|
||||
for (const key of Object.keys(instance.settings)) {
|
||||
if (key in saved) {
|
||||
const setting = instance.settings[key];
|
||||
setting.value = saved[key];
|
||||
if (typeof setting.changed === 'function') {
|
||||
try { setting.changed(setting.value); } catch { /* ignore callback errors */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ──
|
||||
|
||||
export function getInstances(): UserscriptInstance[] {
|
||||
return instances;
|
||||
}
|
||||
|
||||
export async function initUserscripts(
|
||||
_console: { log: Function; warn: Function; error: Function },
|
||||
): Promise<void> {
|
||||
const { scripts, tracker } = await ipcRenderer.invoke('userscripts-scan');
|
||||
if (!scripts || scripts.length === 0) {
|
||||
_console.log('[KCC] No userscripts found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Build instances
|
||||
for (const script of scripts) {
|
||||
const meta = parseMetadata(script.content);
|
||||
instances.push({
|
||||
filename: script.filename,
|
||||
content: script.content,
|
||||
meta,
|
||||
enabled: tracker[script.filename] === true,
|
||||
executed: false,
|
||||
unload: null,
|
||||
settings: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by priority descending
|
||||
instances.sort((a, b) => b.meta.priority - a.meta.priority);
|
||||
|
||||
// Execute document-start scripts
|
||||
for (const inst of instances) {
|
||||
if (inst.enabled && inst.meta.runAt === 'document-start') {
|
||||
executeScript(inst, _console);
|
||||
await applyPreferences(inst);
|
||||
}
|
||||
}
|
||||
|
||||
// Execute document-end scripts
|
||||
const runDocEnd = () => {
|
||||
for (const inst of instances) {
|
||||
if (inst.enabled && inst.meta.runAt === 'document-end' && !inst.executed) {
|
||||
executeScript(inst, _console);
|
||||
applyPreferences(inst);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', runDocEnd, { once: true });
|
||||
} else {
|
||||
runDocEnd();
|
||||
}
|
||||
|
||||
_console.log('[KCC] Userscripts initialized:', instances.length, 'scripts loaded');
|
||||
}
|
||||
|
||||
export function setScriptEnabled(
|
||||
filename: string,
|
||||
enabled: boolean,
|
||||
_console: { log: Function; warn: Function; error: Function },
|
||||
): { needsReload: boolean } {
|
||||
const inst = instances.find(i => i.filename === filename);
|
||||
if (!inst) return { needsReload: false };
|
||||
|
||||
inst.enabled = enabled;
|
||||
|
||||
// Update tracker
|
||||
const tracker: Record<string, boolean> = {};
|
||||
for (const i of instances) tracker[i.filename] = i.enabled;
|
||||
ipcRenderer.invoke('userscripts-set-tracker', tracker);
|
||||
|
||||
if (!enabled) {
|
||||
if (inst.unload && inst.executed) {
|
||||
try {
|
||||
inst.unload();
|
||||
_console.log('[KCC] Userscript unloaded:', inst.meta.name || inst.filename);
|
||||
} catch (err) {
|
||||
_console.error('[KCC] Userscript unload error:', err);
|
||||
}
|
||||
inst.executed = false;
|
||||
inst.unload = null;
|
||||
inst.settings = null;
|
||||
return { needsReload: false };
|
||||
}
|
||||
// No unload function — need page reload to fully disable
|
||||
return { needsReload: inst.executed };
|
||||
} else {
|
||||
// Enabling
|
||||
if (!inst.executed) {
|
||||
executeScript(inst, _console);
|
||||
applyPreferences(inst);
|
||||
return { needsReload: false };
|
||||
}
|
||||
return { needsReload: false };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// ── Shared preload utilities ──
|
||||
// Common types, helpers, and constants used across preload modules.
|
||||
|
||||
// ── Shared interfaces ──
|
||||
|
||||
export interface SavedConsole {
|
||||
log: (...args: unknown[]) => void;
|
||||
warn: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
export interface KeybindDef {
|
||||
key: string;
|
||||
ctrl: boolean;
|
||||
shift: boolean;
|
||||
alt: boolean;
|
||||
}
|
||||
|
||||
// ── HTML escaping ──
|
||||
|
||||
const HTML_ESCAPE_MAP: Record<string, string> = {
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
};
|
||||
|
||||
export function escapeHtml(s: string): string {
|
||||
return s.replace(/[&<>"']/g, c => HTML_ESCAPE_MAP[c]);
|
||||
}
|
||||
|
||||
// ── Chat message injection ──
|
||||
// Creates messages in #chatHolder inside a persistent #kpcMessageHolder div.
|
||||
// timeout=0 means the message is persistent (not auto-removed).
|
||||
|
||||
export function genChatMsg(text: string, timeout = 2.25): HTMLElement | null {
|
||||
const chatHolder = document.getElementById('chatHolder');
|
||||
if (!chatHolder) return null;
|
||||
if (!document.getElementById('kpcMessageHolder')) {
|
||||
chatHolder.insertAdjacentHTML('afterbegin', '<div id="kpcMessageHolder"></div>');
|
||||
}
|
||||
const holder = document.getElementById('kpcMessageHolder')!;
|
||||
holder.insertAdjacentHTML('beforeend',
|
||||
'<div class="chatHolder_kpc"><div class="chatItem_kpc"><span class="chatMsg_kpc">' +
|
||||
escapeHtml(text) + '</span></div></div>');
|
||||
const elem = holder.lastElementChild as HTMLElement;
|
||||
if (timeout !== 0) {
|
||||
setTimeout(() => { elem.remove(); }, timeout * 1000);
|
||||
}
|
||||
return elem;
|
||||
}
|
||||
|
||||
// ── Filename sanitisation ──
|
||||
|
||||
export function sanitizeFilename(name: string): string {
|
||||
return name.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
}
|
||||
|
||||
// ── Shared CSS constants ──
|
||||
|
||||
export const DEATH_ANIM_BLOCK_ID = 'kpc-animationBlock';
|
||||
export const DEATH_ANIM_BLOCK_CSS =
|
||||
'.death-ui-bottom, .death-ui-bottom-empty { animation: none !important; transition: none !important; }';
|
||||
|
||||
/** Inject or remove the death screen animation block style element. */
|
||||
export function setDeathAnimBlock(enabled: boolean): void {
|
||||
let el = document.getElementById(DEATH_ANIM_BLOCK_ID);
|
||||
if (enabled) {
|
||||
if (!el) {
|
||||
el = document.createElement('style');
|
||||
el.id = DEATH_ANIM_BLOCK_ID;
|
||||
el.textContent = DEATH_ANIM_BLOCK_CSS;
|
||||
document.head.appendChild(el);
|
||||
}
|
||||
} else if (el) {
|
||||
el.remove();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user