490 lines
18 KiB
TypeScript
490 lines
18 KiB
TypeScript
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 }
|
|
Rename-Item $asar $backup -Force
|
|
Rename-Item $pending $asar -Force
|
|
if (Test-Path $backup) { Remove-Item $backup -Force }
|
|
} catch {
|
|
if ((Test-Path $backup) -and -not (Test-Path $asar)) {
|
|
Rename-Item $backup $asar -Force
|
|
}
|
|
exit 1
|
|
}
|
|
|
|
Start-Process $ExePath
|
|
`;
|
|
|
|
const SWAP_SCRIPT_BASH = `#!/bin/bash
|
|
PID="$1"
|
|
RESOURCES_DIR="$2"
|
|
EXE_PATH="$3"
|
|
|
|
while kill -0 "$PID" 2>/dev/null; do sleep 0.2; done
|
|
sleep 0.5
|
|
|
|
ASAR="$RESOURCES_DIR/app.asar"
|
|
PENDING="$RESOURCES_DIR/app-pending.asar"
|
|
BACKUP="$RESOURCES_DIR/app-backup.asar"
|
|
|
|
[ -f "$PENDING" ] || exit 1
|
|
|
|
rm -f "$BACKUP"
|
|
mv "$ASAR" "$BACKUP" && mv "$PENDING" "$ASAR" && rm -f "$BACKUP" || {
|
|
[ -f "$BACKUP" ] && [ ! -f "$ASAR" ] && mv "$BACKUP" "$ASAR"
|
|
exit 1
|
|
}
|
|
|
|
"$EXE_PATH" &
|
|
`;
|
|
|
|
// ── Helpers ──
|
|
|
|
function isAllowedRedirect(url: string): boolean {
|
|
try {
|
|
const parsed = new URL(url);
|
|
return UPDATE_CONFIG.allowedHosts.some(h => parsed.hostname === h || parsed.hostname.endsWith('.' + h));
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function versionLessThan(a: string, b: string): boolean {
|
|
const pa = a.split('.').map(Number);
|
|
const pb = b.split('.').map(Number);
|
|
const len = Math.max(pa.length, pb.length);
|
|
for (let i = 0; i < len; i++) {
|
|
const na = pa[i] || 0;
|
|
const nb = pb[i] || 0;
|
|
if (na < nb) return true;
|
|
if (na > nb) return false;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function simpleGet(url: string): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
function doGet(getUrl: string, redirectCount = 0): void {
|
|
if (redirectCount > 5) {
|
|
reject(new Error('Too many redirects'));
|
|
return;
|
|
}
|
|
const req = httpsGet(getUrl, {
|
|
headers: { 'User-Agent': 'KrunkerCivilianClient' },
|
|
}, (res) => {
|
|
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
if (!isAllowedRedirect(res.headers.location)) {
|
|
reject(new Error('Redirect to untrusted host: ' + res.headers.location));
|
|
return;
|
|
}
|
|
doGet(res.headers.location, redirectCount + 1);
|
|
return;
|
|
}
|
|
if (res.statusCode !== 200) {
|
|
reject(new Error('HTTP ' + res.statusCode));
|
|
return;
|
|
}
|
|
let data = '';
|
|
res.on('data', (chunk: string) => { data += chunk; });
|
|
res.on('end', () => resolve(data));
|
|
res.on('error', reject);
|
|
});
|
|
req.setTimeout(CHECK_TIMEOUT_MS, () => {
|
|
req.destroy();
|
|
reject(new Error('Request timed out'));
|
|
});
|
|
req.on('error', reject);
|
|
}
|
|
doGet(url);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Fetch and parse a checksums.sha256 file from a release asset URL.
|
|
* Format: "<sha256> <filename>" per line.
|
|
*/
|
|
async function fetchChecksums(url: string): Promise<Map<string, string>> {
|
|
const text = await simpleGet(url);
|
|
const map = new Map<string, string>();
|
|
for (const line of text.split('\n')) {
|
|
const match = line.trim().match(/^([a-f0-9]{64})\s+(.+)$/i);
|
|
if (match) map.set(match[2].trim(), match[1].toLowerCase());
|
|
}
|
|
return map;
|
|
}
|
|
|
|
// ── Update check ──
|
|
|
|
interface ReleaseAsset {
|
|
name: string;
|
|
browser_download_url: string;
|
|
size: number;
|
|
digest?: string;
|
|
}
|
|
|
|
export function checkForUpdate(currentVersion: string): Promise<UpdateInfo | null> {
|
|
return new Promise((resolve) => {
|
|
electronLog.log('[KCC-Update] Checking for updates at:', UPDATE_CONFIG.checkUrl);
|
|
electronLog.log('[KCC-Update] Current version:', currentVersion);
|
|
|
|
const req = httpsGet(UPDATE_CONFIG.checkUrl, {
|
|
headers: { 'User-Agent': 'KrunkerCivilianClient/' + currentVersion },
|
|
}, (res) => {
|
|
electronLog.log('[KCC-Update] Check response status:', res.statusCode);
|
|
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
const redirectUrl = res.headers.location;
|
|
electronLog.log('[KCC-Update] Redirected to:', redirectUrl);
|
|
if (!isAllowedRedirect(redirectUrl)) {
|
|
electronLog.error('[KCC-Update] Redirect to untrusted host blocked:', redirectUrl);
|
|
resolve(null);
|
|
return;
|
|
}
|
|
httpsGet(redirectUrl, {
|
|
headers: { 'User-Agent': 'KrunkerCivilianClient/' + currentVersion },
|
|
}, (redirectRes) => {
|
|
electronLog.log('[KCC-Update] Redirect response status:', redirectRes.statusCode);
|
|
handleResponse(redirectRes);
|
|
}).on('error', (err) => {
|
|
electronLog.error('[KCC-Update] Redirect error:', err);
|
|
resolve(null);
|
|
});
|
|
return;
|
|
}
|
|
handleResponse(res);
|
|
});
|
|
|
|
async function handleResponse(res: import('http').IncomingMessage): Promise<void> {
|
|
if (res.statusCode !== 200) {
|
|
electronLog.error('[KCC-Update] Check returned status', res.statusCode);
|
|
resolve(null);
|
|
return;
|
|
}
|
|
|
|
let data = '';
|
|
res.on('data', (chunk: string) => { data += chunk; });
|
|
res.on('end', async () => {
|
|
try {
|
|
const release = JSON.parse(data);
|
|
const tagName: string = release.tag_name || '';
|
|
const remoteVersion = tagName.replace(/^v/i, '');
|
|
electronLog.log('[KCC-Update] Latest release:', remoteVersion, '| Current:', currentVersion);
|
|
|
|
if (!remoteVersion || !versionLessThan(currentVersion, remoteVersion)) {
|
|
electronLog.log('[KCC-Update] Already up to date');
|
|
resolve(null);
|
|
return;
|
|
}
|
|
|
|
const assets: ReleaseAsset[] = release.assets || [];
|
|
|
|
// Determine update type: prefer minor (asar) over major (setup)
|
|
const asarAsset = assets.find((a) => ASSET_PATTERNS.asar.test(a.name));
|
|
const setupAsset = assets.find((a) => ASSET_PATTERNS.setup.test(a.name));
|
|
const chosenAsset = asarAsset || setupAsset;
|
|
const updateType: UpdateType = asarAsset ? 'minor' : 'major';
|
|
|
|
if (!chosenAsset) {
|
|
electronLog.error('[KCC-Update] No app.asar or Setup.exe asset found in release', remoteVersion);
|
|
resolve(null);
|
|
return;
|
|
}
|
|
|
|
if (!isAllowedRedirect(chosenAsset.browser_download_url)) {
|
|
electronLog.error('[KCC-Update] Download URL points to untrusted host:', chosenAsset.browser_download_url);
|
|
resolve(null);
|
|
return;
|
|
}
|
|
|
|
// Resolve SHA-256 checksum
|
|
let sha256 = '';
|
|
if (UPDATE_CONFIG.checksumSource === 'digest') {
|
|
sha256 = (chosenAsset.digest || '').replace(/^sha256:/i, '');
|
|
if (!sha256) {
|
|
electronLog.error('[KCC-Update] No SHA-256 digest found for asset');
|
|
resolve(null);
|
|
return;
|
|
}
|
|
} else {
|
|
// Fetch checksums.sha256 companion file
|
|
const checksumAsset = assets.find((a) => ASSET_PATTERNS.checksums.test(a.name));
|
|
if (checksumAsset) {
|
|
try {
|
|
const checksums = await fetchChecksums(checksumAsset.browser_download_url);
|
|
sha256 = checksums.get(chosenAsset.name) || '';
|
|
} catch (err) {
|
|
electronLog.error('[KCC-Update] Failed to fetch checksums:', err);
|
|
}
|
|
}
|
|
if (!sha256) {
|
|
electronLog.warn('[KCC-Update] No checksum available — proceeding without verification');
|
|
}
|
|
}
|
|
|
|
electronLog.log('[KCC-Update] Update available:', remoteVersion,
|
|
'| Type:', updateType,
|
|
'| SHA-256:', sha256 ? sha256.substring(0, 16) + '...' : 'none');
|
|
|
|
resolve({
|
|
version: remoteVersion,
|
|
updateType,
|
|
downloadUrl: chosenAsset.browser_download_url,
|
|
fileSize: chosenAsset.size,
|
|
sha256,
|
|
});
|
|
} catch (err) {
|
|
electronLog.error('[KCC-Update] Failed to parse release data:', err);
|
|
resolve(null);
|
|
}
|
|
});
|
|
res.on('error', (err) => {
|
|
electronLog.error('[KCC-Update] Response error:', err);
|
|
resolve(null);
|
|
});
|
|
}
|
|
|
|
req.setTimeout(CHECK_TIMEOUT_MS, () => {
|
|
electronLog.error('[KCC-Update] Check timed out after', CHECK_TIMEOUT_MS, 'ms');
|
|
req.destroy();
|
|
resolve(null);
|
|
});
|
|
|
|
req.on('error', (err) => {
|
|
electronLog.error('[KCC-Update] Check error:', err);
|
|
resolve(null);
|
|
});
|
|
});
|
|
}
|
|
|
|
// ── Download ──
|
|
|
|
function verifyChecksum(filePath: string, expectedSha256: string): Promise<boolean> {
|
|
return new Promise((resolve, reject) => {
|
|
const hash = createHash('sha256');
|
|
const stream = createReadStream(filePath);
|
|
stream.on('data', (chunk) => hash.update(chunk));
|
|
stream.on('end', () => {
|
|
const actual = hash.digest('hex');
|
|
electronLog.log('[KCC-Update] SHA-256 expected:', expectedSha256);
|
|
electronLog.log('[KCC-Update] SHA-256 actual: ', actual);
|
|
resolve(actual === expectedSha256);
|
|
});
|
|
stream.on('error', reject);
|
|
});
|
|
}
|
|
|
|
export function downloadUpdate(url: string, destPath: string, onProgress: ProgressCallback, expectedSha256?: string): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
const tmpPath = destPath + '.tmp';
|
|
|
|
function doDownload(downloadUrl: string, redirectCount = 0): void {
|
|
if (redirectCount > 5) {
|
|
reject(new Error('Too many redirects'));
|
|
return;
|
|
}
|
|
electronLog.log('[KCC-Update] Downloading from:', downloadUrl);
|
|
const req = httpsGet(downloadUrl, {
|
|
headers: { 'User-Agent': 'KrunkerCivilianClient' },
|
|
}, (res) => {
|
|
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
const redirectUrl = res.headers.location;
|
|
electronLog.log('[KCC-Update] Download redirected to:', redirectUrl);
|
|
if (!isAllowedRedirect(redirectUrl)) {
|
|
electronLog.error('[KCC-Update] Download redirect to untrusted host blocked:', redirectUrl);
|
|
reject(new Error('Download redirect to untrusted host: ' + redirectUrl));
|
|
return;
|
|
}
|
|
doDownload(redirectUrl, redirectCount + 1);
|
|
return;
|
|
}
|
|
|
|
if (res.statusCode !== 200) {
|
|
electronLog.error('[KCC-Update] Download returned status', res.statusCode, 'from:', downloadUrl);
|
|
reject(new Error('Download returned status ' + res.statusCode));
|
|
return;
|
|
}
|
|
|
|
const total = parseInt(res.headers['content-length'] || '0', 10);
|
|
let received = 0;
|
|
|
|
const file = createWriteStream(tmpPath);
|
|
res.on('data', (chunk: Buffer) => {
|
|
received += chunk.length;
|
|
if (total > 0) {
|
|
onProgress(Math.round(100 * received / total));
|
|
}
|
|
});
|
|
res.pipe(file);
|
|
|
|
file.on('finish', () => {
|
|
file.close(async () => {
|
|
try {
|
|
if (expectedSha256) {
|
|
const valid = await verifyChecksum(tmpPath, expectedSha256);
|
|
if (!valid) {
|
|
electronLog.error('[KCC-Update] Checksum mismatch — file may be corrupted or tampered');
|
|
try { unlinkSync(tmpPath); } catch { /* ignore */ }
|
|
reject(new Error('SHA-256 checksum mismatch'));
|
|
return;
|
|
}
|
|
electronLog.log('[KCC-Update] Checksum verified');
|
|
}
|
|
if (existsSync(destPath)) unlinkSync(destPath);
|
|
renameSync(tmpPath, destPath);
|
|
resolve();
|
|
} catch (err) {
|
|
reject(err);
|
|
}
|
|
});
|
|
});
|
|
|
|
file.on('error', (err) => {
|
|
try { unlinkSync(tmpPath); } catch { /* ignore */ }
|
|
reject(err);
|
|
});
|
|
|
|
res.on('error', (err) => {
|
|
try { unlinkSync(tmpPath); } catch { /* ignore */ }
|
|
reject(err);
|
|
});
|
|
});
|
|
|
|
req.setTimeout(DOWNLOAD_TIMEOUT_MS, () => {
|
|
req.destroy();
|
|
try { unlinkSync(tmpPath); } catch { /* ignore */ }
|
|
reject(new Error('Download timed out'));
|
|
});
|
|
|
|
req.on('error', (err) => {
|
|
try { unlinkSync(tmpPath); } catch { /* ignore */ }
|
|
reject(err);
|
|
});
|
|
}
|
|
|
|
doDownload(url);
|
|
});
|
|
}
|
|
|
|
// ── Install / Apply ──
|
|
|
|
export function installUpdate(installerPath: string): void {
|
|
electronLog.log('[KCC-Update] Launching installer:', installerPath);
|
|
const child = spawn(installerPath, [], {
|
|
detached: true,
|
|
stdio: 'ignore',
|
|
});
|
|
child.unref();
|
|
app.quit();
|
|
}
|
|
|
|
export function applyMinorUpdate(pendingAsarPath: string): void {
|
|
const resourcesDir = dirname(pendingAsarPath);
|
|
const exePath = app.getPath('exe');
|
|
const tempDir = join(app.getPath('temp'), 'kcc-update');
|
|
if (!existsSync(tempDir)) mkdirSync(tempDir, { recursive: true });
|
|
|
|
electronLog.log('[KCC-Update] Applying minor update via swap script');
|
|
electronLog.log('[KCC-Update] Resources dir:', resourcesDir);
|
|
electronLog.log('[KCC-Update] Exe path:', exePath);
|
|
electronLog.log('[KCC-Update] PID:', process.pid);
|
|
|
|
if (process.platform === 'win32') {
|
|
const scriptPath = join(tempDir, 'swap-asar.ps1');
|
|
writeFileSync(scriptPath, SWAP_SCRIPT_PS1);
|
|
const child = spawn('powershell.exe', [
|
|
'-ExecutionPolicy', 'Bypass',
|
|
'-File', scriptPath,
|
|
'-ProcessId', String(process.pid),
|
|
'-ResourcesDir', resourcesDir,
|
|
'-ExePath', exePath,
|
|
], { detached: true, stdio: 'ignore' });
|
|
child.unref();
|
|
} else {
|
|
const scriptPath = join(tempDir, 'swap-asar.sh');
|
|
writeFileSync(scriptPath, SWAP_SCRIPT_BASH, { mode: 0o755 });
|
|
const child = spawn('bash', [
|
|
scriptPath, String(process.pid), resourcesDir, exePath,
|
|
], { detached: true, stdio: 'ignore' });
|
|
child.unref();
|
|
}
|
|
|
|
app.quit();
|
|
}
|