Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 68344c6465 | |||
| 19af04468e | |||
| c96c151851 | |||
| c86263291b | |||
| 50fae3b3df | |||
| 0e75affe0d | |||
| 021acf67a0 | |||
| 6612d1213c | |||
| aeabddcf3a |
@@ -1,138 +0,0 @@
|
|||||||
name: Build and Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-release:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Check if version already released
|
|
||||||
id: version-check
|
|
||||||
env:
|
|
||||||
GITEA_TOKEN: ${{ secrets.DEST_GITEA_TOKEN }}
|
|
||||||
run: |
|
|
||||||
VERSION=$(grep '"version"' package.json | head -1 | sed 's/.*"version": *"\([^"]*\)".*/\1/')
|
|
||||||
TAG="v$VERSION"
|
|
||||||
echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "TAG=$TAG" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
|
|
||||||
"https://gitea.crjlab.net/api/v1/repos/bigjakk/krunker-civilian-client/releases/tags/$TAG" \
|
|
||||||
-H "Authorization: token $GITEA_TOKEN")
|
|
||||||
|
|
||||||
if [ "$STATUS" = "200" ]; then
|
|
||||||
echo "Release $TAG already exists, skipping build"
|
|
||||||
echo "SKIP=true" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "No release for $TAG, proceeding with build"
|
|
||||||
echo "SKIP=false" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
if: steps.version-check.outputs.SKIP == 'false'
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '22'
|
|
||||||
|
|
||||||
- name: Install system dependencies
|
|
||||||
if: steps.version-check.outputs.SKIP == 'false'
|
|
||||||
run: |
|
|
||||||
dpkg --add-architecture i386
|
|
||||||
apt-get update -qq
|
|
||||||
apt-get install -y --no-install-recommends \
|
|
||||||
wine wine32 wine64 \
|
|
||||||
libnss3 libatk1.0-0 libatk-bridge2.0-0 libcups2t64 \
|
|
||||||
libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 \
|
|
||||||
libxfixes3 libxrandr2 libgbm1 libpango-1.0-0 \
|
|
||||||
libcairo2 libasound2t64 libgtk-3-0
|
|
||||||
WINEDEBUG=-all wine wineboot --init || true
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
if: steps.version-check.outputs.SKIP == 'false'
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Build source
|
|
||||||
if: steps.version-check.outputs.SKIP == 'false'
|
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
- name: Build Windows distributables
|
|
||||||
if: steps.version-check.outputs.SKIP == 'false'
|
|
||||||
env:
|
|
||||||
WINEDEBUG: "-all"
|
|
||||||
run: npx electron-builder --win -c.electronDist=node_modules/electron/dist-win --publish never
|
|
||||||
|
|
||||||
- name: Build Linux distributables
|
|
||||||
if: steps.version-check.outputs.SKIP == 'false'
|
|
||||||
run: npx electron-builder --linux --publish never
|
|
||||||
|
|
||||||
- name: Report build sizes
|
|
||||||
if: steps.version-check.outputs.SKIP == 'false'
|
|
||||||
run: |
|
|
||||||
echo "=== Build output sizes ==="
|
|
||||||
ls -lh out/*.exe out/*.AppImage out/*.deb 2>/dev/null || true
|
|
||||||
echo "=== Electron dist-win (patched Windows) ==="
|
|
||||||
du -sh node_modules/electron/dist-win/ 2>/dev/null || true
|
|
||||||
echo "=== Electron dist (stock Linux) ==="
|
|
||||||
du -sh node_modules/electron/dist/ 2>/dev/null || true
|
|
||||||
echo "=== Unpacked Windows build ==="
|
|
||||||
du -sh out/win-unpacked/ 2>/dev/null || true
|
|
||||||
du -sh out/win-unpacked/resources/ 2>/dev/null || true
|
|
||||||
du -sh out/win-unpacked/locales/ 2>/dev/null || true
|
|
||||||
|
|
||||||
- name: Create release and upload assets
|
|
||||||
if: steps.version-check.outputs.SKIP == 'false'
|
|
||||||
env:
|
|
||||||
GITEA_TOKEN: ${{ secrets.DEST_GITEA_TOKEN }}
|
|
||||||
TAG: ${{ steps.version-check.outputs.TAG }}
|
|
||||||
run: |
|
|
||||||
GITEA_BASE="https://gitea.crjlab.net"
|
|
||||||
REPO="bigjakk/krunker-civilian-client"
|
|
||||||
|
|
||||||
# Create tag
|
|
||||||
curl -s -X POST "$GITEA_BASE/api/v1/repos/$REPO/tags" \
|
|
||||||
-H "Authorization: token $GITEA_TOKEN" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{\"tag_name\": \"$TAG\", \"message\": \"$TAG\", \"target\": \"$GITHUB_SHA\"}"
|
|
||||||
|
|
||||||
# Create release
|
|
||||||
RESPONSE=$(curl -s -X POST "$GITEA_BASE/api/v1/repos/$REPO/releases" \
|
|
||||||
-H "Authorization: token $GITEA_TOKEN" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{
|
|
||||||
\"tag_name\": \"$TAG\",
|
|
||||||
\"name\": \"$TAG\",
|
|
||||||
\"body\": \"Automated build for $TAG\",
|
|
||||||
\"draft\": false,
|
|
||||||
\"prerelease\": false
|
|
||||||
}")
|
|
||||||
|
|
||||||
RELEASE_ID=$(echo "$RESPONSE" | jq -r '.id')
|
|
||||||
echo "Created release ID: $RELEASE_ID"
|
|
||||||
|
|
||||||
if [ "$RELEASE_ID" = "null" ] || [ -z "$RELEASE_ID" ]; then
|
|
||||||
echo "Failed to create release:"
|
|
||||||
echo "$RESPONSE"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Upload all built artifacts
|
|
||||||
for file in out/*.exe out/*.AppImage out/*.deb; do
|
|
||||||
[ -f "$file" ] || continue
|
|
||||||
FILENAME=$(basename "$file")
|
|
||||||
SAFE_NAME=$(echo "$FILENAME" | tr ' ' '_')
|
|
||||||
echo "Uploading: $SAFE_NAME ($(du -h "$file" | cut -f1))"
|
|
||||||
|
|
||||||
curl -s -X POST \
|
|
||||||
"$GITEA_BASE/api/v1/repos/$REPO/releases/$RELEASE_ID/assets?name=$SAFE_NAME" \
|
|
||||||
-H "Authorization: token $GITEA_TOKEN" \
|
|
||||||
-F "attachment=@$file" \
|
|
||||||
| jq -r '" -> \(.name) (\(.size) bytes)"'
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "All assets uploaded"
|
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
name: Build and Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Check if version already released
|
||||||
|
id: version-check
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
VERSION=$(grep '"version"' package.json | head -1 | sed 's/.*"version": *"\([^"]*\)".*/\1/')
|
||||||
|
TAG="v$VERSION"
|
||||||
|
echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "TAG=$TAG" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||||
|
echo "Release $TAG already exists, skipping build"
|
||||||
|
echo "SKIP=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "No release for $TAG, proceeding with build"
|
||||||
|
echo "SKIP=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
if: steps.version-check.outputs.SKIP == 'false'
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
|
||||||
|
- name: Install system dependencies
|
||||||
|
if: steps.version-check.outputs.SKIP == 'false'
|
||||||
|
run: |
|
||||||
|
sudo dpkg --add-architecture i386
|
||||||
|
sudo apt-get update -qq
|
||||||
|
sudo apt-get install -y --no-install-recommends \
|
||||||
|
wine wine32 wine64 \
|
||||||
|
libnss3 libatk1.0-0 libatk-bridge2.0-0 libcups2t64 \
|
||||||
|
libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 \
|
||||||
|
libxfixes3 libxrandr2 libgbm1 libpango-1.0-0 \
|
||||||
|
libcairo2 libasound2t64 libgtk-3-0
|
||||||
|
WINEDEBUG=-all wine wineboot --init || true
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
if: steps.version-check.outputs.SKIP == 'false'
|
||||||
|
run: npm ci --legacy-peer-deps
|
||||||
|
|
||||||
|
- name: Build source
|
||||||
|
if: steps.version-check.outputs.SKIP == 'false'
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Build Windows distributables
|
||||||
|
if: steps.version-check.outputs.SKIP == 'false'
|
||||||
|
env:
|
||||||
|
WINEDEBUG: "-all"
|
||||||
|
run: npx electron-builder --win -c.electronDist=node_modules/electron/dist-win --publish never
|
||||||
|
|
||||||
|
- name: Build Linux distributables
|
||||||
|
if: steps.version-check.outputs.SKIP == 'false'
|
||||||
|
run: npx electron-builder --linux -c.electronDist=node_modules/electron/dist-linux --publish never
|
||||||
|
|
||||||
|
- name: Report build sizes
|
||||||
|
if: steps.version-check.outputs.SKIP == 'false'
|
||||||
|
run: |
|
||||||
|
echo "=== Build output sizes ==="
|
||||||
|
ls -lh out/*.exe out/*.AppImage out/*.deb 2>/dev/null || true
|
||||||
|
|
||||||
|
- name: Generate release notes
|
||||||
|
if: steps.version-check.outputs.SKIP == 'false'
|
||||||
|
run: |
|
||||||
|
git fetch --unshallow 2>/dev/null || true
|
||||||
|
chmod +x scripts/generate-release-notes.sh
|
||||||
|
scripts/generate-release-notes.sh "${{ steps.version-check.outputs.TAG }}" > /tmp/release-notes.md
|
||||||
|
echo "--- Generated release notes ---"
|
||||||
|
cat /tmp/release-notes.md
|
||||||
|
|
||||||
|
- name: Create GitHub release and upload assets
|
||||||
|
if: steps.version-check.outputs.SKIP == 'false'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
# Collect built artifacts
|
||||||
|
ASSETS=()
|
||||||
|
for file in out/*.exe out/*.AppImage out/*.deb; do
|
||||||
|
[ -f "$file" ] || continue
|
||||||
|
ASSETS+=("$file")
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ ${#ASSETS[@]} -eq 0 ]; then
|
||||||
|
echo "ERROR: No build artifacts found in out/"
|
||||||
|
ls -la out/ 2>/dev/null || echo "out/ directory does not exist"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Uploading ${#ASSETS[@]} assets:"
|
||||||
|
printf ' %s\n' "${ASSETS[@]}"
|
||||||
|
|
||||||
|
gh release create "${{ steps.version-check.outputs.TAG }}" \
|
||||||
|
--repo "$GITHUB_REPOSITORY" \
|
||||||
|
--title "${{ steps.version-check.outputs.TAG }}" \
|
||||||
|
--notes-file /tmp/release-notes.md \
|
||||||
|
"${ASSETS[@]}"
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
npm run lint
|
npx lint-staged
|
||||||
|
|||||||
@@ -1,295 +1,88 @@
|
|||||||
# Krunker Civilian Client
|
# Krunker Civilian Client
|
||||||
|
|
||||||
Cross-platform Electron-based game client for [Krunker.io](https://krunker.io). Loads the game directly in a BrowserWindow with GPU optimizations, unlimited FPS, persistent sessions, ad blocking, resource swapping, custom matchmaker, userscript engine, and Discord Rich Presence.
|
> a high-performance krunker client with unlimited FPS, built on a custom-patched Electron
|
||||||
|
|
||||||
## Quick Start
|
**Download:**
|
||||||
|
[Windows (x64)](https://github.com/bigjakk/Krunker-Civilian-Client/releases/latest) -
|
||||||
```bash
|
[Linux (AppImage)](https://github.com/bigjakk/Krunker-Civilian-Client/releases/latest)
|
||||||
npm install
|
|
||||||
npm start # Build all targets + launch Electron
|
## Features
|
||||||
```
|
|
||||||
|
- unlimited FPS with no aim freeze (custom Electron build, see [below](#custom-electron-build))
|
||||||
For development with sourcemaps:
|
- unobtrusive — all features can be disabled, no watermarks
|
||||||
|
- hides ads by default
|
||||||
```bash
|
- resource swapper (textures, sounds, models)
|
||||||
npm run dev # Builds in dev mode + launches Electron
|
- CSS theme system (drop `.css` files in `swap/themes/`)
|
||||||
```
|
- custom loading screen backgrounds (`swap/backgrounds/`)
|
||||||
|
- customisable matchmaker with lobby scan animation
|
||||||
## Scripts
|
- filter by region, gamemode, map, player count, remaining time
|
||||||
|
- auto-join with server capacity verification
|
||||||
| Script | Description |
|
- tabbed hub/social pages with drag-and-drop reorder
|
||||||
|--------|-------------|
|
- better chat — merged team/all chat with `[T]`/`[M]` prefixes
|
||||||
| `npm run dev` | Development build + launch |
|
- chat history preservation (Krunker prunes old messages, this prevents it)
|
||||||
| `npm start` | Full build (main + preload) + launch |
|
- real-time chat translator (Google Translate, 15+ languages)
|
||||||
| `npm run build` | Build both Vite targets |
|
- userscript support (Tampermonkey-style metadata, per-script settings)
|
||||||
| `npm run build:main` | Build main process only |
|
- alt account manager with encrypted credential storage
|
||||||
| `npm run build:preload` | Build preload script only |
|
- Discord RPC (gamemode, map, class, spectator status)
|
||||||
| `npm run dist:win` | Build + package for Windows (NSIS installer + portable) |
|
- raw input / unadjusted movement (Windows)
|
||||||
| `npm run dist:linux` | Build + package for Linux (AppImage + deb) |
|
- show numeric ping in player list
|
||||||
| `npm run dist:all` | Build + package for all platforms |
|
- double ping display (Krunker shows half the real value)
|
||||||
| `npm run clean` | Remove `dist/` and `out/` directories |
|
- hardpoint enemy counter HUD
|
||||||
| `npm run lint` | Run ESLint (typescript-eslint) on `src/` |
|
- cleaner menu mode (hides clutter)
|
||||||
|
- changelog popup on update
|
||||||
## Architecture
|
- configurable keybinds with visual rebinding dialog
|
||||||
|
- configurable ANGLE backend (D3D11, OpenGL, Vulkan, D3D9, D3D11on12)
|
||||||
### Custom Patched Electron 42
|
- advanced Chromium flag settings (GPU rasterization, low latency, QUIC, and more)
|
||||||
|
- CPU throttling (game vs menu) and process priority control
|
||||||
This client uses a custom-patched Electron 42 (Chromium 134, Node 24). The `--disable-frame-rate-limit` Chromium flag — required for unlimited FPS — causes input starvation ("aim freeze") on Chromium 84+. At uncapped frame rates (300+ FPS), the compositor floods the main thread task queue and input events get delayed 50-300ms, then snap to catch up. The `ImplLatencyRecovery`/`MainLatencyRecovery` features mitigated this on Chromium 87–93 but were removed in Chromium 94.
|
- auto-updater
|
||||||
|
- maintained & open source
|
||||||
#### The Patch
|
|
||||||
|
## Hotkeys
|
||||||
A single-line change in Chromium's main thread scheduler (`main_thread_scheduler_impl.cc`) demotes input tasks from `kHighestPriority` to `kNormalPriority`, allowing the scheduler's built-in anti-starvation logic to fairly interleave input and compositor work:
|
|
||||||
|
All hotkeys are rebindable in settings.
|
||||||
```diff
|
|
||||||
case MainThreadTaskQueue::QueueTraits::PrioritisationType::kInput:
|
| Key | Action |
|
||||||
- return TaskPriority::kHighestPriority;
|
|-----|--------|
|
||||||
+ return TaskPriority::kNormalPriority;
|
| `F4` | New match (triggers matchmaker if enabled) |
|
||||||
```
|
| `F5` | Reload page |
|
||||||
|
| `F6` | Open matchmaker |
|
||||||
Benchmarked via CDP `Input.dispatchMouseEvent`:
|
| `F10` | Pause chat (freeze auto-scroll) |
|
||||||
|
| `F11` | Toggle fullscreen |
|
||||||
| Metric | Before | After |
|
| `F12` | DevTools |
|
||||||
|--------|--------|-------|
|
| `Ctrl+L` | Copy game link |
|
||||||
| p99 latency | 97ms | 34ms |
|
| `Ctrl+J` | Join game from clipboard |
|
||||||
| Max latency | 308ms | 38ms |
|
| `Ctrl+T` | New tab (hub) |
|
||||||
| Events >50ms | 8.6% | 0% |
|
| `Ctrl+W` | Close tab |
|
||||||
| Frames rendered | baseline | +21% |
|
| `Ctrl+Tab` | Next tab |
|
||||||
| Mouse events processed | baseline | +9% |
|
| `Ctrl+Shift+Tab` | Previous tab |
|
||||||
|
| `Ctrl+Shift+T` | Reopen closed tab |
|
||||||
The full patch file, GN build args, and step-by-step build instructions are in [`electron-build/`](electron-build/BUILD.md).
|
| `Ctrl+1-9` | Jump to tab |
|
||||||
|
|
||||||
#### Binary Distribution
|
## Userscripts
|
||||||
|
|
||||||
The patched binary is downloaded automatically during `npm install` via `scripts/download-electron.js` from a Gitea release asset (`electron-patched` tag). The `electron` npm dependency (`npm:electron-nightly@42.0.0-nightly.20260227`) provides TypeScript types and the CLI entry point, but the actual binary comes from the patched download.
|
Any `.js` file in the scripts folder will be loaded as a userscript if enabled in settings. Scripts support Tampermonkey-style metadata blocks (`@name`, `@author`, `@version`, `@desc`) and can define custom settings (boolean, number, select, color, keybind).
|
||||||
|
|
||||||
FPS uncap flags (always applied when enabled):
|
> **Use userscripts at your own risk.** Do not write or use any userscripts which would give you a competitive advantage.
|
||||||
|
|
||||||
```
|
## Custom Electron Build
|
||||||
disable-frame-rate-limit + disable-gpu-vsync + max-gum-fps=9999
|
|
||||||
```
|
This client uses a custom-patched Electron 42 build to overcome the aim freezing issue present in modern Electron versions. The patched binary is downloaded automatically during `npm install`.
|
||||||
|
|
||||||
#### Building from Source
|
For details on the patch and build instructions, see [Electron-Websocket-Fix](https://github.com/bigjakk/Electron-Websocket-Fix).
|
||||||
|
|
||||||
Prerequisites: Windows 10/11 x64, ~100 GB disk, 16+ GB RAM, Visual Studio 2022 with C++ workload.
|
## Building From Source
|
||||||
|
|
||||||
```bash
|
1. Install [git](https://git-scm.com/downloads), [Node.js](https://nodejs.org/), and npm
|
||||||
# 1. Install depot_tools and add to PATH
|
2. Clone and install:
|
||||||
git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git
|
```bash
|
||||||
|
git clone https://github.com/bigjakk/Krunker-Civilian-Client.git
|
||||||
# 2. Check out Electron source (~40-60 GB)
|
cd Krunker-Civilian-Client
|
||||||
mkdir C:\electron && cd C:\electron
|
npm install
|
||||||
gclient config --name "src/electron" --unmanaged https://github.com/nicedayzhu/electron.git@v42.0.0-nightly.20260227
|
```
|
||||||
gclient sync --with_branch_heads --with_tags
|
3. Run: `npm start` or `npm run dev` (dev mode with sourcemaps)
|
||||||
|
4. Package: `npm run dist:win` or `npm run dist:linux`
|
||||||
# 3. Apply the patch
|
|
||||||
cd src
|
## Credits
|
||||||
git apply path/to/electron-build/input-priority-fix.patch
|
|
||||||
|
- Built on ideas from [Crankshaft](https://github.com/KraXen72/crankshaft) by KraXen72
|
||||||
# 4. Configure and build (~2-4 hours for release)
|
- Inspired by [Glorp](https://github.com/slavcp/glorp) by slav
|
||||||
gn gen out/Release
|
|
||||||
cp path/to/electron-build/args.release.gn out/Release/args.gn
|
|
||||||
gn gen out/Release
|
|
||||||
ninja -C out/Release electron
|
|
||||||
|
|
||||||
# 5. Create distributable zip
|
|
||||||
ninja -C out/Release electron:dist_zip
|
|
||||||
```
|
|
||||||
|
|
||||||
See [`electron-build/BUILD.md`](electron-build/BUILD.md) for detailed instructions, testing builds, and verification steps.
|
|
||||||
|
|
||||||
### How the Game Loads
|
|
||||||
|
|
||||||
The game is loaded **directly** in the main `BrowserWindow` via `win.loadURL('https://krunker.io')`. This gives the game full GPU compositing, identical to Chrome.
|
|
||||||
|
|
||||||
### Process Model
|
|
||||||
|
|
||||||
```
|
|
||||||
Main Process (src/main/index.ts)
|
|
||||||
|
|
|
||||||
|-- Creates BrowserWindow loading krunker.io directly
|
|
||||||
|-- Manages session (persist:krunker), UA cleaning, permissions
|
|
||||||
|-- Ad blocking + resource swapping via webRequest.onBeforeRequest
|
|
||||||
|-- Configurable keybinds via before-input-event
|
|
||||||
|-- Matchmaker IPC trigger (sends config to preload on hotkey)
|
|
||||||
|-- CSS injection (ad hiding, client settings, matchmaker popup, keybind dialog)
|
|
||||||
|-- File-based logging (electron.log in userData/logs/)
|
|
||||||
|-- IPC handlers for window controls, config, devtools, userscripts
|
|
||||||
|-- Userscript manager (filesystem scanning, tracker, preferences)
|
|
||||||
|-- Auto-updater (Gitea releases API check, Setup.exe download + launch)
|
|
||||||
|-- Discord Rich Presence via raw IPC socket
|
|
||||||
|-- Consent dismiss via polling (no MutationObserver on main frame)
|
|
||||||
|
|
|
||||||
+-- Preload Script (src/preload/index.ts)
|
|
||||||
|-- IPC bridge (window.kpc)
|
|
||||||
|-- Action button grid (folder shortcuts, log viewers, reset/restart)
|
|
||||||
|-- Settings UI hooks (collapsible sections: swapper, matchmaker, chat, translator, accounts, advanced, userscripts)
|
|
||||||
|-- Userscript engine (metadata parser, execution, CSS injection, settings)
|
|
||||||
|-- Matchmaker IPC listener -> fetchGame() in matchmaker.ts
|
|
||||||
|-- Chat translator (Google Translate API, MutationObserver on #chatList)
|
|
||||||
|-- Exit button (exposes Krunker's native #clientExit element)
|
|
||||||
|-- Keybind capture dialog (Crankshaft-style)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Source Files
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
main/
|
|
||||||
index.ts Main process — window, IPC, session, ad blocking, keybinds
|
|
||||||
platform.ts OS detection, Chromium GPU/performance flags (per-platform)
|
|
||||||
config.ts electron-store schema, defaults, DEFAULT_KEYBINDS
|
|
||||||
client-ui.ts Injected CSS for settings panel, keybind dialog, matchmaker popup
|
|
||||||
swapper.ts Resource swapper — local asset overrides via session-aware custom protocol
|
|
||||||
userscripts.ts Userscript manager — filesystem scanning, tracker.json, preferences
|
|
||||||
discord-rpc.ts Discord Rich Presence via raw IPC socket
|
|
||||||
logger.ts File logging with daily rotation and 7-day retention
|
|
||||||
updater.ts Auto-updater — Gitea releases API check, download, installer launch
|
|
||||||
update-window.ts Update progress BrowserWindow with inline HTML + progress bar
|
|
||||||
preload/
|
|
||||||
index.ts IPC bridge, settings UI hooks, keybind capture, matchmaker listener, alt manager
|
|
||||||
utils.ts Shared types (SavedConsole, KeybindDef), helpers (escapeHtml, genChatMsg)
|
|
||||||
matchmaker.ts Custom matchmaker — lobby fetch, filtering, popup UI
|
|
||||||
translator.ts Real-time chat translation via Google Translate API
|
|
||||||
userscripts.ts Userscript engine — metadata parser, execution, CSS injection, state
|
|
||||||
```
|
|
||||||
|
|
||||||
### Build System
|
|
||||||
|
|
||||||
Two Vite configs build independent targets:
|
|
||||||
|
|
||||||
| 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.preload.config.ts` | Preload script | `dist/preload/index.js` (CJS) | Externalizes `electron`. Targets Node 20. |
|
|
||||||
|
|
||||||
### Custom Electron Binary
|
|
||||||
|
|
||||||
The patched Electron binary is managed by `scripts/download-electron.js`:
|
|
||||||
|
|
||||||
- Downloads from Gitea release tag `electron-patched`, asset `electron-v42.0.0-nightly-patched-win32-x64.zip`
|
|
||||||
- Replaces `node_modules/electron/dist/` with the patched binary
|
|
||||||
- Runs automatically as a `postinstall` script during `npm install`
|
|
||||||
- Skips download if the version file already matches
|
|
||||||
|
|
||||||
### Configuration (electron-store)
|
|
||||||
|
|
||||||
Settings are persisted in `krunker-civilian-config.json` (OS-specific app data directory). Schema defined in `src/main/config.ts`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface AppConfig {
|
|
||||||
window: { width, height, x, y, maximized, fullscreen }
|
|
||||||
performance: { fpsUnlocked, hardwareAccel, gpuPreference }
|
|
||||||
game: { lastServer, socialTabBehaviour, joinAsSpectator }
|
|
||||||
swapper: { enabled, path }
|
|
||||||
userscripts: { enabled, path }
|
|
||||||
matchmaker: { enabled, regions, gamemodes, minPlayers, maxPlayers, minRemainingTime, openServerBrowser }
|
|
||||||
keybinds: { reload, newMatch, copyGameLink, joinFromClipboard, devTools, matchmaker,
|
|
||||||
matchmakerAccept, matchmakerCancel, pauseChat, fullscreenToggle }
|
|
||||||
ui: { showExitButton, deathscreenAnimation, hideMenuPopups }
|
|
||||||
discord: { enabled }
|
|
||||||
translator: { enabled, targetLanguage, showLanguageTag }
|
|
||||||
advanced: { removeUselessFeatures, gpuRasterizing, helpfulFlags, disableAccelerated2D, increaseLimits,
|
|
||||||
lowLatency, experimentalFlags, angleBackend }
|
|
||||||
accounts: SavedAccount[]
|
|
||||||
platform: { detectedOS, gpuBackend }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Platform Handling
|
|
||||||
|
|
||||||
| Feature | Windows | Linux |
|
|
||||||
|---------|---------|-------|
|
|
||||||
| Window chrome | Standard OS frame (`frame: true`) | Standard OS frame (`frame: true`) |
|
|
||||||
| GPU backend | D3D11 via ANGLE (default, configurable) | Default (+ `ozone-platform-hint auto` for Wayland) |
|
|
||||||
| GPU sandbox | Default | Disabled (AppImage FUSE + Mesa driver compat) |
|
|
||||||
|
|
||||||
Common flags always applied: `disable-backgrounding-occluded-windows`, `ignore-gpu-blocklist`. FPS uncap flags (`disable-frame-rate-limit`, `disable-gpu-vsync`, `max-gum-fps=9999`) applied when enabled. The ANGLE backend is configurable (default D3D11 on Windows; options include OpenGL, Vulkan, D3D9, D3D11on12). Additional Chromium flags (GPU rasterization, useful features, low latency, etc.) are configurable via the Advanced settings panel.
|
|
||||||
|
|
||||||
### Session & User-Agent
|
|
||||||
|
|
||||||
- Uses `partition: 'persist:krunker'` for persistent login/settings across restarts
|
|
||||||
- User-Agent is cleaned to strip the app name identifier (keeps `Electron/` so Krunker enables its Client settings tab)
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
### Core Client
|
|
||||||
- Unlimited FPS via custom-patched Electron 42 build
|
|
||||||
- Ad blocking (network-level URL filter + CSS hiding)
|
|
||||||
- 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)
|
|
||||||
- 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)
|
|
||||||
- Exit button in Krunker's sidebar menu (exposes native `#clientExit` element)
|
|
||||||
- Selectable + pausable chat (always-on text selection, F10 to freeze auto-scroll for reading history)
|
|
||||||
- Configurable keybinds with Crankshaft-style rebinding dialog
|
|
||||||
- Configurable ANGLE backend (D3D11, OpenGL, Vulkan, D3D9, D3D11on12 — platform-filtered)
|
|
||||||
- Advanced Chromium flag settings (GPU rasterization, low latency, experimental features, and more)
|
|
||||||
- Client settings tab in Krunker's settings panel with collapsible sections
|
|
||||||
- Action button grid in settings (folder shortcuts, log viewers, reset/restart options)
|
|
||||||
- File-based logging (electron.log in userData/logs/, daily rotation with 7-day retention)
|
|
||||||
- Persistent game session, window state persistence, cookie consent auto-dismiss
|
|
||||||
- Auto-updater (Gitea releases API check, Setup.exe download with progress window, installer launch)
|
|
||||||
- Discord Rich Presence (game state, map, mode, player count)
|
|
||||||
- Death screen animation blocker (prevents CSS animation performance impact at uncapped FPS)
|
|
||||||
- Menu popup hider (hides store ads, stream containers, news popups)
|
|
||||||
|
|
||||||
### Alt Manager (Account Switching)
|
|
||||||
- **Account storage** — Save multiple Krunker accounts (label, username, password)
|
|
||||||
- **Quick switch** — In-game "Accounts" menu item opens Krunker's native popup with saved accounts
|
|
||||||
- **Login automation** — Auto-logout + `loginOrRegister()` credential fill
|
|
||||||
- **Settings section** — Full account CRUD in the collapsed "Accounts" settings section
|
|
||||||
- **Credential obfuscation** — Credentials stored with character-shift encoding to prevent casual config reading
|
|
||||||
|
|
||||||
## Known Constraints
|
|
||||||
|
|
||||||
### MutationObserver Breaks WebGL on Main Frame
|
|
||||||
|
|
||||||
A `MutationObserver` with `{ childList: true, subtree: true }` on Krunker's main frame DOM causes the WebGL 3D engine to hang indefinitely. Consent dismiss uses polling (`setInterval`) on the main frame instead. MutationObserver is safe in iframes and targeted elements like `#menuWindow`.
|
|
||||||
|
|
||||||
### MutationObserver Infinite Loops (Chromium 94+)
|
|
||||||
|
|
||||||
Observers that modify elements within their observed subtree can cause infinite loops. In Chromium 94+, even `textContent` assignments that don't change the value still create new text nodes (triggering `childList` mutations). Observers must disconnect before DOM modifications and reconnect after.
|
|
||||||
|
|
||||||
### Synthetic Events Lack User Activation (Chromium 94+)
|
|
||||||
|
|
||||||
Events created via `new MouseEvent()` / `new KeyboardEvent()` have `isTrusted: false` and do not provide user activation. Krunker ignores untrusted input events. The client uses `webContents.sendInputEvent()` via IPC to inject trusted input events from the main process.
|
|
||||||
|
|
||||||
### Custom Electron Build Required
|
|
||||||
|
|
||||||
The `--disable-frame-rate-limit` flag causes compositor input starvation on all Chromium versions 84+. The `ImplLatencyRecovery`/`MainLatencyRecovery` features that mitigated this were removed in Chromium 94. Our custom Electron 42 build patches the Chromium main thread scheduler to fairly interleave input and compositor work. See [Building from Source](#building-from-source) above and [`electron-build/BUILD.md`](electron-build/BUILD.md) for full instructions.
|
|
||||||
|
|
||||||
### Uncapped FPS and CSS Animations
|
|
||||||
|
|
||||||
At uncapped frame rates (600+ FPS), Krunker's CSS animations (e.g. death screen slide-in) force a layout recalculation on every frame, which can saturate the renderer main thread. The death screen animation blocker (`ui.deathscreenAnimation`) is enabled by default to prevent this.
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
|
|
||||||
- **Electron** 42 (Chromium 134, Node 24) — Custom-patched build for unlimited FPS
|
|
||||||
- **TypeScript** 5.7 — Type-safe source code
|
|
||||||
- **Vite** 6 — Fast bundler (2 build targets: main + preload)
|
|
||||||
- **electron-store** 8 — JSON config persistence (CJS)
|
|
||||||
- **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
|
|
||||||
|
|
||||||
```
|
|
||||||
Krunker-Civilian-Client/
|
|
||||||
src/
|
|
||||||
main/ Main process source (10 modules)
|
|
||||||
preload/ Preload script source (5 modules)
|
|
||||||
dist/ Vite build output
|
|
||||||
main/
|
|
||||||
preload/
|
|
||||||
out/ electron-builder packaged output
|
|
||||||
build/ Build resources (icons, .desktop file)
|
|
||||||
scripts/ Build scripts (Electron patched binary download)
|
|
||||||
electron-build/ Custom Electron build instructions and patches
|
|
||||||
eslint.config.mjs
|
|
||||||
vite.main.config.ts
|
|
||||||
vite.preload.config.ts
|
|
||||||
electron-builder.yml
|
|
||||||
tsconfig.json
|
|
||||||
package.json
|
|
||||||
```
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 279 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 534 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 534 KiB |
@@ -1,4 +1,4 @@
|
|||||||
From: KPD Client <krunker@crjlab.net>
|
From: Krunker Civilian Client <krunker@crjlab.net>
|
||||||
Subject: [PATCH] Fix input starvation when frame rate limit is disabled
|
Subject: [PATCH] Fix input starvation when frame rate limit is disabled
|
||||||
|
|
||||||
Chromium's main thread scheduler assigns kHighestPriority to input tasks,
|
Chromium's main thread scheduler assigns kHighestPriority to input tasks,
|
||||||
|
|||||||
@@ -67,5 +67,5 @@ linux:
|
|||||||
|
|
||||||
publish:
|
publish:
|
||||||
provider: github
|
provider: github
|
||||||
owner: krunker-civilian
|
owner: bigjakk
|
||||||
repo: krunker-civilian-client
|
repo: Krunker-Civilian-Client
|
||||||
|
|||||||
Generated
+7759
-7161
File diff suppressed because it is too large
Load Diff
+6
-2
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "krunker-civilian-client",
|
"name": "krunker-civilian-client",
|
||||||
"version": "0.5.6",
|
"version": "0.6.2",
|
||||||
"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://github.com/bigjakk/Krunker-Civilian-Client",
|
||||||
"author": "Krunker Civilian Client <krunker@crjlab.net>",
|
"author": "Krunker Civilian Client <krunker@crjlab.net>",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -21,6 +21,9 @@
|
|||||||
"lint": "eslint src/",
|
"lint": "eslint src/",
|
||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
|
"lint-staged": {
|
||||||
|
"src/**/*.ts": "eslint --fix"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"electron-store": "^8.2.0"
|
"electron-store": "^8.2.0"
|
||||||
},
|
},
|
||||||
@@ -31,6 +34,7 @@
|
|||||||
"electron-builder": "^26.0.0",
|
"electron-builder": "^26.0.0",
|
||||||
"eslint": "^10.0.2",
|
"eslint": "^10.0.2",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
|
"lint-staged": "^16.3.1",
|
||||||
"rimraf": "^6.0.1",
|
"rimraf": "^6.0.1",
|
||||||
"typescript": "^5.7.0",
|
"typescript": "^5.7.0",
|
||||||
"typescript-eslint": "^8.56.1",
|
"typescript-eslint": "^8.56.1",
|
||||||
|
|||||||
+194
-208
@@ -1,208 +1,194 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Downloads the patched Electron build and extracts it into node_modules/electron/dist/.
|
* Downloads patched Electron builds for Windows (v42) and Linux (v43).
|
||||||
*
|
*
|
||||||
* The patched Electron fixes input starvation ("aim freeze") when --disable-frame-rate-limit
|
* The patched Electron fixes input starvation ("aim freeze") when --disable-frame-rate-limit
|
||||||
* is active on modern Chromium. Without this, uncapped FPS causes 50-300ms input delays.
|
* is active on modern Chromium. Without this, uncapped FPS causes 50-300ms input delays.
|
||||||
*
|
*
|
||||||
* The zip is hosted as a release asset on the same Gitea repo. The script checks the
|
* Platform behavior:
|
||||||
* local version file to skip re-downloading if already present.
|
* Windows: patched Win → dist/ (replaces stock)
|
||||||
*
|
* Linux (local): patched Linux → dist/ (replaces stock), Win → dist-win/
|
||||||
* Usage:
|
* CI (Linux): Win → dist-win/, Linux → dist-linux/ (stock stays in dist/)
|
||||||
* node scripts/download-electron.js # download if needed
|
*
|
||||||
* node scripts/download-electron.js --force # re-download even if present
|
* Usage:
|
||||||
*/
|
* node scripts/download-electron.js # download if needed
|
||||||
|
* node scripts/download-electron.js --force # re-download even if present
|
||||||
const https = require('https');
|
*/
|
||||||
const http = require('http');
|
|
||||||
const fs = require('fs');
|
const https = require('https');
|
||||||
const path = require('path');
|
const http = require('http');
|
||||||
const { execSync } = require('child_process');
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
// ── Configuration ──────────────────────────────────────────────────────────
|
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';
|
// ── Configuration ──────────────────────────────────────────────────────────
|
||||||
const GITEA_BASE = 'https://gitea.crjlab.net';
|
const GITHUB_BASE = 'https://github.com';
|
||||||
const REPO = 'bigjakk/Krunker-Civilian-Client';
|
const REPO = 'bigjakk/Electron-Websocket-Fix';
|
||||||
// The release tag that holds the patched Electron zip.
|
const RELEASE_TAG = 'v1.0.0';
|
||||||
// Upload the zip as an asset to this release on Gitea.
|
|
||||||
const RELEASE_TAG = 'electron-patched';
|
const PLATFORMS = {
|
||||||
|
win32: { asset: 'electron-v42.0.0-nightly-release-patched-win32-x64.zip' },
|
||||||
// On Windows, overwrite the npm-installed Electron with our patched build.
|
linux: { asset: 'electron-v43.0.0-nightly-release-patched-linux-x64.zip' },
|
||||||
// On Linux/macOS (CI cross-compilation), extract to a separate dist-win/ directory
|
};
|
||||||
// so the npm-installed platform-native Electron stays in dist/ for bytenode compilation.
|
|
||||||
const IS_WIN = process.platform === 'win32';
|
const IS_WIN = process.platform === 'win32';
|
||||||
const ELECTRON_DIST = IS_WIN
|
const IS_CI = !!process.env.CI;
|
||||||
? path.resolve(__dirname, '..', 'node_modules', 'electron', 'dist')
|
const ELECTRON_BASE = path.resolve(__dirname, '..', 'node_modules', 'electron');
|
||||||
: path.resolve(__dirname, '..', 'node_modules', 'electron', 'dist-win');
|
|
||||||
const VERSION_FILE = path.join(ELECTRON_DIST, 'version');
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
// Separate marker file to distinguish patched from stock electron-nightly.
|
|
||||||
// Both have the same version string, so VERSION_FILE alone is not sufficient.
|
function get(url) {
|
||||||
const PATCHED_MARKER = path.join(ELECTRON_DIST, '.patched');
|
const lib = url.startsWith('https') ? https : http;
|
||||||
const TEMP_ZIP = path.join(ELECTRON_DIST, '..', '_electron-patched.zip');
|
return new Promise((resolve, reject) => {
|
||||||
|
lib.get(url, { headers: { 'User-Agent': 'KCC-Build' } }, (res) => {
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||||
|
get(res.headers.location).then(resolve, reject);
|
||||||
function get(url) {
|
res.resume();
|
||||||
const lib = url.startsWith('https') ? https : http;
|
return;
|
||||||
return new Promise((resolve, reject) => {
|
}
|
||||||
lib.get(url, { headers: { 'User-Agent': 'KCC-Build' } }, (res) => {
|
if (res.statusCode !== 200) {
|
||||||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
res.resume();
|
||||||
get(res.headers.location).then(resolve, reject);
|
reject(new Error(`HTTP ${res.statusCode} for ${url}`));
|
||||||
res.resume();
|
return;
|
||||||
return;
|
}
|
||||||
}
|
resolve(res);
|
||||||
if (res.statusCode !== 200) {
|
}).on('error', reject);
|
||||||
res.resume();
|
});
|
||||||
reject(new Error(`HTTP ${res.statusCode} for ${url}`));
|
}
|
||||||
return;
|
|
||||||
}
|
function downloadToFile(url, dest) {
|
||||||
resolve(res);
|
return new Promise(async (resolve, reject) => {
|
||||||
}).on('error', reject);
|
try {
|
||||||
});
|
const res = await get(url);
|
||||||
}
|
const total = parseInt(res.headers['content-length'] || '0', 10);
|
||||||
|
let downloaded = 0;
|
||||||
function downloadToFile(url, dest) {
|
|
||||||
return new Promise(async (resolve, reject) => {
|
const file = fs.createWriteStream(dest);
|
||||||
try {
|
res.on('data', (chunk) => {
|
||||||
const res = await get(url);
|
downloaded += chunk.length;
|
||||||
const total = parseInt(res.headers['content-length'] || '0', 10);
|
if (total > 0) {
|
||||||
let downloaded = 0;
|
const pct = ((downloaded / total) * 100).toFixed(1);
|
||||||
|
const mb = (downloaded / 1048576).toFixed(1);
|
||||||
const file = fs.createWriteStream(dest);
|
const totalMb = (total / 1048576).toFixed(1);
|
||||||
res.on('data', (chunk) => {
|
process.stdout.write(`\r Downloading: ${pct}% (${mb}/${totalMb} MB)`);
|
||||||
downloaded += chunk.length;
|
}
|
||||||
if (total > 0) {
|
});
|
||||||
const pct = ((downloaded / total) * 100).toFixed(1);
|
res.pipe(file);
|
||||||
const mb = (downloaded / 1048576).toFixed(1);
|
file.on('finish', () => {
|
||||||
const totalMb = (total / 1048576).toFixed(1);
|
file.close();
|
||||||
process.stdout.write(`\r Downloading: ${pct}% (${mb}/${totalMb} MB)`);
|
process.stdout.write('\n');
|
||||||
}
|
resolve();
|
||||||
});
|
});
|
||||||
res.pipe(file);
|
file.on('error', (err) => {
|
||||||
file.on('finish', () => {
|
fs.unlinkSync(dest);
|
||||||
file.close();
|
reject(err);
|
||||||
process.stdout.write('\n');
|
});
|
||||||
resolve();
|
} catch (err) {
|
||||||
});
|
reject(err);
|
||||||
file.on('error', (err) => {
|
}
|
||||||
fs.unlinkSync(dest);
|
});
|
||||||
reject(err);
|
}
|
||||||
});
|
|
||||||
} catch (err) {
|
function extractZip(zipPath, destDir) {
|
||||||
reject(err);
|
// Use PowerShell on Windows, unzip on Linux/macOS
|
||||||
}
|
if (process.platform === 'win32') {
|
||||||
});
|
execSync(
|
||||||
}
|
`powershell -NoProfile -Command "Expand-Archive -Force -Path '${zipPath}' -DestinationPath '${destDir}'"`,
|
||||||
|
{ stdio: 'inherit' }
|
||||||
async function getAssetUrl() {
|
);
|
||||||
const apiUrl = `${GITEA_BASE}/api/v1/repos/${REPO}/releases/tags/${RELEASE_TAG}`;
|
} else {
|
||||||
const res = await get(apiUrl);
|
execSync(`unzip -o "${zipPath}" -d "${destDir}"`, { stdio: 'inherit' });
|
||||||
const body = await new Promise((resolve, reject) => {
|
}
|
||||||
let data = '';
|
}
|
||||||
res.on('data', (chunk) => { data += chunk; });
|
|
||||||
res.on('end', () => resolve(data));
|
// ── Per-directory install ──────────────────────────────────────────────────
|
||||||
res.on('error', reject);
|
|
||||||
});
|
async function installTo(distDir, platform) {
|
||||||
|
const force = process.argv.includes('--force');
|
||||||
const release = JSON.parse(body);
|
const patchedMarker = path.join(distDir, '.patched');
|
||||||
const asset = release.assets.find((a) => a.name === ASSET_NAME);
|
const tempZip = path.join(ELECTRON_BASE, `_electron-patched-${platform.asset}`);
|
||||||
if (!asset) {
|
const label = path.relative(path.resolve(__dirname, '..'), distDir);
|
||||||
const names = release.assets.map((a) => a.name).join(', ');
|
|
||||||
throw new Error(
|
// Check if this exact asset is already installed.
|
||||||
`Asset "${ASSET_NAME}" not found in release "${RELEASE_TAG}".\n` +
|
// The marker stores the asset filename to handle version changes.
|
||||||
` Available assets: ${names || '(none)'}\n` +
|
if (!force && fs.existsSync(patchedMarker)) {
|
||||||
` Upload the patched Electron zip to: ${GITEA_BASE}/${REPO}/releases/tag/${RELEASE_TAG}`
|
const installed = fs.readFileSync(patchedMarker, 'utf8').trim();
|
||||||
);
|
if (installed === platform.asset) {
|
||||||
}
|
console.log(` [${label}] ${platform.asset} already installed, skipping`);
|
||||||
|
return;
|
||||||
// Gitea API returns browser_download_url for direct download
|
}
|
||||||
return asset.browser_download_url;
|
console.log(` [${label}] Installed: ${installed}, need: ${platform.asset}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractZip(zipPath, destDir) {
|
// Direct download from GitHub release
|
||||||
// Use PowerShell on Windows, unzip on Linux/macOS
|
const url = `${GITHUB_BASE}/${REPO}/releases/download/${RELEASE_TAG}/${platform.asset}`;
|
||||||
if (process.platform === 'win32') {
|
console.log(` [${label}] Asset URL: ${url}`);
|
||||||
execSync(
|
|
||||||
`powershell -NoProfile -Command "Expand-Archive -Force -Path '${zipPath}' -DestinationPath '${destDir}'"`,
|
// Download
|
||||||
{ stdio: 'inherit' }
|
await downloadToFile(url, tempZip);
|
||||||
);
|
const zipSize = (fs.statSync(tempZip).size / 1048576).toFixed(1);
|
||||||
} else {
|
console.log(` [${label}] Downloaded: ${zipSize} MB`);
|
||||||
execSync(`unzip -o "${zipPath}" -d "${destDir}"`, { stdio: 'inherit' });
|
|
||||||
}
|
// Clear existing target dir and extract
|
||||||
}
|
console.log(` [${label}] Extracting...`);
|
||||||
|
if (fs.existsSync(distDir)) {
|
||||||
// ── Main ───────────────────────────────────────────────────────────────────
|
fs.rmSync(distDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
async function main() {
|
fs.mkdirSync(distDir, { recursive: true });
|
||||||
const force = process.argv.includes('--force');
|
extractZip(tempZip, distDir);
|
||||||
|
|
||||||
// Check if patched version is already installed.
|
// Clean up temp zip
|
||||||
// The .patched marker distinguishes our build from stock electron-nightly
|
fs.unlinkSync(tempZip);
|
||||||
// (both share the same version string).
|
|
||||||
if (!force && fs.existsSync(PATCHED_MARKER)) {
|
// Write marker with asset name for future skip-check
|
||||||
const installed = fs.readFileSync(PATCHED_MARKER, 'utf8').trim();
|
fs.writeFileSync(patchedMarker, platform.asset);
|
||||||
if (installed === ELECTRON_VERSION) {
|
const versionFile = path.join(distDir, 'version');
|
||||||
console.log(` Patched Electron ${ELECTRON_VERSION} already installed, skipping`);
|
if (fs.existsSync(versionFile)) {
|
||||||
console.log(' (use --force to re-download)');
|
const ver = fs.readFileSync(versionFile, 'utf8').trim();
|
||||||
return;
|
console.log(` [${label}] Installed patched Electron ${ver}`);
|
||||||
}
|
} else {
|
||||||
console.log(` Installed: ${installed}, need: ${ELECTRON_VERSION}`);
|
console.log(` [${label}] Installed ${platform.asset} (no version file)`);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// Resolve download URL from Gitea release
|
|
||||||
console.log(` Fetching release info for "${RELEASE_TAG}"...`);
|
// ── Main ───────────────────────────────────────────────────────────────────
|
||||||
const url = await getAssetUrl();
|
|
||||||
console.log(` Asset URL: ${url}`);
|
async function main() {
|
||||||
|
if (IS_WIN) {
|
||||||
// Download
|
// Windows local dev: patched Win → dist/ (replaces stock)
|
||||||
await downloadToFile(url, TEMP_ZIP);
|
await installTo(path.join(ELECTRON_BASE, 'dist'), PLATFORMS.win32);
|
||||||
const zipSize = (fs.statSync(TEMP_ZIP).size / 1048576).toFixed(1);
|
} else if (IS_CI) {
|
||||||
console.log(` Downloaded: ${zipSize} MB`);
|
// CI (Linux): keep stock in dist/ untouched,
|
||||||
|
// patched Win → dist-win/, patched Linux → dist-linux/
|
||||||
// Clear existing target dir and extract
|
await installTo(path.join(ELECTRON_BASE, 'dist-win'), PLATFORMS.win32);
|
||||||
console.log(` Extracting to ${path.relative(path.resolve(__dirname, '..'), ELECTRON_DIST)}/...`);
|
await installTo(path.join(ELECTRON_BASE, 'dist-linux'), PLATFORMS.linux);
|
||||||
if (fs.existsSync(ELECTRON_DIST)) {
|
} else {
|
||||||
fs.rmSync(ELECTRON_DIST, { recursive: true, force: true });
|
// Linux local dev: patched Linux → dist/ (for npm run dev),
|
||||||
}
|
// patched Win → dist-win/ (for cross-compilation)
|
||||||
fs.mkdirSync(ELECTRON_DIST, { recursive: true });
|
await installTo(path.join(ELECTRON_BASE, 'dist'), PLATFORMS.linux);
|
||||||
extractZip(TEMP_ZIP, ELECTRON_DIST);
|
await installTo(path.join(ELECTRON_BASE, 'dist-win'), PLATFORMS.win32);
|
||||||
|
}
|
||||||
// Clean up temp zip
|
|
||||||
fs.unlinkSync(TEMP_ZIP);
|
// Write path.txt so the electron package's lazy downloader (index.js)
|
||||||
|
// considers the binary already installed and doesn't re-download stock.
|
||||||
// Write path.txt so the electron package's lazy downloader (index.js)
|
const platformExe = IS_WIN ? 'electron.exe' : 'electron';
|
||||||
// considers the binary already installed and doesn't re-download stock.
|
fs.writeFileSync(path.join(ELECTRON_BASE, '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) {
|
console.log('[KCC] Setting up patched Electron...');
|
||||||
fs.writeFileSync(path.join(ELECTRON_DIST, '..', 'path.txt'), 'electron.exe');
|
main().then(() => {
|
||||||
}
|
console.log('[KCC] Patched Electron ready.');
|
||||||
|
if (!IS_WIN) {
|
||||||
// Write marker and verify
|
console.log(' (use --force to re-download)');
|
||||||
if (fs.existsSync(VERSION_FILE)) {
|
}
|
||||||
const ver = fs.readFileSync(VERSION_FILE, 'utf8').trim();
|
}).catch((err) => {
|
||||||
fs.writeFileSync(PATCHED_MARKER, ver);
|
console.error('[KCC] Electron download failed:', err.message);
|
||||||
console.log(` Installed patched Electron ${ver}`);
|
console.error('');
|
||||||
} else {
|
console.error(' Download the patched Electron manually from:');
|
||||||
console.log(' Warning: version file not found after extraction');
|
console.error(` ${GITHUB_BASE}/${REPO}/releases/tag/${RELEASE_TAG}`);
|
||||||
}
|
console.error('');
|
||||||
}
|
console.error(` Win asset: ${PLATFORMS.win32.asset}`);
|
||||||
|
console.error(` Linux asset: ${PLATFORMS.linux.asset}`);
|
||||||
console.log('[KCC] Setting up patched Electron...');
|
process.exit(1);
|
||||||
main().then(() => {
|
});
|
||||||
console.log('[KCC] Patched Electron ready.');
|
|
||||||
}).catch((err) => {
|
|
||||||
console.error('[KCC] Electron download failed:', err.message);
|
|
||||||
console.error('');
|
|
||||||
console.error(' If this is your first time building, you need the patched Electron zip');
|
|
||||||
console.error(` uploaded as a release asset on ${GITEA_BASE}/${REPO}`);
|
|
||||||
console.error('');
|
|
||||||
console.error(' 1. Go to: ' + GITEA_BASE + '/' + REPO + '/releases/new');
|
|
||||||
console.error(` 2. Create a release with tag: ${RELEASE_TAG}`);
|
|
||||||
console.error(` 3. Upload: ${ASSET_NAME}`);
|
|
||||||
console.error('');
|
|
||||||
console.error(' See electron-build/BUILD.md for how to build Electron from source.');
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Generate Markdown release notes from conventional commits.
|
||||||
|
# Usage: ./scripts/generate-release-notes.sh <tag> [prev-ref]
|
||||||
|
# e.g. ./scripts/generate-release-notes.sh v0.7.0
|
||||||
|
# e.g. ./scripts/generate-release-notes.sh v0.7.0 abc123f
|
||||||
|
#
|
||||||
|
# If prev-ref is not provided, tries git describe to find previous tag.
|
||||||
|
# If no previous ref is found, includes all commits up to HEAD.
|
||||||
|
# Uses HEAD as the endpoint (tag may not exist in git yet).
|
||||||
|
|
||||||
|
set -eo pipefail
|
||||||
|
|
||||||
|
TAG="${1:?Usage: generate-release-notes.sh <tag> [prev-ref]}"
|
||||||
|
PREV_REF="${2:-}"
|
||||||
|
|
||||||
|
# If no prev-ref provided, try to find one from git tags
|
||||||
|
if [ -z "$PREV_REF" ]; then
|
||||||
|
PREV_REF=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$PREV_REF" ]; then
|
||||||
|
RANGE="${PREV_REF}..HEAD"
|
||||||
|
COMPARE_TEXT="**Full changelog**: \`${PREV_REF}...${TAG}\`"
|
||||||
|
else
|
||||||
|
RANGE="HEAD"
|
||||||
|
COMPARE_TEXT="**Initial release**"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect commits into temp files by category
|
||||||
|
TMPDIR_NOTES=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$TMPDIR_NOTES"' EXIT
|
||||||
|
|
||||||
|
for prefix in feat fix refactor perf docs test chore other; do
|
||||||
|
: > "${TMPDIR_NOTES}/${prefix}"
|
||||||
|
done
|
||||||
|
|
||||||
|
while IFS= read -r line; do
|
||||||
|
[ -z "$line" ] && continue
|
||||||
|
MATCHED=false
|
||||||
|
for prefix in feat fix refactor perf docs test chore; do
|
||||||
|
if [[ "$line" =~ ^${prefix}(\(.*\))?:\ (.+)$ ]]; then
|
||||||
|
SCOPE="${BASH_REMATCH[1]}"
|
||||||
|
MSG="${BASH_REMATCH[2]}"
|
||||||
|
if [ -n "$SCOPE" ]; then
|
||||||
|
echo "- **${SCOPE}**: ${MSG}" >> "${TMPDIR_NOTES}/${prefix}"
|
||||||
|
else
|
||||||
|
echo "- ${MSG}" >> "${TMPDIR_NOTES}/${prefix}"
|
||||||
|
fi
|
||||||
|
MATCHED=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ "$MATCHED" = false ]; then
|
||||||
|
echo "- ${line}" >> "${TMPDIR_NOTES}/other"
|
||||||
|
fi
|
||||||
|
done < <(git log --format="%s" "$RANGE" 2>/dev/null)
|
||||||
|
|
||||||
|
# Section display names
|
||||||
|
section_title() {
|
||||||
|
case "$1" in
|
||||||
|
feat) echo "Features" ;;
|
||||||
|
fix) echo "Bug Fixes" ;;
|
||||||
|
refactor) echo "Refactoring" ;;
|
||||||
|
perf) echo "Performance" ;;
|
||||||
|
docs) echo "Documentation" ;;
|
||||||
|
test) echo "Tests" ;;
|
||||||
|
chore) echo "Chores" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
echo "# KCC ${TAG}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
for prefix in feat fix refactor perf docs test chore; do
|
||||||
|
if [ -s "${TMPDIR_NOTES}/${prefix}" ]; then
|
||||||
|
echo "## $(section_title "$prefix")"
|
||||||
|
echo ""
|
||||||
|
cat "${TMPDIR_NOTES}/${prefix}"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -s "${TMPDIR_NOTES}/other" ]; then
|
||||||
|
echo "## Other Changes"
|
||||||
|
echo ""
|
||||||
|
cat "${TMPDIR_NOTES}/other"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "---"
|
||||||
|
echo ""
|
||||||
|
echo "${COMPARE_TEXT}"
|
||||||
+107
-6
@@ -1,5 +1,5 @@
|
|||||||
// ── Injected CSS for client settings in Krunker's settings panel ──
|
// ── Shared CSS theme variables (used by both main page and tab bar) ──
|
||||||
export const CLIENT_SETTINGS_CSS = `
|
export const THEME_CSS = `
|
||||||
:root {
|
:root {
|
||||||
/* ── Surfaces ── */
|
/* ── Surfaces ── */
|
||||||
--kpc-surface-card: rgba(255,255,255,0.04);
|
--kpc-surface-card: rgba(255,255,255,0.04);
|
||||||
@@ -42,8 +42,12 @@ export const CLIENT_SETTINGS_CSS = `
|
|||||||
--kpc-z-notification: 100000;
|
--kpc-z-notification: 100000;
|
||||||
--kpc-z-overlay: 10000000;
|
--kpc-z-overlay: 10000000;
|
||||||
--kpc-z-popup: 10000001;
|
--kpc-z-popup: 10000001;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ── Injected CSS for client settings in Krunker's settings panel ──
|
||||||
|
export const CLIENT_SETTINGS_CSS = `
|
||||||
|
${THEME_CSS}
|
||||||
/* ── Crankshaft-style settings (Krunker-native classes) ── */
|
/* ── Crankshaft-style settings (Krunker-native classes) ── */
|
||||||
|
|
||||||
.kpc-settings .settName,
|
.kpc-settings .settName,
|
||||||
@@ -450,7 +454,7 @@ export const MATCHMAKER_SETTINGS_CSS = `
|
|||||||
0% { transform: translate(-50%, -500%); }
|
0% { transform: translate(-50%, -500%); }
|
||||||
100% { transform: translate(-50%, 0%); }
|
100% { transform: translate(-50%, 0%); }
|
||||||
}
|
}
|
||||||
.onGame #matchmakerPopupContainer {
|
.onGame #matchmakerPopupContainer:not(.searching) {
|
||||||
opacity: 0 !important;
|
opacity: 0 !important;
|
||||||
}
|
}
|
||||||
#matchmakerPopupContainer {
|
#matchmakerPopupContainer {
|
||||||
@@ -505,7 +509,7 @@ export const MATCHMAKER_SETTINGS_CSS = `
|
|||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
#matchmakerCancelButton {
|
#matchmakerCancelButton {
|
||||||
border-color: #f44336;
|
border-color: var(--kpc-red);
|
||||||
}
|
}
|
||||||
.matchmakerPopupButton:hover {
|
.matchmakerPopupButton:hover {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -515,6 +519,91 @@ export const MATCHMAKER_SETTINGS_CSS = `
|
|||||||
.matchmakerPopupButton:active {
|
.matchmakerPopupButton:active {
|
||||||
transform: scale(0.85);
|
transform: scale(0.85);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Search phase ── */
|
||||||
|
#matchmakerPopupContainer.searching {
|
||||||
|
background-image: none !important;
|
||||||
|
background: var(--kpc-surface-raised);
|
||||||
|
width: 24em;
|
||||||
|
aspect-ratio: auto;
|
||||||
|
padding: 1em 1.5em;
|
||||||
|
}
|
||||||
|
#matchmakerPopupContainer.searching #matchmakerPopupTitle,
|
||||||
|
#matchmakerPopupContainer.searching #matchmakerPopupDescription,
|
||||||
|
#matchmakerPopupContainer.searching #matchmakerPopupOptions {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#matchmakerPopupContainer:not(.searching) #matchmakerSearchContainer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#matchmakerSearchStatus {
|
||||||
|
font-size: 1.4em;
|
||||||
|
color: var(--kpc-blue);
|
||||||
|
margin-bottom: 0.6em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
#matchmakerSearchFeed {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15em;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 5.6em;
|
||||||
|
margin-bottom: 0.6em;
|
||||||
|
}
|
||||||
|
@keyframes mmFeedSlideIn {
|
||||||
|
from { opacity: 0; transform: translateX(1em); }
|
||||||
|
to { opacity: 1; transform: translateX(0); }
|
||||||
|
}
|
||||||
|
.mm-feed-entry {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.8em;
|
||||||
|
padding: 0.2em 0.5em;
|
||||||
|
font-size: 0.95em;
|
||||||
|
font-family: 'GameFont', monospace;
|
||||||
|
border-radius: 0.2em;
|
||||||
|
animation: mmFeedSlideIn 0.12s ease forwards;
|
||||||
|
}
|
||||||
|
.mm-feed-entry.mm-pass { background: rgba(76,175,80,0.1); }
|
||||||
|
.mm-feed-entry.mm-pass .mm-feed-region { color: var(--kpc-blue); }
|
||||||
|
.mm-feed-entry.mm-pass .mm-feed-map { color: var(--kpc-text-primary, rgba(255,255,255,0.9)); }
|
||||||
|
.mm-feed-entry.mm-pass .mm-feed-players { color: var(--kpc-green); }
|
||||||
|
.mm-feed-entry.mm-fail { background: rgba(255,255,255,0.02); }
|
||||||
|
.mm-feed-entry.mm-fail .mm-feed-region { color: var(--kpc-text-dim, rgba(255,255,255,0.3)); }
|
||||||
|
.mm-feed-entry.mm-fail .mm-feed-map { color: var(--kpc-text-muted, rgba(255,255,255,0.5)); }
|
||||||
|
.mm-feed-entry.mm-fail .mm-feed-players { color: var(--kpc-red); }
|
||||||
|
.mm-feed-entry:last-child::before {
|
||||||
|
content: '\\25B8 ';
|
||||||
|
color: var(--kpc-yellow);
|
||||||
|
}
|
||||||
|
.mm-feed-region { min-width: 2.5em; font-weight: bold; }
|
||||||
|
.mm-feed-map { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.mm-feed-players { min-width: 3em; text-align: right; font-weight: 600; }
|
||||||
|
#matchmakerSearchCounter {
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: var(--kpc-yellow);
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
#matchmakerSearchCancel {
|
||||||
|
text-align: center;
|
||||||
|
border: 0.2em solid var(--kpc-red);
|
||||||
|
color: white;
|
||||||
|
border-radius: 0.3em;
|
||||||
|
font-size: 1.1em;
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
padding: 0.2em 1.2em;
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: fit-content;
|
||||||
|
transition: all 0.08s;
|
||||||
|
}
|
||||||
|
#matchmakerSearchCancel:hover {
|
||||||
|
border-color: white;
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
#matchmakerSearchCancel:active {
|
||||||
|
transform: scale(0.85);
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const TRANSLATOR_CSS = `
|
export const TRANSLATOR_CSS = `
|
||||||
@@ -583,5 +672,17 @@ export const ALT_MANAGER_CSS = `
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// ── HP enemy counter CSS ──
|
||||||
|
export const HP_COUNTER_CSS = `
|
||||||
|
.kpc-hp-counter .pointVal {
|
||||||
|
color: #ff4444; font-size: 15px; font-weight: bold;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ── Battle Pass Claim All CSS ──
|
||||||
|
export const BP_CLAIM_ALL_CSS = `
|
||||||
|
#claimAllBtn.disabled { opacity: 0.4; pointer-events: none; }
|
||||||
|
`;
|
||||||
|
|
||||||
/** Pre-concatenated CSS for single-call injection (excludes HIDE_ADS_CSS which is separate) */
|
/** Pre-concatenated CSS for single-call injection (excludes HIDE_ADS_CSS which is separate) */
|
||||||
export const ALL_CLIENT_CSS = `${CLIENT_SETTINGS_CSS}\n${MATCHMAKER_SETTINGS_CSS}\n${TRANSLATOR_CSS}\n${ALT_MANAGER_CSS}`;
|
export const ALL_CLIENT_CSS = `${CLIENT_SETTINGS_CSS}\n${MATCHMAKER_SETTINGS_CSS}\n${TRANSLATOR_CSS}\n${ALT_MANAGER_CSS}\n${HP_COUNTER_CSS}\n${BP_CLAIM_ALL_CSS}`;
|
||||||
|
|||||||
@@ -27,11 +27,19 @@ export interface AppConfig {
|
|||||||
fpsUnlocked: boolean;
|
fpsUnlocked: boolean;
|
||||||
hardwareAccel: boolean;
|
hardwareAccel: boolean;
|
||||||
gpuPreference: 'high-performance' | 'low-power' | 'default';
|
gpuPreference: 'high-performance' | 'low-power' | 'default';
|
||||||
|
cpuThrottleGame: number;
|
||||||
|
cpuThrottleMenu: number;
|
||||||
|
processPriority: string;
|
||||||
};
|
};
|
||||||
game: {
|
game: {
|
||||||
lastServer: string;
|
lastServer: string;
|
||||||
socialTabBehaviour: 'New Window' | 'Same Window';
|
socialTabBehaviour: 'New Window' | 'Same Window';
|
||||||
joinAsSpectator: boolean;
|
joinAsSpectator: boolean;
|
||||||
|
rawInput: boolean;
|
||||||
|
betterChat: boolean;
|
||||||
|
chatHistorySize: number;
|
||||||
|
showPing: boolean;
|
||||||
|
hpEnemyCounter: boolean;
|
||||||
};
|
};
|
||||||
swapper: {
|
swapper: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -41,6 +49,7 @@ export interface AppConfig {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
regions: string[];
|
regions: string[];
|
||||||
gamemodes: string[];
|
gamemodes: string[];
|
||||||
|
maps: string[];
|
||||||
minPlayers: number;
|
minPlayers: number;
|
||||||
maxPlayers: number;
|
maxPlayers: number;
|
||||||
minRemainingTime: number;
|
minRemainingTime: number;
|
||||||
@@ -67,6 +76,14 @@ export interface AppConfig {
|
|||||||
showExitButton: boolean;
|
showExitButton: boolean;
|
||||||
deathscreenAnimation: boolean;
|
deathscreenAnimation: boolean;
|
||||||
hideMenuPopups: boolean;
|
hideMenuPopups: boolean;
|
||||||
|
cleanerMenu: boolean;
|
||||||
|
menuTimer: boolean;
|
||||||
|
doublePing: boolean;
|
||||||
|
cssTheme: string;
|
||||||
|
loadingTheme: string;
|
||||||
|
backgroundUrl: string;
|
||||||
|
showChangelog: boolean;
|
||||||
|
lastSeenVersion: string;
|
||||||
};
|
};
|
||||||
discord: {
|
discord: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -85,8 +102,16 @@ export interface AppConfig {
|
|||||||
lowLatency: boolean;
|
lowLatency: boolean;
|
||||||
experimentalFlags: boolean;
|
experimentalFlags: boolean;
|
||||||
angleBackend: string;
|
angleBackend: string;
|
||||||
|
verboseLogging: boolean;
|
||||||
};
|
};
|
||||||
accounts: SavedAccount[];
|
accounts: SavedAccount[];
|
||||||
|
tabWindow: {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
x: number | undefined;
|
||||||
|
y: number | undefined;
|
||||||
|
maximized: boolean;
|
||||||
|
};
|
||||||
platform: {
|
platform: {
|
||||||
detectedOS: string;
|
detectedOS: string;
|
||||||
gpuBackend: string;
|
gpuBackend: string;
|
||||||
@@ -123,11 +148,19 @@ export const config = new Store<AppConfig>({
|
|||||||
fpsUnlocked: true,
|
fpsUnlocked: true,
|
||||||
hardwareAccel: true,
|
hardwareAccel: true,
|
||||||
gpuPreference: 'high-performance',
|
gpuPreference: 'high-performance',
|
||||||
|
cpuThrottleGame: 1,
|
||||||
|
cpuThrottleMenu: 1.5,
|
||||||
|
processPriority: 'Normal',
|
||||||
},
|
},
|
||||||
game: {
|
game: {
|
||||||
lastServer: '',
|
lastServer: '',
|
||||||
socialTabBehaviour: 'New Window',
|
socialTabBehaviour: 'New Window',
|
||||||
joinAsSpectator: false,
|
joinAsSpectator: false,
|
||||||
|
rawInput: true,
|
||||||
|
betterChat: true,
|
||||||
|
chatHistorySize: 200,
|
||||||
|
showPing: true,
|
||||||
|
hpEnemyCounter: true,
|
||||||
},
|
},
|
||||||
swapper: {
|
swapper: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -137,6 +170,7 @@ export const config = new Store<AppConfig>({
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
regions: [],
|
regions: [],
|
||||||
gamemodes: [],
|
gamemodes: [],
|
||||||
|
maps: [],
|
||||||
minPlayers: 1,
|
minPlayers: 1,
|
||||||
maxPlayers: 6,
|
maxPlayers: 6,
|
||||||
minRemainingTime: 120,
|
minRemainingTime: 120,
|
||||||
@@ -152,6 +186,14 @@ export const config = new Store<AppConfig>({
|
|||||||
showExitButton: true,
|
showExitButton: true,
|
||||||
deathscreenAnimation: true,
|
deathscreenAnimation: true,
|
||||||
hideMenuPopups: false,
|
hideMenuPopups: false,
|
||||||
|
cleanerMenu: false,
|
||||||
|
menuTimer: true,
|
||||||
|
doublePing: true,
|
||||||
|
cssTheme: 'disabled',
|
||||||
|
loadingTheme: 'disabled',
|
||||||
|
backgroundUrl: '',
|
||||||
|
showChangelog: true,
|
||||||
|
lastSeenVersion: '',
|
||||||
},
|
},
|
||||||
discord: {
|
discord: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@@ -170,8 +212,16 @@ export const config = new Store<AppConfig>({
|
|||||||
lowLatency: false,
|
lowLatency: false,
|
||||||
experimentalFlags: false,
|
experimentalFlags: false,
|
||||||
angleBackend: 'default',
|
angleBackend: 'default',
|
||||||
|
verboseLogging: false,
|
||||||
},
|
},
|
||||||
accounts: [],
|
accounts: [],
|
||||||
|
tabWindow: {
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
x: undefined,
|
||||||
|
y: undefined,
|
||||||
|
maximized: true,
|
||||||
|
},
|
||||||
platform: {
|
platform: {
|
||||||
detectedOS: platformInfo.os,
|
detectedOS: platformInfo.os,
|
||||||
gpuBackend: platformInfo.gpuBackend,
|
gpuBackend: platformInfo.gpuBackend,
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
// ── CSS theme & loading screen background management ──
|
||||||
|
// Scans swap directory for user CSS themes and loading screen backgrounds.
|
||||||
|
|
||||||
|
import { readdirSync, readFileSync } from 'fs';
|
||||||
|
import { join, extname, basename } from 'path';
|
||||||
|
|
||||||
|
export interface ThemeEntry {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoadingThemeEntry {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listThemes(swapDir: string): ThemeEntry[] {
|
||||||
|
const entries: ThemeEntry[] = [{ id: 'disabled', label: 'Disabled' }];
|
||||||
|
const themesDir = join(swapDir, 'themes');
|
||||||
|
try {
|
||||||
|
const files = readdirSync(themesDir);
|
||||||
|
for (const file of files) {
|
||||||
|
if (extname(file).toLowerCase() === '.css') {
|
||||||
|
entries.push({ id: `user:${file}`, label: basename(file, '.css') });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* themes dir doesn't exist yet — that's fine */ }
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getThemeCSS(themeId: string, swapDir: string): string {
|
||||||
|
if (themeId === 'disabled' || !themeId) return '';
|
||||||
|
const prefix = 'user:';
|
||||||
|
if (!themeId.startsWith(prefix)) return '';
|
||||||
|
const filename = themeId.slice(prefix.length);
|
||||||
|
try {
|
||||||
|
return readFileSync(join(swapDir, 'themes', filename), 'utf-8');
|
||||||
|
} catch { return ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp']);
|
||||||
|
|
||||||
|
export function listLoadingThemes(swapDir: string): LoadingThemeEntry[] {
|
||||||
|
const entries: LoadingThemeEntry[] = [
|
||||||
|
{ id: 'disabled', label: 'Disabled (Default)' },
|
||||||
|
{ id: 'swap:random', label: 'Random (from backgrounds/)' },
|
||||||
|
];
|
||||||
|
const bgDir = join(swapDir, 'backgrounds');
|
||||||
|
try {
|
||||||
|
const files = readdirSync(bgDir);
|
||||||
|
for (const file of files) {
|
||||||
|
if (IMAGE_EXTS.has(extname(file).toLowerCase())) {
|
||||||
|
entries.push({ id: `swap:${file}`, label: file });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* backgrounds dir doesn't exist yet */ }
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mimeFromExt(ext: string): string {
|
||||||
|
switch (ext.toLowerCase()) {
|
||||||
|
case '.jpg':
|
||||||
|
case '.jpeg':
|
||||||
|
return 'image/jpeg';
|
||||||
|
case '.gif':
|
||||||
|
return 'image/gif';
|
||||||
|
case '.webp':
|
||||||
|
return 'image/webp';
|
||||||
|
default:
|
||||||
|
return 'image/png';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBackgroundFiles(swapDir: string): string[] {
|
||||||
|
const bgDir = join(swapDir, 'backgrounds');
|
||||||
|
try {
|
||||||
|
return readdirSync(bgDir).filter(f => IMAGE_EXTS.has(extname(f).toLowerCase()));
|
||||||
|
} catch { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileToDataUri(filePath: string): string {
|
||||||
|
const data = readFileSync(filePath);
|
||||||
|
const mime = mimeFromExt(extname(filePath));
|
||||||
|
return `data:${mime};base64,${data.toString('base64')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLoadingScreenCSS(loadingTheme: string, backgroundUrl: string, swapDir: string): string {
|
||||||
|
let imageUrl = '';
|
||||||
|
|
||||||
|
// Explicit URL takes priority
|
||||||
|
if (backgroundUrl) {
|
||||||
|
try {
|
||||||
|
new URL(backgroundUrl);
|
||||||
|
imageUrl = `url(${backgroundUrl})`;
|
||||||
|
} catch { /* invalid URL — ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!imageUrl && loadingTheme && loadingTheme !== 'disabled') {
|
||||||
|
const bgDir = join(swapDir, 'backgrounds');
|
||||||
|
if (loadingTheme === 'swap:random') {
|
||||||
|
const files = getBackgroundFiles(swapDir);
|
||||||
|
if (files.length > 0) {
|
||||||
|
const pick = files[Math.floor(Math.random() * files.length)];
|
||||||
|
try {
|
||||||
|
imageUrl = `url(${fileToDataUri(join(bgDir, pick))})`;
|
||||||
|
} catch { /* read failed */ }
|
||||||
|
}
|
||||||
|
} else if (loadingTheme.startsWith('swap:')) {
|
||||||
|
const filename = loadingTheme.slice(5);
|
||||||
|
try {
|
||||||
|
imageUrl = `url(${fileToDataUri(join(bgDir, filename))})`;
|
||||||
|
} catch { /* read failed */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!imageUrl) return '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
#instructionHolder[style^="display: block"] {
|
||||||
|
background-image: initial !important;
|
||||||
|
}
|
||||||
|
#instructionHolder {
|
||||||
|
background-image: ${imageUrl} !important;
|
||||||
|
background-size: cover !important;
|
||||||
|
background-position: center !important;
|
||||||
|
}
|
||||||
|
#instructions {
|
||||||
|
display: block;
|
||||||
|
visibility: hidden;
|
||||||
|
}`;
|
||||||
|
}
|
||||||
+162
-59
@@ -1,8 +1,9 @@
|
|||||||
import { app, BrowserWindow, Menu, clipboard, dialog, ipcMain, safeStorage, session, shell } from 'electron';
|
import { app, BrowserWindow, Menu, clipboard, 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';
|
||||||
import { execFile } from 'child_process';
|
import { execFile } from 'child_process';
|
||||||
|
import * as os from 'os';
|
||||||
import { detectPlatform, applyPlatformFlags } from './platform';
|
import { detectPlatform, applyPlatformFlags } from './platform';
|
||||||
import { config, Keybind, DEFAULT_KEYBINDS, SavedAccount } from './config';
|
import { config, Keybind, DEFAULT_KEYBINDS, SavedAccount } from './config';
|
||||||
import { initSwapperProtocol, registerSwapperFileProtocol, ResourceSwapper } from './swapper';
|
import { initSwapperProtocol, registerSwapperFileProtocol, ResourceSwapper } from './swapper';
|
||||||
@@ -12,6 +13,8 @@ import { electronLog, getLogPath, closeLogStreams } from './logger';
|
|||||||
import { checkForUpdate, downloadUpdate, installUpdate } from './updater';
|
import { checkForUpdate, downloadUpdate, installUpdate } from './updater';
|
||||||
import { showUpdateWindow } from './update-window';
|
import { showUpdateWindow } from './update-window';
|
||||||
import { DiscordRPC } from './discord-rpc';
|
import { DiscordRPC } from './discord-rpc';
|
||||||
|
import { listThemes, getThemeCSS, listLoadingThemes, getLoadingScreenCSS } from './css-themes';
|
||||||
|
import { TabManager } from './tab-manager';
|
||||||
|
|
||||||
// ── App version for API calls ──
|
// ── App version for API calls ──
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
@@ -321,10 +324,54 @@ async function launchApp(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Common output directory (used by folder actions) ──
|
// ── Process Priority (Windows only) ──
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
const PRIORITY_MAP: Record<string, number> = {
|
||||||
|
'High': -14,
|
||||||
|
'Above Normal': -7,
|
||||||
|
'Below Normal': 7,
|
||||||
|
'Low': 19,
|
||||||
|
};
|
||||||
|
const prioritySetting = config.get('performance')?.processPriority || 'Normal';
|
||||||
|
const priorityVal = PRIORITY_MAP[prioritySetting];
|
||||||
|
if (priorityVal !== undefined) {
|
||||||
|
try { os.setPriority(process.pid, priorityVal); } catch { /* ignore */ }
|
||||||
|
// Apply to child processes periodically
|
||||||
|
setInterval(() => {
|
||||||
|
for (const m of app.getAppMetrics()) {
|
||||||
|
if (m.pid !== process.pid) {
|
||||||
|
try { os.setPriority(m.pid, priorityVal); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
electronLog.log(`[KCC] Process priority set to ${prioritySetting}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CPU Throttling via Chrome DevTools Protocol ──
|
||||||
|
const throttledContents = new WeakSet<Electron.WebContents>();
|
||||||
|
|
||||||
|
function applyCpuThrottle(wc: Electron.WebContents, rate: number): void {
|
||||||
|
const clamped = Math.max(1, Math.min(3, rate));
|
||||||
|
try {
|
||||||
|
if (!throttledContents.has(wc)) {
|
||||||
|
wc.debugger.attach('1.3');
|
||||||
|
throttledContents.add(wc);
|
||||||
|
}
|
||||||
|
wc.debugger.sendCommand('Emulation.setCPUThrottlingRate', { rate: clamped });
|
||||||
|
} catch { /* debugger may already be attached or detached */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Keybind capture lock (suppresses shortcuts while the keybind dialog is open) ──
|
||||||
|
let keybindCapturing = false;
|
||||||
|
ipcMain.on('keybind-capture', (_e, capturing: boolean) => {
|
||||||
|
keybindCapturing = capturing;
|
||||||
|
});
|
||||||
|
|
||||||
// ── Configurable keybinds via before-input-event ──
|
// ── 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;
|
||||||
|
if (keybindCapturing) return;
|
||||||
|
|
||||||
const binds = getKeybinds();
|
const binds = getKeybinds();
|
||||||
|
|
||||||
@@ -332,7 +379,16 @@ async function launchApp(): Promise<void> {
|
|||||||
win.reload();
|
win.reload();
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
} else if (matchesKeybind(input, binds.newMatch)) {
|
} else if (matchesKeybind(input, binds.newMatch)) {
|
||||||
win.loadURL('https://krunker.io');
|
const mm = config.get('matchmaker');
|
||||||
|
if (mm.enabled) {
|
||||||
|
win.webContents.send('matchmaker-find', {
|
||||||
|
...mm,
|
||||||
|
acceptKey: binds.matchmakerAccept,
|
||||||
|
cancelKey: binds.matchmakerCancel,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
win.loadURL('https://krunker.io');
|
||||||
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
} else if (matchesKeybind(input, binds.joinFromClipboard)) {
|
} else if (matchesKeybind(input, binds.joinFromClipboard)) {
|
||||||
const text = clipboard.readText();
|
const text = clipboard.readText();
|
||||||
@@ -362,6 +418,12 @@ async function launchApp(): Promise<void> {
|
|||||||
} else if (matchesKeybind(input, binds.fullscreenToggle)) {
|
} else if (matchesKeybind(input, binds.fullscreenToggle)) {
|
||||||
win.setFullScreen(!win.isFullScreen());
|
win.setFullScreen(!win.isFullScreen());
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
} else if (input.key === 't' && input.control && !input.shift && !input.alt) {
|
||||||
|
tabManager.openTab('https://krunker.io/social.html');
|
||||||
|
event.preventDefault();
|
||||||
|
} else if (input.key === 'T' && input.control && input.shift && !input.alt) {
|
||||||
|
tabManager.reopenTab();
|
||||||
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -373,7 +435,7 @@ async function launchApp(): Promise<void> {
|
|||||||
win.on('enter-full-screen', () => saveWindowState(win));
|
win.on('enter-full-screen', () => saveWindowState(win));
|
||||||
win.on('leave-full-screen', () => saveWindowState(win));
|
win.on('leave-full-screen', () => saveWindowState(win));
|
||||||
|
|
||||||
// ── Open krunker.io sub-pages in a new window ──
|
// ── URL classification ──
|
||||||
const GAME_PAGE_PATHS = ['/', ''];
|
const GAME_PAGE_PATHS = ['/', ''];
|
||||||
function isGameURL(url: string): boolean {
|
function isGameURL(url: string): boolean {
|
||||||
try {
|
try {
|
||||||
@@ -383,48 +445,6 @@ async function launchApp(): Promise<void> {
|
|||||||
} catch { return false; }
|
} catch { return false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function openSubWindow(url: string): void {
|
|
||||||
const sub = new BrowserWindow({
|
|
||||||
width: 1280,
|
|
||||||
height: 720,
|
|
||||||
frame: true,
|
|
||||||
backgroundColor: '#000000',
|
|
||||||
autoHideMenuBar: true,
|
|
||||||
webPreferences: {
|
|
||||||
preload: join(__dirname, '..', 'preload', 'index.js'),
|
|
||||||
session: ses,
|
|
||||||
contextIsolation: false,
|
|
||||||
nodeIntegration: false,
|
|
||||||
sandbox: false,
|
|
||||||
spellcheck: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
sub.removeMenu();
|
|
||||||
sub.loadURL(url);
|
|
||||||
sub.webContents.on('did-finish-load', () => {
|
|
||||||
sub.webContents.insertCSS(ALL_CLIENT_CSS).catch(() => {});
|
|
||||||
sub.webContents.send('main_did-finish-load');
|
|
||||||
});
|
|
||||||
sub.webContents.setWindowOpenHandler(({ url: subUrl }) => {
|
|
||||||
if (subUrl.includes('krunker.io')) {
|
|
||||||
sub.loadURL(subUrl);
|
|
||||||
} else {
|
|
||||||
setImmediate(() => safeOpenExternal(subUrl));
|
|
||||||
}
|
|
||||||
return { action: 'deny' };
|
|
||||||
});
|
|
||||||
sub.webContents.on('will-prevent-unload', (ev) => {
|
|
||||||
const choice = dialog.showMessageBoxSync(sub, {
|
|
||||||
type: 'question',
|
|
||||||
buttons: ['Leave', 'Stay'],
|
|
||||||
defaultId: 1,
|
|
||||||
title: 'Leave page?',
|
|
||||||
message: 'Changes you made may not be saved.',
|
|
||||||
});
|
|
||||||
if (choice === 0) ev.preventDefault();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Cached game config (invalidated on set-config writes to 'game') ──
|
// ── Cached game config (invalidated on set-config writes to 'game') ──
|
||||||
const gameDefaults = { lastServer: '', socialTabBehaviour: 'New Window' };
|
const gameDefaults = { lastServer: '', socialTabBehaviour: 'New Window' };
|
||||||
let cachedGameConf: typeof gameDefaults | null = null;
|
let cachedGameConf: typeof gameDefaults | null = null;
|
||||||
@@ -433,13 +453,20 @@ async function launchApp(): Promise<void> {
|
|||||||
return cachedGameConf;
|
return cachedGameConf;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Tab Manager ──
|
||||||
|
const preloadPath = join(__dirname, '..', 'preload', 'index.js');
|
||||||
|
let tabMode: 'same' | 'new' = getGameConf().socialTabBehaviour === 'Same Window' ? 'same' : 'new';
|
||||||
|
let tabManager = new TabManager(
|
||||||
|
win, ses, preloadPath, tabMode, isGameURL,
|
||||||
|
() => config.get('tabWindow'),
|
||||||
|
(state) => config.set('tabWindow', state),
|
||||||
|
);
|
||||||
|
|
||||||
// Intercept in-page navigation (e.g. window.location = '/social.html')
|
// Intercept in-page navigation (e.g. window.location = '/social.html')
|
||||||
win.webContents.on('will-navigate', (event, url) => {
|
win.webContents.on('will-navigate', (event, url) => {
|
||||||
if (url.includes('krunker.io') && !isGameURL(url)) {
|
if (url.includes('krunker.io') && !isGameURL(url)) {
|
||||||
if (getGameConf().socialTabBehaviour === 'New Window') {
|
event.preventDefault();
|
||||||
event.preventDefault();
|
tabManager.openTab(url);
|
||||||
openSubWindow(url);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -449,11 +476,7 @@ async function launchApp(): Promise<void> {
|
|||||||
if (isGameURL(url)) {
|
if (isGameURL(url)) {
|
||||||
win.loadURL(url);
|
win.loadURL(url);
|
||||||
} else {
|
} else {
|
||||||
if (getGameConf().socialTabBehaviour === 'New Window') {
|
setImmediate(() => tabManager.openTab(url));
|
||||||
openSubWindow(url);
|
|
||||||
} else {
|
|
||||||
win.loadURL(url);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setImmediate(() => safeOpenExternal(url));
|
setImmediate(() => safeOpenExternal(url));
|
||||||
@@ -461,15 +484,45 @@ async function launchApp(): Promise<void> {
|
|||||||
return { action: 'deny' };
|
return { action: 'deny' };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Right-click context menu on main window with "Open in New Tab"
|
||||||
|
win.webContents.on('context-menu', (_e, params) => {
|
||||||
|
if (!params.linkURL) return;
|
||||||
|
const items: Electron.MenuItemConstructorOptions[] = [];
|
||||||
|
if (params.linkURL.includes('krunker.io') && !isGameURL(params.linkURL)) {
|
||||||
|
items.push({ label: 'Open in New Tab', click: () => tabManager.openTab(params.linkURL) });
|
||||||
|
}
|
||||||
|
items.push({ label: 'Copy Link', click: () => clipboard.writeText(params.linkURL) });
|
||||||
|
if (!params.linkURL.includes('krunker.io')) {
|
||||||
|
items.push({ label: 'Open in Browser', click: () => safeOpenExternal(params.linkURL) });
|
||||||
|
}
|
||||||
|
if (items.length) Menu.buildFromTemplate(items).popup();
|
||||||
|
});
|
||||||
|
|
||||||
// ── Inject scripts after page loads ──
|
// ── 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
|
// Rescan swap directory so new/changed files are picked up on refresh
|
||||||
if (swapper) swapper.rescan().catch(() => {});
|
if (swapper) swapper.rescan().catch(() => {});
|
||||||
Promise.all([
|
|
||||||
|
const cssInjections = [
|
||||||
win.webContents.insertCSS(HIDE_ADS_CSS),
|
win.webContents.insertCSS(HIDE_ADS_CSS),
|
||||||
win.webContents.insertCSS(ALL_CLIENT_CSS),
|
win.webContents.insertCSS(ALL_CLIENT_CSS),
|
||||||
]).catch(() => {});
|
];
|
||||||
|
|
||||||
|
// Inject user CSS theme
|
||||||
|
const uiConf = config.get('ui');
|
||||||
|
const themeCSS = getThemeCSS(uiConf?.cssTheme || 'disabled', swapDir);
|
||||||
|
if (themeCSS) cssInjections.push(win.webContents.insertCSS(themeCSS));
|
||||||
|
|
||||||
|
// Inject loading screen background
|
||||||
|
const loadingCSS = getLoadingScreenCSS(uiConf?.loadingTheme || 'disabled', uiConf?.backgroundUrl || '', swapDir);
|
||||||
|
if (loadingCSS) cssInjections.push(win.webContents.insertCSS(loadingCSS));
|
||||||
|
|
||||||
|
Promise.all(cssInjections).catch(() => {});
|
||||||
|
|
||||||
|
// Apply initial CPU throttle (menu state)
|
||||||
|
const perf = config.get('performance');
|
||||||
|
applyCpuThrottle(win.webContents, perf?.cpuThrottleMenu ?? 1.5);
|
||||||
|
|
||||||
win.webContents.executeJavaScript(ESCAPE_POINTERLOCK_FIX_JS).catch((err) => electronLog.warn('[KCC] Pointerlock fix inject failed:', err));
|
win.webContents.executeJavaScript(ESCAPE_POINTERLOCK_FIX_JS).catch((err) => electronLog.warn('[KCC] Pointerlock fix inject failed:', err));
|
||||||
win.webContents.executeJavaScript(CONSENT_DISMISS_MAIN_JS).catch((err) => electronLog.warn('[KCC] Consent dismiss inject failed:', err));
|
win.webContents.executeJavaScript(CONSENT_DISMISS_MAIN_JS).catch((err) => electronLog.warn('[KCC] Consent dismiss inject failed:', err));
|
||||||
@@ -481,7 +534,7 @@ async function launchApp(): Promise<void> {
|
|||||||
const ALLOWED_CONFIG_KEYS = new Set<string>([
|
const ALLOWED_CONFIG_KEYS = new Set<string>([
|
||||||
'window', 'performance', 'game', 'swapper', 'matchmaker',
|
'window', 'performance', 'game', 'swapper', 'matchmaker',
|
||||||
'keybinds', 'userscripts', 'ui', 'discord', 'translator',
|
'keybinds', 'userscripts', 'ui', 'discord', 'translator',
|
||||||
'advanced', 'accounts', 'platform',
|
'advanced', 'accounts', 'tabWindow', 'platform',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
ipcMain.handle('get-version', () => appVersion);
|
ipcMain.handle('get-version', () => appVersion);
|
||||||
@@ -509,7 +562,23 @@ async function launchApp(): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Invalidate caches immediately (not on flush) to prevent stale reads
|
// Invalidate caches immediately (not on flush) to prevent stale reads
|
||||||
if (key === 'game') cachedGameConf = null;
|
if (key === 'game') {
|
||||||
|
cachedGameConf = null;
|
||||||
|
// Switch tab mode if socialTabBehaviour changed
|
||||||
|
const newGame = value as any;
|
||||||
|
if (newGame?.socialTabBehaviour) {
|
||||||
|
const newMode: 'same' | 'new' = newGame.socialTabBehaviour === 'Same Window' ? 'same' : 'new';
|
||||||
|
if (newMode !== tabMode) {
|
||||||
|
tabManager.destroyAll();
|
||||||
|
tabMode = newMode;
|
||||||
|
tabManager = new TabManager(
|
||||||
|
win, ses, preloadPath, tabMode, isGameURL,
|
||||||
|
() => config.get('tabWindow'),
|
||||||
|
(state) => config.set('tabWindow', state),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
pendingConfigWrites.set(key, value);
|
pendingConfigWrites.set(key, value);
|
||||||
if (!configWriteTimer) {
|
if (!configWriteTimer) {
|
||||||
configWriteTimer = setTimeout(() => {
|
configWriteTimer = setTimeout(() => {
|
||||||
@@ -594,6 +663,40 @@ async function launchApp(): Promise<void> {
|
|||||||
else electronLog.log(...args);
|
else electronLog.log(...args);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── CPU throttle IPC handler ──
|
||||||
|
ipcMain.on('throttle-state', (_e, state: string) => {
|
||||||
|
const perf = config.get('performance');
|
||||||
|
const rate = state === 'game' ? (perf?.cpuThrottleGame ?? 1) : (perf?.cpuThrottleMenu ?? 1.5);
|
||||||
|
applyCpuThrottle(win.webContents, rate);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── CSS theme & loading background IPC handlers ──
|
||||||
|
ipcMain.handle('list-themes', () => listThemes(swapDir));
|
||||||
|
ipcMain.handle('get-theme-css', (_e, themeId: string) => getThemeCSS(themeId, swapDir));
|
||||||
|
ipcMain.handle('list-loading-themes', () => listLoadingThemes(swapDir));
|
||||||
|
ipcMain.handle('get-loading-screen-css', (_e, loadingTheme: string, backgroundUrl: string) => {
|
||||||
|
return getLoadingScreenCSS(loadingTheme, backgroundUrl, swapDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Changelog IPC handler (fetch release notes from Gitea) ──
|
||||||
|
ipcMain.handle('changelog-fetch', async (_e, version: string) => {
|
||||||
|
const tag = version.startsWith('v') ? version : `v${version}`;
|
||||||
|
try {
|
||||||
|
const data = await new Promise<string>((resolve, reject) => {
|
||||||
|
httpsGet(`https://api.github.com/repos/bigjakk/Krunker-Civilian-Client/releases/tags/${tag}`, { headers: { 'User-Agent': 'KCC' } }, (res) => {
|
||||||
|
let body = '';
|
||||||
|
res.on('data', (chunk: string) => { body += chunk; });
|
||||||
|
res.on('end', () => resolve(body));
|
||||||
|
res.on('error', reject);
|
||||||
|
}).on('error', reject);
|
||||||
|
});
|
||||||
|
const release = JSON.parse(data);
|
||||||
|
return release.body || '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── Userscript IPC handlers ──
|
// ── Userscript IPC handlers ──
|
||||||
ipcMain.handle('userscripts-get-dir', () => userscriptManager ? userscriptManager.dir : '');
|
ipcMain.handle('userscripts-get-dir', () => userscriptManager ? userscriptManager.dir : '');
|
||||||
ipcMain.handle('userscripts-open-folder', () => {
|
ipcMain.handle('userscripts-open-folder', () => {
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ export function applyPlatformFlags(info: PlatformInfo, advanced: AppConfig['adva
|
|||||||
|
|
||||||
// ── Always-on platform flags ──
|
// ── Always-on platform flags ──
|
||||||
app.commandLine.appendSwitch('disable-backgrounding-occluded-windows');
|
app.commandLine.appendSwitch('disable-backgrounding-occluded-windows');
|
||||||
|
app.commandLine.appendSwitch('disable-threaded-scrolling');
|
||||||
|
app.commandLine.appendSwitch('overscroll-history-navigation', '0');
|
||||||
|
app.commandLine.appendSwitch('pull-to-refresh', '0');
|
||||||
// WebGL is mandatory for Krunker — force it past any GPU blocklist.
|
// WebGL is mandatory for Krunker — force it past any GPU blocklist.
|
||||||
// On Chromium 134+ the blocklist is stricter and silently disables WebGL on many Linux GPUs.
|
// On Chromium 134+ the blocklist is stricter and silently disables WebGL on many Linux GPUs.
|
||||||
app.commandLine.appendSwitch('ignore-gpu-blocklist');
|
app.commandLine.appendSwitch('ignore-gpu-blocklist');
|
||||||
@@ -68,6 +71,8 @@ export function applyPlatformFlags(info: PlatformInfo, advanced: AppConfig['adva
|
|||||||
// ── Remove useless features ──
|
// ── Remove useless features ──
|
||||||
if (advanced.removeUselessFeatures) {
|
if (advanced.removeUselessFeatures) {
|
||||||
app.commandLine.appendSwitch('disable-breakpad');
|
app.commandLine.appendSwitch('disable-breakpad');
|
||||||
|
app.commandLine.appendSwitch('disable-crash-reporter');
|
||||||
|
app.commandLine.appendSwitch('disable-crashpad-forwarding');
|
||||||
app.commandLine.appendSwitch('disable-print-preview');
|
app.commandLine.appendSwitch('disable-print-preview');
|
||||||
app.commandLine.appendSwitch('disable-metrics-reporting');
|
app.commandLine.appendSwitch('disable-metrics-reporting');
|
||||||
app.commandLine.appendSwitch('disable-metrics');
|
app.commandLine.appendSwitch('disable-metrics');
|
||||||
@@ -75,6 +80,9 @@ export function applyPlatformFlags(info: PlatformInfo, advanced: AppConfig['adva
|
|||||||
app.commandLine.appendSwitch('disable-logging');
|
app.commandLine.appendSwitch('disable-logging');
|
||||||
app.commandLine.appendSwitch('disable-hang-monitor');
|
app.commandLine.appendSwitch('disable-hang-monitor');
|
||||||
app.commandLine.appendSwitch('disable-component-update');
|
app.commandLine.appendSwitch('disable-component-update');
|
||||||
|
app.commandLine.appendSwitch('disable-bundled-ppapi-flash');
|
||||||
|
app.commandLine.appendSwitch('disable-nacl');
|
||||||
|
app.commandLine.appendSwitch('disable-features', 'NativeNotifications,MediaRouter,PerformanceInterventionUI,HappinessTrackingSurveysForDesktopDemo');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── GPU rasterization ──
|
// ── GPU rasterization ──
|
||||||
@@ -82,6 +90,8 @@ export function applyPlatformFlags(info: PlatformInfo, advanced: AppConfig['adva
|
|||||||
if (advanced.gpuRasterizing) {
|
if (advanced.gpuRasterizing) {
|
||||||
app.commandLine.appendSwitch('enable-gpu-rasterization');
|
app.commandLine.appendSwitch('enable-gpu-rasterization');
|
||||||
app.commandLine.appendSwitch('disable-zero-copy');
|
app.commandLine.appendSwitch('disable-zero-copy');
|
||||||
|
app.commandLine.appendSwitch('disable-software-rasterizer');
|
||||||
|
app.commandLine.appendSwitch('disable-gpu-driver-bug-workarounds');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpful flags ──
|
// ── Helpful flags ──
|
||||||
@@ -91,7 +101,9 @@ export function applyPlatformFlags(info: PlatformInfo, advanced: AppConfig['adva
|
|||||||
app.commandLine.appendSwitch('enable-webgl');
|
app.commandLine.appendSwitch('enable-webgl');
|
||||||
app.commandLine.appendSwitch('disable-background-timer-throttling');
|
app.commandLine.appendSwitch('disable-background-timer-throttling');
|
||||||
app.commandLine.appendSwitch('disable-renderer-backgrounding');
|
app.commandLine.appendSwitch('disable-renderer-backgrounding');
|
||||||
|
app.commandLine.appendSwitch('disable-best-effort-tasks');
|
||||||
app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required');
|
app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required');
|
||||||
|
app.commandLine.appendSwitch('enable-features', 'V8VmFuture,WebAssemblyBaseline,WebAssemblyTiering,WebAssemblyLazyCompilation');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Disable accelerated 2D canvas ──
|
// ── Disable accelerated 2D canvas ──
|
||||||
@@ -112,6 +124,9 @@ export function applyPlatformFlags(info: PlatformInfo, advanced: AppConfig['adva
|
|||||||
// is default on Chromium 42+. These enable flags were removed from the source.
|
// is default on Chromium 42+. These enable flags were removed from the source.
|
||||||
if (advanced.lowLatency) {
|
if (advanced.lowLatency) {
|
||||||
app.commandLine.appendSwitch('force-high-performance-gpu');
|
app.commandLine.appendSwitch('force-high-performance-gpu');
|
||||||
|
app.commandLine.appendSwitch('enable-quic');
|
||||||
|
app.commandLine.appendSwitch('quic-max-packet-length', '1460');
|
||||||
|
app.commandLine.appendSwitch('raise-timer-frequency');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Experimental flags ──
|
// ── Experimental flags ──
|
||||||
@@ -120,9 +135,11 @@ export function applyPlatformFlags(info: PlatformInfo, advanced: AppConfig['adva
|
|||||||
// HiDPI is default since M108). Renamed ignore-gpu-blacklist → ignore-gpu-blocklist.
|
// HiDPI is default since M108). Renamed ignore-gpu-blacklist → ignore-gpu-blocklist.
|
||||||
if (advanced.experimentalFlags) {
|
if (advanced.experimentalFlags) {
|
||||||
app.commandLine.appendSwitch('disable-low-end-device-mode');
|
app.commandLine.appendSwitch('disable-low-end-device-mode');
|
||||||
|
app.commandLine.appendSwitch('disable-gpu-watchdog');
|
||||||
app.commandLine.appendSwitch('ignore-gpu-blocklist');
|
app.commandLine.appendSwitch('ignore-gpu-blocklist');
|
||||||
app.commandLine.appendSwitch('no-pings');
|
app.commandLine.appendSwitch('no-pings');
|
||||||
app.commandLine.appendSwitch('no-proxy-server');
|
app.commandLine.appendSwitch('no-proxy-server');
|
||||||
|
app.commandLine.appendSwitch('enable-features', 'BlinkCompositorUseDisplayThreadPriority,GpuUseDisplayThreadPriority');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
// ── Inline HTML for the tab bar WebContentsView ──
|
||||||
|
// Rendered as a data URL. Communicates with TabManager via ipcRenderer.
|
||||||
|
|
||||||
|
import { THEME_CSS } from './client-ui';
|
||||||
|
|
||||||
|
export const TAB_BAR_HTML = `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
${THEME_CSS}
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
background: var(--kpc-surface-dialog);
|
||||||
|
color: var(--kpc-text-primary);
|
||||||
|
height: 40px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 0 6px;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Shared pill style for Game btn, tabs, and New Tab btn ── */
|
||||||
|
.bar-pill {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid var(--kpc-toggle-off);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background 0.12s, border-color 0.12s;
|
||||||
|
background: var(--kpc-surface-card);
|
||||||
|
color: var(--kpc-text-secondary);
|
||||||
|
}
|
||||||
|
.bar-pill:hover {
|
||||||
|
background: var(--kpc-surface-input);
|
||||||
|
border-color: rgba(255,255,255,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Game button (green accent) ── */
|
||||||
|
#gameBtn {
|
||||||
|
background: rgba(76, 175, 80, 0.12);
|
||||||
|
color: var(--kpc-green);
|
||||||
|
border-color: rgba(76, 175, 80, 0.5);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
#gameBtn:hover {
|
||||||
|
background: rgba(76, 175, 80, 0.25);
|
||||||
|
border-color: var(--kpc-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tab strip ── */
|
||||||
|
#tabStrip {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
padding: 4px 0;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
#tabStrip::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
/* ── Tab pills ── */
|
||||||
|
.tab {
|
||||||
|
position: relative;
|
||||||
|
gap: 6px;
|
||||||
|
max-width: 200px;
|
||||||
|
min-width: 60px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
.tab.dragging {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
.tab.drop-before::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: -3px;
|
||||||
|
top: 2px;
|
||||||
|
bottom: 2px;
|
||||||
|
width: 2px;
|
||||||
|
background: var(--kpc-green);
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
.tab.drop-after::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
right: -3px;
|
||||||
|
top: 2px;
|
||||||
|
bottom: 2px;
|
||||||
|
width: 2px;
|
||||||
|
background: var(--kpc-green);
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
.tab.active {
|
||||||
|
background: rgba(76, 175, 80, 0.12);
|
||||||
|
border-color: rgba(76, 175, 80, 0.5);
|
||||||
|
color: var(--kpc-text-primary);
|
||||||
|
}
|
||||||
|
.tab.active:hover {
|
||||||
|
background: rgba(76, 175, 80, 0.2);
|
||||||
|
border-color: var(--kpc-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-title {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-spinner {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border: 1.5px solid var(--kpc-border-medium);
|
||||||
|
border-top-color: var(--kpc-green);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.tab.loading .tab-spinner { display: block; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.tab-close {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
line-height: 15px;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--kpc-text-dim);
|
||||||
|
transition: background 0.1s, color 0.1s;
|
||||||
|
}
|
||||||
|
.tab-close:hover {
|
||||||
|
background: var(--kpc-toggle-off);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── New Tab button ── */
|
||||||
|
#newTabBtn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--kpc-text-faint);
|
||||||
|
padding: 0;
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
#newTabBtn:hover {
|
||||||
|
color: var(--kpc-text-primary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<button id="gameBtn" class="bar-pill">Game</button>
|
||||||
|
<div id="tabStrip"></div>
|
||||||
|
<button id="newTabBtn" class="bar-pill" title="New Tab (Ctrl+T)">+</button>
|
||||||
|
<script>
|
||||||
|
const { ipcRenderer } = require('electron');
|
||||||
|
const strip = document.getElementById('tabStrip');
|
||||||
|
|
||||||
|
document.getElementById('gameBtn').addEventListener('click', () => {
|
||||||
|
ipcRenderer.send('tab-back-to-game');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('newTabBtn').addEventListener('click', () => {
|
||||||
|
ipcRenderer.send('tab-new');
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── Drag state ── */
|
||||||
|
let dragId = null;
|
||||||
|
let dragStartX = 0;
|
||||||
|
let dragging = false;
|
||||||
|
const DRAG_THRESHOLD = 5;
|
||||||
|
|
||||||
|
function clearDropIndicators() {
|
||||||
|
strip.querySelectorAll('.drop-before,.drop-after').forEach(
|
||||||
|
el => el.classList.remove('drop-before', 'drop-after')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDropTarget(clientX) {
|
||||||
|
const tabs = Array.from(strip.querySelectorAll('.tab'));
|
||||||
|
for (const tab of tabs) {
|
||||||
|
if (Number(tab.dataset.id) === dragId) continue;
|
||||||
|
const r = tab.getBoundingClientRect();
|
||||||
|
const mid = r.left + r.width / 2;
|
||||||
|
if (clientX < mid) return { id: Number(tab.dataset.id), side: 'before', el: tab };
|
||||||
|
}
|
||||||
|
const last = tabs[tabs.length - 1];
|
||||||
|
if (last && Number(last.dataset.id) !== dragId) {
|
||||||
|
return { id: Number(last.dataset.id), side: 'after', el: last };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', (e) => {
|
||||||
|
if (dragId === null) return;
|
||||||
|
if (!dragging && Math.abs(e.clientX - dragStartX) >= DRAG_THRESHOLD) {
|
||||||
|
dragging = true;
|
||||||
|
const el = strip.querySelector('.tab[data-id="' + dragId + '"]');
|
||||||
|
if (el) el.classList.add('dragging');
|
||||||
|
}
|
||||||
|
if (!dragging) return;
|
||||||
|
clearDropIndicators();
|
||||||
|
const target = getDropTarget(e.clientX);
|
||||||
|
if (target) target.el.classList.add(target.side === 'before' ? 'drop-before' : 'drop-after');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('mouseup', (e) => {
|
||||||
|
if (dragId === null) return;
|
||||||
|
const wasDragging = dragging;
|
||||||
|
const srcId = dragId;
|
||||||
|
clearDropIndicators();
|
||||||
|
const dragEl = strip.querySelector('.tab.dragging');
|
||||||
|
if (dragEl) dragEl.classList.remove('dragging');
|
||||||
|
dragId = null;
|
||||||
|
dragging = false;
|
||||||
|
|
||||||
|
if (wasDragging) {
|
||||||
|
const target = getDropTarget(e.clientX);
|
||||||
|
if (target) {
|
||||||
|
ipcRenderer.send('tab-reorder', srcId, target.id, target.side);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcRenderer.on('tabs-update', (_e, tabs) => {
|
||||||
|
strip.innerHTML = '';
|
||||||
|
for (const t of tabs) {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'bar-pill tab' + (t.active ? ' active' : '') + (t.loading ? ' loading' : '');
|
||||||
|
el.dataset.id = String(t.id);
|
||||||
|
|
||||||
|
const spinner = document.createElement('div');
|
||||||
|
spinner.className = 'tab-spinner';
|
||||||
|
el.appendChild(spinner);
|
||||||
|
|
||||||
|
const title = document.createElement('span');
|
||||||
|
title.className = 'tab-title';
|
||||||
|
title.textContent = t.title || 'Loading...';
|
||||||
|
title.title = t.title || '';
|
||||||
|
el.appendChild(title);
|
||||||
|
|
||||||
|
const close = document.createElement('span');
|
||||||
|
close.className = 'tab-close';
|
||||||
|
close.textContent = '\\u00d7';
|
||||||
|
close.addEventListener('click', (ev) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
ipcRenderer.send('tab-close', t.id);
|
||||||
|
});
|
||||||
|
el.appendChild(close);
|
||||||
|
|
||||||
|
el.addEventListener('mousedown', (ev) => {
|
||||||
|
if (ev.target.classList.contains('tab-close')) return;
|
||||||
|
dragId = t.id;
|
||||||
|
dragStartX = ev.clientX;
|
||||||
|
dragging = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
el.addEventListener('click', () => {
|
||||||
|
if (!dragging) ipcRenderer.send('tab-switch', t.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
strip.appendChild(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeEl = strip.querySelector('.tab.active');
|
||||||
|
if (activeEl) activeEl.scrollIntoView({ inline: 'nearest', block: 'nearest' });
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
export const TAB_BAR_DATA_URL = 'data:text/html;charset=utf-8,' + encodeURIComponent(TAB_BAR_HTML);
|
||||||
@@ -0,0 +1,666 @@
|
|||||||
|
import { BrowserWindow, WebContentsView, View, Menu, clipboard, ipcMain, shell } from 'electron';
|
||||||
|
import { TAB_BAR_DATA_URL } from './tab-bar-html';
|
||||||
|
import { ALL_CLIENT_CSS } from './client-ui';
|
||||||
|
import { electronLog } from './logger';
|
||||||
|
|
||||||
|
const KRUNKER_SOCIAL = 'https://krunker.io/social.html';
|
||||||
|
const TAB_BAR_HEIGHT = 40;
|
||||||
|
const MAX_TABS = 20;
|
||||||
|
|
||||||
|
interface TabInfo {
|
||||||
|
id: number;
|
||||||
|
view: WebContentsView;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TabWindowState {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
x: number | undefined;
|
||||||
|
y: number | undefined;
|
||||||
|
maximized: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TabMode = 'same' | 'new';
|
||||||
|
|
||||||
|
export class TabManager {
|
||||||
|
private tabs: TabInfo[] = [];
|
||||||
|
private activeTabId: number | null = null;
|
||||||
|
private tabBarView: WebContentsView;
|
||||||
|
private containerView: View;
|
||||||
|
private tabWindow: BrowserWindow | null = null;
|
||||||
|
private visible = false;
|
||||||
|
private nextId = 1;
|
||||||
|
private mode: TabMode;
|
||||||
|
private mainWin: BrowserWindow;
|
||||||
|
private ses: Electron.Session;
|
||||||
|
private preloadPath: string;
|
||||||
|
private isGameURL: (url: string) => boolean;
|
||||||
|
private titlePolls = new Map<number, ReturnType<typeof setInterval>>();
|
||||||
|
private recentlyClosed: { url: string; title: string }[] = [];
|
||||||
|
private getTabWindowState: () => TabWindowState;
|
||||||
|
private saveTabWindowState: (state: TabWindowState) => void;
|
||||||
|
private tabSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
win: BrowserWindow,
|
||||||
|
ses: Electron.Session,
|
||||||
|
preloadPath: string,
|
||||||
|
mode: TabMode,
|
||||||
|
isGameURL: (url: string) => boolean,
|
||||||
|
getTabWindowState: () => TabWindowState,
|
||||||
|
saveTabWindowState: (state: TabWindowState) => void,
|
||||||
|
) {
|
||||||
|
this.mainWin = win;
|
||||||
|
this.ses = ses;
|
||||||
|
this.preloadPath = preloadPath;
|
||||||
|
this.mode = mode;
|
||||||
|
this.isGameURL = isGameURL;
|
||||||
|
this.getTabWindowState = getTabWindowState;
|
||||||
|
this.saveTabWindowState = saveTabWindowState;
|
||||||
|
|
||||||
|
// ── Tab bar view (shared between both modes) ──
|
||||||
|
this.tabBarView = new WebContentsView({
|
||||||
|
webPreferences: {
|
||||||
|
nodeIntegration: true,
|
||||||
|
contextIsolation: false,
|
||||||
|
sandbox: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.tabBarView.webContents.loadURL(TAB_BAR_DATA_URL);
|
||||||
|
|
||||||
|
// ── Container view (holds tab bar + active tab content) ──
|
||||||
|
this.containerView = new View();
|
||||||
|
this.containerView.addChildView(this.tabBarView);
|
||||||
|
|
||||||
|
// Tab bar keybinds (when tab bar itself is focused)
|
||||||
|
this.tabBarView.webContents.on('before-input-event', (event, input) => {
|
||||||
|
if (input.type !== 'keyDown') return;
|
||||||
|
if (this.handleTabShortcut(event, input)) return;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mode === 'same') {
|
||||||
|
this.initSameWindowMode();
|
||||||
|
}
|
||||||
|
// 'new' mode: tabWindow created lazily on first openTab()
|
||||||
|
|
||||||
|
this.registerIPC();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Same Window Mode Setup ──
|
||||||
|
private initSameWindowMode(): void {
|
||||||
|
this.mainWin.contentView.addChildView(this.containerView);
|
||||||
|
this.containerView.setVisible(false);
|
||||||
|
this.visible = false;
|
||||||
|
this.mainWin.on('resize', () => this.updateLayout());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── New Window Mode: create/show the tab window ──
|
||||||
|
private ensureTabWindow(): void {
|
||||||
|
if (this.tabWindow && !this.tabWindow.isDestroyed()) return;
|
||||||
|
|
||||||
|
const saved = this.getTabWindowState();
|
||||||
|
|
||||||
|
this.tabWindow = new BrowserWindow({
|
||||||
|
width: saved.width,
|
||||||
|
height: saved.height,
|
||||||
|
x: saved.x,
|
||||||
|
y: saved.y,
|
||||||
|
frame: true,
|
||||||
|
backgroundColor: '#000000',
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
title: 'KCC - Tabs',
|
||||||
|
show: false,
|
||||||
|
webPreferences: {
|
||||||
|
nodeIntegration: false,
|
||||||
|
contextIsolation: true,
|
||||||
|
sandbox: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.tabWindow.removeMenu();
|
||||||
|
|
||||||
|
if (saved.maximized) this.tabWindow.maximize();
|
||||||
|
|
||||||
|
this.tabWindow.contentView.addChildView(this.containerView);
|
||||||
|
this.containerView.setVisible(true);
|
||||||
|
|
||||||
|
this.tabWindow.on('resize', () => {
|
||||||
|
this.updateLayout();
|
||||||
|
this.debounceSaveTabWindow();
|
||||||
|
});
|
||||||
|
this.tabWindow.on('move', () => this.debounceSaveTabWindow());
|
||||||
|
this.tabWindow.on('close', () => {
|
||||||
|
// Flush pending save before the window is destroyed
|
||||||
|
if (this.tabSaveTimer) clearTimeout(this.tabSaveTimer);
|
||||||
|
if (this.tabWindow && !this.tabWindow.isDestroyed()) {
|
||||||
|
const bounds = this.tabWindow.getBounds();
|
||||||
|
this.saveTabWindowState({
|
||||||
|
width: bounds.width,
|
||||||
|
height: bounds.height,
|
||||||
|
x: bounds.x,
|
||||||
|
y: bounds.y,
|
||||||
|
maximized: this.tabWindow.isMaximized(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.tabWindow.on('closed', () => {
|
||||||
|
this.destroyAllTabs();
|
||||||
|
this.tabWindow = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.tabWindow.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
private debounceSaveTabWindow(): void {
|
||||||
|
if (this.tabSaveTimer) clearTimeout(this.tabSaveTimer);
|
||||||
|
this.tabSaveTimer = setTimeout(() => {
|
||||||
|
if (!this.tabWindow || this.tabWindow.isDestroyed()) return;
|
||||||
|
const bounds = this.tabWindow.getBounds();
|
||||||
|
this.saveTabWindowState({
|
||||||
|
width: bounds.width,
|
||||||
|
height: bounds.height,
|
||||||
|
x: bounds.x,
|
||||||
|
y: bounds.y,
|
||||||
|
maximized: this.tabWindow.isMaximized(),
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── IPC from tab bar ──
|
||||||
|
private registerIPC(): void {
|
||||||
|
ipcMain.on('tab-switch', (_e, id: number) => this.switchToTab(id));
|
||||||
|
ipcMain.on('tab-close', (_e, id: number) => this.closeTab(id));
|
||||||
|
ipcMain.on('tab-new', () => this.openTab(KRUNKER_SOCIAL));
|
||||||
|
ipcMain.on('tab-reorder', (_e, fromId: number, toId: number, side: string) => {
|
||||||
|
this.reorderTab(fromId, toId, side as 'before' | 'after');
|
||||||
|
});
|
||||||
|
ipcMain.on('tab-back-to-game', () => {
|
||||||
|
if (this.mode === 'same') {
|
||||||
|
this.hideTabs();
|
||||||
|
} else {
|
||||||
|
this.mainWin.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Open a new tab ──
|
||||||
|
openTab(url: string): number {
|
||||||
|
if (this.tabs.length >= MAX_TABS) {
|
||||||
|
const existing = this.tabs.find(t => t.url === url);
|
||||||
|
if (existing) {
|
||||||
|
this.switchToTab(existing.id);
|
||||||
|
return existing.id;
|
||||||
|
}
|
||||||
|
electronLog.warn('[KCC-Tabs] Tab limit reached, ignoring openTab');
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = this.nextId++;
|
||||||
|
const view = this.createTabView(id);
|
||||||
|
const tab: TabInfo = { id, view, title: this.titleFromUrl(url), url, loading: true };
|
||||||
|
this.tabs.push(tab);
|
||||||
|
|
||||||
|
if (this.mode === 'new') {
|
||||||
|
this.ensureTabWindow();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.switchToTab(id);
|
||||||
|
this.showTabs();
|
||||||
|
view.webContents.loadURL(url);
|
||||||
|
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create a WebContentsView for a tab ──
|
||||||
|
private createTabView(tabId: number): WebContentsView {
|
||||||
|
const view = new WebContentsView({
|
||||||
|
webPreferences: {
|
||||||
|
preload: this.preloadPath,
|
||||||
|
session: this.ses,
|
||||||
|
contextIsolation: false,
|
||||||
|
nodeIntegration: false,
|
||||||
|
sandbox: false,
|
||||||
|
spellcheck: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const wc = view.webContents;
|
||||||
|
|
||||||
|
wc.on('did-finish-load', () => {
|
||||||
|
wc.insertCSS(ALL_CLIENT_CSS).catch(() => {});
|
||||||
|
wc.send('main_did-finish-load-tab');
|
||||||
|
ipcMain.emit('throttle-state', { sender: wc } as any, 'menu');
|
||||||
|
this.updateTabInfo(tabId, { loading: false });
|
||||||
|
this.startTitleWatcher(tabId, wc);
|
||||||
|
});
|
||||||
|
|
||||||
|
wc.on('did-start-loading', () => {
|
||||||
|
this.updateTabInfo(tabId, { loading: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
wc.on('did-stop-loading', () => {
|
||||||
|
this.updateTabInfo(tabId, { loading: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
wc.on('page-title-updated', (_e, title) => {
|
||||||
|
if (this.isGenericTitle(title)) return;
|
||||||
|
this.updateTabInfo(tabId, { title });
|
||||||
|
});
|
||||||
|
|
||||||
|
wc.on('did-navigate', (_e, url) => {
|
||||||
|
this.updateTabInfo(tabId, { url, title: this.titleFromUrl(url) });
|
||||||
|
});
|
||||||
|
|
||||||
|
wc.setWindowOpenHandler(({ url: linkUrl }) => {
|
||||||
|
if (linkUrl.includes('krunker.io')) {
|
||||||
|
if (this.isGameURL(linkUrl)) {
|
||||||
|
this.mainWin.loadURL(linkUrl);
|
||||||
|
if (this.mode === 'same') this.hideTabs();
|
||||||
|
else this.mainWin.focus();
|
||||||
|
} else {
|
||||||
|
setImmediate(() => this.openTab(linkUrl));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setImmediate(() => shell.openExternal(linkUrl));
|
||||||
|
}
|
||||||
|
return { action: 'deny' as const };
|
||||||
|
});
|
||||||
|
|
||||||
|
wc.on('will-navigate', (event, navUrl) => {
|
||||||
|
if (navUrl.includes('krunker.io') && this.isGameURL(navUrl)) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.mainWin.loadURL(navUrl);
|
||||||
|
if (this.mode === 'same') this.hideTabs();
|
||||||
|
else this.mainWin.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
wc.on('context-menu', (_e, params) => {
|
||||||
|
if (!params.linkURL) return;
|
||||||
|
const items: Electron.MenuItemConstructorOptions[] = [];
|
||||||
|
if (params.linkURL.includes('krunker.io') && !this.isGameURL(params.linkURL)) {
|
||||||
|
items.push({ label: 'Open in New Tab', click: () => this.openTab(params.linkURL) });
|
||||||
|
}
|
||||||
|
items.push({ label: 'Copy Link', click: () => clipboard.writeText(params.linkURL) });
|
||||||
|
if (!params.linkURL.includes('krunker.io')) {
|
||||||
|
items.push({ label: 'Open in Browser', click: () => shell.openExternal(params.linkURL) });
|
||||||
|
}
|
||||||
|
if (items.length) Menu.buildFromTemplate(items).popup();
|
||||||
|
});
|
||||||
|
|
||||||
|
wc.on('before-input-event', (event, input) => {
|
||||||
|
if (input.type !== 'keyDown') return;
|
||||||
|
if (this.handleTabShortcut(event, input)) return;
|
||||||
|
if (input.key === 'F12' && !input.control && !input.shift && !input.alt) {
|
||||||
|
wc.toggleDevTools();
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Switch active tab ──
|
||||||
|
switchToTab(id: number): void {
|
||||||
|
const tab = this.tabs.find(t => t.id === id);
|
||||||
|
if (!tab) return;
|
||||||
|
|
||||||
|
if (this.activeTabId !== null) {
|
||||||
|
const prev = this.tabs.find(t => t.id === this.activeTabId);
|
||||||
|
if (prev) {
|
||||||
|
this.containerView.removeChildView(prev.view);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.activeTabId = id;
|
||||||
|
this.containerView.addChildView(tab.view);
|
||||||
|
this.updateLayout();
|
||||||
|
this.broadcastTabState();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Close a tab ──
|
||||||
|
closeTab(id: number): void {
|
||||||
|
const idx = this.tabs.findIndex(t => t.id === id);
|
||||||
|
if (idx === -1) return;
|
||||||
|
|
||||||
|
const tab = this.tabs[idx];
|
||||||
|
|
||||||
|
if (this.activeTabId === id) {
|
||||||
|
this.containerView.removeChildView(tab.view);
|
||||||
|
this.activeTabId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.recentlyClosed.push({ url: tab.url, title: tab.title });
|
||||||
|
if (this.recentlyClosed.length > 10) this.recentlyClosed.shift();
|
||||||
|
|
||||||
|
this.stopTitleWatcher(id);
|
||||||
|
tab.view.webContents.close();
|
||||||
|
this.tabs.splice(idx, 1);
|
||||||
|
|
||||||
|
if (this.tabs.length > 0) {
|
||||||
|
const nextIdx = Math.min(idx, this.tabs.length - 1);
|
||||||
|
this.switchToTab(this.tabs[nextIdx].id);
|
||||||
|
} else {
|
||||||
|
if (this.mode === 'same') {
|
||||||
|
this.hideTabs();
|
||||||
|
} else {
|
||||||
|
if (this.tabWindow && !this.tabWindow.isDestroyed()) {
|
||||||
|
this.tabWindow.contentView.removeChildView(this.containerView);
|
||||||
|
this.tabWindow.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.broadcastTabState();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Show / hide tabs ──
|
||||||
|
showTabs(): void {
|
||||||
|
if (this.mode === 'same') {
|
||||||
|
this.containerView.setVisible(true);
|
||||||
|
this.visible = true;
|
||||||
|
this.updateLayout();
|
||||||
|
} else {
|
||||||
|
this.ensureTabWindow();
|
||||||
|
if (this.tabWindow && !this.tabWindow.isDestroyed()) {
|
||||||
|
this.tabWindow.show();
|
||||||
|
this.tabWindow.focus();
|
||||||
|
}
|
||||||
|
this.visible = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hideTabs(): void {
|
||||||
|
if (this.mode === 'same') {
|
||||||
|
this.containerView.setVisible(false);
|
||||||
|
this.visible = false;
|
||||||
|
this.mainWin.focus();
|
||||||
|
} else {
|
||||||
|
this.mainWin.focus();
|
||||||
|
this.visible = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tab navigation ──
|
||||||
|
nextTab(): void {
|
||||||
|
if (this.tabs.length < 2 || this.activeTabId === null) return;
|
||||||
|
const idx = this.tabs.findIndex(t => t.id === this.activeTabId);
|
||||||
|
const next = (idx + 1) % this.tabs.length;
|
||||||
|
this.switchToTab(this.tabs[next].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
prevTab(): void {
|
||||||
|
if (this.tabs.length < 2 || this.activeTabId === null) return;
|
||||||
|
const idx = this.tabs.findIndex(t => t.id === this.activeTabId);
|
||||||
|
const prev = (idx - 1 + this.tabs.length) % this.tabs.length;
|
||||||
|
this.switchToTab(this.tabs[prev].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
closeCurrentTab(): void {
|
||||||
|
if (this.activeTabId !== null) this.closeTab(this.activeTabId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reorder tabs via drag ──
|
||||||
|
reorderTab(fromId: number, toId: number, side: 'before' | 'after'): void {
|
||||||
|
const fromIdx = this.tabs.findIndex(t => t.id === fromId);
|
||||||
|
const toIdx = this.tabs.findIndex(t => t.id === toId);
|
||||||
|
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return;
|
||||||
|
|
||||||
|
const [tab] = this.tabs.splice(fromIdx, 1);
|
||||||
|
let insertIdx = this.tabs.findIndex(t => t.id === toId);
|
||||||
|
if (side === 'after') insertIdx++;
|
||||||
|
this.tabs.splice(insertIdx, 0, tab);
|
||||||
|
this.broadcastTabState();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Jump to tab by position (0-based, -1 = last) ──
|
||||||
|
switchToTabByIndex(index: number): void {
|
||||||
|
if (this.tabs.length === 0) return;
|
||||||
|
if (index < 0 || index >= this.tabs.length) index = this.tabs.length - 1;
|
||||||
|
this.switchToTab(this.tabs[index].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reopen last closed tab ──
|
||||||
|
reopenTab(): void {
|
||||||
|
const entry = this.recentlyClosed.pop();
|
||||||
|
if (entry) this.openTab(entry.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared shortcut handler (returns true if handled) ──
|
||||||
|
private handleTabShortcut(event: Electron.Event, input: Electron.Input): boolean {
|
||||||
|
if (input.key === 'Escape' && !input.control && !input.shift && !input.alt) {
|
||||||
|
if (this.mode === 'same') this.hideTabs();
|
||||||
|
else this.mainWin.focus();
|
||||||
|
event.preventDefault();
|
||||||
|
return true;
|
||||||
|
} else if (input.key === 'w' && input.control && !input.shift && !input.alt) {
|
||||||
|
this.closeCurrentTab();
|
||||||
|
event.preventDefault();
|
||||||
|
return true;
|
||||||
|
} else if (input.key === 'Tab' && input.control && !input.shift && !input.alt) {
|
||||||
|
this.nextTab();
|
||||||
|
event.preventDefault();
|
||||||
|
return true;
|
||||||
|
} else if (input.key === 'Tab' && input.control && input.shift && !input.alt) {
|
||||||
|
this.prevTab();
|
||||||
|
event.preventDefault();
|
||||||
|
return true;
|
||||||
|
} else if (input.key === 't' && input.control && !input.shift && !input.alt) {
|
||||||
|
this.openTab(KRUNKER_SOCIAL);
|
||||||
|
event.preventDefault();
|
||||||
|
return true;
|
||||||
|
} else if (input.key === 'T' && input.control && input.shift && !input.alt) {
|
||||||
|
this.reopenTab();
|
||||||
|
event.preventDefault();
|
||||||
|
return true;
|
||||||
|
} else if (input.key >= '1' && input.key <= '8' && input.control && !input.shift && !input.alt) {
|
||||||
|
this.switchToTabByIndex(parseInt(input.key) - 1);
|
||||||
|
event.preventDefault();
|
||||||
|
return true;
|
||||||
|
} else if (input.key === '9' && input.control && !input.shift && !input.alt) {
|
||||||
|
this.switchToTabByIndex(-1);
|
||||||
|
event.preventDefault();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cleanup ──
|
||||||
|
destroyAll(): void {
|
||||||
|
this.destroyAllTabs();
|
||||||
|
|
||||||
|
ipcMain.removeAllListeners('tab-switch');
|
||||||
|
ipcMain.removeAllListeners('tab-close');
|
||||||
|
ipcMain.removeAllListeners('tab-new');
|
||||||
|
ipcMain.removeAllListeners('tab-reorder');
|
||||||
|
ipcMain.removeAllListeners('tab-back-to-game');
|
||||||
|
|
||||||
|
if (this.tabWindow && !this.tabWindow.isDestroyed()) {
|
||||||
|
this.tabWindow.contentView.removeChildView(this.containerView);
|
||||||
|
this.tabWindow.close();
|
||||||
|
this.tabWindow = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.mode === 'same') {
|
||||||
|
try { this.mainWin.contentView.removeChildView(this.containerView); } catch { /* may already be removed */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private destroyAllTabs(): void {
|
||||||
|
for (const tab of this.tabs) {
|
||||||
|
this.stopTitleWatcher(tab.id);
|
||||||
|
if (this.activeTabId === tab.id) {
|
||||||
|
this.containerView.removeChildView(tab.view);
|
||||||
|
}
|
||||||
|
if (!tab.view.webContents.isDestroyed()) {
|
||||||
|
tab.view.webContents.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.tabs = [];
|
||||||
|
this.activeTabId = null;
|
||||||
|
this.broadcastTabState();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Layout ──
|
||||||
|
private updateLayout(): void {
|
||||||
|
let bounds: { width: number; height: number };
|
||||||
|
|
||||||
|
if (this.mode === 'same') {
|
||||||
|
const [w, h] = this.mainWin.getContentSize();
|
||||||
|
bounds = { width: w, height: h };
|
||||||
|
this.containerView.setBounds({ x: 0, y: 0, width: w, height: h });
|
||||||
|
} else if (this.tabWindow && !this.tabWindow.isDestroyed()) {
|
||||||
|
const [w, h] = this.tabWindow.getContentSize();
|
||||||
|
bounds = { width: w, height: h };
|
||||||
|
this.containerView.setBounds({ x: 0, y: 0, width: w, height: h });
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.tabBarView.setBounds({
|
||||||
|
x: 0, y: 0,
|
||||||
|
width: bounds.width,
|
||||||
|
height: TAB_BAR_HEIGHT,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.activeTabId !== null) {
|
||||||
|
const tab = this.tabs.find(t => t.id === this.activeTabId);
|
||||||
|
if (tab) {
|
||||||
|
tab.view.setBounds({
|
||||||
|
x: 0,
|
||||||
|
y: TAB_BAR_HEIGHT,
|
||||||
|
width: bounds.width,
|
||||||
|
height: bounds.height - TAB_BAR_HEIGHT,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Update tab metadata and broadcast ──
|
||||||
|
private updateTabInfo(id: number, updates: Partial<Pick<TabInfo, 'title' | 'url' | 'loading'>>): void {
|
||||||
|
const tab = this.tabs.find(t => t.id === id);
|
||||||
|
if (!tab) return;
|
||||||
|
if (updates.title !== undefined) tab.title = updates.title;
|
||||||
|
if (updates.url !== undefined) tab.url = updates.url;
|
||||||
|
if (updates.loading !== undefined) tab.loading = updates.loading;
|
||||||
|
this.broadcastTabState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private broadcastTabState(): void {
|
||||||
|
if (this.tabBarView.webContents.isDestroyed()) return;
|
||||||
|
const data = this.tabs.map(t => ({
|
||||||
|
id: t.id,
|
||||||
|
title: t.title,
|
||||||
|
active: t.id === this.activeTabId,
|
||||||
|
loading: t.loading,
|
||||||
|
}));
|
||||||
|
this.tabBarView.webContents.send('tabs-update', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly GENERIC_TITLES = new Set([
|
||||||
|
'krunker hub', 'krunker', 'krunker.io', '',
|
||||||
|
'hub', 'social', 'profile', 'new tab', 'loading...',
|
||||||
|
]);
|
||||||
|
|
||||||
|
private isGenericTitle(title: string): boolean {
|
||||||
|
return TabManager.GENERIC_TITLES.has(title.toLowerCase().trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Persistent URL watcher + DOM title extraction ──
|
||||||
|
private startTitleWatcher(tabId: number, wc: Electron.WebContents): void {
|
||||||
|
const existing = this.titlePolls.get(tabId);
|
||||||
|
if (existing) clearInterval(existing);
|
||||||
|
|
||||||
|
let lastUrl = '';
|
||||||
|
let lastDom = '';
|
||||||
|
const poll = setInterval(() => {
|
||||||
|
if (wc.isDestroyed()) {
|
||||||
|
clearInterval(poll);
|
||||||
|
this.titlePolls.delete(tabId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wc.executeJavaScript(
|
||||||
|
`(function() {
|
||||||
|
var url = window.location.href;
|
||||||
|
var title = '';
|
||||||
|
var ph = document.getElementById('profileHolder');
|
||||||
|
if (ph && ph.style.display === 'block') {
|
||||||
|
var ns = document.getElementById('nameSwitch');
|
||||||
|
if (ns && ns.innerText) title = ns.innerText;
|
||||||
|
}
|
||||||
|
return JSON.stringify({ url: url, dom: title });
|
||||||
|
})()`
|
||||||
|
).then((json: string) => {
|
||||||
|
const { url, dom } = JSON.parse(json);
|
||||||
|
if (url === lastUrl && dom === lastDom) return;
|
||||||
|
lastUrl = url;
|
||||||
|
lastDom = dom;
|
||||||
|
|
||||||
|
const tab = this.tabs.find(t => t.id === tabId);
|
||||||
|
if (!tab) return;
|
||||||
|
|
||||||
|
if (dom) {
|
||||||
|
if (tab.title !== dom) {
|
||||||
|
this.updateTabInfo(tabId, { url, title: dom });
|
||||||
|
} else if (tab.url !== url) {
|
||||||
|
this.updateTabInfo(tabId, { url });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tab.url !== url) {
|
||||||
|
this.updateTabInfo(tabId, { url, title: this.titleFromUrl(url) });
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
}, 1000);
|
||||||
|
this.titlePolls.set(tabId, poll);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopTitleWatcher(tabId: number): void {
|
||||||
|
const poll = this.titlePolls.get(tabId);
|
||||||
|
if (poll) {
|
||||||
|
clearInterval(poll);
|
||||||
|
this.titlePolls.delete(tabId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Extract a display title from URL ──
|
||||||
|
private titleFromUrl(url: string): string {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const p = parsed.searchParams.get('p');
|
||||||
|
const q = parsed.searchParams.get('q');
|
||||||
|
|
||||||
|
if (q) return q;
|
||||||
|
|
||||||
|
if (p) {
|
||||||
|
const pageMap: Record<string, string> = {
|
||||||
|
profile: 'Profile',
|
||||||
|
leaders: 'Leaderboard',
|
||||||
|
games: 'Games',
|
||||||
|
clans: 'Clans',
|
||||||
|
skins: 'Skins',
|
||||||
|
mods: 'Mods',
|
||||||
|
maps: 'Maps',
|
||||||
|
editor: 'Editor',
|
||||||
|
market: 'Market',
|
||||||
|
itemsales: 'Market Item',
|
||||||
|
inventory: 'Inventory',
|
||||||
|
settings: 'Settings',
|
||||||
|
feed: 'Hub',
|
||||||
|
};
|
||||||
|
return pageMap[p] || p.charAt(0).toUpperCase() + p.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = parsed.pathname.replace(/\.html$/, '').replace(/^\//, '');
|
||||||
|
if (path === 'social') return 'Hub';
|
||||||
|
if (path) return path.charAt(0).toUpperCase() + path.slice(1);
|
||||||
|
|
||||||
|
return 'New Tab';
|
||||||
|
} catch {
|
||||||
|
return 'New Tab';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-4
@@ -13,11 +13,9 @@ export interface UpdateInfo {
|
|||||||
export type ProgressCallback = (percent: number) => void;
|
export type ProgressCallback = (percent: number) => void;
|
||||||
|
|
||||||
const UPDATE_CONFIG = {
|
const UPDATE_CONFIG = {
|
||||||
// Gitea provider (swap these for kpdclient.com migration)
|
checkUrl: 'https://api.github.com/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,
|
||||||
// Allowed hosts for update check and download (including redirects)
|
allowedHosts: ['github.com', 'githubusercontent.com'],
|
||||||
allowedHosts: ['gitea.crjlab.net'],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const CHECK_TIMEOUT_MS = 10000;
|
const CHECK_TIMEOUT_MS = 10000;
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// ── Changelog Popup ──
|
||||||
|
// Shows release notes in a Shadow DOM modal when the client version changes.
|
||||||
|
|
||||||
|
import { ipcRenderer } from 'electron';
|
||||||
|
|
||||||
|
function versionLessThan(a: string, b: string): boolean {
|
||||||
|
const pa = a.split('.').map(Number);
|
||||||
|
const pb = b.split('.').map(Number);
|
||||||
|
const len = Math.max(pa.length, pb.length);
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
const na = pa[i] || 0;
|
||||||
|
const nb = pb[i] || 0;
|
||||||
|
if (na < nb) return true;
|
||||||
|
if (na > nb) return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMarkdown(md: string): string {
|
||||||
|
const html = md
|
||||||
|
.replace(/### (.+)/g, '<h3>$1</h3>')
|
||||||
|
.replace(/## (.+)/g, '<h2>$1</h2>')
|
||||||
|
.replace(/# (.+)/g, '<h1>$1</h1>')
|
||||||
|
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||||
|
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||||
|
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>');
|
||||||
|
|
||||||
|
// Convert list items
|
||||||
|
const lines = html.split('\n');
|
||||||
|
let inList = false;
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.trimStart().startsWith('- ')) {
|
||||||
|
if (!inList) { out.push('<ul>'); inList = true; }
|
||||||
|
out.push('<li>' + line.trimStart().slice(2) + '</li>');
|
||||||
|
} else {
|
||||||
|
if (inList) { out.push('</ul>'); inList = false; }
|
||||||
|
out.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (inList) out.push('</ul>');
|
||||||
|
|
||||||
|
return out.join('\n').replace(/\n\n/g, '<br><br>').replace(/\n/g, '<br>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showChangelogPopup(version: string, body: string): void {
|
||||||
|
const host = document.createElement('div');
|
||||||
|
host.id = 'kpc-changelog-host';
|
||||||
|
const shadow = host.attachShadow({ mode: 'closed' });
|
||||||
|
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = `
|
||||||
|
.overlay {
|
||||||
|
position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
|
||||||
|
background: rgba(0,0,0,0.75); z-index: 99998;
|
||||||
|
display: flex; justify-content: center; align-items: center;
|
||||||
|
font-family: 'Segoe UI', sans-serif; color: #e0e0e0;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
background: #1a1a2e; border-radius: 12px; padding: 24px;
|
||||||
|
min-width: 400px; max-width: 600px; max-height: 70vh;
|
||||||
|
display: flex; flex-direction: column; box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.header h2 { margin: 0; font-size: 1.4rem; color: #fff; }
|
||||||
|
.close-btn {
|
||||||
|
background: none; border: none; color: #888; font-size: 1.5rem;
|
||||||
|
cursor: pointer; padding: 4px 8px; border-radius: 4px;
|
||||||
|
}
|
||||||
|
.close-btn:hover { color: #fff; background: rgba(255,255,255,0.1); }
|
||||||
|
.body {
|
||||||
|
overflow-y: auto; flex: 1; line-height: 1.6;
|
||||||
|
}
|
||||||
|
.body h1 { font-size: 1.3rem; color: #fff; margin: 12px 0 6px; }
|
||||||
|
.body h2 { font-size: 1.15rem; color: #fff; margin: 10px 0 6px; }
|
||||||
|
.body h3 { font-size: 1rem; color: #ccc; margin: 8px 0 4px; }
|
||||||
|
.body ul { padding-left: 20px; margin: 6px 0; }
|
||||||
|
.body li { margin: 3px 0; }
|
||||||
|
.body a { color: #6ea8fe; }
|
||||||
|
.body strong { color: #fff; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'overlay';
|
||||||
|
overlay.addEventListener('click', (e) => {
|
||||||
|
if (e.target === overlay) host.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.className = 'modal';
|
||||||
|
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'header';
|
||||||
|
header.innerHTML = `<h2>What's New in v${version}</h2>`;
|
||||||
|
const closeBtn = document.createElement('button');
|
||||||
|
closeBtn.className = 'close-btn';
|
||||||
|
closeBtn.textContent = '\u2715';
|
||||||
|
closeBtn.addEventListener('click', () => host.remove());
|
||||||
|
header.appendChild(closeBtn);
|
||||||
|
|
||||||
|
const bodyDiv = document.createElement('div');
|
||||||
|
bodyDiv.className = 'body';
|
||||||
|
bodyDiv.innerHTML = renderMarkdown(body);
|
||||||
|
|
||||||
|
modal.appendChild(header);
|
||||||
|
modal.appendChild(bodyDiv);
|
||||||
|
overlay.appendChild(modal);
|
||||||
|
shadow.appendChild(style);
|
||||||
|
shadow.appendChild(overlay);
|
||||||
|
document.body.appendChild(host);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkChangelog(currentVersion: string, lastSeenVersion: string): Promise<void> {
|
||||||
|
if (lastSeenVersion && !versionLessThan(lastSeenVersion, currentVersion)) return;
|
||||||
|
|
||||||
|
// Update lastSeenVersion regardless of whether we can fetch notes
|
||||||
|
ipcRenderer.invoke('set-config', 'ui', {
|
||||||
|
...await ipcRenderer.invoke('get-config', 'ui'),
|
||||||
|
lastSeenVersion: currentVersion,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await ipcRenderer.invoke('changelog-fetch', currentVersion);
|
||||||
|
if (body) showChangelogPopup(currentVersion, body);
|
||||||
|
} catch { /* fetch failed — skip silently */ }
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
// ── Better Chat + Chat History ──
|
||||||
|
// Merges team/all chat with [T]/[M] prefixes and prevents Krunker from pruning old messages.
|
||||||
|
|
||||||
|
import type { SavedConsole } from './utils';
|
||||||
|
|
||||||
|
const TEAM_MODES = new Set([
|
||||||
|
'Team Deathmatch', 'Hardpoint', 'Capture the Flag', 'Hide & Seek',
|
||||||
|
'Infected', 'Last Man Standing', 'Simon Says', 'Prop Hunt',
|
||||||
|
'Boss Hunt', 'Deposit', 'Stalker', 'Kill Confirmed',
|
||||||
|
'Defuse', 'Traitor', 'Blitz', 'Domination',
|
||||||
|
'Squad Deathmatch', 'Team Defender',
|
||||||
|
]);
|
||||||
|
|
||||||
|
let chatList: HTMLElement | null = null;
|
||||||
|
let observer: MutationObserver | null = null;
|
||||||
|
let historyMax = 0;
|
||||||
|
let betterChatEnabled = false;
|
||||||
|
let reInsertGuard = false;
|
||||||
|
let _con: SavedConsole | null = null;
|
||||||
|
|
||||||
|
function isChatMessage(node: Node): node is HTMLElement {
|
||||||
|
return node.nodeType === 1 && (node as HTMLElement).id?.startsWith('chatMsg_');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTeamMode(): boolean {
|
||||||
|
const modeEl = document.getElementById('gameModeLabel') || document.getElementById('subGameMode');
|
||||||
|
if (!modeEl) return false;
|
||||||
|
return TEAM_MODES.has(modeEl.textContent?.trim() || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMutations(mutations: MutationRecord[]): void {
|
||||||
|
// ── Chat history: re-insert removed messages ──
|
||||||
|
if (historyMax > 0 && chatList && observer) {
|
||||||
|
const removed: HTMLElement[] = [];
|
||||||
|
for (const mut of mutations) {
|
||||||
|
if (reInsertGuard) break;
|
||||||
|
for (const node of mut.removedNodes) {
|
||||||
|
if (isChatMessage(node)) removed.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (removed.length > 0) {
|
||||||
|
reInsertGuard = true;
|
||||||
|
observer.disconnect();
|
||||||
|
const firstLive = chatList.firstChild;
|
||||||
|
for (const node of removed) {
|
||||||
|
chatList.insertBefore(node, firstLive);
|
||||||
|
}
|
||||||
|
while (chatList.children.length > historyMax) {
|
||||||
|
chatList.removeChild(chatList.firstChild!);
|
||||||
|
}
|
||||||
|
observer.observe(chatList, { childList: true });
|
||||||
|
reInsertGuard = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Better chat: tag new messages ──
|
||||||
|
if (betterChatEnabled) {
|
||||||
|
const teamMode = isTeamMode();
|
||||||
|
for (const mut of mutations) {
|
||||||
|
for (const node of mut.addedNodes) {
|
||||||
|
if (!isChatMessage(node)) continue;
|
||||||
|
const chatMsg = node.querySelector('.chatMsg');
|
||||||
|
if (!chatMsg) continue;
|
||||||
|
|
||||||
|
// Remove "Text & Voice Chat" system messages
|
||||||
|
if (chatMsg.textContent?.includes('Text & Voice Chat')) {
|
||||||
|
node.remove();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only tag in team modes with proper chat messages
|
||||||
|
if (!teamMode) continue;
|
||||||
|
if (!chatMsg.innerHTML.includes('\u202E:')) continue;
|
||||||
|
if (!node.dataset.tab) continue;
|
||||||
|
|
||||||
|
const isTeam = node.dataset.tab === '1';
|
||||||
|
const tag = document.createElement('div');
|
||||||
|
tag.style.cssText = 'float:left; margin-right:4px; font-weight:bold;';
|
||||||
|
tag.style.color = isTeam ? '#00FF00' : '#FF0000';
|
||||||
|
tag.textContent = isTeam ? '[T]' : '[M]';
|
||||||
|
chatMsg.insertBefore(tag, chatMsg.firstChild);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-scroll unless paused
|
||||||
|
if (chatList && !chatList.classList.contains('kpc-chat-paused')) {
|
||||||
|
chatList.scrollTop = chatList.scrollHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryAttach(): boolean {
|
||||||
|
chatList = document.getElementById('chatList');
|
||||||
|
if (!chatList) return false;
|
||||||
|
|
||||||
|
observer = new MutationObserver(handleMutations);
|
||||||
|
observer.observe(chatList, { childList: true });
|
||||||
|
_con?.log('[KCC-Chat] Observer attached to #chatList');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initChat(options: { betterChat: boolean; chatHistorySize: number }, con?: SavedConsole): void {
|
||||||
|
_con = con ?? null;
|
||||||
|
betterChatEnabled = options.betterChat;
|
||||||
|
historyMax = options.chatHistorySize;
|
||||||
|
|
||||||
|
if (tryAttach()) return;
|
||||||
|
|
||||||
|
// Poll until #chatList appears
|
||||||
|
let attempts = 0;
|
||||||
|
const poll = setInterval(() => {
|
||||||
|
if (++attempts > 120 || tryAttach()) clearInterval(poll);
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setBetterChat(enabled: boolean): void {
|
||||||
|
betterChatEnabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setChatHistorySize(size: number): void {
|
||||||
|
historyMax = size;
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// ── Hardpoint Enemy Counter ──
|
||||||
|
// Displays enemy capture points being scored in Hardpoint mode.
|
||||||
|
|
||||||
|
let hpObserver: MutationObserver | null = null;
|
||||||
|
let hpCounterEl: HTMLElement | null = null;
|
||||||
|
let hpPointCounter: HTMLElement | null = null;
|
||||||
|
let hpEnemyOBJ = 0;
|
||||||
|
let hpTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let hpCheckInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
function processTeamScores(): void {
|
||||||
|
const teams = document.querySelectorAll('#tScoreC1, #tScoreC2');
|
||||||
|
for (const team of teams) {
|
||||||
|
if (team.className.includes('you')) continue;
|
||||||
|
const scoreEl = team.nextElementSibling;
|
||||||
|
if (!scoreEl) continue;
|
||||||
|
|
||||||
|
const currentScore = parseInt(scoreEl.textContent || '0', 10);
|
||||||
|
if (currentScore > hpEnemyOBJ && hpPointCounter) {
|
||||||
|
hpPointCounter.textContent = String((currentScore - hpEnemyOBJ) / 10);
|
||||||
|
|
||||||
|
if (hpTimeout) clearTimeout(hpTimeout);
|
||||||
|
hpTimeout = setTimeout(() => {
|
||||||
|
if (hpPointCounter) hpPointCounter.textContent = '0';
|
||||||
|
hpTimeout = null;
|
||||||
|
}, 1600);
|
||||||
|
}
|
||||||
|
hpEnemyOBJ = currentScore;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupHPDisplay(): void {
|
||||||
|
const counters = document.querySelector('.topRightCounters');
|
||||||
|
if (!counters || hpCounterEl) return;
|
||||||
|
|
||||||
|
hpCounterEl = document.createElement('div');
|
||||||
|
hpCounterEl.className = 'statIcon kpc-hp-counter';
|
||||||
|
hpCounterEl.innerHTML =
|
||||||
|
'<div class="greyInner" style="display:flex">' +
|
||||||
|
'<span style="color:white;font-size:15px;margin-right:4px;">on</span>' +
|
||||||
|
'<span class="pointVal">0</span></div>';
|
||||||
|
hpPointCounter = hpCounterEl.querySelector('.pointVal');
|
||||||
|
counters.appendChild(hpCounterEl);
|
||||||
|
|
||||||
|
const teamScores = document.getElementById('teamScores');
|
||||||
|
if (teamScores) {
|
||||||
|
hpObserver = new MutationObserver(processTeamScores);
|
||||||
|
hpObserver.observe(teamScores, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initHPCounter(): void {
|
||||||
|
hpCheckInterval = setInterval(() => {
|
||||||
|
if (document.querySelector('.cmpTmHed')) {
|
||||||
|
if (hpCheckInterval) { clearInterval(hpCheckInterval); hpCheckInterval = null; }
|
||||||
|
setupHPDisplay();
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function destroyHPCounter(): void {
|
||||||
|
if (hpCheckInterval) { clearInterval(hpCheckInterval); hpCheckInterval = null; }
|
||||||
|
if (hpObserver) { hpObserver.disconnect(); hpObserver = null; }
|
||||||
|
if (hpCounterEl) { hpCounterEl.remove(); hpCounterEl = null; }
|
||||||
|
if (hpTimeout) { clearTimeout(hpTimeout); hpTimeout = null; }
|
||||||
|
hpPointCounter = null;
|
||||||
|
hpEnemyOBJ = 0;
|
||||||
|
}
|
||||||
+393
-23
@@ -1,16 +1,19 @@
|
|||||||
import { ipcRenderer } from 'electron';
|
import { ipcRenderer } from 'electron';
|
||||||
import { fetchGame, MATCHMAKER_GAMEMODES, MATCHMAKER_REGIONS, MATCHMAKER_REGION_NAMES } from './matchmaker';
|
import { fetchGame, MATCHMAKER_GAMEMODES, MATCHMAKER_REGIONS, MATCHMAKER_REGION_NAMES, MAP_ICON_INDICES, MATCHMAKER_MAP_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 } from './userscripts';
|
import type { UserscriptInstance } from './userscripts';
|
||||||
import { initTranslator, updateTranslatorConfig } from './translator';
|
import { initTranslator, updateTranslatorConfig } from './translator';
|
||||||
import { setDeathAnimBlock, escapeHtml } from './utils';
|
import { setDeathAnimBlock, setCleanerMenu, setMenuTimer, escapeHtml } from './utils';
|
||||||
|
import { initChat, setBetterChat, setChatHistorySize } from './chat';
|
||||||
|
import { initHPCounter, destroyHPCounter } from './competitive';
|
||||||
|
import { checkChangelog } from './changelog';
|
||||||
import type { Keybind } from '../main/config';
|
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
|
||||||
const _verboseLogging = false;
|
let _verboseLogging = false;
|
||||||
|
|
||||||
const _console = {
|
const _console = {
|
||||||
log: (...args: unknown[]) => {
|
log: (...args: unknown[]) => {
|
||||||
@@ -170,6 +173,7 @@ function dismissKeybindDialog(): void {
|
|||||||
document.removeEventListener('keyup', kbKeyupHandler, true);
|
document.removeEventListener('keyup', kbKeyupHandler, true);
|
||||||
if (kbOverlay.parentNode) kbOverlay.remove();
|
if (kbOverlay.parentNode) kbOverlay.remove();
|
||||||
capturingKeybind = null;
|
capturingKeybind = null;
|
||||||
|
ipcRenderer.send('keybind-capture', false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function kbKeydownHandler(event: KeyboardEvent): void {
|
function kbKeydownHandler(event: KeyboardEvent): void {
|
||||||
@@ -215,10 +219,10 @@ function openKeybindDialog(title: string): Promise<Keybind> {
|
|||||||
kbShift.classList.remove('active');
|
kbShift.classList.remove('active');
|
||||||
kbCtrl.classList.remove('active');
|
kbCtrl.classList.remove('active');
|
||||||
kbAlt.classList.remove('active');
|
kbAlt.classList.remove('active');
|
||||||
|
ipcRenderer.send('keybind-capture', true);
|
||||||
document.addEventListener('keydown', kbKeydownHandler, true);
|
document.addEventListener('keydown', kbKeydownHandler, true);
|
||||||
document.addEventListener('keyup', kbKeyupHandler, true);
|
document.addEventListener('keyup', kbKeyupHandler, true);
|
||||||
const uiBase = document.getElementById('uiBase');
|
document.body.appendChild(kbOverlay);
|
||||||
if (uiBase) uiBase.appendChild(kbOverlay);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,6 +395,64 @@ function createCheckboxGrid(opts: {
|
|||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Double Ping Display (Krunker shows half the actual ping) ──
|
||||||
|
let _doublePingObserver: MutationObserver | null = null;
|
||||||
|
|
||||||
|
function initDoublePing(): void {
|
||||||
|
function attach(pingEl: HTMLElement): void {
|
||||||
|
_doublePingObserver = new MutationObserver(() => {
|
||||||
|
const text = pingEl.textContent;
|
||||||
|
if (!text) return;
|
||||||
|
const match = text.match(/(\d+)/);
|
||||||
|
if (!match) return;
|
||||||
|
const doubled = parseInt(match[1]) * 2;
|
||||||
|
_doublePingObserver!.disconnect();
|
||||||
|
pingEl.textContent = text.replace(match[1], String(doubled));
|
||||||
|
_doublePingObserver!.observe(pingEl, { childList: true, characterData: true, subtree: true });
|
||||||
|
});
|
||||||
|
_doublePingObserver.observe(pingEl, { childList: true, characterData: true, subtree: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const el = document.getElementById('pingText');
|
||||||
|
if (el) { attach(el); return; }
|
||||||
|
|
||||||
|
let attempts = 0;
|
||||||
|
const poll = setInterval(() => {
|
||||||
|
if (++attempts > 60) { clearInterval(poll); return; }
|
||||||
|
const pingEl = document.getElementById('pingText');
|
||||||
|
if (pingEl) { clearInterval(poll); attach(pingEl); }
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Show Ping in Player List (numeric ms instead of icon) ──
|
||||||
|
// genList returns an HTML string — parse it, replace icon elements, return modified HTML.
|
||||||
|
function initShowPing(): void {
|
||||||
|
const w = window as any;
|
||||||
|
let attempts = 0;
|
||||||
|
const poll = setInterval(() => {
|
||||||
|
const origGenList = w.windows?.[22]?.genList;
|
||||||
|
if (origGenList && !origGenList.__kpcPingPatched) {
|
||||||
|
clearInterval(poll);
|
||||||
|
const patched = function (this: any) {
|
||||||
|
const html = origGenList.call(this);
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(html, 'text/html');
|
||||||
|
for (const icon of doc.querySelectorAll('.pListPing.material-icons')) {
|
||||||
|
const ping = icon.getAttribute('title');
|
||||||
|
icon.classList.remove('pListPing', 'material-icons');
|
||||||
|
icon.removeAttribute('title');
|
||||||
|
icon.textContent = ping ? ping + ' ' : 'N/A ';
|
||||||
|
}
|
||||||
|
return doc.body.innerHTML;
|
||||||
|
};
|
||||||
|
(patched as any).__kpcPingPatched = true;
|
||||||
|
w.windows[22].genList = patched;
|
||||||
|
} else if (++attempts > 75) {
|
||||||
|
clearInterval(poll);
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
function hookSettings(): void {
|
function hookSettings(): void {
|
||||||
const w = window as any;
|
const w = window as any;
|
||||||
const settingsWindow = w.windows[0];
|
const settingsWindow = w.windows[0];
|
||||||
@@ -499,7 +561,7 @@ function buildGeneralSection(
|
|||||||
body.appendChild(createSelectRow({
|
body.appendChild(createSelectRow({
|
||||||
label: 'Social/Hub Tab Behaviour',
|
label: 'Social/Hub Tab Behaviour',
|
||||||
desc: 'How social, market, and editor pages open when clicked',
|
desc: 'How social, market, and editor pages open when clicked',
|
||||||
options: [{ value: 'New Window', label: 'New Window' }, { value: 'Same Window', label: 'Same Window' }],
|
options: [{ value: 'New Window', label: 'Tabs (Separate Window)' }, { value: 'Same Window', label: 'Tabs (Overlay Game)' }],
|
||||||
value: game.socialTabBehaviour, instant: true,
|
value: game.socialTabBehaviour, instant: true,
|
||||||
onChange: (v) => { game.socialTabBehaviour = v; ipcRenderer.invoke('set-config', 'game', game); },
|
onChange: (v) => { game.socialTabBehaviour = v; ipcRenderer.invoke('set-config', 'game', game); },
|
||||||
}));
|
}));
|
||||||
@@ -546,13 +608,63 @@ function buildGeneralSection(
|
|||||||
onChange: (v) => { game.joinAsSpectator = v; ipcRenderer.invoke('set-config', 'game', game); },
|
onChange: (v) => { game.joinAsSpectator = v; ipcRenderer.invoke('set-config', 'game', game); },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (ui.deathscreenAnimation) setDeathAnimBlock(true);
|
body.appendChild(createToggleRow({
|
||||||
if (ui.hideMenuPopups) startHidePopups();
|
label: 'Cleaner Menu',
|
||||||
|
desc: 'Hide clutter from the main menu (scrollbars, social buttons, class preview, etc.)',
|
||||||
|
checked: ui.cleanerMenu ?? false, instant: true,
|
||||||
|
onChange: (v) => { ui.cleanerMenu = v; saveUI(); setCleanerMenu(v); },
|
||||||
|
}));
|
||||||
|
|
||||||
body.appendChild(createKeybindRow('Pause Chat', 'Freeze chat auto-scroll to read history (default F10)', bag.binds.pauseChat, (b) => {
|
body.appendChild(createToggleRow({
|
||||||
bag.binds.pauseChat = b;
|
label: 'Menu Timer',
|
||||||
bag.saveBinds();
|
desc: 'Show the game/spectate timer on the menu screen',
|
||||||
}, undefined, true));
|
checked: ui.menuTimer ?? true, instant: true,
|
||||||
|
onChange: (v) => { ui.menuTimer = v; saveUI(); setMenuTimer(v); },
|
||||||
|
}));
|
||||||
|
|
||||||
|
body.appendChild(createToggleRow({
|
||||||
|
label: 'Double Ping Display',
|
||||||
|
desc: 'Show the real ping value (Krunker displays half the actual latency)',
|
||||||
|
checked: ui.doublePing ?? true, refreshOnly: true,
|
||||||
|
onChange: (v) => { ui.doublePing = v; saveUI(); },
|
||||||
|
}));
|
||||||
|
|
||||||
|
body.appendChild(createToggleRow({
|
||||||
|
label: 'Show Ping in Player List',
|
||||||
|
desc: 'Replace the ping icon with numeric millisecond values in the player list',
|
||||||
|
checked: game.showPing ?? true, refreshOnly: true,
|
||||||
|
onChange: (v) => { game.showPing = v; ipcRenderer.invoke('set-config', 'game', game); },
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (bag.isWindows) {
|
||||||
|
body.appendChild(createToggleRow({
|
||||||
|
label: 'Raw Input',
|
||||||
|
desc: 'Bypass OS mouse acceleration for direct 1:1 sensor input (Windows only)',
|
||||||
|
checked: game.rawInput ?? true, refreshOnly: true,
|
||||||
|
onChange: (v) => { game.rawInput = v; ipcRenderer.invoke('set-config', 'game', game); },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
body.appendChild(createToggleRow({
|
||||||
|
label: 'Hardpoint Enemy Counter',
|
||||||
|
desc: 'Show enemy capture points in Hardpoint mode',
|
||||||
|
checked: game.hpEnemyCounter ?? true, refreshOnly: true,
|
||||||
|
onChange: (v) => {
|
||||||
|
game.hpEnemyCounter = v; ipcRenderer.invoke('set-config', 'game', game);
|
||||||
|
if (v) initHPCounter(); else destroyHPCounter();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
body.appendChild(createToggleRow({
|
||||||
|
label: 'Show Changelog',
|
||||||
|
desc: 'Show release notes popup when the client updates',
|
||||||
|
checked: ui.showChangelog ?? true, instant: true,
|
||||||
|
onChange: (v) => { ui.showChangelog = v; saveUI(); },
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (ui.deathscreenAnimation) setDeathAnimBlock(true);
|
||||||
|
if (ui.menuTimer ?? true) setMenuTimer(true);
|
||||||
|
if (ui.hideMenuPopups) startHidePopups();
|
||||||
|
|
||||||
body.appendChild(createKeybindRow('Toggle Fullscreen', 'Fullscreen the game window (default F11)', bag.binds.fullscreenToggle, (b) => {
|
body.appendChild(createKeybindRow('Toggle Fullscreen', 'Fullscreen the game window (default F11)', bag.binds.fullscreenToggle, (b) => {
|
||||||
bag.binds.fullscreenToggle = b;
|
bag.binds.fullscreenToggle = b;
|
||||||
@@ -560,8 +672,13 @@ function buildGeneralSection(
|
|||||||
}, undefined, true));
|
}, undefined, true));
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSwapperSection(body: HTMLElement, swapperConf: any): void {
|
function buildSwapperSection(body: HTMLElement, swapperConf: any, uiConfRaw: any): void {
|
||||||
const swapEnabled = swapperConf ? swapperConf.enabled : true;
|
const swapEnabled = swapperConf ? swapperConf.enabled : true;
|
||||||
|
const ui = { cssTheme: 'disabled', loadingTheme: 'disabled', backgroundUrl: '', ...uiConfRaw };
|
||||||
|
|
||||||
|
function saveUI(): void {
|
||||||
|
ipcRenderer.invoke('set-config', 'ui', ui);
|
||||||
|
}
|
||||||
|
|
||||||
body.appendChild(createToggleRow({
|
body.appendChild(createToggleRow({
|
||||||
label: 'Resource Swapper',
|
label: 'Resource Swapper',
|
||||||
@@ -587,6 +704,64 @@ function buildSwapperSection(body: HTMLElement, swapperConf: any): void {
|
|||||||
swapFolderBtn.addEventListener('click', () => ipcRenderer.invoke('open-swap-folder'));
|
swapFolderBtn.addEventListener('click', () => ipcRenderer.invoke('open-swap-folder'));
|
||||||
folderRow.appendChild(swapFolderBtn);
|
folderRow.appendChild(swapFolderBtn);
|
||||||
body.appendChild(folderRow);
|
body.appendChild(folderRow);
|
||||||
|
|
||||||
|
// ── CSS Theme selector (populated from swap/themes/) ──
|
||||||
|
const themeRow = document.createElement('div');
|
||||||
|
themeRow.className = 'setting settName safety-0 sel';
|
||||||
|
themeRow.innerHTML =
|
||||||
|
'<span class="setting-title">CSS Theme</span>' +
|
||||||
|
'<div class="setting-desc-new">Load a custom CSS theme from swap/themes/</div>';
|
||||||
|
const themeSelect = document.createElement('select');
|
||||||
|
themeSelect.className = 's-update inputGrey2';
|
||||||
|
themeSelect.innerHTML = '<option value="disabled">Loading...</option>';
|
||||||
|
themeRow.appendChild(themeSelect);
|
||||||
|
body.appendChild(themeRow);
|
||||||
|
|
||||||
|
ipcRenderer.invoke('list-themes').then((themes: Array<{ id: string; label: string }>) => {
|
||||||
|
themeSelect.innerHTML = '';
|
||||||
|
for (const t of themes) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = t.id;
|
||||||
|
opt.textContent = t.label;
|
||||||
|
if (t.id === ui.cssTheme) opt.selected = true;
|
||||||
|
themeSelect.appendChild(opt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
themeSelect.addEventListener('change', () => {
|
||||||
|
ui.cssTheme = themeSelect.value;
|
||||||
|
saveUI();
|
||||||
|
onSettingChanged('refresh');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Loading Screen Background ──
|
||||||
|
const bgRow = document.createElement('div');
|
||||||
|
bgRow.className = 'setting settName safety-0 sel';
|
||||||
|
bgRow.innerHTML =
|
||||||
|
'<span class="setting-title">Loading Background</span>' +
|
||||||
|
'<div class="setting-desc-new">Custom background image for the loading screen (swap/backgrounds/)</div>';
|
||||||
|
const bgSelect = document.createElement('select');
|
||||||
|
bgSelect.className = 's-update inputGrey2';
|
||||||
|
bgSelect.innerHTML = '<option value="disabled">Loading...</option>';
|
||||||
|
bgRow.appendChild(bgSelect);
|
||||||
|
body.appendChild(bgRow);
|
||||||
|
|
||||||
|
ipcRenderer.invoke('list-loading-themes').then((themes: Array<{ id: string; label: string }>) => {
|
||||||
|
bgSelect.innerHTML = '';
|
||||||
|
for (const t of themes) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = t.id;
|
||||||
|
opt.textContent = t.label;
|
||||||
|
if (t.id === ui.loadingTheme) opt.selected = true;
|
||||||
|
bgSelect.appendChild(opt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
bgSelect.addEventListener('change', () => {
|
||||||
|
ui.loadingTheme = bgSelect.value;
|
||||||
|
saveUI();
|
||||||
|
onSettingChanged('refresh');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMatchmakerSection(body: HTMLElement, mmConf: any, bag: SettingsBag): void {
|
function buildMatchmakerSection(body: HTMLElement, mmConf: any, bag: SettingsBag): void {
|
||||||
@@ -598,7 +773,7 @@ function buildMatchmakerSection(body: HTMLElement, mmConf: any, bag: SettingsBag
|
|||||||
|
|
||||||
body.appendChild(createToggleRow({
|
body.appendChild(createToggleRow({
|
||||||
label: 'Custom Matchmaker',
|
label: 'Custom Matchmaker',
|
||||||
desc: 'Press F6 to find a game matching your criteria',
|
desc: 'Use the matchmaker hotkey to find a game matching your criteria',
|
||||||
checked: mm.enabled, instant: true,
|
checked: mm.enabled, instant: true,
|
||||||
onChange: (v) => { mm.enabled = v; saveMM(); },
|
onChange: (v) => { mm.enabled = v; saveMM(); },
|
||||||
}));
|
}));
|
||||||
@@ -661,6 +836,14 @@ function buildMatchmakerSection(body: HTMLElement, mmConf: any, bag: SettingsBag
|
|||||||
selected: mm.gamemodes,
|
selected: mm.gamemodes,
|
||||||
onChange: () => saveMM(),
|
onChange: () => saveMM(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
if (!mm.maps) mm.maps = [];
|
||||||
|
body.appendChild(createCheckboxGrid({
|
||||||
|
header: 'Maps (none selected = all)',
|
||||||
|
items: MAP_ICON_INDICES.map(m => ({ value: m, label: MATCHMAKER_MAP_NAMES[m] || m })),
|
||||||
|
selected: mm.maps,
|
||||||
|
onChange: () => saveMM(),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDiscordSection(body: HTMLElement, discordConf: any): void {
|
function buildDiscordSection(body: HTMLElement, discordConf: any): void {
|
||||||
@@ -804,7 +987,32 @@ function buildAccountsSection(body: HTMLElement, accountsArr: any[]): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTranslatorSection(body: HTMLElement, translatorConf: any): void {
|
function buildChatSection(body: HTMLElement, gameConf: any, translatorConf: any, bag: SettingsBag): void {
|
||||||
|
const game = { betterChat: true, chatHistorySize: 200, ...gameConf };
|
||||||
|
|
||||||
|
function saveGame(): void {
|
||||||
|
ipcRenderer.invoke('set-config', 'game', game);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.appendChild(createToggleRow({
|
||||||
|
label: 'Better Chat',
|
||||||
|
desc: 'Merge team and all-chat with colored [T]/[M] prefixes',
|
||||||
|
checked: game.betterChat, instant: true,
|
||||||
|
onChange: (v) => { game.betterChat = v; saveGame(); setBetterChat(v); },
|
||||||
|
}));
|
||||||
|
|
||||||
|
body.appendChild(createNumberRow({
|
||||||
|
label: 'Chat History Size', desc: 'Maximum chat messages to keep (0 to disable history preservation)',
|
||||||
|
min: 0, max: 1000, value: game.chatHistorySize, instant: true,
|
||||||
|
onChange: (v) => { game.chatHistorySize = v; saveGame(); setChatHistorySize(v); },
|
||||||
|
}));
|
||||||
|
|
||||||
|
body.appendChild(createKeybindRow('Pause Chat', 'Freeze chat auto-scroll to read history (default F10)', bag.binds.pauseChat, (b) => {
|
||||||
|
bag.binds.pauseChat = b;
|
||||||
|
bag.saveBinds();
|
||||||
|
}, undefined, true));
|
||||||
|
|
||||||
|
// Translator settings inline
|
||||||
const tl = { enabled: true, targetLanguage: 'en', showLanguageTag: true, ...translatorConf };
|
const tl = { enabled: true, targetLanguage: 'en', showLanguageTag: true, ...translatorConf };
|
||||||
|
|
||||||
function saveTL(): void {
|
function saveTL(): void {
|
||||||
@@ -863,7 +1071,7 @@ function buildTranslatorSection(body: HTMLElement, translatorConf: any): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildAdvancedSection(
|
function buildAdvancedSection(
|
||||||
body: HTMLElement, advConf: any, isWindows: boolean,
|
body: HTMLElement, advConf: any, perfConf: any, isWindows: boolean,
|
||||||
): void {
|
): void {
|
||||||
const advDefaults = {
|
const advDefaults = {
|
||||||
removeUselessFeatures: true,
|
removeUselessFeatures: true,
|
||||||
@@ -874,8 +1082,14 @@ function buildAdvancedSection(
|
|||||||
lowLatency: false,
|
lowLatency: false,
|
||||||
experimentalFlags: false,
|
experimentalFlags: false,
|
||||||
angleBackend: 'default',
|
angleBackend: 'default',
|
||||||
|
verboseLogging: false,
|
||||||
};
|
};
|
||||||
const adv = { ...advDefaults, ...advConf };
|
const adv = { ...advDefaults, ...advConf };
|
||||||
|
const perf = { cpuThrottleGame: 1, cpuThrottleMenu: 1.5, processPriority: 'Normal', ...perfConf };
|
||||||
|
|
||||||
|
function savePerf(): void {
|
||||||
|
ipcRenderer.invoke('set-config', 'performance', perf);
|
||||||
|
}
|
||||||
|
|
||||||
function saveAdv(): void {
|
function saveAdv(): void {
|
||||||
ipcRenderer.invoke('set-config', 'advanced', adv);
|
ipcRenderer.invoke('set-config', 'advanced', adv);
|
||||||
@@ -922,6 +1136,44 @@ function buildAdvancedSection(
|
|||||||
onChange: (v) => { adv[t.key] = v; saveAdv(); },
|
onChange: (v) => { adv[t.key] = v; saveAdv(); },
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.appendChild(createNumberRow({
|
||||||
|
label: 'CPU Throttle (Game)', desc: 'CPU throttle rate during gameplay (1 = no throttle, 3 = heavy throttle)',
|
||||||
|
min: 1, max: 3, value: perf.cpuThrottleGame, instant: true, safety: 2,
|
||||||
|
onChange: (v) => { perf.cpuThrottleGame = v; savePerf(); },
|
||||||
|
}));
|
||||||
|
|
||||||
|
body.appendChild(createNumberRow({
|
||||||
|
label: 'CPU Throttle (Menu)', desc: 'CPU throttle rate on menu screens (1 = no throttle, 3 = heavy throttle)',
|
||||||
|
min: 1, max: 3, value: perf.cpuThrottleMenu, instant: true, safety: 1,
|
||||||
|
onChange: (v) => { perf.cpuThrottleMenu = v; savePerf(); },
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (isWindows) {
|
||||||
|
body.appendChild(createSelectRow({
|
||||||
|
label: 'Process Priority',
|
||||||
|
desc: 'OS-level process priority for the client (Windows only)',
|
||||||
|
options: [
|
||||||
|
{ value: 'Normal', label: 'Normal' },
|
||||||
|
{ value: 'Above Normal', label: 'Above Normal' },
|
||||||
|
{ value: 'High', label: 'High' },
|
||||||
|
{ value: 'Below Normal', label: 'Below Normal' },
|
||||||
|
{ value: 'Low', label: 'Low' },
|
||||||
|
],
|
||||||
|
value: perf.processPriority, restart: true, safety: 2,
|
||||||
|
onChange: (v) => { perf.processPriority = v; savePerf(); },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
body.appendChild(createToggleRow({
|
||||||
|
label: 'Verbose Logging',
|
||||||
|
desc: 'Forward all preload console output to the Electron log file',
|
||||||
|
checked: adv.verboseLogging, instant: true,
|
||||||
|
onChange: (v) => {
|
||||||
|
adv.verboseLogging = v; saveAdv();
|
||||||
|
_verboseLogging = v;
|
||||||
|
},
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Search filter + "no settings" cleanup ──
|
// ── Search filter + "no settings" cleanup ──
|
||||||
@@ -1024,8 +1276,8 @@ function renderSettings(searchQuery?: string): void {
|
|||||||
container.appendChild(swapSec.section);
|
container.appendChild(swapSec.section);
|
||||||
const mmSec = createSection('Matchmaker');
|
const mmSec = createSection('Matchmaker');
|
||||||
container.appendChild(mmSec.section);
|
container.appendChild(mmSec.section);
|
||||||
const tlSec = createSection('Translator');
|
const chatSec = createSection('Chat');
|
||||||
container.appendChild(tlSec.section);
|
container.appendChild(chatSec.section);
|
||||||
const discordSec = createSection('Discord');
|
const discordSec = createSection('Discord');
|
||||||
container.appendChild(discordSec.section);
|
container.appendChild(discordSec.section);
|
||||||
const accSec = createSection('Accounts', true);
|
const accSec = createSection('Accounts', true);
|
||||||
@@ -1066,12 +1318,12 @@ function renderSettings(searchQuery?: string): void {
|
|||||||
|
|
||||||
// Populate each section
|
// Populate each section
|
||||||
buildGeneralSection(genSec.body, gameConf, uiConfRaw, allConf.performance, bag);
|
buildGeneralSection(genSec.body, gameConf, uiConfRaw, allConf.performance, bag);
|
||||||
buildSwapperSection(swapSec.body, swapperConf);
|
buildSwapperSection(swapSec.body, swapperConf, uiConfRaw);
|
||||||
buildMatchmakerSection(mmSec.body, mmConf, bag);
|
buildMatchmakerSection(mmSec.body, mmConf, bag);
|
||||||
buildTranslatorSection(tlSec.body, translatorConf);
|
buildChatSection(chatSec.body, gameConf, translatorConf, bag);
|
||||||
buildDiscordSection(discordSec.body, discordConf);
|
buildDiscordSection(discordSec.body, discordConf);
|
||||||
buildAccountsSection(accSec.body, allConf.accounts);
|
buildAccountsSection(accSec.body, allConf.accounts);
|
||||||
buildAdvancedSection(advSec.body, advConf, isWindows);
|
buildAdvancedSection(advSec.body, advConf, allConf.performance, isWindows);
|
||||||
renderUserscriptsSection(usSec.body);
|
renderUserscriptsSection(usSec.body);
|
||||||
|
|
||||||
if (searchQuery) applySearchFilter(container, holder, searchQuery);
|
if (searchQuery) applySearchFilter(container, holder, searchQuery);
|
||||||
@@ -1357,14 +1609,19 @@ ipcRenderer.on('main_did-finish-load', () => {
|
|||||||
// ── Batch all config reads into a single IPC call ──
|
// ── Batch all config reads into a single IPC call ──
|
||||||
(window as any).closeClient = () => window.close();
|
(window as any).closeClient = () => window.close();
|
||||||
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', 'advanced', 'performance']),
|
||||||
ipcRenderer.invoke('get-platform'),
|
ipcRenderer.invoke('get-platform'),
|
||||||
]).then(([allConf, _platformInfo]: [any, any]) => {
|
ipcRenderer.invoke('get-version'),
|
||||||
|
]).then(([allConf, _platformInfo, currentVersion]: [any, any, string]) => {
|
||||||
const uiConf = allConf.ui;
|
const uiConf = allConf.ui;
|
||||||
const usConf = allConf.userscripts;
|
const usConf = allConf.userscripts;
|
||||||
const gameConf = allConf.game;
|
const gameConf = allConf.game;
|
||||||
const translatorConf = allConf.translator;
|
const translatorConf = allConf.translator;
|
||||||
const discordConf = allConf.discord;
|
const discordConf = allConf.discord;
|
||||||
|
const advConf = allConf.advanced;
|
||||||
|
|
||||||
|
// ── Verbose logging toggle ──
|
||||||
|
_verboseLogging = advConf?.verboseLogging ?? false;
|
||||||
|
|
||||||
// ── Exit button + UI toggles ──
|
// ── Exit button + UI toggles ──
|
||||||
const showExit = uiConf ? (uiConf.showExitButton !== false) : true;
|
const showExit = uiConf ? (uiConf.showExitButton !== false) : true;
|
||||||
@@ -1385,6 +1642,113 @@ ipcRenderer.on('main_did-finish-load', () => {
|
|||||||
|
|
||||||
if (uiConf?.deathscreenAnimation) setDeathAnimBlock(true);
|
if (uiConf?.deathscreenAnimation) setDeathAnimBlock(true);
|
||||||
if (uiConf?.hideMenuPopups) startHidePopups();
|
if (uiConf?.hideMenuPopups) startHidePopups();
|
||||||
|
if (uiConf?.cleanerMenu) setCleanerMenu(true);
|
||||||
|
if (uiConf?.menuTimer ?? true) setMenuTimer(true);
|
||||||
|
|
||||||
|
// ── Double ping display ──
|
||||||
|
if (isGamePage && (uiConf?.doublePing ?? true)) {
|
||||||
|
initDoublePing();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Show ping in player list ──
|
||||||
|
if (isGamePage && (gameConf?.showPing ?? true)) {
|
||||||
|
initShowPing();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Raw input (Windows only — unadjustedMovement) ──
|
||||||
|
if (isGamePage && process.platform === 'win32' && (gameConf?.rawInput ?? true)) {
|
||||||
|
const origLock = HTMLCanvasElement.prototype.requestPointerLock;
|
||||||
|
HTMLCanvasElement.prototype.requestPointerLock = function (opts?: any) {
|
||||||
|
const promise = origLock.call(this, { ...opts, unadjustedMovement: true }) as any;
|
||||||
|
if (promise && typeof promise.catch === 'function') {
|
||||||
|
return promise.catch(() => origLock.call(this, opts));
|
||||||
|
}
|
||||||
|
return promise;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Better chat + Chat history ──
|
||||||
|
if (isGamePage) {
|
||||||
|
initChat({
|
||||||
|
betterChat: gameConf?.betterChat ?? true,
|
||||||
|
chatHistorySize: gameConf?.chatHistorySize ?? 200,
|
||||||
|
}, _console);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hardpoint enemy counter ──
|
||||||
|
if (isGamePage && (gameConf?.hpEnemyCounter ?? true)) {
|
||||||
|
initHPCounter();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CPU throttle state notifications ──
|
||||||
|
if (isGamePage) {
|
||||||
|
let inGame = false;
|
||||||
|
setInterval(() => {
|
||||||
|
const uiBase = document.getElementById('uiBase');
|
||||||
|
const nowInGame = !!uiBase && uiBase.className !== 'onMenu' && uiBase.className !== '';
|
||||||
|
if (nowInGame !== inGame) {
|
||||||
|
inGame = nowInGame;
|
||||||
|
ipcRenderer.send('throttle-state', inGame ? 'game' : 'menu');
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Changelog popup ──
|
||||||
|
if (isGamePage && (uiConf?.showChangelog ?? true)) {
|
||||||
|
checkChangelog(currentVersion, uiConf?.lastSeenVersion || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Battle Pass Claim All (game page only) ──
|
||||||
|
// Poll for .bpBotH element — injects button when BP window is visible
|
||||||
|
if (isGamePage) {
|
||||||
|
const getClaimable = () => Array.from(document.querySelectorAll('.bpClaimB')).filter(
|
||||||
|
(el: any) => el.offsetParent !== null && el.textContent?.trim() === 'Claim'
|
||||||
|
);
|
||||||
|
setInterval(() => {
|
||||||
|
const bar = document.querySelector('.bpBotH') as HTMLElement | null;
|
||||||
|
if (!bar || bar.offsetParent === null) return;
|
||||||
|
const existing = document.getElementById('claimAllBtn');
|
||||||
|
if (existing) {
|
||||||
|
// Update state on re-check (rewards may have become claimable)
|
||||||
|
const claimable = getClaimable();
|
||||||
|
if (claimable.length > 0) {
|
||||||
|
existing.textContent = 'Claim All';
|
||||||
|
existing.classList.remove('disabled');
|
||||||
|
} else {
|
||||||
|
existing.textContent = 'Nothing to Claim';
|
||||||
|
existing.classList.add('disabled');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const claimable = getClaimable();
|
||||||
|
const btn = document.createElement('div');
|
||||||
|
btn.className = 'bpBtn skip';
|
||||||
|
btn.id = 'claimAllBtn';
|
||||||
|
btn.style.cssText = 'margin-left: 8px; cursor: pointer; background: #4CAF50;';
|
||||||
|
if (claimable.length > 0) {
|
||||||
|
btn.textContent = 'Claim All';
|
||||||
|
} else {
|
||||||
|
btn.textContent = 'Nothing to Claim';
|
||||||
|
btn.classList.add('disabled');
|
||||||
|
}
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
if (btn.classList.contains('disabled')) return;
|
||||||
|
(window as any).playSelect?.(0.1);
|
||||||
|
const items = getClaimable();
|
||||||
|
if (items.length === 0) return;
|
||||||
|
btn.textContent = 'Claiming...';
|
||||||
|
btn.classList.add('disabled');
|
||||||
|
for (const item of items) {
|
||||||
|
(item as HTMLElement).click();
|
||||||
|
await new Promise(r => setTimeout(r, 200));
|
||||||
|
}
|
||||||
|
const remaining = getClaimable();
|
||||||
|
btn.textContent = remaining.length > 0 ? 'Claim All' : 'Nothing to Claim';
|
||||||
|
btn.classList.toggle('disabled', remaining.length === 0);
|
||||||
|
});
|
||||||
|
bar.appendChild(btn);
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Initialize userscripts ──
|
// ── Initialize userscripts ──
|
||||||
const usEnabled = usConf ? usConf.enabled : true;
|
const usEnabled = usConf ? usConf.enabled : true;
|
||||||
@@ -1649,3 +2013,9 @@ ipcRenderer.on('main_did-finish-load', () => {
|
|||||||
}
|
}
|
||||||
}, 500);
|
}, 500);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Lightweight tab page init (skips game-only features) ──
|
||||||
|
ipcRenderer.on('main_did-finish-load-tab', () => {
|
||||||
|
_console.log('[KCC] Tab page loaded');
|
||||||
|
(window as any).closeClient = () => window.close();
|
||||||
|
});
|
||||||
|
|||||||
+405
-200
@@ -1,6 +1,7 @@
|
|||||||
// ── 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,
|
||||||
// sorts by lowest ping then highest player count, and joins the best match.
|
// sorts by lowest ping then highest player count, and joins the best match.
|
||||||
|
// Shows a live lobby-cycling search popup while scanning.
|
||||||
|
|
||||||
import { ipcRenderer } from 'electron';
|
import { ipcRenderer } from 'electron';
|
||||||
import type { Keybind } from '../main/config';
|
import type { Keybind } from '../main/config';
|
||||||
@@ -9,242 +10,446 @@ import type { SavedConsole } from './utils';
|
|||||||
export const MATCHMAKER_GAMEMODES = ['Free for All', 'Team Deathmatch', 'Hardpoint', 'Capture the Flag', 'Parkour', 'Hide & Seek', 'Infected', 'Race', 'Last Man Standing', 'Simon Says', 'Gun Game', 'Prop Hunt', 'Boss Hunt', 'Classic FFA', 'Deposit', 'Stalker', 'King of the Hill', 'One in the Chamber', 'Trade', 'Kill Confirmed', 'Defuse', 'Sharp Shooter', 'Traitor', 'Raid', 'Blitz', 'Domination', 'Squad Deathmatch', 'Kranked FFA', 'Team Defender', 'Deposit FFA', 'Chaos Snipers', 'Bighead FFA'];
|
export const MATCHMAKER_GAMEMODES = ['Free for All', 'Team Deathmatch', 'Hardpoint', 'Capture the Flag', 'Parkour', 'Hide & Seek', 'Infected', 'Race', 'Last Man Standing', 'Simon Says', 'Gun Game', 'Prop Hunt', 'Boss Hunt', 'Classic FFA', 'Deposit', 'Stalker', 'King of the Hill', 'One in the Chamber', 'Trade', 'Kill Confirmed', 'Defuse', 'Sharp Shooter', 'Traitor', 'Raid', 'Blitz', 'Domination', 'Squad Deathmatch', 'Kranked FFA', 'Team Defender', 'Deposit FFA', 'Chaos Snipers', 'Bighead FFA'];
|
||||||
export const MATCHMAKER_REGIONS = ['MBI', 'NY', 'FRA', 'SIN', 'DAL', 'SYD', 'MIA', 'BHN', 'TOK', 'BRZ', 'AFR', 'LON', 'CHI', 'SV', 'STL', 'MX'];
|
export const MATCHMAKER_REGIONS = ['MBI', 'NY', 'FRA', 'SIN', 'DAL', 'SYD', 'MIA', 'BHN', 'TOK', 'BRZ', 'AFR', 'LON', 'CHI', 'SV', 'STL', 'MX'];
|
||||||
export const MATCHMAKER_REGION_NAMES: Record<string, string> = { MBI: 'Mumbai', NY: 'New York', FRA: 'Frankfurt', SIN: 'Singapore', DAL: 'Dallas', SYD: 'Sydney', MIA: 'Miami', BHN: 'Middle East', TOK: 'Tokyo', BRZ: 'Brazil', AFR: 'South Africa', LON: 'London', CHI: 'China', SV: 'Silicon Valley', STL: 'Seattle', MX: 'Mexico' };
|
export const MATCHMAKER_REGION_NAMES: Record<string, string> = { MBI: 'Mumbai', NY: 'New York', FRA: 'Frankfurt', SIN: 'Singapore', DAL: 'Dallas', SYD: 'Sydney', MIA: 'Miami', BHN: 'Middle East', TOK: 'Tokyo', BRZ: 'Brazil', AFR: 'South Africa', LON: 'London', CHI: 'China', SV: 'Silicon Valley', STL: 'Seattle', MX: 'Mexico' };
|
||||||
const MAP_ICON_INDICES = ['Burg', 'Littletown', 'Sandstorm', 'Subzero', 'Undergrowth', 'Shipment', 'Freight', 'Lostworld', 'Citadel', 'Oasis', 'Kanji', 'Industry', 'Lumber', 'Evacuation', 'Site', 'SkyTemple', 'Lagoon', 'Bureau', 'Tortuga', 'Tropicano', 'Krunk_Plaza', 'Arena', 'Habitat', 'Atomic', 'Old_Burg', 'Throwback', 'Stockade', 'Facility', 'Clockwork', 'Laboratory', 'Shipyard', 'Soul Sanctum', 'Bazaar', 'Erupt', 'HQ', 'Khepri', 'Lush', 'Vivo', 'Slide Moonlight', 'Eterno Sim'];
|
export const MAP_ICON_INDICES = ['Burg', 'Littletown', 'Sandstorm', 'Subzero', 'Undergrowth', 'Shipment', 'Freight', 'Lostworld', 'Citadel', 'Oasis', 'Kanji', 'Industry', 'Lumber', 'Evacuation', 'Site', 'SkyTemple', 'Lagoon', 'Bureau', 'Tortuga', 'Tropicano', 'Krunk_Plaza', 'Arena', 'Habitat', 'Atomic', 'Old_Burg', 'Throwback', 'Stockade', 'Facility', 'Clockwork', 'Laboratory', 'Shipyard', 'Soul Sanctum', 'Bazaar', 'Erupt', 'HQ', 'Khepri', 'Lush', 'Vivo', 'Slide Moonlight', 'Eterno Sim'];
|
||||||
|
export const MATCHMAKER_MAP_NAMES: Record<string, string> = {
|
||||||
|
SkyTemple: 'Sky Temple', Krunk_Plaza: 'Krunk Plaza', Old_Burg: 'Old Burg',
|
||||||
|
'Soul Sanctum': 'Soul Sanctum', 'Slide Moonlight': 'Slide Moonlight', 'Eterno Sim': 'Eterno Sim',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Animation constants ──
|
||||||
|
const MAX_FEED_ENTRIES = 4;
|
||||||
|
const MAX_ANIMATION_MS = 2000;
|
||||||
|
const BASE_TICK_MS = 80;
|
||||||
|
const MIN_TICK_MS = 20;
|
||||||
|
const POST_SCAN_PAUSE_MS = 300;
|
||||||
|
const SCAN_FLASH_MS = 800;
|
||||||
|
|
||||||
interface MatchmakerGame {
|
interface MatchmakerGame {
|
||||||
gameID: string;
|
gameID: string;
|
||||||
region: string;
|
region: string;
|
||||||
playerCount: number;
|
playerCount: number;
|
||||||
playerLimit: number;
|
playerLimit: number;
|
||||||
map: string;
|
map: string;
|
||||||
gamemode: string;
|
gamemode: string;
|
||||||
remainingTime: number;
|
remainingTime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawLobby extends MatchmakerGame {
|
||||||
|
passesFilter: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MatchmakerConfig {
|
export interface MatchmakerConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
regions: string[];
|
regions: string[];
|
||||||
gamemodes: string[];
|
gamemodes: string[];
|
||||||
minPlayers: number;
|
maps: string[];
|
||||||
maxPlayers: number;
|
minPlayers: number;
|
||||||
minRemainingTime: number;
|
maxPlayers: number;
|
||||||
openServerBrowser: boolean;
|
minRemainingTime: number;
|
||||||
autoJoin: boolean;
|
openServerBrowser: boolean;
|
||||||
acceptKey: Keybind;
|
autoJoin: boolean;
|
||||||
cancelKey: Keybind;
|
acceptKey: Keybind;
|
||||||
|
cancelKey: Keybind;
|
||||||
}
|
}
|
||||||
|
|
||||||
function secondsToTimestring(num: number): string {
|
function secondsToTimestring(num: number): string {
|
||||||
const minutes = Math.floor(num / 60);
|
const minutes = Math.floor(num / 60);
|
||||||
const seconds = num % 60;
|
const seconds = num % 60;
|
||||||
if (minutes < 1) return `${num}s`;
|
if (minutes < 1) return `${num}s`;
|
||||||
return `${minutes}m ${seconds}s`;
|
return `${minutes}m ${seconds}s`;
|
||||||
}
|
|
||||||
|
|
||||||
// ── Popup DOM (lazy-initialized on first use) ──
|
|
||||||
const POPUP_ID = 'matchmakerPopupContainer';
|
|
||||||
|
|
||||||
interface PopupDOM {
|
|
||||||
element: HTMLDivElement;
|
|
||||||
title: HTMLDivElement;
|
|
||||||
description: HTMLDivElement;
|
|
||||||
confirmBtn: HTMLDivElement;
|
|
||||||
cancelBtn: HTMLDivElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
let _popup: PopupDOM | null = null;
|
|
||||||
|
|
||||||
function getPopup(): PopupDOM {
|
|
||||||
if (_popup) return _popup;
|
|
||||||
|
|
||||||
const element = document.createElement('div');
|
|
||||||
element.id = POPUP_ID;
|
|
||||||
|
|
||||||
const title = document.createElement('div');
|
|
||||||
title.id = 'matchmakerPopupTitle';
|
|
||||||
element.appendChild(title);
|
|
||||||
|
|
||||||
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 popupGameID = '';
|
|
||||||
let openServerBrowser = true;
|
|
||||||
let confirmKey: Keybind = { key: 'Enter', ctrl: false, shift: false, alt: false };
|
|
||||||
let cancelKey: Keybind = { key: 'Escape', ctrl: false, shift: false, alt: false };
|
|
||||||
|
|
||||||
function decideMatchmakerDecision(accept: boolean): void {
|
|
||||||
const w = window as any;
|
|
||||||
if (typeof w.playSelect === 'function') w.playSelect();
|
|
||||||
|
|
||||||
if (accept && popupGameID !== 'none') {
|
|
||||||
window.location.href = `https://krunker.io/?game=${popupGameID}`;
|
|
||||||
} else {
|
|
||||||
const popup = getPopup();
|
|
||||||
if (popup.element.parentNode) popup.element.remove();
|
|
||||||
if (popupGameID === 'none' && openServerBrowser && typeof w.openServerWindow === 'function') {
|
|
||||||
w.openServerWindow(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchesKey(bind: Keybind, event: KeyboardEvent): boolean {
|
function matchesKey(bind: Keybind, event: KeyboardEvent): boolean {
|
||||||
if ((document.activeElement as HTMLElement)?.tagName === 'INPUT') return false;
|
if ((document.activeElement as HTMLElement)?.tagName === 'INPUT') return false;
|
||||||
return event.key === bind.key
|
return event.key === bind.key
|
||||||
&& event.shiftKey === bind.shift
|
&& event.shiftKey === bind.shift
|
||||||
&& event.altKey === bind.alt
|
&& event.altKey === bind.alt
|
||||||
&& event.ctrlKey === bind.ctrl;
|
&& event.ctrlKey === bind.ctrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Popup DOM (created once, reused) ──
|
||||||
|
const POPUP_ID = 'matchmakerPopupContainer';
|
||||||
|
const popupElement = document.createElement('div');
|
||||||
|
popupElement.id = POPUP_ID;
|
||||||
|
|
||||||
|
// Result-phase elements
|
||||||
|
const popupTitle = document.createElement('div');
|
||||||
|
popupTitle.id = 'matchmakerPopupTitle';
|
||||||
|
popupElement.appendChild(popupTitle);
|
||||||
|
|
||||||
|
const popupDescription = document.createElement('div');
|
||||||
|
popupDescription.id = 'matchmakerPopupDescription';
|
||||||
|
popupElement.appendChild(popupDescription);
|
||||||
|
|
||||||
|
const popupOptions = document.createElement('div');
|
||||||
|
popupOptions.id = 'matchmakerPopupOptions';
|
||||||
|
|
||||||
|
const popupConfirmBtn = document.createElement('div');
|
||||||
|
popupConfirmBtn.id = 'matchmakerConfirmButton';
|
||||||
|
popupConfirmBtn.className = 'matchmakerPopupButton bigShadowT';
|
||||||
|
popupConfirmBtn.textContent = 'Join';
|
||||||
|
popupConfirmBtn.setAttribute('onmouseenter', 'playTick()');
|
||||||
|
popupConfirmBtn.addEventListener('click', () => decideMatchmakerDecision(true));
|
||||||
|
|
||||||
|
const popupCancelBtn = document.createElement('div');
|
||||||
|
popupCancelBtn.id = 'matchmakerCancelButton';
|
||||||
|
popupCancelBtn.className = 'matchmakerPopupButton bigShadowT';
|
||||||
|
popupCancelBtn.textContent = 'Cancel';
|
||||||
|
popupCancelBtn.setAttribute('onmouseenter', 'playTick()');
|
||||||
|
popupCancelBtn.addEventListener('click', () => decideMatchmakerDecision(false));
|
||||||
|
|
||||||
|
popupOptions.appendChild(popupConfirmBtn);
|
||||||
|
popupOptions.appendChild(popupCancelBtn);
|
||||||
|
popupElement.appendChild(popupOptions);
|
||||||
|
|
||||||
|
// Search-phase elements
|
||||||
|
const searchContainer = document.createElement('div');
|
||||||
|
searchContainer.id = 'matchmakerSearchContainer';
|
||||||
|
|
||||||
|
const searchStatus = document.createElement('div');
|
||||||
|
searchStatus.id = 'matchmakerSearchStatus';
|
||||||
|
searchContainer.appendChild(searchStatus);
|
||||||
|
|
||||||
|
const searchFeed = document.createElement('div');
|
||||||
|
searchFeed.id = 'matchmakerSearchFeed';
|
||||||
|
searchContainer.appendChild(searchFeed);
|
||||||
|
|
||||||
|
const searchCounter = document.createElement('div');
|
||||||
|
searchCounter.id = 'matchmakerSearchCounter';
|
||||||
|
searchContainer.appendChild(searchCounter);
|
||||||
|
|
||||||
|
const searchCancelBtn = document.createElement('div');
|
||||||
|
searchCancelBtn.id = 'matchmakerSearchCancel';
|
||||||
|
searchCancelBtn.textContent = 'Cancel';
|
||||||
|
searchCancelBtn.setAttribute('onmouseenter', 'playTick()');
|
||||||
|
searchCancelBtn.addEventListener('click', () => abortSearch());
|
||||||
|
searchContainer.appendChild(searchCancelBtn);
|
||||||
|
|
||||||
|
popupElement.appendChild(searchContainer);
|
||||||
|
|
||||||
|
// ── State ──
|
||||||
|
let popupGameID = '';
|
||||||
|
let popupCandidates: MatchmakerGame[] = [];
|
||||||
|
let openServerBrowser = true;
|
||||||
|
let confirmKey: Keybind = { key: 'Enter', ctrl: false, shift: false, alt: false };
|
||||||
|
let cancelKey: Keybind = { key: 'Escape', ctrl: false, shift: false, alt: false };
|
||||||
|
let searchAborted = false;
|
||||||
|
|
||||||
|
function abortSearch(): void {
|
||||||
|
searchAborted = true;
|
||||||
|
const w = window as any;
|
||||||
|
if (typeof w.playSelect === 'function') w.playSelect();
|
||||||
|
dismissPopup();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyAndJoin(gameID: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`https://matchmaker.krunker.io/game-list?hostname=${window.location.hostname}`);
|
||||||
|
const result = await resp.json();
|
||||||
|
const liveMap = new Map<string, { players: number; limit: number }>();
|
||||||
|
for (const g of result.games) {
|
||||||
|
liveMap.set(g[0], { players: g[2], limit: g[3] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ordered = [gameID, ...popupCandidates.filter(c => c.gameID !== gameID).map(c => c.gameID)];
|
||||||
|
for (const id of ordered) {
|
||||||
|
const live = liveMap.get(id);
|
||||||
|
if (live && live.players < live.limit) {
|
||||||
|
dismissPopup();
|
||||||
|
window.location.href = `https://krunker.io/?game=${id}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dismissPopup();
|
||||||
|
if (openServerBrowser && typeof (window as any).openServerWindow === 'function') {
|
||||||
|
(window as any).openServerWindow(0);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
dismissPopup();
|
||||||
|
window.location.href = `https://krunker.io/?game=${gameID}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissPopup(): void {
|
||||||
|
document.removeEventListener('keydown', handleSearchBind, true);
|
||||||
|
document.removeEventListener('keydown', handleMatchmakerBind, true);
|
||||||
|
if (popupElement.parentNode) popupElement.remove();
|
||||||
|
popupElement.classList.remove('searching');
|
||||||
|
}
|
||||||
|
|
||||||
|
function decideMatchmakerDecision(accept: boolean): void {
|
||||||
|
const w = window as any;
|
||||||
|
if (typeof w.playSelect === 'function') w.playSelect();
|
||||||
|
|
||||||
|
if (accept && popupGameID !== 'none') {
|
||||||
|
verifyAndJoin(popupGameID);
|
||||||
|
} else {
|
||||||
|
dismissPopup();
|
||||||
|
if (popupGameID === 'none' && openServerBrowser && typeof w.openServerWindow === 'function') {
|
||||||
|
w.openServerWindow(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearchBind(event: KeyboardEvent): void {
|
||||||
|
if (document.pointerLockElement) return;
|
||||||
|
if (matchesKey(cancelKey, event)) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
abortSearch();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMatchmakerBind(event: KeyboardEvent): void {
|
function handleMatchmakerBind(event: KeyboardEvent): void {
|
||||||
if (document.pointerLockElement) return;
|
if (document.pointerLockElement) return;
|
||||||
const isAccept = matchesKey(confirmKey, event);
|
const isAccept = matchesKey(confirmKey, event);
|
||||||
const isCancel = matchesKey(cancelKey, event);
|
const isCancel = matchesKey(cancelKey, event);
|
||||||
if (isAccept || isCancel) {
|
if (isAccept || isCancel) {
|
||||||
|
document.removeEventListener('keydown', handleMatchmakerBind, true);
|
||||||
|
decideMatchmakerDecision(isAccept);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showResultPopup(game: MatchmakerGame): void {
|
||||||
|
popupElement.classList.remove('searching');
|
||||||
|
const mapIdx = MAP_ICON_INDICES.indexOf(game.map);
|
||||||
|
popupElement.style.backgroundImage = `url(https://assets.krunker.io/img/maps/map_${mapIdx >= 0 ? mapIdx : 0}.png)`;
|
||||||
|
|
||||||
|
popupGameID = game.gameID;
|
||||||
|
if (game.gameID === 'none') {
|
||||||
|
popupTitle.innerText = 'No Games Found...';
|
||||||
|
popupDescription.innerHTML = 'Check the server browser to see other lobbies.';
|
||||||
|
popupConfirmBtn.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
popupTitle.innerText = 'Game Found!';
|
||||||
|
const regionName = MATCHMAKER_REGION_NAMES[game.region] ?? 'Unknown Region';
|
||||||
|
popupDescription.innerHTML = `${game.gamemode} on ${game.map} (${regionName})<br/>${game.playerCount}/${game.playerLimit} Players, ${secondsToTimestring(game.remainingTime)} Left`;
|
||||||
|
popupConfirmBtn.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-trigger slide animation
|
||||||
|
popupElement.style.animation = 'none';
|
||||||
|
void popupElement.offsetWidth;
|
||||||
|
popupElement.style.animation = '';
|
||||||
|
|
||||||
|
document.removeEventListener('keydown', handleSearchBind, true);
|
||||||
|
document.addEventListener('keydown', handleMatchmakerBind, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSearchPopup(): void {
|
||||||
|
searchAborted = false;
|
||||||
|
popupElement.classList.add('searching');
|
||||||
|
popupElement.style.backgroundImage = 'none';
|
||||||
|
searchStatus.textContent = 'Connecting...';
|
||||||
|
searchFeed.innerHTML = '';
|
||||||
|
searchCounter.textContent = '';
|
||||||
|
|
||||||
document.removeEventListener('keydown', handleMatchmakerBind, true);
|
document.removeEventListener('keydown', handleMatchmakerBind, true);
|
||||||
decideMatchmakerDecision(isAccept);
|
document.addEventListener('keydown', handleSearchBind, true);
|
||||||
}
|
|
||||||
|
const uiBase = document.getElementById('uiBase');
|
||||||
|
if (uiBase) uiBase.appendChild(popupElement);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createFetchedGamePopup(game: MatchmakerGame): void {
|
function createFeedEntry(lobby: RawLobby): HTMLDivElement {
|
||||||
const popup = getPopup();
|
const entry = document.createElement('div');
|
||||||
const mapIdx = MAP_ICON_INDICES.indexOf(game.map);
|
entry.className = `mm-feed-entry ${lobby.passesFilter ? 'mm-pass' : 'mm-fail'}`;
|
||||||
popup.element.style.backgroundImage = `url(https://assets.krunker.io/img/maps/map_${mapIdx >= 0 ? mapIdx : 0}.png)`;
|
|
||||||
|
|
||||||
popupGameID = game.gameID;
|
const region = document.createElement('span');
|
||||||
if (game.gameID === 'none') {
|
region.className = 'mm-feed-region';
|
||||||
popup.title.textContent = 'No Games Found...';
|
region.textContent = lobby.region;
|
||||||
popup.description.textContent = 'Check the server browser to see other lobbies.';
|
|
||||||
popup.confirmBtn.style.display = 'none';
|
|
||||||
} else {
|
|
||||||
popup.title.textContent = 'Game Found!';
|
|
||||||
const regionName = MATCHMAKER_REGION_NAMES[game.region] ?? 'Unknown Region';
|
|
||||||
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 map = document.createElement('span');
|
||||||
const uiBase = document.getElementById('uiBase');
|
map.className = 'mm-feed-map';
|
||||||
if (uiBase) uiBase.appendChild(popup.element);
|
map.textContent = lobby.map;
|
||||||
|
|
||||||
|
const players = document.createElement('span');
|
||||||
|
players.className = 'mm-feed-players';
|
||||||
|
players.textContent = `${lobby.playerCount}/${lobby.playerLimit}`;
|
||||||
|
|
||||||
|
entry.appendChild(region);
|
||||||
|
entry.appendChild(map);
|
||||||
|
entry.appendChild(players);
|
||||||
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchAndFilterGames(mmConfig: MatchmakerConfig): Promise<MatchmakerGame[]> {
|
async function animateLobbyScan(lobbies: RawLobby[]): Promise<void> {
|
||||||
const response = await fetch(`https://matchmaker.krunker.io/game-list?hostname=${window.location.hostname}`);
|
if (lobbies.length === 0) return;
|
||||||
const result = await response.json();
|
|
||||||
const games: MatchmakerGame[] = [];
|
|
||||||
|
|
||||||
for (const game of result.games) {
|
searchStatus.textContent = 'Scanning lobbies...';
|
||||||
const gameID: string = game[0];
|
const total = lobbies.length;
|
||||||
const region = gameID.split(':')[0];
|
|
||||||
const playerCount: number = game[2];
|
|
||||||
const playerLimit: number = game[3];
|
|
||||||
const map: string = game[4].i;
|
|
||||||
const gamemode = MATCHMAKER_GAMEMODES[game[4].g] ?? 'Unknown Gamemode';
|
|
||||||
const remainingTime: number = game[5];
|
|
||||||
|
|
||||||
if (mmConfig.regions.length > 0 && !mmConfig.regions.includes(region)) continue;
|
const maxEntries = Math.floor(MAX_ANIMATION_MS / BASE_TICK_MS);
|
||||||
if (mmConfig.gamemodes.length > 0 && !mmConfig.gamemodes.includes(gamemode)) continue;
|
const step = total > maxEntries ? total / maxEntries : 1;
|
||||||
if (playerCount < mmConfig.minPlayers) continue;
|
const tickMs = total > maxEntries ? BASE_TICK_MS : Math.max(MIN_TICK_MS, Math.min(BASE_TICK_MS, MAX_ANIMATION_MS / total));
|
||||||
if (playerCount > mmConfig.maxPlayers) continue;
|
|
||||||
if (remainingTime < mmConfig.minRemainingTime) continue;
|
|
||||||
if (playerCount === playerLimit) continue;
|
|
||||||
if (window.location.href.includes(gameID)) continue;
|
|
||||||
|
|
||||||
games.push({ gameID, region, playerCount, playerLimit, map, gamemode, remainingTime });
|
for (let f = 0; f < total; f += step) {
|
||||||
}
|
if (searchAborted) return;
|
||||||
|
const i = Math.min(Math.floor(f), total - 1);
|
||||||
|
|
||||||
return games;
|
const entry = createFeedEntry(lobbies[i]);
|
||||||
|
searchFeed.appendChild(entry);
|
||||||
|
|
||||||
|
while (searchFeed.children.length > MAX_FEED_ENTRIES) {
|
||||||
|
searchFeed.removeChild(searchFeed.firstChild!);
|
||||||
|
}
|
||||||
|
|
||||||
|
searchCounter.textContent = `Checked: ${i + 1} / ${total} lobbies`;
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r, tickMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
searchCounter.textContent = `Checked: ${total} / ${total} lobbies`;
|
||||||
|
|
||||||
|
if (!searchAborted) {
|
||||||
|
await new Promise(r => setTimeout(r, POST_SCAN_PAUSE_MS));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAllGames(mmConfig: MatchmakerConfig): Promise<{ all: RawLobby[]; filtered: MatchmakerGame[] }> {
|
||||||
|
const response = await fetch(`https://matchmaker.krunker.io/game-list?hostname=${window.location.hostname}`);
|
||||||
|
const result = await response.json();
|
||||||
|
const all: RawLobby[] = [];
|
||||||
|
const filtered: MatchmakerGame[] = [];
|
||||||
|
|
||||||
|
for (const game of result.games) {
|
||||||
|
const gameID: string = game[0];
|
||||||
|
const region = gameID.split(':')[0];
|
||||||
|
const playerCount: number = game[2];
|
||||||
|
const playerLimit: number = game[3];
|
||||||
|
const map: string = game[4].i;
|
||||||
|
const gamemode = MATCHMAKER_GAMEMODES[game[4].g] ?? 'Unknown Gamemode';
|
||||||
|
const remainingTime: number = game[5];
|
||||||
|
|
||||||
|
let passesFilter = true;
|
||||||
|
if (mmConfig.regions.length > 0 && !mmConfig.regions.includes(region)) passesFilter = false;
|
||||||
|
else if (mmConfig.gamemodes.length > 0 && !mmConfig.gamemodes.includes(gamemode)) passesFilter = false;
|
||||||
|
else if (mmConfig.maps.length > 0 && !mmConfig.maps.includes(map)) passesFilter = false;
|
||||||
|
else if (playerCount < mmConfig.minPlayers) passesFilter = false;
|
||||||
|
else if (playerCount > mmConfig.maxPlayers) passesFilter = false;
|
||||||
|
else if (remainingTime < mmConfig.minRemainingTime) passesFilter = false;
|
||||||
|
else if (playerCount === playerLimit) passesFilter = false;
|
||||||
|
else if (window.location.href.includes(gameID)) passesFilter = false;
|
||||||
|
|
||||||
|
const lobby = { gameID, region, playerCount, playerLimit, map, gamemode, remainingTime, passesFilter };
|
||||||
|
all.push(lobby);
|
||||||
|
if (passesFilter) filtered.push(lobby);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { all, filtered };
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortByPingThenPlayers(games: MatchmakerGame[], pings: Record<string, number>): MatchmakerGame[] {
|
function sortByPingThenPlayers(games: MatchmakerGame[], pings: Record<string, number>): MatchmakerGame[] {
|
||||||
return games.sort((a, b) => {
|
return games.sort((a, b) => {
|
||||||
const pingA = pings[a.region] ?? 999;
|
const pingA = pings[a.region] ?? 999;
|
||||||
const pingB = pings[b.region] ?? 999;
|
const pingB = pings[b.region] ?? 999;
|
||||||
if (pingA !== pingB) return pingA - pingB;
|
if (pingA !== pingB) return pingA - pingB;
|
||||||
return b.playerCount - a.playerCount;
|
return b.playerCount - a.playerCount;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchGame(mmConfig: MatchmakerConfig, _con?: SavedConsole): Promise<void> {
|
export async function fetchGame(mmConfig: MatchmakerConfig, _con?: SavedConsole): Promise<void> {
|
||||||
openServerBrowser = mmConfig.openServerBrowser;
|
openServerBrowser = mmConfig.openServerBrowser;
|
||||||
confirmKey = mmConfig.acceptKey;
|
confirmKey = mmConfig.acceptKey;
|
||||||
cancelKey = mmConfig.cancelKey;
|
cancelKey = mmConfig.cancelKey;
|
||||||
|
|
||||||
// Dismiss existing popup if active
|
// Dismiss existing popup if active (also aborts in-flight search)
|
||||||
if (document.getElementById(POPUP_ID)) decideMatchmakerDecision(false);
|
searchAborted = true;
|
||||||
|
dismissPopup();
|
||||||
|
|
||||||
_con?.log('[KCC-MM] Fetching game list + pings...');
|
// Phase 1: Show search popup immediately
|
||||||
|
showSearchPopup();
|
||||||
|
_con?.log('[KCC-MM] Fetching game list + pings...');
|
||||||
|
|
||||||
const [games, pings] = await Promise.all([
|
// Phase 2: Fetch data
|
||||||
fetchAndFilterGames(mmConfig),
|
let allLobbies: RawLobby[];
|
||||||
ipcRenderer.invoke('ping-regions').catch(() => ({} as Record<string, number>)),
|
let filtered: MatchmakerGame[];
|
||||||
]);
|
let pings: Record<string, number>;
|
||||||
|
try {
|
||||||
_con?.log('[KCC-MM]', games.length, 'games passed filters, pings:', pings);
|
const [fetchResult, pingResult] = await Promise.all([
|
||||||
|
fetchAllGames(mmConfig),
|
||||||
if (games.length > 0) {
|
ipcRenderer.invoke('ping-regions').catch(() => ({} as Record<string, number>)),
|
||||||
sortByPingThenPlayers(games, pings);
|
]);
|
||||||
const best = games[0];
|
allLobbies = fetchResult.all;
|
||||||
_con?.log('[KCC-MM] Best match:', best.gameID, best.region, best.map, `(${pings[best.region] ?? '?'}ms)`);
|
filtered = fetchResult.filtered;
|
||||||
|
pings = pingResult;
|
||||||
if (mmConfig.autoJoin) {
|
} catch {
|
||||||
window.location.href = `https://krunker.io/?game=${best.gameID}`;
|
if (!searchAborted) {
|
||||||
return;
|
searchStatus.textContent = 'Failed to fetch lobbies';
|
||||||
|
await new Promise(r => setTimeout(r, 2000));
|
||||||
|
dismissPopup();
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
createFetchedGamePopup(best);
|
if (searchAborted) return;
|
||||||
} else {
|
|
||||||
_con?.log('[KCC-MM] No matching games found');
|
|
||||||
|
|
||||||
if (mmConfig.autoJoin) {
|
_con?.log('[KCC-MM]', filtered.length, '/', allLobbies.length, 'games passed filters');
|
||||||
if (openServerBrowser && typeof (window as any).openServerWindow === 'function') {
|
|
||||||
(window as any).openServerWindow(0);
|
// Sort immediately — result is ready
|
||||||
}
|
if (filtered.length > 0) sortByPingThenPlayers(filtered, pings);
|
||||||
return;
|
popupCandidates = filtered;
|
||||||
|
|
||||||
|
// Fire animation in background (non-blocking eye candy)
|
||||||
|
animateLobbyScan(allLobbies);
|
||||||
|
|
||||||
|
// Brief visual flash of the feed before showing result
|
||||||
|
await new Promise(r => setTimeout(r, SCAN_FLASH_MS));
|
||||||
|
if (searchAborted) return;
|
||||||
|
|
||||||
|
// Phase 3: Show result
|
||||||
|
if (filtered.length > 0) {
|
||||||
|
// Pick randomly from the top tier of comparable matches for variety
|
||||||
|
const top = filtered[0];
|
||||||
|
const topPing = pings[top.region] ?? 999;
|
||||||
|
const pool = filtered.filter(g => {
|
||||||
|
const gPing = pings[g.region] ?? 999;
|
||||||
|
return Math.abs(gPing - topPing) <= 20
|
||||||
|
&& top.playerCount - g.playerCount <= 2;
|
||||||
|
});
|
||||||
|
const best = pool[Math.floor(Math.random() * pool.length)];
|
||||||
|
_con?.log('[KCC-MM] Best match:', best.gameID, best.region, best.map, `(${pings[best.region] ?? '?'}ms, pool: ${pool.length})`);
|
||||||
|
|
||||||
|
if (mmConfig.autoJoin) {
|
||||||
|
// Brief "Lobby Found!" flash before joining
|
||||||
|
const regionName = MATCHMAKER_REGION_NAMES[best.region] ?? best.region;
|
||||||
|
searchStatus.textContent = 'Lobby Found!';
|
||||||
|
searchFeed.innerHTML = '';
|
||||||
|
const found = document.createElement('div');
|
||||||
|
found.className = 'mm-feed-entry mm-pass';
|
||||||
|
found.style.cssText = 'font-size:1.1em;justify-content:center;';
|
||||||
|
found.innerHTML =
|
||||||
|
`<span class="mm-feed-region">${best.region}</span>` +
|
||||||
|
`<span class="mm-feed-map">${best.map}</span>` +
|
||||||
|
`<span class="mm-feed-players">${best.playerCount}/${best.playerLimit}</span>`;
|
||||||
|
searchFeed.appendChild(found);
|
||||||
|
searchCounter.textContent = `${best.gamemode} \u00B7 ${regionName} \u00B7 ${pings[best.region] ?? '?'}ms`;
|
||||||
|
await new Promise(r => setTimeout(r, 1200));
|
||||||
|
await verifyAndJoin(best.gameID);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showResultPopup(best);
|
||||||
|
} else {
|
||||||
|
_con?.log('[KCC-MM] No matching games found');
|
||||||
|
|
||||||
|
if (mmConfig.autoJoin) {
|
||||||
|
dismissPopup();
|
||||||
|
if (openServerBrowser && typeof (window as any).openServerWindow === 'function') {
|
||||||
|
(window as any).openServerWindow(0);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showResultPopup({
|
||||||
|
gameID: 'none',
|
||||||
|
region: 'none',
|
||||||
|
playerCount: 0,
|
||||||
|
playerLimit: 0,
|
||||||
|
map: MAP_ICON_INDICES[0],
|
||||||
|
gamemode: MATCHMAKER_GAMEMODES[0],
|
||||||
|
remainingTime: 0,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
createFetchedGamePopup({
|
|
||||||
gameID: 'none',
|
|
||||||
region: 'none',
|
|
||||||
playerCount: 0,
|
|
||||||
playerLimit: 0,
|
|
||||||
map: MAP_ICON_INDICES[0],
|
|
||||||
gamemode: MATCHMAKER_GAMEMODES[0],
|
|
||||||
remainingTime: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,3 +66,102 @@ export function setDeathAnimBlock(enabled: boolean): void {
|
|||||||
el.remove();
|
el.remove();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Cleaner Menu ──
|
||||||
|
// Hides clutter from the main menu for a streamlined look.
|
||||||
|
|
||||||
|
const CLEANER_MENU_ID = 'kpc-cleanerMenu';
|
||||||
|
const CLEANER_MENU_CSS = `
|
||||||
|
*::-webkit-scrollbar { display: none !important; }
|
||||||
|
.settingsBtn[style*="width:auto;background-color:#994cd1"] { display: none !important; }
|
||||||
|
.setSugBox2 { display: none !important; }
|
||||||
|
.advancedSwitch { display: none !important; }
|
||||||
|
.menuSocialB { display: none !important; }
|
||||||
|
.serverHostOpH { display: none !important; }
|
||||||
|
.signup-rewards-container { display: none !important; }
|
||||||
|
#tlInfHold { display: none !important; }
|
||||||
|
#gameNameHolder { display: none !important; }
|
||||||
|
#termsInfo { display: none !important; }
|
||||||
|
#bubbleContainer { display: none !important; }
|
||||||
|
#instructions:only-child { display: none !important; }
|
||||||
|
#mapInfoHld { display: none !important; }
|
||||||
|
#krDiscountAd { display: none !important; }
|
||||||
|
#classPreviewCanvas { display: none !important; }
|
||||||
|
#menuClassSubtext { display: none !important; }
|
||||||
|
#settingsPreset { display: none !important; }
|
||||||
|
#menuClassName { display: none !important; }
|
||||||
|
#menuBtnQuickMatch { display: none !important; }
|
||||||
|
#menuClassIcn { display: none !important; }
|
||||||
|
#streamContainerNew { display: none !important; }
|
||||||
|
#editorBtnM { display: none !important; }
|
||||||
|
.verticalSeparator { visibility: hidden !important; }
|
||||||
|
#mLevelCont { background-color: transparent; }
|
||||||
|
#uiBase.onMenu #spectButton { top: 94% !important; }
|
||||||
|
.headerBarL, .headerBar, .menuBtnHL { background-color: transparent; }
|
||||||
|
.headerBarR { right: -23px !important; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ── Menu Timer ──
|
||||||
|
// Shows the native spectate/game timer prominently on the menu screen.
|
||||||
|
// CSS approach from crankshaft/glorp.
|
||||||
|
|
||||||
|
const MENU_TIMER_ID = 'kpc-menuTimer';
|
||||||
|
const MENU_TIMER_CSS = `
|
||||||
|
#uiBase.onMenu #spectateUI { display: block !important; }
|
||||||
|
#uiBase.onCompMenu.onMenu #specTimer,
|
||||||
|
#uiBase.onMenu #specGMessage,
|
||||||
|
#uiBase.onMenu #spec1,
|
||||||
|
#uiBase.onMenu #specGameInfo,
|
||||||
|
#uiBase.onMenu #spec0,
|
||||||
|
#uiBase.onMenu #specControlHolder,
|
||||||
|
#uiBase.onMenu #specNames { display: none !important; }
|
||||||
|
#uiBase.onMenu #spectateHUD {
|
||||||
|
box-sizing: border-box; display: flex !important; justify-content: center;
|
||||||
|
height: 0.5rem; white-space: nowrap; width: max-content;
|
||||||
|
position: fixed; top: calc(50% + 140px);
|
||||||
|
}
|
||||||
|
#uiBase.onMenu #spectateHUD #specGMessage { top: 0; }
|
||||||
|
#uiBase.onMenu #spectateUI > #spectateHUD { z-index: 1; transform: unset; }
|
||||||
|
#uiBase.onMenu .spectateInfo {
|
||||||
|
position: fixed; top: calc(50% + 80px); left: 50%; transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
#uiBase.onMenu #spectateUI div .spectateInfo #specTimer {
|
||||||
|
background-color: transparent; padding: 25px; font-size: 42px; border-radius: 0.5em;
|
||||||
|
}
|
||||||
|
#uiBase.onMenu #specKPDContr { display: none; }
|
||||||
|
#uiBase.onMenu #spectateUI div#specStats {
|
||||||
|
position: absolute; top: calc(50% + 13em); left: 50%; transform: translateX(-50%); z-index: 1;
|
||||||
|
}
|
||||||
|
#uiBase.onMenu #spectateUI div#specStats:before {
|
||||||
|
content: "Spectating"; position: absolute; bottom: 100%; left: 50%;
|
||||||
|
transform: translateX(-50%); font-size: 1.2em; padding-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export function setMenuTimer(enabled: boolean): void {
|
||||||
|
let el = document.getElementById(MENU_TIMER_ID);
|
||||||
|
if (enabled) {
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement('style');
|
||||||
|
el.id = MENU_TIMER_ID;
|
||||||
|
el.textContent = MENU_TIMER_CSS;
|
||||||
|
document.head.appendChild(el);
|
||||||
|
}
|
||||||
|
} else if (el) {
|
||||||
|
el.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCleanerMenu(enabled: boolean): void {
|
||||||
|
let el = document.getElementById(CLEANER_MENU_ID);
|
||||||
|
if (enabled) {
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement('style');
|
||||||
|
el.id = CLEANER_MENU_ID;
|
||||||
|
el.textContent = CLEANER_MENU_CSS;
|
||||||
|
document.head.appendChild(el);
|
||||||
|
}
|
||||||
|
} else if (el) {
|
||||||
|
el.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user