Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d17c1bc0a2 | |||
| b968ace8fa | |||
| 2cf5623186 | |||
| 5e7786d66e | |||
| 9a52d6b8fc | |||
| 255befd1b8 | |||
| 691b363f88 | |||
| 8972c3e363 | |||
| 5a8d77c494 | |||
| 2240ca38bd | |||
| d2696a510f | |||
| 4aecb402d6 |
@@ -4,6 +4,17 @@ 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
|
||||
@@ -14,18 +25,24 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check if version already released
|
||||
id: version-check
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
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"
|
||||
|
||||
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
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
|
||||
@@ -33,6 +50,55 @@ jobs:
|
||||
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-only) release
|
||||
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
|
||||
@@ -42,9 +108,9 @@ jobs:
|
||||
- name: Install system dependencies
|
||||
if: steps.version-check.outputs.SKIP == 'false'
|
||||
run: |
|
||||
sudo dpkg --add-architecture i386
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
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 \
|
||||
@@ -70,46 +136,133 @@ jobs:
|
||||
if: steps.version-check.outputs.SKIP == 'false'
|
||||
run: npx electron-builder --linux -c.electronDist=node_modules/electron/dist-linux --publish never
|
||||
|
||||
- name: Extract asar for patch updates
|
||||
if: steps.version-check.outputs.SKIP == 'false' && steps.release-type.outputs.TYPE == 'patch'
|
||||
run: |
|
||||
# The asar is a byproduct of the Linux build — platform-independent (just JS)
|
||||
ASAR_SRC="out/linux-unpacked/resources/app.asar"
|
||||
ASAR_DIR="out/asar"
|
||||
mkdir -p "$ASAR_DIR"
|
||||
cp "$ASAR_SRC" "$ASAR_DIR/app.asar"
|
||||
sha256sum "$ASAR_DIR/app.asar" | awk '{print $1 " app.asar"}' > "$ASAR_DIR/checksums.sha256"
|
||||
echo "Extracted asar: $(du -h "$ASAR_DIR/app.asar" | cut -f1)"
|
||||
cat "$ASAR_DIR/checksums.sha256"
|
||||
|
||||
- name: Report build sizes
|
||||
if: steps.version-check.outputs.SKIP == 'false'
|
||||
run: |
|
||||
echo "=== Build output sizes ==="
|
||||
echo "=== Release type: ${{ steps.release-type.outputs.TYPE }} ==="
|
||||
ls -lh out/*.exe out/*.AppImage out/*.deb 2>/dev/null || true
|
||||
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: |
|
||||
git fetch --unshallow 2>/dev/null || true
|
||||
chmod +x scripts/generate-release-notes.sh
|
||||
scripts/generate-release-notes.sh "${{ steps.version-check.outputs.TAG }}" > /tmp/release-notes.md
|
||||
|
||||
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 GitHub release and upload assets
|
||||
- name: Create release and upload assets
|
||||
if: steps.version-check.outputs.SKIP == 'false'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITEA_TOKEN: ${{ secrets.DEST_GITEA_TOKEN }}
|
||||
TAG: ${{ steps.version-check.outputs.TAG }}
|
||||
RELEASE_TYPE: ${{ steps.release-type.outputs.TYPE }}
|
||||
run: |
|
||||
# Collect built artifacts
|
||||
ASSETS=()
|
||||
for file in out/*.exe out/*.AppImage out/*.deb; do
|
||||
[ -f "$file" ] || continue
|
||||
ASSETS+=("$file")
|
||||
done
|
||||
GITEA_BASE="https://gitea.crjlab.net"
|
||||
REPO="bigjakk/Krunker-Civilian-Client-Test"
|
||||
|
||||
if [ ${#ASSETS[@]} -eq 0 ]; then
|
||||
echo "ERROR: No build artifacts found in out/"
|
||||
ls -la out/ 2>/dev/null || echo "out/ directory does not exist"
|
||||
# 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
|
||||
|
||||
echo "Uploading ${#ASSETS[@]} assets:"
|
||||
printf ' %s\n' "${ASSETS[@]}"
|
||||
# Upload asar + checksums (patch releases only — signals client to use fast path)
|
||||
if [ "$RELEASE_TYPE" = "patch" ]; then
|
||||
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
|
||||
else
|
||||
echo "Full release — skipping asar upload (forces installer update)"
|
||||
fi
|
||||
|
||||
gh release create "${{ steps.version-check.outputs.TAG }}" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--title "${{ steps.version-check.outputs.TAG }}" \
|
||||
--notes-file /tmp/release-notes.md \
|
||||
--draft=false \
|
||||
--latest \
|
||||
"${ASSETS[@]}"
|
||||
# Upload installers (always — for new users and major updates)
|
||||
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"
|
||||
|
||||
- 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"
|
||||
|
||||
Generated
+139
-43
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "krunker-civilian-client",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "krunker-civilian-client",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.9",
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
@@ -44,37 +44,6 @@
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/asar": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz",
|
||||
"integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"commander": "^5.0.0",
|
||||
"glob": "^7.1.6",
|
||||
"minimatch": "^3.0.4"
|
||||
},
|
||||
"bin": {
|
||||
"asar": "bin/asar.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/asar/node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/fuses": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@electron/fuses/-/fuses-1.8.0.tgz",
|
||||
@@ -338,6 +307,48 @@
|
||||
"node": ">=16.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/universal/node_modules/@electron/asar": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz",
|
||||
"integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"commander": "^5.0.0",
|
||||
"glob": "^7.1.6",
|
||||
"minimatch": "^3.0.4"
|
||||
},
|
||||
"bin": {
|
||||
"asar": "bin/asar.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/universal/node_modules/@electron/asar/node_modules/brace-expansion": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/universal/node_modules/@electron/asar/node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/universal/node_modules/brace-expansion": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
|
||||
@@ -348,6 +359,16 @@
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/universal/node_modules/commander": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz",
|
||||
"integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/universal/node_modules/fs-extra": {
|
||||
"version": "11.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.3.tgz",
|
||||
@@ -2359,6 +2380,37 @@
|
||||
"electron-builder-squirrel-windows": "26.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/app-builder-lib/node_modules/@electron/asar": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz",
|
||||
"integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"commander": "^5.0.0",
|
||||
"glob": "^7.1.6",
|
||||
"minimatch": "^3.0.4"
|
||||
},
|
||||
"bin": {
|
||||
"asar": "bin/asar.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/app-builder-lib/node_modules/@electron/asar/node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/app-builder-lib/node_modules/@electron/get": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@electron/get/-/get-3.1.0.tgz",
|
||||
@@ -2422,6 +2474,16 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/app-builder-lib/node_modules/commander": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz",
|
||||
"integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/app-builder-lib/node_modules/fs-extra": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
|
||||
@@ -3031,16 +3093,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz",
|
||||
"integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/compare-version": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz",
|
||||
@@ -3715,6 +3767,36 @@
|
||||
"@electron/windows-sign": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-winstaller/node_modules/@electron/asar": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz",
|
||||
"integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"commander": "^5.0.0",
|
||||
"glob": "^7.1.6",
|
||||
"minimatch": "^3.0.4"
|
||||
},
|
||||
"bin": {
|
||||
"asar": "bin/asar.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-winstaller/node_modules/commander": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz",
|
||||
"integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-winstaller/node_modules/fs-extra": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz",
|
||||
@@ -3731,6 +3813,20 @@
|
||||
"node": ">=6 <7 || >=8"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-winstaller/node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/electron/node_modules/@types/node": {
|
||||
"version": "24.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.11.0.tgz",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "krunker-civilian-client",
|
||||
"version": "0.7.5",
|
||||
"version": "0.8.0",
|
||||
"description": "Cross-platform Krunker game client",
|
||||
"main": "dist/main/index.js",
|
||||
"homepage": "https://github.com/bigjakk/Krunker-Civilian-Client",
|
||||
|
||||
+72
-29
@@ -1,6 +1,7 @@
|
||||
import { app, BrowserWindow, Menu, clipboard, ipcMain, safeStorage, session, shell } from 'electron';
|
||||
import { join } from 'path';
|
||||
import { join, dirname } from 'path';
|
||||
import { existsSync, mkdirSync, promises as fsp } from 'fs';
|
||||
import { existsSync as origExistsSync, unlinkSync as origUnlinkSync } from 'original-fs';
|
||||
import { get as httpsGet } from 'https';
|
||||
import { execFile } from 'child_process';
|
||||
import * as os from 'os';
|
||||
@@ -10,7 +11,7 @@ import { initSwapperProtocol, registerSwapperFileProtocol, ResourceSwapper } fro
|
||||
import { UserscriptManager } from './userscripts';
|
||||
import { ALL_CLIENT_CSS } from './client-ui';
|
||||
import { electronLog, getLogPath, closeLogStreams } from './logger';
|
||||
import { checkForUpdate, downloadUpdate, installUpdate } from './updater';
|
||||
import { checkForUpdate, downloadUpdate, installUpdate, applyMinorUpdate } from './updater';
|
||||
import { showUpdateWindow } from './update-window';
|
||||
import { DiscordRPC } from './discord-rpc';
|
||||
import { listThemes, getThemeCSS, listLoadingThemes, getLoadingScreenCSS } from './css-themes';
|
||||
@@ -164,51 +165,93 @@ function saveWindowState(win: BrowserWindow): void {
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
electronLog.log('[KCC] App ready');
|
||||
electronLog.log('[KCC] Minor update test — asar swap successful');
|
||||
|
||||
// ── Auto-update check (mandatory, Windows NSIS install only) ──
|
||||
// ── Auto-update check (2-stage: minor asar swap or major installer) ──
|
||||
const isPortable = !!process.env.PORTABLE_EXECUTABLE_DIR;
|
||||
const isAppImage = !!process.env.APPIMAGE;
|
||||
const isDev = !app.isPackaged;
|
||||
if (isDev || process.platform !== 'win32' || isPortable || isAppImage) {
|
||||
electronLog.log('[KCC] Skipping auto-update (portable or non-Windows)');
|
||||
const canMajorUpdate = process.platform === 'win32' && !isPortable;
|
||||
const canMinorUpdate = !isPortable && !isAppImage;
|
||||
|
||||
if (isDev || (!canMajorUpdate && !canMinorUpdate)) {
|
||||
electronLog.log('[KCC] Skipping auto-update');
|
||||
} else {
|
||||
// Clean up stale pending asar from a previous failed swap
|
||||
const resourcesDir = join(dirname(app.getPath('exe')), 'resources');
|
||||
const stalePending = join(resourcesDir, 'app-pending.asar');
|
||||
if (origExistsSync(stalePending)) {
|
||||
try { origUnlinkSync(stalePending); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
try {
|
||||
electronLog.log('[KCC] Checking for updates...');
|
||||
const update = await checkForUpdate(appVersion);
|
||||
if (update) {
|
||||
electronLog.log(`[KCC] Update available: v${update.version}`);
|
||||
electronLog.log(`[KCC] Update available: v${update.version} (${update.updateType})`);
|
||||
const { window: updateWin, sendProgress } = showUpdateWindow();
|
||||
sendProgress(`Update available (v${update.version})`, 0);
|
||||
|
||||
const tempDir = join(app.getPath('temp'), 'kcc-update');
|
||||
if (!existsSync(tempDir)) mkdirSync(tempDir, { recursive: true });
|
||||
const installerPath = join(tempDir, `KCC-${update.version}-Setup.exe`);
|
||||
let cancelled = false;
|
||||
updateWin.on('closed', () => { cancelled = true; });
|
||||
|
||||
let cancelled = false;
|
||||
updateWin.on('closed', () => { cancelled = true; });
|
||||
if (update.updateType === 'minor' && canMinorUpdate) {
|
||||
// Minor update: download app.asar, swap via external script, restart
|
||||
sendProgress(`Patch available (v${update.version})`, 0);
|
||||
const pendingPath = join(resourcesDir, 'app-pending.asar');
|
||||
|
||||
try {
|
||||
await downloadUpdate(update.downloadUrl, installerPath, (pct) => {
|
||||
if (!cancelled && !updateWin.isDestroyed()) {
|
||||
sendProgress(`Downloading update... ${pct}%`, pct);
|
||||
try {
|
||||
await downloadUpdate(update.downloadUrl, pendingPath, (pct) => {
|
||||
if (!cancelled && !updateWin.isDestroyed()) {
|
||||
sendProgress(`Downloading patch... ${pct}%`, pct);
|
||||
}
|
||||
}, update.sha256 || undefined);
|
||||
|
||||
if (!cancelled) {
|
||||
sendProgress('Applying patch...', 100);
|
||||
applyMinorUpdate(pendingPath);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
electronLog.error('[KCC] Patch download failed:', err);
|
||||
// Clean up failed download
|
||||
if (origExistsSync(pendingPath)) {
|
||||
try { origUnlinkSync(pendingPath); } catch { /* ignore */ }
|
||||
}
|
||||
if (!updateWin.isDestroyed()) updateWin.close();
|
||||
}
|
||||
}, update.sha256);
|
||||
} else if (update.updateType === 'major' && canMajorUpdate) {
|
||||
// Major update: download Setup.exe, run installer
|
||||
sendProgress(`Update available (v${update.version})`, 0);
|
||||
const tempDir = join(app.getPath('temp'), 'kcc-update');
|
||||
if (!existsSync(tempDir)) mkdirSync(tempDir, { recursive: true });
|
||||
const installerPath = join(tempDir, `KCC-${update.version}-Setup.exe`);
|
||||
|
||||
if (!cancelled) {
|
||||
sendProgress('Installing update...', 100);
|
||||
installUpdate(installerPath);
|
||||
return; // app.quit() called by installUpdate
|
||||
try {
|
||||
await downloadUpdate(update.downloadUrl, installerPath, (pct) => {
|
||||
if (!cancelled && !updateWin.isDestroyed()) {
|
||||
sendProgress(`Downloading update... ${pct}%`, pct);
|
||||
}
|
||||
}, update.sha256 || undefined);
|
||||
|
||||
if (!cancelled) {
|
||||
sendProgress('Installing update...', 100);
|
||||
installUpdate(installerPath);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
electronLog.error('[KCC] Update download failed:', err);
|
||||
if (!updateWin.isDestroyed()) updateWin.close();
|
||||
}
|
||||
} else {
|
||||
electronLog.log('[KCC] Update available but cannot auto-install on this platform');
|
||||
if (!updateWin.isDestroyed()) updateWin.close();
|
||||
}
|
||||
} catch (err) {
|
||||
electronLog.error('[KCC] Update download failed:', err);
|
||||
if (!updateWin.isDestroyed()) updateWin.close();
|
||||
} else {
|
||||
electronLog.log('[KCC] No updates available');
|
||||
}
|
||||
} else {
|
||||
electronLog.log('[KCC] No updates available');
|
||||
} catch (err) {
|
||||
electronLog.error('[KCC] Update check failed:', err);
|
||||
}
|
||||
} catch (err) {
|
||||
electronLog.error('[KCC] Update check failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
await launchApp();
|
||||
|
||||
+21
-1
@@ -339,19 +339,39 @@ export class TabManager {
|
||||
const tab = this.tabs.find(t => t.id === id);
|
||||
if (!tab) return;
|
||||
|
||||
if (this.activeTabId !== null) {
|
||||
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);
|
||||
|
||||
+489
-279
@@ -1,279 +1,489 @@
|
||||
import { get as httpsGet } from 'https';
|
||||
import { createReadStream, createWriteStream, renameSync, unlinkSync, existsSync } from 'fs';
|
||||
import { createHash } from 'crypto';
|
||||
import { spawn } from 'child_process';
|
||||
import { app } from 'electron';
|
||||
import { electronLog } from './logger';
|
||||
|
||||
export interface UpdateInfo {
|
||||
version: string;
|
||||
downloadUrl: string;
|
||||
fileSize: number;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export type ProgressCallback = (percent: number) => void;
|
||||
|
||||
const UPDATE_CONFIG = {
|
||||
checkUrl: 'https://api.github.com/repos/bigjakk/Krunker-Civilian-Client/releases/latest',
|
||||
assetPattern: /Setup\.exe$/i,
|
||||
allowedHosts: ['github.com', 'githubusercontent.com'],
|
||||
};
|
||||
|
||||
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; digest: string }> = 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;
|
||||
}
|
||||
|
||||
// Extract SHA-256 digest from GitHub API (format: "sha256:<hex>")
|
||||
const sha256 = (setupAsset.digest || '').replace(/^sha256:/i, '');
|
||||
if (!sha256) {
|
||||
electronLog.error('[KCC-Update] No SHA-256 digest found for asset');
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
electronLog.log('[KCC-Update] Update available:', remoteVersion, '| SHA-256:', sha256.substring(0, 16) + '...');
|
||||
resolve({
|
||||
version: remoteVersion,
|
||||
downloadUrl: setupAsset.browser_download_url,
|
||||
fileSize: setupAsset.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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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) => {
|
||||
// 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(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);
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
import { get as httpsGet } from 'https';
|
||||
// Use original-fs to bypass Electron's asar interception — required for
|
||||
// writing/renaming .asar files in the resources directory.
|
||||
import { createReadStream, createWriteStream, writeFileSync, renameSync, unlinkSync, existsSync, mkdirSync } from 'original-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 }
|
||||
Move-Item $asar $backup -Force
|
||||
Move-Item $pending $asar -Force
|
||||
if (Test-Path $backup) { Remove-Item $backup -Force }
|
||||
} catch {
|
||||
if ((Test-Path $backup) -and -not (Test-Path $asar)) {
|
||||
Move-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();
|
||||
}
|
||||
|
||||
+4
-1
@@ -8,6 +8,9 @@ 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'),
|
||||
@@ -17,7 +20,7 @@ export default defineConfig({
|
||||
outDir: 'dist/main',
|
||||
emptyDirBefore: true,
|
||||
rollupOptions: {
|
||||
external: ['electron', 'electron-store', ...nodeBuiltins],
|
||||
external: ['electron', 'electron-store', 'original-fs', ...nodeBuiltins],
|
||||
},
|
||||
target: 'node20',
|
||||
minify: isProd,
|
||||
|
||||
Reference in New Issue
Block a user