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

This commit is contained in:
2026-04-03 15:33:20 -07:00
commit aeabddcf3a
41 changed files with 16061 additions and 0 deletions
+138
View File
@@ -0,0 +1,138 @@
name: Build and Release
on:
push:
branches:
- main
jobs:
build-and-release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Check if version already released
id: version-check
env:
GITEA_TOKEN: ${{ secrets.DEST_GITEA_TOKEN }}
run: |
VERSION=$(grep '"version"' package.json | head -1 | sed 's/.*"version": *"\([^"]*\)".*/\1/')
TAG="v$VERSION"
echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT"
echo "TAG=$TAG" >> "$GITHUB_OUTPUT"
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
"https://gitea.crjlab.net/api/v1/repos/bigjakk/krunker-civilian-client/releases/tags/$TAG" \
-H "Authorization: token $GITEA_TOKEN")
if [ "$STATUS" = "200" ]; then
echo "Release $TAG already exists, skipping build"
echo "SKIP=true" >> "$GITHUB_OUTPUT"
else
echo "No release for $TAG, proceeding with build"
echo "SKIP=false" >> "$GITHUB_OUTPUT"
fi
- name: Setup Node.js
if: steps.version-check.outputs.SKIP == 'false'
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install system dependencies
if: steps.version-check.outputs.SKIP == 'false'
run: |
dpkg --add-architecture i386
apt-get update -qq
apt-get install -y --no-install-recommends \
wine wine32 wine64 \
libnss3 libatk1.0-0 libatk-bridge2.0-0 libcups2t64 \
libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 \
libxfixes3 libxrandr2 libgbm1 libpango-1.0-0 \
libcairo2 libasound2t64 libgtk-3-0
WINEDEBUG=-all wine wineboot --init || true
- name: Install dependencies
if: steps.version-check.outputs.SKIP == 'false'
run: npm ci
- name: Build source
if: steps.version-check.outputs.SKIP == 'false'
run: npm run build
- name: Build Windows distributables
if: steps.version-check.outputs.SKIP == 'false'
env:
WINEDEBUG: "-all"
run: npx electron-builder --win -c.electronDist=node_modules/electron/dist-win --publish never
- name: Build Linux distributables
if: steps.version-check.outputs.SKIP == 'false'
run: npx electron-builder --linux --publish never
- name: Report build sizes
if: steps.version-check.outputs.SKIP == 'false'
run: |
echo "=== Build output sizes ==="
ls -lh out/*.exe out/*.AppImage out/*.deb 2>/dev/null || true
echo "=== Electron dist-win (patched Windows) ==="
du -sh node_modules/electron/dist-win/ 2>/dev/null || true
echo "=== Electron dist (stock Linux) ==="
du -sh node_modules/electron/dist/ 2>/dev/null || true
echo "=== Unpacked Windows build ==="
du -sh out/win-unpacked/ 2>/dev/null || true
du -sh out/win-unpacked/resources/ 2>/dev/null || true
du -sh out/win-unpacked/locales/ 2>/dev/null || true
- name: Create release and upload assets
if: steps.version-check.outputs.SKIP == 'false'
env:
GITEA_TOKEN: ${{ secrets.DEST_GITEA_TOKEN }}
TAG: ${{ steps.version-check.outputs.TAG }}
run: |
GITEA_BASE="https://gitea.crjlab.net"
REPO="bigjakk/krunker-civilian-client"
# Create tag
curl -s -X POST "$GITEA_BASE/api/v1/repos/$REPO/tags" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"$TAG\", \"message\": \"$TAG\", \"target\": \"$GITHUB_SHA\"}"
# Create release
RESPONSE=$(curl -s -X POST "$GITEA_BASE/api/v1/repos/$REPO/releases" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"tag_name\": \"$TAG\",
\"name\": \"$TAG\",
\"body\": \"Automated build for $TAG\",
\"draft\": false,
\"prerelease\": false
}")
RELEASE_ID=$(echo "$RESPONSE" | jq -r '.id')
echo "Created release ID: $RELEASE_ID"
if [ "$RELEASE_ID" = "null" ] || [ -z "$RELEASE_ID" ]; then
echo "Failed to create release:"
echo "$RESPONSE"
exit 1
fi
# Upload all built artifacts
for file in out/*.exe out/*.AppImage out/*.deb; do
[ -f "$file" ] || continue
FILENAME=$(basename "$file")
SAFE_NAME=$(echo "$FILENAME" | tr ' ' '_')
echo "Uploading: $SAFE_NAME ($(du -h "$file" | cut -f1))"
curl -s -X POST \
"$GITEA_BASE/api/v1/repos/$REPO/releases/$RELEASE_ID/assets?name=$SAFE_NAME" \
-H "Authorization: token $GITEA_TOKEN" \
-F "attachment=@$file" \
| jq -r '" -> \(.name) (\(.size) bytes)"'
done
echo "All assets uploaded"
+17
View File
@@ -0,0 +1,17 @@
node_modules/
dist/
out/
*.log
.env
.vscode/
.idea/
.claude/
*.swp
*.swo
nul
CLAUDE.md
Screenshot*
Trace-*
*.cpuprofile
*.heapprofile
userscripts/
+1
View File
@@ -0,0 +1 @@
npx lint-staged
+87
View File
@@ -0,0 +1,87 @@
# Krunker Civilian Client
> a high-performance krunker client with unlimited FPS, built on a custom-patched Electron
**Download:**
[Windows (x64)](https://gitea.crjlab.net/bigjakk/Krunker-Civilian-Client/releases/latest) -
[Linux (AppImage)](https://gitea.crjlab.net/bigjakk/Krunker-Civilian-Client/releases/latest)
## features
- unlimited FPS with no aim freeze (custom Electron build, see [below](#custom-electron-build))
- unobtrusive — all features can be disabled, no watermarks
- hides ads by default
- resource swapper (textures, sounds, models)
- CSS theme system (drop `.css` files in `swap/themes/`)
- custom loading screen backgrounds (`swap/backgrounds/`)
- customisable matchmaker with lobby scan animation
- filter by region, gamemode, map, player count, remaining time
- auto-join with server capacity verification
- tabbed hub/social pages with drag-and-drop reorder
- better chat — merged team/all chat with `[T]`/`[M]` prefixes
- chat history preservation (Krunker prunes old messages, this prevents it)
- real-time chat translator (Google Translate, 15+ languages)
- userscript support (Tampermonkey-style metadata, per-script settings)
- alt account manager with encrypted credential storage
- Discord RPC (gamemode, map, class, spectator status)
- raw input / unadjusted movement (Windows)
- show numeric ping in player list
- double ping display (Krunker shows half the real value)
- hardpoint enemy counter HUD
- cleaner menu mode (hides clutter)
- changelog popup on update
- configurable keybinds with visual rebinding dialog
- configurable ANGLE backend (D3D11, OpenGL, Vulkan, D3D9, D3D11on12)
- advanced Chromium flag settings (GPU rasterization, low latency, QUIC, and more)
- CPU throttling (game vs menu) and process priority control
- auto-updater
- maintained & open source
## hotkeys
All hotkeys are rebindable in settings.
| Key | Action |
|-----|--------|
| `F4` | New match (triggers matchmaker if enabled) |
| `F5` | Reload page |
| `F6` | Open matchmaker |
| `F10` | Pause chat (freeze auto-scroll) |
| `F11` | Toggle fullscreen |
| `F12` | DevTools |
| `Ctrl+L` | Copy game link |
| `Ctrl+J` | Join game from clipboard |
| `Ctrl+T` | New tab (hub) |
| `Ctrl+W` | Close tab |
| `Ctrl+Tab` | Next tab |
| `Ctrl+Shift+Tab` | Previous tab |
| `Ctrl+Shift+T` | Reopen closed tab |
| `Ctrl+1-9` | Jump to tab |
## userscripts
Any `.js` file in the scripts folder will be loaded as a userscript if enabled in settings. Scripts support Tampermonkey-style metadata blocks (`@name`, `@author`, `@version`, `@desc`) and can define custom settings (boolean, number, select, color, keybind).
> **Use userscripts at your own risk.** Do not write or use any userscripts which would give you a competitive advantage.
## custom Electron build
This client uses a custom-patched Electron 42 build to overcome the aim freezing issue present in modern Electron versions. The patched binary is downloaded automatically during `npm install`.
For details on the patch and build instructions, see [Electron-Websocket-Fix](https://github.com/bigjakk/Electron-Websocket-Fix).
## building from source
1. Install [git](https://git-scm.com/downloads), [Node.js](https://nodejs.org/), and npm
2. Clone and install:
```bash
git clone https://gitea.crjlab.net/bigjakk/Krunker-Civilian-Client.git
cd Krunker-Civilian-Client
npm install
```
3. Run: `npm start` or `npm run dev` (dev mode with sourcemaps)
4. Package: `npm run dist:win` or `npm run dist:linux`
## credits
- Built on ideas from [Crankshaft](https://github.com/KraXen72/crankshaft) by KraXen72
+17
View File
@@ -0,0 +1,17 @@
#!/bin/bash
# Generate platform-specific icons from icon.svg
# Requires: imagemagick (convert) or inkscape
# PNG for Linux (multiple sizes for best compatibility)
for size in 16 32 48 64 128 256 512; do
convert icon.svg -resize ${size}x${size} icon_${size}.png 2>/dev/null || \
inkscape icon.svg -w $size -h $size -o icon_${size}.png 2>/dev/null
done
# Copy 256px as the main Linux icon
cp icon_256.png icon.png
# ICO for Windows (multi-resolution)
convert icon_16.png icon_32.png icon_48.png icon_64.png icon_128.png icon_256.png icon.ico 2>/dev/null
echo "Icons generated. Place icon.png and icon.ico in the build/ directory."
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 534 KiB

+10
View File
@@ -0,0 +1,10 @@
[Desktop Entry]
Name=Krunker Civilian Client
Comment=Cross-platform Krunker game client
Exec=krunker-civilian-client %U
Icon=krunker-civilian-client
Type=Application
Categories=Game;ActionGame;
Keywords=krunker;fps;game;
StartupWMClass=krunker-civilian-client
MimeType=x-scheme-handler/krunker;
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 534 KiB

+193
View File
@@ -0,0 +1,193 @@
# Building Patched Electron 42 (Input Priority Fix)
This builds a custom Electron with a one-line Chromium patch that fixes input starvation ("aim freeze") when `--disable-frame-rate-limit` is active. Without this patch, uncapped frame rates cause 50-300ms input delays in GPU-intensive applications like browser FPS games.
## The Problem
Chromium's main thread scheduler gives input tasks `kHighestPriority`. At uncapped frame rates, the compositor floods the task queue and input events get starved — your mouse movements are delayed by up to 300ms, then snap to catch up. Chromium 87-93 had `ImplLatencyRecovery`/`MainLatencyRecovery` features that mitigated this, but they were removed in Chromium 94.
## The Fix
One line in `main_thread_scheduler_impl.cc` — demote input tasks from `kHighestPriority` to `kNormalPriority`, allowing the scheduler's anti-starvation logic to fairly interleave input and compositor work.
## Prerequisites
- **OS**: Windows 10/11 x64 (builds on Linux too, adjust paths accordingly)
- **Disk**: ~100 GB free (Chromium source + build artifacts)
- **RAM**: 16 GB minimum, 32 GB recommended
- **Visual Studio 2022** with "Desktop development with C++" workload and Windows 11 SDK
- **Git** and **Python 3.8+** on PATH
## Step 1: Install depot_tools
```powershell
cd C:\
git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git
# Add C:\depot_tools to the FRONT of your system PATH
# Then open a NEW terminal
```
Verify: `gclient --version` should print a version.
## Step 2: Check out Electron source
```powershell
mkdir C:\electron && cd C:\electron
# Create gclient config for Electron
gclient config --name "src/electron" --unmanaged https://github.com/nicedayzhu/electron.git@v42.0.0-nightly.20260227
```
> **Note**: Replace the repo URL with your fork if you've pushed the patch there. The `@v42.0.0-nightly.20260227` pins the exact nightly tag.
```powershell
# Sync all dependencies (~40-60 GB download, takes a while)
gclient sync --with_branch_heads --with_tags
```
## Step 3: Apply the patch
```powershell
cd C:\electron\src
# Apply the patch file
git apply --directory=. path\to\input-priority-fix.patch
```
Or make the edit manually — in `third_party/blink/renderer/platform/scheduler/main_thread/main_thread_scheduler_impl.cc`, find:
```cpp
case MainThreadTaskQueue::QueueTraits::PrioritisationType::kInput:
return TaskPriority::kHighestPriority;
```
Change `kHighestPriority` to `kNormalPriority`.
## Step 4: Configure the build
### Release build (optimized, for distribution):
```powershell
cd C:\electron\src
# Create build directory
gn gen out/Release
# Copy the release args
copy path\to\args.release.gn out\Release\args.gn
# Regenerate build files with the new args
gn gen out/Release
```
Contents of `args.release.gn`:
```gn
import("//electron/build/args/release.gn")
is_official_build = true
use_remoteexec = false
use_reclient = false
```
### Testing build (faster compile, for development):
```powershell
gn gen out/Testing
```
Write to `out/Testing/args.gn`:
```gn
import("//electron/build/args/testing.gn")
use_remoteexec = false
use_reclient = false
```
Then: `gn gen out/Testing`
## Step 5: Build
```powershell
cd C:\electron\src
# Release build (~2-4 hours depending on CPU)
ninja -C out/Release electron
# OR Testing build (~1-2 hours, less optimization)
ninja -C out/Testing electron
```
> **Tip**: Use `ninja -C out/Release electron -j N` to limit parallelism if you're running out of RAM (where N = number of parallel jobs, try RAM_GB / 2).
## Step 6: Create distributable zip
```powershell
cd C:\electron\src
# Generate the electron dist zip
python3 electron/script/zip_manifests/create-dist-zip.py out/Release
# Or use electron's strip-binaries + create-dist tooling:
ninja -C out/Release electron:dist_zip
```
The output zip will be at `out/Release/dist.zip` (or similar). This contains `electron.exe` and all required DLLs/resources.
## Step 7: Verify
Extract the zip and test with a minimal app:
```powershell
# Create a test directory
mkdir test-app
```
Create `test-app/package.json`:
```json
{ "name": "test", "version": "1.0.0", "main": "main.js" }
```
Create `test-app/main.js`:
```js
const { app, BrowserWindow } = require('electron');
app.commandLine.appendSwitch('disable-frame-rate-limit');
app.commandLine.appendSwitch('disable-gpu-vsync');
app.whenReady().then(() => {
const win = new BrowserWindow({ width: 1280, height: 720 });
win.loadURL('https://krunker.io');
win.webContents.on('did-finish-load', () => {
console.log('Electron:', process.versions.electron);
console.log('Chrome:', process.versions.chrome);
});
});
```
Run it:
```powershell
path\to\electron.exe test-app
```
If Krunker loads at uncapped FPS with no aim freeze, the build is good.
## Using the patched Electron in a project
To use this as the Electron binary in an npm project:
```powershell
# Set environment variable to point to your custom build
set ELECTRON_OVERRIDE_DIST_PATH=C:\path\to\extracted\electron-dist
# Then run your Electron app normally
npm start
```
Or replace the contents of `node_modules/electron/dist/` with the extracted zip contents.
## Build time estimates
| Build type | CPU | Approx. time |
|---|---|---|
| Testing | 8-core | ~1-2 hours |
| Testing | 16-core | ~30-60 min |
| Release | 8-core | ~3-5 hours |
| Release | 16-core | ~1.5-3 hours |
Release builds are significantly slower due to LTO (Link-Time Optimization) which does a whole-program optimization pass.
+8
View File
@@ -0,0 +1,8 @@
import("//electron/build/args/release.gn")
# Full optimization (LTO, minimal symbols, etc.)
is_official_build = true
# Not using Google's remote build infrastructure
use_remoteexec = false
use_reclient = false
+29
View File
@@ -0,0 +1,29 @@
From: Krunker Civilian Client <krunker@crjlab.net>
Subject: [PATCH] Fix input starvation when frame rate limit is disabled
Chromium's main thread scheduler assigns kHighestPriority to input tasks,
which starves the compositor when --disable-frame-rate-limit is active.
At uncapped frame rates (300+ FPS), the compositor floods the task queue
and input events get delayed 50-300ms, causing "aim freeze" in games.
Demoting input tasks to kNormalPriority allows the scheduler's built-in
anti-starvation logic to fairly interleave input and compositor work.
Benchmarked via CDP Input.dispatchMouseEvent:
- p99 latency: 97ms -> 34ms
- Max latency: 308ms -> 38ms
- Events >50ms: 8.6% -> 0%
- Frames rendered: +21%
- Mouse events processed: +9%
--- a/third_party/blink/renderer/platform/scheduler/main_thread/main_thread_scheduler_impl.cc
+++ b/third_party/blink/renderer/platform/scheduler/main_thread/main_thread_scheduler_impl.cc
@@ -2354,7 +2354,7 @@
case MainThreadTaskQueue::QueueTraits::PrioritisationType::kCompositor:
return main_thread_only().compositor_priority;
case MainThreadTaskQueue::QueueTraits::PrioritisationType::kInput:
- return TaskPriority::kHighestPriority;
+ return TaskPriority::kNormalPriority;
case MainThreadTaskQueue::QueueTraits::PrioritisationType::kBestEffort:
return TaskPriority::kBestEffortPriority;
case MainThreadTaskQueue::QueueTraits::PrioritisationType::kRegular:
+71
View File
@@ -0,0 +1,71 @@
appId: com.krunkercivilian.client
productName: Krunker Civilian Client
directories:
output: out
buildResources: build
files:
- dist/**/*
- node_modules/electron-store/**/*
- node_modules/conf/**/*
- node_modules/dot-prop/**/*
- node_modules/type-fest/**/*
- node_modules/pkg-up/**/*
- node_modules/find-up/**/*
- node_modules/locate-path/**/*
- node_modules/p-locate/**/*
- node_modules/p-limit/**/*
- node_modules/yocto-queue/**/*
- node_modules/path-exists/**/*
- node_modules/env-paths/**/*
- node_modules/json-schema-typed/**/*
- node_modules/ajv/**/*
- node_modules/ajv-formats/**/*
- node_modules/atomically/**/*
- node_modules/debounce-fn/**/*
- node_modules/mimic-fn/**/*
- node_modules/semver/**/*
- node_modules/onetime/**/*
- "!node_modules/**/*.ts"
- "!node_modules/**/*.map"
asar: true
win:
target:
- target: nsis
arch: [x64]
- target: portable
arch: [x64]
icon: build/icon.ico
nsis:
oneClick: false
allowToChangeInstallationDirectory: true
createDesktopShortcut: true
createStartMenuShortcut: true
shortcutName: Krunker Civilian Client
artifactName: "${productName}-${version}-Setup.${ext}"
portable:
artifactName: "${productName}-${version}-Portable.${ext}"
linux:
target:
- target: AppImage
arch: [x64]
- target: deb
arch: [x64]
icon: build/icon.png
category: Game
artifactName: "${productName}-${version}-linux-${arch}.${ext}"
desktop:
entry:
Name: Krunker Civilian Client
Comment: Cross-platform Krunker game client
Categories: Game;ActionGame;
Keywords: krunker;fps;game;
StartupWMClass: krunker-civilian-client
publish:
provider: github
owner: krunker-civilian
repo: krunker-civilian-client
+19
View File
@@ -0,0 +1,19 @@
import eslint from "@eslint/js";
import tseslint from "typescript-eslint";
export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.recommended,
{
ignores: ["dist/", "out/", "scripts/"],
},
{
rules: {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
},
}
);
+7759
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
{
"name": "krunker-civilian-client",
"version": "0.5.6",
"description": "Cross-platform Krunker game client",
"main": "dist/main/index.js",
"homepage": "https://gitea.crjlab.net/bigjakk/krunker-civilian-client",
"author": "Krunker Civilian Client <krunker@crjlab.net>",
"license": "MIT",
"scripts": {
"postinstall": "node scripts/download-electron.js",
"dev": "vite build --mode development --config vite.main.config.ts && vite build --mode development --config vite.preload.config.ts && electron .",
"build:main": "vite build --config vite.main.config.ts",
"build:preload": "vite build --config vite.preload.config.ts",
"build": "npm run build:main && npm run build:preload",
"start": "npm run build && electron .",
"download-electron": "node scripts/download-electron.js",
"dist:win": "npm run build && electron-builder --win",
"dist:linux": "npm run build && electron-builder --linux",
"dist:all": "npm run build && electron-builder --win --linux",
"clean": "rimraf dist out",
"lint": "eslint src/",
"prepare": "husky"
},
"lint-staged": {
"src/**/*.ts": "eslint --fix"
},
"dependencies": {
"electron-store": "^8.2.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^22.0.0",
"electron": "npm:electron-nightly@42.0.0-nightly.20260227",
"electron-builder": "^26.0.0",
"eslint": "^10.0.2",
"husky": "^9.1.7",
"lint-staged": "^16.3.1",
"rimraf": "^6.0.1",
"typescript": "^5.7.0",
"typescript-eslint": "^8.56.1",
"vite": "^6.0.0"
}
}
+208
View File
@@ -0,0 +1,208 @@
'use strict';
/**
* Downloads the patched Electron build and extracts it into node_modules/electron/dist/.
*
* The patched Electron fixes input starvation ("aim freeze") when --disable-frame-rate-limit
* is active on modern Chromium. Without this, uncapped FPS causes 50-300ms input delays.
*
* The zip is hosted as a release asset on the same Gitea repo. The script checks the
* local version file to skip re-downloading if already present.
*
* Usage:
* node scripts/download-electron.js # download if needed
* node scripts/download-electron.js --force # re-download even if present
*/
const https = require('https');
const http = require('http');
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
// ── Configuration ──────────────────────────────────────────────────────────
const ELECTRON_VERSION = '42.0.0-nightly.20260227';
const ASSET_NAME = 'electron-v42.0.0-nightly-patched-win32-x64.zip';
const GITEA_BASE = 'https://gitea.crjlab.net';
const REPO = 'bigjakk/Krunker-Civilian-Client';
// The release tag that holds the patched Electron zip.
// Upload the zip as an asset to this release on Gitea.
const RELEASE_TAG = 'electron-patched';
// On Windows, overwrite the npm-installed Electron with our patched build.
// On Linux/macOS (CI cross-compilation), extract to a separate dist-win/ directory
// so the npm-installed platform-native Electron stays in dist/ for bytenode compilation.
const IS_WIN = process.platform === 'win32';
const ELECTRON_DIST = IS_WIN
? path.resolve(__dirname, '..', 'node_modules', 'electron', 'dist')
: path.resolve(__dirname, '..', 'node_modules', 'electron', 'dist-win');
const VERSION_FILE = path.join(ELECTRON_DIST, 'version');
// Separate marker file to distinguish patched from stock electron-nightly.
// Both have the same version string, so VERSION_FILE alone is not sufficient.
const PATCHED_MARKER = path.join(ELECTRON_DIST, '.patched');
const TEMP_ZIP = path.join(ELECTRON_DIST, '..', '_electron-patched.zip');
// ── Helpers ────────────────────────────────────────────────────────────────
function get(url) {
const lib = url.startsWith('https') ? https : http;
return new Promise((resolve, reject) => {
lib.get(url, { headers: { 'User-Agent': 'KCC-Build' } }, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
get(res.headers.location).then(resolve, reject);
res.resume();
return;
}
if (res.statusCode !== 200) {
res.resume();
reject(new Error(`HTTP ${res.statusCode} for ${url}`));
return;
}
resolve(res);
}).on('error', reject);
});
}
function downloadToFile(url, dest) {
return new Promise(async (resolve, reject) => {
try {
const res = await get(url);
const total = parseInt(res.headers['content-length'] || '0', 10);
let downloaded = 0;
const file = fs.createWriteStream(dest);
res.on('data', (chunk) => {
downloaded += chunk.length;
if (total > 0) {
const pct = ((downloaded / total) * 100).toFixed(1);
const mb = (downloaded / 1048576).toFixed(1);
const totalMb = (total / 1048576).toFixed(1);
process.stdout.write(`\r Downloading: ${pct}% (${mb}/${totalMb} MB)`);
}
});
res.pipe(file);
file.on('finish', () => {
file.close();
process.stdout.write('\n');
resolve();
});
file.on('error', (err) => {
fs.unlinkSync(dest);
reject(err);
});
} catch (err) {
reject(err);
}
});
}
async function getAssetUrl() {
const apiUrl = `${GITEA_BASE}/api/v1/repos/${REPO}/releases/tags/${RELEASE_TAG}`;
const res = await get(apiUrl);
const body = await new Promise((resolve, reject) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => resolve(data));
res.on('error', reject);
});
const release = JSON.parse(body);
const asset = release.assets.find((a) => a.name === ASSET_NAME);
if (!asset) {
const names = release.assets.map((a) => a.name).join(', ');
throw new Error(
`Asset "${ASSET_NAME}" not found in release "${RELEASE_TAG}".\n` +
` Available assets: ${names || '(none)'}\n` +
` Upload the patched Electron zip to: ${GITEA_BASE}/${REPO}/releases/tag/${RELEASE_TAG}`
);
}
// Gitea API returns browser_download_url for direct download
return asset.browser_download_url;
}
function extractZip(zipPath, destDir) {
// Use PowerShell on Windows, unzip on Linux/macOS
if (process.platform === 'win32') {
execSync(
`powershell -NoProfile -Command "Expand-Archive -Force -Path '${zipPath}' -DestinationPath '${destDir}'"`,
{ stdio: 'inherit' }
);
} else {
execSync(`unzip -o "${zipPath}" -d "${destDir}"`, { stdio: 'inherit' });
}
}
// ── Main ───────────────────────────────────────────────────────────────────
async function main() {
const force = process.argv.includes('--force');
// Check if patched version is already installed.
// The .patched marker distinguishes our build from stock electron-nightly
// (both share the same version string).
if (!force && fs.existsSync(PATCHED_MARKER)) {
const installed = fs.readFileSync(PATCHED_MARKER, 'utf8').trim();
if (installed === ELECTRON_VERSION) {
console.log(` Patched Electron ${ELECTRON_VERSION} already installed, skipping`);
console.log(' (use --force to re-download)');
return;
}
console.log(` Installed: ${installed}, need: ${ELECTRON_VERSION}`);
}
// Resolve download URL from Gitea release
console.log(` Fetching release info for "${RELEASE_TAG}"...`);
const url = await getAssetUrl();
console.log(` Asset URL: ${url}`);
// Download
await downloadToFile(url, TEMP_ZIP);
const zipSize = (fs.statSync(TEMP_ZIP).size / 1048576).toFixed(1);
console.log(` Downloaded: ${zipSize} MB`);
// Clear existing target dir and extract
console.log(` Extracting to ${path.relative(path.resolve(__dirname, '..'), ELECTRON_DIST)}/...`);
if (fs.existsSync(ELECTRON_DIST)) {
fs.rmSync(ELECTRON_DIST, { recursive: true, force: true });
}
fs.mkdirSync(ELECTRON_DIST, { recursive: true });
extractZip(TEMP_ZIP, ELECTRON_DIST);
// Clean up temp zip
fs.unlinkSync(TEMP_ZIP);
// Write path.txt so the electron package's lazy downloader (index.js)
// considers the binary already installed and doesn't re-download stock.
// On non-Windows (CI cross-compilation), skip this so electron-nightly still
// downloads the native Linux binary into dist/ for the Linux build target.
if (IS_WIN) {
fs.writeFileSync(path.join(ELECTRON_DIST, '..', 'path.txt'), 'electron.exe');
}
// Write marker and verify
if (fs.existsSync(VERSION_FILE)) {
const ver = fs.readFileSync(VERSION_FILE, 'utf8').trim();
fs.writeFileSync(PATCHED_MARKER, ver);
console.log(` Installed patched Electron ${ver}`);
} else {
console.log(' Warning: version file not found after extraction');
}
}
console.log('[KCC] Setting up patched Electron...');
main().then(() => {
console.log('[KCC] Patched Electron ready.');
}).catch((err) => {
console.error('[KCC] Electron download failed:', err.message);
console.error('');
console.error(' If this is your first time building, you need the patched Electron zip');
console.error(` uploaded as a release asset on ${GITEA_BASE}/${REPO}`);
console.error('');
console.error(' 1. Go to: ' + GITEA_BASE + '/' + REPO + '/releases/new');
console.error(` 2. Create a release with tag: ${RELEASE_TAG}`);
console.error(` 3. Upload: ${ASSET_NAME}`);
console.error('');
console.error(' See electron-build/BUILD.md for how to build Electron from source.');
process.exit(1);
});
+683
View File
@@ -0,0 +1,683 @@
// ── Shared CSS theme variables (used by both main page and tab bar) ──
export const THEME_CSS = `
:root {
/* ── Surfaces ── */
--kpc-surface-card: rgba(255,255,255,0.04);
--kpc-surface-input: rgba(255,255,255,0.08);
--kpc-surface-hover: rgba(255,255,255,0.1);
--kpc-surface-hover-strong: rgba(255,255,255,0.15);
--kpc-surface-dialog: #1a1a1a;
--kpc-surface-raised: #212121;
/* ── Text ── */
--kpc-text-primary: rgba(255,255,255,0.9);
--kpc-text-secondary: rgba(255,255,255,0.7);
--kpc-text-muted: rgba(255,255,255,0.5);
--kpc-text-faint: rgba(255,255,255,0.35);
--kpc-text-dim: rgba(255,255,255,0.3);
--kpc-text-info: #888;
/* ── Borders ── */
--kpc-border-subtle: rgba(255,255,255,0.06);
--kpc-border-default: rgba(255,255,255,0.1);
--kpc-border-medium: rgba(255,255,255,0.15);
--kpc-border-focus: rgba(255,255,255,0.35);
/* ── Accents ── */
--kpc-green: #4CAF50;
--kpc-green-hover: #66bb6a;
--kpc-red: #ef5350;
--kpc-red-hover: #e57373;
--kpc-blue: #42a5f5;
--kpc-blue-hover: #64b5f6;
--kpc-orange: #ff9800;
--kpc-orange-hover: #ffb74d;
--kpc-yellow: #ffc107;
--kpc-magenta: #fc03ec;
/* ── Controls ── */
--kpc-toggle-off: rgba(255,255,255,0.12);
/* ── Z-index layers ── */
--kpc-z-notification: 100000;
--kpc-z-overlay: 10000000;
--kpc-z-popup: 10000001;
}
`;
// ── Injected CSS for client settings in Krunker's settings panel ──
export const CLIENT_SETTINGS_CSS = `
${THEME_CSS}
/* ── Crankshaft-style settings (Krunker-native classes) ── */
.kpc-settings .settName,
.kpc-settings .settName .setting-title {
color: rgba(255,255,255,.6) !important;
}
.kpc-settings .settName {
display: grid;
grid-auto-columns: 1fr;
grid-template-columns: 0fr 1fr 0fr;
grid-template-areas:
"icon title input"
"desc desc desc";
grid-template-rows: 0fr min-content;
align-items: center;
}
.kpc-settings .settName.multisel {
grid-template-rows: min-content 1fr;
grid-template-columns: 0fr 1fr;
grid-template-areas:
"icon title"
"input input";
}
.kpc-settings .settName.has-button {
grid-template-areas:
"icon title button input"
"desc desc desc desc";
grid-template-columns: 0fr 1fr min-content 0fr;
}
.kpc-settings .settName.has-button .settingsBtn {
grid-area: button;
margin: 0 .5rem;
}
.kpc-settings .settName.kpc-button-holder {
grid-template-columns: 1fr;
grid-auto-columns: min-content;
column-gap: 0.25rem;
grid-template-areas: unset;
grid-template-rows: 0fr;
grid-auto-flow: column;
}
.kpc-settings .kpc-button-holder .buttons-title, .material-icons { color: inherit; }
.kpc-settings .kpc-button-holder .settingsBtn,
.kpc-settings .settName.has-button .settingsBtn {
width: max-content;
}
/* type: num */
.kpc-settings .settName.num .setting-input-wrapper {
display: flex;
}
.kpc-settings .settName.num .setting-input-wrapper .slidecontainer {
margin-top: -8px;
}
/* type: multisel */
.kpc-multisel-parent {
display: grid;
grid-template-columns: repeat(5, 1fr);
grid-auto-rows: 1fr;
gap: .25rem;
background: #232323;
border-radius: 10px;
margin-top: 0.8rem;
}
.kpc-multisel-parent label.hostOpt {
width: 100%;
margin: 0;
box-sizing: border-box;
}
.kpc-settings .settName.multisel label {
font-size: 1.1rem;
}
.kpc-settings .settName.multisel input {
margin-left: .25rem;
}
/* general settings */
.kpc-settings .settName .setting-title {
grid-area: title;
}
.kpc-settings .settName .s-update:disabled,
.kpc-settings .settName .s-update:disabled+.slider.round {
opacity: 0.5;
pointer-events: none;
}
.kpc-settings .setting .switch {
box-sizing: border-box;
}
.kpc-settings .setting .desc-icon {
grid-area: icon;
cursor: pointer;
font-size: 1rem;
width: 2.2rem;
height: 2.2rem;
line-height: 2.2rem;
border-radius: 5px !important;
color: #969696;
background-color: rgba(99, 99, 99, 0.16);
border: 2px solid rgba(78, 78, 78, 0.81);
margin-right: 10px;
display: flex;
justify-content: center;
align-items: center;
}
.kpc-settings .setting .desc-icon.instant {
background-color: rgba(1, 89, 220, 0.16);
border: 2px solid rgba(3, 133, 255, 0.81);
}
.kpc-settings .setting .desc-icon.instant svg path {
color: #0385ff;
fill: currentColor;
}
.kpc-settings .setting.settName .inputGrey2,
.kpc-settings .setting.settName .switch,
.kpc-settings .setting.settName .kpc-multisel-parent,
.kpc-settings .setting.settName .setting-input-wrapper,
.kpc-settings .setting.settName .keyIcon {
grid-area: input;
}
.kpc-settings .setting.safety-1 .desc-icon,
.kpc-settings .setting .desc-icon.refresh-icon,
.kpc-settings .setting .desc-icon.restart-icon {
background-color: rgba(99, 99, 99, 0.16);
border: 2px solid rgba(78, 78, 78, 0.81);
}
.kpc-settings .setting.safety-1 .desc-icon svg path,
.kpc-settings .setting .desc-icon.refresh-icon svg path,
.kpc-settings .setting .desc-icon.restart-icon svg path {
color: #969696;
fill: currentColor;
}
.kpc-settings .setting.safety-2 .desc-icon {
background-color: rgba(220, 180, 1, 0.16);
border: 2px solid rgba(241, 186, 6, 0.81);
}
.kpc-settings .setting.safety-2 .desc-icon svg path {
color: #ffd903;
fill: currentColor;
}
.kpc-settings .setting.safety-3 .desc-icon {
background-color: rgba(220, 118, 1, 0.16);
border: 2px solid rgba(241, 131, 6, 0.81);
}
.kpc-settings .setting.safety-3 .desc-icon svg path {
color: #ff9203;
fill: currentColor;
}
.kpc-settings .setting.safety-4 .desc-icon {
background-color: rgba(220, 17, 1, 0.16);
border: 2px solid rgba(239, 6, 6, 0.81);
}
.kpc-settings .setting.safety-4 .desc-icon svg path {
color: #ff0303;
fill: currentColor;
}
.desc-icon {
position: relative;
}
.setting-desc-new {
display: block;
width: fit-content;
max-width: 50ch;
line-height: 30px;
font-size: 15px;
letter-spacing: 0.5px;
word-wrap: break-word;
color: rgba(255, 255, 255, 0.4) !important;
overflow: hidden;
max-height: 500px;
margin-top: 6px;
grid-area: desc;
}
.setting-desc-new a {
font-size: inherit !important;
font-family: inherit !important;
}
.setting-category-collapsed {
display: none;
}
/* keybind display */
.keyIcon.kpc-keyIcon:hover {
transform: scale(1.25);
cursor: pointer;
}
.keyIcon.kpc-keyIcon {
display: inline-block;
transition: 0s;
}
/* ── KPC action button grid ── */
.kpc-action-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
padding: 0 12px 12px;
}
.kpc-action-btn {
background: var(--kpc-surface-card);
color: var(--kpc-text-primary);
border: 2px solid var(--kpc-border-medium);
padding: 10px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
text-align: center;
transition: background 0.15s, border-color 0.15s;
user-select: none;
}
.kpc-action-btn:hover {
background: var(--kpc-surface-hover);
border-color: var(--kpc-border-focus);
}
.kpc-action-btn:active {
transform: scale(0.97);
}
.kpc-action-btn.full {
grid-column: 1 / -1;
}
.kpc-action-btn.kpc-ab-purple { border-color: #ab47bc; }
.kpc-action-btn.kpc-ab-purple:hover { border-color: #ce93d8; }
.kpc-action-btn.kpc-ab-cyan { border-color: #00bcd4; }
.kpc-action-btn.kpc-ab-cyan:hover { border-color: #4dd0e1; }
.kpc-action-btn.kpc-ab-pink { border-color: #ec407a; }
.kpc-action-btn.kpc-ab-pink:hover { border-color: #f48fb1; }
.kpc-action-btn.kpc-ab-red { border-color: var(--kpc-red); }
.kpc-action-btn.kpc-ab-red:hover { border-color: var(--kpc-red-hover); }
.kpc-action-btn.kpc-ab-orange { border-color: var(--kpc-orange); }
.kpc-action-btn.kpc-ab-orange:hover { border-color: var(--kpc-orange-hover); }
/* floating toasts css that is required */
.kpc-holder-update {
position: absolute;
font-size: 1.125rem !important;
color: rgba(255, 255, 255, 0.7);
display: block !important;
top: 20px;
left: 20px;
background-color: black;
padding: 1rem;
border-radius: 0.5rem;
width: max-content;
z-index: 10;
}
/* settings refresh popup */
.refresh-popup {
height: min-content;
left: 50%;
transform: translateX(-50%);
color: rgba(255,255,255,0.6)
}
.refresh-popup span {
display: flex;
align-items: center;
column-gap: 0.5rem;
color: rgba(255,255,255,0.6);
}
.refresh-popup,
.refresh-popup span,
.refresh-popup a {
vertical-align: middle;
font-size: .8rem;
line-height: .8rem;
z-index: 12;
}
.refresh-popup svg { fill: rgba(255,255,255,0.6); }
.refresh-popup code {
color: white;
font-size: 1.2rem;
line-height: 1.2rem;
font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace;
background-color: #232323;
padding: 0.08rem 0.4rem;
border-radius: 3px;
border: 2px solid #333333
}
/* ── Keybind capture dialog ── */
.kpc-keybind-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: var(--kpc-z-overlay);
background: rgba(0,0,0,0.7);
display: flex;
align-items: center;
justify-content: center;
}
.kpc-keybind-dialog {
background: var(--kpc-surface-dialog);
border: 1px solid var(--kpc-border-medium);
border-radius: 10px;
padding: 24px 32px;
min-width: 400px;
position: relative;
}
.kpc-keybind-dialog-title {
color: var(--kpc-text-primary);
font-size: 18px;
margin-bottom: 6px;
}
.kpc-keybind-dialog-sub {
color: var(--kpc-text-muted);
font-size: 13px;
margin-bottom: 16px;
}
.kpc-keybind-dialog-sub code {
color: #64b5f6;
}
.kpc-keybind-dialog-modifiers {
display: flex;
gap: 8px;
font-size: 14px;
}
.kpc-keybind-modifier {
background: var(--kpc-surface-raised);
color: var(--kpc-text-faint);
flex: 1;
text-align: center;
padding: 10px 0;
border-radius: 6px;
transition: background 0.15s, color 0.15s;
}
.kpc-keybind-modifier.active {
background: #1976d2;
color: #fff;
}
.kpc-keybind-dialog-cancel {
position: absolute;
top: 12px;
right: 16px;
color: #64b5f6;
cursor: pointer;
font-size: 14px;
}
.kpc-keybind-dialog-cancel:hover {
text-decoration: underline;
}
/* ── Preserved: color input, userscript meta ── */
.kpc-color-input {
width: 36px;
height: 28px;
border: 1px solid var(--kpc-border-default);
border-radius: 4px;
background: transparent;
cursor: pointer;
padding: 0;
flex-shrink: 0;
}
.kpc-color-input::-webkit-color-swatch-wrapper {
padding: 2px;
}
.kpc-color-input::-webkit-color-swatch {
border: none;
border-radius: 2px;
}
.kpc-us-meta {
color: var(--kpc-text-dim);
font-size: 11px;
margin-top: 2px;
}
.kpc-us-settings {
padding: 4px 0 4px 20px;
}
#chatList, #chatList * {
user-select: text !important;
cursor: text;
}
#chatList.kpc-chat-paused {
border-left: 2px solid var(--kpc-yellow);
}
`;
// ── Matchmaker popup CSS + settings extras (injected separately) ──
export const MATCHMAKER_SETTINGS_CSS = `
@keyframes matchmakerPopupSlideDown {
0% { transform: translate(-50%, -500%); }
100% { transform: translate(-50%, 0%); }
}
.onGame #matchmakerPopupContainer:not(.searching) {
opacity: 0 !important;
}
#matchmakerPopupContainer {
position: absolute;
top: 10em;
left: 50%;
z-index: var(--kpc-z-popup);
box-sizing: border-box;
width: 35em;
aspect-ratio: 2.5/1;
border-radius: 1.2em;
overflow: hidden;
background-size: 100% 100%;
pointer-events: all;
background-color: var(--kpc-surface-raised);
animation: matchmakerPopupSlideDown 0.5s ease forwards;
}
#matchmakerPopupTitle {
font-size: 1.8em;
color: white;
padding: 0.3em 0.7em;
background: rgba(0,0,0,0.5);
margin-bottom: 0.3em;
}
#matchmakerPopupDescription {
background: rgba(0,0,0,0.5);
color: var(--kpc-yellow);
box-sizing: border-box;
padding: 0.6em 1em;
}
#matchmakerPopupOptions {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
display: flex;
}
.matchmakerPopupButton {
text-align: center;
border: 0.3em solid;
box-sizing: border-box;
margin: 0.5em;
color: white;
border-radius: 0.3em;
font-size: 1.3em;
background-color: rgba(0,0,0,0.5);
padding: 0.2em 1.4em;
transition: all 0.08s;
}
#matchmakerConfirmButton {
border-color: var(--kpc-green);
flex-grow: 1;
}
#matchmakerCancelButton {
border-color: var(--kpc-red);
}
.matchmakerPopupButton:hover {
cursor: pointer;
border-color: white !important;
transform: scale(0.95);
}
.matchmakerPopupButton:active {
transform: scale(0.85);
}
/* ── Search phase ── */
#matchmakerPopupContainer.searching {
background-image: none !important;
background: var(--kpc-surface-raised);
width: 24em;
aspect-ratio: auto;
padding: 1em 1.5em;
}
#matchmakerPopupContainer.searching #matchmakerPopupTitle,
#matchmakerPopupContainer.searching #matchmakerPopupDescription,
#matchmakerPopupContainer.searching #matchmakerPopupOptions {
display: none;
}
#matchmakerPopupContainer:not(.searching) #matchmakerSearchContainer {
display: none;
}
#matchmakerSearchStatus {
font-size: 1.4em;
color: var(--kpc-blue);
margin-bottom: 0.6em;
text-align: center;
}
#matchmakerSearchFeed {
display: flex;
flex-direction: column;
gap: 0.15em;
overflow: hidden;
min-height: 5.6em;
margin-bottom: 0.6em;
}
@keyframes mmFeedSlideIn {
from { opacity: 0; transform: translateX(1em); }
to { opacity: 1; transform: translateX(0); }
}
.mm-feed-entry {
display: flex;
gap: 0.8em;
padding: 0.2em 0.5em;
font-size: 0.95em;
font-family: 'GameFont', monospace;
border-radius: 0.2em;
animation: mmFeedSlideIn 0.12s ease forwards;
}
.mm-feed-entry.mm-pass { background: rgba(76,175,80,0.1); }
.mm-feed-entry.mm-pass .mm-feed-region { color: var(--kpc-blue); }
.mm-feed-entry.mm-pass .mm-feed-map { color: var(--kpc-text-primary, rgba(255,255,255,0.9)); }
.mm-feed-entry.mm-pass .mm-feed-players { color: var(--kpc-green); }
.mm-feed-entry.mm-fail { background: rgba(255,255,255,0.02); }
.mm-feed-entry.mm-fail .mm-feed-region { color: var(--kpc-text-dim, rgba(255,255,255,0.3)); }
.mm-feed-entry.mm-fail .mm-feed-map { color: var(--kpc-text-muted, rgba(255,255,255,0.5)); }
.mm-feed-entry.mm-fail .mm-feed-players { color: var(--kpc-red); }
.mm-feed-entry:last-child::before {
content: '\\25B8 ';
color: var(--kpc-yellow);
}
.mm-feed-region { min-width: 2.5em; font-weight: bold; }
.mm-feed-map { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mm-feed-players { min-width: 3em; text-align: right; font-weight: 600; }
#matchmakerSearchCounter {
font-size: 0.85em;
color: var(--kpc-yellow);
text-align: center;
margin-bottom: 0.5em;
}
#matchmakerSearchCancel {
text-align: center;
border: 0.2em solid var(--kpc-red);
color: white;
border-radius: 0.3em;
font-size: 1.1em;
background: rgba(0,0,0,0.3);
padding: 0.2em 1.2em;
cursor: pointer;
margin: 0 auto;
width: fit-content;
transition: all 0.08s;
}
#matchmakerSearchCancel:hover {
border-color: white;
transform: scale(0.95);
}
#matchmakerSearchCancel:active {
transform: scale(0.85);
}
`;
export const TRANSLATOR_CSS = `
.kcc-translation {
color: #88ff88;
font-style: italic;
margin-left: 8px;
margin-top: 2px;
}
`;
// ── Alt Manager CSS ──
export const ALT_MANAGER_CSS = `
.kpc-acc-form { display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px; }
.kpc-acc-form input {
background: var(--kpc-surface-input); border: 1px solid var(--kpc-border); border-radius: 4px;
color: #fff; padding: 6px 10px; font-size: 13px; outline: none; font-family: inherit;
}
.kpc-acc-form input:focus { border-color: var(--kpc-accent); }
.kpc-acc-form input::placeholder { color: rgba(255,255,255,0.3); }
.kpc-acc-form-buttons { display: flex; gap: 8px; }
.kpc-acc-form-buttons button {
padding: 6px 16px; border: none; border-radius: 4px; cursor: pointer;
font-size: 13px; font-family: inherit;
}
.kpc-acc-form-buttons .kpc-acc-save {
background: var(--kpc-accent); color: #fff;
}
.kpc-acc-form-buttons .kpc-acc-save:hover { filter: brightness(1.2); }
.kpc-acc-form-buttons .kpc-acc-cancel {
background: var(--kpc-surface-hover); color: #fff;
}
.kpc-acc-form-buttons .kpc-acc-cancel:hover { background: var(--kpc-surface-hover-strong); }
.kpc-acc-item {
display: flex; align-items: center; justify-content: space-between;
padding: 8px 12px; background: var(--kpc-surface-card); border-radius: 6px; margin-bottom: 6px;
}
.kpc-acc-item-info { display: flex; align-items: center; gap: 8px; }
.kpc-acc-item-label { color: #fff; font-size: 14px; font-weight: 500; }
.kpc-acc-item-role {
font-size: 11px; padding: 2px 6px; border-radius: 3px;
background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.6);
}
.kpc-acc-item-actions { display: flex; gap: 6px; }
.kpc-acc-item-actions button {
padding: 4px 12px; border: none; border-radius: 4px; cursor: pointer;
font-size: 12px; font-family: inherit;
}
.kpc-acc-switch { background: var(--kpc-accent); color: #fff; }
.kpc-acc-switch:hover { filter: brightness(1.2); }
.kpc-acc-delete { background: rgba(255,80,80,0.2); color: #ff5050; }
.kpc-acc-delete:hover { background: rgba(255,80,80,0.35); }
.kpc-acc-empty { color: rgba(255,255,255,0.4); font-size: 13px; text-align: center; padding: 16px 0; }
.kpc-alt-overlay-backdrop {
position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: 99998;
background: rgba(0,0,0,0.5);
}
.kpc-alt-overlay {
position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
background: var(--kpc-surface-dialog, #1a1a1a); border-radius: 8px;
padding: 16px; min-width: 280px; max-width: 360px; z-index: 99999;
box-shadow: 0 8px 32px rgba(0,0,0,0.6);
}
.kpc-alt-overlay h3 {
margin: 0 0 12px; color: #fff; font-size: 16px; font-weight: 600;
}
`;
// ── HP enemy counter CSS ──
export const HP_COUNTER_CSS = `
.kpc-hp-counter .pointVal {
color: #ff4444; font-size: 15px; font-weight: bold;
}
`;
/** Pre-concatenated CSS for single-call injection (excludes HIDE_ADS_CSS which is separate) */
export const ALL_CLIENT_CSS = `${CLIENT_SETTINGS_CSS}\n${MATCHMAKER_SETTINGS_CSS}\n${TRANSLATOR_CSS}\n${ALT_MANAGER_CSS}\n${HP_COUNTER_CSS}`;
+228
View File
@@ -0,0 +1,228 @@
import Store from 'electron-store';
import { detectPlatform } from './platform';
export interface Keybind {
key: string;
ctrl: boolean;
shift: boolean;
alt: boolean;
}
export interface SavedAccount {
label: string;
username: string;
password: string;
}
export interface AppConfig {
window: {
width: number;
height: number;
x: number | undefined;
y: number | undefined;
maximized: boolean;
fullscreen: boolean;
};
performance: {
fpsUnlocked: boolean;
hardwareAccel: boolean;
gpuPreference: 'high-performance' | 'low-power' | 'default';
cpuThrottleGame: number;
cpuThrottleMenu: number;
processPriority: string;
};
game: {
lastServer: string;
socialTabBehaviour: 'New Window' | 'Same Window';
joinAsSpectator: boolean;
rawInput: boolean;
betterChat: boolean;
chatHistorySize: number;
showPing: boolean;
hpEnemyCounter: boolean;
};
swapper: {
enabled: boolean;
path: string;
};
matchmaker: {
enabled: boolean;
regions: string[];
gamemodes: string[];
maps: string[];
minPlayers: number;
maxPlayers: number;
minRemainingTime: number;
openServerBrowser: boolean;
autoJoin: boolean;
};
keybinds: {
reload: Keybind;
newMatch: Keybind;
copyGameLink: Keybind;
joinFromClipboard: Keybind;
devTools: Keybind;
matchmaker: Keybind;
matchmakerAccept: Keybind;
matchmakerCancel: Keybind;
pauseChat: Keybind;
fullscreenToggle: Keybind;
};
userscripts: {
enabled: boolean;
path: string;
};
ui: {
showExitButton: boolean;
deathscreenAnimation: boolean;
hideMenuPopups: boolean;
cleanerMenu: boolean;
doublePing: boolean;
cssTheme: string;
loadingTheme: string;
backgroundUrl: string;
showChangelog: boolean;
lastSeenVersion: string;
};
discord: {
enabled: boolean;
};
translator: {
enabled: boolean;
targetLanguage: string;
showLanguageTag: boolean;
};
advanced: {
removeUselessFeatures: boolean;
gpuRasterizing: boolean;
helpfulFlags: boolean;
disableAccelerated2D: boolean;
increaseLimits: boolean;
lowLatency: boolean;
experimentalFlags: boolean;
angleBackend: string;
verboseLogging: boolean;
};
accounts: SavedAccount[];
tabWindow: {
width: number;
height: number;
x: number | undefined;
y: number | undefined;
maximized: boolean;
};
platform: {
detectedOS: string;
gpuBackend: string;
};
}
export const DEFAULT_KEYBINDS: AppConfig['keybinds'] = {
reload: { key: 'F5', ctrl: false, shift: false, alt: false },
newMatch: { key: 'F4', ctrl: false, shift: false, alt: false },
copyGameLink: { key: 'l', ctrl: true, shift: false, alt: false },
joinFromClipboard: { key: 'j', ctrl: true, shift: false, alt: false },
devTools: { key: 'F12', ctrl: false, shift: false, alt: false },
matchmaker: { key: 'F6', ctrl: false, shift: false, alt: false },
matchmakerAccept: { key: 'Enter', ctrl: false, shift: false, alt: false },
matchmakerCancel: { key: 'Escape', ctrl: false, shift: false, alt: false },
pauseChat: { key: 'F10', ctrl: false, shift: false, alt: false },
fullscreenToggle: { key: 'F11', ctrl: false, shift: false, alt: false },
};
const platformInfo = detectPlatform();
export const config = new Store<AppConfig>({
name: 'krunker-civilian-config',
defaults: {
window: {
width: 1600,
height: 900,
x: undefined,
y: undefined,
maximized: false,
fullscreen: false,
},
performance: {
fpsUnlocked: true,
hardwareAccel: true,
gpuPreference: 'high-performance',
cpuThrottleGame: 1,
cpuThrottleMenu: 1.5,
processPriority: 'Normal',
},
game: {
lastServer: '',
socialTabBehaviour: 'New Window',
joinAsSpectator: false,
rawInput: true,
betterChat: true,
chatHistorySize: 200,
showPing: true,
hpEnemyCounter: true,
},
swapper: {
enabled: true,
path: '',
},
matchmaker: {
enabled: true,
regions: [],
gamemodes: [],
maps: [],
minPlayers: 1,
maxPlayers: 6,
minRemainingTime: 120,
openServerBrowser: true,
autoJoin: false,
},
keybinds: DEFAULT_KEYBINDS,
userscripts: {
enabled: true,
path: '',
},
ui: {
showExitButton: true,
deathscreenAnimation: true,
hideMenuPopups: false,
cleanerMenu: false,
doublePing: true,
cssTheme: 'disabled',
loadingTheme: 'disabled',
backgroundUrl: '',
showChangelog: true,
lastSeenVersion: '',
},
discord: {
enabled: false,
},
translator: {
enabled: true,
targetLanguage: 'en',
showLanguageTag: true,
},
advanced: {
removeUselessFeatures: true,
gpuRasterizing: false,
helpfulFlags: true,
disableAccelerated2D: false,
increaseLimits: false,
lowLatency: false,
experimentalFlags: false,
angleBackend: 'default',
verboseLogging: false,
},
accounts: [],
tabWindow: {
width: 1280,
height: 720,
x: undefined,
y: undefined,
maximized: true,
},
platform: {
detectedOS: platformInfo.os,
gpuBackend: platformInfo.gpuBackend,
},
},
});
+131
View File
@@ -0,0 +1,131 @@
// ── CSS theme & loading screen background management ──
// Scans swap directory for user CSS themes and loading screen backgrounds.
import { readdirSync, readFileSync } from 'fs';
import { join, extname, basename } from 'path';
export interface ThemeEntry {
id: string;
label: string;
}
export interface LoadingThemeEntry {
id: string;
label: string;
}
export function listThemes(swapDir: string): ThemeEntry[] {
const entries: ThemeEntry[] = [{ id: 'disabled', label: 'Disabled' }];
const themesDir = join(swapDir, 'themes');
try {
const files = readdirSync(themesDir);
for (const file of files) {
if (extname(file).toLowerCase() === '.css') {
entries.push({ id: `user:${file}`, label: basename(file, '.css') });
}
}
} catch { /* themes dir doesn't exist yet — that's fine */ }
return entries;
}
export function getThemeCSS(themeId: string, swapDir: string): string {
if (themeId === 'disabled' || !themeId) return '';
const prefix = 'user:';
if (!themeId.startsWith(prefix)) return '';
const filename = themeId.slice(prefix.length);
try {
return readFileSync(join(swapDir, 'themes', filename), 'utf-8');
} catch { return ''; }
}
const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp']);
export function listLoadingThemes(swapDir: string): LoadingThemeEntry[] {
const entries: LoadingThemeEntry[] = [
{ id: 'disabled', label: 'Disabled (Default)' },
{ id: 'swap:random', label: 'Random (from backgrounds/)' },
];
const bgDir = join(swapDir, 'backgrounds');
try {
const files = readdirSync(bgDir);
for (const file of files) {
if (IMAGE_EXTS.has(extname(file).toLowerCase())) {
entries.push({ id: `swap:${file}`, label: file });
}
}
} catch { /* backgrounds dir doesn't exist yet */ }
return entries;
}
function mimeFromExt(ext: string): string {
switch (ext.toLowerCase()) {
case '.jpg':
case '.jpeg':
return 'image/jpeg';
case '.gif':
return 'image/gif';
case '.webp':
return 'image/webp';
default:
return 'image/png';
}
}
function getBackgroundFiles(swapDir: string): string[] {
const bgDir = join(swapDir, 'backgrounds');
try {
return readdirSync(bgDir).filter(f => IMAGE_EXTS.has(extname(f).toLowerCase()));
} catch { return []; }
}
function fileToDataUri(filePath: string): string {
const data = readFileSync(filePath);
const mime = mimeFromExt(extname(filePath));
return `data:${mime};base64,${data.toString('base64')}`;
}
export function getLoadingScreenCSS(loadingTheme: string, backgroundUrl: string, swapDir: string): string {
let imageUrl = '';
// Explicit URL takes priority
if (backgroundUrl) {
try {
new URL(backgroundUrl);
imageUrl = `url(${backgroundUrl})`;
} catch { /* invalid URL — ignore */ }
}
if (!imageUrl && loadingTheme && loadingTheme !== 'disabled') {
const bgDir = join(swapDir, 'backgrounds');
if (loadingTheme === 'swap:random') {
const files = getBackgroundFiles(swapDir);
if (files.length > 0) {
const pick = files[Math.floor(Math.random() * files.length)];
try {
imageUrl = `url(${fileToDataUri(join(bgDir, pick))})`;
} catch { /* read failed */ }
}
} else if (loadingTheme.startsWith('swap:')) {
const filename = loadingTheme.slice(5);
try {
imageUrl = `url(${fileToDataUri(join(bgDir, filename))})`;
} catch { /* read failed */ }
}
}
if (!imageUrl) return '';
return `
#instructionHolder[style^="display: block"] {
background-image: initial !important;
}
#instructionHolder {
background-image: ${imageUrl} !important;
background-size: cover !important;
background-position: center !important;
}
#instructions {
display: block;
visibility: hidden;
}`;
}
+285
View File
@@ -0,0 +1,285 @@
import { Socket } from 'net';
import { electronLog } from './logger';
const DISCORD_CLIENT_ID = '1477679025248800982';
// Discord IPC opcodes
const OP_HANDSHAKE = 0;
const OP_FRAME = 1;
const OP_CLOSE = 2;
// Rate limit: Discord rejects updates faster than 15s
const RATE_LIMIT_MS = 5000;
const RECONNECT_INTERVAL_MS = 30000;
export interface ActivityPayload {
details?: string;
state?: string;
startTimestamp?: number;
largeImageKey?: string;
largeImageText?: string;
}
function getPipePath(id: number): string {
if (process.platform === 'win32') {
return `\\\\?\\pipe\\discord-ipc-${id}`;
}
// Linux/macOS: check XDG_RUNTIME_DIR, TMPDIR, TMP, TEMP, /tmp
const dir = process.env.XDG_RUNTIME_DIR
|| process.env.TMPDIR
|| process.env.TMP
|| process.env.TEMP
|| '/tmp';
return `${dir}/discord-ipc-${id}`;
}
function encodeFrame(opcode: number, payload: object): Buffer {
const json = JSON.stringify(payload);
const jsonBuf = Buffer.from(json);
const header = Buffer.alloc(8);
header.writeUInt32LE(opcode, 0);
header.writeUInt32LE(jsonBuf.length, 4);
return Buffer.concat([header, jsonBuf]);
}
export class DiscordRPC {
private socket: Socket | null = null;
private connected = false;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private lastUpdate = 0;
private nonce = 0;
private destroyed = false;
private recvBuf = Buffer.alloc(0);
private pendingActivity: ActivityPayload | null = null;
private flushTimer: ReturnType<typeof setTimeout> | null = null;
get isConnected(): boolean {
return this.connected;
}
connect(): void {
if (this.destroyed) return;
this.tryConnect(0);
}
private tryConnect(pipeIndex: number): void {
if (this.destroyed || pipeIndex > 9) {
this.scheduleReconnect();
return;
}
const pipePath = getPipePath(pipeIndex);
const sock = new Socket();
let settled = false;
const onError = () => {
if (settled) return;
settled = true;
sock.destroy();
// Try next pipe index
this.tryConnect(pipeIndex + 1);
};
sock.once('error', onError);
sock.connect(pipePath, () => {
if (settled || this.destroyed) {
sock.destroy();
return;
}
settled = true;
this.socket = sock;
this.recvBuf = Buffer.alloc(0);
// Remove the initial error handler and set up persistent ones
sock.removeListener('error', onError);
sock.on('error', (err) => {
electronLog.warn('[KCC-Discord] Socket error:', err.message);
this.handleDisconnect();
});
sock.on('close', () => {
this.handleDisconnect();
});
sock.on('data', (data) => {
this.onData(data);
});
// Send handshake
const handshake = encodeFrame(OP_HANDSHAKE, {
v: 1,
client_id: DISCORD_CLIENT_ID,
});
sock.write(handshake);
});
// Connection timeout — 5s
sock.setTimeout(5000, onError);
}
private onData(data: Buffer): void {
this.recvBuf = Buffer.concat([this.recvBuf, data]);
while (this.recvBuf.length >= 8) {
const opcode = this.recvBuf.readUInt32LE(0);
const length = this.recvBuf.readUInt32LE(4);
if (this.recvBuf.length < 8 + length) break;
const jsonBuf = this.recvBuf.slice(8, 8 + length);
this.recvBuf = this.recvBuf.slice(8 + length);
try {
const payload = JSON.parse(jsonBuf.toString());
this.handleMessage(opcode, payload);
} catch {
// Malformed JSON — ignore
}
}
}
private handleMessage(opcode: number, payload: any): void {
if (opcode === OP_FRAME) {
if (payload.cmd === 'DISPATCH' && payload.evt === 'READY') {
this.connected = true;
electronLog.log('[KCC-Discord] Connected to Discord');
// Flush any activity that was set before connection completed
if (this.pendingActivity) {
this.sendActivity(this.pendingActivity);
this.pendingActivity = null;
}
}
} else if (opcode === OP_CLOSE) {
electronLog.warn('[KCC-Discord] Discord closed connection:', payload.message || '');
this.handleDisconnect();
}
}
private handleDisconnect(): void {
if (!this.connected && !this.socket) return;
this.connected = false;
if (this.flushTimer) {
clearTimeout(this.flushTimer);
this.flushTimer = null;
}
if (this.socket) {
this.socket.destroy();
this.socket = null;
}
this.recvBuf = Buffer.alloc(0);
electronLog.log('[KCC-Discord] Disconnected');
this.scheduleReconnect();
}
private scheduleReconnect(): void {
if (this.destroyed || this.reconnectTimer) return;
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
if (!this.destroyed && !this.connected) {
this.tryConnect(0);
}
}, RECONNECT_INTERVAL_MS);
}
setActivity(activity: ActivityPayload): void {
if (this.destroyed) return;
// Always store latest activity so it can be sent on (re)connect
this.pendingActivity = activity;
if (!this.connected || !this.socket) return;
const now = Date.now();
const elapsed = now - this.lastUpdate;
if (elapsed < RATE_LIMIT_MS) {
// Schedule a flush after the rate limit window expires
if (!this.flushTimer) {
this.flushTimer = setTimeout(() => {
this.flushTimer = null;
if (this.pendingActivity && this.connected && this.socket) {
this.sendActivity(this.pendingActivity);
this.pendingActivity = null;
}
}, RATE_LIMIT_MS - elapsed);
}
return;
}
this.sendActivity(activity);
this.pendingActivity = null;
}
private sendActivity(activity: ActivityPayload): void {
if (!this.socket || this.destroyed) return;
this.lastUpdate = Date.now();
const activityObj: any = {};
if (activity.details) activityObj.details = activity.details;
if (activity.state) activityObj.state = activity.state;
if (activity.startTimestamp) {
activityObj.timestamps = { start: activity.startTimestamp };
}
if (activity.largeImageKey) {
activityObj.assets = {
large_image: activity.largeImageKey,
large_text: activity.largeImageText || 'Krunker Civilian Client',
};
}
const frame = encodeFrame(OP_FRAME, {
cmd: 'SET_ACTIVITY',
args: {
pid: process.pid,
activity: activityObj,
},
nonce: String(++this.nonce),
});
try {
this.socket.write(frame);
} catch (err) {
electronLog.warn('[KCC-Discord] Write error:', (err as Error).message);
}
}
clearActivity(): void {
if (!this.connected || !this.socket || this.destroyed) return;
const frame = encodeFrame(OP_FRAME, {
cmd: 'SET_ACTIVITY',
args: {
pid: process.pid,
activity: null,
},
nonce: String(++this.nonce),
});
try {
this.socket.write(frame);
} catch {
// Silent
}
}
disconnect(): void {
this.destroyed = true;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.flushTimer) {
clearTimeout(this.flushTimer);
this.flushTimer = null;
}
if (this.socket) {
try {
this.clearActivity();
} catch {
// Silent
}
this.socket.destroy();
this.socket = null;
}
this.connected = false;
this.recvBuf = Buffer.alloc(0);
}
}
+843
View File
@@ -0,0 +1,843 @@
import { app, BrowserWindow, Menu, clipboard, ipcMain, safeStorage, session, shell } from 'electron';
import { join } from 'path';
import { existsSync, mkdirSync, promises as fsp } from 'fs';
import { get as httpsGet } from 'https';
import { execFile } from 'child_process';
import * as os from 'os';
import { detectPlatform, applyPlatformFlags } from './platform';
import { config, Keybind, DEFAULT_KEYBINDS, SavedAccount } from './config';
import { initSwapperProtocol, registerSwapperFileProtocol, ResourceSwapper } from './swapper';
import { UserscriptManager } from './userscripts';
import { ALL_CLIENT_CSS } from './client-ui';
import { electronLog, getLogPath, closeLogStreams } from './logger';
import { checkForUpdate, downloadUpdate, installUpdate } from './updater';
import { showUpdateWindow } from './update-window';
import { DiscordRPC } from './discord-rpc';
import { listThemes, getThemeCSS, listLoadingThemes, getLoadingScreenCSS } from './css-themes';
import { TabManager } from './tab-manager';
// ── App version for API calls ──
// eslint-disable-next-line @typescript-eslint/no-require-imports
const appVersion: string = require('../../package.json').version;
// ── Region ping cache ──
const SERVER_MAP: Record<string, string> = {
'us-ca-sv': 'SV', 'jb-hnd': 'TOK', 'de-fra': 'FRA',
'as-mb': 'MBI', 'au-syd': 'SYD', 'sgp': 'SIN',
'us-tx': 'DAL', 'me-bhn': 'BHN', 'brz': 'BRZ', 'us-nj': 'NY',
};
let pingCache: Record<string, number> = {};
let pingCacheTime = 0;
function osPing(host: string): Promise<number> {
return new Promise((resolve) => {
const isWin = process.platform === 'win32';
const args = isWin ? ['-n', '1', '-w', '1500', host] : ['-c', '1', '-W', '2', host];
execFile('ping', args, { timeout: 3000 }, (err, stdout) => {
if (err) { resolve(-1); return; }
const match = stdout.match(/time[=<]([\d.]+)\s*ms/i);
if (match) resolve(Math.round(parseFloat(match[1])));
else resolve(-1);
});
});
}
// ── Platform flags (must run before app.ready) ──
const platformInfo = detectPlatform();
const advancedDefaults = {
removeUselessFeatures: true,
gpuRasterizing: false,
helpfulFlags: true,
disableAccelerated2D: false,
increaseLimits: false,
lowLatency: false,
experimentalFlags: false,
};
const advancedConfig = { ...advancedDefaults, ...config.get('advanced') };
const perfConfig = { fpsUnlocked: true, ...config.get('performance') };
applyPlatformFlags(platformInfo, advancedConfig, perfConfig);
// ── App identity (must match electron-builder appId for taskbar pin persistence) ──
app.setAppUserModelId('com.krunkercivilian.client');
// ── Resource swapper protocol (must register before app.ready) ──
initSwapperProtocol();
// ── Ad-blocking URL patterns (matched in C++ layer, never hits JS for non-matches) ──
const BLOCKED_URL_PATTERNS = [
'*://*.pollfish.com/*',
'*://www.paypalobjects.com/*',
'*://fran-cdn.frvr.com/*',
'*://c.amazon-adsystem.com/*',
'*://cdn.frvr.com/fran/*',
'*://cookiepro.com/*',
'*://*.cookiepro.com/*',
'*://www.googletagmanager.com/*',
'*://*.doubleclick.net/*',
'*://storage.googleapis.com/pollfish_production/*',
'*://coeus.frvr.com/*',
'*://apis.google.com/js/platform.js',
'*://imasdk.googleapis.com/*',
];
// ── CSS to hide ad containers ──
const HIDE_ADS_CSS = `
.endAHolder,
#aHider,
#adCon,
#rightABox,
#aContainer,
#topRightAdHolder,
div#aContainer,
#braveWarning,
#topRightAdHolder {
display: none !important;
}`;
// ── Consent dismiss script (polling only — NO MutationObserver on main frame) ──
const CONSENT_DISMISS_MAIN_JS = `
(function dismissConsent() {
let attempts = 0;
const timer = setInterval(() => {
attempts++;
const btn = document.querySelector('.fc-cta-consent, [aria-label="Consent"], .css-47sehv');
if (btn) { btn.click(); clearInterval(timer); }
if (attempts > 30) clearInterval(timer);
}, 500);
})();`;
// ── Escape pointer lock fix ──
const ESCAPE_POINTERLOCK_FIX_JS = `
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && document.pointerLockElement) {
document.exitPointerLock();
}
}, true);`;
// ── Safe external URL opener (only http/https) ──
function safeOpenExternal(url: string): void {
try {
const parsed = new URL(url);
if (parsed.protocol === 'https:' || parsed.protocol === 'http:') {
shell.openExternal(url);
}
} catch { /* malformed URL — ignore */ }
}
// ── Keybind matching ──
function matchesKeybind(input: { key: string; control: boolean; shift: boolean; alt: boolean }, bind: Keybind | undefined): boolean {
if (!bind) return false;
return input.key === bind.key
&& input.control === bind.ctrl
&& input.shift === bind.shift
&& input.alt === bind.alt;
}
// ── Cached keybinds (avoid re-reading electron-store on every keypress) ──
let cachedKeybinds: Record<string, Keybind> | null = null;
function getKeybinds(): Record<string, Keybind> {
if (!cachedKeybinds) {
cachedKeybinds = { ...DEFAULT_KEYBINDS, ...config.get('keybinds') };
}
return cachedKeybinds;
}
// ── Debounced window state persistence ──
let saveTimer: ReturnType<typeof setTimeout> | null = null;
function saveWindowState(win: BrowserWindow): void {
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
if (win.isDestroyed()) return;
const bounds = win.getBounds();
config.set('window', {
width: bounds.width,
height: bounds.height,
x: bounds.x,
y: bounds.y,
maximized: win.isMaximized(),
fullscreen: win.isFullScreen(),
});
}, 1000);
}
app.whenReady().then(async () => {
electronLog.log('[KCC] App ready');
// ── Auto-update check (mandatory, Windows NSIS install only) ──
const isPortable = !!process.env.PORTABLE_EXECUTABLE_DIR;
const isAppImage = !!process.env.APPIMAGE;
const isDev = !app.isPackaged;
if (isDev || process.platform !== 'win32' || isPortable || isAppImage) {
electronLog.log('[KCC] Skipping auto-update (portable or non-Windows)');
} else {
try {
electronLog.log('[KCC] Checking for updates...');
const update = await checkForUpdate(appVersion);
if (update) {
electronLog.log(`[KCC] Update available: v${update.version}`);
const { window: updateWin, sendProgress } = showUpdateWindow();
sendProgress(`Update available (v${update.version})`, 0);
const tempDir = join(app.getPath('temp'), 'kcc-update');
if (!existsSync(tempDir)) mkdirSync(tempDir, { recursive: true });
const installerPath = join(tempDir, `KCC-${update.version}-Setup.exe`);
let cancelled = false;
updateWin.on('closed', () => { cancelled = true; });
try {
await downloadUpdate(update.downloadUrl, installerPath, (pct) => {
if (!cancelled && !updateWin.isDestroyed()) {
sendProgress(`Downloading update... ${pct}%`, pct);
}
});
if (!cancelled) {
sendProgress('Installing update...', 100);
installUpdate(installerPath);
return; // app.quit() called by installUpdate
}
} catch (err) {
electronLog.error('[KCC] Update download failed:', err);
if (!updateWin.isDestroyed()) updateWin.close();
}
} else {
electronLog.log('[KCC] No updates available');
}
} catch (err) {
electronLog.error('[KCC] Update check failed:', err);
}
}
await launchApp();
});
async function launchApp(): Promise<void> {
electronLog.log('[KCC] Starting initialization');
// ── Session: persistent partition + clean user-agent ──
const ses = session.fromPartition('persist:krunker');
const rawUA = ses.getUserAgent();
ses.setUserAgent(rawUA.replace(/\s*krunker-civilian-client\/\S+/i, ''));
// ── Register swapper file protocol on this session ──
registerSwapperFileProtocol(ses);
// ── Resource swapper ──
const swapperConfig = config.get('swapper');
const swapDir = swapperConfig.path || join(app.getPath('userData'), 'Krunker Civilian Client', 'swapper');
const swapper = swapperConfig.enabled ? new ResourceSwapper(swapDir) : null;
electronLog.log(`[KCC] Resource swapper: ${swapper ? 'enabled' : 'disabled'} (${swapDir})`);
// ── Userscript manager ──
const usConfig = config.get('userscripts') || { enabled: true, path: '' };
const usDir = usConfig.path || join(app.getPath('userData'), 'Krunker Civilian Client');
const userscriptManager = usConfig.enabled ? new UserscriptManager(usDir) : null;
electronLog.log(`[KCC] Userscripts: ${userscriptManager ? 'enabled' : 'disabled'} (${usDir})`);
// ── Ad blocking + resource swapper (single onBeforeRequest — Electron only allows one) ──
// The broad *://*.krunker.io/* pattern lets the swapper intercept any krunker asset.
// swapper.getRedirect() returns null before its async scan completes, so swapped
// resources simply pass through until the scan finishes — no re-registration needed.
const requestFilterUrls = swapper
? [...BLOCKED_URL_PATTERNS, '*://*.krunker.io/*']
: [...BLOCKED_URL_PATTERNS];
ses.webRequest.onBeforeRequest({ urls: requestFilterUrls }, (details, callback) => {
// Check swapper first — redirect matching assets to local files
if (swapper) {
const redirect = swapper.getRedirect(details.url);
if (redirect) return callback({ redirectURL: redirect });
}
// Determine if this URL is a krunker.io request (matched by the broad swapper pattern)
// vs an ad-block pattern. krunker.io requests that weren't swapped pass through normally.
try {
if (new URL(details.url).hostname.endsWith('krunker.io')) return callback({});
} catch { /* invalid URL — fall through to cancel */ }
// Matched an ad-block pattern — cancel it
callback({ cancel: true });
});
if (swapper) {
swapper.waitForReady().then(() => {
electronLog.log(`[KCC] Swapper ready: ${swapper.patterns.length} pattern(s)`);
});
}
// ── CORS fix for swapped resources ──
if (swapper) {
ses.webRequest.onHeadersReceived(({ responseHeaders }, callback) => {
if (!responseHeaders) return callback({});
for (const key in responseHeaders) {
const lowercase = key.toLowerCase();
if (lowercase === 'access-control-allow-credentials' && responseHeaders[key][0] === 'true') {
return callback({ responseHeaders });
}
if (lowercase === 'access-control-allow-origin') {
delete responseHeaders[key];
break;
}
}
return callback({
responseHeaders: { ...responseHeaders, 'access-control-allow-origin': ['*'] },
});
});
}
// ── Restore saved window bounds ──
const savedWindow = config.get('window');
const win = new BrowserWindow({
width: savedWindow.width,
height: savedWindow.height,
x: savedWindow.x,
y: savedWindow.y,
frame: true,
backgroundColor: '#000000',
webPreferences: {
preload: join(__dirname, '..', 'preload', 'index.js'),
session: ses,
contextIsolation: false,
nodeIntegration: false,
sandbox: false,
spellcheck: false,
backgroundThrottling: false,
},
});
if (savedWindow.fullscreen) win.setFullScreen(true);
else if (savedWindow.maximized) win.maximize();
// ── No application menu (prevents Escape/Alt interception) ──
Menu.setApplicationMenu(null);
// ── Discord Rich Presence ──
let discordRpc: DiscordRPC | null = null;
{
const discordConf = config.get('discord') || { enabled: false };
if (discordConf.enabled) {
discordRpc = new DiscordRPC();
discordRpc.connect();
electronLog.log('[KCC] Discord Rich Presence enabled');
}
}
// ── Process Priority (Windows only) ──
if (process.platform === 'win32') {
const PRIORITY_MAP: Record<string, number> = {
'High': -14,
'Above Normal': -7,
'Below Normal': 7,
'Low': 19,
};
const prioritySetting = config.get('performance')?.processPriority || 'Normal';
const priorityVal = PRIORITY_MAP[prioritySetting];
if (priorityVal !== undefined) {
try { os.setPriority(process.pid, priorityVal); } catch { /* ignore */ }
// Apply to child processes periodically
setInterval(() => {
for (const m of app.getAppMetrics()) {
if (m.pid !== process.pid) {
try { os.setPriority(m.pid, priorityVal); } catch { /* ignore */ }
}
}
}, 1000);
electronLog.log(`[KCC] Process priority set to ${prioritySetting}`);
}
}
// ── CPU Throttling via Chrome DevTools Protocol ──
const throttledContents = new WeakSet<Electron.WebContents>();
function applyCpuThrottle(wc: Electron.WebContents, rate: number): void {
const clamped = Math.max(1, Math.min(3, rate));
try {
if (!throttledContents.has(wc)) {
wc.debugger.attach('1.3');
throttledContents.add(wc);
}
wc.debugger.sendCommand('Emulation.setCPUThrottlingRate', { rate: clamped });
} catch { /* debugger may already be attached or detached */ }
}
// ── Keybind capture lock (suppresses shortcuts while the keybind dialog is open) ──
let keybindCapturing = false;
ipcMain.on('keybind-capture', (_e, capturing: boolean) => {
keybindCapturing = capturing;
});
// ── Configurable keybinds via before-input-event ──
win.webContents.on('before-input-event', (event, input) => {
if (input.type !== 'keyDown') return;
if (keybindCapturing) return;
const binds = getKeybinds();
if (matchesKeybind(input, binds.reload)) {
win.reload();
event.preventDefault();
} else if (matchesKeybind(input, binds.newMatch)) {
const mm = config.get('matchmaker');
if (mm.enabled) {
win.webContents.send('matchmaker-find', {
...mm,
acceptKey: binds.matchmakerAccept,
cancelKey: binds.matchmakerCancel,
});
} else {
win.loadURL('https://krunker.io');
}
event.preventDefault();
} else if (matchesKeybind(input, binds.joinFromClipboard)) {
const text = clipboard.readText();
try { const u = new URL(text); if (u.protocol === 'https:' && u.hostname.endsWith('krunker.io')) win.loadURL(text); } catch { /* ignore invalid URLs */ }
event.preventDefault();
} else if (matchesKeybind(input, binds.copyGameLink)) {
clipboard.writeText(win.webContents.getURL());
event.preventDefault();
} else if (matchesKeybind(input, binds.devTools)) {
win.webContents.toggleDevTools();
event.preventDefault();
} else if (matchesKeybind(input, binds.matchmaker)) {
const mm = config.get('matchmaker');
if (mm.enabled) {
win.webContents.send('matchmaker-find', {
...mm,
acceptKey: binds.matchmakerAccept,
cancelKey: binds.matchmakerCancel,
});
} else {
win.loadURL('https://krunker.io');
}
event.preventDefault();
} else if (matchesKeybind(input, binds.pauseChat)) {
win.webContents.send('toggle-chat-pause');
event.preventDefault();
} else if (matchesKeybind(input, binds.fullscreenToggle)) {
win.setFullScreen(!win.isFullScreen());
event.preventDefault();
} else if (input.key === 't' && input.control && !input.shift && !input.alt) {
tabManager.openTab('https://krunker.io/social.html');
event.preventDefault();
} else if (input.key === 'T' && input.control && input.shift && !input.alt) {
tabManager.reopenTab();
event.preventDefault();
}
});
// ── Window state persistence (debounced) ──
win.on('resize', () => saveWindowState(win));
win.on('move', () => saveWindowState(win));
win.on('maximize', () => saveWindowState(win));
win.on('unmaximize', () => saveWindowState(win));
win.on('enter-full-screen', () => saveWindowState(win));
win.on('leave-full-screen', () => saveWindowState(win));
// ── URL classification ──
const GAME_PAGE_PATHS = ['/', ''];
function isGameURL(url: string): boolean {
try {
const parsed = new URL(url);
if (!parsed.hostname.includes('krunker.io')) return false;
return GAME_PAGE_PATHS.includes(parsed.pathname);
} catch { return false; }
}
// ── Cached game config (invalidated on set-config writes to 'game') ──
const gameDefaults = { lastServer: '', socialTabBehaviour: 'New Window' };
let cachedGameConf: typeof gameDefaults | null = null;
function getGameConf(): typeof gameDefaults {
if (!cachedGameConf) cachedGameConf = { ...gameDefaults, ...config.get('game') };
return cachedGameConf;
}
// ── Tab Manager ──
const preloadPath = join(__dirname, '..', 'preload', 'index.js');
let tabMode: 'same' | 'new' = getGameConf().socialTabBehaviour === 'Same Window' ? 'same' : 'new';
let tabManager = new TabManager(
win, ses, preloadPath, tabMode, isGameURL,
() => config.get('tabWindow'),
(state) => config.set('tabWindow', state),
);
// Intercept in-page navigation (e.g. window.location = '/social.html')
win.webContents.on('will-navigate', (event, url) => {
if (url.includes('krunker.io') && !isGameURL(url)) {
event.preventDefault();
tabManager.openTab(url);
}
});
// Intercept target="_blank" / window.open links
win.webContents.setWindowOpenHandler(({ url }) => {
if (url.includes('krunker.io')) {
if (isGameURL(url)) {
win.loadURL(url);
} else {
setImmediate(() => tabManager.openTab(url));
}
} else {
setImmediate(() => safeOpenExternal(url));
}
return { action: 'deny' };
});
// Right-click context menu on main window with "Open in New Tab"
win.webContents.on('context-menu', (_e, params) => {
if (!params.linkURL) return;
const items: Electron.MenuItemConstructorOptions[] = [];
if (params.linkURL.includes('krunker.io') && !isGameURL(params.linkURL)) {
items.push({ label: 'Open in New Tab', click: () => tabManager.openTab(params.linkURL) });
}
items.push({ label: 'Copy Link', click: () => clipboard.writeText(params.linkURL) });
if (!params.linkURL.includes('krunker.io')) {
items.push({ label: 'Open in Browser', click: () => safeOpenExternal(params.linkURL) });
}
if (items.length) Menu.buildFromTemplate(items).popup();
});
// ── Inject scripts after page loads ──
win.webContents.on('did-finish-load', () => {
electronLog.log(`[KCC] Page loaded: ${win.webContents.getURL()}`);
// Rescan swap directory so new/changed files are picked up on refresh
if (swapper) swapper.rescan().catch(() => {});
const cssInjections = [
win.webContents.insertCSS(HIDE_ADS_CSS),
win.webContents.insertCSS(ALL_CLIENT_CSS),
];
// Inject user CSS theme
const uiConf = config.get('ui');
const themeCSS = getThemeCSS(uiConf?.cssTheme || 'disabled', swapDir);
if (themeCSS) cssInjections.push(win.webContents.insertCSS(themeCSS));
// Inject loading screen background
const loadingCSS = getLoadingScreenCSS(uiConf?.loadingTheme || 'disabled', uiConf?.backgroundUrl || '', swapDir);
if (loadingCSS) cssInjections.push(win.webContents.insertCSS(loadingCSS));
Promise.all(cssInjections).catch(() => {});
// Apply initial CPU throttle (menu state)
const perf = config.get('performance');
applyCpuThrottle(win.webContents, perf?.cpuThrottleMenu ?? 1.5);
win.webContents.executeJavaScript(ESCAPE_POINTERLOCK_FIX_JS).catch((err) => electronLog.warn('[KCC] Pointerlock fix inject failed:', err));
win.webContents.executeJavaScript(CONSENT_DISMISS_MAIN_JS).catch((err) => electronLog.warn('[KCC] Consent dismiss inject failed:', err));
// Notify preload to start hooking settings (matches Crankshaft's timing)
win.webContents.send('main_did-finish-load');
});
// ── IPC handlers ──
const ALLOWED_CONFIG_KEYS = new Set<string>([
'window', 'performance', 'game', 'swapper', 'matchmaker',
'keybinds', 'userscripts', 'ui', 'discord', 'translator',
'advanced', 'accounts', 'tabWindow', 'platform',
]);
ipcMain.handle('get-version', () => appVersion);
ipcMain.handle('get-platform', () => platformInfo);
ipcMain.handle('get-config', (_e, key: string) => {
if (!ALLOWED_CONFIG_KEYS.has(key)) return undefined;
return config.get(key as keyof typeof config.store);
});
ipcMain.handle('get-all-config', (_e, keys: string[]) => {
const result: Record<string, unknown> = {};
for (const key of keys) {
if (ALLOWED_CONFIG_KEYS.has(key)) result[key] = config.get(key as keyof typeof config.store);
}
return result;
});
let configWriteTimer: ReturnType<typeof setTimeout> | null = null;
const pendingConfigWrites = new Map<string, unknown>();
ipcMain.handle('set-config', (_e, key: string, value: unknown) => {
if (!ALLOWED_CONFIG_KEYS.has(key)) return;
// Flush immediately for keys that have side effects
if (key === 'keybinds') {
config.set(key as any, value);
cachedKeybinds = null;
return;
}
// Invalidate caches immediately (not on flush) to prevent stale reads
if (key === 'game') {
cachedGameConf = null;
// Switch tab mode if socialTabBehaviour changed
const newGame = value as any;
if (newGame?.socialTabBehaviour) {
const newMode: 'same' | 'new' = newGame.socialTabBehaviour === 'Same Window' ? 'same' : 'new';
if (newMode !== tabMode) {
tabManager.destroyAll();
tabMode = newMode;
tabManager = new TabManager(
win, ses, preloadPath, tabMode, isGameURL,
() => config.get('tabWindow'),
(state) => config.set('tabWindow', state),
);
}
}
}
pendingConfigWrites.set(key, value);
if (!configWriteTimer) {
configWriteTimer = setTimeout(() => {
for (const [k, v] of pendingConfigWrites) {
config.set(k as any, v);
}
pendingConfigWrites.clear();
configWriteTimer = null;
}, 300);
}
});
ipcMain.handle('window-minimize', () => win.minimize());
ipcMain.handle('window-maximize', () => {
if (win.isMaximized()) win.unmaximize(); else win.maximize();
});
ipcMain.handle('window-close', () => win.close());
ipcMain.handle('window-is-maximized', () => win.isMaximized());
ipcMain.handle('toggle-devtools', () => win.webContents.toggleDevTools());
ipcMain.handle('inject-game-click', () => {
const [width, height] = win.getContentSize();
const x = Math.round(width / 2);
const y = Math.round(height / 2);
win.webContents.sendInputEvent({ type: 'mouseDown', x, y, button: 'left', clickCount: 1 });
win.webContents.sendInputEvent({ type: 'mouseUp', x, y, button: 'left', clickCount: 1 });
});
ipcMain.handle('get-swap-dir', () => swapDir);
ipcMain.handle('open-swap-folder', () => shell.openPath(swapDir));
// ── Ping regions IPC handler (TCP connect timing, cached 60s) ──
ipcMain.handle('ping-regions', async () => {
if (Object.keys(pingCache).length > 0 && Date.now() - pingCacheTime < 60000) {
return pingCache;
}
try {
const data = await new Promise<string>((resolve, reject) => {
httpsGet('https://matchmaker.krunker.io/ping-list?hostname=krunker.io', (res) => {
let body = '';
res.on('data', (chunk: string) => { body += chunk; });
res.on('end', () => resolve(body));
res.on('error', reject);
}).on('error', reject);
});
const serverIPs: Record<string, string> = JSON.parse(data);
const results: Record<string, number> = {};
async function pingWithRetry(host: string): Promise<number> {
const latency = await osPing(host);
if (latency >= 0) return latency;
const retry = await osPing(host);
return retry >= 0 ? retry : -1;
}
const promises = Object.entries(serverIPs).map(async ([server, ip]) => {
const regionName = SERVER_MAP[server] ?? server;
const host = ip.split(':')[0];
const latency = await pingWithRetry(host);
if (latency >= 0) {
results[regionName] = latency;
}
});
await Promise.allSettled(promises);
pingCache = results;
pingCacheTime = Date.now();
return results;
} catch (err) {
electronLog.error('[KCC] Ping regions error:', err);
return pingCache;
}
});
// ── Discord Rich Presence IPC handler ──
ipcMain.on('discord-update', (_e, activity: any) => {
discordRpc?.setActivity(activity);
});
// ── Verbose log IPC handler (preload forwards logs here) ──
ipcMain.on('verbose-log', (_e, level: string, ...args: unknown[]) => {
if (level === 'error') electronLog.error(...args);
else if (level === 'warn') electronLog.warn(...args);
else electronLog.log(...args);
});
// ── CPU throttle IPC handler ──
ipcMain.on('throttle-state', (_e, state: string) => {
const perf = config.get('performance');
const rate = state === 'game' ? (perf?.cpuThrottleGame ?? 1) : (perf?.cpuThrottleMenu ?? 1.5);
applyCpuThrottle(win.webContents, rate);
});
// ── CSS theme & loading background IPC handlers ──
ipcMain.handle('list-themes', () => listThemes(swapDir));
ipcMain.handle('get-theme-css', (_e, themeId: string) => getThemeCSS(themeId, swapDir));
ipcMain.handle('list-loading-themes', () => listLoadingThemes(swapDir));
ipcMain.handle('get-loading-screen-css', (_e, loadingTheme: string, backgroundUrl: string) => {
return getLoadingScreenCSS(loadingTheme, backgroundUrl, swapDir);
});
// ── Changelog IPC handler (fetch release notes from Gitea) ──
ipcMain.handle('changelog-fetch', async (_e, version: string) => {
const tag = version.startsWith('v') ? version : `v${version}`;
try {
const data = await new Promise<string>((resolve, reject) => {
httpsGet(`https://gitea.crjlab.net/api/v1/repos/bigjakk/Krunker-Civilian-Client/releases/tags/${tag}`, (res) => {
let body = '';
res.on('data', (chunk: string) => { body += chunk; });
res.on('end', () => resolve(body));
res.on('error', reject);
}).on('error', reject);
});
const release = JSON.parse(data);
return release.body || '';
} catch {
return '';
}
});
// ── Userscript IPC handlers ──
ipcMain.handle('userscripts-get-dir', () => userscriptManager ? userscriptManager.dir : '');
ipcMain.handle('userscripts-open-folder', () => {
if (userscriptManager) shell.openPath(userscriptManager.dir);
});
ipcMain.handle('userscripts-scan', async () => {
if (!userscriptManager) return { scripts: [], tracker: {} };
const scripts = await userscriptManager.scanScripts();
const tracker = await userscriptManager.loadTracker(scripts);
return { scripts, tracker };
});
ipcMain.handle('userscripts-set-tracker', (_e, tracker: Record<string, boolean>) => {
if (userscriptManager) userscriptManager.saveTracker(tracker);
});
ipcMain.handle('userscripts-load-prefs', (_e, filename: string) => {
if (!userscriptManager) return {};
return userscriptManager.loadScriptPrefs(filename);
});
ipcMain.handle('userscripts-save-prefs', (_e, filename: string, prefs: Record<string, unknown>) => {
if (userscriptManager) userscriptManager.saveScriptPrefs(filename, prefs);
});
// ── Action button IPC handlers ──
ipcMain.handle('open-electron-log', () => {
shell.openPath(getLogPath('electron'));
});
ipcMain.handle('reset-swapper', async () => {
try {
const entries = await fsp.readdir(swapDir, { withFileTypes: true });
for (const entry of entries) {
await fsp.rm(join(swapDir, entry.name), { recursive: true, force: true });
}
return true;
} catch (err) {
electronLog.error('[KCC] Reset swapper failed:', err);
return false;
}
});
ipcMain.handle('restart-client', () => {
app.relaunch();
app.quit();
});
ipcMain.handle('reset-options', () => {
config.clear();
app.relaunch();
app.quit();
});
ipcMain.handle('delete-all-data', async () => {
config.clear();
const userData = app.getPath('userData');
try {
await fsp.rm(join(userData, 'logs'), { recursive: true, force: true });
} catch (err) {
electronLog.warn('[KCC] Partial data deletion failed (non-fatal):', err);
}
app.relaunch();
app.quit();
});
// ── Alt manager IPC handlers (credentials encrypted via safeStorage) ──
const canEncrypt = safeStorage.isEncryptionAvailable();
if (!canEncrypt) electronLog.warn('[KCC] safeStorage encryption not available — account passwords will use base64 fallback');
function encryptString(plaintext: string): string {
if (canEncrypt) return safeStorage.encryptString(plaintext).toString('base64');
return Buffer.from(plaintext).toString('base64');
}
function decryptString(encrypted: string): string {
if (canEncrypt) return safeStorage.decryptString(Buffer.from(encrypted, 'base64'));
return Buffer.from(encrypted, 'base64').toString();
}
ipcMain.handle('alt-list', () => {
const accounts = config.get('accounts') || [];
// Return only labels to the renderer — never send encrypted credentials
return accounts.map((a: SavedAccount) => ({ label: a.label }));
});
ipcMain.handle('alt-save', (_e, data: { label: string; username: string; password: string }) => {
const accounts = config.get('accounts') || [];
const account: SavedAccount = {
label: data.label,
username: encryptString(data.username),
password: encryptString(data.password),
};
accounts.push(account);
config.set('accounts', accounts);
return { success: true, index: accounts.length - 1 };
});
ipcMain.handle('alt-get-credentials', (_e, index: number) => {
const accounts = config.get('accounts') || [];
if (index < 0 || index >= accounts.length) return null;
const acc = accounts[index];
try {
return {
username: decryptString(acc.username),
password: decryptString(acc.password),
};
} catch (err) {
electronLog.error('[KCC] Failed to decrypt account credentials:', err);
return null;
}
});
ipcMain.handle('alt-remove', (_e, index: number) => {
const accounts = config.get('accounts') || [];
if (index < 0 || index >= accounts.length) return { success: false };
accounts.splice(index, 1);
config.set('accounts', accounts);
return { success: true };
});
ipcMain.handle('alt-rename', (_e, index: number, newLabel: string) => {
const accounts = config.get('accounts') || [];
if (index < 0 || index >= accounts.length) return { success: false };
accounts[index].label = newLabel;
config.set('accounts', accounts);
return { success: true };
});
// ── Stop page immediately on close to kill audio ──
win.on('close', () => {
win.webContents.setAudioMuted(true);
win.webContents.stop();
});
// ── Shutdown: disconnect Discord, then close log streams ──
app.on('will-quit', () => {
discordRpc?.disconnect();
electronLog.log('[KCC] Shutting down');
closeLogStreams();
});
electronLog.log('[KCC] Initialization complete — loading game');
// ── Load the game ──
win.loadURL('https://krunker.io');
}
app.on('window-all-closed', () => {
app.quit();
});
+80
View File
@@ -0,0 +1,80 @@
import { app } from 'electron';
import { join } from 'path';
import { existsSync, mkdirSync, readdirSync, unlinkSync, createWriteStream, WriteStream } from 'fs';
const LOG_RETENTION_DAYS = 7;
let electronStream: WriteStream;
let electronPath: string;
let ready = false;
function dateStamp(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function pruneOldLogs(logDir: string): void {
try {
const cutoff = Date.now() - LOG_RETENTION_DAYS * 86400000;
for (const file of readdirSync(logDir)) {
const m = file.match(/^electron-(\d{4}-\d{2}-\d{2})\.log$/);
if (!m) continue;
const fileDate = new Date(m[1] + 'T00:00:00').getTime();
if (fileDate < cutoff) {
try { unlinkSync(join(logDir, file)); } catch { /* ignore */ }
}
}
} catch { /* ignore */ }
}
function init(): void {
if (ready) return;
const logDir = join(app.getPath('userData'), 'logs');
if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });
pruneOldLogs(logDir);
const stamp = dateStamp();
electronPath = join(logDir, `electron-${stamp}.log`);
// Append to today's log — one file per day, multiple sessions
electronStream = createWriteStream(electronPath, { flags: 'a' });
const sep = `\n${'='.repeat(60)}\n Session started ${new Date().toISOString()}\n${'='.repeat(60)}\n`;
electronStream.write(sep);
ready = true;
}
function ts(): string {
return new Date().toISOString();
}
function fmt(...args: unknown[]): string {
return args.map(a => {
if (a instanceof Error) return `${a.message}\n${a.stack}`;
if (typeof a === 'string') return a;
try { return JSON.stringify(a); } catch { return String(a); }
}).join(' ');
}
function makeLogger(getStream: () => WriteStream) {
return {
log: (...args: unknown[]) => { init(); const m = fmt(...args); console.log(m); if (!closed) getStream().write(`[${ts()}] ${m}\n`); },
warn: (...args: unknown[]) => { init(); const m = fmt(...args); console.warn(m); if (!closed) getStream().write(`[${ts()}] WARN: ${m}\n`); },
error: (...args: unknown[]) => { init(); const m = fmt(...args); console.error(m); if (!closed) getStream().write(`[${ts()}] ERROR: ${m}\n`); },
};
}
export const electronLog = makeLogger(() => electronStream);
export function getLogPath(_type: 'electron'): string {
init();
return electronPath;
}
let closed = false;
export function closeLogStreams(): void {
closed = true;
if (electronStream) electronStream.end();
}
+145
View File
@@ -0,0 +1,145 @@
import { app } from 'electron';
import type { AppConfig } from './config';
export type Platform = 'win32' | 'linux' | 'darwin';
export type GpuBackend = 'angle' | 'opengl' | 'vulkan' | 'default';
export interface PlatformInfo {
os: Platform;
isWindows: boolean;
isLinux: boolean;
useNativeTitlebar: boolean;
gpuBackend: GpuBackend;
}
export function detectPlatform(): PlatformInfo {
const os = process.platform as Platform;
const isWindows = os === 'win32';
const isLinux = os === 'linux';
return {
os,
isWindows,
isLinux,
useNativeTitlebar: isLinux,
gpuBackend: isWindows ? 'angle' : 'default',
};
}
export function applyPlatformFlags(info: PlatformInfo, advanced: AppConfig['advanced'], performance: AppConfig['performance']): void {
// ── FPS uncap ──
// disable-frame-rate-limit causes compositor CPU spin on Chromium 84+, starving
// input events. On Electron 42 (Chromium 147), this is fixed by a patch to
// cc/scheduler/scheduler.cc in our custom Electron build. The latency recovery
// flags below are no-ops on Chromium 94+ (features were removed), but are
// harmless to keep — Chromium ignores unknown feature flags.
if (performance.fpsUnlocked) {
app.commandLine.appendSwitch('disable-frame-rate-limit');
app.commandLine.appendSwitch('disable-gpu-vsync');
app.commandLine.appendSwitch('max-gum-fps', '9999');
app.commandLine.appendSwitch('enable-features', 'ImplLatencyRecovery,MainLatencyRecovery');
}
// ── Always-on platform flags ──
app.commandLine.appendSwitch('disable-backgrounding-occluded-windows');
app.commandLine.appendSwitch('disable-threaded-scrolling');
app.commandLine.appendSwitch('overscroll-history-navigation', '0');
app.commandLine.appendSwitch('pull-to-refresh', '0');
// WebGL is mandatory for Krunker — force it past any GPU blocklist.
// On Chromium 134+ the blocklist is stricter and silently disables WebGL on many Linux GPUs.
app.commandLine.appendSwitch('ignore-gpu-blocklist');
// ── ANGLE backend ──
// 'default' means platform default: D3D11 on Windows, no override on Linux
if (advanced.angleBackend && advanced.angleBackend !== 'default') {
app.commandLine.appendSwitch('use-angle', advanced.angleBackend);
} else if (info.isWindows) {
app.commandLine.appendSwitch('use-angle', 'd3d11');
}
if (info.isWindows) {
app.commandLine.appendSwitch('disable-features', 'CalculateNativeWinOcclusion,HardwareMediaKeyHandling');
}
if (info.isLinux) {
app.commandLine.appendSwitch('ozone-platform-hint', 'auto');
// GPU sandbox can fail inside AppImage FUSE mounts and on certain Mesa driver versions,
// causing the GPU process to crash and leaving a black screen.
app.commandLine.appendSwitch('disable-gpu-sandbox');
}
// ── Remove useless features ──
if (advanced.removeUselessFeatures) {
app.commandLine.appendSwitch('disable-breakpad');
app.commandLine.appendSwitch('disable-crash-reporter');
app.commandLine.appendSwitch('disable-crashpad-forwarding');
app.commandLine.appendSwitch('disable-print-preview');
app.commandLine.appendSwitch('disable-metrics-reporting');
app.commandLine.appendSwitch('disable-metrics');
app.commandLine.appendSwitch('disable-2d-canvas-clip-aa');
app.commandLine.appendSwitch('disable-logging');
app.commandLine.appendSwitch('disable-hang-monitor');
app.commandLine.appendSwitch('disable-component-update');
app.commandLine.appendSwitch('disable-bundled-ppapi-flash');
app.commandLine.appendSwitch('disable-nacl');
app.commandLine.appendSwitch('disable-features', 'NativeNotifications,MediaRouter,PerformanceInterventionUI,HappinessTrackingSurveysForDesktopDemo');
}
// ── GPU rasterization ──
// OOP rasterization is always-on when GPU rasterization is enabled (Chromium 100+)
if (advanced.gpuRasterizing) {
app.commandLine.appendSwitch('enable-gpu-rasterization');
app.commandLine.appendSwitch('disable-zero-copy');
app.commandLine.appendSwitch('disable-software-rasterizer');
app.commandLine.appendSwitch('disable-gpu-driver-bug-workarounds');
}
// ── Helpful flags ──
if (advanced.helpfulFlags) {
app.commandLine.appendSwitch('enable-javascript-harmony');
app.commandLine.appendSwitch('enable-future-v8-vm-features');
app.commandLine.appendSwitch('enable-webgl');
app.commandLine.appendSwitch('disable-background-timer-throttling');
app.commandLine.appendSwitch('disable-renderer-backgrounding');
app.commandLine.appendSwitch('disable-best-effort-tasks');
app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required');
app.commandLine.appendSwitch('enable-features', 'V8VmFuture,WebAssemblyBaseline,WebAssemblyTiering,WebAssemblyLazyCompilation');
}
// ── Disable accelerated 2D canvas ──
if (advanced.disableAccelerated2D) {
app.commandLine.appendSwitch('disable-accelerated-2d-canvas');
}
// ── Increase limits ──
if (advanced.increaseLimits) {
app.commandLine.appendSwitch('renderer-process-limit', '100');
app.commandLine.appendSwitch('max-active-webgl-contexts', '100');
app.commandLine.appendSwitch('webrtc-max-cpu-consumption-percentage', '100');
app.commandLine.appendSwitch('ignore-gpu-blocklist');
}
// ── Low latency ──
// High-res timers and QUIC are default on Chromium 100+. Accelerated 2D canvas
// is default on Chromium 42+. These enable flags were removed from the source.
if (advanced.lowLatency) {
app.commandLine.appendSwitch('force-high-performance-gpu');
app.commandLine.appendSwitch('enable-quic');
app.commandLine.appendSwitch('quic-max-packet-length', '1460');
app.commandLine.appendSwitch('raise-timer-frequency');
}
// ── Experimental flags ──
// Removed dead flags: enable-accelerated-video-decode (default since Chromium 132),
// enable-native-gpu-memory-buffers (Linux-only), high-dpi-support (removed in ~M54,
// HiDPI is default since M108). Renamed ignore-gpu-blacklist → ignore-gpu-blocklist.
if (advanced.experimentalFlags) {
app.commandLine.appendSwitch('disable-low-end-device-mode');
app.commandLine.appendSwitch('disable-gpu-watchdog');
app.commandLine.appendSwitch('ignore-gpu-blocklist');
app.commandLine.appendSwitch('no-pings');
app.commandLine.appendSwitch('no-proxy-server');
app.commandLine.appendSwitch('enable-features', 'BlinkCompositorUseDisplayThreadPriority,GpuUseDisplayThreadPriority');
}
}
+131
View File
@@ -0,0 +1,131 @@
import { existsSync, mkdirSync, promises as fsp } from 'fs';
import { join } from 'path';
import { protocol, net, Session } from 'electron';
const PROTOCOL_NAME = 'kpc-swap';
const TARGET_DOMAIN = 'krunker.io';
/**
* Convert a native file path to a proper kpc-swap:// URL.
* Windows paths like C:\foo\bar become kpc-swap://C/foo/bar
*/
function filePathToSwapURL(filePath: string): string {
const forwardSlash = filePath.replace(/\\/g, '/');
// Windows drive letter: C:/foo → kpc-swap://C/foo
const match = forwardSlash.match(/^([A-Za-z]):\/(.*)/);
if (match) {
return `${PROTOCOL_NAME}://${match[1]}/${match[2]}`;
}
// Unix absolute: /home/user/foo → kpc-swap:///home/user/foo
return `${PROTOCOL_NAME}://${forwardSlash}`;
}
/**
* Register the custom protocol scheme. Must be called BEFORE app.ready.
*/
export function initSwapperProtocol(): void {
protocol.registerSchemesAsPrivileged([{
scheme: PROTOCOL_NAME,
privileges: { standard: true, secure: true, corsEnabled: true, bypassCSP: true },
}]);
}
/**
* Register the file protocol handler on the given session.
* Must be called AFTER app.ready.
*/
export function registerSwapperFileProtocol(ses: Session): void {
ses.protocol.handle(PROTOCOL_NAME, async (request) => {
const url = new URL(request.url);
// Reconstruct the file path from the URL
// Windows: kpc-swap://C/foo/bar → C:/foo/bar
// Unix: kpc-swap:///home/foo → /home/foo
let filePath: string;
if (url.hostname) {
// Windows drive letter is the hostname
filePath = `${url.hostname}:${url.pathname}`;
} else {
filePath = url.pathname;
}
try {
return await net.fetch(`file://${filePath}`);
} catch {
return new Response('Not found', { status: 404 });
}
});
}
/**
* Scans a local directory and intercepts matching Krunker asset requests,
* redirecting them to local replacement files via a custom protocol.
*/
export class ResourceSwapper {
private swapDir: string;
private swapFiles = new Map<string, string>();
private ready = false;
private scanPromise: Promise<void>;
constructor(swapDir: string) {
this.swapDir = swapDir;
if (!existsSync(this.swapDir)) mkdirSync(this.swapDir, { recursive: true });
this.scanPromise = this.scanAsync('');
}
/** Wait for the async directory scan to complete */
async waitForReady(): Promise<void> {
await this.scanPromise;
this.ready = true;
}
/** Rescan the swap directory to pick up added/removed/changed files */
async rescan(): Promise<void> {
this.swapFiles.clear();
await this.scanAsync('');
this.ready = true;
}
/** URL filter patterns for webRequest.onBeforeRequest — single broad pattern */
get patterns(): string[] {
return this.swapFiles.size > 0 ? [`*://*.${TARGET_DOMAIN}/*`] : [];
}
/**
* Returns a redirect URL if the request should be swapped, null otherwise.
* Strips /assets/ prefix so both `assets.krunker.io/assets/textures/foo.png`
* and `assets.krunker.io/textures/foo.png` resolve to the same local file.
*/
getRedirect(url: string): string | null {
if (!this.ready) return null;
try {
// Extract pathname from URL using string ops (faster than new URL())
// URLs are like: https://assets.krunker.io/path/file.ext?v=hash
const protoEnd = url.indexOf('//');
if (protoEnd === -1) return null;
const pathStart = url.indexOf('/', protoEnd + 2);
if (pathStart === -1) return null;
const queryStart = url.indexOf('?', pathStart);
let pathname = queryStart === -1 ? url.substring(pathStart) : url.substring(pathStart, queryStart);
if (pathname.startsWith('/assets/')) pathname = pathname.substring(7);
const localPath = this.swapFiles.get(pathname);
if (localPath) return filePathToSwapURL(localPath);
} catch { /* malformed URL — ignore */ }
return null;
}
/** Recursively scan the swap directory and build the file map (async) */
private async scanAsync(prefix: string): Promise<void> {
try {
const entries = await fsp.readdir(join(this.swapDir, prefix), { withFileTypes: true });
for (const dirent of entries) {
const name = `${prefix}/${dirent.name}`;
if (dirent.isDirectory()) {
await this.scanAsync(name);
} else {
this.swapFiles.set(name, join(this.swapDir, name));
}
}
} catch {
console.error(`Failed to scan swap directory prefix: ${prefix}`);
}
}
}
+287
View File
@@ -0,0 +1,287 @@
// ── Inline HTML for the tab bar WebContentsView ──
// Rendered as a data URL. Communicates with TabManager via ipcRenderer.
import { THEME_CSS } from './client-ui';
export const TAB_BAR_HTML = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
${THEME_CSS}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--kpc-surface-dialog);
color: var(--kpc-text-primary);
height: 40px;
overflow: hidden;
display: flex;
align-items: center;
gap: 4px;
padding: 0 6px;
user-select: none;
-webkit-app-region: no-drag;
}
/* ── Shared pill style for Game btn, tabs, and New Tab btn ── */
.bar-pill {
flex-shrink: 0;
display: flex;
align-items: center;
border: 1px solid var(--kpc-toggle-off);
border-radius: 6px;
padding: 4px 10px;
cursor: pointer;
font-size: 12px;
font-weight: 500;
transition: background 0.12s, border-color 0.12s;
background: var(--kpc-surface-card);
color: var(--kpc-text-secondary);
}
.bar-pill:hover {
background: var(--kpc-surface-input);
border-color: rgba(255,255,255,0.2);
}
/* ── Game button (green accent) ── */
#gameBtn {
background: rgba(76, 175, 80, 0.12);
color: var(--kpc-green);
border-color: rgba(76, 175, 80, 0.5);
font-weight: 600;
}
#gameBtn:hover {
background: rgba(76, 175, 80, 0.25);
border-color: var(--kpc-green);
}
/* ── Tab strip ── */
#tabStrip {
flex: 1;
display: flex;
gap: 4px;
overflow-x: auto;
overflow-y: hidden;
align-items: center;
height: 100%;
padding: 4px 0;
scrollbar-width: none;
}
#tabStrip::-webkit-scrollbar { display: none; }
/* ── Tab pills ── */
.tab {
position: relative;
gap: 6px;
max-width: 200px;
min-width: 60px;
height: 28px;
}
.tab.dragging {
opacity: 0.4;
}
.tab.drop-before::before {
content: '';
position: absolute;
left: -3px;
top: 2px;
bottom: 2px;
width: 2px;
background: var(--kpc-green);
border-radius: 1px;
}
.tab.drop-after::after {
content: '';
position: absolute;
right: -3px;
top: 2px;
bottom: 2px;
width: 2px;
background: var(--kpc-green);
border-radius: 1px;
}
.tab.active {
background: rgba(76, 175, 80, 0.12);
border-color: rgba(76, 175, 80, 0.5);
color: var(--kpc-text-primary);
}
.tab.active:hover {
background: rgba(76, 175, 80, 0.2);
border-color: var(--kpc-green);
}
.tab-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tab-spinner {
width: 10px;
height: 10px;
border: 1.5px solid var(--kpc-border-medium);
border-top-color: var(--kpc-green);
border-radius: 50%;
animation: spin 0.8s linear infinite;
flex-shrink: 0;
display: none;
}
.tab.loading .tab-spinner { display: block; }
@keyframes spin { to { transform: rotate(360deg); } }
.tab-close {
flex-shrink: 0;
width: 16px;
height: 16px;
line-height: 15px;
text-align: center;
border-radius: 3px;
font-size: 13px;
color: var(--kpc-text-dim);
transition: background 0.1s, color 0.1s;
}
.tab-close:hover {
background: var(--kpc-toggle-off);
color: #fff;
}
/* ── New Tab button ── */
#newTabBtn {
width: 28px;
height: 28px;
justify-content: center;
font-size: 16px;
font-weight: 400;
color: var(--kpc-text-faint);
padding: 0;
border-style: dashed;
}
#newTabBtn:hover {
color: var(--kpc-text-primary);
}
</style>
</head>
<body>
<button id="gameBtn" class="bar-pill">Game</button>
<div id="tabStrip"></div>
<button id="newTabBtn" class="bar-pill" title="New Tab (Ctrl+T)">+</button>
<script>
const { ipcRenderer } = require('electron');
const strip = document.getElementById('tabStrip');
document.getElementById('gameBtn').addEventListener('click', () => {
ipcRenderer.send('tab-back-to-game');
});
document.getElementById('newTabBtn').addEventListener('click', () => {
ipcRenderer.send('tab-new');
});
/* ── Drag state ── */
let dragId = null;
let dragStartX = 0;
let dragging = false;
const DRAG_THRESHOLD = 5;
function clearDropIndicators() {
strip.querySelectorAll('.drop-before,.drop-after').forEach(
el => el.classList.remove('drop-before', 'drop-after')
);
}
function getDropTarget(clientX) {
const tabs = Array.from(strip.querySelectorAll('.tab'));
for (const tab of tabs) {
if (Number(tab.dataset.id) === dragId) continue;
const r = tab.getBoundingClientRect();
const mid = r.left + r.width / 2;
if (clientX < mid) return { id: Number(tab.dataset.id), side: 'before', el: tab };
}
const last = tabs[tabs.length - 1];
if (last && Number(last.dataset.id) !== dragId) {
return { id: Number(last.dataset.id), side: 'after', el: last };
}
return null;
}
document.addEventListener('mousemove', (e) => {
if (dragId === null) return;
if (!dragging && Math.abs(e.clientX - dragStartX) >= DRAG_THRESHOLD) {
dragging = true;
const el = strip.querySelector('.tab[data-id="' + dragId + '"]');
if (el) el.classList.add('dragging');
}
if (!dragging) return;
clearDropIndicators();
const target = getDropTarget(e.clientX);
if (target) target.el.classList.add(target.side === 'before' ? 'drop-before' : 'drop-after');
});
document.addEventListener('mouseup', (e) => {
if (dragId === null) return;
const wasDragging = dragging;
const srcId = dragId;
clearDropIndicators();
const dragEl = strip.querySelector('.tab.dragging');
if (dragEl) dragEl.classList.remove('dragging');
dragId = null;
dragging = false;
if (wasDragging) {
const target = getDropTarget(e.clientX);
if (target) {
ipcRenderer.send('tab-reorder', srcId, target.id, target.side);
}
}
});
ipcRenderer.on('tabs-update', (_e, tabs) => {
strip.innerHTML = '';
for (const t of tabs) {
const el = document.createElement('div');
el.className = 'bar-pill tab' + (t.active ? ' active' : '') + (t.loading ? ' loading' : '');
el.dataset.id = String(t.id);
const spinner = document.createElement('div');
spinner.className = 'tab-spinner';
el.appendChild(spinner);
const title = document.createElement('span');
title.className = 'tab-title';
title.textContent = t.title || 'Loading...';
title.title = t.title || '';
el.appendChild(title);
const close = document.createElement('span');
close.className = 'tab-close';
close.textContent = '\\u00d7';
close.addEventListener('click', (ev) => {
ev.stopPropagation();
ipcRenderer.send('tab-close', t.id);
});
el.appendChild(close);
el.addEventListener('mousedown', (ev) => {
if (ev.target.classList.contains('tab-close')) return;
dragId = t.id;
dragStartX = ev.clientX;
dragging = false;
});
el.addEventListener('click', () => {
if (!dragging) ipcRenderer.send('tab-switch', t.id);
});
strip.appendChild(el);
}
const activeEl = strip.querySelector('.tab.active');
if (activeEl) activeEl.scrollIntoView({ inline: 'nearest', block: 'nearest' });
});
</script>
</body>
</html>`;
export const TAB_BAR_DATA_URL = 'data:text/html;charset=utf-8,' + encodeURIComponent(TAB_BAR_HTML);
+666
View File
@@ -0,0 +1,666 @@
import { BrowserWindow, WebContentsView, View, Menu, clipboard, ipcMain, shell } from 'electron';
import { TAB_BAR_DATA_URL } from './tab-bar-html';
import { ALL_CLIENT_CSS } from './client-ui';
import { electronLog } from './logger';
const KRUNKER_SOCIAL = 'https://krunker.io/social.html';
const TAB_BAR_HEIGHT = 40;
const MAX_TABS = 20;
interface TabInfo {
id: number;
view: WebContentsView;
title: string;
url: string;
loading: boolean;
}
interface TabWindowState {
width: number;
height: number;
x: number | undefined;
y: number | undefined;
maximized: boolean;
}
type TabMode = 'same' | 'new';
export class TabManager {
private tabs: TabInfo[] = [];
private activeTabId: number | null = null;
private tabBarView: WebContentsView;
private containerView: View;
private tabWindow: BrowserWindow | null = null;
private visible = false;
private nextId = 1;
private mode: TabMode;
private mainWin: BrowserWindow;
private ses: Electron.Session;
private preloadPath: string;
private isGameURL: (url: string) => boolean;
private titlePolls = new Map<number, ReturnType<typeof setInterval>>();
private recentlyClosed: { url: string; title: string }[] = [];
private getTabWindowState: () => TabWindowState;
private saveTabWindowState: (state: TabWindowState) => void;
private tabSaveTimer: ReturnType<typeof setTimeout> | null = null;
constructor(
win: BrowserWindow,
ses: Electron.Session,
preloadPath: string,
mode: TabMode,
isGameURL: (url: string) => boolean,
getTabWindowState: () => TabWindowState,
saveTabWindowState: (state: TabWindowState) => void,
) {
this.mainWin = win;
this.ses = ses;
this.preloadPath = preloadPath;
this.mode = mode;
this.isGameURL = isGameURL;
this.getTabWindowState = getTabWindowState;
this.saveTabWindowState = saveTabWindowState;
// ── Tab bar view (shared between both modes) ──
this.tabBarView = new WebContentsView({
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
sandbox: false,
},
});
this.tabBarView.webContents.loadURL(TAB_BAR_DATA_URL);
// ── Container view (holds tab bar + active tab content) ──
this.containerView = new View();
this.containerView.addChildView(this.tabBarView);
// Tab bar keybinds (when tab bar itself is focused)
this.tabBarView.webContents.on('before-input-event', (event, input) => {
if (input.type !== 'keyDown') return;
if (this.handleTabShortcut(event, input)) return;
});
if (mode === 'same') {
this.initSameWindowMode();
}
// 'new' mode: tabWindow created lazily on first openTab()
this.registerIPC();
}
// ── Same Window Mode Setup ──
private initSameWindowMode(): void {
this.mainWin.contentView.addChildView(this.containerView);
this.containerView.setVisible(false);
this.visible = false;
this.mainWin.on('resize', () => this.updateLayout());
}
// ── New Window Mode: create/show the tab window ──
private ensureTabWindow(): void {
if (this.tabWindow && !this.tabWindow.isDestroyed()) return;
const saved = this.getTabWindowState();
this.tabWindow = new BrowserWindow({
width: saved.width,
height: saved.height,
x: saved.x,
y: saved.y,
frame: true,
backgroundColor: '#000000',
autoHideMenuBar: true,
title: 'KCC - Tabs',
show: false,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
},
});
this.tabWindow.removeMenu();
if (saved.maximized) this.tabWindow.maximize();
this.tabWindow.contentView.addChildView(this.containerView);
this.containerView.setVisible(true);
this.tabWindow.on('resize', () => {
this.updateLayout();
this.debounceSaveTabWindow();
});
this.tabWindow.on('move', () => this.debounceSaveTabWindow());
this.tabWindow.on('close', () => {
// Flush pending save before the window is destroyed
if (this.tabSaveTimer) clearTimeout(this.tabSaveTimer);
if (this.tabWindow && !this.tabWindow.isDestroyed()) {
const bounds = this.tabWindow.getBounds();
this.saveTabWindowState({
width: bounds.width,
height: bounds.height,
x: bounds.x,
y: bounds.y,
maximized: this.tabWindow.isMaximized(),
});
}
});
this.tabWindow.on('closed', () => {
this.destroyAllTabs();
this.tabWindow = null;
});
this.tabWindow.show();
}
private debounceSaveTabWindow(): void {
if (this.tabSaveTimer) clearTimeout(this.tabSaveTimer);
this.tabSaveTimer = setTimeout(() => {
if (!this.tabWindow || this.tabWindow.isDestroyed()) return;
const bounds = this.tabWindow.getBounds();
this.saveTabWindowState({
width: bounds.width,
height: bounds.height,
x: bounds.x,
y: bounds.y,
maximized: this.tabWindow.isMaximized(),
});
}, 1000);
}
// ── IPC from tab bar ──
private registerIPC(): void {
ipcMain.on('tab-switch', (_e, id: number) => this.switchToTab(id));
ipcMain.on('tab-close', (_e, id: number) => this.closeTab(id));
ipcMain.on('tab-new', () => this.openTab(KRUNKER_SOCIAL));
ipcMain.on('tab-reorder', (_e, fromId: number, toId: number, side: string) => {
this.reorderTab(fromId, toId, side as 'before' | 'after');
});
ipcMain.on('tab-back-to-game', () => {
if (this.mode === 'same') {
this.hideTabs();
} else {
this.mainWin.focus();
}
});
}
// ── Open a new tab ──
openTab(url: string): number {
if (this.tabs.length >= MAX_TABS) {
const existing = this.tabs.find(t => t.url === url);
if (existing) {
this.switchToTab(existing.id);
return existing.id;
}
electronLog.warn('[KCC-Tabs] Tab limit reached, ignoring openTab');
return -1;
}
const id = this.nextId++;
const view = this.createTabView(id);
const tab: TabInfo = { id, view, title: this.titleFromUrl(url), url, loading: true };
this.tabs.push(tab);
if (this.mode === 'new') {
this.ensureTabWindow();
}
this.switchToTab(id);
this.showTabs();
view.webContents.loadURL(url);
return id;
}
// ── Create a WebContentsView for a tab ──
private createTabView(tabId: number): WebContentsView {
const view = new WebContentsView({
webPreferences: {
preload: this.preloadPath,
session: this.ses,
contextIsolation: false,
nodeIntegration: false,
sandbox: false,
spellcheck: false,
},
});
const wc = view.webContents;
wc.on('did-finish-load', () => {
wc.insertCSS(ALL_CLIENT_CSS).catch(() => {});
wc.send('main_did-finish-load-tab');
ipcMain.emit('throttle-state', { sender: wc } as any, 'menu');
this.updateTabInfo(tabId, { loading: false });
this.startTitleWatcher(tabId, wc);
});
wc.on('did-start-loading', () => {
this.updateTabInfo(tabId, { loading: true });
});
wc.on('did-stop-loading', () => {
this.updateTabInfo(tabId, { loading: false });
});
wc.on('page-title-updated', (_e, title) => {
if (this.isGenericTitle(title)) return;
this.updateTabInfo(tabId, { title });
});
wc.on('did-navigate', (_e, url) => {
this.updateTabInfo(tabId, { url, title: this.titleFromUrl(url) });
});
wc.setWindowOpenHandler(({ url: linkUrl }) => {
if (linkUrl.includes('krunker.io')) {
if (this.isGameURL(linkUrl)) {
this.mainWin.loadURL(linkUrl);
if (this.mode === 'same') this.hideTabs();
else this.mainWin.focus();
} else {
setImmediate(() => this.openTab(linkUrl));
}
} else {
setImmediate(() => shell.openExternal(linkUrl));
}
return { action: 'deny' as const };
});
wc.on('will-navigate', (event, navUrl) => {
if (navUrl.includes('krunker.io') && this.isGameURL(navUrl)) {
event.preventDefault();
this.mainWin.loadURL(navUrl);
if (this.mode === 'same') this.hideTabs();
else this.mainWin.focus();
}
});
wc.on('context-menu', (_e, params) => {
if (!params.linkURL) return;
const items: Electron.MenuItemConstructorOptions[] = [];
if (params.linkURL.includes('krunker.io') && !this.isGameURL(params.linkURL)) {
items.push({ label: 'Open in New Tab', click: () => this.openTab(params.linkURL) });
}
items.push({ label: 'Copy Link', click: () => clipboard.writeText(params.linkURL) });
if (!params.linkURL.includes('krunker.io')) {
items.push({ label: 'Open in Browser', click: () => shell.openExternal(params.linkURL) });
}
if (items.length) Menu.buildFromTemplate(items).popup();
});
wc.on('before-input-event', (event, input) => {
if (input.type !== 'keyDown') return;
if (this.handleTabShortcut(event, input)) return;
if (input.key === 'F12' && !input.control && !input.shift && !input.alt) {
wc.toggleDevTools();
event.preventDefault();
}
});
return view;
}
// ── Switch active tab ──
switchToTab(id: number): void {
const tab = this.tabs.find(t => t.id === id);
if (!tab) return;
if (this.activeTabId !== null) {
const prev = this.tabs.find(t => t.id === this.activeTabId);
if (prev) {
this.containerView.removeChildView(prev.view);
}
}
this.activeTabId = id;
this.containerView.addChildView(tab.view);
this.updateLayout();
this.broadcastTabState();
}
// ── Close a tab ──
closeTab(id: number): void {
const idx = this.tabs.findIndex(t => t.id === id);
if (idx === -1) return;
const tab = this.tabs[idx];
if (this.activeTabId === id) {
this.containerView.removeChildView(tab.view);
this.activeTabId = null;
}
this.recentlyClosed.push({ url: tab.url, title: tab.title });
if (this.recentlyClosed.length > 10) this.recentlyClosed.shift();
this.stopTitleWatcher(id);
tab.view.webContents.close();
this.tabs.splice(idx, 1);
if (this.tabs.length > 0) {
const nextIdx = Math.min(idx, this.tabs.length - 1);
this.switchToTab(this.tabs[nextIdx].id);
} else {
if (this.mode === 'same') {
this.hideTabs();
} else {
if (this.tabWindow && !this.tabWindow.isDestroyed()) {
this.tabWindow.contentView.removeChildView(this.containerView);
this.tabWindow.close();
}
}
}
this.broadcastTabState();
}
// ── Show / hide tabs ──
showTabs(): void {
if (this.mode === 'same') {
this.containerView.setVisible(true);
this.visible = true;
this.updateLayout();
} else {
this.ensureTabWindow();
if (this.tabWindow && !this.tabWindow.isDestroyed()) {
this.tabWindow.show();
this.tabWindow.focus();
}
this.visible = true;
}
}
hideTabs(): void {
if (this.mode === 'same') {
this.containerView.setVisible(false);
this.visible = false;
this.mainWin.focus();
} else {
this.mainWin.focus();
this.visible = false;
}
}
// ── Tab navigation ──
nextTab(): void {
if (this.tabs.length < 2 || this.activeTabId === null) return;
const idx = this.tabs.findIndex(t => t.id === this.activeTabId);
const next = (idx + 1) % this.tabs.length;
this.switchToTab(this.tabs[next].id);
}
prevTab(): void {
if (this.tabs.length < 2 || this.activeTabId === null) return;
const idx = this.tabs.findIndex(t => t.id === this.activeTabId);
const prev = (idx - 1 + this.tabs.length) % this.tabs.length;
this.switchToTab(this.tabs[prev].id);
}
closeCurrentTab(): void {
if (this.activeTabId !== null) this.closeTab(this.activeTabId);
}
// ── Reorder tabs via drag ──
reorderTab(fromId: number, toId: number, side: 'before' | 'after'): void {
const fromIdx = this.tabs.findIndex(t => t.id === fromId);
const toIdx = this.tabs.findIndex(t => t.id === toId);
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return;
const [tab] = this.tabs.splice(fromIdx, 1);
let insertIdx = this.tabs.findIndex(t => t.id === toId);
if (side === 'after') insertIdx++;
this.tabs.splice(insertIdx, 0, tab);
this.broadcastTabState();
}
// ── Jump to tab by position (0-based, -1 = last) ──
switchToTabByIndex(index: number): void {
if (this.tabs.length === 0) return;
if (index < 0 || index >= this.tabs.length) index = this.tabs.length - 1;
this.switchToTab(this.tabs[index].id);
}
// ── Reopen last closed tab ──
reopenTab(): void {
const entry = this.recentlyClosed.pop();
if (entry) this.openTab(entry.url);
}
// ── Shared shortcut handler (returns true if handled) ──
private handleTabShortcut(event: Electron.Event, input: Electron.Input): boolean {
if (input.key === 'Escape' && !input.control && !input.shift && !input.alt) {
if (this.mode === 'same') this.hideTabs();
else this.mainWin.focus();
event.preventDefault();
return true;
} else if (input.key === 'w' && input.control && !input.shift && !input.alt) {
this.closeCurrentTab();
event.preventDefault();
return true;
} else if (input.key === 'Tab' && input.control && !input.shift && !input.alt) {
this.nextTab();
event.preventDefault();
return true;
} else if (input.key === 'Tab' && input.control && input.shift && !input.alt) {
this.prevTab();
event.preventDefault();
return true;
} else if (input.key === 't' && input.control && !input.shift && !input.alt) {
this.openTab(KRUNKER_SOCIAL);
event.preventDefault();
return true;
} else if (input.key === 'T' && input.control && input.shift && !input.alt) {
this.reopenTab();
event.preventDefault();
return true;
} else if (input.key >= '1' && input.key <= '8' && input.control && !input.shift && !input.alt) {
this.switchToTabByIndex(parseInt(input.key) - 1);
event.preventDefault();
return true;
} else if (input.key === '9' && input.control && !input.shift && !input.alt) {
this.switchToTabByIndex(-1);
event.preventDefault();
return true;
}
return false;
}
// ── Cleanup ──
destroyAll(): void {
this.destroyAllTabs();
ipcMain.removeAllListeners('tab-switch');
ipcMain.removeAllListeners('tab-close');
ipcMain.removeAllListeners('tab-new');
ipcMain.removeAllListeners('tab-reorder');
ipcMain.removeAllListeners('tab-back-to-game');
if (this.tabWindow && !this.tabWindow.isDestroyed()) {
this.tabWindow.contentView.removeChildView(this.containerView);
this.tabWindow.close();
this.tabWindow = null;
}
if (this.mode === 'same') {
try { this.mainWin.contentView.removeChildView(this.containerView); } catch { /* may already be removed */ }
}
}
private destroyAllTabs(): void {
for (const tab of this.tabs) {
this.stopTitleWatcher(tab.id);
if (this.activeTabId === tab.id) {
this.containerView.removeChildView(tab.view);
}
if (!tab.view.webContents.isDestroyed()) {
tab.view.webContents.close();
}
}
this.tabs = [];
this.activeTabId = null;
this.broadcastTabState();
}
// ── Layout ──
private updateLayout(): void {
let bounds: { width: number; height: number };
if (this.mode === 'same') {
const [w, h] = this.mainWin.getContentSize();
bounds = { width: w, height: h };
this.containerView.setBounds({ x: 0, y: 0, width: w, height: h });
} else if (this.tabWindow && !this.tabWindow.isDestroyed()) {
const [w, h] = this.tabWindow.getContentSize();
bounds = { width: w, height: h };
this.containerView.setBounds({ x: 0, y: 0, width: w, height: h });
} else {
return;
}
this.tabBarView.setBounds({
x: 0, y: 0,
width: bounds.width,
height: TAB_BAR_HEIGHT,
});
if (this.activeTabId !== null) {
const tab = this.tabs.find(t => t.id === this.activeTabId);
if (tab) {
tab.view.setBounds({
x: 0,
y: TAB_BAR_HEIGHT,
width: bounds.width,
height: bounds.height - TAB_BAR_HEIGHT,
});
}
}
}
// ── Update tab metadata and broadcast ──
private updateTabInfo(id: number, updates: Partial<Pick<TabInfo, 'title' | 'url' | 'loading'>>): void {
const tab = this.tabs.find(t => t.id === id);
if (!tab) return;
if (updates.title !== undefined) tab.title = updates.title;
if (updates.url !== undefined) tab.url = updates.url;
if (updates.loading !== undefined) tab.loading = updates.loading;
this.broadcastTabState();
}
private broadcastTabState(): void {
if (this.tabBarView.webContents.isDestroyed()) return;
const data = this.tabs.map(t => ({
id: t.id,
title: t.title,
active: t.id === this.activeTabId,
loading: t.loading,
}));
this.tabBarView.webContents.send('tabs-update', data);
}
private static readonly GENERIC_TITLES = new Set([
'krunker hub', 'krunker', 'krunker.io', '',
'hub', 'social', 'profile', 'new tab', 'loading...',
]);
private isGenericTitle(title: string): boolean {
return TabManager.GENERIC_TITLES.has(title.toLowerCase().trim());
}
// ── Persistent URL watcher + DOM title extraction ──
private startTitleWatcher(tabId: number, wc: Electron.WebContents): void {
const existing = this.titlePolls.get(tabId);
if (existing) clearInterval(existing);
let lastUrl = '';
let lastDom = '';
const poll = setInterval(() => {
if (wc.isDestroyed()) {
clearInterval(poll);
this.titlePolls.delete(tabId);
return;
}
wc.executeJavaScript(
`(function() {
var url = window.location.href;
var title = '';
var ph = document.getElementById('profileHolder');
if (ph && ph.style.display === 'block') {
var ns = document.getElementById('nameSwitch');
if (ns && ns.innerText) title = ns.innerText;
}
return JSON.stringify({ url: url, dom: title });
})()`
).then((json: string) => {
const { url, dom } = JSON.parse(json);
if (url === lastUrl && dom === lastDom) return;
lastUrl = url;
lastDom = dom;
const tab = this.tabs.find(t => t.id === tabId);
if (!tab) return;
if (dom) {
if (tab.title !== dom) {
this.updateTabInfo(tabId, { url, title: dom });
} else if (tab.url !== url) {
this.updateTabInfo(tabId, { url });
}
return;
}
if (tab.url !== url) {
this.updateTabInfo(tabId, { url, title: this.titleFromUrl(url) });
}
}).catch(() => {});
}, 1000);
this.titlePolls.set(tabId, poll);
}
private stopTitleWatcher(tabId: number): void {
const poll = this.titlePolls.get(tabId);
if (poll) {
clearInterval(poll);
this.titlePolls.delete(tabId);
}
}
// ── Extract a display title from URL ──
private titleFromUrl(url: string): string {
try {
const parsed = new URL(url);
const p = parsed.searchParams.get('p');
const q = parsed.searchParams.get('q');
if (q) return q;
if (p) {
const pageMap: Record<string, string> = {
profile: 'Profile',
leaders: 'Leaderboard',
games: 'Games',
clans: 'Clans',
skins: 'Skins',
mods: 'Mods',
maps: 'Maps',
editor: 'Editor',
market: 'Market',
itemsales: 'Market Item',
inventory: 'Inventory',
settings: 'Settings',
feed: 'Hub',
};
return pageMap[p] || p.charAt(0).toUpperCase() + p.slice(1);
}
const path = parsed.pathname.replace(/\.html$/, '').replace(/^\//, '');
if (path === 'social') return 'Hub';
if (path) return path.charAt(0).toUpperCase() + path.slice(1);
return 'New Tab';
} catch {
return 'New Tab';
}
}
}
+96
View File
@@ -0,0 +1,96 @@
import { BrowserWindow } from 'electron';
const UPDATE_HTML = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #1a1a2e;
color: #e0e0e0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
padding: 20px;
}
h2 {
font-size: 16px;
font-weight: 600;
margin-bottom: 16px;
color: #fff;
}
#status {
font-size: 13px;
margin-bottom: 12px;
color: #ccc;
text-align: center;
}
.progress-container {
width: 100%;
height: 8px;
background: #16213e;
border-radius: 4px;
overflow: hidden;
}
.progress-bar {
height: 100%;
width: 0%;
background: #0f3460;
border-radius: 4px;
transition: width 0.3s ease;
}
</style>
</head>
<body>
<h2>Krunker Civilian Client</h2>
<div id="status">Checking for updates...</div>
<div class="progress-container">
<div class="progress-bar" id="progressBar"></div>
</div>
<script>
const { ipcRenderer } = require('electron');
const statusEl = document.getElementById('status');
const progressBar = document.getElementById('progressBar');
ipcRenderer.on('update-progress', function(event, message, percent) {
statusEl.textContent = message;
if (typeof percent === 'number') {
progressBar.style.width = percent + '%';
}
});
</script>
</body>
</html>`;
const UPDATE_DATA_URL = 'data:text/html;charset=utf-8,' + encodeURIComponent(UPDATE_HTML);
export function showUpdateWindow(): { window: BrowserWindow; sendProgress: (message: string, percent?: number) => void } {
const win = new BrowserWindow({
width: 450,
height: 180,
resizable: false,
alwaysOnTop: true,
backgroundColor: '#1a1a2e',
autoHideMenuBar: true,
title: 'Krunker Civilian Client - Update',
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
sandbox: false,
},
});
win.removeMenu();
win.loadURL(UPDATE_DATA_URL);
function sendProgress(message: string, percent?: number): void {
if (!win.isDestroyed()) {
win.webContents.send('update-progress', message, percent);
}
}
return { window: win, sendProgress };
}
+245
View File
@@ -0,0 +1,245 @@
import { get as httpsGet } from 'https';
import { createWriteStream, renameSync, unlinkSync, existsSync } from 'fs';
import { spawn } from 'child_process';
import { app } from 'electron';
import { electronLog } from './logger';
export interface UpdateInfo {
version: string;
downloadUrl: string;
fileSize: number;
}
export type ProgressCallback = (percent: number) => void;
const UPDATE_CONFIG = {
// Gitea provider
checkUrl: 'https://gitea.crjlab.net/api/v1/repos/bigjakk/Krunker-Civilian-Client/releases/latest',
assetPattern: /Setup\.exe$/i,
// Allowed hosts for update check and download (including redirects)
allowedHosts: ['gitea.crjlab.net'],
};
const CHECK_TIMEOUT_MS = 10000;
const DOWNLOAD_TIMEOUT_MS = 300000; // 5 minutes
/**
* Validate that a redirect URL stays on an allowed host.
*/
function isAllowedRedirect(url: string): boolean {
try {
const parsed = new URL(url);
return UPDATE_CONFIG.allowedHosts.some(h => parsed.hostname === h || parsed.hostname.endsWith('.' + h));
} catch {
return false;
}
}
/**
* Simple semver comparison: returns true if a < b.
* Handles versions like "0.1.0", "1.2.3".
*/
function versionLessThan(a: string, b: string): boolean {
const pa = a.split('.').map(Number);
const pb = b.split('.').map(Number);
const len = Math.max(pa.length, pb.length);
for (let i = 0; i < len; i++) {
const na = pa[i] || 0;
const nb = pb[i] || 0;
if (na < nb) return true;
if (na > nb) return false;
}
return false;
}
export function checkForUpdate(currentVersion: string): Promise<UpdateInfo | null> {
return new Promise((resolve) => {
electronLog.log('[KCC-Update] Checking for updates at:', UPDATE_CONFIG.checkUrl);
electronLog.log('[KCC-Update] Current version:', currentVersion);
const req = httpsGet(UPDATE_CONFIG.checkUrl, {
headers: { 'User-Agent': 'KrunkerCivilianClient/' + currentVersion },
}, (res) => {
electronLog.log('[KCC-Update] Check response status:', res.statusCode);
// Follow redirects (with domain validation)
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
const redirectUrl = res.headers.location;
electronLog.log('[KCC-Update] Redirected to:', redirectUrl);
if (!isAllowedRedirect(redirectUrl)) {
electronLog.error('[KCC-Update] Redirect to untrusted host blocked:', redirectUrl);
resolve(null);
return;
}
httpsGet(redirectUrl, {
headers: { 'User-Agent': 'KrunkerCivilianClient/' + currentVersion },
}, (redirectRes) => {
electronLog.log('[KCC-Update] Redirect response status:', redirectRes.statusCode);
handleResponse(redirectRes);
}).on('error', (err) => {
electronLog.error('[KCC-Update] Redirect error:', err);
resolve(null);
});
return;
}
handleResponse(res);
});
function handleResponse(res: import('http').IncomingMessage): void {
if (res.statusCode !== 200) {
electronLog.error('[KCC-Update] Check returned status', res.statusCode);
resolve(null);
return;
}
let data = '';
res.on('data', (chunk: string) => { data += chunk; });
res.on('end', () => {
try {
const release = JSON.parse(data);
const tagName: string = release.tag_name || '';
const remoteVersion = tagName.replace(/^v/i, '');
electronLog.log('[KCC-Update] Latest release:', remoteVersion, '| Current:', currentVersion);
if (!remoteVersion || !versionLessThan(currentVersion, remoteVersion)) {
electronLog.log('[KCC-Update] Already up to date');
resolve(null);
return;
}
const assets: Array<{ name: string; browser_download_url: string; size: number }> = release.assets || [];
const setupAsset = assets.find((a) => UPDATE_CONFIG.assetPattern.test(a.name));
if (!setupAsset) {
electronLog.error('[KCC-Update] No Setup.exe asset found in release', remoteVersion);
resolve(null);
return;
}
// Validate the download URL points to an allowed host
if (!isAllowedRedirect(setupAsset.browser_download_url)) {
electronLog.error('[KCC-Update] Download URL points to untrusted host:', setupAsset.browser_download_url);
resolve(null);
return;
}
electronLog.log('[KCC-Update] Update available:', remoteVersion, '| Download:', setupAsset.browser_download_url, '| Size:', setupAsset.size);
resolve({
version: remoteVersion,
downloadUrl: setupAsset.browser_download_url,
fileSize: setupAsset.size,
});
} catch (err) {
electronLog.error('[KCC-Update] Failed to parse release data:', err);
resolve(null);
}
});
res.on('error', (err) => {
electronLog.error('[KCC-Update] Response error:', err);
resolve(null);
});
}
req.setTimeout(CHECK_TIMEOUT_MS, () => {
electronLog.error('[KCC-Update] Check timed out after', CHECK_TIMEOUT_MS, 'ms');
req.destroy();
resolve(null);
});
req.on('error', (err) => {
electronLog.error('[KCC-Update] Check error:', err);
resolve(null);
});
});
}
export function downloadUpdate(url: string, destPath: string, onProgress: ProgressCallback): Promise<void> {
return new Promise((resolve, reject) => {
const tmpPath = destPath + '.tmp';
function doDownload(downloadUrl: string, redirectCount = 0): void {
if (redirectCount > 5) {
reject(new Error('Too many redirects'));
return;
}
electronLog.log('[KCC-Update] Downloading from:', downloadUrl);
const req = httpsGet(downloadUrl, {
headers: { 'User-Agent': 'KrunkerCivilianClient' },
}, (res) => {
// Follow redirects (with domain validation)
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
const redirectUrl = res.headers.location;
electronLog.log('[KCC-Update] Download redirected to:', redirectUrl);
if (!isAllowedRedirect(redirectUrl)) {
electronLog.error('[KCC-Update] Download redirect to untrusted host blocked:', redirectUrl);
reject(new Error('Download redirect to untrusted host: ' + redirectUrl));
return;
}
doDownload(redirectUrl, redirectCount + 1);
return;
}
if (res.statusCode !== 200) {
electronLog.error('[KCC-Update] Download returned status', res.statusCode, 'from:', downloadUrl);
reject(new Error('Download returned status ' + res.statusCode));
return;
}
const total = parseInt(res.headers['content-length'] || '0', 10);
let received = 0;
const file = createWriteStream(tmpPath);
res.on('data', (chunk: Buffer) => {
received += chunk.length;
if (total > 0) {
onProgress(Math.round(100 * received / total));
}
});
res.pipe(file);
file.on('finish', () => {
file.close(() => {
try {
if (existsSync(destPath)) unlinkSync(destPath);
renameSync(tmpPath, destPath);
resolve();
} catch (err) {
reject(err);
}
});
});
file.on('error', (err) => {
try { unlinkSync(tmpPath); } catch { /* ignore */ }
reject(err);
});
res.on('error', (err) => {
try { unlinkSync(tmpPath); } catch { /* ignore */ }
reject(err);
});
});
req.setTimeout(DOWNLOAD_TIMEOUT_MS, () => {
req.destroy();
try { unlinkSync(tmpPath); } catch { /* ignore */ }
reject(new Error('Download timed out'));
});
req.on('error', (err) => {
try { unlinkSync(tmpPath); } catch { /* ignore */ }
reject(err);
});
}
doDownload(url);
});
}
export function installUpdate(installerPath: string): void {
electronLog.log('[KCC-Update] Launching installer:', installerPath);
const child = spawn(installerPath, [], {
detached: true,
stdio: 'ignore',
});
child.unref();
app.quit();
}
+99
View File
@@ -0,0 +1,99 @@
import { mkdirSync, promises as fsp } from 'fs';
import { join, parse } from 'path';
export interface ScriptFile {
filename: string;
content: string;
fullpath: string;
}
export type ScriptTracker = Record<string, boolean>;
/**
* Manages userscript files, tracker state, and per-script preferences.
* Scripts live in a `scripts/` subdirectory; tracker.json records enabled/disabled state;
* per-script preferences are stored in `scripts/preferences/<name>.json`.
*/
export class UserscriptManager {
private scriptsDir: string;
private prefsDir: string;
private trackerPath: string;
constructor(baseDir: string) {
this.scriptsDir = join(baseDir, 'scripts');
this.prefsDir = join(this.scriptsDir, 'preferences');
this.trackerPath = join(this.scriptsDir, 'tracker.json');
mkdirSync(this.scriptsDir, { recursive: true });
mkdirSync(this.prefsDir, { recursive: true });
}
get dir(): string {
return this.scriptsDir;
}
/** Read all .js files from the scripts directory */
async scanScripts(): Promise<ScriptFile[]> {
const scripts: ScriptFile[] = [];
try {
for (const entry of await fsp.readdir(this.scriptsDir, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith('.js')) continue;
const fullpath = join(this.scriptsDir, entry.name);
try {
const content = await fsp.readFile(fullpath, 'utf-8');
scripts.push({ filename: entry.name, content, fullpath });
} catch { /* skip unreadable files */ }
}
} catch { /* directory read failed */ }
return scripts;
}
/** Load tracker.json, add new scripts as disabled, prune deleted scripts */
async loadTracker(scripts: ScriptFile[]): Promise<ScriptTracker> {
let tracker: ScriptTracker;
try {
tracker = JSON.parse(await fsp.readFile(this.trackerPath, 'utf-8'));
} catch { tracker = {}; }
const filenames = new Set(scripts.map(s => s.filename));
let dirty = false;
// Add new scripts as disabled
for (const name of filenames) {
if (!(name in tracker)) { tracker[name] = false; dirty = true; }
}
// Prune deleted scripts
for (const name of Object.keys(tracker)) {
if (!filenames.has(name)) { delete tracker[name]; dirty = true; }
}
if (dirty) await this.saveTracker(tracker);
return tracker;
}
/** Write tracker.json */
async saveTracker(tracker: ScriptTracker): Promise<void> {
try {
await fsp.writeFile(this.trackerPath, JSON.stringify(tracker, null, 2), 'utf-8');
} catch { /* write failed */ }
}
/** Load per-script preferences from preferences/<name>.json */
async loadScriptPrefs(filename: string): Promise<Record<string, unknown>> {
const name = parse(filename).name;
const prefsPath = join(this.prefsDir, name + '.json');
try {
return JSON.parse(await fsp.readFile(prefsPath, 'utf-8'));
} catch { /* parse failed or file not found */ }
return {};
}
/** Save per-script preferences to preferences/<name>.json */
async saveScriptPrefs(filename: string, prefs: Record<string, unknown>): Promise<void> {
const name = parse(filename).name;
const prefsPath = join(this.prefsDir, name + '.json');
try {
await fsp.writeFile(prefsPath, JSON.stringify(prefs, null, 2), 'utf-8');
} catch { /* write failed */ }
}
}
+129
View File
@@ -0,0 +1,129 @@
// ── Changelog Popup ──
// Shows release notes in a Shadow DOM modal when the client version changes.
import { ipcRenderer } from 'electron';
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;
}
function renderMarkdown(md: string): string {
const html = md
.replace(/### (.+)/g, '<h3>$1</h3>')
.replace(/## (.+)/g, '<h2>$1</h2>')
.replace(/# (.+)/g, '<h1>$1</h1>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>');
// Convert list items
const lines = html.split('\n');
let inList = false;
const out: string[] = [];
for (const line of lines) {
if (line.trimStart().startsWith('- ')) {
if (!inList) { out.push('<ul>'); inList = true; }
out.push('<li>' + line.trimStart().slice(2) + '</li>');
} else {
if (inList) { out.push('</ul>'); inList = false; }
out.push(line);
}
}
if (inList) out.push('</ul>');
return out.join('\n').replace(/\n\n/g, '<br><br>').replace(/\n/g, '<br>');
}
function showChangelogPopup(version: string, body: string): void {
const host = document.createElement('div');
host.id = 'kpc-changelog-host';
const shadow = host.attachShadow({ mode: 'closed' });
const style = document.createElement('style');
style.textContent = `
.overlay {
position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
background: rgba(0,0,0,0.75); z-index: 99998;
display: flex; justify-content: center; align-items: center;
font-family: 'Segoe UI', sans-serif; color: #e0e0e0;
}
.modal {
background: #1a1a2e; border-radius: 12px; padding: 24px;
min-width: 400px; max-width: 600px; max-height: 70vh;
display: flex; flex-direction: column; box-shadow: 0 8px 32px rgba(0,0,0,0.5);
}
.header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 16px;
}
.header h2 { margin: 0; font-size: 1.4rem; color: #fff; }
.close-btn {
background: none; border: none; color: #888; font-size: 1.5rem;
cursor: pointer; padding: 4px 8px; border-radius: 4px;
}
.close-btn:hover { color: #fff; background: rgba(255,255,255,0.1); }
.body {
overflow-y: auto; flex: 1; line-height: 1.6;
}
.body h1 { font-size: 1.3rem; color: #fff; margin: 12px 0 6px; }
.body h2 { font-size: 1.15rem; color: #fff; margin: 10px 0 6px; }
.body h3 { font-size: 1rem; color: #ccc; margin: 8px 0 4px; }
.body ul { padding-left: 20px; margin: 6px 0; }
.body li { margin: 3px 0; }
.body a { color: #6ea8fe; }
.body strong { color: #fff; }
`;
const overlay = document.createElement('div');
overlay.className = 'overlay';
overlay.addEventListener('click', (e) => {
if (e.target === overlay) host.remove();
});
const modal = document.createElement('div');
modal.className = 'modal';
const header = document.createElement('div');
header.className = 'header';
header.innerHTML = `<h2>What's New in v${version}</h2>`;
const closeBtn = document.createElement('button');
closeBtn.className = 'close-btn';
closeBtn.textContent = '\u2715';
closeBtn.addEventListener('click', () => host.remove());
header.appendChild(closeBtn);
const bodyDiv = document.createElement('div');
bodyDiv.className = 'body';
bodyDiv.innerHTML = renderMarkdown(body);
modal.appendChild(header);
modal.appendChild(bodyDiv);
overlay.appendChild(modal);
shadow.appendChild(style);
shadow.appendChild(overlay);
document.body.appendChild(host);
}
export async function checkChangelog(currentVersion: string, lastSeenVersion: string): Promise<void> {
if (lastSeenVersion && !versionLessThan(lastSeenVersion, currentVersion)) return;
// Update lastSeenVersion regardless of whether we can fetch notes
ipcRenderer.invoke('set-config', 'ui', {
...await ipcRenderer.invoke('get-config', 'ui'),
lastSeenVersion: currentVersion,
});
try {
const body = await ipcRenderer.invoke('changelog-fetch', currentVersion);
if (body) showChangelogPopup(currentVersion, body);
} catch { /* fetch failed — skip silently */ }
}
+122
View File
@@ -0,0 +1,122 @@
// ── Better Chat + Chat History ──
// Merges team/all chat with [T]/[M] prefixes and prevents Krunker from pruning old messages.
import type { SavedConsole } from './utils';
const TEAM_MODES = new Set([
'Team Deathmatch', 'Hardpoint', 'Capture the Flag', 'Hide & Seek',
'Infected', 'Last Man Standing', 'Simon Says', 'Prop Hunt',
'Boss Hunt', 'Deposit', 'Stalker', 'Kill Confirmed',
'Defuse', 'Traitor', 'Blitz', 'Domination',
'Squad Deathmatch', 'Team Defender',
]);
let chatList: HTMLElement | null = null;
let observer: MutationObserver | null = null;
let historyMax = 0;
let betterChatEnabled = false;
let reInsertGuard = false;
let _con: SavedConsole | null = null;
function isChatMessage(node: Node): node is HTMLElement {
return node.nodeType === 1 && (node as HTMLElement).id?.startsWith('chatMsg_');
}
function isTeamMode(): boolean {
const modeEl = document.getElementById('gameModeLabel') || document.getElementById('subGameMode');
if (!modeEl) return false;
return TEAM_MODES.has(modeEl.textContent?.trim() || '');
}
function handleMutations(mutations: MutationRecord[]): void {
// ── Chat history: re-insert removed messages ──
if (historyMax > 0 && chatList && observer) {
const removed: HTMLElement[] = [];
for (const mut of mutations) {
if (reInsertGuard) break;
for (const node of mut.removedNodes) {
if (isChatMessage(node)) removed.push(node);
}
}
if (removed.length > 0) {
reInsertGuard = true;
observer.disconnect();
const firstLive = chatList.firstChild;
for (const node of removed) {
chatList.insertBefore(node, firstLive);
}
while (chatList.children.length > historyMax) {
chatList.removeChild(chatList.firstChild!);
}
observer.observe(chatList, { childList: true });
reInsertGuard = false;
}
}
// ── Better chat: tag new messages ──
if (betterChatEnabled) {
const teamMode = isTeamMode();
for (const mut of mutations) {
for (const node of mut.addedNodes) {
if (!isChatMessage(node)) continue;
const chatMsg = node.querySelector('.chatMsg');
if (!chatMsg) continue;
// Remove "Text & Voice Chat" system messages
if (chatMsg.textContent?.includes('Text & Voice Chat')) {
node.remove();
continue;
}
// Only tag in team modes with proper chat messages
if (!teamMode) continue;
if (!chatMsg.innerHTML.includes('\u202E:')) continue;
if (!node.dataset.tab) continue;
const isTeam = node.dataset.tab === '1';
const tag = document.createElement('div');
tag.style.cssText = 'float:left; margin-right:4px; font-weight:bold;';
tag.style.color = isTeam ? '#00FF00' : '#FF0000';
tag.textContent = isTeam ? '[T]' : '[M]';
chatMsg.insertBefore(tag, chatMsg.firstChild);
}
}
}
// Auto-scroll unless paused
if (chatList && !chatList.classList.contains('kpc-chat-paused')) {
chatList.scrollTop = chatList.scrollHeight;
}
}
function tryAttach(): boolean {
chatList = document.getElementById('chatList');
if (!chatList) return false;
observer = new MutationObserver(handleMutations);
observer.observe(chatList, { childList: true });
_con?.log('[KCC-Chat] Observer attached to #chatList');
return true;
}
export function initChat(options: { betterChat: boolean; chatHistorySize: number }, con?: SavedConsole): void {
_con = con ?? null;
betterChatEnabled = options.betterChat;
historyMax = options.chatHistorySize;
if (tryAttach()) return;
// Poll until #chatList appears
let attempts = 0;
const poll = setInterval(() => {
if (++attempts > 120 || tryAttach()) clearInterval(poll);
}, 500);
}
export function setBetterChat(enabled: boolean): void {
betterChatEnabled = enabled;
}
export function setChatHistorySize(size: number): void {
historyMax = size;
}
+68
View File
@@ -0,0 +1,68 @@
// ── Hardpoint Enemy Counter ──
// Displays enemy capture points being scored in Hardpoint mode.
let hpObserver: MutationObserver | null = null;
let hpCounterEl: HTMLElement | null = null;
let hpPointCounter: HTMLElement | null = null;
let hpEnemyOBJ = 0;
let hpTimeout: ReturnType<typeof setTimeout> | null = null;
let hpCheckInterval: ReturnType<typeof setInterval> | null = null;
function processTeamScores(): void {
const teams = document.querySelectorAll('#tScoreC1, #tScoreC2');
for (const team of teams) {
if (team.className.includes('you')) continue;
const scoreEl = team.nextElementSibling;
if (!scoreEl) continue;
const currentScore = parseInt(scoreEl.textContent || '0', 10);
if (currentScore > hpEnemyOBJ && hpPointCounter) {
hpPointCounter.textContent = String((currentScore - hpEnemyOBJ) / 10);
if (hpTimeout) clearTimeout(hpTimeout);
hpTimeout = setTimeout(() => {
if (hpPointCounter) hpPointCounter.textContent = '0';
hpTimeout = null;
}, 1600);
}
hpEnemyOBJ = currentScore;
}
}
function setupHPDisplay(): void {
const counters = document.querySelector('.topRightCounters');
if (!counters || hpCounterEl) return;
hpCounterEl = document.createElement('div');
hpCounterEl.className = 'statIcon kpc-hp-counter';
hpCounterEl.innerHTML =
'<div class="greyInner" style="display:flex">' +
'<span style="color:white;font-size:15px;margin-right:4px;">on</span>' +
'<span class="pointVal">0</span></div>';
hpPointCounter = hpCounterEl.querySelector('.pointVal');
counters.appendChild(hpCounterEl);
const teamScores = document.getElementById('teamScores');
if (teamScores) {
hpObserver = new MutationObserver(processTeamScores);
hpObserver.observe(teamScores, { childList: true, subtree: true });
}
}
export function initHPCounter(): void {
hpCheckInterval = setInterval(() => {
if (document.querySelector('.cmpTmHed')) {
if (hpCheckInterval) { clearInterval(hpCheckInterval); hpCheckInterval = null; }
setupHPDisplay();
}
}, 2000);
}
export function destroyHPCounter(): void {
if (hpCheckInterval) { clearInterval(hpCheckInterval); hpCheckInterval = null; }
if (hpObserver) { hpObserver.disconnect(); hpObserver = null; }
if (hpCounterEl) { hpCounterEl.remove(); hpCounterEl = null; }
if (hpTimeout) { clearTimeout(hpTimeout); hpTimeout = null; }
hpPointCounter = null;
hpEnemyOBJ = 0;
}
+1960
View File
File diff suppressed because it is too large Load Diff
+455
View File
@@ -0,0 +1,455 @@
// ── Custom Matchmaker (ported from Crankshaft) ──
// Fetches live lobby list from matchmaker.krunker.io, filters by user criteria,
// sorts by lowest ping then highest player count, and joins the best match.
// Shows a live lobby-cycling search popup while scanning.
import { ipcRenderer } from 'electron';
import type { Keybind } from '../main/config';
import type { SavedConsole } from './utils';
export const MATCHMAKER_GAMEMODES = ['Free for All', 'Team Deathmatch', 'Hardpoint', 'Capture the Flag', 'Parkour', 'Hide & Seek', 'Infected', 'Race', 'Last Man Standing', 'Simon Says', 'Gun Game', 'Prop Hunt', 'Boss Hunt', 'Classic FFA', 'Deposit', 'Stalker', 'King of the Hill', 'One in the Chamber', 'Trade', 'Kill Confirmed', 'Defuse', 'Sharp Shooter', 'Traitor', 'Raid', 'Blitz', 'Domination', 'Squad Deathmatch', 'Kranked FFA', 'Team Defender', 'Deposit FFA', 'Chaos Snipers', 'Bighead FFA'];
export const MATCHMAKER_REGIONS = ['MBI', 'NY', 'FRA', 'SIN', 'DAL', 'SYD', 'MIA', 'BHN', 'TOK', 'BRZ', 'AFR', 'LON', 'CHI', 'SV', 'STL', 'MX'];
export const MATCHMAKER_REGION_NAMES: Record<string, string> = { MBI: 'Mumbai', NY: 'New York', FRA: 'Frankfurt', SIN: 'Singapore', DAL: 'Dallas', SYD: 'Sydney', MIA: 'Miami', BHN: 'Middle East', TOK: 'Tokyo', BRZ: 'Brazil', AFR: 'South Africa', LON: 'London', CHI: 'China', SV: 'Silicon Valley', STL: 'Seattle', MX: 'Mexico' };
export const MAP_ICON_INDICES = ['Burg', 'Littletown', 'Sandstorm', 'Subzero', 'Undergrowth', 'Shipment', 'Freight', 'Lostworld', 'Citadel', 'Oasis', 'Kanji', 'Industry', 'Lumber', 'Evacuation', 'Site', 'SkyTemple', 'Lagoon', 'Bureau', 'Tortuga', 'Tropicano', 'Krunk_Plaza', 'Arena', 'Habitat', 'Atomic', 'Old_Burg', 'Throwback', 'Stockade', 'Facility', 'Clockwork', 'Laboratory', 'Shipyard', 'Soul Sanctum', 'Bazaar', 'Erupt', 'HQ', 'Khepri', 'Lush', 'Vivo', 'Slide Moonlight', 'Eterno Sim'];
export const MATCHMAKER_MAP_NAMES: Record<string, string> = {
SkyTemple: 'Sky Temple', Krunk_Plaza: 'Krunk Plaza', Old_Burg: 'Old Burg',
'Soul Sanctum': 'Soul Sanctum', 'Slide Moonlight': 'Slide Moonlight', 'Eterno Sim': 'Eterno Sim',
};
// ── Animation constants ──
const MAX_FEED_ENTRIES = 4;
const MAX_ANIMATION_MS = 2000;
const BASE_TICK_MS = 80;
const MIN_TICK_MS = 20;
const POST_SCAN_PAUSE_MS = 300;
const SCAN_FLASH_MS = 800;
interface MatchmakerGame {
gameID: string;
region: string;
playerCount: number;
playerLimit: number;
map: string;
gamemode: string;
remainingTime: number;
}
interface RawLobby extends MatchmakerGame {
passesFilter: boolean;
}
export interface MatchmakerConfig {
enabled: boolean;
regions: string[];
gamemodes: string[];
maps: string[];
minPlayers: number;
maxPlayers: number;
minRemainingTime: number;
openServerBrowser: boolean;
autoJoin: boolean;
acceptKey: Keybind;
cancelKey: Keybind;
}
function secondsToTimestring(num: number): string {
const minutes = Math.floor(num / 60);
const seconds = num % 60;
if (minutes < 1) return `${num}s`;
return `${minutes}m ${seconds}s`;
}
function matchesKey(bind: Keybind, event: KeyboardEvent): boolean {
if ((document.activeElement as HTMLElement)?.tagName === 'INPUT') return false;
return event.key === bind.key
&& event.shiftKey === bind.shift
&& event.altKey === bind.alt
&& event.ctrlKey === bind.ctrl;
}
// ── Popup DOM (created once, reused) ──
const POPUP_ID = 'matchmakerPopupContainer';
const popupElement = document.createElement('div');
popupElement.id = POPUP_ID;
// Result-phase elements
const popupTitle = document.createElement('div');
popupTitle.id = 'matchmakerPopupTitle';
popupElement.appendChild(popupTitle);
const popupDescription = document.createElement('div');
popupDescription.id = 'matchmakerPopupDescription';
popupElement.appendChild(popupDescription);
const popupOptions = document.createElement('div');
popupOptions.id = 'matchmakerPopupOptions';
const popupConfirmBtn = document.createElement('div');
popupConfirmBtn.id = 'matchmakerConfirmButton';
popupConfirmBtn.className = 'matchmakerPopupButton bigShadowT';
popupConfirmBtn.textContent = 'Join';
popupConfirmBtn.setAttribute('onmouseenter', 'playTick()');
popupConfirmBtn.addEventListener('click', () => decideMatchmakerDecision(true));
const popupCancelBtn = document.createElement('div');
popupCancelBtn.id = 'matchmakerCancelButton';
popupCancelBtn.className = 'matchmakerPopupButton bigShadowT';
popupCancelBtn.textContent = 'Cancel';
popupCancelBtn.setAttribute('onmouseenter', 'playTick()');
popupCancelBtn.addEventListener('click', () => decideMatchmakerDecision(false));
popupOptions.appendChild(popupConfirmBtn);
popupOptions.appendChild(popupCancelBtn);
popupElement.appendChild(popupOptions);
// Search-phase elements
const searchContainer = document.createElement('div');
searchContainer.id = 'matchmakerSearchContainer';
const searchStatus = document.createElement('div');
searchStatus.id = 'matchmakerSearchStatus';
searchContainer.appendChild(searchStatus);
const searchFeed = document.createElement('div');
searchFeed.id = 'matchmakerSearchFeed';
searchContainer.appendChild(searchFeed);
const searchCounter = document.createElement('div');
searchCounter.id = 'matchmakerSearchCounter';
searchContainer.appendChild(searchCounter);
const searchCancelBtn = document.createElement('div');
searchCancelBtn.id = 'matchmakerSearchCancel';
searchCancelBtn.textContent = 'Cancel';
searchCancelBtn.setAttribute('onmouseenter', 'playTick()');
searchCancelBtn.addEventListener('click', () => abortSearch());
searchContainer.appendChild(searchCancelBtn);
popupElement.appendChild(searchContainer);
// ── State ──
let popupGameID = '';
let popupCandidates: MatchmakerGame[] = [];
let openServerBrowser = true;
let confirmKey: Keybind = { key: 'Enter', ctrl: false, shift: false, alt: false };
let cancelKey: Keybind = { key: 'Escape', ctrl: false, shift: false, alt: false };
let searchAborted = false;
function abortSearch(): void {
searchAborted = true;
const w = window as any;
if (typeof w.playSelect === 'function') w.playSelect();
dismissPopup();
}
async function verifyAndJoin(gameID: string): Promise<void> {
try {
const resp = await fetch(`https://matchmaker.krunker.io/game-list?hostname=${window.location.hostname}`);
const result = await resp.json();
const liveMap = new Map<string, { players: number; limit: number }>();
for (const g of result.games) {
liveMap.set(g[0], { players: g[2], limit: g[3] });
}
const ordered = [gameID, ...popupCandidates.filter(c => c.gameID !== gameID).map(c => c.gameID)];
for (const id of ordered) {
const live = liveMap.get(id);
if (live && live.players < live.limit) {
dismissPopup();
window.location.href = `https://krunker.io/?game=${id}`;
return;
}
}
dismissPopup();
if (openServerBrowser && typeof (window as any).openServerWindow === 'function') {
(window as any).openServerWindow(0);
}
} catch {
dismissPopup();
window.location.href = `https://krunker.io/?game=${gameID}`;
}
}
function dismissPopup(): void {
document.removeEventListener('keydown', handleSearchBind, true);
document.removeEventListener('keydown', handleMatchmakerBind, true);
if (popupElement.parentNode) popupElement.remove();
popupElement.classList.remove('searching');
}
function decideMatchmakerDecision(accept: boolean): void {
const w = window as any;
if (typeof w.playSelect === 'function') w.playSelect();
if (accept && popupGameID !== 'none') {
verifyAndJoin(popupGameID);
} else {
dismissPopup();
if (popupGameID === 'none' && openServerBrowser && typeof w.openServerWindow === 'function') {
w.openServerWindow(0);
}
}
}
function handleSearchBind(event: KeyboardEvent): void {
if (document.pointerLockElement) return;
if (matchesKey(cancelKey, event)) {
event.preventDefault();
event.stopPropagation();
abortSearch();
}
}
function handleMatchmakerBind(event: KeyboardEvent): void {
if (document.pointerLockElement) return;
const isAccept = matchesKey(confirmKey, event);
const isCancel = matchesKey(cancelKey, event);
if (isAccept || isCancel) {
document.removeEventListener('keydown', handleMatchmakerBind, true);
decideMatchmakerDecision(isAccept);
}
}
function showResultPopup(game: MatchmakerGame): void {
popupElement.classList.remove('searching');
const mapIdx = MAP_ICON_INDICES.indexOf(game.map);
popupElement.style.backgroundImage = `url(https://assets.krunker.io/img/maps/map_${mapIdx >= 0 ? mapIdx : 0}.png)`;
popupGameID = game.gameID;
if (game.gameID === 'none') {
popupTitle.innerText = 'No Games Found...';
popupDescription.innerHTML = 'Check the server browser to see other lobbies.';
popupConfirmBtn.style.display = 'none';
} else {
popupTitle.innerText = 'Game Found!';
const regionName = MATCHMAKER_REGION_NAMES[game.region] ?? 'Unknown Region';
popupDescription.innerHTML = `${game.gamemode} on ${game.map} (${regionName})<br/>${game.playerCount}/${game.playerLimit} Players, ${secondsToTimestring(game.remainingTime)} Left`;
popupConfirmBtn.style.display = 'block';
}
// Re-trigger slide animation
popupElement.style.animation = 'none';
void popupElement.offsetWidth;
popupElement.style.animation = '';
document.removeEventListener('keydown', handleSearchBind, true);
document.addEventListener('keydown', handleMatchmakerBind, true);
}
function showSearchPopup(): void {
searchAborted = false;
popupElement.classList.add('searching');
popupElement.style.backgroundImage = 'none';
searchStatus.textContent = 'Connecting...';
searchFeed.innerHTML = '';
searchCounter.textContent = '';
document.removeEventListener('keydown', handleMatchmakerBind, true);
document.addEventListener('keydown', handleSearchBind, true);
const uiBase = document.getElementById('uiBase');
if (uiBase) uiBase.appendChild(popupElement);
}
function createFeedEntry(lobby: RawLobby): HTMLDivElement {
const entry = document.createElement('div');
entry.className = `mm-feed-entry ${lobby.passesFilter ? 'mm-pass' : 'mm-fail'}`;
const region = document.createElement('span');
region.className = 'mm-feed-region';
region.textContent = lobby.region;
const map = document.createElement('span');
map.className = 'mm-feed-map';
map.textContent = lobby.map;
const players = document.createElement('span');
players.className = 'mm-feed-players';
players.textContent = `${lobby.playerCount}/${lobby.playerLimit}`;
entry.appendChild(region);
entry.appendChild(map);
entry.appendChild(players);
return entry;
}
async function animateLobbyScan(lobbies: RawLobby[]): Promise<void> {
if (lobbies.length === 0) return;
searchStatus.textContent = 'Scanning lobbies...';
const total = lobbies.length;
const maxEntries = Math.floor(MAX_ANIMATION_MS / BASE_TICK_MS);
const step = total > maxEntries ? total / maxEntries : 1;
const tickMs = total > maxEntries ? BASE_TICK_MS : Math.max(MIN_TICK_MS, Math.min(BASE_TICK_MS, MAX_ANIMATION_MS / total));
for (let f = 0; f < total; f += step) {
if (searchAborted) return;
const i = Math.min(Math.floor(f), total - 1);
const entry = createFeedEntry(lobbies[i]);
searchFeed.appendChild(entry);
while (searchFeed.children.length > MAX_FEED_ENTRIES) {
searchFeed.removeChild(searchFeed.firstChild!);
}
searchCounter.textContent = `Checked: ${i + 1} / ${total} lobbies`;
await new Promise(r => setTimeout(r, tickMs));
}
searchCounter.textContent = `Checked: ${total} / ${total} lobbies`;
if (!searchAborted) {
await new Promise(r => setTimeout(r, POST_SCAN_PAUSE_MS));
}
}
async function fetchAllGames(mmConfig: MatchmakerConfig): Promise<{ all: RawLobby[]; filtered: MatchmakerGame[] }> {
const response = await fetch(`https://matchmaker.krunker.io/game-list?hostname=${window.location.hostname}`);
const result = await response.json();
const all: RawLobby[] = [];
const filtered: MatchmakerGame[] = [];
for (const game of result.games) {
const gameID: string = game[0];
const region = gameID.split(':')[0];
const playerCount: number = game[2];
const playerLimit: number = game[3];
const map: string = game[4].i;
const gamemode = MATCHMAKER_GAMEMODES[game[4].g] ?? 'Unknown Gamemode';
const remainingTime: number = game[5];
let passesFilter = true;
if (mmConfig.regions.length > 0 && !mmConfig.regions.includes(region)) passesFilter = false;
else if (mmConfig.gamemodes.length > 0 && !mmConfig.gamemodes.includes(gamemode)) passesFilter = false;
else if (mmConfig.maps.length > 0 && !mmConfig.maps.includes(map)) passesFilter = false;
else if (playerCount < mmConfig.minPlayers) passesFilter = false;
else if (playerCount > mmConfig.maxPlayers) passesFilter = false;
else if (remainingTime < mmConfig.minRemainingTime) passesFilter = false;
else if (playerCount === playerLimit) passesFilter = false;
else if (window.location.href.includes(gameID)) passesFilter = false;
const lobby = { gameID, region, playerCount, playerLimit, map, gamemode, remainingTime, passesFilter };
all.push(lobby);
if (passesFilter) filtered.push(lobby);
}
return { all, filtered };
}
function sortByPingThenPlayers(games: MatchmakerGame[], pings: Record<string, number>): MatchmakerGame[] {
return games.sort((a, b) => {
const pingA = pings[a.region] ?? 999;
const pingB = pings[b.region] ?? 999;
if (pingA !== pingB) return pingA - pingB;
return b.playerCount - a.playerCount;
});
}
export async function fetchGame(mmConfig: MatchmakerConfig, _con?: SavedConsole): Promise<void> {
openServerBrowser = mmConfig.openServerBrowser;
confirmKey = mmConfig.acceptKey;
cancelKey = mmConfig.cancelKey;
// Dismiss existing popup if active (also aborts in-flight search)
searchAborted = true;
dismissPopup();
// Phase 1: Show search popup immediately
showSearchPopup();
_con?.log('[KCC-MM] Fetching game list + pings...');
// Phase 2: Fetch data
let allLobbies: RawLobby[];
let filtered: MatchmakerGame[];
let pings: Record<string, number>;
try {
const [fetchResult, pingResult] = await Promise.all([
fetchAllGames(mmConfig),
ipcRenderer.invoke('ping-regions').catch(() => ({} as Record<string, number>)),
]);
allLobbies = fetchResult.all;
filtered = fetchResult.filtered;
pings = pingResult;
} catch {
if (!searchAborted) {
searchStatus.textContent = 'Failed to fetch lobbies';
await new Promise(r => setTimeout(r, 2000));
dismissPopup();
}
return;
}
if (searchAborted) return;
_con?.log('[KCC-MM]', filtered.length, '/', allLobbies.length, 'games passed filters');
// Sort immediately — result is ready
if (filtered.length > 0) sortByPingThenPlayers(filtered, pings);
popupCandidates = filtered;
// Fire animation in background (non-blocking eye candy)
animateLobbyScan(allLobbies);
// Brief visual flash of the feed before showing result
await new Promise(r => setTimeout(r, SCAN_FLASH_MS));
if (searchAborted) return;
// Phase 3: Show result
if (filtered.length > 0) {
// Pick randomly from the top tier of comparable matches for variety
const top = filtered[0];
const topPing = pings[top.region] ?? 999;
const pool = filtered.filter(g => {
const gPing = pings[g.region] ?? 999;
return Math.abs(gPing - topPing) <= 20
&& top.playerCount - g.playerCount <= 2;
});
const best = pool[Math.floor(Math.random() * pool.length)];
_con?.log('[KCC-MM] Best match:', best.gameID, best.region, best.map, `(${pings[best.region] ?? '?'}ms, pool: ${pool.length})`);
if (mmConfig.autoJoin) {
// Brief "Lobby Found!" flash before joining
const regionName = MATCHMAKER_REGION_NAMES[best.region] ?? best.region;
searchStatus.textContent = 'Lobby Found!';
searchFeed.innerHTML = '';
const found = document.createElement('div');
found.className = 'mm-feed-entry mm-pass';
found.style.cssText = 'font-size:1.1em;justify-content:center;';
found.innerHTML =
`<span class="mm-feed-region">${best.region}</span>` +
`<span class="mm-feed-map">${best.map}</span>` +
`<span class="mm-feed-players">${best.playerCount}/${best.playerLimit}</span>`;
searchFeed.appendChild(found);
searchCounter.textContent = `${best.gamemode} \u00B7 ${regionName} \u00B7 ${pings[best.region] ?? '?'}ms`;
await new Promise(r => setTimeout(r, 1200));
await verifyAndJoin(best.gameID);
return;
}
showResultPopup(best);
} else {
_con?.log('[KCC-MM] No matching games found');
if (mmConfig.autoJoin) {
dismissPopup();
if (openServerBrowser && typeof (window as any).openServerWindow === 'function') {
(window as any).openServerWindow(0);
}
return;
}
showResultPopup({
gameID: 'none',
region: 'none',
playerCount: 0,
playerLimit: 0,
map: MAP_ICON_INDICES[0],
gamemode: MATCHMAKER_GAMEMODES[0],
remainingTime: 0,
});
}
}
+361
View File
@@ -0,0 +1,361 @@
import type { SavedConsole } from './utils';
// ── Config ──
interface TranslatorConfig {
enabled: boolean;
targetLanguage: string;
showLanguageTag: boolean;
}
const DEFAULTS: TranslatorConfig = {
enabled: true,
targetLanguage: 'en',
showLanguageTag: true,
};
// ── Module state ──
let _con: SavedConsole;
let cfg: TranslatorConfig = { ...DEFAULTS };
let chatObserver: MutationObserver | null = null;
let pollTimer: ReturnType<typeof setInterval> | null = null;
// ── Translation cache (sessionStorage, 10-min expiry) ──
const CACHE_KEY_PREFIX = 'kccTL_';
const CACHE_EXPIRY_MS = 10 * 60 * 1000;
interface CacheEntry {
t: string; // translation
l: string; // source language
ts: number; // timestamp
}
function cacheGet(text: string): CacheEntry | null {
try {
const raw = sessionStorage.getItem(CACHE_KEY_PREFIX + text.toLowerCase().trim());
if (!raw) return null;
const entry: CacheEntry = JSON.parse(raw);
if (Date.now() - entry.ts > CACHE_EXPIRY_MS) return null;
return entry;
} catch { return null; }
}
function cacheSet(text: string, translation: string, srcLang: string): void {
try {
const entry: CacheEntry = { t: translation, l: srcLang, ts: Date.now() };
sessionStorage.setItem(CACHE_KEY_PREFIX + text.toLowerCase().trim(), JSON.stringify(entry));
} catch { /* sessionStorage full */ }
}
// ── Skip terms (gaming/chat slang — never sent for translation) ──
const SKIP_TERMS = new Set([
// Greetings & basics
'hi', 'hey', 'hello', 'yo', 'sup', 'bye', 'cya', 'gn', 'gm',
'yes', 'no', 'yep', 'yea', 'yeah', 'nah', 'nope', 'ok', 'okay', 'kk',
// Chat abbreviations
'lol', 'lmao', 'lmfao', 'rofl', 'omg', 'omfg', 'wtf', 'wth',
'bruh', 'bro', 'dude', 'man', 'brb', 'afk', 'gtg', 'g2g',
'smh', 'tbh', 'imo', 'imho', 'ngl', 'fr', 'frfr', 'fax',
'idk', 'idc', 'idgaf', 'nvm', 'stfu', 'pls', 'plz',
'thx', 'ty', 'tysm', 'np', 'yw', 'mb', 'sry', 'sorry',
'bet', 'cap', 'nocap', 'sus', 'mid', 'based', 'cringe', 'ratio',
'rip', 'oof', 'uwu', 'owo', 'xd', 'xdd', 'xddd', 'lel', 'kek',
'damn', 'dang', 'boi', 'fam', 'goat', 'goated',
'lit', 'vibe', 'vibes', 'lowkey', 'highkey', 'deadass',
'nice', 'cool', 'sick', 'fire', 'trash', 'ass', 'toxic',
'wow', 'whoa', 'wha', 'huh', 'wat', 'wut', 'hmm',
// Gaming general
'gg', 'ggwp', 'ggez', 'wp', 'ez', 'gl', 'hf', 'glhf',
'nt', 'ns', 'gj', 'mvp', 'clutch', 'ace', 'carry',
'noob', 'newb', 'n00b', 'bot', 'tryhard', 'sweat', 'sweaty',
'hack', 'hacks', 'hacker', 'hax', 'cheater', 'cheats',
'lag', 'laggy', 'ping', 'fps', 'dc', 'disconnect',
'nerf', 'buff', 'op', 'broken', 'meta', 'spam', 'camp', 'camper',
'aim', 'aimbot', 'wh', 'wallhack', 'esp',
'rush', 'push', 'rotate', 'flank', 'peek', 'hold',
'one', 'low', 'dead', 'down', 'res', 'revive',
'w', 'l', 'dub', 'win', 'loss', 'f', 'ggs',
// Krunker-specific
'kr', 'ak', 'smg', 'sniper', 'shotty', 'rev', 'semi',
'crossy', 'famas', 'rpg', 'lmg', 'deagle', 'comp',
'pub', 'pubs', 'ranked', 'nuke', 'nuked', 'nuking',
'kpd', 'bhop', 'bhopping', 'slidehopping', 'slidehop',
'krunker', 'krunky', 'yendis', 'krunkitis',
'contra', 'relic', 'unob', 'unobtainable', 'spin',
'market', 'trade', 'gift', 'drop', 'drops', 'skin', 'skins',
'clan', 'verified', 'lvl', 'level',
'trig', 'trigger', 'runner', 'det', 'detective',
'vince', 'bowman', 'spray', 'agent', 'rocketeer',
'streamer', 'ttv',
// Emoticons
':)', ':(', ':d', ':p', ':o', '<3',
]);
// ── False-positive source languages ──
const FALSE_POSITIVE_LANGS = new Set([
'so', 'cy', 'ht', 'hmn', 'ceb', 'haw', 'la', 'mg', 'mi',
'ny', 'sm', 'st', 'su', 'sw', 'tl', 'yo', 'zu', 'sn',
'ig', 'rw', 'co', 'fy', 'gd', 'lb', 'mt', 'eo',
]);
// ── Auto-suppression (repeated short phrases) ──
const suppressionCounts = new Map<string, number>();
const SUPPRESS_THRESHOLD = 3;
const MIN_LATIN_WORDS = 3;
const SHORT_TEXT_THRESHOLD = 15;
// ── Concurrency control ──
let activeRequests = 0;
const MAX_CONCURRENT = 3;
const pendingQueue: Array<() => void> = [];
function enqueue(fn: () => Promise<void>): void {
if (activeRequests < MAX_CONCURRENT) {
activeRequests++;
fn().finally(() => {
activeRequests--;
if (pendingQueue.length > 0) pendingQueue.shift()!();
});
} else {
pendingQueue.push(() => enqueue(fn));
}
}
// ── System message patterns to skip ──
const SYSTEM_PATTERNS = [
'joined the game', 'left the game', 'has been kicked', 'has been banned',
'vote to kick', 'press f1', 'connecting', 'connected', 'was arrested',
'started a vote', 'was kicked', 'was banned',
];
// ── Pre-translation filtering ──
function isLatinOnly(text: string): boolean {
// eslint-disable-next-line no-control-regex
return /^[\x00-\x7F\u00C0-\u024F\u1E00-\u1EFF\s\d.,!?;:'"()\-/@#$%^&*+=~`[\]{}|\\<>]+$/u.test(text);
}
function shouldTranslate(text: string): boolean {
const cleaned = text.trim();
if (cleaned.length < 2) return false;
// Tokenize for skip-term checking
const words = cleaned.replace(/[^a-zA-Z0-9\s]/g, '').toLowerCase().split(/\s+/).filter(w => w.length > 0);
if (words.length === 0) return false;
if (words.every(w => SKIP_TERMS.has(w))) return false;
// Auto-suppressed phrases
const key = cleaned.toLowerCase();
if ((suppressionCounts.get(key) ?? 0) >= SUPPRESS_THRESHOLD) return false;
// Non-Latin characters = almost certainly needs translation
if (!isLatinOnly(cleaned)) return true;
// Latin-only: require minimum word count (short English slang triggers false positives)
if (words.length < MIN_LATIN_WORDS) {
// Allow if accented characters suggest non-English
if (!/[À-ÿ]/.test(cleaned)) return false;
}
return true;
}
// ── Chat text extraction ──
interface ChatExtraction {
message: string;
username: string; // "Username:" prefix or empty
}
function extractChatText(node: HTMLElement): ChatExtraction | null {
const text = node.textContent?.trim();
if (!text || text.length < 2) return null;
// Skip nodes with images (kill feed has weapon/skull icons)
if (node.querySelector('img')) return null;
// Skip commands
if (text.startsWith('/')) return null;
// Skip system messages
const lower = text.toLowerCase();
if (SYSTEM_PATTERNS.some(p => lower.includes(p))) return null;
// Extract message content after "Username: " prefix
const colonIdx = text.indexOf(':');
if (colonIdx > 0 && colonIdx < 25) {
const username = text.substring(0, colonIdx + 1);
const msg = text.substring(colonIdx + 1).trim();
return msg.length >= 2 ? { message: msg, username } : null;
}
return { message: text, username: '' };
}
// ── Google Translate API ──
async function translateText(text: string): Promise<{ translation: string; srcLang: string } | null> {
// Check cache
const cached = cacheGet(text);
if (cached) return { translation: cached.t, srcLang: cached.l };
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const url = 'https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl='
+ cfg.targetLanguage + '&dt=t&q=' + encodeURIComponent(text);
const response = await fetch(url, { signal: controller.signal });
clearTimeout(timeout);
if (!response.ok) {
_con.warn('[KCC-TL] HTTP', response.status);
return null;
}
const data = await response.json();
if (!data?.[0]?.[0]) return null;
const translation = (data[0] as any[]).map((item: any) => item[0]).join('');
const srcLang: string = data[2] || 'unknown';
// Already in target language
if (srcLang === cfg.targetLanguage) return null;
// Identical translation (strip punctuation/whitespace for robust comparison)
const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
if (norm(translation) === norm(text)) return null;
// Post-filter: false-positive languages on short text
if (text.length < SHORT_TEXT_THRESHOLD && FALSE_POSITIVE_LANGS.has(srcLang)) {
const key = text.toLowerCase().trim();
suppressionCounts.set(key, (suppressionCounts.get(key) ?? 0) + 1);
return null;
}
// Track short phrases for auto-suppression learning
const wordCount = text.trim().split(/\s+/).length;
if (wordCount <= 2) {
const key = text.toLowerCase().trim();
const count = (suppressionCounts.get(key) ?? 0) + 1;
suppressionCounts.set(key, count);
if (count >= SUPPRESS_THRESHOLD) return null;
}
cacheSet(text, translation, srcLang);
return { translation, srcLang };
} catch (err: any) {
if (err.name !== 'AbortError') _con.warn('[KCC-TL] Error:', err.message);
return null;
}
}
// ── DOM manipulation ──
function appendTranslation(chatNode: HTMLElement, username: string, translation: string, srcLang: string): void {
const div = document.createElement('div');
div.className = 'kcc-translation';
const langTag = (cfg.showLanguageTag && srcLang !== 'unknown') ? ' [' + srcLang.toUpperCase() + ']' : '';
div.textContent = '\u{1F310} ' + (username ? username + ' ' : '') + translation + langTag;
chatNode.appendChild(div);
}
// ── Message processing ──
function processMessage(node: HTMLElement): void {
if (node.hasAttribute('data-kpc-translated')) return;
node.setAttribute('data-kpc-translated', '1');
const extracted = extractChatText(node);
if (!extracted) return;
if (!shouldTranslate(extracted.message)) return;
const { message, username } = extracted;
enqueue(async () => {
const result = await translateText(message);
if (result) appendTranslation(node, username, result.translation, result.srcLang);
});
}
// ── Observer lifecycle ──
function startObserver(): void {
if (chatObserver) return;
let attempts = 0;
pollTimer = setInterval(() => {
attempts++;
const chatList = document.getElementById('chatList');
if (!chatList) {
if (attempts > 60) {
clearInterval(pollTimer!);
pollTimer = null;
_con.warn('[KCC-TL] #chatList not found after 30s, giving up');
}
return;
}
clearInterval(pollTimer!);
pollTimer = null;
chatObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType === 1) processMessage(node as HTMLElement);
}
}
});
chatObserver.observe(chatList, { childList: true });
_con.log('[KCC-TL] Chat observer active');
}, 500);
}
function stopObserver(): void {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
if (chatObserver) {
chatObserver.disconnect();
chatObserver = null;
}
}
// ── Public API ──
export function initTranslator(savedConsole: SavedConsole, initCfg: TranslatorConfig): void {
_con = savedConsole;
cfg = {
enabled: initCfg.enabled ?? DEFAULTS.enabled,
targetLanguage: initCfg.targetLanguage ?? DEFAULTS.targetLanguage,
showLanguageTag: initCfg.showLanguageTag ?? DEFAULTS.showLanguageTag,
};
if (!cfg.enabled) {
_con.log('[KCC-TL] Translator disabled');
return;
}
_con.log('[KCC-TL] Initializing (target: ' + cfg.targetLanguage + ')');
startObserver();
}
export function updateTranslatorConfig(update: Partial<TranslatorConfig>): void {
if (update.enabled !== undefined) {
cfg.enabled = update.enabled;
if (update.enabled && !chatObserver) startObserver();
if (!update.enabled) stopObserver();
}
if (update.targetLanguage !== undefined) cfg.targetLanguage = update.targetLanguage;
if (update.showLanguageTag !== undefined) cfg.showLanguageTag = update.showLanguageTag;
}
+258
View File
@@ -0,0 +1,258 @@
import { ipcRenderer, webFrame } from 'electron';
// ── Types ──
export interface ScriptMetadata {
name: string;
author: string;
version: string;
desc: string;
src: string;
license: string;
runAt: 'document-start' | 'document-end';
priority: number;
}
export interface UserscriptSetting {
title: string;
type: 'bool' | 'num' | 'sel' | 'color' | 'keybind';
value: unknown;
desc?: string;
min?: number;
max?: number;
step?: number;
opts?: (string | number)[];
changed?: (value: unknown) => void;
}
export interface UserscriptInstance {
filename: string;
content: string;
meta: ScriptMetadata;
enabled: boolean;
executed: boolean;
unload: (() => void) | null;
settings: Record<string, UserscriptSetting> | null;
}
// ── State ──
const instances: UserscriptInstance[] = [];
const cssHandles = new Map<string, string>(); // identifier -> webFrame CSS key
// ── Metadata parser ──
export function parseMetadata(code: string): ScriptMetadata {
const meta: ScriptMetadata = {
name: '',
author: '',
version: '',
desc: '',
src: '',
license: '',
runAt: 'document-end',
priority: 0,
};
const startMatch = code.match(/\/\/\s*==UserScript==/);
const endMatch = code.match(/\/\/\s*==\/UserScript==/);
if (!startMatch || !endMatch) return meta;
const block = code.substring(
startMatch.index! + startMatch[0].length,
endMatch.index!,
);
for (const line of block.split('\n')) {
const m = line.match(/\/\/\s*@(\S+)\s+(.*)/);
if (!m) continue;
const [, tag, val] = m;
const v = val.trim();
switch (tag) {
case 'name': meta.name = v; break;
case 'author': meta.author = v; break;
case 'version': meta.version = v; break;
case 'desc':
case 'description': meta.desc = v; break;
case 'src': meta.src = v; break;
case 'license': meta.license = v; break;
case 'run-at':
if (v === 'document-start') meta.runAt = 'document-start';
else meta.runAt = 'document-end';
break;
case 'priority':
meta.priority = parseInt(v, 10) || 0;
break;
}
}
return meta;
}
// ── CSS injection via webFrame ──
function toggleCSS(css: string, identifier: string, value: boolean): void {
const existing = cssHandles.get(identifier);
if (value) {
if (existing) return; // already inserted
const key = webFrame.insertCSS(css);
cssHandles.set(identifier, key);
} else {
if (!existing) return;
webFrame.removeInsertedCSS(existing);
cssHandles.delete(identifier);
}
}
// ── Script execution ──
function executeScript(
instance: UserscriptInstance,
_console: { log: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void },
): void {
if (instance.executed) return;
const context: Record<string, unknown> = {
_console,
_css(css: string, identifier: string, value: boolean) {
toggleCSS(css, instance.filename + ':' + identifier, value);
},
unload: null as (() => void) | null,
settings: null as Record<string, UserscriptSetting> | null,
};
try {
const fn = new Function(instance.content);
const result = fn.apply(context);
// Script returned `this` — capture settings and unload
if (result === context) {
instance.unload = (typeof context.unload === 'function') ? context.unload as () => void : null;
instance.settings = context.settings as Record<string, UserscriptSetting> | null;
} else {
instance.unload = null;
instance.settings = null;
}
instance.executed = true;
_console.log('[KCC] Userscript executed:', instance.meta.name || instance.filename);
} catch (err) {
_console.error('[KCC] Userscript error in', instance.filename, ':', err);
}
}
// ── Apply saved preferences ──
async function applyPreferences(instance: UserscriptInstance): Promise<void> {
if (!instance.settings) return;
const saved = await ipcRenderer.invoke('userscripts-load-prefs', instance.filename);
for (const key of Object.keys(instance.settings)) {
if (key in saved) {
const setting = instance.settings[key];
setting.value = saved[key];
if (typeof setting.changed === 'function') {
try { setting.changed(setting.value); } catch { /* ignore callback errors */ }
}
}
}
}
// ── Public API ──
export function getInstances(): UserscriptInstance[] {
return instances;
}
export async function initUserscripts(
_console: { log: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void },
): Promise<void> {
const { scripts, tracker } = await ipcRenderer.invoke('userscripts-scan');
if (!scripts || scripts.length === 0) {
_console.log('[KCC] No userscripts found');
return;
}
// Build instances
for (const script of scripts) {
const meta = parseMetadata(script.content);
instances.push({
filename: script.filename,
content: script.content,
meta,
enabled: tracker[script.filename] === true,
executed: false,
unload: null,
settings: null,
});
}
// Sort by priority descending
instances.sort((a, b) => b.meta.priority - a.meta.priority);
// Execute document-start scripts
for (const inst of instances) {
if (inst.enabled && inst.meta.runAt === 'document-start') {
executeScript(inst, _console);
await applyPreferences(inst);
}
}
// Execute document-end scripts
const runDocEnd = () => {
for (const inst of instances) {
if (inst.enabled && inst.meta.runAt === 'document-end' && !inst.executed) {
executeScript(inst, _console);
applyPreferences(inst);
}
}
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', runDocEnd, { once: true });
} else {
runDocEnd();
}
_console.log('[KCC] Userscripts initialized:', instances.length, 'scripts loaded');
}
export function setScriptEnabled(
filename: string,
enabled: boolean,
_console: { log: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void },
): { needsReload: boolean } {
const inst = instances.find(i => i.filename === filename);
if (!inst) return { needsReload: false };
inst.enabled = enabled;
// Update tracker
const tracker: Record<string, boolean> = {};
for (const i of instances) tracker[i.filename] = i.enabled;
ipcRenderer.invoke('userscripts-set-tracker', tracker);
if (!enabled) {
if (inst.unload && inst.executed) {
try {
inst.unload();
_console.log('[KCC] Userscript unloaded:', inst.meta.name || inst.filename);
} catch (err) {
_console.error('[KCC] Userscript unload error:', err);
}
inst.executed = false;
inst.unload = null;
inst.settings = null;
return { needsReload: false };
}
// No unload function — need page reload to fully disable
return { needsReload: inst.executed };
} else {
// Enabling
if (!inst.executed) {
executeScript(inst, _console);
applyPreferences(inst);
return { needsReload: false };
}
return { needsReload: false };
}
}
+116
View File
@@ -0,0 +1,116 @@
// ── Shared preload utilities ──
// Common types, helpers, and constants used across preload modules.
// ── Shared interfaces ──
export interface SavedConsole {
log: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
}
// ── HTML escaping ──
const HTML_ESCAPE_MAP: Record<string, string> = {
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
};
export function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, c => HTML_ESCAPE_MAP[c]);
}
// ── Chat message injection ──
// Creates messages in #chatHolder inside a persistent #kpcMessageHolder div.
// timeout=0 means the message is persistent (not auto-removed).
export function genChatMsg(text: string, timeout = 2.25): HTMLElement | null {
const chatHolder = document.getElementById('chatHolder');
if (!chatHolder) return null;
if (!document.getElementById('kpcMessageHolder')) {
chatHolder.insertAdjacentHTML('afterbegin', '<div id="kpcMessageHolder"></div>');
}
const holder = document.getElementById('kpcMessageHolder')!;
holder.insertAdjacentHTML('beforeend',
'<div class="chatHolder_kpc"><div class="chatItem_kpc"><span class="chatMsg_kpc">' +
escapeHtml(text) + '</span></div></div>');
const elem = holder.lastElementChild as HTMLElement;
if (timeout !== 0) {
setTimeout(() => { elem.remove(); }, timeout * 1000);
}
return elem;
}
// ── Filename sanitisation ──
export function sanitizeFilename(name: string): string {
return name.replace(/[^a-zA-Z0-9_-]/g, '_');
}
// ── Shared CSS constants ──
export const DEATH_ANIM_BLOCK_ID = 'kpc-animationBlock';
export const DEATH_ANIM_BLOCK_CSS =
'.death-ui-bottom, .death-ui-bottom-empty { animation: none !important; transition: none !important; }';
/** Inject or remove the death screen animation block style element. */
export function setDeathAnimBlock(enabled: boolean): void {
let el = document.getElementById(DEATH_ANIM_BLOCK_ID);
if (enabled) {
if (!el) {
el = document.createElement('style');
el.id = DEATH_ANIM_BLOCK_ID;
el.textContent = DEATH_ANIM_BLOCK_CSS;
document.head.appendChild(el);
}
} else if (el) {
el.remove();
}
}
// ── Cleaner Menu ──
// Hides clutter from the main menu for a streamlined look.
const CLEANER_MENU_ID = 'kpc-cleanerMenu';
const CLEANER_MENU_CSS = `
*::-webkit-scrollbar { display: none !important; }
.settingsBtn[style*="width:auto;background-color:#994cd1"] { display: none !important; }
.setSugBox2 { display: none !important; }
.advancedSwitch { display: none !important; }
.menuSocialB { display: none !important; }
.serverHostOpH { display: none !important; }
.signup-rewards-container { display: none !important; }
#tlInfHold { display: none !important; }
#gameNameHolder { display: none !important; }
#termsInfo { display: none !important; }
#bubbleContainer { display: none !important; }
#instructions:only-child { display: none !important; }
#mapInfoHld { display: none !important; }
#krDiscountAd { display: none !important; }
#classPreviewCanvas { display: none !important; }
#menuClassSubtext { display: none !important; }
#settingsPreset { display: none !important; }
#menuClassName { display: none !important; }
#menuBtnQuickMatch { display: none !important; }
#menuClassIcn { display: none !important; }
#streamContainerNew { display: none !important; }
#editorBtnM { display: none !important; }
.verticalSeparator { visibility: hidden !important; }
#mLevelCont { background-color: transparent; }
#uiBase.onMenu #spectButton { top: 94% !important; }
.headerBarL, .headerBar, .menuBtnHL { background-color: transparent; }
.headerBarR { right: -23px !important; }
`;
export function setCleanerMenu(enabled: boolean): void {
let el = document.getElementById(CLEANER_MENU_ID);
if (enabled) {
if (!el) {
el = document.createElement('style');
el.id = CLEANER_MENU_ID;
el.textContent = CLEANER_MENU_CSS;
document.head.appendChild(el);
}
} else if (el) {
el.remove();
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src",
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "out"]
}
+31
View File
@@ -0,0 +1,31 @@
import { defineConfig } from 'vite';
import { resolve } from 'path';
import { builtinModules } from 'module';
// Both 'fs' and 'node:fs' forms must be externalized
const nodeBuiltins = builtinModules.flatMap((m) => [m, `node:${m}`]);
const isProd = process.env.NODE_ENV === 'production' || !process.argv.includes('--mode');
export default defineConfig({
build: {
lib: {
entry: resolve(__dirname, 'src/main/index.ts'),
formats: ['cjs'],
fileName: () => 'index.js',
},
outDir: 'dist/main',
emptyDirBefore: true,
rollupOptions: {
external: ['electron', 'electron-store', ...nodeBuiltins],
},
target: 'node20',
minify: isProd,
sourcemap: !isProd,
},
resolve: {
// Treat this as a Node build — don't swap node builtins for browser stubs
conditions: ['node'],
mainFields: ['module', 'main'],
},
});
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig } from 'vite';
import { resolve } from 'path';
const isProd = process.env.NODE_ENV === 'production' || !process.argv.includes('--mode');
export default defineConfig({
build: {
lib: {
entry: resolve(__dirname, 'src/preload/index.ts'),
formats: ['cjs'],
fileName: () => 'index.js',
},
outDir: 'dist/preload',
emptyDirBefore: true,
rollupOptions: {
external: ['electron'],
},
target: 'node20',
minify: isProd,
sourcemap: !isProd,
},
});