370 lines
13 KiB
JavaScript
370 lines
13 KiB
JavaScript
'use strict';
|
||
|
||
/*
|
||
* updater.js – automatische Updates über die Gitea-Releases
|
||
* ---------------------------------------------------------
|
||
* Ablauf: Releases abfragen -> neueste Version mit der laufenden vergleichen ->
|
||
* bei Bedarf den Windows-Installer herunterladen und starten.
|
||
* Ein Klick des Nutzers genügt; Download, Prüfung und Start laufen automatisch.
|
||
*/
|
||
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const os = require('os');
|
||
const crypto = require('crypto');
|
||
const { spawn } = require('child_process');
|
||
|
||
const API = 'https://git.viper.ipv64.net/api/v1/repos/M_Viper/AeroMc-Launcher/releases';
|
||
const UA = 'AeroMC-Launcher';
|
||
|
||
/*
|
||
* Paketart der laufenden Anwendung: 'portable', 'setup' oder 'unknown'.
|
||
*
|
||
* Wichtig: es wird nie geraten. Portable-Nutzer dürfen niemals einen Installer
|
||
* bekommen und Setup-Nutzer niemals eine Portable-Datei – im Zweifel lieber
|
||
* 'unknown' und gar kein automatisches Update anbieten.
|
||
*/
|
||
let cachedKind = null;
|
||
function detectInstallKind() {
|
||
if (cachedKind) return cachedKind;
|
||
cachedKind = computeInstallKind();
|
||
return cachedKind;
|
||
}
|
||
|
||
function computeInstallKind() {
|
||
// 1. Portable: diese Variablen setzt ausschließlich der Portable-Build von
|
||
// electron-builder – das ist das verlässlichste Signal überhaupt.
|
||
if (String(process.env.PORTABLE_EXECUTABLE_FILE || '').trim()) return 'portable';
|
||
if (String(process.env.PORTABLE_EXECUTABLE_DIR || '').trim()) return 'portable';
|
||
if (String(process.env.PORTABLE_EXECUTABLE_APP_FILENAME || '').trim()) return 'portable';
|
||
|
||
const exe = process.execPath || '';
|
||
// 2. Notnagel über den Dateinamen, falls die Variablen einmal fehlen
|
||
if (/portable/i.test(path.basename(exe))) return 'portable';
|
||
|
||
// 3. Setup: NSIS legt neben der EXE immer einen Uninstaller ab. Nur wenn der
|
||
// da ist, ist es wirklich eine Installation.
|
||
if (hasUninstaller(path.dirname(exe))) return 'setup';
|
||
|
||
// 4. Alles andere (Entwicklungsmodus, entpackter Ordner, unbekannte Kopie)
|
||
return 'unknown';
|
||
}
|
||
|
||
function hasUninstaller(dir) {
|
||
try {
|
||
return fs.readdirSync(dir).some((n) => /^unins(tall)?.*\.exe$/i.test(n));
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// Pfad der laufenden Portable-EXE (die Datei, die der Nutzer selbst gestartet hat)
|
||
function portableTarget() {
|
||
const p = String(process.env.PORTABLE_EXECUTABLE_FILE || '').trim();
|
||
if (p) return p;
|
||
// Fallback: nur brauchbar, wenn die EXE nicht entpackt ausgeführt wird
|
||
const exe = process.execPath || '';
|
||
return path.basename(exe).toLowerCase().includes('portable') ? exe : '';
|
||
}
|
||
|
||
function updateDir() {
|
||
return path.join(os.tmpdir(), 'aeromc-update');
|
||
}
|
||
|
||
/*
|
||
* Räumt Reste alter Update-Vorgänge weg (Installer sind mehrere hundert MB groß).
|
||
* Dateien, die jünger als 10 Minuten sind, bleiben liegen – sie könnten gerade
|
||
* von einem laufenden Update benutzt werden.
|
||
*/
|
||
function cleanupUpdateCache() {
|
||
try {
|
||
const dir = updateDir();
|
||
if (!fs.existsSync(dir)) return;
|
||
for (const name of fs.readdirSync(dir)) {
|
||
const p = path.join(dir, name);
|
||
try {
|
||
if (Date.now() - fs.statSync(p).mtimeMs < 600000) continue;
|
||
fs.rmSync(p, { force: true, recursive: true });
|
||
} catch { /* gesperrt – beim nächsten Start erneut versuchen */ }
|
||
}
|
||
} catch { /* egal */ }
|
||
}
|
||
|
||
// "v1.2.3-beta.2" -> [1,2,3]; führendes "v" wird ignoriert
|
||
function parseVersion(v) {
|
||
const m = String(v || '').trim().replace(/^v/i, '').match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
|
||
if (!m) return null;
|
||
return [Number(m[1] || 0), Number(m[2] || 0), Number(m[3] || 0)];
|
||
}
|
||
|
||
// Vorabkennung hinter dem ersten "-", z. B. "beta" oder "beta.2"
|
||
function parsePreRelease(v) {
|
||
const m = String(v || '').trim().replace(/^v/i, '').match(/^\d+(?:\.\d+){0,2}-([0-9A-Za-z.\-]+)/);
|
||
return m ? m[1].split('.') : null;
|
||
}
|
||
|
||
/*
|
||
* Vergleicht die Vorabkennungen zweier gleicher Versionsnummern.
|
||
*
|
||
* Bewusst nicht streng nach SemVer: trägt ein Release-Tag die Vorabkennung
|
||
* nicht, die die App selbst meldet (Tag "1.0.1" gegen App "1.0.1-beta"),
|
||
* würde "1.0.1" als neuer gelten – der Launcher böte dasselbe Update nach
|
||
* jeder Installation erneut an, eine Endlosschleife. Deshalb zählt eine
|
||
* Vorabkennung nur, wenn sie auf beiden Seiten vorhanden ist (beta.1 -> beta.2).
|
||
*/
|
||
function comparePreRelease(a, b) {
|
||
if (!a || !b) return 0;
|
||
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
||
const x = a[i]; const y = b[i];
|
||
if (x === undefined) return -1;
|
||
if (y === undefined) return 1;
|
||
const nx = /^\d+$/.test(x) ? Number(x) : null;
|
||
const ny = /^\d+$/.test(y) ? Number(y) : null;
|
||
if (nx !== null && ny !== null) { if (nx !== ny) return nx - ny; continue; }
|
||
if (nx !== null) return -1; // Zahlen zählen weniger als Text
|
||
if (ny !== null) return 1;
|
||
if (x !== y) return x < y ? -1 : 1;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
// >0 wenn a neuer als b
|
||
function compareVersions(a, b) {
|
||
const x = parseVersion(a); const y = parseVersion(b);
|
||
if (!x || !y) return 0;
|
||
for (let i = 0; i < 3; i++) {
|
||
if (x[i] !== y[i]) return x[i] - y[i];
|
||
}
|
||
return comparePreRelease(parsePreRelease(a), parsePreRelease(b));
|
||
}
|
||
|
||
/*
|
||
* Paketart einer Release-Datei am Namen erkennen: 'portable', 'setup' oder null.
|
||
* Namen, die auf beides passen (z. B. "AeroMC-Portable-Setup.exe"), gelten als
|
||
* mehrdeutig und werden bewusst niemandem angeboten – lieber kein Update als
|
||
* das falsche.
|
||
*/
|
||
function assetKind(name) {
|
||
const n = String(name || '');
|
||
if (!/\.exe$/i.test(n)) return null;
|
||
const portable = /portable/i.test(n);
|
||
const setup = /setup|install(er)?/i.test(n);
|
||
if (portable && setup) return null;
|
||
if (portable) return 'portable';
|
||
if (setup) return 'setup';
|
||
return null; // ohne Kennzeichnung im Namen nicht zuzuordnen
|
||
}
|
||
|
||
// passendes Windows-Artefakt im Release finden
|
||
function pickAsset(release, installKind) {
|
||
if (installKind !== 'portable' && installKind !== 'setup') return null;
|
||
const assets = release.assets || [];
|
||
return assets.find((a) => assetKind(a.name) === installKind) || null;
|
||
}
|
||
|
||
/*
|
||
* Prüft auf Updates.
|
||
* -> { available, currentVersion, latestVersion, notes, asset, url, noReleases }
|
||
*/
|
||
async function checkForUpdate(currentVersion) {
|
||
const installKind = detectInstallKind();
|
||
const res = await fetch(API + '?limit=10', { headers: { 'User-Agent': UA, Accept: 'application/json' } });
|
||
if (!res.ok) throw new Error('Update-Server antwortet mit HTTP ' + res.status);
|
||
const list = await res.json();
|
||
const releases = (Array.isArray(list) ? list : []).filter((r) => !r.draft);
|
||
if (!releases.length) {
|
||
return { available: false, noReleases: true, currentVersion };
|
||
}
|
||
// neueste anhand der Versionsnummer (nicht nach Datum, damit Nachträge nicht stören)
|
||
releases.sort((a, b) => compareVersions(b.tag_name, a.tag_name));
|
||
const latest = releases[0];
|
||
const available = compareVersions(latest.tag_name, currentVersion) > 0;
|
||
const asset = pickAsset(latest, installKind);
|
||
return {
|
||
available,
|
||
currentVersion,
|
||
latestVersion: String(latest.tag_name || '').replace(/^v/i, ''),
|
||
installKind,
|
||
notes: latest.body || '',
|
||
published: latest.published_at || latest.created_at || null,
|
||
asset: asset ? { name: asset.name, url: asset.browser_download_url, size: asset.size, kind: installKind } : null,
|
||
url: latest.html_url,
|
||
};
|
||
}
|
||
|
||
/*
|
||
* Wirft, sobald eine Datei nicht zweifelsfrei zur laufenden Paketart gehört.
|
||
* Wird vor dem Download und noch einmal direkt vor dem Ausführen aufgerufen.
|
||
*/
|
||
function assertMatchingKind(fileName, installKind) {
|
||
if (installKind !== 'portable' && installKind !== 'setup') {
|
||
throw new Error('Die Paketart des Launchers lässt sich nicht sicher bestimmen – bitte manuell aktualisieren.');
|
||
}
|
||
const kind = assetKind(fileName);
|
||
if (!kind) {
|
||
throw new Error(`"${fileName}" lässt sich keiner Paketart zuordnen – bitte manuell aktualisieren.`);
|
||
}
|
||
if (kind !== installKind) {
|
||
const soll = installKind === 'portable' ? 'eine Portable-Datei' : 'einen Installer';
|
||
const ist = kind === 'portable' ? 'eine Portable-Datei' : 'ein Installer';
|
||
throw new Error(`Falsches Paket: Dieser Launcher braucht ${soll}, angeboten wurde ${ist}.`);
|
||
}
|
||
}
|
||
|
||
/*
|
||
* Lädt den Installer herunter (mit Fortschritt) und startet ihn.
|
||
* onProgress: ({ phase, loaded, total, percent })
|
||
*/
|
||
async function downloadAndInstall(asset, onProgress) {
|
||
if (!asset || !asset.url) throw new Error('Kein Installer im Release gefunden.');
|
||
const installKind = detectInstallKind();
|
||
// Paketart am echten Dateinamen prüfen, nicht am mitgelieferten Feld –
|
||
// sonst kontrolliert sich die Angabe nur selbst.
|
||
assertMatchingKind(asset.name, installKind);
|
||
|
||
// Portable-Updates ersetzen später die laufende EXE – das geht nur, wenn wir sie kennen
|
||
const target = installKind === 'portable' ? portableTarget() : '';
|
||
if (installKind === 'portable' && !target) {
|
||
throw new Error('Der Pfad der laufenden Portable-Datei ist unbekannt – bitte manuell aktualisieren.');
|
||
}
|
||
|
||
const dir = updateDir();
|
||
fs.mkdirSync(dir, { recursive: true });
|
||
let dest = path.join(dir, asset.name.replace(/[^\w.\-]/g, '_'));
|
||
try {
|
||
fs.rmSync(dest, { force: true });
|
||
} catch {
|
||
// Alte Datei ist gesperrt (Virenscanner prüft sie gerade, oder ein vorheriger
|
||
// Installer-Versuch läuft noch) - dann eben unter neuem Namen laden, statt mit
|
||
// EPERM abzubrechen. Der Rest wird bei nächster Gelegenheit von
|
||
// cleanupUpdateCache() aufgeräumt (ab 10 Minuten Alter).
|
||
const parsed = path.parse(dest);
|
||
dest = path.join(parsed.dir, `${parsed.name}-${Date.now()}${parsed.ext}`);
|
||
}
|
||
|
||
const res = await fetch(asset.url, { headers: { 'User-Agent': UA } });
|
||
if (!res.ok) throw new Error('Download fehlgeschlagen: HTTP ' + res.status);
|
||
|
||
const total = Number(res.headers.get('content-length')) || asset.size || 0;
|
||
|
||
// direkt auf die Platte schreiben statt alles im Speicher zu sammeln
|
||
const out = fs.createWriteStream(dest);
|
||
const hash = crypto.createHash('sha256');
|
||
let loaded = 0;
|
||
let lastReport = 0;
|
||
try {
|
||
for await (const chunk of res.body) {
|
||
const buf = Buffer.from(chunk);
|
||
hash.update(buf);
|
||
loaded += buf.length;
|
||
// auf den Puffer warten; der jeweils andere Listener wird mit abgeräumt,
|
||
// sonst sammeln sich über den ganzen Download hunderte davon an
|
||
if (!out.write(buf)) await new Promise((resolve, reject) => {
|
||
const onDrain = () => { out.off('error', onError); resolve(); };
|
||
const onError = (e) => { out.off('drain', onDrain); reject(e); };
|
||
out.once('drain', onDrain);
|
||
out.once('error', onError);
|
||
});
|
||
// höchstens viermal pro Sekunde melden, sonst flutet es die Oberfläche
|
||
if (onProgress && Date.now() - lastReport >= 250) {
|
||
lastReport = Date.now();
|
||
onProgress({ phase: 'download', loaded, total, percent: total ? Math.round((loaded / total) * 100) : 0 });
|
||
}
|
||
}
|
||
await new Promise((resolve, reject) => {
|
||
out.once('error', reject);
|
||
out.end((err) => (err ? reject(err) : resolve()));
|
||
});
|
||
} catch (err) {
|
||
out.destroy();
|
||
fs.rmSync(dest, { force: true });
|
||
throw err;
|
||
}
|
||
|
||
if (total && loaded !== total) {
|
||
fs.rmSync(dest, { force: true });
|
||
throw new Error('Download unvollständig.');
|
||
}
|
||
|
||
if (onProgress) onProgress({ phase: 'ready', loaded, total: loaded, percent: 100 });
|
||
return { path: dest, bytes: loaded, sha256: hash.digest('hex'), kind: installKind, target };
|
||
}
|
||
|
||
/*
|
||
* Hilfsskript für Portable-Updates: wartet, bis der Launcher die eigene EXE
|
||
* freigibt, tauscht sie gegen die neue aus und startet sie wieder.
|
||
* "ping" statt "timeout", weil timeout ohne Konsole abbricht.
|
||
*/
|
||
const SWAP_SCRIPT = `@echo off
|
||
setlocal
|
||
set "SRC=%~1"
|
||
set "DST=%~2"
|
||
set "LOG=%~dp0update.log"
|
||
set TRIES=0
|
||
:loop
|
||
ping -n 2 127.0.0.1 >nul
|
||
copy /y "%SRC%" "%DST%" >nul 2>&1
|
||
if not errorlevel 1 goto done
|
||
set /a TRIES+=1
|
||
if %TRIES% GEQ 60 goto fail
|
||
goto loop
|
||
:done
|
||
del /f /q "%SRC%" >nul 2>&1
|
||
start "" "%DST%"
|
||
exit /b 0
|
||
:fail
|
||
echo [%DATE% %TIME%] Konnte "%DST%" nicht ersetzen - Datei gesperrt oder schreibgeschuetzt.>>"%LOG%"
|
||
exit /b 1
|
||
`;
|
||
|
||
/*
|
||
* Wendet das geladene Update an.
|
||
* setup -> NSIS-Installer starten, er überschreibt die Installation selbst
|
||
* portable -> Skript starten, das die laufende EXE nach dem Beenden ersetzt
|
||
* Der Launcher muss sich danach beenden.
|
||
*/
|
||
function applyUpdate(file) {
|
||
const installKind = detectInstallKind();
|
||
// letzte Kontrolle unmittelbar vor dem Start: passt die Datei auf der Platte
|
||
// wirklich zu dieser Paketart?
|
||
assertMatchingKind(path.basename(file.path || ''), installKind);
|
||
const kind = installKind;
|
||
|
||
if (kind !== 'portable') {
|
||
const child = spawn(file.path, [], { detached: true, stdio: 'ignore' });
|
||
child.unref();
|
||
return { started: 'installer' };
|
||
}
|
||
|
||
const target = file.target || portableTarget();
|
||
if (!target) throw new Error('Der Pfad der laufenden Portable-Datei ist unbekannt.');
|
||
|
||
const script = path.join(updateDir(), 'aeromc-swap.cmd');
|
||
fs.writeFileSync(script, SWAP_SCRIPT, 'ascii');
|
||
|
||
// über "start /min", damit kein Konsolenfenster im Vordergrund stehen bleibt
|
||
const child = spawn(process.env.COMSPEC || 'cmd.exe',
|
||
['/c', 'start', '', '/min', script, file.path, target],
|
||
{ detached: true, stdio: 'ignore', windowsHide: true });
|
||
child.unref();
|
||
return { started: 'swap', target };
|
||
}
|
||
|
||
// Rückwärtskompatibel: nur für die Setup-Variante
|
||
function runInstaller(installerPath) {
|
||
return applyUpdate({ path: installerPath, kind: 'setup' });
|
||
}
|
||
|
||
module.exports = {
|
||
checkForUpdate,
|
||
downloadAndInstall,
|
||
applyUpdate,
|
||
runInstaller,
|
||
cleanupUpdateCache,
|
||
compareVersions,
|
||
parseVersion,
|
||
detectInstallKind,
|
||
assetKind,
|
||
portableTarget,
|
||
};
|