Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f3a74ddb4 | |||
| 93775cc36a | |||
| 467ac95b4e | |||
| 1eabea195a | |||
| b8bfa2941c | |||
| 819caea65a | |||
| 96e0cbfc07 | |||
| ceb8f73a2a | |||
| 1568c74cac | |||
| 21684c5fbd | |||
| 955d715373 | |||
| 87ddf1499d |
@@ -0,0 +1,138 @@
|
||||
name: Build and Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build-and-release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check if version already released
|
||||
id: version-check
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.DEST_GITEA_TOKEN }}
|
||||
run: |
|
||||
VERSION=$(grep '"version"' package.json | head -1 | sed 's/.*"version": *"\([^"]*\)".*/\1/')
|
||||
TAG="v$VERSION"
|
||||
echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "TAG=$TAG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
"https://gitea.crjlab.net/api/v1/repos/bigjakk/krunker-civilian-client/releases/tags/$TAG" \
|
||||
-H "Authorization: token $GITEA_TOKEN")
|
||||
|
||||
if [ "$STATUS" = "200" ]; then
|
||||
echo "Release $TAG already exists, skipping build"
|
||||
echo "SKIP=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "No release for $TAG, proceeding with build"
|
||||
echo "SKIP=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.version-check.outputs.SKIP == 'false'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install system dependencies
|
||||
if: steps.version-check.outputs.SKIP == 'false'
|
||||
run: |
|
||||
dpkg --add-architecture i386
|
||||
apt-get update -qq
|
||||
apt-get install -y --no-install-recommends \
|
||||
wine wine32 wine64 \
|
||||
libnss3 libatk1.0-0 libatk-bridge2.0-0 libcups2t64 \
|
||||
libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 \
|
||||
libxfixes3 libxrandr2 libgbm1 libpango-1.0-0 \
|
||||
libcairo2 libasound2t64 libgtk-3-0
|
||||
WINEDEBUG=-all wine wineboot --init || true
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.version-check.outputs.SKIP == 'false'
|
||||
run: npm ci
|
||||
|
||||
- name: Build source
|
||||
if: steps.version-check.outputs.SKIP == 'false'
|
||||
run: npm run build
|
||||
|
||||
- name: Build Windows distributables
|
||||
if: steps.version-check.outputs.SKIP == 'false'
|
||||
env:
|
||||
WINEDEBUG: "-all"
|
||||
run: npx electron-builder --win -c.electronDist=node_modules/electron/dist-win --publish never
|
||||
|
||||
- name: Build Linux distributables
|
||||
if: steps.version-check.outputs.SKIP == 'false'
|
||||
run: npx electron-builder --linux --publish never
|
||||
|
||||
- name: Report build sizes
|
||||
if: steps.version-check.outputs.SKIP == 'false'
|
||||
run: |
|
||||
echo "=== Build output sizes ==="
|
||||
ls -lh out/*.exe out/*.AppImage out/*.deb 2>/dev/null || true
|
||||
echo "=== Electron dist-win (patched Windows) ==="
|
||||
du -sh node_modules/electron/dist-win/ 2>/dev/null || true
|
||||
echo "=== Electron dist (stock Linux) ==="
|
||||
du -sh node_modules/electron/dist/ 2>/dev/null || true
|
||||
echo "=== Unpacked Windows build ==="
|
||||
du -sh out/win-unpacked/ 2>/dev/null || true
|
||||
du -sh out/win-unpacked/resources/ 2>/dev/null || true
|
||||
du -sh out/win-unpacked/locales/ 2>/dev/null || true
|
||||
|
||||
- name: Create release and upload assets
|
||||
if: steps.version-check.outputs.SKIP == 'false'
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.DEST_GITEA_TOKEN }}
|
||||
TAG: ${{ steps.version-check.outputs.TAG }}
|
||||
run: |
|
||||
GITEA_BASE="https://gitea.crjlab.net"
|
||||
REPO="bigjakk/krunker-civilian-client"
|
||||
|
||||
# Create tag
|
||||
curl -s -X POST "$GITEA_BASE/api/v1/repos/$REPO/tags" \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\": \"$TAG\", \"message\": \"$TAG\", \"target\": \"$GITHUB_SHA\"}"
|
||||
|
||||
# Create release
|
||||
RESPONSE=$(curl -s -X POST "$GITEA_BASE/api/v1/repos/$REPO/releases" \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"tag_name\": \"$TAG\",
|
||||
\"name\": \"$TAG\",
|
||||
\"body\": \"Automated build for $TAG\",
|
||||
\"draft\": false,
|
||||
\"prerelease\": false
|
||||
}")
|
||||
|
||||
RELEASE_ID=$(echo "$RESPONSE" | jq -r '.id')
|
||||
echo "Created release ID: $RELEASE_ID"
|
||||
|
||||
if [ "$RELEASE_ID" = "null" ] || [ -z "$RELEASE_ID" ]; then
|
||||
echo "Failed to create release:"
|
||||
echo "$RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Upload all built artifacts
|
||||
for file in out/*.exe out/*.AppImage out/*.deb; do
|
||||
[ -f "$file" ] || continue
|
||||
FILENAME=$(basename "$file")
|
||||
SAFE_NAME=$(echo "$FILENAME" | tr ' ' '_')
|
||||
echo "Uploading: $SAFE_NAME ($(du -h "$file" | cut -f1))"
|
||||
|
||||
curl -s -X POST \
|
||||
"$GITEA_BASE/api/v1/repos/$REPO/releases/$RELEASE_ID/assets?name=$SAFE_NAME" \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-F "attachment=@$file" \
|
||||
| jq -r '" -> \(.name) (\(.size) bytes)"'
|
||||
done
|
||||
|
||||
echo "All assets uploaded"
|
||||
@@ -1,269 +0,0 @@
|
||||
name: Build and Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_type:
|
||||
description: 'Override release type (auto-detected by default)'
|
||||
required: false
|
||||
default: 'auto'
|
||||
type: choice
|
||||
options:
|
||||
- auto
|
||||
- patch
|
||||
- full
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build-and-release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- 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-Test/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: Detect release type
|
||||
if: steps.version-check.outputs.SKIP == 'false'
|
||||
id: release-type
|
||||
run: |
|
||||
OVERRIDE="${{ github.event.inputs.release_type || 'auto' }}"
|
||||
|
||||
if [ "$OVERRIDE" != "auto" ]; then
|
||||
echo "Release type overridden to: $OVERRIDE"
|
||||
echo "TYPE=$OVERRIDE" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Auto-detect: find what changed since the last release tag
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || true)
|
||||
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
echo "No previous tag found — defaulting to full build"
|
||||
echo "TYPE=full" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Comparing changes since $PREV_TAG"
|
||||
CHANGED=$(git diff --name-only "$PREV_TAG"..HEAD)
|
||||
echo "$CHANGED"
|
||||
|
||||
# Files that require a full (installer) build
|
||||
FULL_TRIGGERS="electron-builder.yml|scripts/download-electron.js"
|
||||
|
||||
if echo "$CHANGED" | grep -qE "^($FULL_TRIGGERS)$"; then
|
||||
echo "Full build trigger detected in changed files"
|
||||
echo "TYPE=full" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check if package.json deps changed (not just version field)
|
||||
if echo "$CHANGED" | grep -q "^package.json$"; then
|
||||
DEP_DIFF=$(git diff "$PREV_TAG"..HEAD -- package.json \
|
||||
| grep -E '^\+' | grep -vE '^\+\+\+' \
|
||||
| grep -E '"(dependencies|devDependencies|electron|electron-nightly)"' || true)
|
||||
if [ -n "$DEP_DIFF" ]; then
|
||||
echo "Dependency changes detected in package.json"
|
||||
echo "TYPE=full" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Only source changes detected — patch release"
|
||||
echo "TYPE=patch" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.version-check.outputs.SKIP == 'false'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
# Full build needs wine + system deps for electron-builder
|
||||
- name: Install system dependencies
|
||||
if: steps.version-check.outputs.SKIP == 'false' && steps.release-type.outputs.TYPE == 'full'
|
||||
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
|
||||
|
||||
# ── Patch release: asar only ──
|
||||
|
||||
- name: Build asar
|
||||
if: steps.version-check.outputs.SKIP == 'false' && steps.release-type.outputs.TYPE == 'patch'
|
||||
run: npm run build:asar
|
||||
|
||||
# ── Full release: installer + asar ──
|
||||
|
||||
- name: Build source
|
||||
if: steps.version-check.outputs.SKIP == 'false' && steps.release-type.outputs.TYPE == 'full'
|
||||
run: npm run build
|
||||
|
||||
- name: Build Windows distributables
|
||||
if: steps.version-check.outputs.SKIP == 'false' && steps.release-type.outputs.TYPE == 'full'
|
||||
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' && steps.release-type.outputs.TYPE == 'full'
|
||||
run: npx electron-builder --linux -c.electronDist=node_modules/electron/dist-linux --publish never
|
||||
|
||||
- name: Build asar for full release
|
||||
if: steps.version-check.outputs.SKIP == 'false' && steps.release-type.outputs.TYPE == 'full'
|
||||
run: npm run build:asar
|
||||
|
||||
- name: Report build sizes
|
||||
if: steps.version-check.outputs.SKIP == 'false'
|
||||
run: |
|
||||
echo "=== Release type: ${{ steps.release-type.outputs.TYPE }} ==="
|
||||
if [ "${{ steps.release-type.outputs.TYPE }}" = "full" ]; then
|
||||
ls -lh out/*.exe out/*.AppImage out/*.deb 2>/dev/null || true
|
||||
fi
|
||||
ls -lh out/asar/* 2>/dev/null || true
|
||||
|
||||
- name: Generate release notes
|
||||
if: steps.version-check.outputs.SKIP == 'false'
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.DEST_GITEA_TOKEN }}
|
||||
run: |
|
||||
chmod +x scripts/generate-release-notes.sh
|
||||
|
||||
PREV_SHA=$(curl -s "https://gitea.crjlab.net/api/v1/repos/bigjakk/Krunker-Civilian-Client-Test/releases?limit=1" \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
| jq -r '.[0].target_commitish // empty' 2>/dev/null || true)
|
||||
|
||||
echo "Previous release SHA: ${PREV_SHA:-none}"
|
||||
scripts/generate-release-notes.sh "${{ steps.version-check.outputs.TAG }}" "$PREV_SHA" > /tmp/release-notes.md
|
||||
echo "--- Generated release notes ---"
|
||||
cat /tmp/release-notes.md
|
||||
|
||||
- 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 }}
|
||||
RELEASE_TYPE: ${{ steps.release-type.outputs.TYPE }}
|
||||
run: |
|
||||
GITEA_BASE="https://gitea.crjlab.net"
|
||||
REPO="bigjakk/Krunker-Civilian-Client-Test"
|
||||
|
||||
# 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\"}"
|
||||
|
||||
# Read release notes
|
||||
NOTES=$(cat /tmp/release-notes.md)
|
||||
BODY=$(echo "$NOTES" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read()))')
|
||||
|
||||
# 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\": $BODY,
|
||||
\"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 asar + checksums (both patch and full releases)
|
||||
for file in out/asar/app.asar out/asar/checksums.sha256; do
|
||||
[ -f "$file" ] || continue
|
||||
FILENAME=$(basename "$file")
|
||||
echo "Uploading: $FILENAME ($(du -h "$file" | cut -f1))"
|
||||
curl -s -X POST \
|
||||
"$GITEA_BASE/api/v1/repos/$REPO/releases/$RELEASE_ID/assets?name=$FILENAME" \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-F "attachment=@$file" \
|
||||
| jq -r '" -> \(.name) (\(.size) bytes)"'
|
||||
done
|
||||
|
||||
# Upload installer artifacts (full release only)
|
||||
if [ "$RELEASE_TYPE" = "full" ]; then
|
||||
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
|
||||
fi
|
||||
|
||||
echo "All assets uploaded (release type: $RELEASE_TYPE)"
|
||||
|
||||
- name: Prune old releases
|
||||
if: steps.version-check.outputs.SKIP == 'false'
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.DEST_GITEA_TOKEN }}
|
||||
KEEP: 5
|
||||
run: |
|
||||
GITEA_BASE="https://gitea.crjlab.net"
|
||||
REPO="bigjakk/Krunker-Civilian-Client-Test"
|
||||
echo "=== Pruning $REPO (keeping last $KEEP) ==="
|
||||
RELEASES=$(curl -s "$GITEA_BASE/api/v1/repos/$REPO/releases?limit=50" \
|
||||
-H "Authorization: token $GITEA_TOKEN")
|
||||
|
||||
DELETE_IDS=$(echo "$RELEASES" | jq -r "sort_by(.created_at) | reverse | .[$KEEP:][] | .id")
|
||||
|
||||
for ID in $DELETE_IDS; do
|
||||
TAG_NAME=$(echo "$RELEASES" | jq -r ".[] | select(.id == $ID) | .tag_name")
|
||||
echo "Deleting release $TAG_NAME (id=$ID)"
|
||||
curl -s -X DELETE "$GITEA_BASE/api/v1/repos/$REPO/releases/$ID" \
|
||||
-H "Authorization: token $GITEA_TOKEN"
|
||||
curl -s -X DELETE "$GITEA_BASE/api/v1/repos/$REPO/tags/$TAG_NAME" \
|
||||
-H "Authorization: token $GITEA_TOKEN"
|
||||
done
|
||||
echo "Prune complete"
|
||||
+1
-1
@@ -1 +1 @@
|
||||
npx lint-staged
|
||||
npm run lint
|
||||
|
||||
@@ -1,674 +0,0 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
@@ -1,95 +1,295 @@
|
||||
# Krunker Civilian Client
|
||||
|
||||
[](https://github.com/bigjakk/Krunker-Civilian-Client/releases)
|
||||
[](https://github.com/bigjakk/Krunker-Civilian-Client/stargazers)
|
||||
[](https://github.com/bigjakk/Krunker-Civilian-Client/releases/latest)
|
||||
[](https://github.com/bigjakk/Krunker-Civilian-Client/blob/main/LICENSE)
|
||||
|
||||
> a high-performance krunker client with unlimited FPS, built on a custom-patched Electron
|
||||
|
||||
**Download:**
|
||||
[Windows (x64)](https://github.com/bigjakk/Krunker-Civilian-Client/releases/latest) -
|
||||
[Linux (AppImage)](https://github.com/bigjakk/Krunker-Civilian-Client/releases/latest)
|
||||
|
||||
## Features
|
||||
|
||||
- unlimited FPS with no aim freeze (custom Electron build, see [below](#custom-electron-build))
|
||||
- unobtrusive — nearly all features can be disabled, no watermarks
|
||||
- hides ads by default
|
||||
- resource swapper (textures, sounds, models)
|
||||
- CSS theme system with `@import` support (drop `.css` files in `swap/themes/`)
|
||||
- custom loading screen backgrounds (`swap/backgrounds/`)
|
||||
- customisable matchmaker with lobby scan animation
|
||||
- filter by region, gamemode, map, player count, remaining time
|
||||
- auto-join with server capacity verification
|
||||
- external ranked queue (works even when the game is closed)
|
||||
- rank progress tracker with ELO bar and rank distribution popup
|
||||
- tabbed hub/social pages with drag-and-drop reorder
|
||||
- better chat — merged team/all chat with `[T]`/`[M]` prefixes
|
||||
- chat history preservation (Krunker prunes old messages, this prevents it)
|
||||
- real-time chat translator (Google Translate, 15+ languages)
|
||||
- userscript support (Tampermonkey-style metadata, per-script settings)
|
||||
- battle pass claim all button
|
||||
- alt account manager with encrypted credential storage
|
||||
- Discord RPC (gamemode, map, class, spectator status)
|
||||
- raw input / unadjusted movement (Windows)
|
||||
- show numeric ping in player list
|
||||
- double ping display (Krunker shows half the real value)
|
||||
- hardpoint enemy counter HUD
|
||||
- changelog popup on update
|
||||
- configurable keybinds with visual rebinding dialog
|
||||
- configurable ANGLE backend (D3D11, OpenGL, Vulkan, D3D9, D3D11on12)
|
||||
- advanced Chromium flag settings (GPU rasterization, low latency, QUIC, and more)
|
||||
- CPU throttling (game vs menu) and process priority control
|
||||
- auto-updater
|
||||
- maintained & open source (GPL-3.0)
|
||||
|
||||
## Hotkeys
|
||||
|
||||
All hotkeys are rebindable in settings.
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `F4` | New match (triggers matchmaker if enabled) |
|
||||
| `F5` | Reload page |
|
||||
| `F6` | Open matchmaker |
|
||||
| `F10` | Pause chat (freeze auto-scroll) |
|
||||
| `F11` | Toggle fullscreen |
|
||||
| `F12` | DevTools |
|
||||
| `Ctrl+L` | Copy game link |
|
||||
| `Ctrl+J` | Join game from clipboard |
|
||||
| `Ctrl+T` | New tab (hub) |
|
||||
| `Ctrl+W` | Close tab |
|
||||
| `Ctrl+Tab` | Next tab |
|
||||
| `Ctrl+Shift+Tab` | Previous tab |
|
||||
| `Ctrl+Shift+T` | Reopen closed tab |
|
||||
| `Ctrl+1-9` | Jump to tab |
|
||||
|
||||
## Userscripts
|
||||
|
||||
Any `.js` file in the scripts folder will be loaded as a userscript if enabled in settings. Scripts support Tampermonkey-style metadata blocks (`@name`, `@author`, `@version`, `@desc`) and can define custom settings (boolean, number, select, color, keybind).
|
||||
|
||||
> **Use userscripts at your own risk.** Do not write or use any userscripts which would give you a competitive advantage.
|
||||
|
||||
## Custom Electron Build
|
||||
|
||||
This client uses a custom-patched Electron 42 build to overcome the aim freezing issue present in modern Electron versions. The patched binary is downloaded automatically during `npm install`.
|
||||
|
||||
For details on the patch and build instructions, see [Electron-Websocket-Fix](https://github.com/bigjakk/Electron-Websocket-Fix).
|
||||
|
||||
## Building From Source
|
||||
|
||||
1. Install [git](https://git-scm.com/downloads), [Node.js](https://nodejs.org/), and npm
|
||||
2. Clone and install:
|
||||
```bash
|
||||
git clone https://github.com/bigjakk/Krunker-Civilian-Client.git
|
||||
cd Krunker-Civilian-Client
|
||||
npm install
|
||||
```
|
||||
3. Run: `npm start` or `npm run dev` (dev mode with sourcemaps)
|
||||
4. Package: `npm run dist:win` or `npm run dist:linux`
|
||||
|
||||
## Credits
|
||||
|
||||
- [Crankshaft](https://github.com/KraXen72/crankshaft) by KraXen72 - Original inspiration. Settings Layout, Matchmaker
|
||||
- [Glorp](https://github.com/slavcp/glorp) by slav - Numerous features for the newer chromium verisions. External Ranked Queue
|
||||
# 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.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm start # Build all targets + launch Electron
|
||||
```
|
||||
|
||||
For development with sourcemaps:
|
||||
|
||||
```bash
|
||||
npm run dev # Builds in dev mode + launches Electron
|
||||
```
|
||||
|
||||
## Scripts
|
||||
|
||||
| Script | Description |
|
||||
|--------|-------------|
|
||||
| `npm run dev` | Development build + launch |
|
||||
| `npm start` | Full build (main + preload) + launch |
|
||||
| `npm run build` | Build both Vite targets |
|
||||
| `npm run build:main` | Build main process only |
|
||||
| `npm run build:preload` | Build preload script only |
|
||||
| `npm run dist:win` | Build + package for Windows (NSIS installer + portable) |
|
||||
| `npm run dist:linux` | Build + package for Linux (AppImage + deb) |
|
||||
| `npm run dist:all` | Build + package for all platforms |
|
||||
| `npm run clean` | Remove `dist/` and `out/` directories |
|
||||
| `npm run lint` | Run ESLint (typescript-eslint) on `src/` |
|
||||
|
||||
## Architecture
|
||||
|
||||
### Custom Patched Electron 42
|
||||
|
||||
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.
|
||||
|
||||
#### The Patch
|
||||
|
||||
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:
|
||||
|
||||
```diff
|
||||
case MainThreadTaskQueue::QueueTraits::PrioritisationType::kInput:
|
||||
- return TaskPriority::kHighestPriority;
|
||||
+ return TaskPriority::kNormalPriority;
|
||||
```
|
||||
|
||||
Benchmarked via CDP `Input.dispatchMouseEvent`:
|
||||
|
||||
| Metric | Before | After |
|
||||
|--------|--------|-------|
|
||||
| p99 latency | 97ms | 34ms |
|
||||
| Max latency | 308ms | 38ms |
|
||||
| Events >50ms | 8.6% | 0% |
|
||||
| Frames rendered | baseline | +21% |
|
||||
| Mouse events processed | baseline | +9% |
|
||||
|
||||
The full patch file, GN build args, and step-by-step build instructions are in [`electron-build/`](electron-build/BUILD.md).
|
||||
|
||||
#### Binary Distribution
|
||||
|
||||
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.
|
||||
|
||||
FPS uncap flags (always applied when enabled):
|
||||
|
||||
```
|
||||
disable-frame-rate-limit + disable-gpu-vsync + max-gum-fps=9999
|
||||
```
|
||||
|
||||
#### Building from Source
|
||||
|
||||
Prerequisites: Windows 10/11 x64, ~100 GB disk, 16+ GB RAM, Visual Studio 2022 with C++ workload.
|
||||
|
||||
```bash
|
||||
# 1. Install depot_tools and add to PATH
|
||||
git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git
|
||||
|
||||
# 2. Check out Electron source (~40-60 GB)
|
||||
mkdir C:\electron && cd C:\electron
|
||||
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. Apply the patch
|
||||
cd src
|
||||
git apply path/to/electron-build/input-priority-fix.patch
|
||||
|
||||
# 4. Configure and build (~2-4 hours for release)
|
||||
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: 112 KiB After Width: | Height: | Size: 73 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 45 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 534 KiB After Width: | Height: | Size: 1.4 MiB |
@@ -0,0 +1,193 @@
|
||||
# Building Patched Electron 42 (Input Priority Fix)
|
||||
|
||||
This builds a custom Electron with a one-line Chromium patch that fixes input starvation ("aim freeze") when `--disable-frame-rate-limit` is active. Without this patch, uncapped frame rates cause 50-300ms input delays in GPU-intensive applications like browser FPS games.
|
||||
|
||||
## The Problem
|
||||
|
||||
Chromium's main thread scheduler gives input tasks `kHighestPriority`. At uncapped frame rates, the compositor floods the task queue and input events get starved — your mouse movements are delayed by up to 300ms, then snap to catch up. Chromium 87-93 had `ImplLatencyRecovery`/`MainLatencyRecovery` features that mitigated this, but they were removed in Chromium 94.
|
||||
|
||||
## The Fix
|
||||
|
||||
One line in `main_thread_scheduler_impl.cc` — demote input tasks from `kHighestPriority` to `kNormalPriority`, allowing the scheduler's anti-starvation logic to fairly interleave input and compositor work.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **OS**: Windows 10/11 x64 (builds on Linux too, adjust paths accordingly)
|
||||
- **Disk**: ~100 GB free (Chromium source + build artifacts)
|
||||
- **RAM**: 16 GB minimum, 32 GB recommended
|
||||
- **Visual Studio 2022** with "Desktop development with C++" workload and Windows 11 SDK
|
||||
- **Git** and **Python 3.8+** on PATH
|
||||
|
||||
## Step 1: Install depot_tools
|
||||
|
||||
```powershell
|
||||
cd C:\
|
||||
git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git
|
||||
# Add C:\depot_tools to the FRONT of your system PATH
|
||||
# Then open a NEW terminal
|
||||
```
|
||||
|
||||
Verify: `gclient --version` should print a version.
|
||||
|
||||
## Step 2: Check out Electron source
|
||||
|
||||
```powershell
|
||||
mkdir C:\electron && cd C:\electron
|
||||
|
||||
# Create gclient config for Electron
|
||||
gclient config --name "src/electron" --unmanaged https://github.com/nicedayzhu/electron.git@v42.0.0-nightly.20260227
|
||||
```
|
||||
|
||||
> **Note**: Replace the repo URL with your fork if you've pushed the patch there. The `@v42.0.0-nightly.20260227` pins the exact nightly tag.
|
||||
|
||||
```powershell
|
||||
# Sync all dependencies (~40-60 GB download, takes a while)
|
||||
gclient sync --with_branch_heads --with_tags
|
||||
```
|
||||
|
||||
## Step 3: Apply the patch
|
||||
|
||||
```powershell
|
||||
cd C:\electron\src
|
||||
|
||||
# Apply the patch file
|
||||
git apply --directory=. path\to\input-priority-fix.patch
|
||||
```
|
||||
|
||||
Or make the edit manually — in `third_party/blink/renderer/platform/scheduler/main_thread/main_thread_scheduler_impl.cc`, find:
|
||||
|
||||
```cpp
|
||||
case MainThreadTaskQueue::QueueTraits::PrioritisationType::kInput:
|
||||
return TaskPriority::kHighestPriority;
|
||||
```
|
||||
|
||||
Change `kHighestPriority` to `kNormalPriority`.
|
||||
|
||||
## Step 4: Configure the build
|
||||
|
||||
### Release build (optimized, for distribution):
|
||||
|
||||
```powershell
|
||||
cd C:\electron\src
|
||||
|
||||
# Create build directory
|
||||
gn gen out/Release
|
||||
|
||||
# Copy the release args
|
||||
copy path\to\args.release.gn out\Release\args.gn
|
||||
|
||||
# Regenerate build files with the new args
|
||||
gn gen out/Release
|
||||
```
|
||||
|
||||
Contents of `args.release.gn`:
|
||||
```gn
|
||||
import("//electron/build/args/release.gn")
|
||||
is_official_build = true
|
||||
use_remoteexec = false
|
||||
use_reclient = false
|
||||
```
|
||||
|
||||
### Testing build (faster compile, for development):
|
||||
|
||||
```powershell
|
||||
gn gen out/Testing
|
||||
```
|
||||
|
||||
Write to `out/Testing/args.gn`:
|
||||
```gn
|
||||
import("//electron/build/args/testing.gn")
|
||||
use_remoteexec = false
|
||||
use_reclient = false
|
||||
```
|
||||
|
||||
Then: `gn gen out/Testing`
|
||||
|
||||
## Step 5: Build
|
||||
|
||||
```powershell
|
||||
cd C:\electron\src
|
||||
|
||||
# Release build (~2-4 hours depending on CPU)
|
||||
ninja -C out/Release electron
|
||||
|
||||
# OR Testing build (~1-2 hours, less optimization)
|
||||
ninja -C out/Testing electron
|
||||
```
|
||||
|
||||
> **Tip**: Use `ninja -C out/Release electron -j N` to limit parallelism if you're running out of RAM (where N = number of parallel jobs, try RAM_GB / 2).
|
||||
|
||||
## Step 6: Create distributable zip
|
||||
|
||||
```powershell
|
||||
cd C:\electron\src
|
||||
|
||||
# Generate the electron dist zip
|
||||
python3 electron/script/zip_manifests/create-dist-zip.py out/Release
|
||||
|
||||
# Or use electron's strip-binaries + create-dist tooling:
|
||||
ninja -C out/Release electron:dist_zip
|
||||
```
|
||||
|
||||
The output zip will be at `out/Release/dist.zip` (or similar). This contains `electron.exe` and all required DLLs/resources.
|
||||
|
||||
## Step 7: Verify
|
||||
|
||||
Extract the zip and test with a minimal app:
|
||||
|
||||
```powershell
|
||||
# Create a test directory
|
||||
mkdir test-app
|
||||
```
|
||||
|
||||
Create `test-app/package.json`:
|
||||
```json
|
||||
{ "name": "test", "version": "1.0.0", "main": "main.js" }
|
||||
```
|
||||
|
||||
Create `test-app/main.js`:
|
||||
```js
|
||||
const { app, BrowserWindow } = require('electron');
|
||||
app.commandLine.appendSwitch('disable-frame-rate-limit');
|
||||
app.commandLine.appendSwitch('disable-gpu-vsync');
|
||||
app.whenReady().then(() => {
|
||||
const win = new BrowserWindow({ width: 1280, height: 720 });
|
||||
win.loadURL('https://krunker.io');
|
||||
win.webContents.on('did-finish-load', () => {
|
||||
console.log('Electron:', process.versions.electron);
|
||||
console.log('Chrome:', process.versions.chrome);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Run it:
|
||||
```powershell
|
||||
path\to\electron.exe test-app
|
||||
```
|
||||
|
||||
If Krunker loads at uncapped FPS with no aim freeze, the build is good.
|
||||
|
||||
## Using the patched Electron in a project
|
||||
|
||||
To use this as the Electron binary in an npm project:
|
||||
|
||||
```powershell
|
||||
# Set environment variable to point to your custom build
|
||||
set ELECTRON_OVERRIDE_DIST_PATH=C:\path\to\extracted\electron-dist
|
||||
|
||||
# Then run your Electron app normally
|
||||
npm start
|
||||
```
|
||||
|
||||
Or replace the contents of `node_modules/electron/dist/` with the extracted zip contents.
|
||||
|
||||
## Build time estimates
|
||||
|
||||
| Build type | CPU | Approx. time |
|
||||
|---|---|---|
|
||||
| Testing | 8-core | ~1-2 hours |
|
||||
| Testing | 16-core | ~30-60 min |
|
||||
| Release | 8-core | ~3-5 hours |
|
||||
| Release | 16-core | ~1.5-3 hours |
|
||||
|
||||
Release builds are significantly slower due to LTO (Link-Time Optimization) which does a whole-program optimization pass.
|
||||
@@ -0,0 +1,8 @@
|
||||
import("//electron/build/args/release.gn")
|
||||
|
||||
# Full optimization (LTO, minimal symbols, etc.)
|
||||
is_official_build = true
|
||||
|
||||
# Not using Google's remote build infrastructure
|
||||
use_remoteexec = false
|
||||
use_reclient = false
|
||||
@@ -0,0 +1,29 @@
|
||||
From: KPD Client <krunker@crjlab.net>
|
||||
Subject: [PATCH] Fix input starvation when frame rate limit is disabled
|
||||
|
||||
Chromium's main thread scheduler assigns kHighestPriority to input tasks,
|
||||
which starves the compositor when --disable-frame-rate-limit is active.
|
||||
At uncapped frame rates (300+ FPS), the compositor floods the task queue
|
||||
and input events get delayed 50-300ms, causing "aim freeze" in games.
|
||||
|
||||
Demoting input tasks to kNormalPriority allows the scheduler's built-in
|
||||
anti-starvation logic to fairly interleave input and compositor work.
|
||||
|
||||
Benchmarked via CDP Input.dispatchMouseEvent:
|
||||
- p99 latency: 97ms -> 34ms
|
||||
- Max latency: 308ms -> 38ms
|
||||
- Events >50ms: 8.6% -> 0%
|
||||
- Frames rendered: +21%
|
||||
- Mouse events processed: +9%
|
||||
|
||||
--- a/third_party/blink/renderer/platform/scheduler/main_thread/main_thread_scheduler_impl.cc
|
||||
+++ b/third_party/blink/renderer/platform/scheduler/main_thread/main_thread_scheduler_impl.cc
|
||||
@@ -2354,7 +2354,7 @@
|
||||
case MainThreadTaskQueue::QueueTraits::PrioritisationType::kCompositor:
|
||||
return main_thread_only().compositor_priority;
|
||||
case MainThreadTaskQueue::QueueTraits::PrioritisationType::kInput:
|
||||
- return TaskPriority::kHighestPriority;
|
||||
+ return TaskPriority::kNormalPriority;
|
||||
case MainThreadTaskQueue::QueueTraits::PrioritisationType::kBestEffort:
|
||||
return TaskPriority::kBestEffortPriority;
|
||||
case MainThreadTaskQueue::QueueTraits::PrioritisationType::kRegular:
|
||||
@@ -67,5 +67,5 @@ linux:
|
||||
|
||||
publish:
|
||||
provider: github
|
||||
owner: bigjakk
|
||||
repo: Krunker-Civilian-Client
|
||||
owner: krunker-civilian
|
||||
repo: krunker-civilian-client
|
||||
|
||||
Generated
+172
-979
File diff suppressed because it is too large
Load Diff
+39
-45
@@ -1,45 +1,39 @@
|
||||
{
|
||||
"name": "krunker-civilian-client",
|
||||
"version": "0.7.6",
|
||||
"description": "Cross-platform Krunker game client",
|
||||
"main": "dist/main/index.js",
|
||||
"homepage": "https://github.com/bigjakk/Krunker-Civilian-Client",
|
||||
"author": "Krunker Civilian Client <krunker@crjlab.net>",
|
||||
"license": "GPL-3.0",
|
||||
"scripts": {
|
||||
"postinstall": "node scripts/download-electron.js",
|
||||
"dev": "vite build --mode development --config vite.main.config.ts && vite build --mode development --config vite.preload.config.ts && electron .",
|
||||
"build:main": "vite build --config vite.main.config.ts",
|
||||
"build:preload": "vite build --config vite.preload.config.ts",
|
||||
"build": "npm run build:main && npm run build:preload",
|
||||
"start": "npm run build && electron .",
|
||||
"download-electron": "node scripts/download-electron.js",
|
||||
"dist:win": "npm run build && electron-builder --win",
|
||||
"dist:linux": "npm run build && electron-builder --linux",
|
||||
"dist:all": "npm run build && electron-builder --win --linux",
|
||||
"build:asar": "node scripts/build-asar.js",
|
||||
"clean": "rimraf dist out",
|
||||
"lint": "eslint src/",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"lint-staged": {
|
||||
"src/**/*.ts": "eslint --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-store": "^8.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron/asar": "^4.2.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/node": "^22.0.0",
|
||||
"electron": "npm:electron-nightly@42.0.0-nightly.20260227",
|
||||
"electron-builder": "^26.0.0",
|
||||
"eslint": "^10.0.2",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.3.1",
|
||||
"rimraf": "^6.0.1",
|
||||
"typescript": "^5.7.0",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
{
|
||||
"name": "krunker-civilian-client",
|
||||
"version": "0.5.5",
|
||||
"description": "Cross-platform Krunker game client",
|
||||
"main": "dist/main/index.js",
|
||||
"homepage": "https://gitea.crjlab.net/bigjakk/krunker-civilian-client",
|
||||
"author": "Krunker Civilian Client <krunker@crjlab.net>",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"postinstall": "node scripts/download-electron.js",
|
||||
"dev": "vite build --mode development --config vite.main.config.ts && vite build --mode development --config vite.preload.config.ts && electron .",
|
||||
"build:main": "vite build --config vite.main.config.ts",
|
||||
"build:preload": "vite build --config vite.preload.config.ts",
|
||||
"build": "npm run build:main && npm run build:preload",
|
||||
"start": "npm run build && electron .",
|
||||
"download-electron": "node scripts/download-electron.js",
|
||||
"dist:win": "npm run build && electron-builder --win",
|
||||
"dist:linux": "npm run build && electron-builder --linux",
|
||||
"dist:all": "npm run build && electron-builder --win --linux",
|
||||
"clean": "rimraf dist out",
|
||||
"lint": "eslint src/",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-store": "^8.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/node": "^22.0.0",
|
||||
"electron": "npm:electron-nightly@42.0.0-nightly.20260227",
|
||||
"electron-builder": "^26.0.0",
|
||||
"eslint": "^10.0.2",
|
||||
"husky": "^9.1.7",
|
||||
"rimraf": "^6.0.1",
|
||||
"typescript": "^5.7.0",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
/**
|
||||
* Build a standalone app.asar + checksums.sha256 for minor (patch) updates.
|
||||
*
|
||||
* Replicates what electron-builder packs into the asar (dist/ + package.json +
|
||||
* required node_modules) without running the full installer build.
|
||||
*
|
||||
* Usage:
|
||||
* npm run build:asar # default (github) update source
|
||||
* UPDATE_SOURCE=gitea npm run build:asar # gitea update source
|
||||
*/
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const { cpSync, mkdirSync, rmSync, readFileSync, writeFileSync, createReadStream } = require('fs');
|
||||
const { join } = require('path');
|
||||
const { createHash } = require('crypto');
|
||||
const asar = require('@electron/asar');
|
||||
|
||||
const ROOT = join(__dirname, '..');
|
||||
const STAGING = join(ROOT, 'out', 'asar-staging');
|
||||
const OUTPUT = join(ROOT, 'out', 'asar');
|
||||
|
||||
// Same node_modules list as electron-builder.yml
|
||||
const NODE_MODULES = [
|
||||
'electron-store', 'conf', 'dot-prop', 'type-fest', 'pkg-up',
|
||||
'find-up', 'locate-path', 'p-locate', 'p-limit', 'yocto-queue',
|
||||
'path-exists', 'env-paths', 'json-schema-typed', 'ajv', 'ajv-formats',
|
||||
'atomically', 'debounce-fn', 'mimic-fn', 'semver', 'onetime',
|
||||
];
|
||||
|
||||
function shouldExclude(src) {
|
||||
return src.endsWith('.ts') || src.endsWith('.map');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// 1. Run Vite build (pass UPDATE_SOURCE through env)
|
||||
const env = { ...process.env };
|
||||
if (env.UPDATE_SOURCE) {
|
||||
console.log(`Building with UPDATE_SOURCE=${env.UPDATE_SOURCE}`);
|
||||
}
|
||||
execSync('npm run build', { cwd: ROOT, stdio: 'inherit', env });
|
||||
|
||||
// 2. Prepare staging directory
|
||||
rmSync(STAGING, { recursive: true, force: true });
|
||||
mkdirSync(STAGING, { recursive: true });
|
||||
|
||||
// Copy dist/
|
||||
cpSync(join(ROOT, 'dist'), join(STAGING, 'dist'), { recursive: true });
|
||||
|
||||
// Copy package.json (Electron reads "main" from it)
|
||||
cpSync(join(ROOT, 'package.json'), join(STAGING, 'package.json'));
|
||||
|
||||
// Copy required node_modules (excluding .ts and .map files)
|
||||
for (const mod of NODE_MODULES) {
|
||||
const src = join(ROOT, 'node_modules', mod);
|
||||
const dest = join(STAGING, 'node_modules', mod);
|
||||
cpSync(src, dest, {
|
||||
recursive: true,
|
||||
filter: (s) => !shouldExclude(s),
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Pack into asar
|
||||
mkdirSync(OUTPUT, { recursive: true });
|
||||
const asarPath = join(OUTPUT, 'app.asar');
|
||||
await asar.createPackage(STAGING, asarPath);
|
||||
console.log('Created:', asarPath);
|
||||
|
||||
// 4. Generate checksums.sha256
|
||||
const hex = await fileHash(asarPath);
|
||||
const checksumsPath = join(OUTPUT, 'checksums.sha256');
|
||||
writeFileSync(checksumsPath, `${hex} app.asar\n`);
|
||||
console.log(`SHA-256: ${hex}`);
|
||||
console.log('Created:', checksumsPath);
|
||||
|
||||
// 5. Cleanup staging
|
||||
rmSync(STAGING, { recursive: true, force: true });
|
||||
|
||||
// Print size
|
||||
const size = readFileSync(asarPath).length;
|
||||
console.log(`\napp.asar size: ${(size / 1024).toFixed(1)} KB`);
|
||||
}
|
||||
|
||||
function fileHash(filePath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = createHash('sha256');
|
||||
const stream = createReadStream(filePath);
|
||||
stream.on('data', (chunk) => hash.update(chunk));
|
||||
stream.on('end', () => resolve(hash.digest('hex')));
|
||||
stream.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('build-asar failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
+208
-194
@@ -1,194 +1,208 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Downloads patched Electron builds for Windows (v42) and Linux (v43).
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Platform behavior:
|
||||
* Windows: patched Win → dist/ (replaces stock)
|
||||
* Linux (local): patched Linux → dist/ (replaces stock), Win → dist-win/
|
||||
* CI (Linux): Win → dist-win/, Linux → dist-linux/ (stock stays in dist/)
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/download-electron.js # download if needed
|
||||
* node scripts/download-electron.js --force # re-download even if present
|
||||
*/
|
||||
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
// ── Configuration ──────────────────────────────────────────────────────────
|
||||
const GITHUB_BASE = 'https://github.com';
|
||||
const REPO = 'bigjakk/Electron-Websocket-Fix';
|
||||
const RELEASE_TAG = 'v1.0.0';
|
||||
|
||||
const PLATFORMS = {
|
||||
win32: { asset: 'electron-v42.0.0-nightly-release-patched-win32-x64.zip' },
|
||||
linux: { asset: 'electron-v43.0.0-nightly-release-patched-linux-x64.zip' },
|
||||
};
|
||||
|
||||
const IS_WIN = process.platform === 'win32';
|
||||
const IS_CI = !!process.env.CI;
|
||||
const ELECTRON_BASE = path.resolve(__dirname, '..', 'node_modules', 'electron');
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function get(url) {
|
||||
const lib = url.startsWith('https') ? https : http;
|
||||
return new Promise((resolve, reject) => {
|
||||
lib.get(url, { headers: { 'User-Agent': 'KCC-Build' } }, (res) => {
|
||||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
get(res.headers.location).then(resolve, reject);
|
||||
res.resume();
|
||||
return;
|
||||
}
|
||||
if (res.statusCode !== 200) {
|
||||
res.resume();
|
||||
reject(new Error(`HTTP ${res.statusCode} for ${url}`));
|
||||
return;
|
||||
}
|
||||
resolve(res);
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function downloadToFile(url, dest) {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
const res = await get(url);
|
||||
const total = parseInt(res.headers['content-length'] || '0', 10);
|
||||
let downloaded = 0;
|
||||
|
||||
const file = fs.createWriteStream(dest);
|
||||
res.on('data', (chunk) => {
|
||||
downloaded += chunk.length;
|
||||
if (total > 0) {
|
||||
const pct = ((downloaded / total) * 100).toFixed(1);
|
||||
const mb = (downloaded / 1048576).toFixed(1);
|
||||
const totalMb = (total / 1048576).toFixed(1);
|
||||
process.stdout.write(`\r Downloading: ${pct}% (${mb}/${totalMb} MB)`);
|
||||
}
|
||||
});
|
||||
res.pipe(file);
|
||||
file.on('finish', () => {
|
||||
file.close();
|
||||
process.stdout.write('\n');
|
||||
resolve();
|
||||
});
|
||||
file.on('error', (err) => {
|
||||
fs.unlinkSync(dest);
|
||||
reject(err);
|
||||
});
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function extractZip(zipPath, destDir) {
|
||||
// Use PowerShell on Windows, unzip on Linux/macOS
|
||||
if (process.platform === 'win32') {
|
||||
execSync(
|
||||
`powershell -NoProfile -Command "Expand-Archive -Force -Path '${zipPath}' -DestinationPath '${destDir}'"`,
|
||||
{ stdio: 'inherit' }
|
||||
);
|
||||
} else {
|
||||
execSync(`unzip -o "${zipPath}" -d "${destDir}"`, { stdio: 'inherit' });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Per-directory install ──────────────────────────────────────────────────
|
||||
|
||||
async function installTo(distDir, platform) {
|
||||
const force = process.argv.includes('--force');
|
||||
const patchedMarker = path.join(distDir, '.patched');
|
||||
const tempZip = path.join(ELECTRON_BASE, `_electron-patched-${platform.asset}`);
|
||||
const label = path.relative(path.resolve(__dirname, '..'), distDir);
|
||||
|
||||
// Check if this exact asset is already installed.
|
||||
// The marker stores the asset filename to handle version changes.
|
||||
if (!force && fs.existsSync(patchedMarker)) {
|
||||
const installed = fs.readFileSync(patchedMarker, 'utf8').trim();
|
||||
if (installed === platform.asset) {
|
||||
console.log(` [${label}] ${platform.asset} already installed, skipping`);
|
||||
return;
|
||||
}
|
||||
console.log(` [${label}] Installed: ${installed}, need: ${platform.asset}`);
|
||||
}
|
||||
|
||||
// Direct download from GitHub release
|
||||
const url = `${GITHUB_BASE}/${REPO}/releases/download/${RELEASE_TAG}/${platform.asset}`;
|
||||
console.log(` [${label}] Asset URL: ${url}`);
|
||||
|
||||
// Download
|
||||
await downloadToFile(url, tempZip);
|
||||
const zipSize = (fs.statSync(tempZip).size / 1048576).toFixed(1);
|
||||
console.log(` [${label}] Downloaded: ${zipSize} MB`);
|
||||
|
||||
// Clear existing target dir and extract
|
||||
console.log(` [${label}] Extracting...`);
|
||||
if (fs.existsSync(distDir)) {
|
||||
fs.rmSync(distDir, { recursive: true, force: true });
|
||||
}
|
||||
fs.mkdirSync(distDir, { recursive: true });
|
||||
extractZip(tempZip, distDir);
|
||||
|
||||
// Clean up temp zip
|
||||
fs.unlinkSync(tempZip);
|
||||
|
||||
// Write marker with asset name for future skip-check
|
||||
fs.writeFileSync(patchedMarker, platform.asset);
|
||||
const versionFile = path.join(distDir, 'version');
|
||||
if (fs.existsSync(versionFile)) {
|
||||
const ver = fs.readFileSync(versionFile, 'utf8').trim();
|
||||
console.log(` [${label}] Installed patched Electron ${ver}`);
|
||||
} else {
|
||||
console.log(` [${label}] Installed ${platform.asset} (no version file)`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Main ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
if (IS_WIN) {
|
||||
// Windows local dev: patched Win → dist/ (replaces stock)
|
||||
await installTo(path.join(ELECTRON_BASE, 'dist'), PLATFORMS.win32);
|
||||
} else if (IS_CI) {
|
||||
// CI (Linux): keep stock in dist/ untouched,
|
||||
// patched Win → dist-win/, patched Linux → dist-linux/
|
||||
await installTo(path.join(ELECTRON_BASE, 'dist-win'), PLATFORMS.win32);
|
||||
await installTo(path.join(ELECTRON_BASE, 'dist-linux'), PLATFORMS.linux);
|
||||
} else {
|
||||
// Linux local dev: patched Linux → dist/ (for npm run dev),
|
||||
// patched Win → dist-win/ (for cross-compilation)
|
||||
await installTo(path.join(ELECTRON_BASE, 'dist'), PLATFORMS.linux);
|
||||
await installTo(path.join(ELECTRON_BASE, 'dist-win'), PLATFORMS.win32);
|
||||
}
|
||||
|
||||
// Write path.txt so the electron package's lazy downloader (index.js)
|
||||
// considers the binary already installed and doesn't re-download stock.
|
||||
const platformExe = IS_WIN ? 'electron.exe' : 'electron';
|
||||
fs.writeFileSync(path.join(ELECTRON_BASE, 'path.txt'), platformExe);
|
||||
}
|
||||
|
||||
console.log('[KCC] Setting up patched Electron...');
|
||||
main().then(() => {
|
||||
console.log('[KCC] Patched Electron ready.');
|
||||
if (!IS_WIN) {
|
||||
console.log(' (use --force to re-download)');
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.error('[KCC] Electron download failed:', err.message);
|
||||
console.error('');
|
||||
console.error(' Download the patched Electron manually from:');
|
||||
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}`);
|
||||
process.exit(1);
|
||||
});
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Downloads the patched Electron build and extracts it into node_modules/electron/dist/.
|
||||
*
|
||||
* The patched Electron fixes input starvation ("aim freeze") when --disable-frame-rate-limit
|
||||
* is active on modern Chromium. Without this, uncapped FPS causes 50-300ms input delays.
|
||||
*
|
||||
* The zip is hosted as a release asset on the same Gitea repo. The script checks the
|
||||
* local version file to skip re-downloading if already present.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/download-electron.js # download if needed
|
||||
* node scripts/download-electron.js --force # re-download even if present
|
||||
*/
|
||||
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
// ── Configuration ──────────────────────────────────────────────────────────
|
||||
const ELECTRON_VERSION = '42.0.0-nightly.20260227';
|
||||
const ASSET_NAME = 'electron-v42.0.0-nightly-patched-win32-x64.zip';
|
||||
const GITEA_BASE = 'https://gitea.crjlab.net';
|
||||
const REPO = 'bigjakk/Krunker-Civilian-Client';
|
||||
// The release tag that holds the patched Electron zip.
|
||||
// Upload the zip as an asset to this release on Gitea.
|
||||
const RELEASE_TAG = 'electron-patched';
|
||||
|
||||
// On Windows, overwrite the npm-installed Electron with our patched build.
|
||||
// On Linux/macOS (CI cross-compilation), extract to a separate dist-win/ directory
|
||||
// so the npm-installed platform-native Electron stays in dist/ for bytenode compilation.
|
||||
const IS_WIN = process.platform === 'win32';
|
||||
const ELECTRON_DIST = IS_WIN
|
||||
? path.resolve(__dirname, '..', 'node_modules', 'electron', 'dist')
|
||||
: path.resolve(__dirname, '..', 'node_modules', 'electron', 'dist-win');
|
||||
const VERSION_FILE = path.join(ELECTRON_DIST, 'version');
|
||||
// Separate marker file to distinguish patched from stock electron-nightly.
|
||||
// Both have the same version string, so VERSION_FILE alone is not sufficient.
|
||||
const PATCHED_MARKER = path.join(ELECTRON_DIST, '.patched');
|
||||
const TEMP_ZIP = path.join(ELECTRON_DIST, '..', '_electron-patched.zip');
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function get(url) {
|
||||
const lib = url.startsWith('https') ? https : http;
|
||||
return new Promise((resolve, reject) => {
|
||||
lib.get(url, { headers: { 'User-Agent': 'KCC-Build' } }, (res) => {
|
||||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
get(res.headers.location).then(resolve, reject);
|
||||
res.resume();
|
||||
return;
|
||||
}
|
||||
if (res.statusCode !== 200) {
|
||||
res.resume();
|
||||
reject(new Error(`HTTP ${res.statusCode} for ${url}`));
|
||||
return;
|
||||
}
|
||||
resolve(res);
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function downloadToFile(url, dest) {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
const res = await get(url);
|
||||
const total = parseInt(res.headers['content-length'] || '0', 10);
|
||||
let downloaded = 0;
|
||||
|
||||
const file = fs.createWriteStream(dest);
|
||||
res.on('data', (chunk) => {
|
||||
downloaded += chunk.length;
|
||||
if (total > 0) {
|
||||
const pct = ((downloaded / total) * 100).toFixed(1);
|
||||
const mb = (downloaded / 1048576).toFixed(1);
|
||||
const totalMb = (total / 1048576).toFixed(1);
|
||||
process.stdout.write(`\r Downloading: ${pct}% (${mb}/${totalMb} MB)`);
|
||||
}
|
||||
});
|
||||
res.pipe(file);
|
||||
file.on('finish', () => {
|
||||
file.close();
|
||||
process.stdout.write('\n');
|
||||
resolve();
|
||||
});
|
||||
file.on('error', (err) => {
|
||||
fs.unlinkSync(dest);
|
||||
reject(err);
|
||||
});
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function getAssetUrl() {
|
||||
const apiUrl = `${GITEA_BASE}/api/v1/repos/${REPO}/releases/tags/${RELEASE_TAG}`;
|
||||
const res = await get(apiUrl);
|
||||
const body = await new Promise((resolve, reject) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => { data += chunk; });
|
||||
res.on('end', () => resolve(data));
|
||||
res.on('error', reject);
|
||||
});
|
||||
|
||||
const release = JSON.parse(body);
|
||||
const asset = release.assets.find((a) => a.name === ASSET_NAME);
|
||||
if (!asset) {
|
||||
const names = release.assets.map((a) => a.name).join(', ');
|
||||
throw new Error(
|
||||
`Asset "${ASSET_NAME}" not found in release "${RELEASE_TAG}".\n` +
|
||||
` Available assets: ${names || '(none)'}\n` +
|
||||
` Upload the patched Electron zip to: ${GITEA_BASE}/${REPO}/releases/tag/${RELEASE_TAG}`
|
||||
);
|
||||
}
|
||||
|
||||
// Gitea API returns browser_download_url for direct download
|
||||
return asset.browser_download_url;
|
||||
}
|
||||
|
||||
function extractZip(zipPath, destDir) {
|
||||
// Use PowerShell on Windows, unzip on Linux/macOS
|
||||
if (process.platform === 'win32') {
|
||||
execSync(
|
||||
`powershell -NoProfile -Command "Expand-Archive -Force -Path '${zipPath}' -DestinationPath '${destDir}'"`,
|
||||
{ stdio: 'inherit' }
|
||||
);
|
||||
} else {
|
||||
execSync(`unzip -o "${zipPath}" -d "${destDir}"`, { stdio: 'inherit' });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Main ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
const force = process.argv.includes('--force');
|
||||
|
||||
// Check if patched version is already installed.
|
||||
// The .patched marker distinguishes our build from stock electron-nightly
|
||||
// (both share the same version string).
|
||||
if (!force && fs.existsSync(PATCHED_MARKER)) {
|
||||
const installed = fs.readFileSync(PATCHED_MARKER, 'utf8').trim();
|
||||
if (installed === ELECTRON_VERSION) {
|
||||
console.log(` Patched Electron ${ELECTRON_VERSION} already installed, skipping`);
|
||||
console.log(' (use --force to re-download)');
|
||||
return;
|
||||
}
|
||||
console.log(` Installed: ${installed}, need: ${ELECTRON_VERSION}`);
|
||||
}
|
||||
|
||||
// Resolve download URL from Gitea release
|
||||
console.log(` Fetching release info for "${RELEASE_TAG}"...`);
|
||||
const url = await getAssetUrl();
|
||||
console.log(` Asset URL: ${url}`);
|
||||
|
||||
// Download
|
||||
await downloadToFile(url, TEMP_ZIP);
|
||||
const zipSize = (fs.statSync(TEMP_ZIP).size / 1048576).toFixed(1);
|
||||
console.log(` Downloaded: ${zipSize} MB`);
|
||||
|
||||
// Clear existing target dir and extract
|
||||
console.log(` Extracting to ${path.relative(path.resolve(__dirname, '..'), ELECTRON_DIST)}/...`);
|
||||
if (fs.existsSync(ELECTRON_DIST)) {
|
||||
fs.rmSync(ELECTRON_DIST, { recursive: true, force: true });
|
||||
}
|
||||
fs.mkdirSync(ELECTRON_DIST, { recursive: true });
|
||||
extractZip(TEMP_ZIP, ELECTRON_DIST);
|
||||
|
||||
// Clean up temp zip
|
||||
fs.unlinkSync(TEMP_ZIP);
|
||||
|
||||
// Write path.txt so the electron package's lazy downloader (index.js)
|
||||
// considers the binary already installed and doesn't re-download stock.
|
||||
// On non-Windows (CI cross-compilation), skip this so electron-nightly still
|
||||
// downloads the native Linux binary into dist/ for the Linux build target.
|
||||
if (IS_WIN) {
|
||||
fs.writeFileSync(path.join(ELECTRON_DIST, '..', 'path.txt'), 'electron.exe');
|
||||
}
|
||||
|
||||
// Write marker and verify
|
||||
if (fs.existsSync(VERSION_FILE)) {
|
||||
const ver = fs.readFileSync(VERSION_FILE, 'utf8').trim();
|
||||
fs.writeFileSync(PATCHED_MARKER, ver);
|
||||
console.log(` Installed patched Electron ${ver}`);
|
||||
} else {
|
||||
console.log(' Warning: version file not found after extraction');
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[KCC] Setting up patched Electron...');
|
||||
main().then(() => {
|
||||
console.log('[KCC] Patched Electron ready.');
|
||||
}).catch((err) => {
|
||||
console.error('[KCC] Electron download failed:', err.message);
|
||||
console.error('');
|
||||
console.error(' If this is your first time building, you need the patched Electron zip');
|
||||
console.error(` uploaded as a release asset on ${GITEA_BASE}/${REPO}`);
|
||||
console.error('');
|
||||
console.error(' 1. Go to: ' + GITEA_BASE + '/' + REPO + '/releases/new');
|
||||
console.error(` 2. Create a release with tag: ${RELEASE_TAG}`);
|
||||
console.error(` 3. Upload: ${ASSET_NAME}`);
|
||||
console.error('');
|
||||
console.error(' See electron-build/BUILD.md for how to build Electron from source.');
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Generate Markdown release notes from conventional commits.
|
||||
# Usage: ./scripts/generate-release-notes.sh <tag> [prev-ref]
|
||||
#
|
||||
# Skips version bumps, CI-only changes, and other noise.
|
||||
# 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.
|
||||
|
||||
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"
|
||||
else
|
||||
RANGE="HEAD"
|
||||
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 other; do
|
||||
: > "${TMPDIR_NOTES}/${prefix}"
|
||||
done
|
||||
|
||||
while IFS= read -r line; do
|
||||
[ -z "$line" ] && continue
|
||||
|
||||
# Skip version bump commits (e.g. "v0.6.1", "v0.6.2 — description")
|
||||
[[ "$line" =~ ^v[0-9] ]] && continue
|
||||
|
||||
# Skip chore/docs/test/ci commits — not user-facing
|
||||
[[ "$line" =~ ^(chore|docs|test|ci)(\(.*\))?: ]] && continue
|
||||
|
||||
MATCHED=false
|
||||
for prefix in feat fix refactor perf; do
|
||||
if [[ "$line" =~ ^${prefix}(\(.*\))?:\ (.+)$ ]]; then
|
||||
MSG="${BASH_REMATCH[2]}"
|
||||
echo "- ${MSG}" >> "${TMPDIR_NOTES}/${prefix}"
|
||||
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 "## New" ;;
|
||||
fix) echo "## Fixes" ;;
|
||||
refactor) echo "## Improvements" ;;
|
||||
perf) echo "## Performance" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Build output — only user-facing sections
|
||||
HAS_CONTENT=false
|
||||
|
||||
for prefix in feat fix refactor perf; do
|
||||
if [ -s "${TMPDIR_NOTES}/${prefix}" ]; then
|
||||
section_title "$prefix"
|
||||
echo ""
|
||||
cat "${TMPDIR_NOTES}/${prefix}"
|
||||
echo ""
|
||||
HAS_CONTENT=true
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -s "${TMPDIR_NOTES}/other" ]; then
|
||||
echo "## Other"
|
||||
echo ""
|
||||
cat "${TMPDIR_NOTES}/other"
|
||||
echo ""
|
||||
HAS_CONTENT=true
|
||||
fi
|
||||
|
||||
if [ "$HAS_CONTENT" = false ]; then
|
||||
echo "Bug fixes and improvements."
|
||||
fi
|
||||
+6
-148
@@ -1,5 +1,5 @@
|
||||
// ── Shared CSS theme variables (used by both main page and tab bar) ──
|
||||
export const THEME_CSS = `
|
||||
// ── Injected CSS for client settings in Krunker's settings panel ──
|
||||
export const CLIENT_SETTINGS_CSS = `
|
||||
:root {
|
||||
/* ── Surfaces ── */
|
||||
--kpc-surface-card: rgba(255,255,255,0.04);
|
||||
@@ -42,12 +42,8 @@ export const THEME_CSS = `
|
||||
--kpc-z-notification: 100000;
|
||||
--kpc-z-overlay: 10000000;
|
||||
--kpc-z-popup: 10000001;
|
||||
}
|
||||
`;
|
||||
|
||||
// ── Injected CSS for client settings in Krunker's settings panel ──
|
||||
export const CLIENT_SETTINGS_CSS = `
|
||||
${THEME_CSS}
|
||||
}
|
||||
/* ── Crankshaft-style settings (Krunker-native classes) ── */
|
||||
|
||||
.kpc-settings .settName,
|
||||
@@ -454,7 +450,7 @@ export const MATCHMAKER_SETTINGS_CSS = `
|
||||
0% { transform: translate(-50%, -500%); }
|
||||
100% { transform: translate(-50%, 0%); }
|
||||
}
|
||||
.onGame #matchmakerPopupContainer:not(.searching) {
|
||||
.onGame #matchmakerPopupContainer {
|
||||
opacity: 0 !important;
|
||||
}
|
||||
#matchmakerPopupContainer {
|
||||
@@ -509,7 +505,7 @@ export const MATCHMAKER_SETTINGS_CSS = `
|
||||
flex-grow: 1;
|
||||
}
|
||||
#matchmakerCancelButton {
|
||||
border-color: var(--kpc-red);
|
||||
border-color: #f44336;
|
||||
}
|
||||
.matchmakerPopupButton:hover {
|
||||
cursor: pointer;
|
||||
@@ -519,91 +515,6 @@ export const MATCHMAKER_SETTINGS_CSS = `
|
||||
.matchmakerPopupButton:active {
|
||||
transform: scale(0.85);
|
||||
}
|
||||
|
||||
/* ── Search phase ── */
|
||||
#matchmakerPopupContainer.searching {
|
||||
background-image: none !important;
|
||||
background: var(--kpc-surface-raised);
|
||||
width: 24em;
|
||||
aspect-ratio: auto;
|
||||
padding: 1em 1.5em;
|
||||
}
|
||||
#matchmakerPopupContainer.searching #matchmakerPopupTitle,
|
||||
#matchmakerPopupContainer.searching #matchmakerPopupDescription,
|
||||
#matchmakerPopupContainer.searching #matchmakerPopupOptions {
|
||||
display: none;
|
||||
}
|
||||
#matchmakerPopupContainer:not(.searching) #matchmakerSearchContainer {
|
||||
display: none;
|
||||
}
|
||||
#matchmakerSearchStatus {
|
||||
font-size: 1.4em;
|
||||
color: var(--kpc-blue);
|
||||
margin-bottom: 0.6em;
|
||||
text-align: center;
|
||||
}
|
||||
#matchmakerSearchFeed {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15em;
|
||||
overflow: hidden;
|
||||
min-height: 5.6em;
|
||||
margin-bottom: 0.6em;
|
||||
}
|
||||
@keyframes mmFeedSlideIn {
|
||||
from { opacity: 0; transform: translateX(1em); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
.mm-feed-entry {
|
||||
display: flex;
|
||||
gap: 0.8em;
|
||||
padding: 0.2em 0.5em;
|
||||
font-size: 0.95em;
|
||||
font-family: 'GameFont', monospace;
|
||||
border-radius: 0.2em;
|
||||
animation: mmFeedSlideIn 0.12s ease forwards;
|
||||
}
|
||||
.mm-feed-entry.mm-pass { background: rgba(76,175,80,0.1); }
|
||||
.mm-feed-entry.mm-pass .mm-feed-region { color: var(--kpc-blue); }
|
||||
.mm-feed-entry.mm-pass .mm-feed-map { color: var(--kpc-text-primary, rgba(255,255,255,0.9)); }
|
||||
.mm-feed-entry.mm-pass .mm-feed-players { color: var(--kpc-green); }
|
||||
.mm-feed-entry.mm-fail { background: rgba(255,255,255,0.02); }
|
||||
.mm-feed-entry.mm-fail .mm-feed-region { color: var(--kpc-text-dim, rgba(255,255,255,0.3)); }
|
||||
.mm-feed-entry.mm-fail .mm-feed-map { color: var(--kpc-text-muted, rgba(255,255,255,0.5)); }
|
||||
.mm-feed-entry.mm-fail .mm-feed-players { color: var(--kpc-red); }
|
||||
.mm-feed-entry:last-child::before {
|
||||
content: '\\25B8 ';
|
||||
color: var(--kpc-yellow);
|
||||
}
|
||||
.mm-feed-region { min-width: 2.5em; font-weight: bold; }
|
||||
.mm-feed-map { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.mm-feed-players { min-width: 3em; text-align: right; font-weight: 600; }
|
||||
#matchmakerSearchCounter {
|
||||
font-size: 0.85em;
|
||||
color: var(--kpc-yellow);
|
||||
text-align: center;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
#matchmakerSearchCancel {
|
||||
text-align: center;
|
||||
border: 0.2em solid var(--kpc-red);
|
||||
color: white;
|
||||
border-radius: 0.3em;
|
||||
font-size: 1.1em;
|
||||
background: rgba(0,0,0,0.3);
|
||||
padding: 0.2em 1.2em;
|
||||
cursor: pointer;
|
||||
margin: 0 auto;
|
||||
width: fit-content;
|
||||
transition: all 0.08s;
|
||||
}
|
||||
#matchmakerSearchCancel:hover {
|
||||
border-color: white;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
#matchmakerSearchCancel:active {
|
||||
transform: scale(0.85);
|
||||
}
|
||||
`;
|
||||
|
||||
export const TRANSLATOR_CSS = `
|
||||
@@ -672,58 +583,5 @@ 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; }
|
||||
`;
|
||||
|
||||
// ── Rank progress tracker CSS ──
|
||||
export const RANK_TRACKER_CSS = `
|
||||
#kpc-elo-tracker { width: 100%; margin: 8px 0; }
|
||||
.kpc-elo-info-row { display: flex; align-items: center; gap: 8px; }
|
||||
.kpc-rank-container { display: flex; align-items: center; gap: 4px; white-space: nowrap; font-size: 12px; color: #ccc; }
|
||||
.kpc-elo-rank-img { width: 20px; height: 20px; }
|
||||
.kpc-elo-bar-bg { flex: 1; height: 14px; background: rgba(255,255,255,0.1); border-radius: 7px; position: relative; overflow: hidden; }
|
||||
.kpc-elo-bar-fill { height: 100%; background: linear-gradient(90deg, #388E3C, #4CAF50); border-radius: 7px; transition: width 0.3s; }
|
||||
.kpc-elo-bar-text { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; font-size: 10px; color: #fff; text-shadow: 0 1px 2px rgba(0,0,0,0.5); }
|
||||
#kpc-rank-list-btn { position: absolute; bottom: 8px; right: 8px; cursor: pointer; padding: 6px 14px; border-radius: 6px; font-size: 12px; background: rgba(76,175,80,0.3); color: #4CAF50; border: 1px solid rgba(76,175,80,0.4); z-index: 1; }
|
||||
#kpc-rank-list-btn:hover { background: rgba(76,175,80,0.5); color: #fff; }
|
||||
#kpc-rank-overlay { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.75); z-index: 9998; display: flex; justify-content: center; align-items: center; }
|
||||
.kpc-rank-popup { background: #1a1a2e; border-radius: 12px; padding: 20px; min-width: 340px; max-width: 500px; box-shadow: 0 8px 32px rgba(0,0,0,0.6); }
|
||||
.kpc-rank-popup-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||
.kpc-rank-popup-header h2 { margin: 0; color: #fff; font-size: 16px; }
|
||||
.kpc-rank-popup-close { cursor: pointer; color: #888; font-size: 18px; padding: 4px 8px; }
|
||||
.kpc-rank-popup-close:hover { color: #fff; }
|
||||
.kpc-rank-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; max-height: 60vh; overflow-y: auto; }
|
||||
.kpc-rank-grid-item { display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: rgba(255,255,255,0.05); border-radius: 6px; }
|
||||
.kpc-rank-grid-item img { width: 28px; height: 28px; }
|
||||
.kpc-rank-name { font-size: 13px; font-weight: 600; }
|
||||
.kpc-rank-elo { font-size: 11px; color: #888; }
|
||||
|
||||
/* Ranked queue button in ranked menu footer */
|
||||
#kpc-ranked-queue-btn {
|
||||
background-color: #5ce05a;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 9px;
|
||||
padding: 12px 14px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
#kpc-ranked-queue-btn:hover { background-color: #4bc94a; }
|
||||
`;
|
||||
|
||||
/** Pre-concatenated CSS for single-call injection (excludes HIDE_ADS_CSS which is separate) */
|
||||
export const ALL_CLIENT_CSS = `${CLIENT_SETTINGS_CSS}\n${MATCHMAKER_SETTINGS_CSS}\n${TRANSLATOR_CSS}\n${ALT_MANAGER_CSS}\n${HP_COUNTER_CSS}\n${BP_CLAIM_ALL_CSS}\n${RANK_TRACKER_CSS}`;
|
||||
export const ALL_CLIENT_CSS = `${CLIENT_SETTINGS_CSS}\n${MATCHMAKER_SETTINGS_CSS}\n${TRANSLATOR_CSS}\n${ALT_MANAGER_CSS}`;
|
||||
|
||||
+178
-216
@@ -1,216 +1,178 @@
|
||||
import Store from 'electron-store';
|
||||
|
||||
export interface Keybind {
|
||||
key: string;
|
||||
ctrl: boolean;
|
||||
shift: boolean;
|
||||
alt: boolean;
|
||||
}
|
||||
|
||||
export interface SavedAccount {
|
||||
label: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
window: {
|
||||
width: number;
|
||||
height: number;
|
||||
x: number | undefined;
|
||||
y: number | undefined;
|
||||
maximized: boolean;
|
||||
fullscreen: boolean;
|
||||
};
|
||||
performance: {
|
||||
fpsUnlocked: boolean;
|
||||
hardwareAccel: boolean;
|
||||
gpuPreference: 'high-performance' | 'low-power' | 'default';
|
||||
cpuThrottleGame: number;
|
||||
cpuThrottleMenu: number;
|
||||
processPriority: string;
|
||||
};
|
||||
game: {
|
||||
lastServer: string;
|
||||
socialTabBehaviour: 'New Window' | 'Same Window';
|
||||
rememberTabs: boolean;
|
||||
joinAsSpectator: boolean;
|
||||
rawInput: boolean;
|
||||
betterChat: boolean;
|
||||
chatHistorySize: number;
|
||||
showPing: boolean;
|
||||
hpEnemyCounter: boolean;
|
||||
};
|
||||
swapper: {
|
||||
enabled: boolean;
|
||||
path: string;
|
||||
};
|
||||
matchmaker: {
|
||||
enabled: boolean;
|
||||
regions: string[];
|
||||
gamemodes: string[];
|
||||
maps: string[];
|
||||
minPlayers: number;
|
||||
maxPlayers: number;
|
||||
minRemainingTime: number;
|
||||
openServerBrowser: boolean;
|
||||
sortByPlayers: boolean;
|
||||
};
|
||||
keybinds: {
|
||||
reload: Keybind;
|
||||
newMatch: Keybind;
|
||||
copyGameLink: Keybind;
|
||||
joinFromClipboard: Keybind;
|
||||
devTools: Keybind;
|
||||
matchmaker: Keybind;
|
||||
matchmakerCancel: Keybind;
|
||||
fullscreenToggle: Keybind;
|
||||
};
|
||||
userscripts: {
|
||||
enabled: boolean;
|
||||
path: string;
|
||||
};
|
||||
ui: {
|
||||
showExitButton: boolean;
|
||||
deathscreenAnimation: boolean;
|
||||
hideMenuPopups: boolean;
|
||||
menuTimer: boolean;
|
||||
doublePing: boolean;
|
||||
cssTheme: string;
|
||||
loadingTheme: string;
|
||||
backgroundUrl: string;
|
||||
showChangelog: boolean;
|
||||
lastSeenVersion: string;
|
||||
};
|
||||
discord: {
|
||||
enabled: boolean;
|
||||
};
|
||||
translator: {
|
||||
enabled: boolean;
|
||||
targetLanguage: string;
|
||||
showLanguageTag: boolean;
|
||||
};
|
||||
advanced: {
|
||||
removeUselessFeatures: boolean;
|
||||
gpuRasterizing: boolean;
|
||||
helpfulFlags: boolean;
|
||||
increaseLimits: boolean;
|
||||
lowLatency: boolean;
|
||||
experimentalFlags: boolean;
|
||||
angleBackend: string;
|
||||
verboseLogging: boolean;
|
||||
};
|
||||
accounts: SavedAccount[];
|
||||
tabWindow: {
|
||||
width: number;
|
||||
height: number;
|
||||
x: number | undefined;
|
||||
y: number | undefined;
|
||||
maximized: boolean;
|
||||
};
|
||||
savedTabs: string[];
|
||||
}
|
||||
|
||||
export const DEFAULT_KEYBINDS: AppConfig['keybinds'] = {
|
||||
reload: { key: 'F5', ctrl: false, shift: false, alt: false },
|
||||
newMatch: { key: 'F4', ctrl: false, shift: false, alt: false },
|
||||
copyGameLink: { key: 'l', ctrl: true, shift: false, alt: false },
|
||||
joinFromClipboard: { key: 'j', ctrl: true, shift: false, alt: false },
|
||||
devTools: { key: 'F12', ctrl: false, shift: false, alt: false },
|
||||
matchmaker: { key: 'F6', ctrl: false, shift: false, alt: false },
|
||||
matchmakerCancel: { key: 'Escape', ctrl: false, shift: false, alt: false },
|
||||
fullscreenToggle: { key: 'F11', ctrl: false, shift: false, alt: false },
|
||||
};
|
||||
|
||||
|
||||
export const config = new Store<AppConfig>({
|
||||
name: 'krunker-civilian-config',
|
||||
defaults: {
|
||||
window: {
|
||||
width: 1600,
|
||||
height: 900,
|
||||
x: undefined,
|
||||
y: undefined,
|
||||
maximized: false,
|
||||
fullscreen: false,
|
||||
},
|
||||
performance: {
|
||||
fpsUnlocked: true,
|
||||
hardwareAccel: true,
|
||||
gpuPreference: 'high-performance',
|
||||
cpuThrottleGame: 1,
|
||||
cpuThrottleMenu: 1.5,
|
||||
processPriority: 'Normal',
|
||||
},
|
||||
game: {
|
||||
lastServer: '',
|
||||
socialTabBehaviour: 'New Window',
|
||||
rememberTabs: false,
|
||||
joinAsSpectator: false,
|
||||
rawInput: true,
|
||||
betterChat: true,
|
||||
chatHistorySize: 200,
|
||||
showPing: true,
|
||||
hpEnemyCounter: true,
|
||||
},
|
||||
swapper: {
|
||||
enabled: false,
|
||||
path: '',
|
||||
},
|
||||
matchmaker: {
|
||||
enabled: true,
|
||||
regions: [],
|
||||
gamemodes: [],
|
||||
maps: [],
|
||||
minPlayers: 1,
|
||||
maxPlayers: 6,
|
||||
minRemainingTime: 120,
|
||||
openServerBrowser: true,
|
||||
sortByPlayers: false,
|
||||
},
|
||||
keybinds: DEFAULT_KEYBINDS,
|
||||
userscripts: {
|
||||
enabled: false,
|
||||
path: '',
|
||||
},
|
||||
ui: {
|
||||
showExitButton: true,
|
||||
deathscreenAnimation: true,
|
||||
hideMenuPopups: false,
|
||||
menuTimer: true,
|
||||
doublePing: true,
|
||||
cssTheme: 'disabled',
|
||||
loadingTheme: 'disabled',
|
||||
backgroundUrl: '',
|
||||
showChangelog: true,
|
||||
lastSeenVersion: '',
|
||||
},
|
||||
discord: {
|
||||
enabled: true,
|
||||
},
|
||||
translator: {
|
||||
enabled: true,
|
||||
targetLanguage: 'en',
|
||||
showLanguageTag: true,
|
||||
},
|
||||
advanced: {
|
||||
removeUselessFeatures: true,
|
||||
gpuRasterizing: false,
|
||||
helpfulFlags: true,
|
||||
increaseLimits: false,
|
||||
lowLatency: false,
|
||||
experimentalFlags: false,
|
||||
angleBackend: 'default',
|
||||
verboseLogging: false,
|
||||
},
|
||||
accounts: [],
|
||||
tabWindow: {
|
||||
width: 1280,
|
||||
height: 720,
|
||||
x: undefined,
|
||||
y: undefined,
|
||||
maximized: true,
|
||||
},
|
||||
savedTabs: [],
|
||||
},
|
||||
});
|
||||
import Store from 'electron-store';
|
||||
import { detectPlatform } from './platform';
|
||||
|
||||
export interface Keybind {
|
||||
key: string;
|
||||
ctrl: boolean;
|
||||
shift: boolean;
|
||||
alt: boolean;
|
||||
}
|
||||
|
||||
export interface SavedAccount {
|
||||
label: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
window: {
|
||||
width: number;
|
||||
height: number;
|
||||
x: number | undefined;
|
||||
y: number | undefined;
|
||||
maximized: boolean;
|
||||
fullscreen: boolean;
|
||||
};
|
||||
performance: {
|
||||
fpsUnlocked: boolean;
|
||||
hardwareAccel: boolean;
|
||||
gpuPreference: 'high-performance' | 'low-power' | 'default';
|
||||
};
|
||||
game: {
|
||||
lastServer: string;
|
||||
socialTabBehaviour: 'New Window' | 'Same Window';
|
||||
joinAsSpectator: boolean;
|
||||
};
|
||||
swapper: {
|
||||
enabled: boolean;
|
||||
path: string;
|
||||
};
|
||||
matchmaker: {
|
||||
enabled: boolean;
|
||||
regions: string[];
|
||||
gamemodes: string[];
|
||||
minPlayers: number;
|
||||
maxPlayers: number;
|
||||
minRemainingTime: number;
|
||||
openServerBrowser: boolean;
|
||||
};
|
||||
keybinds: {
|
||||
reload: Keybind;
|
||||
newMatch: Keybind;
|
||||
copyGameLink: Keybind;
|
||||
joinFromClipboard: Keybind;
|
||||
devTools: Keybind;
|
||||
matchmaker: Keybind;
|
||||
matchmakerAccept: Keybind;
|
||||
matchmakerCancel: Keybind;
|
||||
pauseChat: Keybind;
|
||||
fullscreenToggle: Keybind;
|
||||
};
|
||||
userscripts: {
|
||||
enabled: boolean;
|
||||
path: string;
|
||||
};
|
||||
ui: {
|
||||
showExitButton: boolean;
|
||||
deathscreenAnimation: boolean;
|
||||
hideMenuPopups: boolean;
|
||||
};
|
||||
discord: {
|
||||
enabled: boolean;
|
||||
};
|
||||
translator: {
|
||||
enabled: boolean;
|
||||
targetLanguage: string;
|
||||
showLanguageTag: boolean;
|
||||
};
|
||||
advanced: {
|
||||
removeUselessFeatures: boolean;
|
||||
gpuRasterizing: boolean;
|
||||
helpfulFlags: boolean;
|
||||
disableAccelerated2D: boolean;
|
||||
increaseLimits: boolean;
|
||||
lowLatency: boolean;
|
||||
experimentalFlags: boolean;
|
||||
angleBackend: string;
|
||||
};
|
||||
accounts: SavedAccount[];
|
||||
platform: {
|
||||
detectedOS: string;
|
||||
gpuBackend: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const DEFAULT_KEYBINDS: AppConfig['keybinds'] = {
|
||||
reload: { key: 'F5', ctrl: false, shift: false, alt: false },
|
||||
newMatch: { key: 'F4', ctrl: false, shift: false, alt: false },
|
||||
copyGameLink: { key: 'l', ctrl: true, shift: false, alt: false },
|
||||
joinFromClipboard: { key: 'j', ctrl: true, shift: false, alt: false },
|
||||
devTools: { key: 'F12', ctrl: false, shift: false, alt: false },
|
||||
matchmaker: { key: 'F6', ctrl: false, shift: false, alt: false },
|
||||
matchmakerAccept: { key: 'Enter', ctrl: false, shift: false, alt: false },
|
||||
matchmakerCancel: { key: 'Escape', ctrl: false, shift: false, alt: false },
|
||||
pauseChat: { key: 'F10', ctrl: false, shift: false, alt: false },
|
||||
fullscreenToggle: { key: 'F11', ctrl: false, shift: false, alt: false },
|
||||
};
|
||||
|
||||
const platformInfo = detectPlatform();
|
||||
|
||||
export const config = new Store<AppConfig>({
|
||||
name: 'krunker-civilian-config',
|
||||
defaults: {
|
||||
window: {
|
||||
width: 1600,
|
||||
height: 900,
|
||||
x: undefined,
|
||||
y: undefined,
|
||||
maximized: false,
|
||||
fullscreen: false,
|
||||
},
|
||||
performance: {
|
||||
fpsUnlocked: true,
|
||||
hardwareAccel: true,
|
||||
gpuPreference: 'high-performance',
|
||||
},
|
||||
game: {
|
||||
lastServer: '',
|
||||
socialTabBehaviour: 'New Window',
|
||||
joinAsSpectator: false,
|
||||
},
|
||||
swapper: {
|
||||
enabled: true,
|
||||
path: '',
|
||||
},
|
||||
matchmaker: {
|
||||
enabled: true,
|
||||
regions: [],
|
||||
gamemodes: [],
|
||||
minPlayers: 1,
|
||||
maxPlayers: 6,
|
||||
minRemainingTime: 120,
|
||||
openServerBrowser: true,
|
||||
},
|
||||
keybinds: DEFAULT_KEYBINDS,
|
||||
userscripts: {
|
||||
enabled: true,
|
||||
path: '',
|
||||
},
|
||||
ui: {
|
||||
showExitButton: true,
|
||||
deathscreenAnimation: true,
|
||||
hideMenuPopups: false,
|
||||
},
|
||||
discord: {
|
||||
enabled: false,
|
||||
},
|
||||
translator: {
|
||||
enabled: true,
|
||||
targetLanguage: 'en',
|
||||
showLanguageTag: true,
|
||||
},
|
||||
advanced: {
|
||||
removeUselessFeatures: true,
|
||||
gpuRasterizing: false,
|
||||
helpfulFlags: true,
|
||||
disableAccelerated2D: false,
|
||||
increaseLimits: false,
|
||||
lowLatency: false,
|
||||
experimentalFlags: false,
|
||||
angleBackend: 'default',
|
||||
},
|
||||
accounts: [],
|
||||
platform: {
|
||||
detectedOS: platformInfo.os,
|
||||
gpuBackend: platformInfo.gpuBackend,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
// ── 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 = basename(themeId.slice(prefix.length));
|
||||
if (!filename) return '';
|
||||
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 = basename(loadingTheme.slice(5));
|
||||
if (!filename) return '';
|
||||
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;
|
||||
}`;
|
||||
}
|
||||
+740
-916
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -67,7 +67,7 @@ function makeLogger(getStream: () => WriteStream) {
|
||||
|
||||
export const electronLog = makeLogger(() => electronStream);
|
||||
|
||||
export function getLogPath(): string {
|
||||
export function getLogPath(_type: 'electron'): string {
|
||||
init();
|
||||
return electronPath;
|
||||
}
|
||||
|
||||
+5
-17
@@ -42,9 +42,6 @@ export function applyPlatformFlags(info: PlatformInfo, advanced: AppConfig['adva
|
||||
|
||||
// ── Always-on platform flags ──
|
||||
app.commandLine.appendSwitch('disable-backgrounding-occluded-windows');
|
||||
app.commandLine.appendSwitch('disable-threaded-scrolling');
|
||||
app.commandLine.appendSwitch('overscroll-history-navigation', '0');
|
||||
app.commandLine.appendSwitch('pull-to-refresh', '0');
|
||||
// WebGL is mandatory for Krunker — force it past any GPU blocklist.
|
||||
// On Chromium 134+ the blocklist is stricter and silently disables WebGL on many Linux GPUs.
|
||||
app.commandLine.appendSwitch('ignore-gpu-blocklist');
|
||||
@@ -71,8 +68,6 @@ export function applyPlatformFlags(info: PlatformInfo, advanced: AppConfig['adva
|
||||
// ── Remove useless features ──
|
||||
if (advanced.removeUselessFeatures) {
|
||||
app.commandLine.appendSwitch('disable-breakpad');
|
||||
app.commandLine.appendSwitch('disable-crash-reporter');
|
||||
app.commandLine.appendSwitch('disable-crashpad-forwarding');
|
||||
app.commandLine.appendSwitch('disable-print-preview');
|
||||
app.commandLine.appendSwitch('disable-metrics-reporting');
|
||||
app.commandLine.appendSwitch('disable-metrics');
|
||||
@@ -80,9 +75,6 @@ export function applyPlatformFlags(info: PlatformInfo, advanced: AppConfig['adva
|
||||
app.commandLine.appendSwitch('disable-logging');
|
||||
app.commandLine.appendSwitch('disable-hang-monitor');
|
||||
app.commandLine.appendSwitch('disable-component-update');
|
||||
app.commandLine.appendSwitch('disable-bundled-ppapi-flash');
|
||||
app.commandLine.appendSwitch('disable-nacl');
|
||||
app.commandLine.appendSwitch('disable-features', 'NativeNotifications,MediaRouter,PerformanceInterventionUI,HappinessTrackingSurveysForDesktopDemo');
|
||||
}
|
||||
|
||||
// ── GPU rasterization ──
|
||||
@@ -90,8 +82,6 @@ export function applyPlatformFlags(info: PlatformInfo, advanced: AppConfig['adva
|
||||
if (advanced.gpuRasterizing) {
|
||||
app.commandLine.appendSwitch('enable-gpu-rasterization');
|
||||
app.commandLine.appendSwitch('disable-zero-copy');
|
||||
app.commandLine.appendSwitch('disable-software-rasterizer');
|
||||
app.commandLine.appendSwitch('disable-gpu-driver-bug-workarounds');
|
||||
}
|
||||
|
||||
// ── Helpful flags ──
|
||||
@@ -101,9 +91,12 @@ export function applyPlatformFlags(info: PlatformInfo, advanced: AppConfig['adva
|
||||
app.commandLine.appendSwitch('enable-webgl');
|
||||
app.commandLine.appendSwitch('disable-background-timer-throttling');
|
||||
app.commandLine.appendSwitch('disable-renderer-backgrounding');
|
||||
app.commandLine.appendSwitch('disable-best-effort-tasks');
|
||||
app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required');
|
||||
app.commandLine.appendSwitch('enable-features', 'V8VmFuture,WebAssemblyBaseline,WebAssemblyTiering,WebAssemblyLazyCompilation');
|
||||
}
|
||||
|
||||
// ── Disable accelerated 2D canvas ──
|
||||
if (advanced.disableAccelerated2D) {
|
||||
app.commandLine.appendSwitch('disable-accelerated-2d-canvas');
|
||||
}
|
||||
|
||||
// ── Increase limits ──
|
||||
@@ -119,9 +112,6 @@ export function applyPlatformFlags(info: PlatformInfo, advanced: AppConfig['adva
|
||||
// is default on Chromium 42+. These enable flags were removed from the source.
|
||||
if (advanced.lowLatency) {
|
||||
app.commandLine.appendSwitch('force-high-performance-gpu');
|
||||
app.commandLine.appendSwitch('enable-quic');
|
||||
app.commandLine.appendSwitch('quic-max-packet-length', '1460');
|
||||
app.commandLine.appendSwitch('raise-timer-frequency');
|
||||
}
|
||||
|
||||
// ── Experimental flags ──
|
||||
@@ -130,11 +120,9 @@ export function applyPlatformFlags(info: PlatformInfo, advanced: AppConfig['adva
|
||||
// HiDPI is default since M108). Renamed ignore-gpu-blacklist → ignore-gpu-blocklist.
|
||||
if (advanced.experimentalFlags) {
|
||||
app.commandLine.appendSwitch('disable-low-end-device-mode');
|
||||
app.commandLine.appendSwitch('disable-gpu-watchdog');
|
||||
app.commandLine.appendSwitch('ignore-gpu-blocklist');
|
||||
app.commandLine.appendSwitch('no-pings');
|
||||
app.commandLine.appendSwitch('no-proxy-server');
|
||||
app.commandLine.appendSwitch('enable-features', 'BlinkCompositorUseDisplayThreadPriority,GpuUseDisplayThreadPriority');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,516 +0,0 @@
|
||||
import { BrowserWindow } from 'electron';
|
||||
import { QUEUE_NOTIFICATION_AUDIO } from './ranked-queue-audio';
|
||||
|
||||
let queueWindow: BrowserWindow | null = null;
|
||||
|
||||
const RANKED_QUEUE_WS = 'wss://gamefrontend.svc.krunker.io/v1/matchmaking/queue';
|
||||
|
||||
const RANKED_MAPS: Record<string, { number: number; image: string }> = {
|
||||
sandstorm_v3: { number: 2, image: 'https://assets.krunker.io/img/maps/map_2.png' },
|
||||
undergrowth: { number: 4, image: 'https://assets.krunker.io/img/maps/map_4.png' },
|
||||
industry: { number: 11, image: 'https://assets.krunker.io/img/maps/map_11.png' },
|
||||
site: { number: 14, image: 'https://assets.krunker.io/img/maps/map_14.png' },
|
||||
bureau: { number: 17, image: 'https://assets.krunker.io/img/maps/map_17.png' },
|
||||
burg_new: { number: 0, image: 'https://assets.krunker.io/img/maps/map_0.png' },
|
||||
eterno_sim: { number: 39, image: 'https://assets.krunker.io/img/maps/map_39.png' },
|
||||
};
|
||||
|
||||
const RANKED_REGIONS: Record<string, string> = {
|
||||
na: 'North America',
|
||||
eu: 'Europe',
|
||||
as: 'Asia',
|
||||
};
|
||||
|
||||
const QUEUE_CSS = `
|
||||
* { user-select: none; margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: "Trebuchet MS", sans-serif;
|
||||
background: #0d0d0d;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #e0e0e0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.queuer-container {
|
||||
position: relative;
|
||||
background: #1a1a1a;
|
||||
padding: 40px 52px;
|
||||
max-width: 1000px;
|
||||
width: 90vw;
|
||||
border: 2px solid #2a2a2a;
|
||||
border-top: 3px solid #06b6d4;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.7);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.main-content { display: flex; align-items: center; gap: 56px; }
|
||||
.left-section { flex: 1; display: flex; flex-direction: column; gap: 24px; }
|
||||
.status-area {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
position: relative; padding-left: 18px;
|
||||
}
|
||||
.status-area::before {
|
||||
content: ""; position: absolute; left: 0;
|
||||
width: 8px; height: 8px; background: #666;
|
||||
border-radius: 50%; transition: background 0.3s ease;
|
||||
}
|
||||
.status-area.active::before {
|
||||
background: #06b6d4;
|
||||
box-shadow: 0 0 12px rgba(6, 182, 212, 0.6);
|
||||
}
|
||||
#queueStatus {
|
||||
font-size: 14px; font-weight: 600; color: #666;
|
||||
text-transform: uppercase; letter-spacing: 1px;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
#queueStatus.active { color: #06b6d4; }
|
||||
.timer-display {
|
||||
font-size: 52px; font-weight: 700; color: #fff;
|
||||
font-variant-numeric: tabular-nums; letter-spacing: 0.5px;
|
||||
padding: 12px 16px; background: #222;
|
||||
border-left: 3px solid #06b6d4; border-radius: 2px;
|
||||
}
|
||||
.region-controls { display: flex; gap: 12px; }
|
||||
.region-option { position: relative; }
|
||||
.region-option input { display: none; }
|
||||
.region-option label {
|
||||
display: block; padding: 12px 24px; background: #222;
|
||||
border: 2px solid #2d2d2d; border-radius: 4px;
|
||||
color: #888; font-size: 16px; font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: 0.5px;
|
||||
cursor: pointer; transition: all 0.2s ease;
|
||||
}
|
||||
.region-option label:hover { background: #2a2a2a; border-color: #3a3a3a; }
|
||||
.region-option input:checked + label {
|
||||
background: rgba(6, 182, 212, 0.1);
|
||||
border-color: #06b6d4; color: #06b6d4;
|
||||
}
|
||||
.divider { width: 1px; height: 120px; background: #2a2a2a; }
|
||||
.right-section { display: flex; flex-direction: column; gap: 14px; }
|
||||
.btn {
|
||||
padding: 16px 42px; border: 2px solid transparent;
|
||||
font-size: 20px; font-weight: 600; cursor: pointer;
|
||||
transition: all 0.2s ease; font-family: "Trebuchet MS", sans-serif;
|
||||
text-transform: uppercase; letter-spacing: 1px; border-radius: 4px;
|
||||
}
|
||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.btn-secondary { background: #222; color: #999; border-color: #2a2a2a; }
|
||||
.btn-secondary:hover:not(:disabled) { background: #2a2a2a; border-color: #3a3a3a; }
|
||||
.btn-primary { background: #06b6d4; color: #fff; border-color: #06b6d4; }
|
||||
.btn-primary:hover:not(:disabled) { background: #0ea5ca; border-color: #0ea5ca; }
|
||||
.btn-primary:active:not(:disabled) { transform: scale(0.98); }
|
||||
.btn-primary.in-queue { background: #222; border-color: #06b6d4; color: #06b6d4; }
|
||||
.btn-primary.in-queue:hover:not(:disabled) { background: rgba(6, 182, 212, 0.1); }
|
||||
.overlay {
|
||||
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
||||
background: rgba(0, 0, 0, 0.9); display: flex;
|
||||
align-items: center; justify-content: center;
|
||||
opacity: 0; visibility: hidden; transition: all 0.3s ease; z-index: 1000;
|
||||
}
|
||||
.overlay.active { opacity: 1; visibility: visible; }
|
||||
.popup {
|
||||
background: #1a1a1a; border: 2px solid #2a2a2a;
|
||||
border-top: 3px solid #06b6d4; max-width: 560px; width: 90vw;
|
||||
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.8);
|
||||
text-align: center; transform: scale(0.95);
|
||||
transition: transform 0.3s ease; border-radius: 4px;
|
||||
}
|
||||
.overlay.active .popup { transform: scale(1); }
|
||||
.popup h2 {
|
||||
margin-top: 12px; font-size: 32px; font-weight: 700;
|
||||
color: #06b6d4; text-transform: uppercase; letter-spacing: 1.5px;
|
||||
}
|
||||
.popup-content { margin: 20px 0; }
|
||||
.popup-content p {
|
||||
font-size: 15px; color: #888; margin-bottom: 12px;
|
||||
text-transform: uppercase; letter-spacing: 0.5px; font-weight: 600;
|
||||
}
|
||||
.region-found {
|
||||
font-size: 18px; font-weight: 700; color: #fff;
|
||||
text-transform: uppercase; letter-spacing: 1px;
|
||||
display: inline-block; padding: 12px 24px;
|
||||
background: rgba(6, 182, 212, 0.15);
|
||||
border: 2px solid #06b6d4; border-radius: 4px;
|
||||
}
|
||||
.countdown-large {
|
||||
font-size: 48px; font-weight: 700; color: #06b6d4;
|
||||
margin: 16px 0; font-variant-numeric: tabular-nums; line-height: 1;
|
||||
}
|
||||
#matchFoundMessage {
|
||||
margin-top: 12px; font-size: 20px; color: #fff;
|
||||
text-align: center; width: 400px; margin-left: auto; margin-right: auto;
|
||||
}
|
||||
#closeButton {
|
||||
position: absolute; right: 0; top: 0;
|
||||
margin: 10px 20px 0 0; font-size: 20px; cursor: pointer;
|
||||
}
|
||||
`;
|
||||
|
||||
function buildMapsJson(): string {
|
||||
const entries: string[] = [];
|
||||
for (const [name, data] of Object.entries(RANKED_MAPS)) {
|
||||
entries.push(`${JSON.stringify(name)}: { number: ${data.number}, image: ${JSON.stringify(data.image)} }`);
|
||||
}
|
||||
return `{ ${entries.join(', ')} }`;
|
||||
}
|
||||
|
||||
function buildRegionsJson(): string {
|
||||
return JSON.stringify(RANKED_REGIONS);
|
||||
}
|
||||
|
||||
function buildRegionCheckboxes(): string {
|
||||
return Object.entries(RANKED_REGIONS).map(([code, name]) => {
|
||||
const inputId = code === 'as' ? 'asia' : code;
|
||||
return `<div class="region-option"><input type="checkbox" id="${inputId}" value="${code}"><label for="${inputId}">${name === 'North America' ? 'NA' : name === 'Europe' ? 'EU' : 'Asia'}</label></div>`;
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
function buildQueueScript(token: string, region: string, allRegions: boolean): string {
|
||||
return `
|
||||
let isQueued = false;
|
||||
let queueStartTime = null;
|
||||
let queueInterval = null;
|
||||
let queueConnection = null;
|
||||
let countdownInterval = null;
|
||||
let isConnecting = false;
|
||||
let audioContext = null;
|
||||
let notificationBuffer = null;
|
||||
let currentSource = null;
|
||||
let audioInitialized = false;
|
||||
const selectedMaps = new Set();
|
||||
|
||||
const WS_URL = ${JSON.stringify(RANKED_QUEUE_WS)};
|
||||
const INIT_TOKEN = ${JSON.stringify(token)};
|
||||
const INIT_REGION = ${JSON.stringify(region)};
|
||||
const INIT_ALL_REGIONS = ${JSON.stringify(allRegions)};
|
||||
const maps = ${buildMapsJson()};
|
||||
const regions = ${buildRegionsJson()};
|
||||
|
||||
const queueStatus = document.getElementById('queueStatus');
|
||||
const statusArea = document.getElementById('statusArea');
|
||||
const queueTimerDisplay = document.getElementById('queueTimerDisplay');
|
||||
const regionCheckboxes = document.getElementById('regionCheckboxes');
|
||||
const matchPopupOverlay = document.getElementById('matchPopupOverlay');
|
||||
const countdownTimer = document.getElementById('countDownTimer');
|
||||
const foundRegion = document.getElementById('foundRegion');
|
||||
const queueButton = document.getElementById('queueButton');
|
||||
const closeButton = document.getElementById('closeButton');
|
||||
|
||||
const base64String = ${JSON.stringify(QUEUE_NOTIFICATION_AUDIO)};
|
||||
|
||||
function saveSettings() {
|
||||
const selectedRegions = Array.from(document.querySelectorAll('#regionCheckboxes input:checked')).map(el => el.value);
|
||||
localStorage.setItem('queue_selectedRegions', JSON.stringify(selectedRegions));
|
||||
}
|
||||
|
||||
function loadSettings() {
|
||||
const savedRegions = localStorage.getItem('queue_selectedRegions');
|
||||
if (savedRegions) {
|
||||
for (const regionId of JSON.parse(savedRegions)) {
|
||||
const checkbox = document.getElementById(regionId === 'as' ? 'asia' : regionId);
|
||||
if (checkbox) checkbox.checked = true;
|
||||
}
|
||||
} else if (INIT_REGION) {
|
||||
const checkbox = document.getElementById(INIT_REGION === 'as' ? 'asia' : INIT_REGION);
|
||||
if (checkbox) checkbox.checked = true;
|
||||
if (INIT_ALL_REGIONS) {
|
||||
for (const el of document.querySelectorAll('#regionCheckboxes input')) el.checked = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const base64ToArrayBuffer = (b64) => {
|
||||
const bin = atob(b64);
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||
return bytes.buffer;
|
||||
};
|
||||
|
||||
async function initializeAudio() {
|
||||
audioContext = new AudioContext();
|
||||
if (audioContext.state === 'suspended') await audioContext.resume();
|
||||
const arrayBuffer = base64ToArrayBuffer(base64String);
|
||||
notificationBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
||||
audioInitialized = true;
|
||||
}
|
||||
|
||||
function playNotificationSound() {
|
||||
if (!notificationBuffer || !audioContext) return;
|
||||
try {
|
||||
const source = audioContext.createBufferSource();
|
||||
source.buffer = notificationBuffer;
|
||||
source.connect(audioContext.destination);
|
||||
source.start(0);
|
||||
currentSource = source;
|
||||
} catch (e) { console.error('Audio play error:', e); }
|
||||
}
|
||||
|
||||
function stopNotificationSound() {
|
||||
if (currentSource) {
|
||||
try { currentSource.stop(); currentSource.disconnect(); } catch {}
|
||||
currentSource = null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(seconds) {
|
||||
const h = String(Math.floor(seconds / 3600)).padStart(2, '0');
|
||||
const m = String(Math.floor((seconds % 3600) / 60)).padStart(2, '0');
|
||||
const s = String(Math.floor(seconds % 60)).padStart(2, '0');
|
||||
return h + ':' + m + ':' + s;
|
||||
}
|
||||
|
||||
function updateCooldownTimer(ms) {
|
||||
const endTime = Date.now() + ms;
|
||||
function updateDisplay() {
|
||||
const remaining = Math.ceil((endTime - Date.now()) / 1000);
|
||||
if (remaining <= 0) {
|
||||
queueStatus.textContent = 'Ready';
|
||||
queueStatus.classList.remove('active');
|
||||
statusArea.classList.remove('active');
|
||||
queueButton.disabled = false;
|
||||
return;
|
||||
}
|
||||
queueStatus.textContent = 'Cooldown: ' + formatTime(remaining);
|
||||
queueButton.disabled = true;
|
||||
setTimeout(updateDisplay, 1000);
|
||||
}
|
||||
updateDisplay();
|
||||
}
|
||||
|
||||
function updateQueueTimer() {
|
||||
if (queueStartTime) {
|
||||
const elapsed = Math.floor((Date.now() - queueStartTime) / 1000);
|
||||
queueTimerDisplay.textContent = formatTime(elapsed);
|
||||
}
|
||||
}
|
||||
|
||||
function startQueue() {
|
||||
const selectedRegions = Array.from(document.querySelectorAll('#regionCheckboxes input:checked')).map(el => el.value);
|
||||
|
||||
if (selectedRegions.length === 0) {
|
||||
queueStatus.textContent = 'Select at least one region';
|
||||
queueButton.disabled = false;
|
||||
isConnecting = false;
|
||||
return;
|
||||
}
|
||||
if (selectedMaps.size === 0) {
|
||||
queueStatus.textContent = 'Select at least one map';
|
||||
queueButton.disabled = false;
|
||||
isConnecting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const wsUrl = WS_URL + '?token=' + INIT_TOKEN + '&maps=' + Array.from(selectedMaps).join(',') + '®ions=' + selectedRegions.join(',');
|
||||
|
||||
try {
|
||||
queueConnection = new WebSocket(wsUrl);
|
||||
} catch (error) {
|
||||
console.error('WebSocket creation error:', error);
|
||||
queueStatus.textContent = 'Connection failed';
|
||||
queueButton.disabled = false;
|
||||
isConnecting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
queueConnection.onerror = (error) => {
|
||||
console.error('queueConnection error:', error);
|
||||
queueStatus.textContent = 'Connection error';
|
||||
queueStatus.classList.remove('active');
|
||||
statusArea.classList.remove('active');
|
||||
isQueued = false;
|
||||
isConnecting = false;
|
||||
queueButton.disabled = false;
|
||||
};
|
||||
|
||||
queueConnection.onopen = () => {
|
||||
isQueued = true;
|
||||
isConnecting = false;
|
||||
queueStartTime = Date.now();
|
||||
queueButton.textContent = 'Leave Queue';
|
||||
queueButton.classList.add('in-queue');
|
||||
queueStatus.textContent = 'In queue';
|
||||
queueStatus.classList.add('active');
|
||||
statusArea.classList.add('active');
|
||||
updateQueueTimer();
|
||||
queueInterval = setInterval(updateQueueTimer, 1000);
|
||||
queueButton.disabled = false;
|
||||
};
|
||||
|
||||
queueConnection.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
switch (data.type) {
|
||||
case 'QUEUE_STATUS':
|
||||
if (data.payload.status === 'MATCHED')
|
||||
matchFound(data.payload.assignment.extensions.map.trim(), data.payload.assignment.extensions.region);
|
||||
break;
|
||||
case 'ERROR':
|
||||
if (data.payload.code === 'COOLDOWN') {
|
||||
queueConnection.close();
|
||||
isQueued = false;
|
||||
isConnecting = false;
|
||||
queueButton.disabled = false;
|
||||
updateCooldownTimer(data.payload.payload.cooldown);
|
||||
}
|
||||
break;
|
||||
case 'INTERNAL_ERROR':
|
||||
queueConnection.close();
|
||||
isQueued = false;
|
||||
isConnecting = false;
|
||||
queueButton.disabled = false;
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
queueConnection.onclose = () => {
|
||||
isQueued = false;
|
||||
isConnecting = false;
|
||||
clearInterval(queueInterval);
|
||||
queueButton.textContent = 'Start Queue';
|
||||
queueButton.classList.remove('in-queue');
|
||||
queueStatus.textContent = 'Ready';
|
||||
queueStatus.classList.remove('active');
|
||||
statusArea.classList.remove('active');
|
||||
queueTimerDisplay.textContent = '00:00:00';
|
||||
queueButton.disabled = false;
|
||||
};
|
||||
}
|
||||
|
||||
function matchFound(map, region) {
|
||||
playNotificationSound();
|
||||
matchPopupOverlay.classList.add('active');
|
||||
region = region.slice(2);
|
||||
const regionName = regions[region] || region;
|
||||
let foundMapName = 'unknown';
|
||||
for (const [mapName, mapData] of Object.entries(maps)) {
|
||||
if (mapData.number === parseInt(map, 10)) { foundMapName = mapName; break; }
|
||||
}
|
||||
foundRegion.textContent = regionName + ', ' + foundMapName;
|
||||
|
||||
const duration = 60;
|
||||
const startTime = Date.now();
|
||||
countdownInterval = setInterval(() => {
|
||||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||
const remaining = Math.max(0, duration - elapsed);
|
||||
countdownTimer.textContent = formatTime(remaining);
|
||||
if (remaining <= 0) {
|
||||
clearInterval(countdownInterval);
|
||||
matchPopupOverlay.classList.remove('active');
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
isQueued = false;
|
||||
isConnecting = false;
|
||||
clearInterval(queueInterval);
|
||||
queueButton.textContent = 'Start Queue';
|
||||
queueButton.classList.remove('in-queue');
|
||||
queueStatus.textContent = 'Ready';
|
||||
queueStatus.classList.remove('active');
|
||||
statusArea.classList.remove('active');
|
||||
queueTimerDisplay.textContent = '00:00:00';
|
||||
queueButton.disabled = false;
|
||||
}
|
||||
|
||||
// Queue button
|
||||
queueButton.onclick = async () => {
|
||||
if (isConnecting) return;
|
||||
queueButton.disabled = true;
|
||||
isConnecting = true;
|
||||
if (isQueued) {
|
||||
queueConnection.close();
|
||||
} else {
|
||||
if (!audioInitialized) await initializeAudio();
|
||||
startQueue();
|
||||
}
|
||||
};
|
||||
|
||||
// Close match popup
|
||||
closeButton.onclick = () => {
|
||||
matchPopupOverlay.classList.remove('active');
|
||||
stopNotificationSound();
|
||||
if (countdownInterval) clearInterval(countdownInterval);
|
||||
};
|
||||
|
||||
// Region checkbox changes
|
||||
for (const sel of regionCheckboxes.querySelectorAll('input')) {
|
||||
sel.onclick = () => {
|
||||
if (isQueued && queueConnection) queueConnection.close();
|
||||
saveSettings();
|
||||
};
|
||||
}
|
||||
|
||||
// Init — select all maps unconditionally (ranked doesn't allow map choice)
|
||||
for (const data of Object.values(maps)) selectedMaps.add(data.number);
|
||||
loadSettings();
|
||||
`;
|
||||
}
|
||||
|
||||
function buildQueueHtml(token: string, region: string, allRegions: boolean): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en-US">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Ranked Queue</title>
|
||||
<style>${QUEUE_CSS}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="queuer-container">
|
||||
<div class="main-content">
|
||||
<div class="left-section">
|
||||
<div class="status-area" id="statusArea">
|
||||
<span id="queueStatus">Ready</span>
|
||||
</div>
|
||||
<div class="timer-display" id="queueTimerDisplay">00:00:00</div>
|
||||
<div class="region-controls" id="regionCheckboxes">
|
||||
${buildRegionCheckboxes()}
|
||||
</div>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<div class="right-section">
|
||||
<button type="button" class="btn btn-primary" id="queueButton">Start Queue</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overlay" id="matchPopupOverlay">
|
||||
<div class="popup">
|
||||
<h2>Match Found</h2>
|
||||
<div class="popup-content">
|
||||
<div id="closeButton">X</div>
|
||||
<div class="region-found" id="foundRegion">Region: </div>
|
||||
<div id="matchFoundMessage">open the client and rejoin the game from the ranked menu</div>
|
||||
<div class="countdown-large" id="countDownTimer">00:00:60</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>${buildQueueScript(token, region, allRegions)}</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export function openRankedQueue(token: string, region: string, allRegions: boolean): void {
|
||||
if (queueWindow && !queueWindow.isDestroyed()) {
|
||||
queueWindow.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const win = new BrowserWindow({
|
||||
width: 850,
|
||||
height: 350,
|
||||
resizable: false,
|
||||
autoHideMenuBar: true,
|
||||
backgroundColor: '#0d0d0d',
|
||||
title: 'Ranked Queue',
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
},
|
||||
});
|
||||
win.removeMenu();
|
||||
|
||||
queueWindow = win;
|
||||
win.on('closed', () => { queueWindow = null; });
|
||||
|
||||
const html = buildQueueHtml(token, region, allRegions);
|
||||
win.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(html));
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
// ── 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);
|
||||
@@ -1,724 +0,0 @@
|
||||
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 getSavedTabs: () => string[];
|
||||
private saveTabs: (urls: string[]) => void;
|
||||
private isRememberEnabled: () => boolean;
|
||||
private tabSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private restoredTabs = false;
|
||||
|
||||
constructor(
|
||||
win: BrowserWindow,
|
||||
ses: Electron.Session,
|
||||
preloadPath: string,
|
||||
mode: TabMode,
|
||||
isGameURL: (url: string) => boolean,
|
||||
getTabWindowState: () => TabWindowState,
|
||||
saveTabWindowState: (state: TabWindowState) => void,
|
||||
getSavedTabs: () => string[],
|
||||
saveTabs: (urls: string[]) => void,
|
||||
isRememberEnabled: () => boolean,
|
||||
) {
|
||||
this.mainWin = win;
|
||||
this.ses = ses;
|
||||
this.preloadPath = preloadPath;
|
||||
this.mode = mode;
|
||||
this.isGameURL = isGameURL;
|
||||
this.getTabWindowState = getTabWindowState;
|
||||
this.saveTabWindowState = saveTabWindowState;
|
||||
this.getSavedTabs = getSavedTabs;
|
||||
this.saveTabs = saveTabs;
|
||||
this.isRememberEnabled = isRememberEnabled;
|
||||
|
||||
// ── 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Restore saved tabs on first open, then open the requested tab ──
|
||||
openTab(url: string): number {
|
||||
if (!this.restoredTabs) {
|
||||
this.restoredTabs = true;
|
||||
const saved = this.isRememberEnabled() ? this.getSavedTabs() : [];
|
||||
this.saveTabs([]);
|
||||
if (saved.length > 0) {
|
||||
for (const savedUrl of saved) {
|
||||
this.openSingleTab(savedUrl);
|
||||
}
|
||||
// If the requested URL is already among the restored tabs, just activate it
|
||||
const existing = this.tabs.find(t => t.url === url);
|
||||
if (existing) {
|
||||
this.switchToTab(existing.id);
|
||||
this.showTabs();
|
||||
return existing.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
return this.openSingleTab(url);
|
||||
}
|
||||
|
||||
// ── Open a single new tab ──
|
||||
private openSingleTab(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: true,
|
||||
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 && this.activeTabId !== id) {
|
||||
const prev = this.tabs.find(t => t.id === this.activeTabId);
|
||||
if (prev) {
|
||||
this.containerView.removeChildView(prev.view);
|
||||
this.freezeTab(prev);
|
||||
}
|
||||
}
|
||||
|
||||
this.activeTabId = id;
|
||||
this.unfreezeTab(tab);
|
||||
this.containerView.addChildView(tab.view);
|
||||
this.updateLayout();
|
||||
this.broadcastTabState();
|
||||
}
|
||||
|
||||
// ── Freeze/unfreeze background tabs via CDP Page Lifecycle ──
|
||||
private freezeTab(tab: TabInfo): void {
|
||||
const wc = tab.view.webContents;
|
||||
if (wc.isDestroyed()) return;
|
||||
this.stopTitleWatcher(tab.id);
|
||||
try { wc.debugger.attach('1.3'); } catch { /* already attached (DevTools open) */ }
|
||||
wc.debugger.sendCommand('Page.setWebLifecycleState', { state: 'frozen' }).catch(() => {});
|
||||
}
|
||||
|
||||
private unfreezeTab(tab: TabInfo): void {
|
||||
const wc = tab.view.webContents;
|
||||
if (wc.isDestroyed()) return;
|
||||
wc.debugger.sendCommand('Page.setWebLifecycleState', { state: 'active' }).catch(() => {}).finally(() => {
|
||||
try { wc.debugger.detach(); } catch { /* not attached */ }
|
||||
if (!wc.isDestroyed()) this.startTitleWatcher(tab.id, wc);
|
||||
});
|
||||
}
|
||||
|
||||
// ── 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 {
|
||||
// Persist tab URLs so they can be restored later
|
||||
if (this.tabs.length > 0 && this.isRememberEnabled()) {
|
||||
this.saveTabs(this.tabs.map(t => t.url));
|
||||
this.restoredTabs = false;
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,17 @@ const UPDATE_HTML = `<!DOCTYPE html>
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar" id="progressBar"></div>
|
||||
</div>
|
||||
<script>
|
||||
const { ipcRenderer } = require('electron');
|
||||
const statusEl = document.getElementById('status');
|
||||
const progressBar = document.getElementById('progressBar');
|
||||
ipcRenderer.on('update-progress', function(event, message, percent) {
|
||||
statusEl.textContent = message;
|
||||
if (typeof percent === 'number') {
|
||||
progressBar.style.width = percent + '%';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
@@ -66,9 +77,9 @@ export function showUpdateWindow(): { window: BrowserWindow; sendProgress: (mess
|
||||
autoHideMenuBar: true,
|
||||
title: 'Krunker Civilian Client - Update',
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
nodeIntegration: true,
|
||||
contextIsolation: false,
|
||||
sandbox: false,
|
||||
},
|
||||
});
|
||||
win.removeMenu();
|
||||
@@ -77,12 +88,7 @@ export function showUpdateWindow(): { window: BrowserWindow; sendProgress: (mess
|
||||
|
||||
function sendProgress(message: string, percent?: number): void {
|
||||
if (!win.isDestroyed()) {
|
||||
win.webContents.executeJavaScript(`(() => {
|
||||
const s = document.getElementById('status');
|
||||
const p = document.getElementById('progressBar');
|
||||
if (s) s.textContent = ${JSON.stringify(message)};
|
||||
if (p && typeof ${JSON.stringify(percent)} === 'number') p.style.width = ${JSON.stringify(percent)} + '%';
|
||||
})()`).catch(() => {});
|
||||
win.webContents.send('update-progress', message, percent);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+245
-487
@@ -1,487 +1,245 @@
|
||||
import { get as httpsGet } from 'https';
|
||||
import { createReadStream, createWriteStream, writeFileSync, renameSync, unlinkSync, existsSync, mkdirSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { createHash } from 'crypto';
|
||||
import { spawn } from 'child_process';
|
||||
import { app } from 'electron';
|
||||
import { electronLog } from './logger';
|
||||
|
||||
// ── Types ──
|
||||
|
||||
export type UpdateType = 'minor' | 'major';
|
||||
|
||||
export interface UpdateInfo {
|
||||
version: string;
|
||||
updateType: UpdateType;
|
||||
downloadUrl: string;
|
||||
fileSize: number;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export type ProgressCallback = (percent: number) => void;
|
||||
|
||||
// ── Build-time update source (injected by Vite define) ──
|
||||
|
||||
declare const __UPDATE_SOURCE__: 'github' | 'gitea';
|
||||
|
||||
interface UpdateSourceConfig {
|
||||
checkUrl: string;
|
||||
allowedHosts: string[];
|
||||
checksumSource: 'digest' | 'file';
|
||||
}
|
||||
|
||||
const UPDATE_SOURCES: Record<string, UpdateSourceConfig> = {
|
||||
github: {
|
||||
checkUrl: 'https://api.github.com/repos/bigjakk/Krunker-Civilian-Client/releases/latest',
|
||||
allowedHosts: ['github.com', 'githubusercontent.com'],
|
||||
checksumSource: 'digest',
|
||||
},
|
||||
gitea: {
|
||||
checkUrl: 'https://gitea.crjlab.net/api/v1/repos/bigjakk/Krunker-Civilian-Client-Test/releases/latest',
|
||||
allowedHosts: ['gitea.crjlab.net'],
|
||||
checksumSource: 'file',
|
||||
},
|
||||
};
|
||||
|
||||
const sourceKey = typeof __UPDATE_SOURCE__ !== 'undefined' ? __UPDATE_SOURCE__ : 'github';
|
||||
const UPDATE_CONFIG = UPDATE_SOURCES[sourceKey] || UPDATE_SOURCES.github;
|
||||
|
||||
const ASSET_PATTERNS = {
|
||||
asar: /^app\.asar$/i,
|
||||
setup: /Setup\.exe$/i,
|
||||
checksums: /^checksums\.sha256$/i,
|
||||
};
|
||||
|
||||
const CHECK_TIMEOUT_MS = 10000;
|
||||
const DOWNLOAD_TIMEOUT_MS = 300000; // 5 minutes
|
||||
|
||||
// ── Swap scripts (embedded, written to temp at runtime) ──
|
||||
|
||||
const SWAP_SCRIPT_PS1 = `param(
|
||||
[int]$ProcessId,
|
||||
[string]$ResourcesDir,
|
||||
[string]$ExePath
|
||||
)
|
||||
|
||||
try {
|
||||
$proc = Get-Process -Id $ProcessId -ErrorAction SilentlyContinue
|
||||
if ($proc) { $proc.WaitForExit(30000) | Out-Null }
|
||||
} catch {}
|
||||
|
||||
Start-Sleep -Milliseconds 500
|
||||
|
||||
$asar = Join-Path $ResourcesDir "app.asar"
|
||||
$pending = Join-Path $ResourcesDir "app-pending.asar"
|
||||
$backup = Join-Path $ResourcesDir "app-backup.asar"
|
||||
|
||||
if (-not (Test-Path $pending)) { exit 1 }
|
||||
|
||||
try {
|
||||
if (Test-Path $backup) { Remove-Item $backup -Force }
|
||||
Rename-Item $asar $backup -Force
|
||||
Rename-Item $pending $asar -Force
|
||||
if (Test-Path $backup) { Remove-Item $backup -Force }
|
||||
} catch {
|
||||
if ((Test-Path $backup) -and -not (Test-Path $asar)) {
|
||||
Rename-Item $backup $asar -Force
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
|
||||
Start-Process $ExePath
|
||||
`;
|
||||
|
||||
const SWAP_SCRIPT_BASH = `#!/bin/bash
|
||||
PID="$1"
|
||||
RESOURCES_DIR="$2"
|
||||
EXE_PATH="$3"
|
||||
|
||||
while kill -0 "$PID" 2>/dev/null; do sleep 0.2; done
|
||||
sleep 0.5
|
||||
|
||||
ASAR="$RESOURCES_DIR/app.asar"
|
||||
PENDING="$RESOURCES_DIR/app-pending.asar"
|
||||
BACKUP="$RESOURCES_DIR/app-backup.asar"
|
||||
|
||||
[ -f "$PENDING" ] || exit 1
|
||||
|
||||
rm -f "$BACKUP"
|
||||
mv "$ASAR" "$BACKUP" && mv "$PENDING" "$ASAR" && rm -f "$BACKUP" || {
|
||||
[ -f "$BACKUP" ] && [ ! -f "$ASAR" ] && mv "$BACKUP" "$ASAR"
|
||||
exit 1
|
||||
}
|
||||
|
||||
"$EXE_PATH" &
|
||||
`;
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
function isAllowedRedirect(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return UPDATE_CONFIG.allowedHosts.some(h => parsed.hostname === h || parsed.hostname.endsWith('.' + h));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
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 simpleGet(url: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
function doGet(getUrl: string, redirectCount = 0): void {
|
||||
if (redirectCount > 5) {
|
||||
reject(new Error('Too many redirects'));
|
||||
return;
|
||||
}
|
||||
const req = httpsGet(getUrl, {
|
||||
headers: { 'User-Agent': 'KrunkerCivilianClient' },
|
||||
}, (res) => {
|
||||
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
if (!isAllowedRedirect(res.headers.location)) {
|
||||
reject(new Error('Redirect to untrusted host: ' + res.headers.location));
|
||||
return;
|
||||
}
|
||||
doGet(res.headers.location, redirectCount + 1);
|
||||
return;
|
||||
}
|
||||
if (res.statusCode !== 200) {
|
||||
reject(new Error('HTTP ' + res.statusCode));
|
||||
return;
|
||||
}
|
||||
let data = '';
|
||||
res.on('data', (chunk: string) => { data += chunk; });
|
||||
res.on('end', () => resolve(data));
|
||||
res.on('error', reject);
|
||||
});
|
||||
req.setTimeout(CHECK_TIMEOUT_MS, () => {
|
||||
req.destroy();
|
||||
reject(new Error('Request timed out'));
|
||||
});
|
||||
req.on('error', reject);
|
||||
}
|
||||
doGet(url);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and parse a checksums.sha256 file from a release asset URL.
|
||||
* Format: "<sha256> <filename>" per line.
|
||||
*/
|
||||
async function fetchChecksums(url: string): Promise<Map<string, string>> {
|
||||
const text = await simpleGet(url);
|
||||
const map = new Map<string, string>();
|
||||
for (const line of text.split('\n')) {
|
||||
const match = line.trim().match(/^([a-f0-9]{64})\s+(.+)$/i);
|
||||
if (match) map.set(match[2].trim(), match[1].toLowerCase());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// ── Update check ──
|
||||
|
||||
interface ReleaseAsset {
|
||||
name: string;
|
||||
browser_download_url: string;
|
||||
size: number;
|
||||
digest?: string;
|
||||
}
|
||||
|
||||
export function checkForUpdate(currentVersion: string): Promise<UpdateInfo | null> {
|
||||
return new Promise((resolve) => {
|
||||
electronLog.log('[KCC-Update] Checking for updates at:', UPDATE_CONFIG.checkUrl);
|
||||
electronLog.log('[KCC-Update] Current version:', currentVersion);
|
||||
|
||||
const req = httpsGet(UPDATE_CONFIG.checkUrl, {
|
||||
headers: { 'User-Agent': 'KrunkerCivilianClient/' + currentVersion },
|
||||
}, (res) => {
|
||||
electronLog.log('[KCC-Update] Check response status:', res.statusCode);
|
||||
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
const redirectUrl = res.headers.location;
|
||||
electronLog.log('[KCC-Update] Redirected to:', redirectUrl);
|
||||
if (!isAllowedRedirect(redirectUrl)) {
|
||||
electronLog.error('[KCC-Update] Redirect to untrusted host blocked:', redirectUrl);
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
httpsGet(redirectUrl, {
|
||||
headers: { 'User-Agent': 'KrunkerCivilianClient/' + currentVersion },
|
||||
}, (redirectRes) => {
|
||||
electronLog.log('[KCC-Update] Redirect response status:', redirectRes.statusCode);
|
||||
handleResponse(redirectRes);
|
||||
}).on('error', (err) => {
|
||||
electronLog.error('[KCC-Update] Redirect error:', err);
|
||||
resolve(null);
|
||||
});
|
||||
return;
|
||||
}
|
||||
handleResponse(res);
|
||||
});
|
||||
|
||||
async function handleResponse(res: import('http').IncomingMessage): Promise<void> {
|
||||
if (res.statusCode !== 200) {
|
||||
electronLog.error('[KCC-Update] Check returned status', res.statusCode);
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let data = '';
|
||||
res.on('data', (chunk: string) => { data += chunk; });
|
||||
res.on('end', async () => {
|
||||
try {
|
||||
const release = JSON.parse(data);
|
||||
const tagName: string = release.tag_name || '';
|
||||
const remoteVersion = tagName.replace(/^v/i, '');
|
||||
electronLog.log('[KCC-Update] Latest release:', remoteVersion, '| Current:', currentVersion);
|
||||
|
||||
if (!remoteVersion || !versionLessThan(currentVersion, remoteVersion)) {
|
||||
electronLog.log('[KCC-Update] Already up to date');
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const assets: ReleaseAsset[] = release.assets || [];
|
||||
|
||||
// Determine update type: prefer minor (asar) over major (setup)
|
||||
const asarAsset = assets.find((a) => ASSET_PATTERNS.asar.test(a.name));
|
||||
const setupAsset = assets.find((a) => ASSET_PATTERNS.setup.test(a.name));
|
||||
const chosenAsset = asarAsset || setupAsset;
|
||||
const updateType: UpdateType = asarAsset ? 'minor' : 'major';
|
||||
|
||||
if (!chosenAsset) {
|
||||
electronLog.error('[KCC-Update] No app.asar or Setup.exe asset found in release', remoteVersion);
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isAllowedRedirect(chosenAsset.browser_download_url)) {
|
||||
electronLog.error('[KCC-Update] Download URL points to untrusted host:', chosenAsset.browser_download_url);
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve SHA-256 checksum
|
||||
let sha256 = '';
|
||||
if (UPDATE_CONFIG.checksumSource === 'digest') {
|
||||
sha256 = (chosenAsset.digest || '').replace(/^sha256:/i, '');
|
||||
if (!sha256) {
|
||||
electronLog.error('[KCC-Update] No SHA-256 digest found for asset');
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Fetch checksums.sha256 companion file
|
||||
const checksumAsset = assets.find((a) => ASSET_PATTERNS.checksums.test(a.name));
|
||||
if (checksumAsset) {
|
||||
try {
|
||||
const checksums = await fetchChecksums(checksumAsset.browser_download_url);
|
||||
sha256 = checksums.get(chosenAsset.name) || '';
|
||||
} catch (err) {
|
||||
electronLog.error('[KCC-Update] Failed to fetch checksums:', err);
|
||||
}
|
||||
}
|
||||
if (!sha256) {
|
||||
electronLog.warn('[KCC-Update] No checksum available — proceeding without verification');
|
||||
}
|
||||
}
|
||||
|
||||
electronLog.log('[KCC-Update] Update available:', remoteVersion,
|
||||
'| Type:', updateType,
|
||||
'| SHA-256:', sha256 ? sha256.substring(0, 16) + '...' : 'none');
|
||||
|
||||
resolve({
|
||||
version: remoteVersion,
|
||||
updateType,
|
||||
downloadUrl: chosenAsset.browser_download_url,
|
||||
fileSize: chosenAsset.size,
|
||||
sha256,
|
||||
});
|
||||
} catch (err) {
|
||||
electronLog.error('[KCC-Update] Failed to parse release data:', err);
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
res.on('error', (err) => {
|
||||
electronLog.error('[KCC-Update] Response error:', err);
|
||||
resolve(null);
|
||||
});
|
||||
}
|
||||
|
||||
req.setTimeout(CHECK_TIMEOUT_MS, () => {
|
||||
electronLog.error('[KCC-Update] Check timed out after', CHECK_TIMEOUT_MS, 'ms');
|
||||
req.destroy();
|
||||
resolve(null);
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
electronLog.error('[KCC-Update] Check error:', err);
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Download ──
|
||||
|
||||
function verifyChecksum(filePath: string, expectedSha256: string): Promise<boolean> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = createHash('sha256');
|
||||
const stream = createReadStream(filePath);
|
||||
stream.on('data', (chunk) => hash.update(chunk));
|
||||
stream.on('end', () => {
|
||||
const actual = hash.digest('hex');
|
||||
electronLog.log('[KCC-Update] SHA-256 expected:', expectedSha256);
|
||||
electronLog.log('[KCC-Update] SHA-256 actual: ', actual);
|
||||
resolve(actual === expectedSha256);
|
||||
});
|
||||
stream.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
export function downloadUpdate(url: string, destPath: string, onProgress: ProgressCallback, expectedSha256?: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tmpPath = destPath + '.tmp';
|
||||
|
||||
function doDownload(downloadUrl: string, redirectCount = 0): void {
|
||||
if (redirectCount > 5) {
|
||||
reject(new Error('Too many redirects'));
|
||||
return;
|
||||
}
|
||||
electronLog.log('[KCC-Update] Downloading from:', downloadUrl);
|
||||
const req = httpsGet(downloadUrl, {
|
||||
headers: { 'User-Agent': 'KrunkerCivilianClient' },
|
||||
}, (res) => {
|
||||
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
const redirectUrl = res.headers.location;
|
||||
electronLog.log('[KCC-Update] Download redirected to:', redirectUrl);
|
||||
if (!isAllowedRedirect(redirectUrl)) {
|
||||
electronLog.error('[KCC-Update] Download redirect to untrusted host blocked:', redirectUrl);
|
||||
reject(new Error('Download redirect to untrusted host: ' + redirectUrl));
|
||||
return;
|
||||
}
|
||||
doDownload(redirectUrl, redirectCount + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.statusCode !== 200) {
|
||||
electronLog.error('[KCC-Update] Download returned status', res.statusCode, 'from:', downloadUrl);
|
||||
reject(new Error('Download returned status ' + res.statusCode));
|
||||
return;
|
||||
}
|
||||
|
||||
const total = parseInt(res.headers['content-length'] || '0', 10);
|
||||
let received = 0;
|
||||
|
||||
const file = createWriteStream(tmpPath);
|
||||
res.on('data', (chunk: Buffer) => {
|
||||
received += chunk.length;
|
||||
if (total > 0) {
|
||||
onProgress(Math.round(100 * received / total));
|
||||
}
|
||||
});
|
||||
res.pipe(file);
|
||||
|
||||
file.on('finish', () => {
|
||||
file.close(async () => {
|
||||
try {
|
||||
if (expectedSha256) {
|
||||
const valid = await verifyChecksum(tmpPath, expectedSha256);
|
||||
if (!valid) {
|
||||
electronLog.error('[KCC-Update] Checksum mismatch — file may be corrupted or tampered');
|
||||
try { unlinkSync(tmpPath); } catch { /* ignore */ }
|
||||
reject(new Error('SHA-256 checksum mismatch'));
|
||||
return;
|
||||
}
|
||||
electronLog.log('[KCC-Update] Checksum verified');
|
||||
}
|
||||
if (existsSync(destPath)) unlinkSync(destPath);
|
||||
renameSync(tmpPath, destPath);
|
||||
resolve();
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
file.on('error', (err) => {
|
||||
try { unlinkSync(tmpPath); } catch { /* ignore */ }
|
||||
reject(err);
|
||||
});
|
||||
|
||||
res.on('error', (err) => {
|
||||
try { unlinkSync(tmpPath); } catch { /* ignore */ }
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
|
||||
req.setTimeout(DOWNLOAD_TIMEOUT_MS, () => {
|
||||
req.destroy();
|
||||
try { unlinkSync(tmpPath); } catch { /* ignore */ }
|
||||
reject(new Error('Download timed out'));
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
try { unlinkSync(tmpPath); } catch { /* ignore */ }
|
||||
reject(err);
|
||||
});
|
||||
}
|
||||
|
||||
doDownload(url);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Install / Apply ──
|
||||
|
||||
export function installUpdate(installerPath: string): void {
|
||||
electronLog.log('[KCC-Update] Launching installer:', installerPath);
|
||||
const child = spawn(installerPath, [], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
child.unref();
|
||||
app.quit();
|
||||
}
|
||||
|
||||
export function applyMinorUpdate(pendingAsarPath: string): void {
|
||||
const resourcesDir = dirname(pendingAsarPath);
|
||||
const exePath = app.getPath('exe');
|
||||
const tempDir = join(app.getPath('temp'), 'kcc-update');
|
||||
if (!existsSync(tempDir)) mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
electronLog.log('[KCC-Update] Applying minor update via swap script');
|
||||
electronLog.log('[KCC-Update] Resources dir:', resourcesDir);
|
||||
electronLog.log('[KCC-Update] Exe path:', exePath);
|
||||
electronLog.log('[KCC-Update] PID:', process.pid);
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const scriptPath = join(tempDir, 'swap-asar.ps1');
|
||||
writeFileSync(scriptPath, SWAP_SCRIPT_PS1);
|
||||
const child = spawn('powershell.exe', [
|
||||
'-ExecutionPolicy', 'Bypass',
|
||||
'-File', scriptPath,
|
||||
'-ProcessId', String(process.pid),
|
||||
'-ResourcesDir', resourcesDir,
|
||||
'-ExePath', exePath,
|
||||
], { detached: true, stdio: 'ignore' });
|
||||
child.unref();
|
||||
} else {
|
||||
const scriptPath = join(tempDir, 'swap-asar.sh');
|
||||
writeFileSync(scriptPath, SWAP_SCRIPT_BASH, { mode: 0o755 });
|
||||
const child = spawn('bash', [
|
||||
scriptPath, String(process.pid), resourcesDir, exePath,
|
||||
], { detached: true, stdio: 'ignore' });
|
||||
child.unref();
|
||||
}
|
||||
|
||||
app.quit();
|
||||
}
|
||||
import { get as httpsGet } from 'https';
|
||||
import { createWriteStream, renameSync, unlinkSync, existsSync } from 'fs';
|
||||
import { spawn } from 'child_process';
|
||||
import { app } from 'electron';
|
||||
import { electronLog } from './logger';
|
||||
|
||||
export interface UpdateInfo {
|
||||
version: string;
|
||||
downloadUrl: string;
|
||||
fileSize: number;
|
||||
}
|
||||
|
||||
export type ProgressCallback = (percent: number) => void;
|
||||
|
||||
const UPDATE_CONFIG = {
|
||||
// Gitea provider (swap these for kpdclient.com migration)
|
||||
checkUrl: 'https://gitea.crjlab.net/api/v1/repos/bigjakk/Krunker-Civilian-Client/releases/latest',
|
||||
assetPattern: /Setup\.exe$/i,
|
||||
// Allowed hosts for update check and download (including redirects)
|
||||
allowedHosts: ['gitea.crjlab.net'],
|
||||
};
|
||||
|
||||
const CHECK_TIMEOUT_MS = 10000;
|
||||
const DOWNLOAD_TIMEOUT_MS = 300000; // 5 minutes
|
||||
|
||||
/**
|
||||
* Validate that a redirect URL stays on an allowed host.
|
||||
*/
|
||||
function isAllowedRedirect(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return UPDATE_CONFIG.allowedHosts.some(h => parsed.hostname === h || parsed.hostname.endsWith('.' + h));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple semver comparison: returns true if a < b.
|
||||
* Handles versions like "0.1.0", "1.2.3".
|
||||
*/
|
||||
function versionLessThan(a: string, b: string): boolean {
|
||||
const pa = a.split('.').map(Number);
|
||||
const pb = b.split('.').map(Number);
|
||||
const len = Math.max(pa.length, pb.length);
|
||||
for (let i = 0; i < len; i++) {
|
||||
const na = pa[i] || 0;
|
||||
const nb = pb[i] || 0;
|
||||
if (na < nb) return true;
|
||||
if (na > nb) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function checkForUpdate(currentVersion: string): Promise<UpdateInfo | null> {
|
||||
return new Promise((resolve) => {
|
||||
electronLog.log('[KCC-Update] Checking for updates at:', UPDATE_CONFIG.checkUrl);
|
||||
electronLog.log('[KCC-Update] Current version:', currentVersion);
|
||||
|
||||
const req = httpsGet(UPDATE_CONFIG.checkUrl, {
|
||||
headers: { 'User-Agent': 'KrunkerCivilianClient/' + currentVersion },
|
||||
}, (res) => {
|
||||
electronLog.log('[KCC-Update] Check response status:', res.statusCode);
|
||||
// Follow redirects (with domain validation)
|
||||
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
const redirectUrl = res.headers.location;
|
||||
electronLog.log('[KCC-Update] Redirected to:', redirectUrl);
|
||||
if (!isAllowedRedirect(redirectUrl)) {
|
||||
electronLog.error('[KCC-Update] Redirect to untrusted host blocked:', redirectUrl);
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
httpsGet(redirectUrl, {
|
||||
headers: { 'User-Agent': 'KrunkerCivilianClient/' + currentVersion },
|
||||
}, (redirectRes) => {
|
||||
electronLog.log('[KCC-Update] Redirect response status:', redirectRes.statusCode);
|
||||
handleResponse(redirectRes);
|
||||
}).on('error', (err) => {
|
||||
electronLog.error('[KCC-Update] Redirect error:', err);
|
||||
resolve(null);
|
||||
});
|
||||
return;
|
||||
}
|
||||
handleResponse(res);
|
||||
});
|
||||
|
||||
function handleResponse(res: import('http').IncomingMessage): void {
|
||||
if (res.statusCode !== 200) {
|
||||
electronLog.error('[KCC-Update] Check returned status', res.statusCode);
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let data = '';
|
||||
res.on('data', (chunk: string) => { data += chunk; });
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const release = JSON.parse(data);
|
||||
const tagName: string = release.tag_name || '';
|
||||
const remoteVersion = tagName.replace(/^v/i, '');
|
||||
electronLog.log('[KCC-Update] Latest release:', remoteVersion, '| Current:', currentVersion);
|
||||
|
||||
if (!remoteVersion || !versionLessThan(currentVersion, remoteVersion)) {
|
||||
electronLog.log('[KCC-Update] Already up to date');
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const assets: Array<{ name: string; browser_download_url: string; size: number }> = release.assets || [];
|
||||
const setupAsset = assets.find((a) => UPDATE_CONFIG.assetPattern.test(a.name));
|
||||
if (!setupAsset) {
|
||||
electronLog.error('[KCC-Update] No Setup.exe asset found in release', remoteVersion);
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate the download URL points to an allowed host
|
||||
if (!isAllowedRedirect(setupAsset.browser_download_url)) {
|
||||
electronLog.error('[KCC-Update] Download URL points to untrusted host:', setupAsset.browser_download_url);
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
electronLog.log('[KCC-Update] Update available:', remoteVersion, '| Download:', setupAsset.browser_download_url, '| Size:', setupAsset.size);
|
||||
resolve({
|
||||
version: remoteVersion,
|
||||
downloadUrl: setupAsset.browser_download_url,
|
||||
fileSize: setupAsset.size,
|
||||
});
|
||||
} catch (err) {
|
||||
electronLog.error('[KCC-Update] Failed to parse release data:', err);
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
res.on('error', (err) => {
|
||||
electronLog.error('[KCC-Update] Response error:', err);
|
||||
resolve(null);
|
||||
});
|
||||
}
|
||||
|
||||
req.setTimeout(CHECK_TIMEOUT_MS, () => {
|
||||
electronLog.error('[KCC-Update] Check timed out after', CHECK_TIMEOUT_MS, 'ms');
|
||||
req.destroy();
|
||||
resolve(null);
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
electronLog.error('[KCC-Update] Check error:', err);
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function downloadUpdate(url: string, destPath: string, onProgress: ProgressCallback): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tmpPath = destPath + '.tmp';
|
||||
|
||||
function doDownload(downloadUrl: string, redirectCount = 0): void {
|
||||
if (redirectCount > 5) {
|
||||
reject(new Error('Too many redirects'));
|
||||
return;
|
||||
}
|
||||
electronLog.log('[KCC-Update] Downloading from:', downloadUrl);
|
||||
const req = httpsGet(downloadUrl, {
|
||||
headers: { 'User-Agent': 'KrunkerCivilianClient' },
|
||||
}, (res) => {
|
||||
// Follow redirects (with domain validation)
|
||||
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
const redirectUrl = res.headers.location;
|
||||
electronLog.log('[KCC-Update] Download redirected to:', redirectUrl);
|
||||
if (!isAllowedRedirect(redirectUrl)) {
|
||||
electronLog.error('[KCC-Update] Download redirect to untrusted host blocked:', redirectUrl);
|
||||
reject(new Error('Download redirect to untrusted host: ' + redirectUrl));
|
||||
return;
|
||||
}
|
||||
doDownload(redirectUrl, redirectCount + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.statusCode !== 200) {
|
||||
electronLog.error('[KCC-Update] Download returned status', res.statusCode, 'from:', downloadUrl);
|
||||
reject(new Error('Download returned status ' + res.statusCode));
|
||||
return;
|
||||
}
|
||||
|
||||
const total = parseInt(res.headers['content-length'] || '0', 10);
|
||||
let received = 0;
|
||||
|
||||
const file = createWriteStream(tmpPath);
|
||||
res.on('data', (chunk: Buffer) => {
|
||||
received += chunk.length;
|
||||
if (total > 0) {
|
||||
onProgress(Math.round(100 * received / total));
|
||||
}
|
||||
});
|
||||
res.pipe(file);
|
||||
|
||||
file.on('finish', () => {
|
||||
file.close(() => {
|
||||
try {
|
||||
if (existsSync(destPath)) unlinkSync(destPath);
|
||||
renameSync(tmpPath, destPath);
|
||||
resolve();
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
file.on('error', (err) => {
|
||||
try { unlinkSync(tmpPath); } catch { /* ignore */ }
|
||||
reject(err);
|
||||
});
|
||||
|
||||
res.on('error', (err) => {
|
||||
try { unlinkSync(tmpPath); } catch { /* ignore */ }
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
|
||||
req.setTimeout(DOWNLOAD_TIMEOUT_MS, () => {
|
||||
req.destroy();
|
||||
try { unlinkSync(tmpPath); } catch { /* ignore */ }
|
||||
reject(new Error('Download timed out'));
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
try { unlinkSync(tmpPath); } catch { /* ignore */ }
|
||||
reject(err);
|
||||
});
|
||||
}
|
||||
|
||||
doDownload(url);
|
||||
});
|
||||
}
|
||||
|
||||
export function installUpdate(installerPath: string): void {
|
||||
electronLog.log('[KCC-Update] Launching installer:', installerPath);
|
||||
const child = spawn(installerPath, [], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
child.unref();
|
||||
app.quit();
|
||||
}
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
// ── Changelog Popup ──
|
||||
// Shows release notes in a Shadow DOM modal when the client version changes.
|
||||
|
||||
import { ipcRenderer } from 'electron';
|
||||
import { escapeHtml } from './utils';
|
||||
|
||||
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 sanitizeUrl(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol === 'https:' || parsed.protocol === 'http:') return escapeHtml(url);
|
||||
} catch { /* invalid URL */ }
|
||||
return '#';
|
||||
}
|
||||
|
||||
function renderMarkdown(md: string): string {
|
||||
// Escape all HTML first, then apply markdown formatting to the safe text
|
||||
const escaped = escapeHtml(md);
|
||||
const html = escaped
|
||||
.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, (_m, text, url) =>
|
||||
`<a href="${sanitizeUrl(url)}" target="_blank">${text}</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${escapeHtml(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 */ }
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
// ── 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 scrollPaused = false;
|
||||
let _con: SavedConsole | null = null;
|
||||
|
||||
const SCROLL_BOTTOM_THRESHOLD = 30; // px from bottom to consider "at bottom"
|
||||
|
||||
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 to bottom unless the user has scrolled up
|
||||
if (chatList && !scrollPaused) {
|
||||
chatList.scrollTop = chatList.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function isNearBottom(el: HTMLElement): boolean {
|
||||
return el.scrollHeight - el.scrollTop - el.clientHeight <= SCROLL_BOTTOM_THRESHOLD;
|
||||
}
|
||||
|
||||
function updatePauseState(): void {
|
||||
if (!chatList) return;
|
||||
const atBottom = isNearBottom(chatList);
|
||||
if (scrollPaused && atBottom) {
|
||||
scrollPaused = false;
|
||||
chatList.classList.remove('kpc-chat-paused');
|
||||
} else if (!scrollPaused && !atBottom) {
|
||||
scrollPaused = true;
|
||||
chatList.classList.add('kpc-chat-paused');
|
||||
}
|
||||
}
|
||||
|
||||
function tryAttach(): boolean {
|
||||
chatList = document.getElementById('chatList');
|
||||
if (!chatList) return false;
|
||||
|
||||
observer = new MutationObserver(handleMutations);
|
||||
observer.observe(chatList, { childList: true });
|
||||
|
||||
chatList.addEventListener('scroll', updatePauseState, { passive: 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;
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
// ── Competitive features: Hardpoint enemy counter + Rank progress tracker + Ranked queue ──
|
||||
import { ipcRenderer } from 'electron';
|
||||
|
||||
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;
|
||||
|
||||
// ── Hardpoint Enemy Counter ──
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
function startHPCounter(): void {
|
||||
hpCheckInterval = setInterval(() => {
|
||||
if (document.querySelector('.cmpTmHed')) {
|
||||
if (hpCheckInterval) { clearInterval(hpCheckInterval); hpCheckInterval = null; }
|
||||
setupHPDisplay();
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function stopHPCounter(): void {
|
||||
clearInterval(hpCheckInterval!); hpCheckInterval = null;
|
||||
hpObserver?.disconnect(); hpObserver = null;
|
||||
hpCounterEl?.remove(); hpCounterEl = null;
|
||||
clearTimeout(hpTimeout!); hpTimeout = null;
|
||||
hpPointCounter = null;
|
||||
hpEnemyOBJ = 0;
|
||||
}
|
||||
|
||||
export function initHPCounter(): void { startHPCounter(); }
|
||||
export function destroyHPCounter(): void { stopHPCounter(); }
|
||||
|
||||
// ── Rank Progress Tracker ──
|
||||
|
||||
interface RankInfo {
|
||||
rank: string;
|
||||
elo: number | null;
|
||||
color: string;
|
||||
image: string;
|
||||
}
|
||||
|
||||
const RANKS: RankInfo[] = [
|
||||
{ rank: 'Unranked', elo: null, color: '#FFFFFF', image: 'rank_unranked.svg' },
|
||||
{ rank: 'Bronze 1', elo: 0, color: '#CD7F32', image: 'rank_bronze.svg' },
|
||||
{ rank: 'Bronze 2', elo: 200, color: '#CD7F32', image: 'rank_bronze.svg' },
|
||||
{ rank: 'Bronze 3', elo: 400, color: '#CD7F32', image: 'rank_bronze.svg' },
|
||||
{ rank: 'Silver 1', elo: 700, color: '#C0C0C0', image: 'rank_silver.svg' },
|
||||
{ rank: 'Silver 2', elo: 900, color: '#C0C0C0', image: 'rank_silver.svg' },
|
||||
{ rank: 'Silver 3', elo: 1100, color: '#C0C0C0', image: 'rank_silver.svg' },
|
||||
{ rank: 'Gold 1', elo: 1300, color: '#FFD700', image: 'rank_gold.svg' },
|
||||
{ rank: 'Gold 2', elo: 1600, color: '#FFD700', image: 'rank_gold.svg' },
|
||||
{ rank: 'Gold 3', elo: 2000, color: '#FFD700', image: 'rank_gold.svg' },
|
||||
{ rank: 'Platinum', elo: 2300, color: '#4B69FF', image: 'rank_platinum.svg' },
|
||||
{ rank: 'Diamond', elo: 3000, color: '#4B69FF', image: 'rank_diamond.svg' },
|
||||
{ rank: 'Master', elo: 3300, color: '#EE7032', image: 'rank_master.svg' },
|
||||
{ rank: 'Kracked', elo: 4700, color: '#FF0000', image: 'rank_kracked.svg' },
|
||||
];
|
||||
|
||||
const RANK_IMG_BASE = 'https://assets.krunker.io/img/ranked/ranks/';
|
||||
|
||||
function getRankData(currentElo: number): { current: RankInfo; next: RankInfo; progress: number; isMax: boolean } {
|
||||
let idx = 0;
|
||||
for (let i = RANKS.length - 1; i >= 0; i--) {
|
||||
if (RANKS[i].elo !== null && currentElo >= RANKS[i].elo!) { idx = i; break; }
|
||||
}
|
||||
const current = RANKS[idx];
|
||||
const next = RANKS[idx + 1] || current;
|
||||
const isMax = idx === RANKS.length - 1;
|
||||
let progress = 0;
|
||||
if (!isMax && current.elo !== null && next.elo !== null) {
|
||||
progress = Math.min(100, Math.max(0, ((currentElo - current.elo!) / (next.elo! - current.elo!)) * 100));
|
||||
} else if (isMax) {
|
||||
progress = 100;
|
||||
}
|
||||
return { current, next, progress, isMax };
|
||||
}
|
||||
|
||||
function openRankPopup(): void {
|
||||
if (document.getElementById('kpc-rank-overlay')) return;
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'kpc-rank-overlay';
|
||||
overlay.addEventListener('mousedown', (e) => { if (e.target === overlay) overlay.remove(); });
|
||||
|
||||
let grid = '';
|
||||
for (const r of RANKS) {
|
||||
grid += `<div class="kpc-rank-grid-item">
|
||||
<img src="${RANK_IMG_BASE}${r.image}" loading="lazy">
|
||||
<div><div class="kpc-rank-name" style="color:${r.color}">${r.rank}</div>
|
||||
<div class="kpc-rank-elo">${r.elo !== null ? r.elo + '+' : 'Placement'}</div></div></div>`;
|
||||
}
|
||||
|
||||
overlay.innerHTML = `<div class="kpc-rank-popup">
|
||||
<div class="kpc-rank-popup-header"><h2>Rank Distribution</h2>
|
||||
<div class="kpc-rank-popup-close" id="kpc-rank-close">\u2715</div></div>
|
||||
<div class="kpc-rank-grid">${grid}</div></div>`;
|
||||
document.body.appendChild(overlay);
|
||||
document.getElementById('kpc-rank-close')?.addEventListener('click', () => overlay.remove());
|
||||
}
|
||||
|
||||
function injectRankBar(container: Element): void {
|
||||
if (container.querySelector('#kpc-elo-tracker')) return;
|
||||
const statValues = container.querySelectorAll('.quick-stat-value');
|
||||
if (!statValues.length) return;
|
||||
const currentElo = Number(statValues[0].textContent);
|
||||
if (isNaN(currentElo)) return;
|
||||
|
||||
const data = getRankData(currentElo);
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.id = 'kpc-elo-tracker';
|
||||
|
||||
const nextHtml = data.isMax ? '' :
|
||||
`<div class="kpc-rank-container"><img src="${RANK_IMG_BASE}${data.next.image}" class="kpc-elo-rank-img"><span>${data.next.rank}</span></div>`;
|
||||
const barText = data.isMax ? `${currentElo}` : `${currentElo} / ${data.next.elo}`;
|
||||
|
||||
wrapper.innerHTML = `<div class="kpc-elo-info-row">
|
||||
<div class="kpc-rank-container"><img src="${RANK_IMG_BASE}${data.current.image}" class="kpc-elo-rank-img"><span>${data.current.rank}</span></div>
|
||||
<div class="kpc-elo-bar-bg"><div class="kpc-elo-bar-fill" style="width:${data.progress}%"></div>
|
||||
<div class="kpc-elo-bar-text">${barText}</div></div>${nextHtml}</div>`;
|
||||
|
||||
const statsBlock = container.querySelector('.quick-stats');
|
||||
if (statsBlock) container.insertBefore(wrapper, statsBlock);
|
||||
else container.appendChild(wrapper);
|
||||
}
|
||||
|
||||
function injectRankButton(card: Element): void {
|
||||
if (card.querySelector('#kpc-rank-list-btn')) return;
|
||||
const btn = document.createElement('div');
|
||||
btn.id = 'kpc-rank-list-btn';
|
||||
btn.innerHTML = '<span class="material-icons" style="font-size:16px;vertical-align:middle;margin-right:4px;">list</span> Ranks';
|
||||
btn.addEventListener('click', openRankPopup);
|
||||
if (getComputedStyle(card as HTMLElement).position === 'static') (card as HTMLElement).style.position = 'relative';
|
||||
card.appendChild(btn);
|
||||
}
|
||||
|
||||
function checkRankedMenu(): void {
|
||||
const card = document.querySelector('.rank-card');
|
||||
const container = document.querySelector('.rank-and-stats');
|
||||
if (card && container) {
|
||||
injectRankBar(container);
|
||||
injectRankButton(card);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ranked Queue Button ──
|
||||
|
||||
function injectQueueButton(): void {
|
||||
const footer = document.querySelector('.footer-controls');
|
||||
if (!footer || footer.querySelector('#kpc-ranked-queue-btn')) return;
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.id = 'kpc-ranked-queue-btn';
|
||||
btn.className = 'kpc-ranked-queue-btn';
|
||||
btn.innerHTML = '<span class="material-icons" style="font-size:20px;vertical-align:middle;">open_in_new</span>';
|
||||
btn.title = 'Open External Queue';
|
||||
btn.addEventListener('click', () => {
|
||||
let token = localStorage.getItem('__FRVR_auth_access_token') || '';
|
||||
token = token.replace(/"/g, '').replace(/\//g, '');
|
||||
const regionEl = document.querySelector('.region-indicator');
|
||||
let region = 'na';
|
||||
if (regionEl) {
|
||||
const text = regionEl.textContent || '';
|
||||
const parts = text.split(': ');
|
||||
const regionName = parts[1] || parts[0];
|
||||
if (regionName.includes('Europe')) region = 'eu';
|
||||
else if (regionName.includes('Asia')) region = 'as';
|
||||
}
|
||||
const allRegions = localStorage.getItem('s_rankedAllRegions') === 'true';
|
||||
ipcRenderer.send('open-ranked-queue', token, region, allRegions);
|
||||
});
|
||||
|
||||
const lastChild = footer.lastElementChild;
|
||||
if (lastChild) footer.insertBefore(btn, lastChild);
|
||||
else footer.appendChild(btn);
|
||||
}
|
||||
|
||||
export function initRankProgress(): void {
|
||||
// Poll for window.openRankedMenu — Krunker defines it async after DOM load
|
||||
let attempts = 0;
|
||||
const poll = setInterval(() => {
|
||||
const origRanked = (window as any).openRankedMenu;
|
||||
if (origRanked && !origRanked.__kpcRankPatched) {
|
||||
clearInterval(poll);
|
||||
|
||||
let rankObserver: MutationObserver | null = null;
|
||||
let cleanupInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const patched = function (this: any, ...args: any[]) {
|
||||
origRanked.apply(this, args);
|
||||
|
||||
const modal = document.querySelector('.rankedMenuModal');
|
||||
if (!modal) return;
|
||||
|
||||
rankObserver = new MutationObserver(checkRankedMenu);
|
||||
rankObserver.observe(modal, { childList: true, subtree: true });
|
||||
checkRankedMenu();
|
||||
injectQueueButton();
|
||||
|
||||
cleanupInterval = setInterval(() => {
|
||||
if (!document.querySelector('.rankedMenuModal')) {
|
||||
if (rankObserver) { rankObserver.disconnect(); rankObserver = null; }
|
||||
if (cleanupInterval) { clearInterval(cleanupInterval); cleanupInterval = null; }
|
||||
}
|
||||
}, 5000);
|
||||
};
|
||||
(patched as any).__kpcRankPatched = true;
|
||||
(window as any).openRankedMenu = patched;
|
||||
} else if (++attempts > 75) { // 15s timeout
|
||||
clearInterval(poll);
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
+1644
-2042
File diff suppressed because it is too large
Load Diff
+185
-362
@@ -1,394 +1,217 @@
|
||||
// ── Custom Matchmaker (ported from Crankshaft) ──
|
||||
// Fetches live lobby list from matchmaker.krunker.io, filters by user criteria,
|
||||
// sorts by lowest ping then highest player count, and joins the best match.
|
||||
// Shows a live lobby-cycling search popup while scanning.
|
||||
// presents a popup to join a random matching game.
|
||||
|
||||
import { ipcRenderer } from 'electron';
|
||||
import type { Keybind } from '../main/config';
|
||||
import { escapeHtml, type SavedConsole } from './utils';
|
||||
import type { SavedConsole } from './utils';
|
||||
|
||||
// Full array — indices must match the server's gamemode IDs (game[4].g)
|
||||
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'];
|
||||
|
||||
// Modes shown in matchmaker settings
|
||||
export const MATCHMAKER_GAMEMODE_FILTER = [
|
||||
'Free for All', 'Team Deathmatch', 'Hardpoint', 'Capture the Flag', 'Parkour',
|
||||
'Gun Game', 'Classic FFA', 'Deposit', 'Kill Confirmed', 'Sharp Shooter',
|
||||
'Domination', 'Kranked FFA', 'Team Defender', 'Deposit FFA', 'Chaos Snipers',
|
||||
'Bighead FFA',
|
||||
];
|
||||
export const MATCHMAKER_REGIONS = ['SV', 'TOK', 'FRA', 'MBI', 'SYD', 'SIN', 'DAL', 'BHN', 'BRZ', 'NY'];
|
||||
export const MATCHMAKER_REGION_NAMES: Record<string, string> = { SV: 'Silicon Valley', TOK: 'Tokyo', FRA: 'Frankfurt', MBI: 'Mumbai', SYD: 'Sydney', SIN: 'Singapore', DAL: 'Dallas', BHN: 'Bahrain', BRZ: 'Brazil', NY: 'New York' };
|
||||
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',
|
||||
};
|
||||
|
||||
// Official maps shown in matchmaker settings
|
||||
export const MATCHMAKER_MAP_FILTER = [
|
||||
'Burg', 'Littletown', 'Sandstorm', 'Subzero', 'Undergrowth', 'Freight',
|
||||
'Lostworld', 'Citadel', 'Oasis', 'Kanji', 'Industry', 'Lumber', 'Evacuation',
|
||||
'Site', 'SkyTemple', 'Lagoon', 'Tropicano', 'Habitat', 'Atomic', 'Old_Burg',
|
||||
'Throwback', 'Clockwork', 'Bazaar', 'Erupt', 'HQ', 'Lush', 'Vivo',
|
||||
'Slide Moonlight', '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;
|
||||
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' };
|
||||
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'];
|
||||
|
||||
interface MatchmakerGame {
|
||||
gameID: string;
|
||||
region: string;
|
||||
playerCount: number;
|
||||
playerLimit: number;
|
||||
map: string;
|
||||
gamemode: string;
|
||||
remainingTime: number;
|
||||
}
|
||||
|
||||
interface RawLobby extends MatchmakerGame {
|
||||
passesFilter: boolean;
|
||||
gameID: string;
|
||||
region: string;
|
||||
playerCount: number;
|
||||
playerLimit: number;
|
||||
map: string;
|
||||
gamemode: string;
|
||||
remainingTime: number;
|
||||
}
|
||||
|
||||
export interface MatchmakerConfig {
|
||||
enabled: boolean;
|
||||
regions: string[];
|
||||
gamemodes: string[];
|
||||
maps: string[];
|
||||
minPlayers: number;
|
||||
maxPlayers: number;
|
||||
minRemainingTime: number;
|
||||
openServerBrowser: boolean;
|
||||
sortByPlayers: boolean;
|
||||
cancelKey: Keybind;
|
||||
enabled: boolean;
|
||||
regions: string[];
|
||||
gamemodes: string[];
|
||||
minPlayers: number;
|
||||
maxPlayers: number;
|
||||
minRemainingTime: number;
|
||||
openServerBrowser: boolean;
|
||||
acceptKey: Keybind;
|
||||
cancelKey: Keybind;
|
||||
}
|
||||
|
||||
function secondsToTimestring(num: number): string {
|
||||
const minutes = Math.floor(num / 60);
|
||||
const seconds = num % 60;
|
||||
if (minutes < 1) return `${num}s`;
|
||||
return `${minutes}m ${seconds}s`;
|
||||
}
|
||||
|
||||
// ── 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 currentMatch = '';
|
||||
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 && currentMatch !== 'none') {
|
||||
window.location.href = `https://krunker.io/?game=${currentMatch}`;
|
||||
} else {
|
||||
const popup = getPopup();
|
||||
if (popup.element.parentNode) popup.element.remove();
|
||||
if (currentMatch === 'none' && openServerBrowser && typeof w.openServerWindow === 'function') {
|
||||
w.openServerWindow(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function matchesKey(bind: Keybind, event: KeyboardEvent): boolean {
|
||||
if ((document.activeElement as HTMLElement)?.tagName === 'INPUT') return false;
|
||||
return event.key === bind.key
|
||||
&& event.shiftKey === bind.shift
|
||||
&& event.altKey === bind.alt
|
||||
&& event.ctrlKey === bind.ctrl;
|
||||
if ((document.activeElement as HTMLElement)?.tagName === 'INPUT') return false;
|
||||
return event.key === bind.key
|
||||
&& event.shiftKey === bind.shift
|
||||
&& event.altKey === bind.alt
|
||||
&& event.ctrlKey === bind.ctrl;
|
||||
}
|
||||
|
||||
// ── Popup DOM (created once, reused) ──
|
||||
const POPUP_ID = 'matchmakerPopupContainer';
|
||||
const popupElement = document.createElement('div');
|
||||
popupElement.id = POPUP_ID;
|
||||
|
||||
// Result-phase elements
|
||||
const popupTitle = document.createElement('div');
|
||||
popupTitle.id = 'matchmakerPopupTitle';
|
||||
popupElement.appendChild(popupTitle);
|
||||
|
||||
const popupDescription = document.createElement('div');
|
||||
popupDescription.id = 'matchmakerPopupDescription';
|
||||
popupElement.appendChild(popupDescription);
|
||||
|
||||
const popupOptions = document.createElement('div');
|
||||
popupOptions.id = 'matchmakerPopupOptions';
|
||||
|
||||
const popupCancelBtn = document.createElement('div');
|
||||
popupCancelBtn.id = 'matchmakerCancelButton';
|
||||
popupCancelBtn.className = 'matchmakerPopupButton bigShadowT';
|
||||
popupCancelBtn.textContent = 'Cancel';
|
||||
popupCancelBtn.setAttribute('onmouseenter', 'playTick()');
|
||||
popupCancelBtn.addEventListener('click', () => {
|
||||
const w = window as any;
|
||||
if (typeof w.playSelect === 'function') w.playSelect();
|
||||
dismissPopup();
|
||||
});
|
||||
|
||||
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 popupCandidates: MatchmakerGame[] = [];
|
||||
let openServerBrowser = true;
|
||||
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();
|
||||
function handleMatchmakerBind(event: KeyboardEvent): void {
|
||||
if (document.pointerLockElement) return;
|
||||
const isAccept = matchesKey(confirmKey, event);
|
||||
const isCancel = matchesKey(cancelKey, event);
|
||||
if (isAccept || isCancel) {
|
||||
document.removeEventListener('keydown', handleMatchmakerBind, true);
|
||||
decideMatchmakerDecision(isAccept);
|
||||
}
|
||||
}
|
||||
|
||||
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] });
|
||||
}
|
||||
function createFetchedGamePopup(game: MatchmakerGame): void {
|
||||
const popup = getPopup();
|
||||
const mapIdx = MAP_ICON_INDICES.indexOf(game.map);
|
||||
popup.element.style.backgroundImage = `url(https://assets.krunker.io/img/maps/map_${mapIdx >= 0 ? mapIdx : 0}.png)`;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
currentMatch = game.gameID;
|
||||
if (game.gameID === 'none') {
|
||||
popup.title.textContent = 'No Games Found...';
|
||||
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';
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
if (popupElement.parentNode) popupElement.remove();
|
||||
popupElement.classList.remove('searching');
|
||||
}
|
||||
|
||||
function handleSearchBind(event: KeyboardEvent): void {
|
||||
if (document.pointerLockElement) return;
|
||||
if (matchesKey(cancelKey, event)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
abortSearch();
|
||||
}
|
||||
}
|
||||
|
||||
function showSearchPopup(): void {
|
||||
searchAborted = false;
|
||||
popupElement.classList.add('searching');
|
||||
popupElement.style.backgroundImage = 'none';
|
||||
searchStatus.textContent = 'Connecting...';
|
||||
searchFeed.innerHTML = '';
|
||||
searchCounter.textContent = '';
|
||||
|
||||
|
||||
document.addEventListener('keydown', handleSearchBind, true);
|
||||
|
||||
const uiBase = document.getElementById('uiBase');
|
||||
if (uiBase) uiBase.appendChild(popupElement);
|
||||
}
|
||||
|
||||
function createFeedEntry(lobby: RawLobby): HTMLDivElement {
|
||||
const entry = document.createElement('div');
|
||||
entry.className = `mm-feed-entry ${lobby.passesFilter ? 'mm-pass' : 'mm-fail'}`;
|
||||
|
||||
const region = document.createElement('span');
|
||||
region.className = 'mm-feed-region';
|
||||
region.textContent = lobby.region;
|
||||
|
||||
const map = document.createElement('span');
|
||||
map.className = 'mm-feed-map';
|
||||
map.textContent = lobby.map;
|
||||
|
||||
const players = document.createElement('span');
|
||||
players.className = 'mm-feed-players';
|
||||
players.textContent = `${lobby.playerCount}/${lobby.playerLimit}`;
|
||||
|
||||
entry.appendChild(region);
|
||||
entry.appendChild(map);
|
||||
entry.appendChild(players);
|
||||
return entry;
|
||||
}
|
||||
|
||||
async function animateLobbyScan(lobbies: RawLobby[]): Promise<void> {
|
||||
if (lobbies.length === 0) return;
|
||||
|
||||
searchStatus.textContent = 'Scanning lobbies...';
|
||||
const total = lobbies.length;
|
||||
|
||||
const maxEntries = Math.floor(MAX_ANIMATION_MS / BASE_TICK_MS);
|
||||
const step = total > maxEntries ? total / maxEntries : 1;
|
||||
const tickMs = total > maxEntries ? BASE_TICK_MS : Math.max(MIN_TICK_MS, Math.min(BASE_TICK_MS, MAX_ANIMATION_MS / total));
|
||||
|
||||
for (let f = 0; f < total; f += step) {
|
||||
if (searchAborted) return;
|
||||
const i = Math.min(Math.floor(f), total - 1);
|
||||
|
||||
const entry = createFeedEntry(lobbies[i]);
|
||||
searchFeed.appendChild(entry);
|
||||
|
||||
while (searchFeed.children.length > MAX_FEED_ENTRIES) {
|
||||
searchFeed.removeChild(searchFeed.firstChild!);
|
||||
}
|
||||
|
||||
searchCounter.textContent = `Checked: ${i + 1} / ${total} lobbies`;
|
||||
|
||||
await new Promise(r => setTimeout(r, tickMs));
|
||||
}
|
||||
|
||||
searchCounter.textContent = `Checked: ${total} / ${total} lobbies`;
|
||||
|
||||
if (!searchAborted) {
|
||||
await new Promise(r => setTimeout(r, POST_SCAN_PAUSE_MS));
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAllGames(mmConfig: MatchmakerConfig): Promise<{ all: RawLobby[]; filtered: MatchmakerGame[] }> {
|
||||
const response = await fetch(`https://matchmaker.krunker.io/game-list?hostname=${window.location.hostname}`);
|
||||
const result = await response.json();
|
||||
const all: RawLobby[] = [];
|
||||
const filtered: MatchmakerGame[] = [];
|
||||
|
||||
for (const game of result.games) {
|
||||
const gameID: string = game[0];
|
||||
const region = gameID.split(':')[0];
|
||||
const playerCount: number = game[2];
|
||||
const playerLimit: number = game[3];
|
||||
const map: string = game[4].i;
|
||||
const gamemode = MATCHMAKER_GAMEMODES[game[4].g] ?? 'Unknown Gamemode';
|
||||
const remainingTime: number = game[5];
|
||||
|
||||
let passesFilter = true;
|
||||
if (mmConfig.regions.length > 0 && !mmConfig.regions.includes(region)) passesFilter = false;
|
||||
else if (mmConfig.gamemodes.length > 0 && !mmConfig.gamemodes.includes(gamemode)) passesFilter = false;
|
||||
else if (mmConfig.maps.length > 0 && !mmConfig.maps.includes(map)) passesFilter = false;
|
||||
else if (playerCount < mmConfig.minPlayers) passesFilter = false;
|
||||
else if (playerCount > mmConfig.maxPlayers) passesFilter = false;
|
||||
else if (remainingTime < mmConfig.minRemainingTime) passesFilter = false;
|
||||
else if (playerCount === playerLimit) passesFilter = false;
|
||||
else if (window.location.href.includes(gameID)) passesFilter = false;
|
||||
|
||||
const lobby = { gameID, region, playerCount, playerLimit, map, gamemode, remainingTime, passesFilter };
|
||||
all.push(lobby);
|
||||
if (passesFilter) filtered.push(lobby);
|
||||
}
|
||||
|
||||
return { all, filtered };
|
||||
}
|
||||
|
||||
function sortGames(games: MatchmakerGame[], pings: Record<string, number>, sortByPlayers: boolean): MatchmakerGame[] {
|
||||
return games.sort((a, b) => {
|
||||
if (sortByPlayers) {
|
||||
if (a.playerCount !== b.playerCount) return b.playerCount - a.playerCount;
|
||||
return (pings[a.region] ?? 999) - (pings[b.region] ?? 999);
|
||||
}
|
||||
const pingA = pings[a.region] ?? 999;
|
||||
const pingB = pings[b.region] ?? 999;
|
||||
if (pingA !== pingB) return pingA - pingB;
|
||||
return b.playerCount - a.playerCount;
|
||||
});
|
||||
document.addEventListener('keydown', handleMatchmakerBind, true);
|
||||
const uiBase = document.getElementById('uiBase');
|
||||
if (uiBase) uiBase.appendChild(popup.element);
|
||||
}
|
||||
|
||||
export async function fetchGame(mmConfig: MatchmakerConfig, _con?: SavedConsole): Promise<void> {
|
||||
openServerBrowser = mmConfig.openServerBrowser;
|
||||
cancelKey = mmConfig.cancelKey;
|
||||
openServerBrowser = mmConfig.openServerBrowser;
|
||||
confirmKey = mmConfig.acceptKey;
|
||||
cancelKey = mmConfig.cancelKey;
|
||||
|
||||
// Dismiss existing popup if active (also aborts in-flight search)
|
||||
searchAborted = true;
|
||||
dismissPopup();
|
||||
// Dismiss existing popup if active
|
||||
if (document.getElementById(POPUP_ID)) decideMatchmakerDecision(false);
|
||||
|
||||
// Phase 1: Show search popup immediately
|
||||
showSearchPopup();
|
||||
_con?.log('[KCC-MM] Fetching game list + pings...');
|
||||
_con?.log('[KCC-MM] Fetching game list...');
|
||||
|
||||
// Phase 2: Fetch data
|
||||
let allLobbies: RawLobby[];
|
||||
let filtered: MatchmakerGame[];
|
||||
let pings: Record<string, number>;
|
||||
try {
|
||||
const [fetchResult, pingResult] = await Promise.all([
|
||||
fetchAllGames(mmConfig),
|
||||
ipcRenderer.invoke('ping-regions').catch(() => ({} as Record<string, number>)),
|
||||
]);
|
||||
allLobbies = fetchResult.all;
|
||||
filtered = fetchResult.filtered;
|
||||
pings = pingResult;
|
||||
} catch {
|
||||
if (!searchAborted) {
|
||||
searchStatus.textContent = 'Failed to fetch lobbies';
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
dismissPopup();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const response = await fetch(`https://matchmaker.krunker.io/game-list?hostname=${window.location.hostname}`);
|
||||
const result = await response.json();
|
||||
const games: MatchmakerGame[] = [];
|
||||
|
||||
if (searchAborted) return;
|
||||
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];
|
||||
|
||||
_con?.log('[KCC-MM]', filtered.length, '/', allLobbies.length, 'games passed filters');
|
||||
// Apply filters — empty arrays mean "all selected" (no filter)
|
||||
if (mmConfig.regions.length > 0 && !mmConfig.regions.includes(region)) continue;
|
||||
if (mmConfig.gamemodes.length > 0 && !mmConfig.gamemodes.includes(gamemode)) continue;
|
||||
if (playerCount < mmConfig.minPlayers) continue;
|
||||
if (playerCount > mmConfig.maxPlayers) continue;
|
||||
if (remainingTime < mmConfig.minRemainingTime) continue;
|
||||
if (playerCount === playerLimit) continue;
|
||||
if (window.location.href.includes(gameID)) continue;
|
||||
if (currentMatch === gameID) continue;
|
||||
|
||||
// Sort immediately — result is ready
|
||||
if (filtered.length > 0) sortGames(filtered, pings, mmConfig.sortByPlayers);
|
||||
popupCandidates = filtered;
|
||||
games.push({ gameID, region, playerCount, playerLimit, map, gamemode, remainingTime });
|
||||
}
|
||||
|
||||
// Fire animation in background (non-blocking eye candy)
|
||||
animateLobbyScan(allLobbies);
|
||||
_con?.log('[KCC-MM] Received', result.games?.length ?? 0, 'games,', games.length, 'passed filters');
|
||||
|
||||
// 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})`);
|
||||
|
||||
// Brief "Lobby Found!" flash before auto-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">${escapeHtml(best.region)}</span>` +
|
||||
`<span class="mm-feed-map">${escapeHtml(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);
|
||||
} else {
|
||||
_con?.log('[KCC-MM] No matching games found');
|
||||
|
||||
dismissPopup();
|
||||
if (openServerBrowser && typeof (window as any).openServerWindow === 'function') {
|
||||
(window as any).openServerWindow(0);
|
||||
}
|
||||
}
|
||||
if (games.length > 0) {
|
||||
const selected = games[Math.floor(Math.random() * games.length)];
|
||||
_con?.log('[KCC-MM] Selected:', selected.gameID, selected.region, selected.map);
|
||||
createFetchedGamePopup(selected);
|
||||
} else {
|
||||
_con?.log('[KCC-MM] No matching games found');
|
||||
createFetchedGamePopup({
|
||||
gameID: 'none',
|
||||
region: 'none',
|
||||
playerCount: 0,
|
||||
playerLimit: 0,
|
||||
map: MAP_ICON_INDICES[0],
|
||||
gamemode: MATCHMAKER_GAMEMODES[0],
|
||||
remainingTime: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ const FALSE_POSITIVE_LANGS = new Set([
|
||||
|
||||
// ── Auto-suppression (repeated short phrases) ──
|
||||
|
||||
let suppressionCounts = new Map<string, number>();
|
||||
const suppressionCounts = new Map<string, number>();
|
||||
const SUPPRESS_THRESHOLD = 3;
|
||||
const MIN_LATIN_WORDS = 3;
|
||||
const SHORT_TEXT_THRESHOLD = 15;
|
||||
@@ -113,7 +113,6 @@ const SHORT_TEXT_THRESHOLD = 15;
|
||||
|
||||
let activeRequests = 0;
|
||||
const MAX_CONCURRENT = 3;
|
||||
const MAX_QUEUE = 15;
|
||||
const pendingQueue: Array<() => void> = [];
|
||||
|
||||
function enqueue(fn: () => Promise<void>): void {
|
||||
@@ -124,45 +123,10 @@ function enqueue(fn: () => Promise<void>): void {
|
||||
if (pendingQueue.length > 0) pendingQueue.shift()!();
|
||||
});
|
||||
} else {
|
||||
// Drop oldest if queue is full — old messages have already scrolled off-screen
|
||||
if (pendingQueue.length >= MAX_QUEUE) pendingQueue.shift();
|
||||
pendingQueue.push(() => enqueue(fn));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Periodic cleanup (prevents unbounded memory growth in long sessions) ──
|
||||
|
||||
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
||||
let cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function startCleanup(): void {
|
||||
if (cleanupTimer) return;
|
||||
cleanupTimer = setInterval(() => {
|
||||
// Reset suppression counts (re-learned naturally from fresh messages)
|
||||
suppressionCounts = new Map();
|
||||
|
||||
// Prune expired sessionStorage cache entries
|
||||
const now = Date.now();
|
||||
const keysToRemove: string[] = [];
|
||||
for (let i = 0; i < sessionStorage.length; i++) {
|
||||
const key = sessionStorage.key(i);
|
||||
if (!key?.startsWith(CACHE_KEY_PREFIX)) continue;
|
||||
try {
|
||||
const entry: CacheEntry = JSON.parse(sessionStorage.getItem(key) || '');
|
||||
if (now - entry.ts > CACHE_EXPIRY_MS) keysToRemove.push(key);
|
||||
} catch { keysToRemove.push(key); }
|
||||
}
|
||||
for (const key of keysToRemove) sessionStorage.removeItem(key);
|
||||
}, CLEANUP_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function stopCleanup(): void {
|
||||
if (cleanupTimer) {
|
||||
clearInterval(cleanupTimer);
|
||||
cleanupTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── System message patterns to skip ──
|
||||
|
||||
const SYSTEM_PATTERNS = [
|
||||
@@ -317,10 +281,8 @@ function processMessage(node: HTMLElement): void {
|
||||
|
||||
const { message, username } = extracted;
|
||||
enqueue(async () => {
|
||||
// Node may have been removed by chat history trimming while queued
|
||||
if (!node.isConnected) return;
|
||||
const result = await translateText(message);
|
||||
if (result && node.isConnected) appendTranslation(node, username, result.translation, result.srcLang);
|
||||
if (result) appendTranslation(node, username, result.translation, result.srcLang);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -354,7 +316,6 @@ function startObserver(): void {
|
||||
});
|
||||
|
||||
chatObserver.observe(chatList, { childList: true });
|
||||
startCleanup();
|
||||
_con.log('[KCC-TL] Chat observer active');
|
||||
}, 500);
|
||||
}
|
||||
@@ -368,8 +329,6 @@ function stopObserver(): void {
|
||||
chatObserver.disconnect();
|
||||
chatObserver = null;
|
||||
}
|
||||
stopCleanup();
|
||||
pendingQueue.length = 0;
|
||||
}
|
||||
|
||||
// ── Public API ──
|
||||
|
||||
@@ -122,21 +122,7 @@ function executeScript(
|
||||
};
|
||||
|
||||
try {
|
||||
// Wrap script so DOMContentLoaded listeners fire immediately if the
|
||||
// document is already loaded (scripts run after did-finish-load IPC).
|
||||
const wrapped = `
|
||||
if (document.readyState !== 'loading') {
|
||||
const _origAdd = EventTarget.prototype.addEventListener;
|
||||
EventTarget.prototype.addEventListener = function(type, fn, opts) {
|
||||
if ((this === window || this === document) && type === 'DOMContentLoaded') {
|
||||
Promise.resolve().then(() => fn.call(this, new Event('DOMContentLoaded')));
|
||||
return;
|
||||
}
|
||||
return _origAdd.call(this, type, fn, opts);
|
||||
};
|
||||
try { ${instance.content}\n } finally { EventTarget.prototype.addEventListener = _origAdd; }
|
||||
} else { ${instance.content}\n }`;
|
||||
const fn = new Function(wrapped);
|
||||
const fn = new Function(instance.content);
|
||||
const result = fn.apply(context);
|
||||
|
||||
// Script returned `this` — capture settings and unload
|
||||
|
||||
+29
-54
@@ -19,10 +19,37 @@ export function escapeHtml(s: string): string {
|
||||
return s.replace(/[&<>"']/g, c => HTML_ESCAPE_MAP[c]);
|
||||
}
|
||||
|
||||
// ── Chat message injection ──
|
||||
// Creates messages in #chatHolder inside a persistent #kpcMessageHolder div.
|
||||
// timeout=0 means the message is persistent (not auto-removed).
|
||||
|
||||
export function genChatMsg(text: string, timeout = 2.25): HTMLElement | null {
|
||||
const chatHolder = document.getElementById('chatHolder');
|
||||
if (!chatHolder) return null;
|
||||
if (!document.getElementById('kpcMessageHolder')) {
|
||||
chatHolder.insertAdjacentHTML('afterbegin', '<div id="kpcMessageHolder"></div>');
|
||||
}
|
||||
const holder = document.getElementById('kpcMessageHolder')!;
|
||||
holder.insertAdjacentHTML('beforeend',
|
||||
'<div class="chatHolder_kpc"><div class="chatItem_kpc"><span class="chatMsg_kpc">' +
|
||||
escapeHtml(text) + '</span></div></div>');
|
||||
const elem = holder.lastElementChild as HTMLElement;
|
||||
if (timeout !== 0) {
|
||||
setTimeout(() => { elem.remove(); }, timeout * 1000);
|
||||
}
|
||||
return elem;
|
||||
}
|
||||
|
||||
// ── Filename sanitisation ──
|
||||
|
||||
export function sanitizeFilename(name: string): string {
|
||||
return name.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
}
|
||||
|
||||
// ── Shared CSS constants ──
|
||||
|
||||
const DEATH_ANIM_BLOCK_ID = 'kpc-animationBlock';
|
||||
const DEATH_ANIM_BLOCK_CSS =
|
||||
export const DEATH_ANIM_BLOCK_ID = 'kpc-animationBlock';
|
||||
export const DEATH_ANIM_BLOCK_CSS =
|
||||
'.death-ui-bottom, .death-ui-bottom-empty { animation: none !important; transition: none !important; }';
|
||||
|
||||
/** Inject or remove the death screen animation block style element. */
|
||||
@@ -39,55 +66,3 @@ export function setDeathAnimBlock(enabled: boolean): void {
|
||||
el.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,9 +8,6 @@ const nodeBuiltins = builtinModules.flatMap((m) => [m, `node:${m}`]);
|
||||
const isProd = process.env.NODE_ENV === 'production' || !process.argv.includes('--mode');
|
||||
|
||||
export default defineConfig({
|
||||
define: {
|
||||
__UPDATE_SOURCE__: JSON.stringify(process.env.UPDATE_SOURCE || 'gitea'),
|
||||
},
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve(__dirname, 'src/main/index.ts'),
|
||||
|
||||
Reference in New Issue
Block a user