Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1eabea195a | |||
| b8bfa2941c | |||
| 819caea65a | |||
| 96e0cbfc07 | |||
| ceb8f73a2a | |||
| 1568c74cac | |||
| 21684c5fbd |
@@ -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,6 +1,5 @@
|
||||
appId: com.krunkercivilian.client
|
||||
productName: Krunker Civilian Client
|
||||
electronDist: node_modules/electron/dist
|
||||
directories:
|
||||
output: out
|
||||
buildResources: build
|
||||
|
||||
@@ -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: "^_" },
|
||||
],
|
||||
},
|
||||
}
|
||||
);
|
||||
Generated
+911
-26
File diff suppressed because it is too large
Load Diff
+6
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "krunker-civilian-client",
|
||||
"version": "4.1.22",
|
||||
"version": "0.5.2",
|
||||
"description": "Cross-platform Krunker game client",
|
||||
"main": "dist/main/index.js",
|
||||
"homepage": "https://gitea.crjlab.net/bigjakk/krunker-civilian-client",
|
||||
@@ -17,18 +17,22 @@
|
||||
"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"
|
||||
"clean": "rimraf dist out",
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-store": "^8.2.0",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"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",
|
||||
"rimraf": "^6.0.1",
|
||||
"typescript": "^5.7.0",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ const { execSync } = require('child_process');
|
||||
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/KPC';
|
||||
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';
|
||||
@@ -174,8 +174,11 @@ async function main() {
|
||||
|
||||
// Write path.txt so the electron package's lazy downloader (index.js)
|
||||
// considers the binary already installed and doesn't re-download stock.
|
||||
const platformExe = process.platform === 'win32' ? 'electron.exe' : 'electron';
|
||||
fs.writeFileSync(path.join(ELECTRON_DIST, '..', 'path.txt'), platformExe);
|
||||
// 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)) {
|
||||
|
||||
@@ -518,7 +518,7 @@ export const MATCHMAKER_SETTINGS_CSS = `
|
||||
`;
|
||||
|
||||
export const TRANSLATOR_CSS = `
|
||||
.kpc-translation {
|
||||
.kcc-translation {
|
||||
color: #88ff88;
|
||||
font-style: italic;
|
||||
margin-left: 8px;
|
||||
|
||||
+88
-24
@@ -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 { existsSync, mkdirSync, promises as fsp } from 'fs';
|
||||
import { get as httpsGet } from 'https';
|
||||
@@ -14,6 +14,7 @@ import { showUpdateWindow } from './update-window';
|
||||
import { DiscordRPC } from './discord-rpc';
|
||||
|
||||
// ── App version for API calls ──
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const appVersion: string = require('../../package.json').version;
|
||||
|
||||
// ── Region ping cache ──
|
||||
@@ -107,6 +108,16 @@ document.addEventListener('keydown', function(e) {
|
||||
}
|
||||
}, 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;
|
||||
@@ -210,34 +221,40 @@ async function launchApp(): Promise<void> {
|
||||
|
||||
// ── Resource 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;
|
||||
electronLog.log(`[KCC] Resource swapper: ${swapper ? 'enabled' : 'disabled'} (${swapDir})`);
|
||||
|
||||
// ── Userscript manager ──
|
||||
const usConfig = config.get('userscripts') || { enabled: true, path: '' };
|
||||
const usDir = usConfig.path || join(app.getPath('userData'), 'KCCClient');
|
||||
const 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) ──
|
||||
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) => {
|
||||
if (swapper) {
|
||||
const redirect = swapper.getRedirect(details.url);
|
||||
if (redirect) return callback({ redirectURL: redirect });
|
||||
}
|
||||
// If we got here via the broad krunker.io pattern (not an ad), let it through
|
||||
try {
|
||||
const host = new URL(details.url).hostname;
|
||||
if (host.endsWith('krunker.io')) return callback({});
|
||||
} catch { /* ignore invalid URLs */ }
|
||||
// Otherwise it matched an ad-block pattern — cancel it
|
||||
callback({ cancel: true });
|
||||
});
|
||||
|
||||
// Once swapper scan finishes, re-register with swapper patterns included
|
||||
if (swapper) {
|
||||
swapper.waitForReady().then(() => {
|
||||
const filterUrls = [...BLOCKED_URL_PATTERNS, ...swapper.patterns];
|
||||
ses.webRequest.onBeforeRequest({ urls: filterUrls }, (details, callback) => {
|
||||
const redirect = swapper.getRedirect(details.url);
|
||||
if (redirect) return callback({ redirectURL: redirect });
|
||||
callback({ cancel: true });
|
||||
});
|
||||
electronLog.log(`[KCC] Swapper ready: ${swapper.patterns.length} pattern(s)`);
|
||||
});
|
||||
}
|
||||
@@ -301,8 +318,6 @@ async function launchApp(): Promise<void> {
|
||||
}
|
||||
|
||||
// ── Common output directory (used by folder actions) ──
|
||||
const outputDir = join(app.getPath('documents'), 'KrunkerCivilianClient');
|
||||
|
||||
// ── Configurable keybinds via before-input-event ──
|
||||
win.webContents.on('before-input-event', (event, input) => {
|
||||
if (input.type !== 'keyDown') return;
|
||||
@@ -317,7 +332,7 @@ async function launchApp(): Promise<void> {
|
||||
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 {};
|
||||
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());
|
||||
@@ -390,7 +405,7 @@ async function launchApp(): Promise<void> {
|
||||
if (subUrl.includes('krunker.io')) {
|
||||
sub.loadURL(subUrl);
|
||||
} else {
|
||||
setImmediate(() => shell.openExternal(subUrl));
|
||||
setImmediate(() => safeOpenExternal(subUrl));
|
||||
}
|
||||
return { action: 'deny' };
|
||||
});
|
||||
@@ -437,7 +452,7 @@ async function launchApp(): Promise<void> {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setImmediate(() => shell.openExternal(url));
|
||||
setImmediate(() => safeOpenExternal(url));
|
||||
}
|
||||
return { action: 'deny' };
|
||||
});
|
||||
@@ -457,32 +472,44 @@ async function launchApp(): Promise<void> {
|
||||
});
|
||||
|
||||
// ── 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-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[]) => {
|
||||
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;
|
||||
});
|
||||
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;
|
||||
pendingConfigWrites.set(key, value);
|
||||
if (!configWriteTimer) {
|
||||
configWriteTimer = setTimeout(() => {
|
||||
for (const [k, v] of pendingConfigWrites) {
|
||||
config.set(k as any, v);
|
||||
}
|
||||
// Invalidate caches for keys that affect runtime behavior
|
||||
if (pendingConfigWrites.has('game')) cachedGameConf = null;
|
||||
pendingConfigWrites.clear();
|
||||
configWriteTimer = null;
|
||||
}, 300);
|
||||
@@ -512,7 +539,7 @@ async function launchApp(): Promise<void> {
|
||||
}
|
||||
try {
|
||||
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 = '';
|
||||
res.on('data', (chunk: string) => { body += chunk; });
|
||||
res.on('end', () => resolve(body));
|
||||
@@ -620,16 +647,53 @@ async function launchApp(): Promise<void> {
|
||||
app.quit();
|
||||
});
|
||||
|
||||
// ── Alt manager IPC handlers ──
|
||||
ipcMain.handle('alt-list', () => config.get('accounts') || []);
|
||||
// ── 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');
|
||||
|
||||
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') || [];
|
||||
// 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 };
|
||||
|
||||
+1
-1
@@ -67,7 +67,7 @@ function makeLogger(getStream: () => WriteStream) {
|
||||
|
||||
export const electronLog = makeLogger(() => electronStream);
|
||||
|
||||
export function getLogPath(type: 'electron'): string {
|
||||
export function getLogPath(_type: 'electron'): string {
|
||||
init();
|
||||
return electronPath;
|
||||
}
|
||||
|
||||
+1
-2
@@ -4,7 +4,6 @@ import { protocol, net } from 'electron';
|
||||
|
||||
const PROTOCOL_NAME = 'kpc-swap';
|
||||
const TARGET_DOMAIN = 'krunker.io';
|
||||
const STANDARD_ASSET_RE = /^\/(?:models|textures|sound|scares|videos)(?:$|\/)/u;
|
||||
|
||||
/**
|
||||
* Register the custom protocol scheme. Must be called BEFORE app.ready.
|
||||
@@ -88,7 +87,7 @@ export class ResourceSwapper {
|
||||
this.swapFiles.set(name, join(this.swapDir, name));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
} catch {
|
||||
console.error(`Failed to scan swap directory prefix: ${prefix}`);
|
||||
}
|
||||
}
|
||||
|
||||
+45
-12
@@ -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 { spawn } from 'child_process';
|
||||
import { app } from 'electron';
|
||||
@@ -16,12 +16,25 @@ const UPDATE_CONFIG = {
|
||||
// Gitea provider (swap these for kpdclient.com migration)
|
||||
checkUrl: 'https://gitea.crjlab.net/api/v1/repos/bigjakk/Krunker-Civilian-Client/releases/latest',
|
||||
assetPattern: /Setup\.exe$/i,
|
||||
rejectUnauthorized: false,
|
||||
// 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".
|
||||
@@ -45,15 +58,19 @@ export function checkForUpdate(currentVersion: string): Promise<UpdateInfo | nul
|
||||
electronLog.log('[KCC-Update] Current version:', currentVersion);
|
||||
|
||||
const req = httpsGet(UPDATE_CONFIG.checkUrl, {
|
||||
rejectUnauthorized: UPDATE_CONFIG.rejectUnauthorized,
|
||||
headers: { 'User-Agent': 'KrunkerCivilianClient/' + currentVersion },
|
||||
}, (res) => {
|
||||
electronLog.log('[KCC-Update] Check response status:', res.statusCode);
|
||||
// Follow redirects
|
||||
// Follow redirects (with domain validation)
|
||||
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
electronLog.log('[KCC-Update] Redirected to:', res.headers.location);
|
||||
httpsGet(res.headers.location, {
|
||||
rejectUnauthorized: UPDATE_CONFIG.rejectUnauthorized,
|
||||
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);
|
||||
@@ -97,6 +114,13 @@ export function checkForUpdate(currentVersion: string): Promise<UpdateInfo | nul
|
||||
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,
|
||||
@@ -131,16 +155,25 @@ export function downloadUpdate(url: string, destPath: string, onProgress: Progre
|
||||
return new Promise((resolve, reject) => {
|
||||
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);
|
||||
const req = httpsGet(downloadUrl, {
|
||||
rejectUnauthorized: UPDATE_CONFIG.rejectUnauthorized,
|
||||
headers: { 'User-Agent': 'KrunkerCivilianClient' },
|
||||
}, (res) => {
|
||||
// Follow redirects
|
||||
// Follow redirects (with domain validation)
|
||||
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
electronLog.log('[KCC-Update] Download redirected to:', res.headers.location);
|
||||
doDownload(res.headers.location);
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ export class UserscriptManager {
|
||||
|
||||
/** Load tracker.json, add new scripts as disabled, prune deleted scripts */
|
||||
async loadTracker(scripts: ScriptFile[]): Promise<ScriptTracker> {
|
||||
let tracker: ScriptTracker = {};
|
||||
let tracker: ScriptTracker;
|
||||
try {
|
||||
tracker = JSON.parse(await fsp.readFile(this.trackerPath, 'utf-8'));
|
||||
} catch { tracker = {}; }
|
||||
|
||||
+43
-51
@@ -2,14 +2,15 @@ import { ipcRenderer } from 'electron';
|
||||
import { fetchGame, MATCHMAKER_GAMEMODES, MATCHMAKER_REGIONS, MATCHMAKER_REGION_NAMES } from './matchmaker';
|
||||
import type { MatchmakerConfig } from './matchmaker';
|
||||
import { initUserscripts, getInstances, setScriptEnabled } from './userscripts';
|
||||
import type { UserscriptInstance, UserscriptSetting } from './userscripts';
|
||||
import type { UserscriptInstance } from './userscripts';
|
||||
import { initTranslator, updateTranslatorConfig } from './translator';
|
||||
import { setDeathAnimBlock, escapeHtml } from './utils';
|
||||
import type { Keybind } from '../main/config';
|
||||
|
||||
|
||||
// ── Save console methods before Krunker overwrites them ──
|
||||
// Wrapped to forward errors/warnings always, and logs when verbose is enabled
|
||||
let _verboseLogging = false;
|
||||
const _verboseLogging = false;
|
||||
|
||||
const _console = {
|
||||
log: (...args: unknown[]) => {
|
||||
@@ -74,7 +75,7 @@ function updateRefreshNotification(): void {
|
||||
if (refreshPopupEl) { refreshPopupEl.remove(); refreshPopupEl = null; }
|
||||
return;
|
||||
}
|
||||
if (refreshPopupEl) { try { refreshPopupEl.remove(); } catch (_e) { /* noop */ } }
|
||||
if (refreshPopupEl) { try { refreshPopupEl.remove(); } catch { /* noop */ } }
|
||||
refreshPopupEl = document.createElement('div');
|
||||
refreshPopupEl.className = 'kpc-holder-update refresh-popup';
|
||||
if (refreshLevel === RefreshLevel.restart) {
|
||||
@@ -119,13 +120,7 @@ function updateRefreshNotification(): void {
|
||||
|
||||
// ── Client settings tab in Krunker's settings ──
|
||||
|
||||
function hasOwn(obj: any, key: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(obj, key);
|
||||
}
|
||||
|
||||
// ── Keybind types + helpers ──
|
||||
interface Keybind { key: string; ctrl: boolean; shift: boolean; alt: boolean; }
|
||||
|
||||
// ── Keybind helpers ──
|
||||
function keybindDisplayString(bind: Keybind): string {
|
||||
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');
|
||||
label.className = 'hostOpt';
|
||||
label.innerHTML =
|
||||
'<span class="optName">' + item.label + '</span>' +
|
||||
'<span class="optName">' + escapeHtml(item.label) + '</span>' +
|
||||
'<input type="checkbox"' + (opts.selected.includes(item.value) ? ' checked' : '') + '>' +
|
||||
'<div class="optCheck"></div>';
|
||||
const cb = label.querySelector('input') as HTMLInputElement;
|
||||
@@ -677,19 +672,6 @@ function buildDiscordSection(body: HTMLElement, discordConf: any): void {
|
||||
}
|
||||
|
||||
// ── 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 {
|
||||
const w = window as any;
|
||||
if (typeof w.loginOrRegister !== 'function') return;
|
||||
@@ -703,8 +685,8 @@ function switchToAccount(account: { username: string; password: string }): void
|
||||
const nameInput = document.querySelector('#accName') as HTMLInputElement;
|
||||
const passInput = document.querySelector('#accPass') as HTMLInputElement;
|
||||
if (!nameInput || !passInput) return;
|
||||
nameInput.value = decodeCredential(account.username);
|
||||
passInput.value = decodeCredential(account.password);
|
||||
nameInput.value = account.username;
|
||||
passInput.value = account.password;
|
||||
nameInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
passInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
const submitBtn = document.querySelector('.io-button') as HTMLElement;
|
||||
@@ -749,6 +731,11 @@ function buildAccountsSection(body: HTMLElement, accountsArr: any[]): void {
|
||||
const userIn = form.querySelector('.kpc-acc-user') 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', () => {
|
||||
form.style.display = form.style.display === 'none' ? '' : 'none';
|
||||
});
|
||||
@@ -777,7 +764,11 @@ function buildAccountsSection(body: HTMLElement, accountsArr: any[]): void {
|
||||
'<button class="kpc-acc-switch">Switch</button>' +
|
||||
'<button class="kpc-acc-delete">Delete</button>' +
|
||||
'</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', () => {
|
||||
ipcRenderer.invoke('alt-remove', i).then(() => {
|
||||
accounts.splice(i, 1);
|
||||
@@ -794,13 +785,9 @@ function buildAccountsSection(body: HTMLElement, accountsArr: any[]): void {
|
||||
const user = userIn.value.trim();
|
||||
const pass = passIn.value;
|
||||
if (!label || !user || !pass) return;
|
||||
const newAcc = {
|
||||
label,
|
||||
username: encodeCredential(user),
|
||||
password: encodeCredential(pass),
|
||||
};
|
||||
const newAcc = { label, username: user, password: pass };
|
||||
ipcRenderer.invoke('alt-save', newAcc).then(() => {
|
||||
accounts.push(newAcc);
|
||||
accounts.push({ label });
|
||||
labelIn.value = '';
|
||||
userIn.value = '';
|
||||
passIn.value = '';
|
||||
@@ -1127,12 +1114,12 @@ function renderUserscriptsSection(body: HTMLElement): void {
|
||||
const scriptRow = document.createElement('div');
|
||||
scriptRow.className = 'setting settName safety-0 bool';
|
||||
|
||||
const displayName = inst.meta.name || inst.filename;
|
||||
let metaParts: string[] = [];
|
||||
if (inst.meta.author) metaParts.push('by ' + inst.meta.author);
|
||||
if (inst.meta.version) metaParts.push('v' + inst.meta.version);
|
||||
const displayName = escapeHtml(inst.meta.name || inst.filename);
|
||||
const metaParts: string[] = [];
|
||||
if (inst.meta.author) metaParts.push('by ' + escapeHtml(inst.meta.author));
|
||||
if (inst.meta.version) metaParts.push('v' + escapeHtml(inst.meta.version));
|
||||
const metaLine = metaParts.length > 0 ? '<span class="kpc-us-meta">' + metaParts.join(' · ') + '</span>' : '';
|
||||
const descText = inst.meta.desc || '';
|
||||
const descText = escapeHtml(inst.meta.desc || '');
|
||||
|
||||
scriptRow.innerHTML =
|
||||
'<span class="setting-title">' + displayName + '</span>' +
|
||||
@@ -1174,8 +1161,8 @@ function renderScriptSettings(inst: UserscriptInstance, container: HTMLElement):
|
||||
const row = document.createElement('div');
|
||||
row.className = 'setting settName safety-0' + (typeClass ? ' ' + typeClass : '');
|
||||
row.innerHTML =
|
||||
'<span class="setting-title">' + setting.title + '</span>' +
|
||||
(setting.desc ? '<div class="setting-desc-new">' + setting.desc + '</div>' : '');
|
||||
'<span class="setting-title">' + escapeHtml(setting.title) + '</span>' +
|
||||
(setting.desc ? '<div class="setting-desc-new">' + escapeHtml(setting.desc) + '</div>' : '');
|
||||
|
||||
switch (setting.type) {
|
||||
case 'bool': {
|
||||
@@ -1365,7 +1352,7 @@ ipcRenderer.on('main_did-finish-load', () => {
|
||||
Promise.all([
|
||||
ipcRenderer.invoke('get-all-config', ['ui', 'userscripts', 'game', 'translator', 'keybinds', 'discord']),
|
||||
ipcRenderer.invoke('get-platform'),
|
||||
]).then(([allConf, platformInfo]: [any, any]) => {
|
||||
]).then(([allConf, _platformInfo]: [any, any]) => {
|
||||
const uiConf = allConf.ui;
|
||||
const usConf = allConf.userscripts;
|
||||
const gameConf = allConf.game;
|
||||
@@ -1430,7 +1417,7 @@ ipcRenderer.on('main_did-finish-load', () => {
|
||||
let gameStartTimestamp = Math.floor(Date.now() / 1000);
|
||||
|
||||
function pollDiscordState(): void {
|
||||
let details = '';
|
||||
let details: string;
|
||||
let state = '';
|
||||
let startTimestamp: number | undefined = undefined;
|
||||
|
||||
@@ -1439,7 +1426,7 @@ ipcRenderer.on('main_did-finish-load', () => {
|
||||
|
||||
let gameActivity: any = null;
|
||||
if (typeof w.getGameActivity === 'function') {
|
||||
try { gameActivity = w.getGameActivity(); } catch {}
|
||||
try { gameActivity = w.getGameActivity(); } catch { /* game API unavailable */ }
|
||||
}
|
||||
|
||||
if (spectating) {
|
||||
@@ -1493,9 +1480,7 @@ ipcRenderer.on('main_did-finish-load', () => {
|
||||
}
|
||||
// ── In-game Accounts quick-switch button ──
|
||||
if (isGamePage) {
|
||||
ipcRenderer.invoke('alt-list').then((accounts: any[]) => {
|
||||
if (!accounts || accounts.length === 0) return;
|
||||
|
||||
ipcRenderer.invoke('alt-list').then(() => {
|
||||
const altBtn = document.createElement('div');
|
||||
altBtn.id = 'kpcAltBtn';
|
||||
altBtn.className = 'menuItem';
|
||||
@@ -1557,7 +1542,9 @@ ipcRenderer.on('main_did-finish-load', () => {
|
||||
const idx = parseInt((el as HTMLElement).dataset.idx || '0', 10);
|
||||
if (accs[idx]) {
|
||||
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 +1573,11 @@ ipcRenderer.on('main_did-finish-load', () => {
|
||||
'</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('kpcAltSaveBtn')!.addEventListener('click', () => {
|
||||
const label = (document.getElementById('kpcAltLabel') as HTMLInputElement).value.trim();
|
||||
@@ -1594,8 +1586,8 @@ ipcRenderer.on('main_did-finish-load', () => {
|
||||
if (!label || !user || !pass) return;
|
||||
ipcRenderer.invoke('alt-save', {
|
||||
label,
|
||||
username: encodeCredential(user),
|
||||
password: encodeCredential(pass),
|
||||
username: user,
|
||||
password: pass,
|
||||
}).then(() => renderAccountList());
|
||||
});
|
||||
}
|
||||
@@ -1636,9 +1628,9 @@ ipcRenderer.on('main_did-finish-load', () => {
|
||||
const pollInterval = setInterval(() => {
|
||||
const w = window as any;
|
||||
if (
|
||||
hasOwn(w, 'showWindow')
|
||||
Object.hasOwn(w, 'showWindow')
|
||||
&& typeof w.showWindow === 'function'
|
||||
&& hasOwn(w, 'windows')
|
||||
&& Object.hasOwn(w, 'windows')
|
||||
&& Array.isArray(w.windows)
|
||||
&& w.windows.length >= 0
|
||||
&& typeof w.windows[0] !== 'undefined'
|
||||
|
||||
+62
-35
@@ -39,39 +39,57 @@ function secondsToTimestring(num: number): string {
|
||||
return `${minutes}m ${seconds}s`;
|
||||
}
|
||||
|
||||
// ── Popup DOM (created once, reused) ──
|
||||
// ── Popup DOM (lazy-initialized on first use) ──
|
||||
const POPUP_ID = 'matchmakerPopupContainer';
|
||||
const popupElement = document.createElement('div');
|
||||
popupElement.id = POPUP_ID;
|
||||
|
||||
const popupTitle = document.createElement('div');
|
||||
popupTitle.id = 'matchmakerPopupTitle';
|
||||
popupElement.appendChild(popupTitle);
|
||||
interface PopupDOM {
|
||||
element: HTMLDivElement;
|
||||
title: HTMLDivElement;
|
||||
description: HTMLDivElement;
|
||||
confirmBtn: HTMLDivElement;
|
||||
cancelBtn: HTMLDivElement;
|
||||
}
|
||||
|
||||
const popupDescription = document.createElement('div');
|
||||
popupDescription.id = 'matchmakerPopupDescription';
|
||||
popupElement.appendChild(popupDescription);
|
||||
let _popup: PopupDOM | null = null;
|
||||
|
||||
const popupOptions = document.createElement('div');
|
||||
popupOptions.id = 'matchmakerPopupOptions';
|
||||
function getPopup(): PopupDOM {
|
||||
if (_popup) return _popup;
|
||||
|
||||
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 element = document.createElement('div');
|
||||
element.id = POPUP_ID;
|
||||
|
||||
const popupCancelBtn = document.createElement('div');
|
||||
popupCancelBtn.id = 'matchmakerCancelButton';
|
||||
popupCancelBtn.className = 'matchmakerPopupButton bigShadowT';
|
||||
popupCancelBtn.textContent = 'Cancel';
|
||||
popupCancelBtn.setAttribute('onmouseenter', 'playTick()');
|
||||
popupCancelBtn.addEventListener('click', () => decideMatchmakerDecision(false));
|
||||
const title = document.createElement('div');
|
||||
title.id = 'matchmakerPopupTitle';
|
||||
element.appendChild(title);
|
||||
|
||||
popupOptions.appendChild(popupConfirmBtn);
|
||||
popupOptions.appendChild(popupCancelBtn);
|
||||
popupElement.appendChild(popupOptions);
|
||||
const description = document.createElement('div');
|
||||
description.id = 'matchmakerPopupDescription';
|
||||
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 ──
|
||||
let currentMatch = '';
|
||||
@@ -86,7 +104,8 @@ function decideMatchmakerDecision(accept: boolean): void {
|
||||
if (accept && currentMatch !== 'none') {
|
||||
window.location.href = `https://krunker.io/?game=${currentMatch}`;
|
||||
} else {
|
||||
if (popupElement.parentNode) popupElement.remove();
|
||||
const popup = getPopup();
|
||||
if (popup.element.parentNode) popup.element.remove();
|
||||
if (currentMatch === 'none' && openServerBrowser && typeof w.openServerWindow === 'function') {
|
||||
w.openServerWindow(0);
|
||||
}
|
||||
@@ -112,24 +131,32 @@ function handleMatchmakerBind(event: KeyboardEvent): void {
|
||||
}
|
||||
|
||||
function createFetchedGamePopup(game: MatchmakerGame): void {
|
||||
const popup = getPopup();
|
||||
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;
|
||||
if (game.gameID === 'none') {
|
||||
popupTitle.innerText = 'No Games Found...';
|
||||
popupDescription.innerHTML = 'Check the server browser to see other lobbies.';
|
||||
popupConfirmBtn.style.display = 'none';
|
||||
popup.title.textContent = 'No Games Found...';
|
||||
popup.description.textContent = 'Check the server browser to see other lobbies.';
|
||||
popup.confirmBtn.style.display = 'none';
|
||||
} else {
|
||||
popupTitle.innerText = 'Game Found!';
|
||||
popup.title.textContent = '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';
|
||||
popup.description.textContent = '';
|
||||
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);
|
||||
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> {
|
||||
|
||||
@@ -138,6 +138,7 @@ const SYSTEM_PATTERNS = [
|
||||
// ── 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ function toggleCSS(css: string, identifier: string, value: boolean): void {
|
||||
|
||||
function executeScript(
|
||||
instance: UserscriptInstance,
|
||||
_console: { log: Function; warn: Function; error: Function },
|
||||
_console: { log: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void },
|
||||
): void {
|
||||
if (instance.executed) return;
|
||||
|
||||
@@ -164,7 +164,7 @@ export function getInstances(): UserscriptInstance[] {
|
||||
}
|
||||
|
||||
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> {
|
||||
const { scripts, tracker } = await ipcRenderer.invoke('userscripts-scan');
|
||||
if (!scripts || scripts.length === 0) {
|
||||
@@ -219,7 +219,7 @@ export async function initUserscripts(
|
||||
export function setScriptEnabled(
|
||||
filename: string,
|
||||
enabled: boolean,
|
||||
_console: { log: Function; warn: Function; error: Function },
|
||||
_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 };
|
||||
|
||||
@@ -9,13 +9,6 @@ export interface SavedConsole {
|
||||
error: (...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
export interface KeybindDef {
|
||||
key: string;
|
||||
ctrl: boolean;
|
||||
shift: boolean;
|
||||
alt: boolean;
|
||||
}
|
||||
|
||||
// ── HTML escaping ──
|
||||
|
||||
const HTML_ESCAPE_MAP: Record<string, string> = {
|
||||
|
||||
Reference in New Issue
Block a user