Compare commits

..

11 Commits

Author SHA1 Message Date
bigjakk 60d4d5bb47 v0.5.6 — Ping-sorted matchmaker with auto-join
Matchmaker now sorts filtered lobbies by lowest ping then highest
player count instead of picking randomly. New auto-join toggle skips
the popup and navigates directly to the best match.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 14:48:54 -08:00
bigjakk 8f3a74ddb4 v0.5.5 — Fix ERR_FILE_NOT_FOUND log spam in resource swapper
Wrap net.fetch() in the kpc-swap protocol handler with try/catch and
return a 404 response on failure instead of letting the error propagate
as an uncaught promise rejection. Revert workflow cache changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 12:29:52 -08:00
bigjakk 93775cc36a v0.5.4 — Fix taskbar pin persistence across updates
Set app.setAppUserModelId() to match electron-builder appId
(com.krunkercivilian.client) so Windows associates the running process
with the installed shortcut. Add explicit shortcutName to NSIS config
for stable shortcut identity across installs. Remove stale uuid entry
from electron-builder files list.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:38:51 -08:00
bigjakk 467ac95b4e v0.5.3 — Fix resource swapper, add Husky, remove uuid
Fix three Electron 12→42 protocol migration bugs in the resource swapper:
register protocol on the app session instead of default, generate valid
URLs from Windows paths, and prevent non-swapped krunker.io requests from
being cancelled. Swapper now rescans on page refresh to pick up file changes.

Add Husky pre-commit hook to run ESLint. Remove unused uuid dependency.
Update README with lint script, husky, and swapper improvements.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:19:10 -08:00
bigjakk 1eabea195a v0.5.2 — Add ESLint + typescript-eslint, fix lint errors
Add flat-config ESLint with typescript-eslint recommended rules,
fix all lint errors (unused imports/vars, empty catches, Function types,
prefer-const, useless assignments), and rename stale kpc- CSS class to kcc-.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 09:24:34 -08:00
bigjakk b8bfa2941c v0.5.1 — Escape userscript metadata in settings UI
Fixes XSS via malicious userscript @name, @author, @version, @description
metadata and script setting titles/descriptions. Also escapes checkbox
grid labels. All use existing escapeHtml() helper.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 09:04:50 -08:00
bigjakk 819caea65a Security hardening and codebase cleanup
Security fixes:
- Replace Caesar cipher with electron.safeStorage for account credentials
- Validate shell.openExternal URLs (allow only http/https protocols)
- Remove rejectUnauthorized:false from all HTTPS calls
- Add redirect domain validation to auto-updater
- Fix XSS in matchmaker popup (innerHTML → textContent/createTextNode)
- Add IPC config key whitelist to prevent arbitrary store access
- Credentials never sent to renderer; decrypted on-demand via IPC

Optimizations and cleanup:
- Simplify onBeforeRequest from double-registration to single handler
- Lazy-init matchmaker popup DOM (defer until first use)
- Invalidate game config cache immediately on write, not on flush
- Remove unused STANDARD_ASSET_RE and KeybindDef exports
- Deduplicate Keybind type (import from config.ts)
- Replace custom hasOwn wrapper with Object.hasOwn

Bug fix:
- Stop Krunker's global keydown handler from eating keystrokes in
  alt manager input fields (stopPropagation)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 08:54:52 -08:00
