This commit is contained in:
@@ -0,0 +1,683 @@
|
||||
// ── Shared CSS theme variables (used by both main page and tab bar) ──
|
||||
export const THEME_CSS = `
|
||||
:root {
|
||||
/* ── Surfaces ── */
|
||||
--kpc-surface-card: rgba(255,255,255,0.04);
|
||||
--kpc-surface-input: rgba(255,255,255,0.08);
|
||||
--kpc-surface-hover: rgba(255,255,255,0.1);
|
||||
--kpc-surface-hover-strong: rgba(255,255,255,0.15);
|
||||
--kpc-surface-dialog: #1a1a1a;
|
||||
--kpc-surface-raised: #212121;
|
||||
|
||||
/* ── Text ── */
|
||||
--kpc-text-primary: rgba(255,255,255,0.9);
|
||||
--kpc-text-secondary: rgba(255,255,255,0.7);
|
||||
--kpc-text-muted: rgba(255,255,255,0.5);
|
||||
--kpc-text-faint: rgba(255,255,255,0.35);
|
||||
--kpc-text-dim: rgba(255,255,255,0.3);
|
||||
--kpc-text-info: #888;
|
||||
|
||||
/* ── Borders ── */
|
||||
--kpc-border-subtle: rgba(255,255,255,0.06);
|
||||
--kpc-border-default: rgba(255,255,255,0.1);
|
||||
--kpc-border-medium: rgba(255,255,255,0.15);
|
||||
--kpc-border-focus: rgba(255,255,255,0.35);
|
||||
|
||||
/* ── Accents ── */
|
||||
--kpc-green: #4CAF50;
|
||||
--kpc-green-hover: #66bb6a;
|
||||
--kpc-red: #ef5350;
|
||||
--kpc-red-hover: #e57373;
|
||||
--kpc-blue: #42a5f5;
|
||||
--kpc-blue-hover: #64b5f6;
|
||||
--kpc-orange: #ff9800;
|
||||
--kpc-orange-hover: #ffb74d;
|
||||
--kpc-yellow: #ffc107;
|
||||
--kpc-magenta: #fc03ec;
|
||||
|
||||
/* ── Controls ── */
|
||||
--kpc-toggle-off: rgba(255,255,255,0.12);
|
||||
|
||||
/* ── Z-index layers ── */
|
||||
--kpc-z-notification: 100000;
|
||||
--kpc-z-overlay: 10000000;
|
||||
--kpc-z-popup: 10000001;
|
||||
}
|
||||
`;
|
||||
|
||||
// ── Injected CSS for client settings in Krunker's settings panel ──
|
||||
export const CLIENT_SETTINGS_CSS = `
|
||||
${THEME_CSS}
|
||||
/* ── Crankshaft-style settings (Krunker-native classes) ── */
|
||||
|
||||
.kpc-settings .settName,
|
||||
.kpc-settings .settName .setting-title {
|
||||
color: rgba(255,255,255,.6) !important;
|
||||
}
|
||||
|
||||
.kpc-settings .settName {
|
||||
display: grid;
|
||||
grid-auto-columns: 1fr;
|
||||
grid-template-columns: 0fr 1fr 0fr;
|
||||
grid-template-areas:
|
||||
"icon title input"
|
||||
"desc desc desc";
|
||||
grid-template-rows: 0fr min-content;
|
||||
align-items: center;
|
||||
}
|
||||
.kpc-settings .settName.multisel {
|
||||
grid-template-rows: min-content 1fr;
|
||||
grid-template-columns: 0fr 1fr;
|
||||
grid-template-areas:
|
||||
"icon title"
|
||||
"input input";
|
||||
}
|
||||
.kpc-settings .settName.has-button {
|
||||
grid-template-areas:
|
||||
"icon title button input"
|
||||
"desc desc desc desc";
|
||||
grid-template-columns: 0fr 1fr min-content 0fr;
|
||||
}
|
||||
.kpc-settings .settName.has-button .settingsBtn {
|
||||
grid-area: button;
|
||||
margin: 0 .5rem;
|
||||
}
|
||||
|
||||
.kpc-settings .settName.kpc-button-holder {
|
||||
grid-template-columns: 1fr;
|
||||
grid-auto-columns: min-content;
|
||||
column-gap: 0.25rem;
|
||||
grid-template-areas: unset;
|
||||
grid-template-rows: 0fr;
|
||||
grid-auto-flow: column;
|
||||
}
|
||||
.kpc-settings .kpc-button-holder .buttons-title, .material-icons { color: inherit; }
|
||||
.kpc-settings .kpc-button-holder .settingsBtn,
|
||||
.kpc-settings .settName.has-button .settingsBtn {
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
/* type: num */
|
||||
.kpc-settings .settName.num .setting-input-wrapper {
|
||||
display: flex;
|
||||
}
|
||||
.kpc-settings .settName.num .setting-input-wrapper .slidecontainer {
|
||||
margin-top: -8px;
|
||||
}
|
||||
|
||||
/* type: multisel */
|
||||
.kpc-multisel-parent {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
grid-auto-rows: 1fr;
|
||||
gap: .25rem;
|
||||
background: #232323;
|
||||
border-radius: 10px;
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
.kpc-multisel-parent label.hostOpt {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.kpc-settings .settName.multisel label {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.kpc-settings .settName.multisel input {
|
||||
margin-left: .25rem;
|
||||
}
|
||||
|
||||
/* general settings */
|
||||
.kpc-settings .settName .setting-title {
|
||||
grid-area: title;
|
||||
}
|
||||
|
||||
.kpc-settings .settName .s-update:disabled,
|
||||
.kpc-settings .settName .s-update:disabled+.slider.round {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.kpc-settings .setting .switch {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.kpc-settings .setting .desc-icon {
|
||||
grid-area: icon;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
width: 2.2rem;
|
||||
height: 2.2rem;
|
||||
line-height: 2.2rem;
|
||||
border-radius: 5px !important;
|
||||
color: #969696;
|
||||
background-color: rgba(99, 99, 99, 0.16);
|
||||
border: 2px solid rgba(78, 78, 78, 0.81);
|
||||
margin-right: 10px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.kpc-settings .setting .desc-icon.instant {
|
||||
background-color: rgba(1, 89, 220, 0.16);
|
||||
border: 2px solid rgba(3, 133, 255, 0.81);
|
||||
}
|
||||
|
||||
.kpc-settings .setting .desc-icon.instant svg path {
|
||||
color: #0385ff;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.kpc-settings .setting.settName .inputGrey2,
|
||||
.kpc-settings .setting.settName .switch,
|
||||
.kpc-settings .setting.settName .kpc-multisel-parent,
|
||||
.kpc-settings .setting.settName .setting-input-wrapper,
|
||||
.kpc-settings .setting.settName .keyIcon {
|
||||
grid-area: input;
|
||||
}
|
||||
|
||||
.kpc-settings .setting.safety-1 .desc-icon,
|
||||
.kpc-settings .setting .desc-icon.refresh-icon,
|
||||
.kpc-settings .setting .desc-icon.restart-icon {
|
||||
background-color: rgba(99, 99, 99, 0.16);
|
||||
border: 2px solid rgba(78, 78, 78, 0.81);
|
||||
}
|
||||
|
||||
.kpc-settings .setting.safety-1 .desc-icon svg path,
|
||||
.kpc-settings .setting .desc-icon.refresh-icon svg path,
|
||||
.kpc-settings .setting .desc-icon.restart-icon svg path {
|
||||
color: #969696;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.kpc-settings .setting.safety-2 .desc-icon {
|
||||
background-color: rgba(220, 180, 1, 0.16);
|
||||
border: 2px solid rgba(241, 186, 6, 0.81);
|
||||
}
|
||||
|
||||
.kpc-settings .setting.safety-2 .desc-icon svg path {
|
||||
color: #ffd903;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.kpc-settings .setting.safety-3 .desc-icon {
|
||||
background-color: rgba(220, 118, 1, 0.16);
|
||||
border: 2px solid rgba(241, 131, 6, 0.81);
|
||||
}
|
||||
|
||||
.kpc-settings .setting.safety-3 .desc-icon svg path {
|
||||
color: #ff9203;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.kpc-settings .setting.safety-4 .desc-icon {
|
||||
background-color: rgba(220, 17, 1, 0.16);
|
||||
border: 2px solid rgba(239, 6, 6, 0.81);
|
||||
}
|
||||
|
||||
.kpc-settings .setting.safety-4 .desc-icon svg path {
|
||||
color: #ff0303;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.desc-icon {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.setting-desc-new {
|
||||
display: block;
|
||||
width: fit-content;
|
||||
max-width: 50ch;
|
||||
line-height: 30px;
|
||||
font-size: 15px;
|
||||
letter-spacing: 0.5px;
|
||||
word-wrap: break-word;
|
||||
color: rgba(255, 255, 255, 0.4) !important;
|
||||
overflow: hidden;
|
||||
max-height: 500px;
|
||||
margin-top: 6px;
|
||||
grid-area: desc;
|
||||
}
|
||||
|
||||
.setting-desc-new a {
|
||||
font-size: inherit !important;
|
||||
font-family: inherit !important;
|
||||
}
|
||||
|
||||
.setting-category-collapsed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* keybind display */
|
||||
.keyIcon.kpc-keyIcon:hover {
|
||||
transform: scale(1.25);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.keyIcon.kpc-keyIcon {
|
||||
display: inline-block;
|
||||
transition: 0s;
|
||||
}
|
||||
|
||||
/* ── KPC action button grid ── */
|
||||
.kpc-action-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
padding: 0 12px 12px;
|
||||
}
|
||||
.kpc-action-btn {
|
||||
background: var(--kpc-surface-card);
|
||||
color: var(--kpc-text-primary);
|
||||
border: 2px solid var(--kpc-border-medium);
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
.kpc-action-btn:hover {
|
||||
background: var(--kpc-surface-hover);
|
||||
border-color: var(--kpc-border-focus);
|
||||
}
|
||||
.kpc-action-btn:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
.kpc-action-btn.full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.kpc-action-btn.kpc-ab-purple { border-color: #ab47bc; }
|
||||
.kpc-action-btn.kpc-ab-purple:hover { border-color: #ce93d8; }
|
||||
.kpc-action-btn.kpc-ab-cyan { border-color: #00bcd4; }
|
||||
.kpc-action-btn.kpc-ab-cyan:hover { border-color: #4dd0e1; }
|
||||
.kpc-action-btn.kpc-ab-pink { border-color: #ec407a; }
|
||||
.kpc-action-btn.kpc-ab-pink:hover { border-color: #f48fb1; }
|
||||
.kpc-action-btn.kpc-ab-red { border-color: var(--kpc-red); }
|
||||
.kpc-action-btn.kpc-ab-red:hover { border-color: var(--kpc-red-hover); }
|
||||
.kpc-action-btn.kpc-ab-orange { border-color: var(--kpc-orange); }
|
||||
.kpc-action-btn.kpc-ab-orange:hover { border-color: var(--kpc-orange-hover); }
|
||||
|
||||
/* floating toasts css that is required */
|
||||
.kpc-holder-update {
|
||||
position: absolute;
|
||||
font-size: 1.125rem !important;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
display: block !important;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
background-color: black;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
width: max-content;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* settings refresh popup */
|
||||
.refresh-popup {
|
||||
height: min-content;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
color: rgba(255,255,255,0.6)
|
||||
}
|
||||
.refresh-popup span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
column-gap: 0.5rem;
|
||||
color: rgba(255,255,255,0.6);
|
||||
}
|
||||
.refresh-popup,
|
||||
.refresh-popup span,
|
||||
.refresh-popup a {
|
||||
vertical-align: middle;
|
||||
font-size: .8rem;
|
||||
line-height: .8rem;
|
||||
z-index: 12;
|
||||
}
|
||||
.refresh-popup svg { fill: rgba(255,255,255,0.6); }
|
||||
.refresh-popup code {
|
||||
color: white;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.2rem;
|
||||
font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace;
|
||||
background-color: #232323;
|
||||
padding: 0.08rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
border: 2px solid #333333
|
||||
}
|
||||
/* ── Keybind capture dialog ── */
|
||||
.kpc-keybind-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: var(--kpc-z-overlay);
|
||||
background: rgba(0,0,0,0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.kpc-keybind-dialog {
|
||||
background: var(--kpc-surface-dialog);
|
||||
border: 1px solid var(--kpc-border-medium);
|
||||
border-radius: 10px;
|
||||
padding: 24px 32px;
|
||||
min-width: 400px;
|
||||
position: relative;
|
||||
}
|
||||
.kpc-keybind-dialog-title {
|
||||
color: var(--kpc-text-primary);
|
||||
font-size: 18px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.kpc-keybind-dialog-sub {
|
||||
color: var(--kpc-text-muted);
|
||||
font-size: 13px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.kpc-keybind-dialog-sub code {
|
||||
color: #64b5f6;
|
||||
}
|
||||
.kpc-keybind-dialog-modifiers {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.kpc-keybind-modifier {
|
||||
background: var(--kpc-surface-raised);
|
||||
color: var(--kpc-text-faint);
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 10px 0;
|
||||
border-radius: 6px;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.kpc-keybind-modifier.active {
|
||||
background: #1976d2;
|
||||
color: #fff;
|
||||
}
|
||||
.kpc-keybind-dialog-cancel {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 16px;
|
||||
color: #64b5f6;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
.kpc-keybind-dialog-cancel:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
/* ── Preserved: color input, userscript meta ── */
|
||||
.kpc-color-input {
|
||||
width: 36px;
|
||||
height: 28px;
|
||||
border: 1px solid var(--kpc-border-default);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.kpc-color-input::-webkit-color-swatch-wrapper {
|
||||
padding: 2px;
|
||||
}
|
||||
.kpc-color-input::-webkit-color-swatch {
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.kpc-us-meta {
|
||||
color: var(--kpc-text-dim);
|
||||
font-size: 11px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.kpc-us-settings {
|
||||
padding: 4px 0 4px 20px;
|
||||
}
|
||||
#chatList, #chatList * {
|
||||
user-select: text !important;
|
||||
cursor: text;
|
||||
}
|
||||
#chatList.kpc-chat-paused {
|
||||
border-left: 2px solid var(--kpc-yellow);
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
// ── Matchmaker popup CSS + settings extras (injected separately) ──
|
||||
export const MATCHMAKER_SETTINGS_CSS = `
|
||||
@keyframes matchmakerPopupSlideDown {
|
||||
0% { transform: translate(-50%, -500%); }
|
||||
100% { transform: translate(-50%, 0%); }
|
||||
}
|
||||
.onGame #matchmakerPopupContainer:not(.searching) {
|
||||
opacity: 0 !important;
|
||||
}
|
||||
#matchmakerPopupContainer {
|
||||
position: absolute;
|
||||
top: 10em;
|
||||
left: 50%;
|
||||
z-index: var(--kpc-z-popup);
|
||||
box-sizing: border-box;
|
||||
width: 35em;
|
||||
aspect-ratio: 2.5/1;
|
||||
border-radius: 1.2em;
|
||||
overflow: hidden;
|
||||
background-size: 100% 100%;
|
||||
pointer-events: all;
|
||||
background-color: var(--kpc-surface-raised);
|
||||
animation: matchmakerPopupSlideDown 0.5s ease forwards;
|
||||
}
|
||||
#matchmakerPopupTitle {
|
||||
font-size: 1.8em;
|
||||
color: white;
|
||||
padding: 0.3em 0.7em;
|
||||
background: rgba(0,0,0,0.5);
|
||||
margin-bottom: 0.3em;
|
||||
}
|
||||
#matchmakerPopupDescription {
|
||||
background: rgba(0,0,0,0.5);
|
||||
color: var(--kpc-yellow);
|
||||
box-sizing: border-box;
|
||||
padding: 0.6em 1em;
|
||||
}
|
||||
#matchmakerPopupOptions {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
}
|
||||
.matchmakerPopupButton {
|
||||
text-align: center;
|
||||
border: 0.3em solid;
|
||||
box-sizing: border-box;
|
||||
margin: 0.5em;
|
||||
color: white;
|
||||
border-radius: 0.3em;
|
||||
font-size: 1.3em;
|
||||
background-color: rgba(0,0,0,0.5);
|
||||
padding: 0.2em 1.4em;
|
||||
transition: all 0.08s;
|
||||
}
|
||||
#matchmakerConfirmButton {
|
||||
border-color: var(--kpc-green);
|
||||
flex-grow: 1;
|
||||
}
|
||||
#matchmakerCancelButton {
|
||||
border-color: var(--kpc-red);
|
||||
}
|
||||
.matchmakerPopupButton:hover {
|
||||
cursor: pointer;
|
||||
border-color: white !important;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
.matchmakerPopupButton:active {
|
||||
transform: scale(0.85);
|
||||
}
|
||||
|
||||
/* ── Search phase ── */
|
||||
#matchmakerPopupContainer.searching {
|
||||
background-image: none !important;
|
||||
background: var(--kpc-surface-raised);
|
||||
width: 24em;
|
||||
aspect-ratio: auto;
|
||||
padding: 1em 1.5em;
|
||||
}
|
||||
#matchmakerPopupContainer.searching #matchmakerPopupTitle,
|
||||
#matchmakerPopupContainer.searching #matchmakerPopupDescription,
|
||||
#matchmakerPopupContainer.searching #matchmakerPopupOptions {
|
||||
display: none;
|
||||
}
|
||||
#matchmakerPopupContainer:not(.searching) #matchmakerSearchContainer {
|
||||
display: none;
|
||||
}
|
||||
#matchmakerSearchStatus {
|
||||
font-size: 1.4em;
|
||||
color: var(--kpc-blue);
|
||||
margin-bottom: 0.6em;
|
||||
text-align: center;
|
||||
}
|
||||
#matchmakerSearchFeed {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15em;
|
||||
overflow: hidden;
|
||||
min-height: 5.6em;
|
||||
margin-bottom: 0.6em;
|
||||
}
|
||||
@keyframes mmFeedSlideIn {
|
||||
from { opacity: 0; transform: translateX(1em); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
.mm-feed-entry {
|
||||
display: flex;
|
||||
gap: 0.8em;
|
||||
padding: 0.2em 0.5em;
|
||||
font-size: 0.95em;
|
||||
font-family: 'GameFont', monospace;
|
||||
border-radius: 0.2em;
|
||||
animation: mmFeedSlideIn 0.12s ease forwards;
|
||||
}
|
||||
.mm-feed-entry.mm-pass { background: rgba(76,175,80,0.1); }
|
||||
.mm-feed-entry.mm-pass .mm-feed-region { color: var(--kpc-blue); }
|
||||
.mm-feed-entry.mm-pass .mm-feed-map { color: var(--kpc-text-primary, rgba(255,255,255,0.9)); }
|
||||
.mm-feed-entry.mm-pass .mm-feed-players { color: var(--kpc-green); }
|
||||
.mm-feed-entry.mm-fail { background: rgba(255,255,255,0.02); }
|
||||
.mm-feed-entry.mm-fail .mm-feed-region { color: var(--kpc-text-dim, rgba(255,255,255,0.3)); }
|
||||
.mm-feed-entry.mm-fail .mm-feed-map { color: var(--kpc-text-muted, rgba(255,255,255,0.5)); }
|
||||
.mm-feed-entry.mm-fail .mm-feed-players { color: var(--kpc-red); }
|
||||
.mm-feed-entry:last-child::before {
|
||||
content: '\\25B8 ';
|
||||
color: var(--kpc-yellow);
|
||||
}
|
||||
.mm-feed-region { min-width: 2.5em; font-weight: bold; }
|
||||
.mm-feed-map { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.mm-feed-players { min-width: 3em; text-align: right; font-weight: 600; }
|
||||
#matchmakerSearchCounter {
|
||||
font-size: 0.85em;
|
||||
color: var(--kpc-yellow);
|
||||
text-align: center;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
#matchmakerSearchCancel {
|
||||
text-align: center;
|
||||
border: 0.2em solid var(--kpc-red);
|
||||
color: white;
|
||||
border-radius: 0.3em;
|
||||
font-size: 1.1em;
|
||||
background: rgba(0,0,0,0.3);
|
||||
padding: 0.2em 1.2em;
|
||||
cursor: pointer;
|
||||
margin: 0 auto;
|
||||
width: fit-content;
|
||||
transition: all 0.08s;
|
||||
}
|
||||
#matchmakerSearchCancel:hover {
|
||||
border-color: white;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
#matchmakerSearchCancel:active {
|
||||
transform: scale(0.85);
|
||||
}
|
||||
`;
|
||||
|
||||
export const TRANSLATOR_CSS = `
|
||||
.kcc-translation {
|
||||
color: #88ff88;
|
||||
font-style: italic;
|
||||
margin-left: 8px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
`;
|
||||
|
||||
// ── Alt Manager CSS ──
|
||||
export const ALT_MANAGER_CSS = `
|
||||
.kpc-acc-form { display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px; }
|
||||
.kpc-acc-form input {
|
||||
background: var(--kpc-surface-input); border: 1px solid var(--kpc-border); border-radius: 4px;
|
||||
color: #fff; padding: 6px 10px; font-size: 13px; outline: none; font-family: inherit;
|
||||
}
|
||||
.kpc-acc-form input:focus { border-color: var(--kpc-accent); }
|
||||
.kpc-acc-form input::placeholder { color: rgba(255,255,255,0.3); }
|
||||
.kpc-acc-form-buttons { display: flex; gap: 8px; }
|
||||
.kpc-acc-form-buttons button {
|
||||
padding: 6px 16px; border: none; border-radius: 4px; cursor: pointer;
|
||||
font-size: 13px; font-family: inherit;
|
||||
}
|
||||
.kpc-acc-form-buttons .kpc-acc-save {
|
||||
background: var(--kpc-accent); color: #fff;
|
||||
}
|
||||
.kpc-acc-form-buttons .kpc-acc-save:hover { filter: brightness(1.2); }
|
||||
.kpc-acc-form-buttons .kpc-acc-cancel {
|
||||
background: var(--kpc-surface-hover); color: #fff;
|
||||
}
|
||||
.kpc-acc-form-buttons .kpc-acc-cancel:hover { background: var(--kpc-surface-hover-strong); }
|
||||
.kpc-acc-item {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 8px 12px; background: var(--kpc-surface-card); border-radius: 6px; margin-bottom: 6px;
|
||||
}
|
||||
.kpc-acc-item-info { display: flex; align-items: center; gap: 8px; }
|
||||
.kpc-acc-item-label { color: #fff; font-size: 14px; font-weight: 500; }
|
||||
.kpc-acc-item-role {
|
||||
font-size: 11px; padding: 2px 6px; border-radius: 3px;
|
||||
background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.6);
|
||||
}
|
||||
.kpc-acc-item-actions { display: flex; gap: 6px; }
|
||||
.kpc-acc-item-actions button {
|
||||
padding: 4px 12px; border: none; border-radius: 4px; cursor: pointer;
|
||||
font-size: 12px; font-family: inherit;
|
||||
}
|
||||
.kpc-acc-switch { background: var(--kpc-accent); color: #fff; }
|
||||
.kpc-acc-switch:hover { filter: brightness(1.2); }
|
||||
.kpc-acc-delete { background: rgba(255,80,80,0.2); color: #ff5050; }
|
||||
.kpc-acc-delete:hover { background: rgba(255,80,80,0.35); }
|
||||
.kpc-acc-empty { color: rgba(255,255,255,0.4); font-size: 13px; text-align: center; padding: 16px 0; }
|
||||
.kpc-alt-overlay-backdrop {
|
||||
position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: 99998;
|
||||
background: rgba(0,0,0,0.5);
|
||||
}
|
||||
.kpc-alt-overlay {
|
||||
position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
|
||||
background: var(--kpc-surface-dialog, #1a1a1a); border-radius: 8px;
|
||||
padding: 16px; min-width: 280px; max-width: 360px; z-index: 99999;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.6);
|
||||
}
|
||||
.kpc-alt-overlay h3 {
|
||||
margin: 0 0 12px; color: #fff; font-size: 16px; font-weight: 600;
|
||||
}
|
||||
`;
|
||||
|
||||
// ── HP enemy counter CSS ──
|
||||
export const HP_COUNTER_CSS = `
|
||||
.kpc-hp-counter .pointVal {
|
||||
color: #ff4444; font-size: 15px; font-weight: bold;
|
||||
}
|
||||
`;
|
||||
|
||||
/** Pre-concatenated CSS for single-call injection (excludes HIDE_ADS_CSS which is separate) */
|
||||
export const ALL_CLIENT_CSS = `${CLIENT_SETTINGS_CSS}\n${MATCHMAKER_SETTINGS_CSS}\n${TRANSLATOR_CSS}\n${ALT_MANAGER_CSS}\n${HP_COUNTER_CSS}`;
|
||||
@@ -0,0 +1,228 @@
|
||||
import Store from 'electron-store';
|
||||
import { detectPlatform } from './platform';
|
||||
|
||||
export interface Keybind {
|
||||
key: string;
|
||||
ctrl: boolean;
|
||||
shift: boolean;
|
||||
alt: boolean;
|
||||
}
|
||||
|
||||
export interface SavedAccount {
|
||||
label: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
window: {
|
||||
width: number;
|
||||
height: number;
|
||||
x: number | undefined;
|
||||
y: number | undefined;
|
||||
maximized: boolean;
|
||||
fullscreen: boolean;
|
||||
};
|
||||
performance: {
|
||||
fpsUnlocked: boolean;
|
||||
hardwareAccel: boolean;
|
||||
gpuPreference: 'high-performance' | 'low-power' | 'default';
|
||||
cpuThrottleGame: number;
|
||||
cpuThrottleMenu: number;
|
||||
processPriority: string;
|
||||
};
|
||||
game: {
|
||||
lastServer: string;
|
||||
socialTabBehaviour: 'New Window' | 'Same Window';
|
||||
joinAsSpectator: boolean;
|
||||
rawInput: boolean;
|
||||
betterChat: boolean;
|
||||
chatHistorySize: number;
|
||||
showPing: boolean;
|
||||
hpEnemyCounter: boolean;
|
||||
};
|
||||
swapper: {
|
||||
enabled: boolean;
|
||||
path: string;
|
||||
};
|
||||
matchmaker: {
|
||||
enabled: boolean;
|
||||
regions: string[];
|
||||
gamemodes: string[];
|
||||
maps: string[];
|
||||
minPlayers: number;
|
||||
maxPlayers: number;
|
||||
minRemainingTime: number;
|
||||
openServerBrowser: boolean;
|
||||
autoJoin: boolean;
|
||||
};
|
||||
keybinds: {
|
||||
reload: Keybind;
|
||||
newMatch: Keybind;
|
||||
copyGameLink: Keybind;
|
||||
joinFromClipboard: Keybind;
|
||||
devTools: Keybind;
|
||||
matchmaker: Keybind;
|
||||
matchmakerAccept: Keybind;
|
||||
matchmakerCancel: Keybind;
|
||||
pauseChat: Keybind;
|
||||
fullscreenToggle: Keybind;
|
||||
};
|
||||
userscripts: {
|
||||
enabled: boolean;
|
||||
path: string;
|
||||
};
|
||||
ui: {
|
||||
showExitButton: boolean;
|
||||
deathscreenAnimation: boolean;
|
||||
hideMenuPopups: boolean;
|
||||
cleanerMenu: boolean;
|
||||
doublePing: boolean;
|
||||
cssTheme: string;
|
||||
loadingTheme: string;
|
||||
backgroundUrl: string;
|
||||
showChangelog: boolean;
|
||||
lastSeenVersion: string;
|
||||
};
|
||||
discord: {
|
||||
enabled: boolean;
|
||||
};
|
||||
translator: {
|
||||
enabled: boolean;
|
||||
targetLanguage: string;
|
||||
showLanguageTag: boolean;
|
||||
};
|
||||
advanced: {
|
||||
removeUselessFeatures: boolean;
|
||||
gpuRasterizing: boolean;
|
||||
helpfulFlags: boolean;
|
||||
disableAccelerated2D: boolean;
|
||||
increaseLimits: boolean;
|
||||
lowLatency: boolean;
|
||||
experimentalFlags: boolean;
|
||||
angleBackend: string;
|
||||
verboseLogging: boolean;
|
||||
};
|
||||
accounts: SavedAccount[];
|
||||
tabWindow: {
|
||||
width: number;
|
||||
height: number;
|
||||
x: number | undefined;
|
||||
y: number | undefined;
|
||||
maximized: boolean;
|
||||
};
|
||||
platform: {
|
||||
detectedOS: string;
|
||||
gpuBackend: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const DEFAULT_KEYBINDS: AppConfig['keybinds'] = {
|
||||
reload: { key: 'F5', ctrl: false, shift: false, alt: false },
|
||||
newMatch: { key: 'F4', ctrl: false, shift: false, alt: false },
|
||||
copyGameLink: { key: 'l', ctrl: true, shift: false, alt: false },
|
||||
joinFromClipboard: { key: 'j', ctrl: true, shift: false, alt: false },
|
||||
devTools: { key: 'F12', ctrl: false, shift: false, alt: false },
|
||||
matchmaker: { key: 'F6', ctrl: false, shift: false, alt: false },
|
||||
matchmakerAccept: { key: 'Enter', ctrl: false, shift: false, alt: false },
|
||||
matchmakerCancel: { key: 'Escape', ctrl: false, shift: false, alt: false },
|
||||
pauseChat: { key: 'F10', ctrl: false, shift: false, alt: false },
|
||||
fullscreenToggle: { key: 'F11', ctrl: false, shift: false, alt: false },
|
||||
};
|
||||
|
||||
const platformInfo = detectPlatform();
|
||||
|
||||
export const config = new Store<AppConfig>({
|
||||
name: 'krunker-civilian-config',
|
||||
defaults: {
|
||||
window: {
|
||||
width: 1600,
|
||||
height: 900,
|
||||
x: undefined,
|
||||
y: undefined,
|
||||
maximized: false,
|
||||
fullscreen: false,
|
||||
},
|
||||
performance: {
|
||||
fpsUnlocked: true,
|
||||
hardwareAccel: true,
|
||||
gpuPreference: 'high-performance',
|
||||
cpuThrottleGame: 1,
|
||||
cpuThrottleMenu: 1.5,
|
||||
processPriority: 'Normal',
|
||||
},
|
||||
game: {
|
||||
lastServer: '',
|
||||
socialTabBehaviour: 'New Window',
|
||||
joinAsSpectator: false,
|
||||
rawInput: true,
|
||||
betterChat: true,
|
||||
chatHistorySize: 200,
|
||||
showPing: true,
|
||||
hpEnemyCounter: true,
|
||||
},
|
||||
swapper: {
|
||||
enabled: true,
|
||||
path: '',
|
||||
},
|
||||
matchmaker: {
|
||||
enabled: true,
|
||||
regions: [],
|
||||
gamemodes: [],
|
||||
maps: [],
|
||||
minPlayers: 1,
|
||||
maxPlayers: 6,
|
||||
minRemainingTime: 120,
|
||||
openServerBrowser: true,
|
||||
autoJoin: false,
|
||||
},
|
||||
keybinds: DEFAULT_KEYBINDS,
|
||||
userscripts: {
|
||||
enabled: true,
|
||||
path: '',
|
||||
},
|
||||
ui: {
|
||||
showExitButton: true,
|
||||
deathscreenAnimation: true,
|
||||
hideMenuPopups: false,
|
||||
cleanerMenu: false,
|
||||
doublePing: true,
|
||||
cssTheme: 'disabled',
|
||||
loadingTheme: 'disabled',
|
||||
backgroundUrl: '',
|
||||
showChangelog: true,
|
||||
lastSeenVersion: '',
|
||||
},
|
||||
discord: {
|
||||
enabled: false,
|
||||
},
|
||||
translator: {
|
||||
enabled: true,
|
||||
targetLanguage: 'en',
|
||||
showLanguageTag: true,
|
||||
},
|
||||
advanced: {
|
||||
removeUselessFeatures: true,
|
||||
gpuRasterizing: false,
|
||||
helpfulFlags: true,
|
||||
disableAccelerated2D: false,
|
||||
increaseLimits: false,
|
||||
lowLatency: false,
|
||||
experimentalFlags: false,
|
||||
angleBackend: 'default',
|
||||
verboseLogging: false,
|
||||
},
|
||||
accounts: [],
|
||||
tabWindow: {
|
||||
width: 1280,
|
||||
height: 720,
|
||||
x: undefined,
|
||||
y: undefined,
|
||||
maximized: true,
|
||||
},
|
||||
platform: {
|
||||
detectedOS: platformInfo.os,
|
||||
gpuBackend: platformInfo.gpuBackend,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
// ── CSS theme & loading screen background management ──
|
||||
// Scans swap directory for user CSS themes and loading screen backgrounds.
|
||||
|
||||
import { readdirSync, readFileSync } from 'fs';
|
||||
import { join, extname, basename } from 'path';
|
||||
|
||||
export interface ThemeEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface LoadingThemeEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function listThemes(swapDir: string): ThemeEntry[] {
|
||||
const entries: ThemeEntry[] = [{ id: 'disabled', label: 'Disabled' }];
|
||||
const themesDir = join(swapDir, 'themes');
|
||||
try {
|
||||
const files = readdirSync(themesDir);
|
||||
for (const file of files) {
|
||||
if (extname(file).toLowerCase() === '.css') {
|
||||
entries.push({ id: `user:${file}`, label: basename(file, '.css') });
|
||||
}
|
||||
}
|
||||
} catch { /* themes dir doesn't exist yet — that's fine */ }
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function getThemeCSS(themeId: string, swapDir: string): string {
|
||||
if (themeId === 'disabled' || !themeId) return '';
|
||||
const prefix = 'user:';
|
||||
if (!themeId.startsWith(prefix)) return '';
|
||||
const filename = themeId.slice(prefix.length);
|
||||
try {
|
||||
return readFileSync(join(swapDir, 'themes', filename), 'utf-8');
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp']);
|
||||
|
||||
export function listLoadingThemes(swapDir: string): LoadingThemeEntry[] {
|
||||
const entries: LoadingThemeEntry[] = [
|
||||
{ id: 'disabled', label: 'Disabled (Default)' },
|
||||
{ id: 'swap:random', label: 'Random (from backgrounds/)' },
|
||||
];
|
||||
const bgDir = join(swapDir, 'backgrounds');
|
||||
try {
|
||||
const files = readdirSync(bgDir);
|
||||
for (const file of files) {
|
||||
if (IMAGE_EXTS.has(extname(file).toLowerCase())) {
|
||||
entries.push({ id: `swap:${file}`, label: file });
|
||||
}
|
||||
}
|
||||
} catch { /* backgrounds dir doesn't exist yet */ }
|
||||
return entries;
|
||||
}
|
||||
|
||||
function mimeFromExt(ext: string): string {
|
||||
switch (ext.toLowerCase()) {
|
||||
case '.jpg':
|
||||
case '.jpeg':
|
||||
return 'image/jpeg';
|
||||
case '.gif':
|
||||
return 'image/gif';
|
||||
case '.webp':
|
||||
return 'image/webp';
|
||||
default:
|
||||
return 'image/png';
|
||||
}
|
||||
}
|
||||
|
||||
function getBackgroundFiles(swapDir: string): string[] {
|
||||
const bgDir = join(swapDir, 'backgrounds');
|
||||
try {
|
||||
return readdirSync(bgDir).filter(f => IMAGE_EXTS.has(extname(f).toLowerCase()));
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
function fileToDataUri(filePath: string): string {
|
||||
const data = readFileSync(filePath);
|
||||
const mime = mimeFromExt(extname(filePath));
|
||||
return `data:${mime};base64,${data.toString('base64')}`;
|
||||
}
|
||||
|
||||
export function getLoadingScreenCSS(loadingTheme: string, backgroundUrl: string, swapDir: string): string {
|
||||
let imageUrl = '';
|
||||
|
||||
// Explicit URL takes priority
|
||||
if (backgroundUrl) {
|
||||
try {
|
||||
new URL(backgroundUrl);
|
||||
imageUrl = `url(${backgroundUrl})`;
|
||||
} catch { /* invalid URL — ignore */ }
|
||||
}
|
||||
|
||||
if (!imageUrl && loadingTheme && loadingTheme !== 'disabled') {
|
||||
const bgDir = join(swapDir, 'backgrounds');
|
||||
if (loadingTheme === 'swap:random') {
|
||||
const files = getBackgroundFiles(swapDir);
|
||||
if (files.length > 0) {
|
||||
const pick = files[Math.floor(Math.random() * files.length)];
|
||||
try {
|
||||
imageUrl = `url(${fileToDataUri(join(bgDir, pick))})`;
|
||||
} catch { /* read failed */ }
|
||||
}
|
||||
} else if (loadingTheme.startsWith('swap:')) {
|
||||
const filename = loadingTheme.slice(5);
|
||||
try {
|
||||
imageUrl = `url(${fileToDataUri(join(bgDir, filename))})`;
|
||||
} catch { /* read failed */ }
|
||||
}
|
||||
}
|
||||
|
||||
if (!imageUrl) return '';
|
||||
|
||||
return `
|
||||
#instructionHolder[style^="display: block"] {
|
||||
background-image: initial !important;
|
||||
}
|
||||
#instructionHolder {
|
||||
background-image: ${imageUrl} !important;
|
||||
background-size: cover !important;
|
||||
background-position: center !important;
|
||||
}
|
||||
#instructions {
|
||||
display: block;
|
||||
visibility: hidden;
|
||||
}`;
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import { Socket } from 'net';
|
||||
import { electronLog } from './logger';
|
||||
|
||||
const DISCORD_CLIENT_ID = '1477679025248800982';
|
||||
|
||||
// Discord IPC opcodes
|
||||
const OP_HANDSHAKE = 0;
|
||||
const OP_FRAME = 1;
|
||||
const OP_CLOSE = 2;
|
||||
|
||||
// Rate limit: Discord rejects updates faster than 15s
|
||||
const RATE_LIMIT_MS = 5000;
|
||||
const RECONNECT_INTERVAL_MS = 30000;
|
||||
|
||||
export interface ActivityPayload {
|
||||
details?: string;
|
||||
state?: string;
|
||||
startTimestamp?: number;
|
||||
largeImageKey?: string;
|
||||
largeImageText?: string;
|
||||
}
|
||||
|
||||
function getPipePath(id: number): string {
|
||||
if (process.platform === 'win32') {
|
||||
return `\\\\?\\pipe\\discord-ipc-${id}`;
|
||||
}
|
||||
// Linux/macOS: check XDG_RUNTIME_DIR, TMPDIR, TMP, TEMP, /tmp
|
||||
const dir = process.env.XDG_RUNTIME_DIR
|
||||
|| process.env.TMPDIR
|
||||
|| process.env.TMP
|
||||
|| process.env.TEMP
|
||||
|| '/tmp';
|
||||
return `${dir}/discord-ipc-${id}`;
|
||||
}
|
||||
|
||||
function encodeFrame(opcode: number, payload: object): Buffer {
|
||||
const json = JSON.stringify(payload);
|
||||
const jsonBuf = Buffer.from(json);
|
||||
const header = Buffer.alloc(8);
|
||||
header.writeUInt32LE(opcode, 0);
|
||||
header.writeUInt32LE(jsonBuf.length, 4);
|
||||
return Buffer.concat([header, jsonBuf]);
|
||||
}
|
||||
|
||||
export class DiscordRPC {
|
||||
private socket: Socket | null = null;
|
||||
private connected = false;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private lastUpdate = 0;
|
||||
private nonce = 0;
|
||||
private destroyed = false;
|
||||
private recvBuf = Buffer.alloc(0);
|
||||
private pendingActivity: ActivityPayload | null = null;
|
||||
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
get isConnected(): boolean {
|
||||
return this.connected;
|
||||
}
|
||||
|
||||
connect(): void {
|
||||
if (this.destroyed) return;
|
||||
this.tryConnect(0);
|
||||
}
|
||||
|
||||
private tryConnect(pipeIndex: number): void {
|
||||
if (this.destroyed || pipeIndex > 9) {
|
||||
this.scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
const pipePath = getPipePath(pipeIndex);
|
||||
const sock = new Socket();
|
||||
let settled = false;
|
||||
|
||||
const onError = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
sock.destroy();
|
||||
// Try next pipe index
|
||||
this.tryConnect(pipeIndex + 1);
|
||||
};
|
||||
|
||||
sock.once('error', onError);
|
||||
|
||||
sock.connect(pipePath, () => {
|
||||
if (settled || this.destroyed) {
|
||||
sock.destroy();
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
this.socket = sock;
|
||||
this.recvBuf = Buffer.alloc(0);
|
||||
|
||||
// Remove the initial error handler and set up persistent ones
|
||||
sock.removeListener('error', onError);
|
||||
sock.on('error', (err) => {
|
||||
electronLog.warn('[KCC-Discord] Socket error:', err.message);
|
||||
this.handleDisconnect();
|
||||
});
|
||||
sock.on('close', () => {
|
||||
this.handleDisconnect();
|
||||
});
|
||||
sock.on('data', (data) => {
|
||||
this.onData(data);
|
||||
});
|
||||
|
||||
// Send handshake
|
||||
const handshake = encodeFrame(OP_HANDSHAKE, {
|
||||
v: 1,
|
||||
client_id: DISCORD_CLIENT_ID,
|
||||
});
|
||||
sock.write(handshake);
|
||||
});
|
||||
|
||||
// Connection timeout — 5s
|
||||
sock.setTimeout(5000, onError);
|
||||
}
|
||||
|
||||
private onData(data: Buffer): void {
|
||||
this.recvBuf = Buffer.concat([this.recvBuf, data]);
|
||||
|
||||
while (this.recvBuf.length >= 8) {
|
||||
const opcode = this.recvBuf.readUInt32LE(0);
|
||||
const length = this.recvBuf.readUInt32LE(4);
|
||||
|
||||
if (this.recvBuf.length < 8 + length) break;
|
||||
|
||||
const jsonBuf = this.recvBuf.slice(8, 8 + length);
|
||||
this.recvBuf = this.recvBuf.slice(8 + length);
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(jsonBuf.toString());
|
||||
this.handleMessage(opcode, payload);
|
||||
} catch {
|
||||
// Malformed JSON — ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleMessage(opcode: number, payload: any): void {
|
||||
if (opcode === OP_FRAME) {
|
||||
if (payload.cmd === 'DISPATCH' && payload.evt === 'READY') {
|
||||
this.connected = true;
|
||||
electronLog.log('[KCC-Discord] Connected to Discord');
|
||||
// Flush any activity that was set before connection completed
|
||||
if (this.pendingActivity) {
|
||||
this.sendActivity(this.pendingActivity);
|
||||
this.pendingActivity = null;
|
||||
}
|
||||
}
|
||||
} else if (opcode === OP_CLOSE) {
|
||||
electronLog.warn('[KCC-Discord] Discord closed connection:', payload.message || '');
|
||||
this.handleDisconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private handleDisconnect(): void {
|
||||
if (!this.connected && !this.socket) return;
|
||||
this.connected = false;
|
||||
if (this.flushTimer) {
|
||||
clearTimeout(this.flushTimer);
|
||||
this.flushTimer = null;
|
||||
}
|
||||
if (this.socket) {
|
||||
this.socket.destroy();
|
||||
this.socket = null;
|
||||
}
|
||||
this.recvBuf = Buffer.alloc(0);
|
||||
electronLog.log('[KCC-Discord] Disconnected');
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.destroyed || this.reconnectTimer) return;
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
if (!this.destroyed && !this.connected) {
|
||||
this.tryConnect(0);
|
||||
}
|
||||
}, RECONNECT_INTERVAL_MS);
|
||||
}
|
||||
|
||||
setActivity(activity: ActivityPayload): void {
|
||||
if (this.destroyed) return;
|
||||
|
||||
// Always store latest activity so it can be sent on (re)connect
|
||||
this.pendingActivity = activity;
|
||||
|
||||
if (!this.connected || !this.socket) return;
|
||||
|
||||
const now = Date.now();
|
||||
const elapsed = now - this.lastUpdate;
|
||||
if (elapsed < RATE_LIMIT_MS) {
|
||||
// Schedule a flush after the rate limit window expires
|
||||
if (!this.flushTimer) {
|
||||
this.flushTimer = setTimeout(() => {
|
||||
this.flushTimer = null;
|
||||
if (this.pendingActivity && this.connected && this.socket) {
|
||||
this.sendActivity(this.pendingActivity);
|
||||
this.pendingActivity = null;
|
||||
}
|
||||
}, RATE_LIMIT_MS - elapsed);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendActivity(activity);
|
||||
this.pendingActivity = null;
|
||||
}
|
||||
|
||||
private sendActivity(activity: ActivityPayload): void {
|
||||
if (!this.socket || this.destroyed) return;
|
||||
this.lastUpdate = Date.now();
|
||||
|
||||
const activityObj: any = {};
|
||||
if (activity.details) activityObj.details = activity.details;
|
||||
if (activity.state) activityObj.state = activity.state;
|
||||
if (activity.startTimestamp) {
|
||||
activityObj.timestamps = { start: activity.startTimestamp };
|
||||
}
|
||||
if (activity.largeImageKey) {
|
||||
activityObj.assets = {
|
||||
large_image: activity.largeImageKey,
|
||||
large_text: activity.largeImageText || 'Krunker Civilian Client',
|
||||
};
|
||||
}
|
||||
|
||||
const frame = encodeFrame(OP_FRAME, {
|
||||
cmd: 'SET_ACTIVITY',
|
||||
args: {
|
||||
pid: process.pid,
|
||||
activity: activityObj,
|
||||
},
|
||||
nonce: String(++this.nonce),
|
||||
});
|
||||
|
||||
try {
|
||||
this.socket.write(frame);
|
||||
} catch (err) {
|
||||
electronLog.warn('[KCC-Discord] Write error:', (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
clearActivity(): void {
|
||||
if (!this.connected || !this.socket || this.destroyed) return;
|
||||
|
||||
const frame = encodeFrame(OP_FRAME, {
|
||||
cmd: 'SET_ACTIVITY',
|
||||
args: {
|
||||
pid: process.pid,
|
||||
activity: null,
|
||||
},
|
||||
nonce: String(++this.nonce),
|
||||
});
|
||||
|
||||
try {
|
||||
this.socket.write(frame);
|
||||
} catch {
|
||||
// Silent
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.destroyed = true;
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
if (this.flushTimer) {
|
||||
clearTimeout(this.flushTimer);
|
||||
this.flushTimer = null;
|
||||
}
|
||||
if (this.socket) {
|
||||
try {
|
||||
this.clearActivity();
|
||||
} catch {
|
||||
// Silent
|
||||
}
|
||||
this.socket.destroy();
|
||||
this.socket = null;
|
||||
}
|
||||
this.connected = false;
|
||||
this.recvBuf = Buffer.alloc(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,843 @@
|
||||
import { app, BrowserWindow, Menu, clipboard, ipcMain, safeStorage, session, shell } from 'electron';
|
||||
import { join } from 'path';
|
||||
import { existsSync, mkdirSync, promises as fsp } from 'fs';
|
||||
import { get as httpsGet } from 'https';
|
||||
import { execFile } from 'child_process';
|
||||
import * as os from 'os';
|
||||
import { detectPlatform, applyPlatformFlags } from './platform';
|
||||
import { config, Keybind, DEFAULT_KEYBINDS, SavedAccount } from './config';
|
||||
import { initSwapperProtocol, registerSwapperFileProtocol, ResourceSwapper } from './swapper';
|
||||
import { UserscriptManager } from './userscripts';
|
||||
import { ALL_CLIENT_CSS } from './client-ui';
|
||||
import { electronLog, getLogPath, closeLogStreams } from './logger';
|
||||
import { checkForUpdate, downloadUpdate, installUpdate } from './updater';
|
||||
import { showUpdateWindow } from './update-window';
|
||||
import { DiscordRPC } from './discord-rpc';
|
||||
import { listThemes, getThemeCSS, listLoadingThemes, getLoadingScreenCSS } from './css-themes';
|
||||
import { TabManager } from './tab-manager';
|
||||
|
||||
// ── App version for API calls ──
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const appVersion: string = require('../../package.json').version;
|
||||
|
||||
// ── Region ping cache ──
|
||||
const SERVER_MAP: Record<string, string> = {
|
||||
'us-ca-sv': 'SV', 'jb-hnd': 'TOK', 'de-fra': 'FRA',
|
||||
'as-mb': 'MBI', 'au-syd': 'SYD', 'sgp': 'SIN',
|
||||
'us-tx': 'DAL', 'me-bhn': 'BHN', 'brz': 'BRZ', 'us-nj': 'NY',
|
||||
};
|
||||
let pingCache: Record<string, number> = {};
|
||||
let pingCacheTime = 0;
|
||||
|
||||
function osPing(host: string): Promise<number> {
|
||||
return new Promise((resolve) => {
|
||||
const isWin = process.platform === 'win32';
|
||||
const args = isWin ? ['-n', '1', '-w', '1500', host] : ['-c', '1', '-W', '2', host];
|
||||
execFile('ping', args, { timeout: 3000 }, (err, stdout) => {
|
||||
if (err) { resolve(-1); return; }
|
||||
const match = stdout.match(/time[=<]([\d.]+)\s*ms/i);
|
||||
if (match) resolve(Math.round(parseFloat(match[1])));
|
||||
else resolve(-1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Platform flags (must run before app.ready) ──
|
||||
const platformInfo = detectPlatform();
|
||||
const advancedDefaults = {
|
||||
removeUselessFeatures: true,
|
||||
gpuRasterizing: false,
|
||||
helpfulFlags: true,
|
||||
disableAccelerated2D: false,
|
||||
increaseLimits: false,
|
||||
lowLatency: false,
|
||||
experimentalFlags: false,
|
||||
};
|
||||
const advancedConfig = { ...advancedDefaults, ...config.get('advanced') };
|
||||
const perfConfig = { fpsUnlocked: true, ...config.get('performance') };
|
||||
applyPlatformFlags(platformInfo, advancedConfig, perfConfig);
|
||||
|
||||
// ── App identity (must match electron-builder appId for taskbar pin persistence) ──
|
||||
app.setAppUserModelId('com.krunkercivilian.client');
|
||||
|
||||
// ── Resource swapper protocol (must register before app.ready) ──
|
||||
initSwapperProtocol();
|
||||
|
||||
// ── Ad-blocking URL patterns (matched in C++ layer, never hits JS for non-matches) ──
|
||||
const BLOCKED_URL_PATTERNS = [
|
||||
'*://*.pollfish.com/*',
|
||||
'*://www.paypalobjects.com/*',
|
||||
'*://fran-cdn.frvr.com/*',
|
||||
'*://c.amazon-adsystem.com/*',
|
||||
'*://cdn.frvr.com/fran/*',
|
||||
'*://cookiepro.com/*',
|
||||
'*://*.cookiepro.com/*',
|
||||
'*://www.googletagmanager.com/*',
|
||||
'*://*.doubleclick.net/*',
|
||||
'*://storage.googleapis.com/pollfish_production/*',
|
||||
'*://coeus.frvr.com/*',
|
||||
'*://apis.google.com/js/platform.js',
|
||||
'*://imasdk.googleapis.com/*',
|
||||
];
|
||||
|
||||
// ── CSS to hide ad containers ──
|
||||
const HIDE_ADS_CSS = `
|
||||
.endAHolder,
|
||||
#aHider,
|
||||
#adCon,
|
||||
#rightABox,
|
||||
#aContainer,
|
||||
#topRightAdHolder,
|
||||
div#aContainer,
|
||||
#braveWarning,
|
||||
#topRightAdHolder {
|
||||
display: none !important;
|
||||
}`;
|
||||
|
||||
// ── Consent dismiss script (polling only — NO MutationObserver on main frame) ──
|
||||
const CONSENT_DISMISS_MAIN_JS = `
|
||||
(function dismissConsent() {
|
||||
let attempts = 0;
|
||||
const timer = setInterval(() => {
|
||||
attempts++;
|
||||
const btn = document.querySelector('.fc-cta-consent, [aria-label="Consent"], .css-47sehv');
|
||||
if (btn) { btn.click(); clearInterval(timer); }
|
||||
if (attempts > 30) clearInterval(timer);
|
||||
}, 500);
|
||||
})();`;
|
||||
|
||||
// ── Escape pointer lock fix ──
|
||||
const ESCAPE_POINTERLOCK_FIX_JS = `
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape' && document.pointerLockElement) {
|
||||
document.exitPointerLock();
|
||||
}
|
||||
}, true);`;
|
||||
|
||||
// ── Safe external URL opener (only http/https) ──
|
||||
function safeOpenExternal(url: string): void {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol === 'https:' || parsed.protocol === 'http:') {
|
||||
shell.openExternal(url);
|
||||
}
|
||||
} catch { /* malformed URL — ignore */ }
|
||||
}
|
||||
|
||||
// ── Keybind matching ──
|
||||
function matchesKeybind(input: { key: string; control: boolean; shift: boolean; alt: boolean }, bind: Keybind | undefined): boolean {
|
||||
if (!bind) return false;
|
||||
return input.key === bind.key
|
||||
&& input.control === bind.ctrl
|
||||
&& input.shift === bind.shift
|
||||
&& input.alt === bind.alt;
|
||||
}
|
||||
|
||||
// ── Cached keybinds (avoid re-reading electron-store on every keypress) ──
|
||||
let cachedKeybinds: Record<string, Keybind> | null = null;
|
||||
|
||||
function getKeybinds(): Record<string, Keybind> {
|
||||
if (!cachedKeybinds) {
|
||||
cachedKeybinds = { ...DEFAULT_KEYBINDS, ...config.get('keybinds') };
|
||||
}
|
||||
return cachedKeybinds;
|
||||
}
|
||||
|
||||
// ── Debounced window state persistence ──
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function saveWindowState(win: BrowserWindow): void {
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(() => {
|
||||
if (win.isDestroyed()) return;
|
||||
const bounds = win.getBounds();
|
||||
config.set('window', {
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
maximized: win.isMaximized(),
|
||||
fullscreen: win.isFullScreen(),
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
electronLog.log('[KCC] App ready');
|
||||
|
||||
// ── Auto-update check (mandatory, Windows NSIS install only) ──
|
||||
const isPortable = !!process.env.PORTABLE_EXECUTABLE_DIR;
|
||||
const isAppImage = !!process.env.APPIMAGE;
|
||||
const isDev = !app.isPackaged;
|
||||
if (isDev || process.platform !== 'win32' || isPortable || isAppImage) {
|
||||
electronLog.log('[KCC] Skipping auto-update (portable or non-Windows)');
|
||||
} else {
|
||||
try {
|
||||
electronLog.log('[KCC] Checking for updates...');
|
||||
const update = await checkForUpdate(appVersion);
|
||||
if (update) {
|
||||
electronLog.log(`[KCC] Update available: v${update.version}`);
|
||||
const { window: updateWin, sendProgress } = showUpdateWindow();
|
||||
sendProgress(`Update available (v${update.version})`, 0);
|
||||
|
||||
const tempDir = join(app.getPath('temp'), 'kcc-update');
|
||||
if (!existsSync(tempDir)) mkdirSync(tempDir, { recursive: true });
|
||||
const installerPath = join(tempDir, `KCC-${update.version}-Setup.exe`);
|
||||
|
||||
let cancelled = false;
|
||||
updateWin.on('closed', () => { cancelled = true; });
|
||||
|
||||
try {
|
||||
await downloadUpdate(update.downloadUrl, installerPath, (pct) => {
|
||||
if (!cancelled && !updateWin.isDestroyed()) {
|
||||
sendProgress(`Downloading update... ${pct}%`, pct);
|
||||
}
|
||||
});
|
||||
|
||||
if (!cancelled) {
|
||||
sendProgress('Installing update...', 100);
|
||||
installUpdate(installerPath);
|
||||
return; // app.quit() called by installUpdate
|
||||
}
|
||||
} catch (err) {
|
||||
electronLog.error('[KCC] Update download failed:', err);
|
||||
if (!updateWin.isDestroyed()) updateWin.close();
|
||||
}
|
||||
} else {
|
||||
electronLog.log('[KCC] No updates available');
|
||||
}
|
||||
} catch (err) {
|
||||
electronLog.error('[KCC] Update check failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
await launchApp();
|
||||
});
|
||||
|
||||
async function launchApp(): Promise<void> {
|
||||
electronLog.log('[KCC] Starting initialization');
|
||||
|
||||
// ── Session: persistent partition + clean user-agent ──
|
||||
const ses = session.fromPartition('persist:krunker');
|
||||
const rawUA = ses.getUserAgent();
|
||||
ses.setUserAgent(rawUA.replace(/\s*krunker-civilian-client\/\S+/i, ''));
|
||||
|
||||
// ── Register swapper file protocol on this session ──
|
||||
registerSwapperFileProtocol(ses);
|
||||
|
||||
// ── Resource swapper ──
|
||||
const swapperConfig = config.get('swapper');
|
||||
const swapDir = swapperConfig.path || join(app.getPath('userData'), 'Krunker Civilian Client', 'swapper');
|
||||
const swapper = swapperConfig.enabled ? new ResourceSwapper(swapDir) : null;
|
||||
electronLog.log(`[KCC] Resource swapper: ${swapper ? 'enabled' : 'disabled'} (${swapDir})`);
|
||||
|
||||
// ── Userscript manager ──
|
||||
const usConfig = config.get('userscripts') || { enabled: true, path: '' };
|
||||
const usDir = usConfig.path || join(app.getPath('userData'), 'Krunker Civilian Client');
|
||||
const userscriptManager = usConfig.enabled ? new UserscriptManager(usDir) : null;
|
||||
electronLog.log(`[KCC] Userscripts: ${userscriptManager ? 'enabled' : 'disabled'} (${usDir})`);
|
||||
|
||||
// ── Ad blocking + resource swapper (single onBeforeRequest — Electron only allows one) ──
|
||||
// The broad *://*.krunker.io/* pattern lets the swapper intercept any krunker asset.
|
||||
// swapper.getRedirect() returns null before its async scan completes, so swapped
|
||||
// resources simply pass through until the scan finishes — no re-registration needed.
|
||||
const requestFilterUrls = swapper
|
||||
? [...BLOCKED_URL_PATTERNS, '*://*.krunker.io/*']
|
||||
: [...BLOCKED_URL_PATTERNS];
|
||||
|
||||
ses.webRequest.onBeforeRequest({ urls: requestFilterUrls }, (details, callback) => {
|
||||
// Check swapper first — redirect matching assets to local files
|
||||
if (swapper) {
|
||||
const redirect = swapper.getRedirect(details.url);
|
||||
if (redirect) return callback({ redirectURL: redirect });
|
||||
}
|
||||
// Determine if this URL is a krunker.io request (matched by the broad swapper pattern)
|
||||
// vs an ad-block pattern. krunker.io requests that weren't swapped pass through normally.
|
||||
try {
|
||||
if (new URL(details.url).hostname.endsWith('krunker.io')) return callback({});
|
||||
} catch { /* invalid URL — fall through to cancel */ }
|
||||
// Matched an ad-block pattern — cancel it
|
||||
callback({ cancel: true });
|
||||
});
|
||||
|
||||
if (swapper) {
|
||||
swapper.waitForReady().then(() => {
|
||||
electronLog.log(`[KCC] Swapper ready: ${swapper.patterns.length} pattern(s)`);
|
||||
});
|
||||
}
|
||||
|
||||
// ── CORS fix for swapped resources ──
|
||||
if (swapper) {
|
||||
ses.webRequest.onHeadersReceived(({ responseHeaders }, callback) => {
|
||||
if (!responseHeaders) return callback({});
|
||||
for (const key in responseHeaders) {
|
||||
const lowercase = key.toLowerCase();
|
||||
if (lowercase === 'access-control-allow-credentials' && responseHeaders[key][0] === 'true') {
|
||||
return callback({ responseHeaders });
|
||||
}
|
||||
if (lowercase === 'access-control-allow-origin') {
|
||||
delete responseHeaders[key];
|
||||
break;
|
||||
}
|
||||
}
|
||||
return callback({
|
||||
responseHeaders: { ...responseHeaders, 'access-control-allow-origin': ['*'] },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Restore saved window bounds ──
|
||||
const savedWindow = config.get('window');
|
||||
|
||||
const win = new BrowserWindow({
|
||||
width: savedWindow.width,
|
||||
height: savedWindow.height,
|
||||
x: savedWindow.x,
|
||||
y: savedWindow.y,
|
||||
frame: true,
|
||||
backgroundColor: '#000000',
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '..', 'preload', 'index.js'),
|
||||
session: ses,
|
||||
contextIsolation: false,
|
||||
nodeIntegration: false,
|
||||
sandbox: false,
|
||||
spellcheck: false,
|
||||
backgroundThrottling: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (savedWindow.fullscreen) win.setFullScreen(true);
|
||||
else if (savedWindow.maximized) win.maximize();
|
||||
|
||||
// ── No application menu (prevents Escape/Alt interception) ──
|
||||
Menu.setApplicationMenu(null);
|
||||
|
||||
// ── Discord Rich Presence ──
|
||||
let discordRpc: DiscordRPC | null = null;
|
||||
{
|
||||
const discordConf = config.get('discord') || { enabled: false };
|
||||
if (discordConf.enabled) {
|
||||
discordRpc = new DiscordRPC();
|
||||
discordRpc.connect();
|
||||
electronLog.log('[KCC] Discord Rich Presence enabled');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Process Priority (Windows only) ──
|
||||
if (process.platform === 'win32') {
|
||||
const PRIORITY_MAP: Record<string, number> = {
|
||||
'High': -14,
|
||||
'Above Normal': -7,
|
||||
'Below Normal': 7,
|
||||
'Low': 19,
|
||||
};
|
||||
const prioritySetting = config.get('performance')?.processPriority || 'Normal';
|
||||
const priorityVal = PRIORITY_MAP[prioritySetting];
|
||||
if (priorityVal !== undefined) {
|
||||
try { os.setPriority(process.pid, priorityVal); } catch { /* ignore */ }
|
||||
// Apply to child processes periodically
|
||||
setInterval(() => {
|
||||
for (const m of app.getAppMetrics()) {
|
||||
if (m.pid !== process.pid) {
|
||||
try { os.setPriority(m.pid, priorityVal); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
electronLog.log(`[KCC] Process priority set to ${prioritySetting}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── CPU Throttling via Chrome DevTools Protocol ──
|
||||
const throttledContents = new WeakSet<Electron.WebContents>();
|
||||
|
||||
function applyCpuThrottle(wc: Electron.WebContents, rate: number): void {
|
||||
const clamped = Math.max(1, Math.min(3, rate));
|
||||
try {
|
||||
if (!throttledContents.has(wc)) {
|
||||
wc.debugger.attach('1.3');
|
||||
throttledContents.add(wc);
|
||||
}
|
||||
wc.debugger.sendCommand('Emulation.setCPUThrottlingRate', { rate: clamped });
|
||||
} catch { /* debugger may already be attached or detached */ }
|
||||
}
|
||||
|
||||
// ── Keybind capture lock (suppresses shortcuts while the keybind dialog is open) ──
|
||||
let keybindCapturing = false;
|
||||
ipcMain.on('keybind-capture', (_e, capturing: boolean) => {
|
||||
keybindCapturing = capturing;
|
||||
});
|
||||
|
||||
// ── Configurable keybinds via before-input-event ──
|
||||
win.webContents.on('before-input-event', (event, input) => {
|
||||
if (input.type !== 'keyDown') return;
|
||||
if (keybindCapturing) return;
|
||||
|
||||
const binds = getKeybinds();
|
||||
|
||||
if (matchesKeybind(input, binds.reload)) {
|
||||
win.reload();
|
||||
event.preventDefault();
|
||||
} else if (matchesKeybind(input, binds.newMatch)) {
|
||||
const mm = config.get('matchmaker');
|
||||
if (mm.enabled) {
|
||||
win.webContents.send('matchmaker-find', {
|
||||
...mm,
|
||||
acceptKey: binds.matchmakerAccept,
|
||||
cancelKey: binds.matchmakerCancel,
|
||||
});
|
||||
} else {
|
||||
win.loadURL('https://krunker.io');
|
||||
}
|
||||
event.preventDefault();
|
||||
} else if (matchesKeybind(input, binds.joinFromClipboard)) {
|
||||
const text = clipboard.readText();
|
||||
try { const u = new URL(text); if (u.protocol === 'https:' && u.hostname.endsWith('krunker.io')) win.loadURL(text); } catch { /* ignore invalid URLs */ }
|
||||
event.preventDefault();
|
||||
} else if (matchesKeybind(input, binds.copyGameLink)) {
|
||||
clipboard.writeText(win.webContents.getURL());
|
||||
event.preventDefault();
|
||||
} else if (matchesKeybind(input, binds.devTools)) {
|
||||
win.webContents.toggleDevTools();
|
||||
event.preventDefault();
|
||||
} else if (matchesKeybind(input, binds.matchmaker)) {
|
||||
const mm = config.get('matchmaker');
|
||||
if (mm.enabled) {
|
||||
win.webContents.send('matchmaker-find', {
|
||||
...mm,
|
||||
acceptKey: binds.matchmakerAccept,
|
||||
cancelKey: binds.matchmakerCancel,
|
||||
});
|
||||
} else {
|
||||
win.loadURL('https://krunker.io');
|
||||
}
|
||||
event.preventDefault();
|
||||
} else if (matchesKeybind(input, binds.pauseChat)) {
|
||||
win.webContents.send('toggle-chat-pause');
|
||||
event.preventDefault();
|
||||
} else if (matchesKeybind(input, binds.fullscreenToggle)) {
|
||||
win.setFullScreen(!win.isFullScreen());
|
||||
event.preventDefault();
|
||||
} else if (input.key === 't' && input.control && !input.shift && !input.alt) {
|
||||
tabManager.openTab('https://krunker.io/social.html');
|
||||
event.preventDefault();
|
||||
} else if (input.key === 'T' && input.control && input.shift && !input.alt) {
|
||||
tabManager.reopenTab();
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Window state persistence (debounced) ──
|
||||
win.on('resize', () => saveWindowState(win));
|
||||
win.on('move', () => saveWindowState(win));
|
||||
win.on('maximize', () => saveWindowState(win));
|
||||
win.on('unmaximize', () => saveWindowState(win));
|
||||
win.on('enter-full-screen', () => saveWindowState(win));
|
||||
win.on('leave-full-screen', () => saveWindowState(win));
|
||||
|
||||
// ── URL classification ──
|
||||
const GAME_PAGE_PATHS = ['/', ''];
|
||||
function isGameURL(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (!parsed.hostname.includes('krunker.io')) return false;
|
||||
return GAME_PAGE_PATHS.includes(parsed.pathname);
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
// ── Cached game config (invalidated on set-config writes to 'game') ──
|
||||
const gameDefaults = { lastServer: '', socialTabBehaviour: 'New Window' };
|
||||
let cachedGameConf: typeof gameDefaults | null = null;
|
||||
function getGameConf(): typeof gameDefaults {
|
||||
if (!cachedGameConf) cachedGameConf = { ...gameDefaults, ...config.get('game') };
|
||||
return cachedGameConf;
|
||||
}
|
||||
|
||||
// ── Tab Manager ──
|
||||
const preloadPath = join(__dirname, '..', 'preload', 'index.js');
|
||||
let tabMode: 'same' | 'new' = getGameConf().socialTabBehaviour === 'Same Window' ? 'same' : 'new';
|
||||
let tabManager = new TabManager(
|
||||
win, ses, preloadPath, tabMode, isGameURL,
|
||||
() => config.get('tabWindow'),
|
||||
(state) => config.set('tabWindow', state),
|
||||
);
|
||||
|
||||
// Intercept in-page navigation (e.g. window.location = '/social.html')
|
||||
win.webContents.on('will-navigate', (event, url) => {
|
||||
if (url.includes('krunker.io') && !isGameURL(url)) {
|
||||
event.preventDefault();
|
||||
tabManager.openTab(url);
|
||||
}
|
||||
});
|
||||
|
||||
// Intercept target="_blank" / window.open links
|
||||
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (url.includes('krunker.io')) {
|
||||
if (isGameURL(url)) {
|
||||
win.loadURL(url);
|
||||
} else {
|
||||
setImmediate(() => tabManager.openTab(url));
|
||||
}
|
||||
} else {
|
||||
setImmediate(() => safeOpenExternal(url));
|
||||
}
|
||||
return { action: 'deny' };
|
||||
});
|
||||
|
||||
// Right-click context menu on main window with "Open in New Tab"
|
||||
win.webContents.on('context-menu', (_e, params) => {
|
||||
if (!params.linkURL) return;
|
||||
const items: Electron.MenuItemConstructorOptions[] = [];
|
||||
if (params.linkURL.includes('krunker.io') && !isGameURL(params.linkURL)) {
|
||||
items.push({ label: 'Open in New Tab', click: () => tabManager.openTab(params.linkURL) });
|
||||
}
|
||||
items.push({ label: 'Copy Link', click: () => clipboard.writeText(params.linkURL) });
|
||||
if (!params.linkURL.includes('krunker.io')) {
|
||||
items.push({ label: 'Open in Browser', click: () => safeOpenExternal(params.linkURL) });
|
||||
}
|
||||
if (items.length) Menu.buildFromTemplate(items).popup();
|
||||
});
|
||||
|
||||
// ── Inject scripts after page loads ──
|
||||
win.webContents.on('did-finish-load', () => {
|
||||
electronLog.log(`[KCC] Page loaded: ${win.webContents.getURL()}`);
|
||||
// Rescan swap directory so new/changed files are picked up on refresh
|
||||
if (swapper) swapper.rescan().catch(() => {});
|
||||
|
||||
const cssInjections = [
|
||||
win.webContents.insertCSS(HIDE_ADS_CSS),
|
||||
win.webContents.insertCSS(ALL_CLIENT_CSS),
|
||||
];
|
||||
|
||||
// Inject user CSS theme
|
||||
const uiConf = config.get('ui');
|
||||
const themeCSS = getThemeCSS(uiConf?.cssTheme || 'disabled', swapDir);
|
||||
if (themeCSS) cssInjections.push(win.webContents.insertCSS(themeCSS));
|
||||
|
||||
// Inject loading screen background
|
||||
const loadingCSS = getLoadingScreenCSS(uiConf?.loadingTheme || 'disabled', uiConf?.backgroundUrl || '', swapDir);
|
||||
if (loadingCSS) cssInjections.push(win.webContents.insertCSS(loadingCSS));
|
||||
|
||||
Promise.all(cssInjections).catch(() => {});
|
||||
|
||||
// Apply initial CPU throttle (menu state)
|
||||
const perf = config.get('performance');
|
||||
applyCpuThrottle(win.webContents, perf?.cpuThrottleMenu ?? 1.5);
|
||||
|
||||
win.webContents.executeJavaScript(ESCAPE_POINTERLOCK_FIX_JS).catch((err) => electronLog.warn('[KCC] Pointerlock fix inject failed:', err));
|
||||
win.webContents.executeJavaScript(CONSENT_DISMISS_MAIN_JS).catch((err) => electronLog.warn('[KCC] Consent dismiss inject failed:', err));
|
||||
// Notify preload to start hooking settings (matches Crankshaft's timing)
|
||||
win.webContents.send('main_did-finish-load');
|
||||
});
|
||||
|
||||
// ── IPC handlers ──
|
||||
const ALLOWED_CONFIG_KEYS = new Set<string>([
|
||||
'window', 'performance', 'game', 'swapper', 'matchmaker',
|
||||
'keybinds', 'userscripts', 'ui', 'discord', 'translator',
|
||||
'advanced', 'accounts', 'tabWindow', 'platform',
|
||||
]);
|
||||
|
||||
ipcMain.handle('get-version', () => appVersion);
|
||||
ipcMain.handle('get-platform', () => platformInfo);
|
||||
ipcMain.handle('get-config', (_e, key: string) => {
|
||||
if (!ALLOWED_CONFIG_KEYS.has(key)) return undefined;
|
||||
return config.get(key as keyof typeof config.store);
|
||||
});
|
||||
ipcMain.handle('get-all-config', (_e, keys: string[]) => {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const key of keys) {
|
||||
if (ALLOWED_CONFIG_KEYS.has(key)) result[key] = config.get(key as keyof typeof config.store);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
let configWriteTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const pendingConfigWrites = new Map<string, unknown>();
|
||||
|
||||
ipcMain.handle('set-config', (_e, key: string, value: unknown) => {
|
||||
if (!ALLOWED_CONFIG_KEYS.has(key)) return;
|
||||
// Flush immediately for keys that have side effects
|
||||
if (key === 'keybinds') {
|
||||
config.set(key as any, value);
|
||||
cachedKeybinds = null;
|
||||
return;
|
||||
}
|
||||
// Invalidate caches immediately (not on flush) to prevent stale reads
|
||||
if (key === 'game') {
|
||||
cachedGameConf = null;
|
||||
// Switch tab mode if socialTabBehaviour changed
|
||||
const newGame = value as any;
|
||||
if (newGame?.socialTabBehaviour) {
|
||||
const newMode: 'same' | 'new' = newGame.socialTabBehaviour === 'Same Window' ? 'same' : 'new';
|
||||
if (newMode !== tabMode) {
|
||||
tabManager.destroyAll();
|
||||
tabMode = newMode;
|
||||
tabManager = new TabManager(
|
||||
win, ses, preloadPath, tabMode, isGameURL,
|
||||
() => config.get('tabWindow'),
|
||||
(state) => config.set('tabWindow', state),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
pendingConfigWrites.set(key, value);
|
||||
if (!configWriteTimer) {
|
||||
configWriteTimer = setTimeout(() => {
|
||||
for (const [k, v] of pendingConfigWrites) {
|
||||
config.set(k as any, v);
|
||||
}
|
||||
pendingConfigWrites.clear();
|
||||
configWriteTimer = null;
|
||||
}, 300);
|
||||
}
|
||||
});
|
||||
ipcMain.handle('window-minimize', () => win.minimize());
|
||||
ipcMain.handle('window-maximize', () => {
|
||||
if (win.isMaximized()) win.unmaximize(); else win.maximize();
|
||||
});
|
||||
ipcMain.handle('window-close', () => win.close());
|
||||
ipcMain.handle('window-is-maximized', () => win.isMaximized());
|
||||
ipcMain.handle('toggle-devtools', () => win.webContents.toggleDevTools());
|
||||
ipcMain.handle('inject-game-click', () => {
|
||||
const [width, height] = win.getContentSize();
|
||||
const x = Math.round(width / 2);
|
||||
const y = Math.round(height / 2);
|
||||
win.webContents.sendInputEvent({ type: 'mouseDown', x, y, button: 'left', clickCount: 1 });
|
||||
win.webContents.sendInputEvent({ type: 'mouseUp', x, y, button: 'left', clickCount: 1 });
|
||||
});
|
||||
ipcMain.handle('get-swap-dir', () => swapDir);
|
||||
ipcMain.handle('open-swap-folder', () => shell.openPath(swapDir));
|
||||
|
||||
// ── Ping regions IPC handler (TCP connect timing, cached 60s) ──
|
||||
ipcMain.handle('ping-regions', async () => {
|
||||
if (Object.keys(pingCache).length > 0 && Date.now() - pingCacheTime < 60000) {
|
||||
return pingCache;
|
||||
}
|
||||
try {
|
||||
const data = await new Promise<string>((resolve, reject) => {
|
||||
httpsGet('https://matchmaker.krunker.io/ping-list?hostname=krunker.io', (res) => {
|
||||
let body = '';
|
||||
res.on('data', (chunk: string) => { body += chunk; });
|
||||
res.on('end', () => resolve(body));
|
||||
res.on('error', reject);
|
||||
}).on('error', reject);
|
||||
});
|
||||
const serverIPs: Record<string, string> = JSON.parse(data);
|
||||
|
||||
const results: Record<string, number> = {};
|
||||
|
||||
async function pingWithRetry(host: string): Promise<number> {
|
||||
const latency = await osPing(host);
|
||||
if (latency >= 0) return latency;
|
||||
const retry = await osPing(host);
|
||||
return retry >= 0 ? retry : -1;
|
||||
}
|
||||
|
||||
const promises = Object.entries(serverIPs).map(async ([server, ip]) => {
|
||||
const regionName = SERVER_MAP[server] ?? server;
|
||||
const host = ip.split(':')[0];
|
||||
const latency = await pingWithRetry(host);
|
||||
if (latency >= 0) {
|
||||
results[regionName] = latency;
|
||||
}
|
||||
});
|
||||
await Promise.allSettled(promises);
|
||||
pingCache = results;
|
||||
pingCacheTime = Date.now();
|
||||
|
||||
return results;
|
||||
} catch (err) {
|
||||
electronLog.error('[KCC] Ping regions error:', err);
|
||||
return pingCache;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Discord Rich Presence IPC handler ──
|
||||
ipcMain.on('discord-update', (_e, activity: any) => {
|
||||
discordRpc?.setActivity(activity);
|
||||
});
|
||||
|
||||
// ── Verbose log IPC handler (preload forwards logs here) ──
|
||||
ipcMain.on('verbose-log', (_e, level: string, ...args: unknown[]) => {
|
||||
if (level === 'error') electronLog.error(...args);
|
||||
else if (level === 'warn') electronLog.warn(...args);
|
||||
else electronLog.log(...args);
|
||||
});
|
||||
|
||||
// ── CPU throttle IPC handler ──
|
||||
ipcMain.on('throttle-state', (_e, state: string) => {
|
||||
const perf = config.get('performance');
|
||||
const rate = state === 'game' ? (perf?.cpuThrottleGame ?? 1) : (perf?.cpuThrottleMenu ?? 1.5);
|
||||
applyCpuThrottle(win.webContents, rate);
|
||||
});
|
||||
|
||||
// ── CSS theme & loading background IPC handlers ──
|
||||
ipcMain.handle('list-themes', () => listThemes(swapDir));
|
||||
ipcMain.handle('get-theme-css', (_e, themeId: string) => getThemeCSS(themeId, swapDir));
|
||||
ipcMain.handle('list-loading-themes', () => listLoadingThemes(swapDir));
|
||||
ipcMain.handle('get-loading-screen-css', (_e, loadingTheme: string, backgroundUrl: string) => {
|
||||
return getLoadingScreenCSS(loadingTheme, backgroundUrl, swapDir);
|
||||
});
|
||||
|
||||
// ── Changelog IPC handler (fetch release notes from Gitea) ──
|
||||
ipcMain.handle('changelog-fetch', async (_e, version: string) => {
|
||||
const tag = version.startsWith('v') ? version : `v${version}`;
|
||||
try {
|
||||
const data = await new Promise<string>((resolve, reject) => {
|
||||
httpsGet(`https://gitea.crjlab.net/api/v1/repos/bigjakk/Krunker-Civilian-Client/releases/tags/${tag}`, (res) => {
|
||||
let body = '';
|
||||
res.on('data', (chunk: string) => { body += chunk; });
|
||||
res.on('end', () => resolve(body));
|
||||
res.on('error', reject);
|
||||
}).on('error', reject);
|
||||
});
|
||||
const release = JSON.parse(data);
|
||||
return release.body || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
});
|
||||
|
||||
// ── Userscript IPC handlers ──
|
||||
ipcMain.handle('userscripts-get-dir', () => userscriptManager ? userscriptManager.dir : '');
|
||||
ipcMain.handle('userscripts-open-folder', () => {
|
||||
if (userscriptManager) shell.openPath(userscriptManager.dir);
|
||||
});
|
||||
ipcMain.handle('userscripts-scan', async () => {
|
||||
if (!userscriptManager) return { scripts: [], tracker: {} };
|
||||
const scripts = await userscriptManager.scanScripts();
|
||||
const tracker = await userscriptManager.loadTracker(scripts);
|
||||
return { scripts, tracker };
|
||||
});
|
||||
ipcMain.handle('userscripts-set-tracker', (_e, tracker: Record<string, boolean>) => {
|
||||
if (userscriptManager) userscriptManager.saveTracker(tracker);
|
||||
});
|
||||
ipcMain.handle('userscripts-load-prefs', (_e, filename: string) => {
|
||||
if (!userscriptManager) return {};
|
||||
return userscriptManager.loadScriptPrefs(filename);
|
||||
});
|
||||
ipcMain.handle('userscripts-save-prefs', (_e, filename: string, prefs: Record<string, unknown>) => {
|
||||
if (userscriptManager) userscriptManager.saveScriptPrefs(filename, prefs);
|
||||
});
|
||||
|
||||
// ── Action button IPC handlers ──
|
||||
ipcMain.handle('open-electron-log', () => {
|
||||
shell.openPath(getLogPath('electron'));
|
||||
});
|
||||
ipcMain.handle('reset-swapper', async () => {
|
||||
try {
|
||||
const entries = await fsp.readdir(swapDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
await fsp.rm(join(swapDir, entry.name), { recursive: true, force: true });
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
electronLog.error('[KCC] Reset swapper failed:', err);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
ipcMain.handle('restart-client', () => {
|
||||
app.relaunch();
|
||||
app.quit();
|
||||
});
|
||||
ipcMain.handle('reset-options', () => {
|
||||
config.clear();
|
||||
app.relaunch();
|
||||
app.quit();
|
||||
});
|
||||
ipcMain.handle('delete-all-data', async () => {
|
||||
config.clear();
|
||||
const userData = app.getPath('userData');
|
||||
try {
|
||||
await fsp.rm(join(userData, 'logs'), { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
electronLog.warn('[KCC] Partial data deletion failed (non-fatal):', err);
|
||||
}
|
||||
app.relaunch();
|
||||
app.quit();
|
||||
});
|
||||
|
||||
// ── Alt manager IPC handlers (credentials encrypted via safeStorage) ──
|
||||
const canEncrypt = safeStorage.isEncryptionAvailable();
|
||||
if (!canEncrypt) electronLog.warn('[KCC] safeStorage encryption not available — account passwords will use base64 fallback');
|
||||
|
||||
function encryptString(plaintext: string): string {
|
||||
if (canEncrypt) return safeStorage.encryptString(plaintext).toString('base64');
|
||||
return Buffer.from(plaintext).toString('base64');
|
||||
}
|
||||
|
||||
function decryptString(encrypted: string): string {
|
||||
if (canEncrypt) return safeStorage.decryptString(Buffer.from(encrypted, 'base64'));
|
||||
return Buffer.from(encrypted, 'base64').toString();
|
||||
}
|
||||
|
||||
ipcMain.handle('alt-list', () => {
|
||||
const accounts = config.get('accounts') || [];
|
||||
// Return only labels to the renderer — never send encrypted credentials
|
||||
return accounts.map((a: SavedAccount) => ({ label: a.label }));
|
||||
});
|
||||
|
||||
ipcMain.handle('alt-save', (_e, data: { label: string; username: string; password: string }) => {
|
||||
const accounts = config.get('accounts') || [];
|
||||
const account: SavedAccount = {
|
||||
label: data.label,
|
||||
username: encryptString(data.username),
|
||||
password: encryptString(data.password),
|
||||
};
|
||||
accounts.push(account);
|
||||
config.set('accounts', accounts);
|
||||
return { success: true, index: accounts.length - 1 };
|
||||
});
|
||||
|
||||
ipcMain.handle('alt-get-credentials', (_e, index: number) => {
|
||||
const accounts = config.get('accounts') || [];
|
||||
if (index < 0 || index >= accounts.length) return null;
|
||||
const acc = accounts[index];
|
||||
try {
|
||||
return {
|
||||
username: decryptString(acc.username),
|
||||
password: decryptString(acc.password),
|
||||
};
|
||||
} catch (err) {
|
||||
electronLog.error('[KCC] Failed to decrypt account credentials:', err);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('alt-remove', (_e, index: number) => {
|
||||
const accounts = config.get('accounts') || [];
|
||||
if (index < 0 || index >= accounts.length) return { success: false };
|
||||
accounts.splice(index, 1);
|
||||
config.set('accounts', accounts);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
ipcMain.handle('alt-rename', (_e, index: number, newLabel: string) => {
|
||||
const accounts = config.get('accounts') || [];
|
||||
if (index < 0 || index >= accounts.length) return { success: false };
|
||||
accounts[index].label = newLabel;
|
||||
config.set('accounts', accounts);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// ── Stop page immediately on close to kill audio ──
|
||||
win.on('close', () => {
|
||||
win.webContents.setAudioMuted(true);
|
||||
win.webContents.stop();
|
||||
});
|
||||
|
||||
// ── Shutdown: disconnect Discord, then close log streams ──
|
||||
app.on('will-quit', () => {
|
||||
discordRpc?.disconnect();
|
||||
electronLog.log('[KCC] Shutting down');
|
||||
closeLogStreams();
|
||||
});
|
||||
|
||||
electronLog.log('[KCC] Initialization complete — loading game');
|
||||
|
||||
// ── Load the game ──
|
||||
win.loadURL('https://krunker.io');
|
||||
}
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
app.quit();
|
||||
});
|
||||
@@ -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,145 @@
|
||||
import { app } from 'electron';
|
||||
import type { AppConfig } from './config';
|
||||
|
||||
export type Platform = 'win32' | 'linux' | 'darwin';
|
||||
export type GpuBackend = 'angle' | 'opengl' | 'vulkan' | 'default';
|
||||
|
||||
export interface PlatformInfo {
|
||||
os: Platform;
|
||||
isWindows: boolean;
|
||||
isLinux: boolean;
|
||||
useNativeTitlebar: boolean;
|
||||
gpuBackend: GpuBackend;
|
||||
}
|
||||
|
||||
export function detectPlatform(): PlatformInfo {
|
||||
const os = process.platform as Platform;
|
||||
const isWindows = os === 'win32';
|
||||
const isLinux = os === 'linux';
|
||||
|
||||
return {
|
||||
os,
|
||||
isWindows,
|
||||
isLinux,
|
||||
useNativeTitlebar: isLinux,
|
||||
gpuBackend: isWindows ? 'angle' : 'default',
|
||||
};
|
||||
}
|
||||
|
||||
export function applyPlatformFlags(info: PlatformInfo, advanced: AppConfig['advanced'], performance: AppConfig['performance']): void {
|
||||
// ── FPS uncap ──
|
||||
// disable-frame-rate-limit causes compositor CPU spin on Chromium 84+, starving
|
||||
// input events. On Electron 42 (Chromium 147), this is fixed by a patch to
|
||||
// cc/scheduler/scheduler.cc in our custom Electron build. The latency recovery
|
||||
// flags below are no-ops on Chromium 94+ (features were removed), but are
|
||||
// harmless to keep — Chromium ignores unknown feature flags.
|
||||
if (performance.fpsUnlocked) {
|
||||
app.commandLine.appendSwitch('disable-frame-rate-limit');
|
||||
app.commandLine.appendSwitch('disable-gpu-vsync');
|
||||
app.commandLine.appendSwitch('max-gum-fps', '9999');
|
||||
app.commandLine.appendSwitch('enable-features', 'ImplLatencyRecovery,MainLatencyRecovery');
|
||||
}
|
||||
|
||||
// ── Always-on platform flags ──
|
||||
app.commandLine.appendSwitch('disable-backgrounding-occluded-windows');
|
||||
app.commandLine.appendSwitch('disable-threaded-scrolling');
|
||||
app.commandLine.appendSwitch('overscroll-history-navigation', '0');
|
||||
app.commandLine.appendSwitch('pull-to-refresh', '0');
|
||||
// WebGL is mandatory for Krunker — force it past any GPU blocklist.
|
||||
// On Chromium 134+ the blocklist is stricter and silently disables WebGL on many Linux GPUs.
|
||||
app.commandLine.appendSwitch('ignore-gpu-blocklist');
|
||||
|
||||
// ── ANGLE backend ──
|
||||
// 'default' means platform default: D3D11 on Windows, no override on Linux
|
||||
if (advanced.angleBackend && advanced.angleBackend !== 'default') {
|
||||
app.commandLine.appendSwitch('use-angle', advanced.angleBackend);
|
||||
} else if (info.isWindows) {
|
||||
app.commandLine.appendSwitch('use-angle', 'd3d11');
|
||||
}
|
||||
|
||||
if (info.isWindows) {
|
||||
app.commandLine.appendSwitch('disable-features', 'CalculateNativeWinOcclusion,HardwareMediaKeyHandling');
|
||||
}
|
||||
|
||||
if (info.isLinux) {
|
||||
app.commandLine.appendSwitch('ozone-platform-hint', 'auto');
|
||||
// GPU sandbox can fail inside AppImage FUSE mounts and on certain Mesa driver versions,
|
||||
// causing the GPU process to crash and leaving a black screen.
|
||||
app.commandLine.appendSwitch('disable-gpu-sandbox');
|
||||
}
|
||||
|
||||
// ── Remove useless features ──
|
||||
if (advanced.removeUselessFeatures) {
|
||||
app.commandLine.appendSwitch('disable-breakpad');
|
||||
app.commandLine.appendSwitch('disable-crash-reporter');
|
||||
app.commandLine.appendSwitch('disable-crashpad-forwarding');
|
||||
app.commandLine.appendSwitch('disable-print-preview');
|
||||
app.commandLine.appendSwitch('disable-metrics-reporting');
|
||||
app.commandLine.appendSwitch('disable-metrics');
|
||||
app.commandLine.appendSwitch('disable-2d-canvas-clip-aa');
|
||||
app.commandLine.appendSwitch('disable-logging');
|
||||
app.commandLine.appendSwitch('disable-hang-monitor');
|
||||
app.commandLine.appendSwitch('disable-component-update');
|
||||
app.commandLine.appendSwitch('disable-bundled-ppapi-flash');
|
||||
app.commandLine.appendSwitch('disable-nacl');
|
||||
app.commandLine.appendSwitch('disable-features', 'NativeNotifications,MediaRouter,PerformanceInterventionUI,HappinessTrackingSurveysForDesktopDemo');
|
||||
}
|
||||
|
||||
// ── GPU rasterization ──
|
||||
// OOP rasterization is always-on when GPU rasterization is enabled (Chromium 100+)
|
||||
if (advanced.gpuRasterizing) {
|
||||
app.commandLine.appendSwitch('enable-gpu-rasterization');
|
||||
app.commandLine.appendSwitch('disable-zero-copy');
|
||||
app.commandLine.appendSwitch('disable-software-rasterizer');
|
||||
app.commandLine.appendSwitch('disable-gpu-driver-bug-workarounds');
|
||||
}
|
||||
|
||||
// ── Helpful flags ──
|
||||
if (advanced.helpfulFlags) {
|
||||
app.commandLine.appendSwitch('enable-javascript-harmony');
|
||||
app.commandLine.appendSwitch('enable-future-v8-vm-features');
|
||||
app.commandLine.appendSwitch('enable-webgl');
|
||||
app.commandLine.appendSwitch('disable-background-timer-throttling');
|
||||
app.commandLine.appendSwitch('disable-renderer-backgrounding');
|
||||
app.commandLine.appendSwitch('disable-best-effort-tasks');
|
||||
app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required');
|
||||
app.commandLine.appendSwitch('enable-features', 'V8VmFuture,WebAssemblyBaseline,WebAssemblyTiering,WebAssemblyLazyCompilation');
|
||||
}
|
||||
|
||||
// ── Disable accelerated 2D canvas ──
|
||||
if (advanced.disableAccelerated2D) {
|
||||
app.commandLine.appendSwitch('disable-accelerated-2d-canvas');
|
||||
}
|
||||
|
||||
// ── Increase limits ──
|
||||
if (advanced.increaseLimits) {
|
||||
app.commandLine.appendSwitch('renderer-process-limit', '100');
|
||||
app.commandLine.appendSwitch('max-active-webgl-contexts', '100');
|
||||
app.commandLine.appendSwitch('webrtc-max-cpu-consumption-percentage', '100');
|
||||
app.commandLine.appendSwitch('ignore-gpu-blocklist');
|
||||
}
|
||||
|
||||
// ── Low latency ──
|
||||
// High-res timers and QUIC are default on Chromium 100+. Accelerated 2D canvas
|
||||
// is default on Chromium 42+. These enable flags were removed from the source.
|
||||
if (advanced.lowLatency) {
|
||||
app.commandLine.appendSwitch('force-high-performance-gpu');
|
||||
app.commandLine.appendSwitch('enable-quic');
|
||||
app.commandLine.appendSwitch('quic-max-packet-length', '1460');
|
||||
app.commandLine.appendSwitch('raise-timer-frequency');
|
||||
}
|
||||
|
||||
// ── Experimental flags ──
|
||||
// Removed dead flags: enable-accelerated-video-decode (default since Chromium 132),
|
||||
// enable-native-gpu-memory-buffers (Linux-only), high-dpi-support (removed in ~M54,
|
||||
// HiDPI is default since M108). Renamed ignore-gpu-blacklist → ignore-gpu-blocklist.
|
||||
if (advanced.experimentalFlags) {
|
||||
app.commandLine.appendSwitch('disable-low-end-device-mode');
|
||||
app.commandLine.appendSwitch('disable-gpu-watchdog');
|
||||
app.commandLine.appendSwitch('ignore-gpu-blocklist');
|
||||
app.commandLine.appendSwitch('no-pings');
|
||||
app.commandLine.appendSwitch('no-proxy-server');
|
||||
app.commandLine.appendSwitch('enable-features', 'BlinkCompositorUseDisplayThreadPriority,GpuUseDisplayThreadPriority');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { existsSync, mkdirSync, promises as fsp } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { protocol, net, Session } from 'electron';
|
||||
|
||||
const PROTOCOL_NAME = 'kpc-swap';
|
||||
const TARGET_DOMAIN = 'krunker.io';
|
||||
|
||||
/**
|
||||
* Convert a native file path to a proper kpc-swap:// URL.
|
||||
* Windows paths like C:\foo\bar become kpc-swap://C/foo/bar
|
||||
*/
|
||||
function filePathToSwapURL(filePath: string): string {
|
||||
const forwardSlash = filePath.replace(/\\/g, '/');
|
||||
// Windows drive letter: C:/foo → kpc-swap://C/foo
|
||||
const match = forwardSlash.match(/^([A-Za-z]):\/(.*)/);
|
||||
if (match) {
|
||||
return `${PROTOCOL_NAME}://${match[1]}/${match[2]}`;
|
||||
}
|
||||
// Unix absolute: /home/user/foo → kpc-swap:///home/user/foo
|
||||
return `${PROTOCOL_NAME}://${forwardSlash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the custom protocol scheme. Must be called BEFORE app.ready.
|
||||
*/
|
||||
export function initSwapperProtocol(): void {
|
||||
protocol.registerSchemesAsPrivileged([{
|
||||
scheme: PROTOCOL_NAME,
|
||||
privileges: { standard: true, secure: true, corsEnabled: true, bypassCSP: true },
|
||||
}]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the file protocol handler on the given session.
|
||||
* Must be called AFTER app.ready.
|
||||
*/
|
||||
export function registerSwapperFileProtocol(ses: Session): void {
|
||||
ses.protocol.handle(PROTOCOL_NAME, async (request) => {
|
||||
const url = new URL(request.url);
|
||||
// Reconstruct the file path from the URL
|
||||
// Windows: kpc-swap://C/foo/bar → C:/foo/bar
|
||||
// Unix: kpc-swap:///home/foo → /home/foo
|
||||
let filePath: string;
|
||||
if (url.hostname) {
|
||||
// Windows drive letter is the hostname
|
||||
filePath = `${url.hostname}:${url.pathname}`;
|
||||
} else {
|
||||
filePath = url.pathname;
|
||||
}
|
||||
try {
|
||||
return await net.fetch(`file://${filePath}`);
|
||||
} catch {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans a local directory and intercepts matching Krunker asset requests,
|
||||
* redirecting them to local replacement files via a custom protocol.
|
||||
*/
|
||||
export class ResourceSwapper {
|
||||
private swapDir: string;
|
||||
private swapFiles = new Map<string, string>();
|
||||
private ready = false;
|
||||
private scanPromise: Promise<void>;
|
||||
|
||||
constructor(swapDir: string) {
|
||||
this.swapDir = swapDir;
|
||||
if (!existsSync(this.swapDir)) mkdirSync(this.swapDir, { recursive: true });
|
||||
this.scanPromise = this.scanAsync('');
|
||||
}
|
||||
|
||||
/** Wait for the async directory scan to complete */
|
||||
async waitForReady(): Promise<void> {
|
||||
await this.scanPromise;
|
||||
this.ready = true;
|
||||
}
|
||||
|
||||
/** Rescan the swap directory to pick up added/removed/changed files */
|
||||
async rescan(): Promise<void> {
|
||||
this.swapFiles.clear();
|
||||
await this.scanAsync('');
|
||||
this.ready = true;
|
||||
}
|
||||
|
||||
/** URL filter patterns for webRequest.onBeforeRequest — single broad pattern */
|
||||
get patterns(): string[] {
|
||||
return this.swapFiles.size > 0 ? [`*://*.${TARGET_DOMAIN}/*`] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a redirect URL if the request should be swapped, null otherwise.
|
||||
* Strips /assets/ prefix so both `assets.krunker.io/assets/textures/foo.png`
|
||||
* and `assets.krunker.io/textures/foo.png` resolve to the same local file.
|
||||
*/
|
||||
getRedirect(url: string): string | null {
|
||||
if (!this.ready) return null;
|
||||
try {
|
||||
// Extract pathname from URL using string ops (faster than new URL())
|
||||
// URLs are like: https://assets.krunker.io/path/file.ext?v=hash
|
||||
const protoEnd = url.indexOf('//');
|
||||
if (protoEnd === -1) return null;
|
||||
const pathStart = url.indexOf('/', protoEnd + 2);
|
||||
if (pathStart === -1) return null;
|
||||
const queryStart = url.indexOf('?', pathStart);
|
||||
let pathname = queryStart === -1 ? url.substring(pathStart) : url.substring(pathStart, queryStart);
|
||||
if (pathname.startsWith('/assets/')) pathname = pathname.substring(7);
|
||||
const localPath = this.swapFiles.get(pathname);
|
||||
if (localPath) return filePathToSwapURL(localPath);
|
||||
} catch { /* malformed URL — ignore */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Recursively scan the swap directory and build the file map (async) */
|
||||
private async scanAsync(prefix: string): Promise<void> {
|
||||
try {
|
||||
const entries = await fsp.readdir(join(this.swapDir, prefix), { withFileTypes: true });
|
||||
for (const dirent of entries) {
|
||||
const name = `${prefix}/${dirent.name}`;
|
||||
if (dirent.isDirectory()) {
|
||||
await this.scanAsync(name);
|
||||
} else {
|
||||
this.swapFiles.set(name, join(this.swapDir, name));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
console.error(`Failed to scan swap directory prefix: ${prefix}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
// ── Inline HTML for the tab bar WebContentsView ──
|
||||
// Rendered as a data URL. Communicates with TabManager via ipcRenderer.
|
||||
|
||||
import { THEME_CSS } from './client-ui';
|
||||
|
||||
export const TAB_BAR_HTML = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
${THEME_CSS}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: var(--kpc-surface-dialog);
|
||||
color: var(--kpc-text-primary);
|
||||
height: 40px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 6px;
|
||||
user-select: none;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
/* ── Shared pill style for Game btn, tabs, and New Tab btn ── */
|
||||
.bar-pill {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid var(--kpc-toggle-off);
|
||||
border-radius: 6px;
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
transition: background 0.12s, border-color 0.12s;
|
||||
background: var(--kpc-surface-card);
|
||||
color: var(--kpc-text-secondary);
|
||||
}
|
||||
.bar-pill:hover {
|
||||
background: var(--kpc-surface-input);
|
||||
border-color: rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
/* ── Game button (green accent) ── */
|
||||
#gameBtn {
|
||||
background: rgba(76, 175, 80, 0.12);
|
||||
color: var(--kpc-green);
|
||||
border-color: rgba(76, 175, 80, 0.5);
|
||||
font-weight: 600;
|
||||
}
|
||||
#gameBtn:hover {
|
||||
background: rgba(76, 175, 80, 0.25);
|
||||
border-color: var(--kpc-green);
|
||||
}
|
||||
|
||||
/* ── Tab strip ── */
|
||||
#tabStrip {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
padding: 4px 0;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
#tabStrip::-webkit-scrollbar { display: none; }
|
||||
|
||||
/* ── Tab pills ── */
|
||||
.tab {
|
||||
position: relative;
|
||||
gap: 6px;
|
||||
max-width: 200px;
|
||||
min-width: 60px;
|
||||
height: 28px;
|
||||
}
|
||||
.tab.dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
.tab.drop-before::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -3px;
|
||||
top: 2px;
|
||||
bottom: 2px;
|
||||
width: 2px;
|
||||
background: var(--kpc-green);
|
||||
border-radius: 1px;
|
||||
}
|
||||
.tab.drop-after::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: -3px;
|
||||
top: 2px;
|
||||
bottom: 2px;
|
||||
width: 2px;
|
||||
background: var(--kpc-green);
|
||||
border-radius: 1px;
|
||||
}
|
||||
.tab.active {
|
||||
background: rgba(76, 175, 80, 0.12);
|
||||
border-color: rgba(76, 175, 80, 0.5);
|
||||
color: var(--kpc-text-primary);
|
||||
}
|
||||
.tab.active:hover {
|
||||
background: rgba(76, 175, 80, 0.2);
|
||||
border-color: var(--kpc-green);
|
||||
}
|
||||
|
||||
.tab-title {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab-spinner {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: 1.5px solid var(--kpc-border-medium);
|
||||
border-top-color: var(--kpc-green);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
flex-shrink: 0;
|
||||
display: none;
|
||||
}
|
||||
.tab.loading .tab-spinner { display: block; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.tab-close {
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
line-height: 15px;
|
||||
text-align: center;
|
||||
border-radius: 3px;
|
||||
font-size: 13px;
|
||||
color: var(--kpc-text-dim);
|
||||
transition: background 0.1s, color 0.1s;
|
||||
}
|
||||
.tab-close:hover {
|
||||
background: var(--kpc-toggle-off);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ── New Tab button ── */
|
||||
#newTabBtn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
color: var(--kpc-text-faint);
|
||||
padding: 0;
|
||||
border-style: dashed;
|
||||
}
|
||||
#newTabBtn:hover {
|
||||
color: var(--kpc-text-primary);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<button id="gameBtn" class="bar-pill">Game</button>
|
||||
<div id="tabStrip"></div>
|
||||
<button id="newTabBtn" class="bar-pill" title="New Tab (Ctrl+T)">+</button>
|
||||
<script>
|
||||
const { ipcRenderer } = require('electron');
|
||||
const strip = document.getElementById('tabStrip');
|
||||
|
||||
document.getElementById('gameBtn').addEventListener('click', () => {
|
||||
ipcRenderer.send('tab-back-to-game');
|
||||
});
|
||||
|
||||
document.getElementById('newTabBtn').addEventListener('click', () => {
|
||||
ipcRenderer.send('tab-new');
|
||||
});
|
||||
|
||||
/* ── Drag state ── */
|
||||
let dragId = null;
|
||||
let dragStartX = 0;
|
||||
let dragging = false;
|
||||
const DRAG_THRESHOLD = 5;
|
||||
|
||||
function clearDropIndicators() {
|
||||
strip.querySelectorAll('.drop-before,.drop-after').forEach(
|
||||
el => el.classList.remove('drop-before', 'drop-after')
|
||||
);
|
||||
}
|
||||
|
||||
function getDropTarget(clientX) {
|
||||
const tabs = Array.from(strip.querySelectorAll('.tab'));
|
||||
for (const tab of tabs) {
|
||||
if (Number(tab.dataset.id) === dragId) continue;
|
||||
const r = tab.getBoundingClientRect();
|
||||
const mid = r.left + r.width / 2;
|
||||
if (clientX < mid) return { id: Number(tab.dataset.id), side: 'before', el: tab };
|
||||
}
|
||||
const last = tabs[tabs.length - 1];
|
||||
if (last && Number(last.dataset.id) !== dragId) {
|
||||
return { id: Number(last.dataset.id), side: 'after', el: last };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
if (dragId === null) return;
|
||||
if (!dragging && Math.abs(e.clientX - dragStartX) >= DRAG_THRESHOLD) {
|
||||
dragging = true;
|
||||
const el = strip.querySelector('.tab[data-id="' + dragId + '"]');
|
||||
if (el) el.classList.add('dragging');
|
||||
}
|
||||
if (!dragging) return;
|
||||
clearDropIndicators();
|
||||
const target = getDropTarget(e.clientX);
|
||||
if (target) target.el.classList.add(target.side === 'before' ? 'drop-before' : 'drop-after');
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', (e) => {
|
||||
if (dragId === null) return;
|
||||
const wasDragging = dragging;
|
||||
const srcId = dragId;
|
||||
clearDropIndicators();
|
||||
const dragEl = strip.querySelector('.tab.dragging');
|
||||
if (dragEl) dragEl.classList.remove('dragging');
|
||||
dragId = null;
|
||||
dragging = false;
|
||||
|
||||
if (wasDragging) {
|
||||
const target = getDropTarget(e.clientX);
|
||||
if (target) {
|
||||
ipcRenderer.send('tab-reorder', srcId, target.id, target.side);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ipcRenderer.on('tabs-update', (_e, tabs) => {
|
||||
strip.innerHTML = '';
|
||||
for (const t of tabs) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'bar-pill tab' + (t.active ? ' active' : '') + (t.loading ? ' loading' : '');
|
||||
el.dataset.id = String(t.id);
|
||||
|
||||
const spinner = document.createElement('div');
|
||||
spinner.className = 'tab-spinner';
|
||||
el.appendChild(spinner);
|
||||
|
||||
const title = document.createElement('span');
|
||||
title.className = 'tab-title';
|
||||
title.textContent = t.title || 'Loading...';
|
||||
title.title = t.title || '';
|
||||
el.appendChild(title);
|
||||
|
||||
const close = document.createElement('span');
|
||||
close.className = 'tab-close';
|
||||
close.textContent = '\\u00d7';
|
||||
close.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
ipcRenderer.send('tab-close', t.id);
|
||||
});
|
||||
el.appendChild(close);
|
||||
|
||||
el.addEventListener('mousedown', (ev) => {
|
||||
if (ev.target.classList.contains('tab-close')) return;
|
||||
dragId = t.id;
|
||||
dragStartX = ev.clientX;
|
||||
dragging = false;
|
||||
});
|
||||
|
||||
el.addEventListener('click', () => {
|
||||
if (!dragging) ipcRenderer.send('tab-switch', t.id);
|
||||
});
|
||||
|
||||
strip.appendChild(el);
|
||||
}
|
||||
|
||||
const activeEl = strip.querySelector('.tab.active');
|
||||
if (activeEl) activeEl.scrollIntoView({ inline: 'nearest', block: 'nearest' });
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
export const TAB_BAR_DATA_URL = 'data:text/html;charset=utf-8,' + encodeURIComponent(TAB_BAR_HTML);
|
||||
@@ -0,0 +1,666 @@
|
||||
import { BrowserWindow, WebContentsView, View, Menu, clipboard, ipcMain, shell } from 'electron';
|
||||
import { TAB_BAR_DATA_URL } from './tab-bar-html';
|
||||
import { ALL_CLIENT_CSS } from './client-ui';
|
||||
import { electronLog } from './logger';
|
||||
|
||||
const KRUNKER_SOCIAL = 'https://krunker.io/social.html';
|
||||
const TAB_BAR_HEIGHT = 40;
|
||||
const MAX_TABS = 20;
|
||||
|
||||
interface TabInfo {
|
||||
id: number;
|
||||
view: WebContentsView;
|
||||
title: string;
|
||||
url: string;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
interface TabWindowState {
|
||||
width: number;
|
||||
height: number;
|
||||
x: number | undefined;
|
||||
y: number | undefined;
|
||||
maximized: boolean;
|
||||
}
|
||||
|
||||
type TabMode = 'same' | 'new';
|
||||
|
||||
export class TabManager {
|
||||
private tabs: TabInfo[] = [];
|
||||
private activeTabId: number | null = null;
|
||||
private tabBarView: WebContentsView;
|
||||
private containerView: View;
|
||||
private tabWindow: BrowserWindow | null = null;
|
||||
private visible = false;
|
||||
private nextId = 1;
|
||||
private mode: TabMode;
|
||||
private mainWin: BrowserWindow;
|
||||
private ses: Electron.Session;
|
||||
private preloadPath: string;
|
||||
private isGameURL: (url: string) => boolean;
|
||||
private titlePolls = new Map<number, ReturnType<typeof setInterval>>();
|
||||
private recentlyClosed: { url: string; title: string }[] = [];
|
||||
private getTabWindowState: () => TabWindowState;
|
||||
private saveTabWindowState: (state: TabWindowState) => void;
|
||||
private tabSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(
|
||||
win: BrowserWindow,
|
||||
ses: Electron.Session,
|
||||
preloadPath: string,
|
||||
mode: TabMode,
|
||||
isGameURL: (url: string) => boolean,
|
||||
getTabWindowState: () => TabWindowState,
|
||||
saveTabWindowState: (state: TabWindowState) => void,
|
||||
) {
|
||||
this.mainWin = win;
|
||||
this.ses = ses;
|
||||
this.preloadPath = preloadPath;
|
||||
this.mode = mode;
|
||||
this.isGameURL = isGameURL;
|
||||
this.getTabWindowState = getTabWindowState;
|
||||
this.saveTabWindowState = saveTabWindowState;
|
||||
|
||||
// ── Tab bar view (shared between both modes) ──
|
||||
this.tabBarView = new WebContentsView({
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
contextIsolation: false,
|
||||
sandbox: false,
|
||||
},
|
||||
});
|
||||
this.tabBarView.webContents.loadURL(TAB_BAR_DATA_URL);
|
||||
|
||||
// ── Container view (holds tab bar + active tab content) ──
|
||||
this.containerView = new View();
|
||||
this.containerView.addChildView(this.tabBarView);
|
||||
|
||||
// Tab bar keybinds (when tab bar itself is focused)
|
||||
this.tabBarView.webContents.on('before-input-event', (event, input) => {
|
||||
if (input.type !== 'keyDown') return;
|
||||
if (this.handleTabShortcut(event, input)) return;
|
||||
});
|
||||
|
||||
if (mode === 'same') {
|
||||
this.initSameWindowMode();
|
||||
}
|
||||
// 'new' mode: tabWindow created lazily on first openTab()
|
||||
|
||||
this.registerIPC();
|
||||
}
|
||||
|
||||
// ── Same Window Mode Setup ──
|
||||
private initSameWindowMode(): void {
|
||||
this.mainWin.contentView.addChildView(this.containerView);
|
||||
this.containerView.setVisible(false);
|
||||
this.visible = false;
|
||||
this.mainWin.on('resize', () => this.updateLayout());
|
||||
}
|
||||
|
||||
// ── New Window Mode: create/show the tab window ──
|
||||
private ensureTabWindow(): void {
|
||||
if (this.tabWindow && !this.tabWindow.isDestroyed()) return;
|
||||
|
||||
const saved = this.getTabWindowState();
|
||||
|
||||
this.tabWindow = new BrowserWindow({
|
||||
width: saved.width,
|
||||
height: saved.height,
|
||||
x: saved.x,
|
||||
y: saved.y,
|
||||
frame: true,
|
||||
backgroundColor: '#000000',
|
||||
autoHideMenuBar: true,
|
||||
title: 'KCC - Tabs',
|
||||
show: false,
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
},
|
||||
});
|
||||
this.tabWindow.removeMenu();
|
||||
|
||||
if (saved.maximized) this.tabWindow.maximize();
|
||||
|
||||
this.tabWindow.contentView.addChildView(this.containerView);
|
||||
this.containerView.setVisible(true);
|
||||
|
||||
this.tabWindow.on('resize', () => {
|
||||
this.updateLayout();
|
||||
this.debounceSaveTabWindow();
|
||||
});
|
||||
this.tabWindow.on('move', () => this.debounceSaveTabWindow());
|
||||
this.tabWindow.on('close', () => {
|
||||
// Flush pending save before the window is destroyed
|
||||
if (this.tabSaveTimer) clearTimeout(this.tabSaveTimer);
|
||||
if (this.tabWindow && !this.tabWindow.isDestroyed()) {
|
||||
const bounds = this.tabWindow.getBounds();
|
||||
this.saveTabWindowState({
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
maximized: this.tabWindow.isMaximized(),
|
||||
});
|
||||
}
|
||||
});
|
||||
this.tabWindow.on('closed', () => {
|
||||
this.destroyAllTabs();
|
||||
this.tabWindow = null;
|
||||
});
|
||||
|
||||
this.tabWindow.show();
|
||||
}
|
||||
|
||||
private debounceSaveTabWindow(): void {
|
||||
if (this.tabSaveTimer) clearTimeout(this.tabSaveTimer);
|
||||
this.tabSaveTimer = setTimeout(() => {
|
||||
if (!this.tabWindow || this.tabWindow.isDestroyed()) return;
|
||||
const bounds = this.tabWindow.getBounds();
|
||||
this.saveTabWindowState({
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
maximized: this.tabWindow.isMaximized(),
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// ── IPC from tab bar ──
|
||||
private registerIPC(): void {
|
||||
ipcMain.on('tab-switch', (_e, id: number) => this.switchToTab(id));
|
||||
ipcMain.on('tab-close', (_e, id: number) => this.closeTab(id));
|
||||
ipcMain.on('tab-new', () => this.openTab(KRUNKER_SOCIAL));
|
||||
ipcMain.on('tab-reorder', (_e, fromId: number, toId: number, side: string) => {
|
||||
this.reorderTab(fromId, toId, side as 'before' | 'after');
|
||||
});
|
||||
ipcMain.on('tab-back-to-game', () => {
|
||||
if (this.mode === 'same') {
|
||||
this.hideTabs();
|
||||
} else {
|
||||
this.mainWin.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Open a new tab ──
|
||||
openTab(url: string): number {
|
||||
if (this.tabs.length >= MAX_TABS) {
|
||||
const existing = this.tabs.find(t => t.url === url);
|
||||
if (existing) {
|
||||
this.switchToTab(existing.id);
|
||||
return existing.id;
|
||||
}
|
||||
electronLog.warn('[KCC-Tabs] Tab limit reached, ignoring openTab');
|
||||
return -1;
|
||||
}
|
||||
|
||||
const id = this.nextId++;
|
||||
const view = this.createTabView(id);
|
||||
const tab: TabInfo = { id, view, title: this.titleFromUrl(url), url, loading: true };
|
||||
this.tabs.push(tab);
|
||||
|
||||
if (this.mode === 'new') {
|
||||
this.ensureTabWindow();
|
||||
}
|
||||
|
||||
this.switchToTab(id);
|
||||
this.showTabs();
|
||||
view.webContents.loadURL(url);
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
// ── Create a WebContentsView for a tab ──
|
||||
private createTabView(tabId: number): WebContentsView {
|
||||
const view = new WebContentsView({
|
||||
webPreferences: {
|
||||
preload: this.preloadPath,
|
||||
session: this.ses,
|
||||
contextIsolation: false,
|
||||
nodeIntegration: false,
|
||||
sandbox: false,
|
||||
spellcheck: false,
|
||||
},
|
||||
});
|
||||
|
||||
const wc = view.webContents;
|
||||
|
||||
wc.on('did-finish-load', () => {
|
||||
wc.insertCSS(ALL_CLIENT_CSS).catch(() => {});
|
||||
wc.send('main_did-finish-load-tab');
|
||||
ipcMain.emit('throttle-state', { sender: wc } as any, 'menu');
|
||||
this.updateTabInfo(tabId, { loading: false });
|
||||
this.startTitleWatcher(tabId, wc);
|
||||
});
|
||||
|
||||
wc.on('did-start-loading', () => {
|
||||
this.updateTabInfo(tabId, { loading: true });
|
||||
});
|
||||
|
||||
wc.on('did-stop-loading', () => {
|
||||
this.updateTabInfo(tabId, { loading: false });
|
||||
});
|
||||
|
||||
wc.on('page-title-updated', (_e, title) => {
|
||||
if (this.isGenericTitle(title)) return;
|
||||
this.updateTabInfo(tabId, { title });
|
||||
});
|
||||
|
||||
wc.on('did-navigate', (_e, url) => {
|
||||
this.updateTabInfo(tabId, { url, title: this.titleFromUrl(url) });
|
||||
});
|
||||
|
||||
wc.setWindowOpenHandler(({ url: linkUrl }) => {
|
||||
if (linkUrl.includes('krunker.io')) {
|
||||
if (this.isGameURL(linkUrl)) {
|
||||
this.mainWin.loadURL(linkUrl);
|
||||
if (this.mode === 'same') this.hideTabs();
|
||||
else this.mainWin.focus();
|
||||
} else {
|
||||
setImmediate(() => this.openTab(linkUrl));
|
||||
}
|
||||
} else {
|
||||
setImmediate(() => shell.openExternal(linkUrl));
|
||||
}
|
||||
return { action: 'deny' as const };
|
||||
});
|
||||
|
||||
wc.on('will-navigate', (event, navUrl) => {
|
||||
if (navUrl.includes('krunker.io') && this.isGameURL(navUrl)) {
|
||||
event.preventDefault();
|
||||
this.mainWin.loadURL(navUrl);
|
||||
if (this.mode === 'same') this.hideTabs();
|
||||
else this.mainWin.focus();
|
||||
}
|
||||
});
|
||||
|
||||
wc.on('context-menu', (_e, params) => {
|
||||
if (!params.linkURL) return;
|
||||
const items: Electron.MenuItemConstructorOptions[] = [];
|
||||
if (params.linkURL.includes('krunker.io') && !this.isGameURL(params.linkURL)) {
|
||||
items.push({ label: 'Open in New Tab', click: () => this.openTab(params.linkURL) });
|
||||
}
|
||||
items.push({ label: 'Copy Link', click: () => clipboard.writeText(params.linkURL) });
|
||||
if (!params.linkURL.includes('krunker.io')) {
|
||||
items.push({ label: 'Open in Browser', click: () => shell.openExternal(params.linkURL) });
|
||||
}
|
||||
if (items.length) Menu.buildFromTemplate(items).popup();
|
||||
});
|
||||
|
||||
wc.on('before-input-event', (event, input) => {
|
||||
if (input.type !== 'keyDown') return;
|
||||
if (this.handleTabShortcut(event, input)) return;
|
||||
if (input.key === 'F12' && !input.control && !input.shift && !input.alt) {
|
||||
wc.toggleDevTools();
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
// ── Switch active tab ──
|
||||
switchToTab(id: number): void {
|
||||
const tab = this.tabs.find(t => t.id === id);
|
||||
if (!tab) return;
|
||||
|
||||
if (this.activeTabId !== null) {
|
||||
const prev = this.tabs.find(t => t.id === this.activeTabId);
|
||||
if (prev) {
|
||||
this.containerView.removeChildView(prev.view);
|
||||
}
|
||||
}
|
||||
|
||||
this.activeTabId = id;
|
||||
this.containerView.addChildView(tab.view);
|
||||
this.updateLayout();
|
||||
this.broadcastTabState();
|
||||
}
|
||||
|
||||
// ── Close a tab ──
|
||||
closeTab(id: number): void {
|
||||
const idx = this.tabs.findIndex(t => t.id === id);
|
||||
if (idx === -1) return;
|
||||
|
||||
const tab = this.tabs[idx];
|
||||
|
||||
if (this.activeTabId === id) {
|
||||
this.containerView.removeChildView(tab.view);
|
||||
this.activeTabId = null;
|
||||
}
|
||||
|
||||
this.recentlyClosed.push({ url: tab.url, title: tab.title });
|
||||
if (this.recentlyClosed.length > 10) this.recentlyClosed.shift();
|
||||
|
||||
this.stopTitleWatcher(id);
|
||||
tab.view.webContents.close();
|
||||
this.tabs.splice(idx, 1);
|
||||
|
||||
if (this.tabs.length > 0) {
|
||||
const nextIdx = Math.min(idx, this.tabs.length - 1);
|
||||
this.switchToTab(this.tabs[nextIdx].id);
|
||||
} else {
|
||||
if (this.mode === 'same') {
|
||||
this.hideTabs();
|
||||
} else {
|
||||
if (this.tabWindow && !this.tabWindow.isDestroyed()) {
|
||||
this.tabWindow.contentView.removeChildView(this.containerView);
|
||||
this.tabWindow.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.broadcastTabState();
|
||||
}
|
||||
|
||||
// ── Show / hide tabs ──
|
||||
showTabs(): void {
|
||||
if (this.mode === 'same') {
|
||||
this.containerView.setVisible(true);
|
||||
this.visible = true;
|
||||
this.updateLayout();
|
||||
} else {
|
||||
this.ensureTabWindow();
|
||||
if (this.tabWindow && !this.tabWindow.isDestroyed()) {
|
||||
this.tabWindow.show();
|
||||
this.tabWindow.focus();
|
||||
}
|
||||
this.visible = true;
|
||||
}
|
||||
}
|
||||
|
||||
hideTabs(): void {
|
||||
if (this.mode === 'same') {
|
||||
this.containerView.setVisible(false);
|
||||
this.visible = false;
|
||||
this.mainWin.focus();
|
||||
} else {
|
||||
this.mainWin.focus();
|
||||
this.visible = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tab navigation ──
|
||||
nextTab(): void {
|
||||
if (this.tabs.length < 2 || this.activeTabId === null) return;
|
||||
const idx = this.tabs.findIndex(t => t.id === this.activeTabId);
|
||||
const next = (idx + 1) % this.tabs.length;
|
||||
this.switchToTab(this.tabs[next].id);
|
||||
}
|
||||
|
||||
prevTab(): void {
|
||||
if (this.tabs.length < 2 || this.activeTabId === null) return;
|
||||
const idx = this.tabs.findIndex(t => t.id === this.activeTabId);
|
||||
const prev = (idx - 1 + this.tabs.length) % this.tabs.length;
|
||||
this.switchToTab(this.tabs[prev].id);
|
||||
}
|
||||
|
||||
closeCurrentTab(): void {
|
||||
if (this.activeTabId !== null) this.closeTab(this.activeTabId);
|
||||
}
|
||||
|
||||
// ── Reorder tabs via drag ──
|
||||
reorderTab(fromId: number, toId: number, side: 'before' | 'after'): void {
|
||||
const fromIdx = this.tabs.findIndex(t => t.id === fromId);
|
||||
const toIdx = this.tabs.findIndex(t => t.id === toId);
|
||||
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return;
|
||||
|
||||
const [tab] = this.tabs.splice(fromIdx, 1);
|
||||
let insertIdx = this.tabs.findIndex(t => t.id === toId);
|
||||
if (side === 'after') insertIdx++;
|
||||
this.tabs.splice(insertIdx, 0, tab);
|
||||
this.broadcastTabState();
|
||||
}
|
||||
|
||||
// ── Jump to tab by position (0-based, -1 = last) ──
|
||||
switchToTabByIndex(index: number): void {
|
||||
if (this.tabs.length === 0) return;
|
||||
if (index < 0 || index >= this.tabs.length) index = this.tabs.length - 1;
|
||||
this.switchToTab(this.tabs[index].id);
|
||||
}
|
||||
|
||||
// ── Reopen last closed tab ──
|
||||
reopenTab(): void {
|
||||
const entry = this.recentlyClosed.pop();
|
||||
if (entry) this.openTab(entry.url);
|
||||
}
|
||||
|
||||
// ── Shared shortcut handler (returns true if handled) ──
|
||||
private handleTabShortcut(event: Electron.Event, input: Electron.Input): boolean {
|
||||
if (input.key === 'Escape' && !input.control && !input.shift && !input.alt) {
|
||||
if (this.mode === 'same') this.hideTabs();
|
||||
else this.mainWin.focus();
|
||||
event.preventDefault();
|
||||
return true;
|
||||
} else if (input.key === 'w' && input.control && !input.shift && !input.alt) {
|
||||
this.closeCurrentTab();
|
||||
event.preventDefault();
|
||||
return true;
|
||||
} else if (input.key === 'Tab' && input.control && !input.shift && !input.alt) {
|
||||
this.nextTab();
|
||||
event.preventDefault();
|
||||
return true;
|
||||
} else if (input.key === 'Tab' && input.control && input.shift && !input.alt) {
|
||||
this.prevTab();
|
||||
event.preventDefault();
|
||||
return true;
|
||||
} else if (input.key === 't' && input.control && !input.shift && !input.alt) {
|
||||
this.openTab(KRUNKER_SOCIAL);
|
||||
event.preventDefault();
|
||||
return true;
|
||||
} else if (input.key === 'T' && input.control && input.shift && !input.alt) {
|
||||
this.reopenTab();
|
||||
event.preventDefault();
|
||||
return true;
|
||||
} else if (input.key >= '1' && input.key <= '8' && input.control && !input.shift && !input.alt) {
|
||||
this.switchToTabByIndex(parseInt(input.key) - 1);
|
||||
event.preventDefault();
|
||||
return true;
|
||||
} else if (input.key === '9' && input.control && !input.shift && !input.alt) {
|
||||
this.switchToTabByIndex(-1);
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Cleanup ──
|
||||
destroyAll(): void {
|
||||
this.destroyAllTabs();
|
||||
|
||||
ipcMain.removeAllListeners('tab-switch');
|
||||
ipcMain.removeAllListeners('tab-close');
|
||||
ipcMain.removeAllListeners('tab-new');
|
||||
ipcMain.removeAllListeners('tab-reorder');
|
||||
ipcMain.removeAllListeners('tab-back-to-game');
|
||||
|
||||
if (this.tabWindow && !this.tabWindow.isDestroyed()) {
|
||||
this.tabWindow.contentView.removeChildView(this.containerView);
|
||||
this.tabWindow.close();
|
||||
this.tabWindow = null;
|
||||
}
|
||||
|
||||
if (this.mode === 'same') {
|
||||
try { this.mainWin.contentView.removeChildView(this.containerView); } catch { /* may already be removed */ }
|
||||
}
|
||||
}
|
||||
|
||||
private destroyAllTabs(): void {
|
||||
for (const tab of this.tabs) {
|
||||
this.stopTitleWatcher(tab.id);
|
||||
if (this.activeTabId === tab.id) {
|
||||
this.containerView.removeChildView(tab.view);
|
||||
}
|
||||
if (!tab.view.webContents.isDestroyed()) {
|
||||
tab.view.webContents.close();
|
||||
}
|
||||
}
|
||||
this.tabs = [];
|
||||
this.activeTabId = null;
|
||||
this.broadcastTabState();
|
||||
}
|
||||
|
||||
// ── Layout ──
|
||||
private updateLayout(): void {
|
||||
let bounds: { width: number; height: number };
|
||||
|
||||
if (this.mode === 'same') {
|
||||
const [w, h] = this.mainWin.getContentSize();
|
||||
bounds = { width: w, height: h };
|
||||
this.containerView.setBounds({ x: 0, y: 0, width: w, height: h });
|
||||
} else if (this.tabWindow && !this.tabWindow.isDestroyed()) {
|
||||
const [w, h] = this.tabWindow.getContentSize();
|
||||
bounds = { width: w, height: h };
|
||||
this.containerView.setBounds({ x: 0, y: 0, width: w, height: h });
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
this.tabBarView.setBounds({
|
||||
x: 0, y: 0,
|
||||
width: bounds.width,
|
||||
height: TAB_BAR_HEIGHT,
|
||||
});
|
||||
|
||||
if (this.activeTabId !== null) {
|
||||
const tab = this.tabs.find(t => t.id === this.activeTabId);
|
||||
if (tab) {
|
||||
tab.view.setBounds({
|
||||
x: 0,
|
||||
y: TAB_BAR_HEIGHT,
|
||||
width: bounds.width,
|
||||
height: bounds.height - TAB_BAR_HEIGHT,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Update tab metadata and broadcast ──
|
||||
private updateTabInfo(id: number, updates: Partial<Pick<TabInfo, 'title' | 'url' | 'loading'>>): void {
|
||||
const tab = this.tabs.find(t => t.id === id);
|
||||
if (!tab) return;
|
||||
if (updates.title !== undefined) tab.title = updates.title;
|
||||
if (updates.url !== undefined) tab.url = updates.url;
|
||||
if (updates.loading !== undefined) tab.loading = updates.loading;
|
||||
this.broadcastTabState();
|
||||
}
|
||||
|
||||
private broadcastTabState(): void {
|
||||
if (this.tabBarView.webContents.isDestroyed()) return;
|
||||
const data = this.tabs.map(t => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
active: t.id === this.activeTabId,
|
||||
loading: t.loading,
|
||||
}));
|
||||
this.tabBarView.webContents.send('tabs-update', data);
|
||||
}
|
||||
|
||||
private static readonly GENERIC_TITLES = new Set([
|
||||
'krunker hub', 'krunker', 'krunker.io', '',
|
||||
'hub', 'social', 'profile', 'new tab', 'loading...',
|
||||
]);
|
||||
|
||||
private isGenericTitle(title: string): boolean {
|
||||
return TabManager.GENERIC_TITLES.has(title.toLowerCase().trim());
|
||||
}
|
||||
|
||||
// ── Persistent URL watcher + DOM title extraction ──
|
||||
private startTitleWatcher(tabId: number, wc: Electron.WebContents): void {
|
||||
const existing = this.titlePolls.get(tabId);
|
||||
if (existing) clearInterval(existing);
|
||||
|
||||
let lastUrl = '';
|
||||
let lastDom = '';
|
||||
const poll = setInterval(() => {
|
||||
if (wc.isDestroyed()) {
|
||||
clearInterval(poll);
|
||||
this.titlePolls.delete(tabId);
|
||||
return;
|
||||
}
|
||||
wc.executeJavaScript(
|
||||
`(function() {
|
||||
var url = window.location.href;
|
||||
var title = '';
|
||||
var ph = document.getElementById('profileHolder');
|
||||
if (ph && ph.style.display === 'block') {
|
||||
var ns = document.getElementById('nameSwitch');
|
||||
if (ns && ns.innerText) title = ns.innerText;
|
||||
}
|
||||
return JSON.stringify({ url: url, dom: title });
|
||||
})()`
|
||||
).then((json: string) => {
|
||||
const { url, dom } = JSON.parse(json);
|
||||
if (url === lastUrl && dom === lastDom) return;
|
||||
lastUrl = url;
|
||||
lastDom = dom;
|
||||
|
||||
const tab = this.tabs.find(t => t.id === tabId);
|
||||
if (!tab) return;
|
||||
|
||||
if (dom) {
|
||||
if (tab.title !== dom) {
|
||||
this.updateTabInfo(tabId, { url, title: dom });
|
||||
} else if (tab.url !== url) {
|
||||
this.updateTabInfo(tabId, { url });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (tab.url !== url) {
|
||||
this.updateTabInfo(tabId, { url, title: this.titleFromUrl(url) });
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, 1000);
|
||||
this.titlePolls.set(tabId, poll);
|
||||
}
|
||||
|
||||
private stopTitleWatcher(tabId: number): void {
|
||||
const poll = this.titlePolls.get(tabId);
|
||||
if (poll) {
|
||||
clearInterval(poll);
|
||||
this.titlePolls.delete(tabId);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Extract a display title from URL ──
|
||||
private titleFromUrl(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const p = parsed.searchParams.get('p');
|
||||
const q = parsed.searchParams.get('q');
|
||||
|
||||
if (q) return q;
|
||||
|
||||
if (p) {
|
||||
const pageMap: Record<string, string> = {
|
||||
profile: 'Profile',
|
||||
leaders: 'Leaderboard',
|
||||
games: 'Games',
|
||||
clans: 'Clans',
|
||||
skins: 'Skins',
|
||||
mods: 'Mods',
|
||||
maps: 'Maps',
|
||||
editor: 'Editor',
|
||||
market: 'Market',
|
||||
itemsales: 'Market Item',
|
||||
inventory: 'Inventory',
|
||||
settings: 'Settings',
|
||||
feed: 'Hub',
|
||||
};
|
||||
return pageMap[p] || p.charAt(0).toUpperCase() + p.slice(1);
|
||||
}
|
||||
|
||||
const path = parsed.pathname.replace(/\.html$/, '').replace(/^\//, '');
|
||||
if (path === 'social') return 'Hub';
|
||||
if (path) return path.charAt(0).toUpperCase() + path.slice(1);
|
||||
|
||||
return 'New Tab';
|
||||
} catch {
|
||||
return 'New Tab';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,245 @@
|
||||
import { get as httpsGet } from 'https';
|
||||
import { createWriteStream, renameSync, unlinkSync, existsSync } from 'fs';
|
||||
import { spawn } from 'child_process';
|
||||
import { app } from 'electron';
|
||||
import { electronLog } from './logger';
|
||||
|
||||
export interface UpdateInfo {
|
||||
version: string;
|
||||
downloadUrl: string;
|
||||
fileSize: number;
|
||||
}
|
||||
|
||||
export type ProgressCallback = (percent: number) => void;
|
||||
|
||||
const UPDATE_CONFIG = {
|
||||
// Gitea provider
|
||||
checkUrl: 'https://gitea.crjlab.net/api/v1/repos/bigjakk/Krunker-Civilian-Client/releases/latest',
|
||||
assetPattern: /Setup\.exe$/i,
|
||||
// Allowed hosts for update check and download (including redirects)
|
||||
allowedHosts: ['gitea.crjlab.net'],
|
||||
};
|
||||
|
||||
const CHECK_TIMEOUT_MS = 10000;
|
||||
const DOWNLOAD_TIMEOUT_MS = 300000; // 5 minutes
|
||||
|
||||
/**
|
||||
* Validate that a redirect URL stays on an allowed host.
|
||||
*/
|
||||
function isAllowedRedirect(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return UPDATE_CONFIG.allowedHosts.some(h => parsed.hostname === h || parsed.hostname.endsWith('.' + h));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple semver comparison: returns true if a < b.
|
||||
* Handles versions like "0.1.0", "1.2.3".
|
||||
*/
|
||||
function versionLessThan(a: string, b: string): boolean {
|
||||
const pa = a.split('.').map(Number);
|
||||
const pb = b.split('.').map(Number);
|
||||
const len = Math.max(pa.length, pb.length);
|
||||
for (let i = 0; i < len; i++) {
|
||||
const na = pa[i] || 0;
|
||||
const nb = pb[i] || 0;
|
||||
if (na < nb) return true;
|
||||
if (na > nb) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function checkForUpdate(currentVersion: string): Promise<UpdateInfo | null> {
|
||||
return new Promise((resolve) => {
|
||||
electronLog.log('[KCC-Update] Checking for updates at:', UPDATE_CONFIG.checkUrl);
|
||||
electronLog.log('[KCC-Update] Current version:', currentVersion);
|
||||
|
||||
const req = httpsGet(UPDATE_CONFIG.checkUrl, {
|
||||
headers: { 'User-Agent': 'KrunkerCivilianClient/' + currentVersion },
|
||||
}, (res) => {
|
||||
electronLog.log('[KCC-Update] Check response status:', res.statusCode);
|
||||
// Follow redirects (with domain validation)
|
||||
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
const redirectUrl = res.headers.location;
|
||||
electronLog.log('[KCC-Update] Redirected to:', redirectUrl);
|
||||
if (!isAllowedRedirect(redirectUrl)) {
|
||||
electronLog.error('[KCC-Update] Redirect to untrusted host blocked:', redirectUrl);
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
httpsGet(redirectUrl, {
|
||||
headers: { 'User-Agent': 'KrunkerCivilianClient/' + currentVersion },
|
||||
}, (redirectRes) => {
|
||||
electronLog.log('[KCC-Update] Redirect response status:', redirectRes.statusCode);
|
||||
handleResponse(redirectRes);
|
||||
}).on('error', (err) => {
|
||||
electronLog.error('[KCC-Update] Redirect error:', err);
|
||||
resolve(null);
|
||||
});
|
||||
return;
|
||||
}
|
||||
handleResponse(res);
|
||||
});
|
||||
|
||||
function handleResponse(res: import('http').IncomingMessage): void {
|
||||
if (res.statusCode !== 200) {
|
||||
electronLog.error('[KCC-Update] Check returned status', res.statusCode);
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let data = '';
|
||||
res.on('data', (chunk: string) => { data += chunk; });
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const release = JSON.parse(data);
|
||||
const tagName: string = release.tag_name || '';
|
||||
const remoteVersion = tagName.replace(/^v/i, '');
|
||||
electronLog.log('[KCC-Update] Latest release:', remoteVersion, '| Current:', currentVersion);
|
||||
|
||||
if (!remoteVersion || !versionLessThan(currentVersion, remoteVersion)) {
|
||||
electronLog.log('[KCC-Update] Already up to date');
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const assets: Array<{ name: string; browser_download_url: string; size: number }> = release.assets || [];
|
||||
const setupAsset = assets.find((a) => UPDATE_CONFIG.assetPattern.test(a.name));
|
||||
if (!setupAsset) {
|
||||
electronLog.error('[KCC-Update] No Setup.exe asset found in release', remoteVersion);
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate the download URL points to an allowed host
|
||||
if (!isAllowedRedirect(setupAsset.browser_download_url)) {
|
||||
electronLog.error('[KCC-Update] Download URL points to untrusted host:', setupAsset.browser_download_url);
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
electronLog.log('[KCC-Update] Update available:', remoteVersion, '| Download:', setupAsset.browser_download_url, '| Size:', setupAsset.size);
|
||||
resolve({
|
||||
version: remoteVersion,
|
||||
downloadUrl: setupAsset.browser_download_url,
|
||||
fileSize: setupAsset.size,
|
||||
});
|
||||
} catch (err) {
|
||||
electronLog.error('[KCC-Update] Failed to parse release data:', err);
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
res.on('error', (err) => {
|
||||
electronLog.error('[KCC-Update] Response error:', err);
|
||||
resolve(null);
|
||||
});
|
||||
}
|
||||
|
||||
req.setTimeout(CHECK_TIMEOUT_MS, () => {
|
||||
electronLog.error('[KCC-Update] Check timed out after', CHECK_TIMEOUT_MS, 'ms');
|
||||
req.destroy();
|
||||
resolve(null);
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
electronLog.error('[KCC-Update] Check error:', err);
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function downloadUpdate(url: string, destPath: string, onProgress: ProgressCallback): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tmpPath = destPath + '.tmp';
|
||||
|
||||
function doDownload(downloadUrl: string, redirectCount = 0): void {
|
||||
if (redirectCount > 5) {
|
||||
reject(new Error('Too many redirects'));
|
||||
return;
|
||||
}
|
||||
electronLog.log('[KCC-Update] Downloading from:', downloadUrl);
|
||||
const req = httpsGet(downloadUrl, {
|
||||
headers: { 'User-Agent': 'KrunkerCivilianClient' },
|
||||
}, (res) => {
|
||||
// Follow redirects (with domain validation)
|
||||
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
const redirectUrl = res.headers.location;
|
||||
electronLog.log('[KCC-Update] Download redirected to:', redirectUrl);
|
||||
if (!isAllowedRedirect(redirectUrl)) {
|
||||
electronLog.error('[KCC-Update] Download redirect to untrusted host blocked:', redirectUrl);
|
||||
reject(new Error('Download redirect to untrusted host: ' + redirectUrl));
|
||||
return;
|
||||
}
|
||||
doDownload(redirectUrl, redirectCount + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.statusCode !== 200) {
|
||||
electronLog.error('[KCC-Update] Download returned status', res.statusCode, 'from:', downloadUrl);
|
||||
reject(new Error('Download returned status ' + res.statusCode));
|
||||
return;
|
||||
}
|
||||
|
||||
const total = parseInt(res.headers['content-length'] || '0', 10);
|
||||
let received = 0;
|
||||
|
||||
const file = createWriteStream(tmpPath);
|
||||
res.on('data', (chunk: Buffer) => {
|
||||
received += chunk.length;
|
||||
if (total > 0) {
|
||||
onProgress(Math.round(100 * received / total));
|
||||
}
|
||||
});
|
||||
res.pipe(file);
|
||||
|
||||
file.on('finish', () => {
|
||||
file.close(() => {
|
||||
try {
|
||||
if (existsSync(destPath)) unlinkSync(destPath);
|
||||
renameSync(tmpPath, destPath);
|
||||
resolve();
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
file.on('error', (err) => {
|
||||
try { unlinkSync(tmpPath); } catch { /* ignore */ }
|
||||
reject(err);
|
||||
});
|
||||
|
||||
res.on('error', (err) => {
|
||||
try { unlinkSync(tmpPath); } catch { /* ignore */ }
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
|
||||
req.setTimeout(DOWNLOAD_TIMEOUT_MS, () => {
|
||||
req.destroy();
|
||||
try { unlinkSync(tmpPath); } catch { /* ignore */ }
|
||||
reject(new Error('Download timed out'));
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
try { unlinkSync(tmpPath); } catch { /* ignore */ }
|
||||
reject(err);
|
||||
});
|
||||
}
|
||||
|
||||
doDownload(url);
|
||||
});
|
||||
}
|
||||
|
||||
export function installUpdate(installerPath: string): void {
|
||||
electronLog.log('[KCC-Update] Launching installer:', installerPath);
|
||||
const child = spawn(installerPath, [], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
child.unref();
|
||||
app.quit();
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { mkdirSync, promises as fsp } from 'fs';
|
||||
import { join, parse } from 'path';
|
||||
|
||||
export interface ScriptFile {
|
||||
filename: string;
|
||||
content: string;
|
||||
fullpath: string;
|
||||
}
|
||||
|
||||
export type ScriptTracker = Record<string, boolean>;
|
||||
|
||||
/**
|
||||
* Manages userscript files, tracker state, and per-script preferences.
|
||||
* Scripts live in a `scripts/` subdirectory; tracker.json records enabled/disabled state;
|
||||
* per-script preferences are stored in `scripts/preferences/<name>.json`.
|
||||
*/
|
||||
export class UserscriptManager {
|
||||
private scriptsDir: string;
|
||||
private prefsDir: string;
|
||||
private trackerPath: string;
|
||||
|
||||
constructor(baseDir: string) {
|
||||
this.scriptsDir = join(baseDir, 'scripts');
|
||||
this.prefsDir = join(this.scriptsDir, 'preferences');
|
||||
this.trackerPath = join(this.scriptsDir, 'tracker.json');
|
||||
mkdirSync(this.scriptsDir, { recursive: true });
|
||||
mkdirSync(this.prefsDir, { recursive: true });
|
||||
}
|
||||
|
||||
get dir(): string {
|
||||
return this.scriptsDir;
|
||||
}
|
||||
|
||||
/** Read all .js files from the scripts directory */
|
||||
async scanScripts(): Promise<ScriptFile[]> {
|
||||
const scripts: ScriptFile[] = [];
|
||||
try {
|
||||
for (const entry of await fsp.readdir(this.scriptsDir, { withFileTypes: true })) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.js')) continue;
|
||||
const fullpath = join(this.scriptsDir, entry.name);
|
||||
try {
|
||||
const content = await fsp.readFile(fullpath, 'utf-8');
|
||||
scripts.push({ filename: entry.name, content, fullpath });
|
||||
} catch { /* skip unreadable files */ }
|
||||
}
|
||||
} catch { /* directory read failed */ }
|
||||
return scripts;
|
||||
}
|
||||
|
||||
/** Load tracker.json, add new scripts as disabled, prune deleted scripts */
|
||||
async loadTracker(scripts: ScriptFile[]): Promise<ScriptTracker> {
|
||||
let tracker: ScriptTracker;
|
||||
try {
|
||||
tracker = JSON.parse(await fsp.readFile(this.trackerPath, 'utf-8'));
|
||||
} catch { tracker = {}; }
|
||||
|
||||
const filenames = new Set(scripts.map(s => s.filename));
|
||||
let dirty = false;
|
||||
|
||||
// Add new scripts as disabled
|
||||
for (const name of filenames) {
|
||||
if (!(name in tracker)) { tracker[name] = false; dirty = true; }
|
||||
}
|
||||
|
||||
// Prune deleted scripts
|
||||
for (const name of Object.keys(tracker)) {
|
||||
if (!filenames.has(name)) { delete tracker[name]; dirty = true; }
|
||||
}
|
||||
|
||||
if (dirty) await this.saveTracker(tracker);
|
||||
return tracker;
|
||||
}
|
||||
|
||||
/** Write tracker.json */
|
||||
async saveTracker(tracker: ScriptTracker): Promise<void> {
|
||||
try {
|
||||
await fsp.writeFile(this.trackerPath, JSON.stringify(tracker, null, 2), 'utf-8');
|
||||
} catch { /* write failed */ }
|
||||
}
|
||||
|
||||
/** Load per-script preferences from preferences/<name>.json */
|
||||
async loadScriptPrefs(filename: string): Promise<Record<string, unknown>> {
|
||||
const name = parse(filename).name;
|
||||
const prefsPath = join(this.prefsDir, name + '.json');
|
||||
try {
|
||||
return JSON.parse(await fsp.readFile(prefsPath, 'utf-8'));
|
||||
} catch { /* parse failed or file not found */ }
|
||||
return {};
|
||||
}
|
||||
|
||||
/** Save per-script preferences to preferences/<name>.json */
|
||||
async saveScriptPrefs(filename: string, prefs: Record<string, unknown>): Promise<void> {
|
||||
const name = parse(filename).name;
|
||||
const prefsPath = join(this.prefsDir, name + '.json');
|
||||
try {
|
||||
await fsp.writeFile(prefsPath, JSON.stringify(prefs, null, 2), 'utf-8');
|
||||
} catch { /* write failed */ }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user