diff --git a/package-lock.json b/package-lock.json index ed5ac52..a413277 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,19 @@ { "name": "krunker-civilian-client", - "version": "0.7.1", + "version": "0.7.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "krunker-civilian-client", - "version": "0.7.1", + "version": "0.7.5", "hasInstallScript": true, "license": "GPL-3.0", "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", @@ -45,34 +46,66 @@ } }, "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==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-4.2.0.tgz", + "integrity": "sha512-npW1NW5yy8EB9XY/vEw9sUdgmq0sJEhmSBb6bqyFOAw1CSkrhvAvO6QWlW8CdIMo8VN1lkdF345l/MeW0LrY0Q==", "dev": true, "license": "MIT", "dependencies": { - "commander": "^5.0.0", - "glob": "^7.1.6", - "minimatch": "^3.0.4" + "commander": "^13.1.0", + "glob": "^13.0.2", + "minimatch": "^10.0.1" }, "bin": { - "asar": "bin/asar.js" + "asar": "bin/asar.mjs" }, "engines": { - "node": ">=10.12.0" + "node": ">=22.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==", + "node_modules/@electron/asar/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^1.1.7" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" }, "engines": { - "node": "*" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@electron/asar/node_modules/lru-cache": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@electron/asar/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/@electron/fuses": { @@ -338,6 +371,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 +423,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 +2444,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 +2538,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", @@ -3032,13 +3158,13 @@ } }, "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==", + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", "dev": true, "license": "MIT", "engines": { - "node": ">= 6" + "node": ">=18" } }, "node_modules/compare-version": { @@ -3715,6 +3841,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 +3887,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", diff --git a/package.json b/package.json index 7c9b964..baf9559 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "krunker-civilian-client", - "version": "0.7.5", + "version": "0.7.6", "description": "Cross-platform Krunker game client", "main": "dist/main/index.js", "homepage": "https://github.com/bigjakk/Krunker-Civilian-Client", @@ -17,6 +17,7 @@ "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" @@ -28,6 +29,7 @@ "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", diff --git a/scripts/build-asar.js b/scripts/build-asar.js new file mode 100644 index 0000000..e7347d0 --- /dev/null +++ b/scripts/build-asar.js @@ -0,0 +1,96 @@ +/** + * 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); +}); diff --git a/src/main/index.ts b/src/main/index.ts index 461176d..d3ae1c7 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,6 +1,6 @@ import { app, BrowserWindow, Menu, clipboard, ipcMain, safeStorage, session, shell } from 'electron'; -import { join } from 'path'; -import { existsSync, mkdirSync, promises as fsp } from 'fs'; +import { join, dirname } from 'path'; +import { existsSync, mkdirSync, unlinkSync, promises as fsp } from 'fs'; import { get as httpsGet } from 'https'; import { execFile } from 'child_process'; import * as os from 'os'; @@ -10,7 +10,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 +164,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 (existsSync(stalePending)) { + try { unlinkSync(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 (existsSync(pendingPath)) { + try { unlinkSync(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(); diff --git a/src/main/updater.ts b/src/main/updater.ts index b122f3d..5d66ae8 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -1,279 +1,487 @@ -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 { - 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:") - 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 { - 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 { - 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'; +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 = { + 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 { + 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: " " per line. + */ +async function fetchChecksums(url: string): Promise> { + const text = await simpleGet(url); + const map = new Map(); + 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 { + 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 { + 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 { + 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 { + 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(); +} diff --git a/vite.main.config.ts b/vite.main.config.ts index 63244f9..629089b 100644 --- a/vite.main.config.ts +++ b/vite.main.config.ts @@ -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'),