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(); }