bigjakk 96e0cbfc07 Always show Accounts button, fix Linux CI electronDist
Show Accounts menu button even with no saved accounts so users can
add accounts from the in-game menu. Remove hardcoded electronDist
from electron-builder.yml — let electron-builder auto-detect on
Linux CI, Windows CI overrides via -c.electronDist flag.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 07:46:07 -08:00
bigjakk ceb8f73a2a Fix Linux CI build — don't block stock Electron download
On non-Windows (CI), skip writing path.txt so electron-nightly still
downloads the native Linux binary into dist/. The patched Windows
binary goes to dist-win/ and is used via -c.electronDist override.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 07:30:31 -08:00
bigjakk 1568c74cac v0.5.0 — Rename save folders, bump version
Rename data folders from KCCClient to "Krunker Civilian Client" for
swapper, userscripts, and documents output. Bump version to 0.5.0.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 07:18:53 -08:00
bigjakk 21684c5fbd Update build scripts for new repo
Point electron download script to Krunker-Civilian-Client repo.
Remove mirror-releases workflow (repo is public, no KPC copy needed).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 07:12:32 -08:00
21 changed files with 1368 additions and 337 deletions
-82
View File
@@ -1,82 +0,0 @@
name: Mirror Release to KCC
on:
release:
types: [published]
jobs:
mirror-release:
runs-on: ubuntu-latest
steps:
- name: Mirror release and assets
env:
BASE: https://gitea.crjlab.net/api/v1
SOURCE_REPO: bigjakk/krunker-civilian-client
DEST_REPO: bigjakk/KPC
TOKEN: ${{ secrets.DEST_GITEA_TOKEN }}
run: |
TAG="${{ github.event.release.tag_name }}"
NAME="${{ github.event.release.name }}"
BODY=$(echo '${{ toJson(github.event.release.body) }}')
# Create tag on KPC pointing to latest main commit
SHA=$(curl -s "$BASE/repos/$DEST_REPO/branches/main" \
-H "Authorization: token $TOKEN" | jq -r '.commit.id')
echo "Creating tag $TAG on KPC at $SHA"
curl -s -X POST "$BASE/repos/$DEST_REPO/tags" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"$TAG\", \"message\": \"$TAG\", \"target\": \"$SHA\"}"
# Create release on KPC
RESPONSE=$(curl -s -X POST "$BASE/repos/$DEST_REPO/releases" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"tag_name\": \"$TAG\",
\"name\": \"$NAME\",
\"body\": $BODY,
\"draft\": ${{ github.event.release.draft }},
\"prerelease\": ${{ github.event.release.prerelease }}
}")
RELEASE_ID=$(echo "$RESPONSE" | jq -r '.id')
echo "Created KPC release ID: $RELEASE_ID"
if [ "$RELEASE_ID" = "null" ] || [ -z "$RELEASE_ID" ]; then
echo "Failed to create release"
exit 1
fi
# Poll source release until assets appear (30s intervals, up to ~6 min)
echo "Waiting for source assets..."
for ATTEMPT in $(seq 1 12); do
RELEASE_INFO=$(curl -s "$BASE/repos/$SOURCE_REPO/releases/tags/$TAG" \
-H "Authorization: token $TOKEN")
ASSET_COUNT=$(echo "$RELEASE_INFO" | jq '.assets | length')
echo "Attempt $ATTEMPT/12: $ASSET_COUNT asset(s)"
[ "$ASSET_COUNT" -gt 0 ] && break
[ "$ATTEMPT" -lt 12 ] && sleep 30
done
if [ "$ASSET_COUNT" -eq 0 ]; then
echo "No assets found after 12 attempts. Aborting."
exit 1
fi
# Download and re-upload each asset to KPC
echo "$RELEASE_INFO" | jq -c '.assets[]' | while read -r asset; do
ASSET_NAME=$(echo "$asset" | jq -r '.name')
ASSET_URL=$(echo "$asset" | jq -r '.browser_download_url')
echo "Mirroring: $ASSET_NAME"
curl -sL "$ASSET_URL" -o "/tmp/$ASSET_NAME" -H "Authorization: token $TOKEN"
curl -s -X POST "$BASE/repos/$DEST_REPO/releases/$RELEASE_ID/assets?name=$ASSET_NAME" \
-H "Authorization: token $TOKEN" \
-F "attachment=@/tmp/$ASSET_NAME" | jq -r '" -> \(.name) (\(.size) bytes)"'
rm "/tmp/$ASSET_NAME"
done
+1
View File
@@ -0,0 +1 @@
npm run lint
+7 -4
View File
@@ -28,6 +28,7 @@ npm run dev # Builds in dev mode + launches Electron
| `npm run dist:linux` | Build + package for Linux (AppImage + deb) | | `npm run dist:linux` | Build + package for Linux (AppImage + deb) |
| `npm run dist:all` | Build + package for all platforms | | `npm run dist:all` | Build + package for all platforms |
| `npm run clean` | Remove `dist/` and `out/` directories | | `npm run clean` | Remove `dist/` and `out/` directories |
| `npm run lint` | Run ESLint (typescript-eslint) on `src/` |
## Architecture ## Architecture
@@ -138,7 +139,7 @@ src/
platform.ts OS detection, Chromium GPU/performance flags (per-platform) platform.ts OS detection, Chromium GPU/performance flags (per-platform)
config.ts electron-store schema, defaults, DEFAULT_KEYBINDS config.ts electron-store schema, defaults, DEFAULT_KEYBINDS
client-ui.ts Injected CSS for settings panel, keybind dialog, matchmaker popup client-ui.ts Injected CSS for settings panel, keybind dialog, matchmaker popup
swapper.ts Resource swapper — local asset overrides via custom protocol swapper.ts Resource swapper — local asset overrides via session-aware custom protocol
userscripts.ts Userscript manager — filesystem scanning, tracker.json, preferences userscripts.ts Userscript manager — filesystem scanning, tracker.json, preferences
discord-rpc.ts Discord Rich Presence via raw IPC socket discord-rpc.ts Discord Rich Presence via raw IPC socket
logger.ts File logging with daily rotation and 7-day retention logger.ts File logging with daily rotation and 7-day retention
@@ -159,7 +160,7 @@ Two Vite configs build independent targets:
| Config | Target | Output | Notes | | Config | Target | Output | Notes |
|--------|--------|--------|-------| |--------|--------|--------|-------|
| `vite.main.config.ts` | Main process | `dist/main/index.js` (CJS) | Externalizes `electron`, `electron-store`, and Node builtins. Targets Node 20. | | `vite.main.config.ts` | Main process | `dist/main/index.js` (CJS) | Externalizes `electron`, `electron-store`, and Node builtins. Targets Node 20. |
| `vite.preload.config.ts` | Preload script | `dist/preload/index.js` (CJS) | Externalizes `electron` and `uuid`. Targets Node 20. | | `vite.preload.config.ts` | Preload script | `dist/preload/index.js` (CJS) | Externalizes `electron`. Targets Node 20. |
### Custom Electron Binary ### Custom Electron Binary
@@ -214,7 +215,7 @@ Common flags always applied: `disable-backgrounding-occluded-windows`, `ignore-g
### Core Client ### Core Client
- Unlimited FPS via custom-patched Electron 42 build - Unlimited FPS via custom-patched Electron 42 build
- Ad blocking (network-level URL filter + CSS hiding) - Ad blocking (network-level URL filter + CSS hiding)
- Resource swapper (replace game textures, sounds, models with local files) - Resource swapper (replace game textures, sounds, models with local files — rescans on page refresh)
- Custom matchmaker (filter lobbies by region, gamemode, player count, remaining time) - Custom matchmaker (filter lobbies by region, gamemode, player count, remaining time)
- Userscript system (Tampermonkey-style metadata, custom per-script settings, instant toggle via unload) - Userscript system (Tampermonkey-style metadata, custom per-script settings, instant toggle via unload)
- Chat translator (real-time translation via Google Translate API with language tags) - Chat translator (real-time translation via Google Translate API with language tags)
@@ -267,8 +268,9 @@ At uncapped frame rates (600+ FPS), Krunker's CSS animations (e.g. death screen
- **TypeScript** 5.7 — Type-safe source code - **TypeScript** 5.7 — Type-safe source code
- **Vite** 6 — Fast bundler (2 build targets: main + preload) - **Vite** 6 — Fast bundler (2 build targets: main + preload)
- **electron-store** 8 — JSON config persistence (CJS) - **electron-store** 8 — JSON config persistence (CJS)
- **uuid** 9 — Unique ID generation
- **electron-builder** 26 — Cross-platform packaging (NSIS, portable, AppImage, deb) - **electron-builder** 26 — Cross-platform packaging (NSIS, portable, AppImage, deb)
- **ESLint** 10 + **typescript-eslint** — Linting with recommended rules
- **Husky** 9 — Git hooks (pre-commit lint)
## Project Structure ## Project Structure
@@ -284,6 +286,7 @@ Krunker-Civilian-Client/
build/ Build resources (icons, .desktop file) build/ Build resources (icons, .desktop file)
scripts/ Build scripts (Electron patched binary download) scripts/ Build scripts (Electron patched binary download)
electron-build/ Custom Electron build instructions and patches electron-build/ Custom Electron build instructions and patches
eslint.config.mjs
vite.main.config.ts vite.main.config.ts
vite.preload.config.ts vite.preload.config.ts
electron-builder.yml electron-builder.yml
+1 -2
View File
@@ -1,6 +1,5 @@
appId: com.krunkercivilian.client appId: com.krunkercivilian.client
productName: Krunker Civilian Client productName: Krunker Civilian Client
electronDist: node_modules/electron/dist
directories: directories:
output: out output: out
buildResources: build buildResources: build
@@ -26,7 +25,6 @@ files:
- node_modules/mimic-fn/**/* - node_modules/mimic-fn/**/*
- node_modules/semver/**/* - node_modules/semver/**/*
- node_modules/onetime/**/* - node_modules/onetime/**/*
- node_modules/uuid/**/*
- "!node_modules/**/*.ts" - "!node_modules/**/*.ts"
- "!node_modules/**/*.map" - "!node_modules/**/*.map"
asar: true asar: true
@@ -44,6 +42,7 @@ nsis:
allowToChangeInstallationDirectory: true allowToChangeInstallationDirectory: true
createDesktopShortcut: true createDesktopShortcut: true
createStartMenuShortcut: true createStartMenuShortcut: true
shortcutName: Krunker Civilian Client
artifactName: "${productName}-${version}-Setup.${ext}" artifactName: "${productName}-${version}-Setup.${ext}"
portable: portable:
+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: "^_" },
],
},
}
);
+929 -40
View File
File diff suppressed because it is too large Load Diff
+39 -34
View File
@@ -1,34 +1,39 @@
{ {
"name": "krunker-civilian-client", "name": "krunker-civilian-client",
"version": "4.1.22", "version": "0.5.6",
"description": "Cross-platform Krunker game client", "description": "Cross-platform Krunker game client",
"main": "dist/main/index.js", "main": "dist/main/index.js",
"homepage": "https://gitea.crjlab.net/bigjakk/krunker-civilian-client", "homepage": "https://gitea.crjlab.net/bigjakk/krunker-civilian-client",
"author": "Krunker Civilian Client <krunker@crjlab.net>", "author": "Krunker Civilian Client <krunker@crjlab.net>",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"postinstall": "node scripts/download-electron.js", "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 .", "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:main": "vite build --config vite.main.config.ts",
"build:preload": "vite build --config vite.preload.config.ts", "build:preload": "vite build --config vite.preload.config.ts",
"build": "npm run build:main && npm run build:preload", "build": "npm run build:main && npm run build:preload",
"start": "npm run build && electron .", "start": "npm run build && electron .",
"download-electron": "node scripts/download-electron.js", "download-electron": "node scripts/download-electron.js",
"dist:win": "npm run build && electron-builder --win", "dist:win": "npm run build && electron-builder --win",
"dist:linux": "npm run build && electron-builder --linux", "dist:linux": "npm run build && electron-builder --linux",
"dist:all": "npm run build && electron-builder --win --linux", "dist:all": "npm run build && electron-builder --win --linux",
"clean": "rimraf dist out" "clean": "rimraf dist out",
}, "lint": "eslint src/",
"dependencies": { "prepare": "husky"
"electron-store": "^8.2.0", },
"uuid": "^9.0.1" "dependencies": {
}, "electron-store": "^8.2.0"
"devDependencies": { },
"@types/node": "^22.0.0", "devDependencies": {
"electron": "npm:electron-nightly@42.0.0-nightly.20260227", "@eslint/js": "^10.0.1",
"electron-builder": "^26.0.0", "@types/node": "^22.0.0",
"rimraf": "^6.0.1", "electron": "npm:electron-nightly@42.0.0-nightly.20260227",
"typescript": "^5.7.0", "electron-builder": "^26.0.0",
"vite": "^6.0.0" "eslint": "^10.0.2",
} "husky": "^9.1.7",
} "rimraf": "^6.0.1",
"typescript": "^5.7.0",
"typescript-eslint": "^8.56.1",
"vite": "^6.0.0"
}
}
+6 -3
View File
@@ -24,7 +24,7 @@ const { execSync } = require('child_process');
const ELECTRON_VERSION = '42.0.0-nightly.20260227'; const ELECTRON_VERSION = '42.0.0-nightly.20260227';
const ASSET_NAME = 'electron-v42.0.0-nightly-patched-win32-x64.zip'; const ASSET_NAME = 'electron-v42.0.0-nightly-patched-win32-x64.zip';
const GITEA_BASE = 'https://gitea.crjlab.net'; const GITEA_BASE = 'https://gitea.crjlab.net';
const REPO = 'bigjakk/KPC'; const REPO = 'bigjakk/Krunker-Civilian-Client';
// The release tag that holds the patched Electron zip. // The release tag that holds the patched Electron zip.
// Upload the zip as an asset to this release on Gitea. // Upload the zip as an asset to this release on Gitea.
const RELEASE_TAG = 'electron-patched'; const RELEASE_TAG = 'electron-patched';
@@ -174,8 +174,11 @@ async function main() {
// Write path.txt so the electron package's lazy downloader (index.js) // Write path.txt so the electron package's lazy downloader (index.js)
// considers the binary already installed and doesn't re-download stock. // considers the binary already installed and doesn't re-download stock.
const platformExe = process.platform === 'win32' ? 'electron.exe' : 'electron'; // On non-Windows (CI cross-compilation), skip this so electron-nightly still
fs.writeFileSync(path.join(ELECTRON_DIST, '..', 'path.txt'), platformExe); // 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 // Write marker and verify
if (fs.existsSync(VERSION_FILE)) { if (fs.existsSync(VERSION_FILE)) {
+1 -1
View File
@@ -518,7 +518,7 @@ export const MATCHMAKER_SETTINGS_CSS = `
`; `;
export const TRANSLATOR_CSS = ` export const TRANSLATOR_CSS = `
.kpc-translation { .kcc-translation {
color: #88ff88; color: #88ff88;
font-style: italic; font-style: italic;
margin-left: 8px; margin-left: 8px;
+2
View File
@@ -45,6 +45,7 @@ export interface AppConfig {
maxPlayers: number; maxPlayers: number;
minRemainingTime: number; minRemainingTime: number;
openServerBrowser: boolean; openServerBrowser: boolean;
autoJoin: boolean;
}; };
keybinds: { keybinds: {
reload: Keybind; reload: Keybind;
@@ -140,6 +141,7 @@ export const config = new Store<AppConfig>({
maxPlayers: 6, maxPlayers: 6,
minRemainingTime: 120, minRemainingTime: 120,
openServerBrowser: true, openServerBrowser: true,
autoJoin: false,
}, },
keybinds: DEFAULT_KEYBINDS, keybinds: DEFAULT_KEYBINDS,
userscripts: { userscripts: {
+97 -27
View File
@@ -1,4 +1,4 @@
import { app, BrowserWindow, Menu, clipboard, dialog, ipcMain, protocol, session, shell } from 'electron'; import { app, BrowserWindow, Menu, clipboard, dialog, ipcMain, safeStorage, session, shell } from 'electron';
import { join } from 'path'; import { join } from 'path';
import { existsSync, mkdirSync, promises as fsp } from 'fs'; import { existsSync, mkdirSync, promises as fsp } from 'fs';
import { get as httpsGet } from 'https'; import { get as httpsGet } from 'https';
@@ -14,6 +14,7 @@ import { showUpdateWindow } from './update-window';
import { DiscordRPC } from './discord-rpc'; import { DiscordRPC } from './discord-rpc';
// ── App version for API calls ── // ── App version for API calls ──
// eslint-disable-next-line @typescript-eslint/no-require-imports
const appVersion: string = require('../../package.json').version; const appVersion: string = require('../../package.json').version;
// ── Region ping cache ── // ── Region ping cache ──
@@ -53,6 +54,9 @@ const advancedConfig = { ...advancedDefaults, ...config.get('advanced') };
const perfConfig = { fpsUnlocked: true, ...config.get('performance') }; const perfConfig = { fpsUnlocked: true, ...config.get('performance') };
applyPlatformFlags(platformInfo, advancedConfig, perfConfig); 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) ── // ── Resource swapper protocol (must register before app.ready) ──
initSwapperProtocol(); initSwapperProtocol();
@@ -107,6 +111,16 @@ document.addEventListener('keydown', function(e) {
} }
}, true);`; }, 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 ── // ── Keybind matching ──
function matchesKeybind(input: { key: string; control: boolean; shift: boolean; alt: boolean }, bind: Keybind | undefined): boolean { function matchesKeybind(input: { key: string; control: boolean; shift: boolean; alt: boolean }, bind: Keybind | undefined): boolean {
if (!bind) return false; if (!bind) return false;
@@ -200,44 +214,51 @@ app.whenReady().then(async () => {
async function launchApp(): Promise<void> { async function launchApp(): Promise<void> {
electronLog.log('[KCC] Starting initialization'); electronLog.log('[KCC] Starting initialization');
// ── Register swapper file protocol ──
registerSwapperFileProtocol();
// ── Session: persistent partition + clean user-agent ── // ── Session: persistent partition + clean user-agent ──
const ses = session.fromPartition('persist:krunker'); const ses = session.fromPartition('persist:krunker');
const rawUA = ses.getUserAgent(); const rawUA = ses.getUserAgent();
ses.setUserAgent(rawUA.replace(/\s*krunker-civilian-client\/\S+/i, '')); ses.setUserAgent(rawUA.replace(/\s*krunker-civilian-client\/\S+/i, ''));
// ── Register swapper file protocol on this session ──
registerSwapperFileProtocol(ses);
// ── Resource swapper ── // ── Resource swapper ──
const swapperConfig = config.get('swapper'); const swapperConfig = config.get('swapper');
const swapDir = swapperConfig.path || join(app.getPath('userData'), 'KCCClient', 'swapper'); const swapDir = swapperConfig.path || join(app.getPath('userData'), 'Krunker Civilian Client', 'swapper');
const swapper = swapperConfig.enabled ? new ResourceSwapper(swapDir) : null; const swapper = swapperConfig.enabled ? new ResourceSwapper(swapDir) : null;
electronLog.log(`[KCC] Resource swapper: ${swapper ? 'enabled' : 'disabled'} (${swapDir})`); electronLog.log(`[KCC] Resource swapper: ${swapper ? 'enabled' : 'disabled'} (${swapDir})`);
// ── Userscript manager ── // ── Userscript manager ──
const usConfig = config.get('userscripts') || { enabled: true, path: '' }; const usConfig = config.get('userscripts') || { enabled: true, path: '' };
const usDir = usConfig.path || join(app.getPath('userData'), 'KCCClient'); const usDir = usConfig.path || join(app.getPath('userData'), 'Krunker Civilian Client');
const userscriptManager = usConfig.enabled ? new UserscriptManager(usDir) : null; const userscriptManager = usConfig.enabled ? new UserscriptManager(usDir) : null;
electronLog.log(`[KCC] Userscripts: ${userscriptManager ? 'enabled' : 'disabled'} (${usDir})`); electronLog.log(`[KCC] Userscripts: ${userscriptManager ? 'enabled' : 'disabled'} (${usDir})`);
// ── Ad blocking + resource swapper (single onBeforeRequest — Electron only allows one) ── // ── Ad blocking + resource swapper (single onBeforeRequest — Electron only allows one) ──
ses.webRequest.onBeforeRequest({ urls: [...BLOCKED_URL_PATTERNS] }, (details, callback) => { // 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) { if (swapper) {
const redirect = swapper.getRedirect(details.url); const redirect = swapper.getRedirect(details.url);
if (redirect) return callback({ redirectURL: redirect }); 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 }); callback({ cancel: true });
}); });
// Once swapper scan finishes, re-register with swapper patterns included
if (swapper) { if (swapper) {
swapper.waitForReady().then(() => { swapper.waitForReady().then(() => {
const filterUrls = [...BLOCKED_URL_PATTERNS, ...swapper.patterns];
ses.webRequest.onBeforeRequest({ urls: filterUrls }, (details, callback) => {
const redirect = swapper.getRedirect(details.url);
if (redirect) return callback({ redirectURL: redirect });
callback({ cancel: true });
});
electronLog.log(`[KCC] Swapper ready: ${swapper.patterns.length} pattern(s)`); electronLog.log(`[KCC] Swapper ready: ${swapper.patterns.length} pattern(s)`);
}); });
} }
@@ -301,8 +322,6 @@ async function launchApp(): Promise<void> {
} }
// ── Common output directory (used by folder actions) ── // ── Common output directory (used by folder actions) ──
const outputDir = join(app.getPath('documents'), 'KrunkerCivilianClient');
// ── Configurable keybinds via before-input-event ── // ── Configurable keybinds via before-input-event ──
win.webContents.on('before-input-event', (event, input) => { win.webContents.on('before-input-event', (event, input) => {
if (input.type !== 'keyDown') return; if (input.type !== 'keyDown') return;
@@ -317,7 +336,7 @@ async function launchApp(): Promise<void> {
event.preventDefault(); event.preventDefault();
} else if (matchesKeybind(input, binds.joinFromClipboard)) { } else if (matchesKeybind(input, binds.joinFromClipboard)) {
const text = clipboard.readText(); const text = clipboard.readText();
try { const u = new URL(text); if (u.protocol === 'https:' && u.hostname.endsWith('krunker.io')) win.loadURL(text); } catch {}; try { const u = new URL(text); if (u.protocol === 'https:' && u.hostname.endsWith('krunker.io')) win.loadURL(text); } catch { /* ignore invalid URLs */ }
event.preventDefault(); event.preventDefault();
} else if (matchesKeybind(input, binds.copyGameLink)) { } else if (matchesKeybind(input, binds.copyGameLink)) {
clipboard.writeText(win.webContents.getURL()); clipboard.writeText(win.webContents.getURL());
@@ -390,7 +409,7 @@ async function launchApp(): Promise<void> {
if (subUrl.includes('krunker.io')) { if (subUrl.includes('krunker.io')) {
sub.loadURL(subUrl); sub.loadURL(subUrl);
} else { } else {
setImmediate(() => shell.openExternal(subUrl)); setImmediate(() => safeOpenExternal(subUrl));
} }
return { action: 'deny' }; return { action: 'deny' };
}); });
@@ -437,7 +456,7 @@ async function launchApp(): Promise<void> {
} }
} }
} else { } else {
setImmediate(() => shell.openExternal(url)); setImmediate(() => safeOpenExternal(url));
} }
return { action: 'deny' }; return { action: 'deny' };
}); });
@@ -445,6 +464,8 @@ async function launchApp(): Promise<void> {
// ── Inject scripts after page loads ── // ── Inject scripts after page loads ──
win.webContents.on('did-finish-load', () => { win.webContents.on('did-finish-load', () => {
electronLog.log(`[KCC] Page loaded: ${win.webContents.getURL()}`); 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(() => {});
Promise.all([ Promise.all([
win.webContents.insertCSS(HIDE_ADS_CSS), win.webContents.insertCSS(HIDE_ADS_CSS),
win.webContents.insertCSS(ALL_CLIENT_CSS), win.webContents.insertCSS(ALL_CLIENT_CSS),
@@ -457,32 +478,44 @@ async function launchApp(): Promise<void> {
}); });
// ── IPC handlers ── // ── IPC handlers ──
const ALLOWED_CONFIG_KEYS = new Set<string>([
'window', 'performance', 'game', 'swapper', 'matchmaker',
'keybinds', 'userscripts', 'ui', 'discord', 'translator',
'advanced', 'accounts', 'platform',
]);
ipcMain.handle('get-version', () => appVersion); ipcMain.handle('get-version', () => appVersion);
ipcMain.handle('get-platform', () => platformInfo); ipcMain.handle('get-platform', () => platformInfo);
ipcMain.handle('get-config', (_e, key: string) => config.get(key as keyof typeof config.store)); 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[]) => { ipcMain.handle('get-all-config', (_e, keys: string[]) => {
const result: Record<string, unknown> = {}; const result: Record<string, unknown> = {};
for (const key of keys) result[key] = config.get(key as keyof typeof config.store); for (const key of keys) {
if (ALLOWED_CONFIG_KEYS.has(key)) result[key] = config.get(key as keyof typeof config.store);
}
return result; return result;
}); });
let configWriteTimer: ReturnType<typeof setTimeout> | null = null; let configWriteTimer: ReturnType<typeof setTimeout> | null = null;
const pendingConfigWrites = new Map<string, unknown>(); const pendingConfigWrites = new Map<string, unknown>();
ipcMain.handle('set-config', (_e, key: string, value: 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 // Flush immediately for keys that have side effects
if (key === 'keybinds') { if (key === 'keybinds') {
config.set(key as any, value); config.set(key as any, value);
cachedKeybinds = null; cachedKeybinds = null;
return; return;
} }
// Invalidate caches immediately (not on flush) to prevent stale reads
if (key === 'game') cachedGameConf = null;
pendingConfigWrites.set(key, value); pendingConfigWrites.set(key, value);
if (!configWriteTimer) { if (!configWriteTimer) {
configWriteTimer = setTimeout(() => { configWriteTimer = setTimeout(() => {
for (const [k, v] of pendingConfigWrites) { for (const [k, v] of pendingConfigWrites) {
config.set(k as any, v); config.set(k as any, v);
} }
// Invalidate caches for keys that affect runtime behavior
if (pendingConfigWrites.has('game')) cachedGameConf = null;
pendingConfigWrites.clear(); pendingConfigWrites.clear();
configWriteTimer = null; configWriteTimer = null;
}, 300); }, 300);
@@ -512,7 +545,7 @@ async function launchApp(): Promise<void> {
} }
try { try {
const data = await new Promise<string>((resolve, reject) => { const data = await new Promise<string>((resolve, reject) => {
httpsGet('https://matchmaker.krunker.io/ping-list?hostname=krunker.io', { rejectUnauthorized: false }, (res) => { httpsGet('https://matchmaker.krunker.io/ping-list?hostname=krunker.io', (res) => {
let body = ''; let body = '';
res.on('data', (chunk: string) => { body += chunk; }); res.on('data', (chunk: string) => { body += chunk; });
res.on('end', () => resolve(body)); res.on('end', () => resolve(body));
@@ -620,16 +653,53 @@ async function launchApp(): Promise<void> {
app.quit(); app.quit();
}); });
// ── Alt manager IPC handlers ── // ── Alt manager IPC handlers (credentials encrypted via safeStorage) ──
ipcMain.handle('alt-list', () => config.get('accounts') || []); const canEncrypt = safeStorage.isEncryptionAvailable();
if (!canEncrypt) electronLog.warn('[KCC] safeStorage encryption not available — account passwords will use base64 fallback');
ipcMain.handle('alt-save', (_e, account: SavedAccount) => { 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') || []; 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); accounts.push(account);
config.set('accounts', accounts); config.set('accounts', accounts);
return { success: true, index: accounts.length - 1 }; 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) => { ipcMain.handle('alt-remove', (_e, index: number) => {
const accounts = config.get('accounts') || []; const accounts = config.get('accounts') || [];
if (index < 0 || index >= accounts.length) return { success: false }; if (index < 0 || index >= accounts.length) return { success: false };
+1 -1
View File
@@ -67,7 +67,7 @@ function makeLogger(getStream: () => WriteStream) {
export const electronLog = makeLogger(() => electronStream); export const electronLog = makeLogger(() => electronStream);
export function getLogPath(type: 'electron'): string { export function getLogPath(_type: 'electron'): string {
init(); init();
return electronPath; return electronPath;
} }
+46 -10
View File
@@ -1,10 +1,24 @@
import { existsSync, mkdirSync, promises as fsp } from 'fs'; import { existsSync, mkdirSync, promises as fsp } from 'fs';
import { join } from 'path'; import { join } from 'path';
import { protocol, net } from 'electron'; import { protocol, net, Session } from 'electron';
const PROTOCOL_NAME = 'kpc-swap'; const PROTOCOL_NAME = 'kpc-swap';
const TARGET_DOMAIN = 'krunker.io'; const TARGET_DOMAIN = 'krunker.io';
const STANDARD_ASSET_RE = /^\/(?:models|textures|sound|scares|videos)(?:$|\/)/u;
/**
* 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. * Register the custom protocol scheme. Must be called BEFORE app.ready.
@@ -12,17 +26,32 @@ const STANDARD_ASSET_RE = /^\/(?:models|textures|sound|scares|videos)(?:$|\/)/u;
export function initSwapperProtocol(): void { export function initSwapperProtocol(): void {
protocol.registerSchemesAsPrivileged([{ protocol.registerSchemesAsPrivileged([{
scheme: PROTOCOL_NAME, scheme: PROTOCOL_NAME,
privileges: { secure: true, corsEnabled: true, bypassCSP: true }, privileges: { standard: true, secure: true, corsEnabled: true, bypassCSP: true },
}]); }]);
} }
/** /**
* Register the file protocol handler. Must be called AFTER app.ready. * Register the file protocol handler on the given session.
* Must be called AFTER app.ready.
*/ */
export function registerSwapperFileProtocol(): void { export function registerSwapperFileProtocol(ses: Session): void {
protocol.handle(PROTOCOL_NAME, (request) => { ses.protocol.handle(PROTOCOL_NAME, async (request) => {
const filePath = decodeURI(request.url.replace(`${PROTOCOL_NAME}:`, '')); const url = new URL(request.url);
return net.fetch('file://' + filePath); // 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 });
}
}); });
} }
@@ -48,6 +77,13 @@ export class ResourceSwapper {
this.ready = true; 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 */ /** URL filter patterns for webRequest.onBeforeRequest — single broad pattern */
get patterns(): string[] { get patterns(): string[] {
return this.swapFiles.size > 0 ? [`*://*.${TARGET_DOMAIN}/*`] : []; return this.swapFiles.size > 0 ? [`*://*.${TARGET_DOMAIN}/*`] : [];
@@ -71,7 +107,7 @@ export class ResourceSwapper {
let pathname = queryStart === -1 ? url.substring(pathStart) : url.substring(pathStart, queryStart); let pathname = queryStart === -1 ? url.substring(pathStart) : url.substring(pathStart, queryStart);
if (pathname.startsWith('/assets/')) pathname = pathname.substring(7); if (pathname.startsWith('/assets/')) pathname = pathname.substring(7);
const localPath = this.swapFiles.get(pathname); const localPath = this.swapFiles.get(pathname);
if (localPath) return `${PROTOCOL_NAME}:/${localPath}`; if (localPath) return filePathToSwapURL(localPath);
} catch { /* malformed URL — ignore */ } } catch { /* malformed URL — ignore */ }
return null; return null;
} }
@@ -88,7 +124,7 @@ export class ResourceSwapper {
this.swapFiles.set(name, join(this.swapDir, name)); this.swapFiles.set(name, join(this.swapDir, name));
} }
} }
} catch (err) { } catch {
console.error(`Failed to scan swap directory prefix: ${prefix}`); console.error(`Failed to scan swap directory prefix: ${prefix}`);
} }
} }
+45 -12
View File
@@ -1,4 +1,4 @@
import { get as httpsGet, request as httpsRequest } from 'https'; import { get as httpsGet } from 'https';
import { createWriteStream, renameSync, unlinkSync, existsSync } from 'fs'; import { createWriteStream, renameSync, unlinkSync, existsSync } from 'fs';
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import { app } from 'electron'; import { app } from 'electron';
@@ -16,12 +16,25 @@ const UPDATE_CONFIG = {
// Gitea provider (swap these for kpdclient.com migration) // Gitea provider (swap these for kpdclient.com migration)
checkUrl: 'https://gitea.crjlab.net/api/v1/repos/bigjakk/Krunker-Civilian-Client/releases/latest', checkUrl: 'https://gitea.crjlab.net/api/v1/repos/bigjakk/Krunker-Civilian-Client/releases/latest',
assetPattern: /Setup\.exe$/i, assetPattern: /Setup\.exe$/i,
rejectUnauthorized: false, // Allowed hosts for update check and download (including redirects)
allowedHosts: ['gitea.crjlab.net'],
}; };
const CHECK_TIMEOUT_MS = 10000; const CHECK_TIMEOUT_MS = 10000;
const DOWNLOAD_TIMEOUT_MS = 300000; // 5 minutes 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. * Simple semver comparison: returns true if a < b.
* Handles versions like "0.1.0", "1.2.3". * Handles versions like "0.1.0", "1.2.3".
@@ -45,15 +58,19 @@ export function checkForUpdate(currentVersion: string): Promise<UpdateInfo | nul
electronLog.log('[KCC-Update] Current version:', currentVersion); electronLog.log('[KCC-Update] Current version:', currentVersion);
const req = httpsGet(UPDATE_CONFIG.checkUrl, { const req = httpsGet(UPDATE_CONFIG.checkUrl, {
rejectUnauthorized: UPDATE_CONFIG.rejectUnauthorized,
headers: { 'User-Agent': 'KrunkerCivilianClient/' + currentVersion }, headers: { 'User-Agent': 'KrunkerCivilianClient/' + currentVersion },
}, (res) => { }, (res) => {
electronLog.log('[KCC-Update] Check response status:', res.statusCode); electronLog.log('[KCC-Update] Check response status:', res.statusCode);
// Follow redirects // Follow redirects (with domain validation)
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
electronLog.log('[KCC-Update] Redirected to:', res.headers.location); const redirectUrl = res.headers.location;
httpsGet(res.headers.location, { electronLog.log('[KCC-Update] Redirected to:', redirectUrl);
rejectUnauthorized: UPDATE_CONFIG.rejectUnauthorized, if (!isAllowedRedirect(redirectUrl)) {
electronLog.error('[KCC-Update] Redirect to untrusted host blocked:', redirectUrl);
resolve(null);
return;
}
httpsGet(redirectUrl, {
headers: { 'User-Agent': 'KrunkerCivilianClient/' + currentVersion }, headers: { 'User-Agent': 'KrunkerCivilianClient/' + currentVersion },
}, (redirectRes) => { }, (redirectRes) => {
electronLog.log('[KCC-Update] Redirect response status:', redirectRes.statusCode); electronLog.log('[KCC-Update] Redirect response status:', redirectRes.statusCode);
@@ -97,6 +114,13 @@ export function checkForUpdate(currentVersion: string): Promise<UpdateInfo | nul
return; 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); electronLog.log('[KCC-Update] Update available:', remoteVersion, '| Download:', setupAsset.browser_download_url, '| Size:', setupAsset.size);
resolve({ resolve({
version: remoteVersion, version: remoteVersion,
@@ -131,16 +155,25 @@ export function downloadUpdate(url: string, destPath: string, onProgress: Progre
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const tmpPath = destPath + '.tmp'; const tmpPath = destPath + '.tmp';
function doDownload(downloadUrl: string): void { function doDownload(downloadUrl: string, redirectCount = 0): void {
if (redirectCount > 5) {
reject(new Error('Too many redirects'));
return;
}
electronLog.log('[KCC-Update] Downloading from:', downloadUrl); electronLog.log('[KCC-Update] Downloading from:', downloadUrl);
const req = httpsGet(downloadUrl, { const req = httpsGet(downloadUrl, {
rejectUnauthorized: UPDATE_CONFIG.rejectUnauthorized,
headers: { 'User-Agent': 'KrunkerCivilianClient' }, headers: { 'User-Agent': 'KrunkerCivilianClient' },
}, (res) => { }, (res) => {
// Follow redirects // Follow redirects (with domain validation)
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
electronLog.log('[KCC-Update] Download redirected to:', res.headers.location); const redirectUrl = res.headers.location;
doDownload(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; return;
} }
+1 -1
View File
@@ -49,7 +49,7 @@ export class UserscriptManager {
/** Load tracker.json, add new scripts as disabled, prune deleted scripts */ /** Load tracker.json, add new scripts as disabled, prune deleted scripts */
async loadTracker(scripts: ScriptFile[]): Promise<ScriptTracker> { async loadTracker(scripts: ScriptFile[]): Promise<ScriptTracker> {
let tracker: ScriptTracker = {}; let tracker: ScriptTracker;
try { try {
tracker = JSON.parse(await fsp.readFile(this.trackerPath, 'utf-8')); tracker = JSON.parse(await fsp.readFile(this.trackerPath, 'utf-8'));
} catch { tracker = {}; } } catch { tracker = {}; }
+51 -52
View File
@@ -2,14 +2,15 @@ import { ipcRenderer } from 'electron';
import { fetchGame, MATCHMAKER_GAMEMODES, MATCHMAKER_REGIONS, MATCHMAKER_REGION_NAMES } from './matchmaker'; import { fetchGame, MATCHMAKER_GAMEMODES, MATCHMAKER_REGIONS, MATCHMAKER_REGION_NAMES } from './matchmaker';
import type { MatchmakerConfig } from './matchmaker'; import type { MatchmakerConfig } from './matchmaker';
import { initUserscripts, getInstances, setScriptEnabled } from './userscripts'; import { initUserscripts, getInstances, setScriptEnabled } from './userscripts';
import type { UserscriptInstance, UserscriptSetting } from './userscripts'; import type { UserscriptInstance } from './userscripts';
import { initTranslator, updateTranslatorConfig } from './translator'; import { initTranslator, updateTranslatorConfig } from './translator';
import { setDeathAnimBlock, escapeHtml } from './utils'; import { setDeathAnimBlock, escapeHtml } from './utils';
import type { Keybind } from '../main/config';
// ── Save console methods before Krunker overwrites them ── // ── Save console methods before Krunker overwrites them ──
// Wrapped to forward errors/warnings always, and logs when verbose is enabled // Wrapped to forward errors/warnings always, and logs when verbose is enabled
let _verboseLogging = false; const _verboseLogging = false;
const _console = { const _console = {
log: (...args: unknown[]) => { log: (...args: unknown[]) => {
@@ -74,7 +75,7 @@ function updateRefreshNotification(): void {
if (refreshPopupEl) { refreshPopupEl.remove(); refreshPopupEl = null; } if (refreshPopupEl) { refreshPopupEl.remove(); refreshPopupEl = null; }
return; return;
} }
if (refreshPopupEl) { try { refreshPopupEl.remove(); } catch (_e) { /* noop */ } } if (refreshPopupEl) { try { refreshPopupEl.remove(); } catch { /* noop */ } }
refreshPopupEl = document.createElement('div'); refreshPopupEl = document.createElement('div');
refreshPopupEl.className = 'kpc-holder-update refresh-popup'; refreshPopupEl.className = 'kpc-holder-update refresh-popup';
if (refreshLevel === RefreshLevel.restart) { if (refreshLevel === RefreshLevel.restart) {
@@ -119,13 +120,7 @@ function updateRefreshNotification(): void {
// ── Client settings tab in Krunker's settings ── // ── Client settings tab in Krunker's settings ──
function hasOwn(obj: any, key: string): boolean { // ── Keybind helpers ──
return Object.prototype.hasOwnProperty.call(obj, key);
}
// ── Keybind types + helpers ──
interface Keybind { key: string; ctrl: boolean; shift: boolean; alt: boolean; }
function keybindDisplayString(bind: Keybind): string { function keybindDisplayString(bind: Keybind): string {
return (bind.shift ? 'Shift+' : '') + (bind.ctrl ? 'Ctrl+' : '') + (bind.alt ? 'Alt+' : '') + bind.key.toUpperCase(); return (bind.shift ? 'Shift+' : '') + (bind.ctrl ? 'Ctrl+' : '') + (bind.alt ? 'Alt+' : '') + bind.key.toUpperCase();
} }
@@ -377,7 +372,7 @@ function createCheckboxGrid(opts: {
const label = document.createElement('label'); const label = document.createElement('label');
label.className = 'hostOpt'; label.className = 'hostOpt';
label.innerHTML = label.innerHTML =
'<span class="optName">' + item.label + '</span>' + '<span class="optName">' + escapeHtml(item.label) + '</span>' +
'<input type="checkbox"' + (opts.selected.includes(item.value) ? ' checked' : '') + '>' + '<input type="checkbox"' + (opts.selected.includes(item.value) ? ' checked' : '') + '>' +
'<div class="optCheck"></div>'; '<div class="optCheck"></div>';
const cb = label.querySelector('input') as HTMLInputElement; const cb = label.querySelector('input') as HTMLInputElement;
@@ -595,7 +590,7 @@ function buildSwapperSection(body: HTMLElement, swapperConf: any): void {
} }
function buildMatchmakerSection(body: HTMLElement, mmConf: any, bag: SettingsBag): void { function buildMatchmakerSection(body: HTMLElement, mmConf: any, bag: SettingsBag): void {
const mm = mmConf || { enabled: true, regions: [], gamemodes: [], minPlayers: 1, maxPlayers: 6, minRemainingTime: 120, openServerBrowser: true }; const mm = mmConf || { enabled: true, regions: [], gamemodes: [], minPlayers: 1, maxPlayers: 6, minRemainingTime: 120, openServerBrowser: true, autoJoin: false };
function saveMM(): void { function saveMM(): void {
ipcRenderer.invoke('set-config', 'matchmaker', mm); ipcRenderer.invoke('set-config', 'matchmaker', mm);
@@ -615,6 +610,13 @@ function buildMatchmakerSection(body: HTMLElement, mmConf: any, bag: SettingsBag
onChange: (v) => { mm.openServerBrowser = v; saveMM(); }, onChange: (v) => { mm.openServerBrowser = v; saveMM(); },
})); }));
body.appendChild(createToggleRow({
label: 'Auto-Join',
desc: 'Automatically join the best match without showing the popup',
checked: mm.autoJoin ?? false, instant: true,
onChange: (v) => { mm.autoJoin = v; saveMM(); },
}));
body.appendChild(createKeybindRow('Matchmaker Hotkey', 'Key to trigger the custom matchmaker', bag.binds.matchmaker, (b) => { body.appendChild(createKeybindRow('Matchmaker Hotkey', 'Key to trigger the custom matchmaker', bag.binds.matchmaker, (b) => {
bag.binds.matchmaker = b; bag.binds.matchmaker = b;
bag.saveBinds(); bag.saveBinds();
@@ -677,19 +679,6 @@ function buildDiscordSection(body: HTMLElement, discordConf: any): void {
} }
// ── Alt Manager helpers ── // ── Alt Manager helpers ──
function encodeCredential(decoded: string): string {
const key = decoded.length;
return encodeURIComponent(
decoded.split('').map(c => String.fromCharCode(c.charCodeAt(0) + key)).join('')
);
}
function decodeCredential(encoded: string): string {
const str = decodeURIComponent(encoded);
const key = str.length;
return str.split('').map(c => String.fromCharCode(c.charCodeAt(0) - key)).join('');
}
function switchToAccount(account: { username: string; password: string }): void { function switchToAccount(account: { username: string; password: string }): void {
const w = window as any; const w = window as any;
if (typeof w.loginOrRegister !== 'function') return; if (typeof w.loginOrRegister !== 'function') return;
@@ -703,8 +692,8 @@ function switchToAccount(account: { username: string; password: string }): void
const nameInput = document.querySelector('#accName') as HTMLInputElement; const nameInput = document.querySelector('#accName') as HTMLInputElement;
const passInput = document.querySelector('#accPass') as HTMLInputElement; const passInput = document.querySelector('#accPass') as HTMLInputElement;
if (!nameInput || !passInput) return; if (!nameInput || !passInput) return;
nameInput.value = decodeCredential(account.username); nameInput.value = account.username;
passInput.value = decodeCredential(account.password); passInput.value = account.password;
nameInput.dispatchEvent(new Event('input', { bubbles: true })); nameInput.dispatchEvent(new Event('input', { bubbles: true }));
passInput.dispatchEvent(new Event('input', { bubbles: true })); passInput.dispatchEvent(new Event('input', { bubbles: true }));
const submitBtn = document.querySelector('.io-button') as HTMLElement; const submitBtn = document.querySelector('.io-button') as HTMLElement;
@@ -749,6 +738,11 @@ function buildAccountsSection(body: HTMLElement, accountsArr: any[]): void {
const userIn = form.querySelector('.kpc-acc-user') as HTMLInputElement; const userIn = form.querySelector('.kpc-acc-user') as HTMLInputElement;
const passIn = form.querySelector('.kpc-acc-pass') as HTMLInputElement; const passIn = form.querySelector('.kpc-acc-pass') as HTMLInputElement;
// Stop Krunker's global keydown handler from eating keystrokes in our inputs
form.querySelectorAll('input').forEach(input => {
input.addEventListener('keydown', (e) => e.stopPropagation());
});
addBtn.querySelector('button')!.addEventListener('click', () => { addBtn.querySelector('button')!.addEventListener('click', () => {
form.style.display = form.style.display === 'none' ? '' : 'none'; form.style.display = form.style.display === 'none' ? '' : 'none';
}); });
@@ -777,7 +771,11 @@ function buildAccountsSection(body: HTMLElement, accountsArr: any[]): void {
'<button class="kpc-acc-switch">Switch</button>' + '<button class="kpc-acc-switch">Switch</button>' +
'<button class="kpc-acc-delete">Delete</button>' + '<button class="kpc-acc-delete">Delete</button>' +
'</div>'; '</div>';
row.querySelector('.kpc-acc-switch')!.addEventListener('click', () => switchToAccount(acc)); row.querySelector('.kpc-acc-switch')!.addEventListener('click', () => {
ipcRenderer.invoke('alt-get-credentials', i).then((creds: { username: string; password: string } | null) => {
if (creds) switchToAccount(creds);
});
});
row.querySelector('.kpc-acc-delete')!.addEventListener('click', () => { row.querySelector('.kpc-acc-delete')!.addEventListener('click', () => {
ipcRenderer.invoke('alt-remove', i).then(() => { ipcRenderer.invoke('alt-remove', i).then(() => {
accounts.splice(i, 1); accounts.splice(i, 1);
@@ -794,13 +792,9 @@ function buildAccountsSection(body: HTMLElement, accountsArr: any[]): void {
const user = userIn.value.trim(); const user = userIn.value.trim();
const pass = passIn.value; const pass = passIn.value;
if (!label || !user || !pass) return; if (!label || !user || !pass) return;
const newAcc = { const newAcc = { label, username: user, password: pass };
label,
username: encodeCredential(user),
password: encodeCredential(pass),
};
ipcRenderer.invoke('alt-save', newAcc).then(() => { ipcRenderer.invoke('alt-save', newAcc).then(() => {
accounts.push(newAcc); accounts.push({ label });
labelIn.value = ''; labelIn.value = '';
userIn.value = ''; userIn.value = '';
passIn.value = ''; passIn.value = '';
@@ -1127,12 +1121,12 @@ function renderUserscriptsSection(body: HTMLElement): void {
const scriptRow = document.createElement('div'); const scriptRow = document.createElement('div');
scriptRow.className = 'setting settName safety-0 bool'; scriptRow.className = 'setting settName safety-0 bool';
const displayName = inst.meta.name || inst.filename; const displayName = escapeHtml(inst.meta.name || inst.filename);
let metaParts: string[] = []; const metaParts: string[] = [];
if (inst.meta.author) metaParts.push('by ' + inst.meta.author); if (inst.meta.author) metaParts.push('by ' + escapeHtml(inst.meta.author));
if (inst.meta.version) metaParts.push('v' + inst.meta.version); if (inst.meta.version) metaParts.push('v' + escapeHtml(inst.meta.version));
const metaLine = metaParts.length > 0 ? '<span class="kpc-us-meta">' + metaParts.join(' &middot; ') + '</span>' : ''; const metaLine = metaParts.length > 0 ? '<span class="kpc-us-meta">' + metaParts.join(' &middot; ') + '</span>' : '';
const descText = inst.meta.desc || ''; const descText = escapeHtml(inst.meta.desc || '');
scriptRow.innerHTML = scriptRow.innerHTML =
'<span class="setting-title">' + displayName + '</span>' + '<span class="setting-title">' + displayName + '</span>' +
@@ -1174,8 +1168,8 @@ function renderScriptSettings(inst: UserscriptInstance, container: HTMLElement):
const row = document.createElement('div'); const row = document.createElement('div');
row.className = 'setting settName safety-0' + (typeClass ? ' ' + typeClass : ''); row.className = 'setting settName safety-0' + (typeClass ? ' ' + typeClass : '');
row.innerHTML = row.innerHTML =
'<span class="setting-title">' + setting.title + '</span>' + '<span class="setting-title">' + escapeHtml(setting.title) + '</span>' +
(setting.desc ? '<div class="setting-desc-new">' + setting.desc + '</div>' : ''); (setting.desc ? '<div class="setting-desc-new">' + escapeHtml(setting.desc) + '</div>' : '');
switch (setting.type) { switch (setting.type) {
case 'bool': { case 'bool': {
@@ -1365,7 +1359,7 @@ ipcRenderer.on('main_did-finish-load', () => {
Promise.all([ Promise.all([
ipcRenderer.invoke('get-all-config', ['ui', 'userscripts', 'game', 'translator', 'keybinds', 'discord']), ipcRenderer.invoke('get-all-config', ['ui', 'userscripts', 'game', 'translator', 'keybinds', 'discord']),
ipcRenderer.invoke('get-platform'), ipcRenderer.invoke('get-platform'),
]).then(([allConf, platformInfo]: [any, any]) => { ]).then(([allConf, _platformInfo]: [any, any]) => {
const uiConf = allConf.ui; const uiConf = allConf.ui;
const usConf = allConf.userscripts; const usConf = allConf.userscripts;
const gameConf = allConf.game; const gameConf = allConf.game;
@@ -1430,7 +1424,7 @@ ipcRenderer.on('main_did-finish-load', () => {
let gameStartTimestamp = Math.floor(Date.now() / 1000); let gameStartTimestamp = Math.floor(Date.now() / 1000);
function pollDiscordState(): void { function pollDiscordState(): void {
let details = ''; let details: string;
let state = ''; let state = '';
let startTimestamp: number | undefined = undefined; let startTimestamp: number | undefined = undefined;
@@ -1439,7 +1433,7 @@ ipcRenderer.on('main_did-finish-load', () => {
let gameActivity: any = null; let gameActivity: any = null;
if (typeof w.getGameActivity === 'function') { if (typeof w.getGameActivity === 'function') {
try { gameActivity = w.getGameActivity(); } catch {} try { gameActivity = w.getGameActivity(); } catch { /* game API unavailable */ }
} }
if (spectating) { if (spectating) {
@@ -1493,9 +1487,7 @@ ipcRenderer.on('main_did-finish-load', () => {
} }
// ── In-game Accounts quick-switch button ── // ── In-game Accounts quick-switch button ──
if (isGamePage) { if (isGamePage) {
ipcRenderer.invoke('alt-list').then((accounts: any[]) => { ipcRenderer.invoke('alt-list').then(() => {
if (!accounts || accounts.length === 0) return;
const altBtn = document.createElement('div'); const altBtn = document.createElement('div');
altBtn.id = 'kpcAltBtn'; altBtn.id = 'kpcAltBtn';
altBtn.className = 'menuItem'; altBtn.className = 'menuItem';
@@ -1557,7 +1549,9 @@ ipcRenderer.on('main_did-finish-load', () => {
const idx = parseInt((el as HTMLElement).dataset.idx || '0', 10); const idx = parseInt((el as HTMLElement).dataset.idx || '0', 10);
if (accs[idx]) { if (accs[idx]) {
windowHolder.style.display = 'none'; windowHolder.style.display = 'none';
switchToAccount(accs[idx]); ipcRenderer.invoke('alt-get-credentials', idx).then((creds: { username: string; password: string } | null) => {
if (creds) switchToAccount(creds);
});
} }
}); });
}); });
@@ -1586,6 +1580,11 @@ ipcRenderer.on('main_did-finish-load', () => {
'</div>' + '</div>' +
'</div>'; '</div>';
// Stop Krunker's global keydown handler from eating keystrokes in our inputs
menuWindow.querySelectorAll('input.accountInput').forEach((input) => {
input.addEventListener('keydown', (e) => e.stopPropagation());
});
document.getElementById('kpcAltBackBtn')!.addEventListener('click', renderAccountList); document.getElementById('kpcAltBackBtn')!.addEventListener('click', renderAccountList);
document.getElementById('kpcAltSaveBtn')!.addEventListener('click', () => { document.getElementById('kpcAltSaveBtn')!.addEventListener('click', () => {
const label = (document.getElementById('kpcAltLabel') as HTMLInputElement).value.trim(); const label = (document.getElementById('kpcAltLabel') as HTMLInputElement).value.trim();
@@ -1594,8 +1593,8 @@ ipcRenderer.on('main_did-finish-load', () => {
if (!label || !user || !pass) return; if (!label || !user || !pass) return;
ipcRenderer.invoke('alt-save', { ipcRenderer.invoke('alt-save', {
label, label,
username: encodeCredential(user), username: user,
password: encodeCredential(pass), password: pass,
}).then(() => renderAccountList()); }).then(() => renderAccountList());
}); });
} }
@@ -1636,9 +1635,9 @@ ipcRenderer.on('main_did-finish-load', () => {
const pollInterval = setInterval(() => { const pollInterval = setInterval(() => {
const w = window as any; const w = window as any;
if ( if (
hasOwn(w, 'showWindow') Object.hasOwn(w, 'showWindow')
&& typeof w.showWindow === 'function' && typeof w.showWindow === 'function'
&& hasOwn(w, 'windows') && Object.hasOwn(w, 'windows')
&& Array.isArray(w.windows) && Array.isArray(w.windows)
&& w.windows.length >= 0 && w.windows.length >= 0
&& typeof w.windows[0] !== 'undefined' && typeof w.windows[0] !== 'undefined'
+117 -57
View File
@@ -1,7 +1,8 @@
// ── Custom Matchmaker (ported from Crankshaft) ── // ── Custom Matchmaker (ported from Crankshaft) ──
// Fetches live lobby list from matchmaker.krunker.io, filters by user criteria, // Fetches live lobby list from matchmaker.krunker.io, filters by user criteria,
// presents a popup to join a random matching game. // sorts by lowest ping then highest player count, and joins the best match.
import { ipcRenderer } from 'electron';
import type { Keybind } from '../main/config'; import type { Keybind } from '../main/config';
import type { SavedConsole } from './utils'; import type { SavedConsole } from './utils';
@@ -28,6 +29,7 @@ export interface MatchmakerConfig {
maxPlayers: number; maxPlayers: number;
minRemainingTime: number; minRemainingTime: number;
openServerBrowser: boolean; openServerBrowser: boolean;
autoJoin: boolean;
acceptKey: Keybind; acceptKey: Keybind;
cancelKey: Keybind; cancelKey: Keybind;
} }
@@ -39,42 +41,60 @@ function secondsToTimestring(num: number): string {
return `${minutes}m ${seconds}s`; return `${minutes}m ${seconds}s`;
} }
// ── Popup DOM (created once, reused) ── // ── Popup DOM (lazy-initialized on first use) ──
const POPUP_ID = 'matchmakerPopupContainer'; const POPUP_ID = 'matchmakerPopupContainer';
const popupElement = document.createElement('div');
popupElement.id = POPUP_ID;
const popupTitle = document.createElement('div'); interface PopupDOM {
popupTitle.id = 'matchmakerPopupTitle'; element: HTMLDivElement;
popupElement.appendChild(popupTitle); title: HTMLDivElement;
description: HTMLDivElement;
confirmBtn: HTMLDivElement;
cancelBtn: HTMLDivElement;
}
const popupDescription = document.createElement('div'); let _popup: PopupDOM | null = null;
popupDescription.id = 'matchmakerPopupDescription';
popupElement.appendChild(popupDescription);
const popupOptions = document.createElement('div'); function getPopup(): PopupDOM {
popupOptions.id = 'matchmakerPopupOptions'; if (_popup) return _popup;
const popupConfirmBtn = document.createElement('div'); const element = document.createElement('div');
popupConfirmBtn.id = 'matchmakerConfirmButton'; element.id = POPUP_ID;
popupConfirmBtn.className = 'matchmakerPopupButton bigShadowT';
popupConfirmBtn.textContent = 'Join';
popupConfirmBtn.setAttribute('onmouseenter', 'playTick()');
popupConfirmBtn.addEventListener('click', () => decideMatchmakerDecision(true));
const popupCancelBtn = document.createElement('div'); const title = document.createElement('div');
popupCancelBtn.id = 'matchmakerCancelButton'; title.id = 'matchmakerPopupTitle';
popupCancelBtn.className = 'matchmakerPopupButton bigShadowT'; element.appendChild(title);
popupCancelBtn.textContent = 'Cancel';
popupCancelBtn.setAttribute('onmouseenter', 'playTick()');
popupCancelBtn.addEventListener('click', () => decideMatchmakerDecision(false));
popupOptions.appendChild(popupConfirmBtn); const description = document.createElement('div');
popupOptions.appendChild(popupCancelBtn); description.id = 'matchmakerPopupDescription';
popupElement.appendChild(popupOptions); element.appendChild(description);
const options = document.createElement('div');
options.id = 'matchmakerPopupOptions';
const confirmBtn = document.createElement('div');
confirmBtn.id = 'matchmakerConfirmButton';
confirmBtn.className = 'matchmakerPopupButton bigShadowT';
confirmBtn.textContent = 'Join';
confirmBtn.addEventListener('mouseenter', () => { (window as any).playTick?.(); });
confirmBtn.addEventListener('click', () => decideMatchmakerDecision(true));
const cancelBtn = document.createElement('div');
cancelBtn.id = 'matchmakerCancelButton';
cancelBtn.className = 'matchmakerPopupButton bigShadowT';
cancelBtn.textContent = 'Cancel';
cancelBtn.addEventListener('mouseenter', () => { (window as any).playTick?.(); });
cancelBtn.addEventListener('click', () => decideMatchmakerDecision(false));
options.appendChild(confirmBtn);
options.appendChild(cancelBtn);
element.appendChild(options);
_popup = { element, title, description, confirmBtn, cancelBtn };
return _popup;
}
// ── State ── // ── State ──
let currentMatch = ''; let popupGameID = '';
let openServerBrowser = true; let openServerBrowser = true;
let confirmKey: Keybind = { key: 'Enter', ctrl: false, shift: false, alt: false }; let confirmKey: Keybind = { key: 'Enter', ctrl: false, shift: false, alt: false };
let cancelKey: Keybind = { key: 'Escape', ctrl: false, shift: false, alt: false }; let cancelKey: Keybind = { key: 'Escape', ctrl: false, shift: false, alt: false };
@@ -83,11 +103,12 @@ function decideMatchmakerDecision(accept: boolean): void {
const w = window as any; const w = window as any;
if (typeof w.playSelect === 'function') w.playSelect(); if (typeof w.playSelect === 'function') w.playSelect();
if (accept && currentMatch !== 'none') { if (accept && popupGameID !== 'none') {
window.location.href = `https://krunker.io/?game=${currentMatch}`; window.location.href = `https://krunker.io/?game=${popupGameID}`;
} else { } else {
if (popupElement.parentNode) popupElement.remove(); const popup = getPopup();
if (currentMatch === 'none' && openServerBrowser && typeof w.openServerWindow === 'function') { if (popup.element.parentNode) popup.element.remove();
if (popupGameID === 'none' && openServerBrowser && typeof w.openServerWindow === 'function') {
w.openServerWindow(0); w.openServerWindow(0);
} }
} }
@@ -112,36 +133,35 @@ function handleMatchmakerBind(event: KeyboardEvent): void {
} }
function createFetchedGamePopup(game: MatchmakerGame): void { function createFetchedGamePopup(game: MatchmakerGame): void {
const popup = getPopup();
const mapIdx = MAP_ICON_INDICES.indexOf(game.map); const mapIdx = MAP_ICON_INDICES.indexOf(game.map);
popupElement.style.backgroundImage = `url(https://assets.krunker.io/img/maps/map_${mapIdx >= 0 ? mapIdx : 0}.png)`; popup.element.style.backgroundImage = `url(https://assets.krunker.io/img/maps/map_${mapIdx >= 0 ? mapIdx : 0}.png)`;
currentMatch = game.gameID; popupGameID = game.gameID;
if (game.gameID === 'none') { if (game.gameID === 'none') {
popupTitle.innerText = 'No Games Found...'; popup.title.textContent = 'No Games Found...';
popupDescription.innerHTML = 'Check the server browser to see other lobbies.'; popup.description.textContent = 'Check the server browser to see other lobbies.';
popupConfirmBtn.style.display = 'none'; popup.confirmBtn.style.display = 'none';
} else { } else {
popupTitle.innerText = 'Game Found!'; popup.title.textContent = 'Game Found!';
const regionName = MATCHMAKER_REGION_NAMES[game.region] ?? 'Unknown Region'; 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`; popup.description.textContent = '';
popupConfirmBtn.style.display = 'block'; popup.description.appendChild(document.createTextNode(
`${game.gamemode} on ${game.map} (${regionName})`
));
popup.description.appendChild(document.createElement('br'));
popup.description.appendChild(document.createTextNode(
`${game.playerCount}/${game.playerLimit} Players, ${secondsToTimestring(game.remainingTime)} Left`
));
popup.confirmBtn.style.display = 'block';
} }
document.addEventListener('keydown', handleMatchmakerBind, true); document.addEventListener('keydown', handleMatchmakerBind, true);
const uiBase = document.getElementById('uiBase'); const uiBase = document.getElementById('uiBase');
if (uiBase) uiBase.appendChild(popupElement); if (uiBase) uiBase.appendChild(popup.element);
} }
export async function fetchGame(mmConfig: MatchmakerConfig, _con?: SavedConsole): Promise<void> { async function fetchAndFilterGames(mmConfig: MatchmakerConfig): Promise<MatchmakerGame[]> {
openServerBrowser = mmConfig.openServerBrowser;
confirmKey = mmConfig.acceptKey;
cancelKey = mmConfig.cancelKey;
// Dismiss existing popup if active
if (document.getElementById(POPUP_ID)) decideMatchmakerDecision(false);
_con?.log('[KCC-MM] Fetching game list...');
const response = await fetch(`https://matchmaker.krunker.io/game-list?hostname=${window.location.hostname}`); const response = await fetch(`https://matchmaker.krunker.io/game-list?hostname=${window.location.hostname}`);
const result = await response.json(); const result = await response.json();
const games: MatchmakerGame[] = []; const games: MatchmakerGame[] = [];
@@ -155,7 +175,6 @@ export async function fetchGame(mmConfig: MatchmakerConfig, _con?: SavedConsole)
const gamemode = MATCHMAKER_GAMEMODES[game[4].g] ?? 'Unknown Gamemode'; const gamemode = MATCHMAKER_GAMEMODES[game[4].g] ?? 'Unknown Gamemode';
const remainingTime: number = game[5]; const remainingTime: number = game[5];
// Apply filters — empty arrays mean "all selected" (no filter)
if (mmConfig.regions.length > 0 && !mmConfig.regions.includes(region)) continue; if (mmConfig.regions.length > 0 && !mmConfig.regions.includes(region)) continue;
if (mmConfig.gamemodes.length > 0 && !mmConfig.gamemodes.includes(gamemode)) continue; if (mmConfig.gamemodes.length > 0 && !mmConfig.gamemodes.includes(gamemode)) continue;
if (playerCount < mmConfig.minPlayers) continue; if (playerCount < mmConfig.minPlayers) continue;
@@ -163,19 +182,60 @@ export async function fetchGame(mmConfig: MatchmakerConfig, _con?: SavedConsole)
if (remainingTime < mmConfig.minRemainingTime) continue; if (remainingTime < mmConfig.minRemainingTime) continue;
if (playerCount === playerLimit) continue; if (playerCount === playerLimit) continue;
if (window.location.href.includes(gameID)) continue; if (window.location.href.includes(gameID)) continue;
if (currentMatch === gameID) continue;
games.push({ gameID, region, playerCount, playerLimit, map, gamemode, remainingTime }); games.push({ gameID, region, playerCount, playerLimit, map, gamemode, remainingTime });
} }
_con?.log('[KCC-MM] Received', result.games?.length ?? 0, 'games,', games.length, 'passed filters'); return games;
}
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
if (document.getElementById(POPUP_ID)) decideMatchmakerDecision(false);
_con?.log('[KCC-MM] Fetching game list + pings...');
const [games, pings] = await Promise.all([
fetchAndFilterGames(mmConfig),
ipcRenderer.invoke('ping-regions').catch(() => ({} as Record<string, number>)),
]);
_con?.log('[KCC-MM]', games.length, 'games passed filters, pings:', pings);
if (games.length > 0) { if (games.length > 0) {
const selected = games[Math.floor(Math.random() * games.length)]; sortByPingThenPlayers(games, pings);
_con?.log('[KCC-MM] Selected:', selected.gameID, selected.region, selected.map); const best = games[0];
createFetchedGamePopup(selected); _con?.log('[KCC-MM] Best match:', best.gameID, best.region, best.map, `(${pings[best.region] ?? '?'}ms)`);
if (mmConfig.autoJoin) {
window.location.href = `https://krunker.io/?game=${best.gameID}`;
return;
}
createFetchedGamePopup(best);
} else { } else {
_con?.log('[KCC-MM] No matching games found'); _con?.log('[KCC-MM] No matching games found');
if (mmConfig.autoJoin) {
if (openServerBrowser && typeof (window as any).openServerWindow === 'function') {
(window as any).openServerWindow(0);
}
return;
}
createFetchedGamePopup({ createFetchedGamePopup({
gameID: 'none', gameID: 'none',
region: 'none', region: 'none',
+1
View File
@@ -138,6 +138,7 @@ const SYSTEM_PATTERNS = [
// ── Pre-translation filtering ── // ── Pre-translation filtering ──
function isLatinOnly(text: string): boolean { function isLatinOnly(text: string): boolean {
// eslint-disable-next-line no-control-regex
return /^[\x00-\x7F\u00C0-\u024F\u1E00-\u1EFF\s\d.,!?;:'"()\-/@#$%^&*+=~`[\]{}|\\<>]+$/u.test(text); return /^[\x00-\x7F\u00C0-\u024F\u1E00-\u1EFF\s\d.,!?;:'"()\-/@#$%^&*+=~`[\]{}|\\<>]+$/u.test(text);
} }
+3 -3
View File
@@ -108,7 +108,7 @@ function toggleCSS(css: string, identifier: string, value: boolean): void {
function executeScript( function executeScript(
instance: UserscriptInstance, instance: UserscriptInstance,
_console: { log: Function; warn: Function; error: Function }, _console: { log: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void },
): void { ): void {
if (instance.executed) return; if (instance.executed) return;
@@ -164,7 +164,7 @@ export function getInstances(): UserscriptInstance[] {
} }
export async function initUserscripts( export async function initUserscripts(
_console: { log: Function; warn: Function; error: Function }, _console: { log: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void },
): Promise<void> { ): Promise<void> {
const { scripts, tracker } = await ipcRenderer.invoke('userscripts-scan'); const { scripts, tracker } = await ipcRenderer.invoke('userscripts-scan');
if (!scripts || scripts.length === 0) { if (!scripts || scripts.length === 0) {
@@ -219,7 +219,7 @@ export async function initUserscripts(
export function setScriptEnabled( export function setScriptEnabled(
filename: string, filename: string,
enabled: boolean, enabled: boolean,
_console: { log: Function; warn: Function; error: Function }, _console: { log: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void },
): { needsReload: boolean } { ): { needsReload: boolean } {
const inst = instances.find(i => i.filename === filename); const inst = instances.find(i => i.filename === filename);
if (!inst) return { needsReload: false }; if (!inst) return { needsReload: false };
-7
View File
@@ -9,13 +9,6 @@ export interface SavedConsole {
error: (...args: unknown[]) => void; error: (...args: unknown[]) => void;
} }
export interface KeybindDef {
key: string;
ctrl: boolean;
shift: boolean;
alt: boolean;
}
// ── HTML escaping ── // ── HTML escaping ──
const HTML_ESCAPE_MAP: Record<string, string> = { const HTML_ESCAPE_MAP: Record<string, string> = {
+1 -1
View File
@@ -13,7 +13,7 @@ export default defineConfig({
outDir: 'dist/preload', outDir: 'dist/preload',
emptyDirBefore: true, emptyDirBefore: true,
rollupOptions: { rollupOptions: {
external: ['electron', 'uuid'], external: ['electron'],
}, },
target: 'node20', target: 'node20',
minify: isProd, minify: isProd,