Files
AeroMc-Launcher/src/updater.js
T
2026-10-07 07:32:04 +00:00

496 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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';
// Downloads nur von hier – eine Release-Datei mit fremder Adresse wird abgelehnt
const ERLAUBTER_HOST = 'git.viper.ipv64.net';
// Die Signaturen liegen nicht im Gitea-Release, sondern auf der WordPress-Seite
// (Plugin aeromc-friends, includes/signaturen.php). Hochgeladen werden sie beim
// Bauen automatisch von scripts/sign-release.js.
const SIGNATUR_API = 'https://viper-network.de/wp-json/aeromc/v1/update-signatur';
/*
* Sicherheit: Jede Update-Datei muss mit dem privaten Release-Schlüssel signiert
* sein (Ed25519). Der liegt nur auf dem Rechner, auf dem gebaut wird – nie auf
* dem Server. Wer den Gitea-Server oder das Konto übernimmt, kann deshalb zwar
* Dateien hochladen, aber keine gültige Signatur erzeugen; der Launcher
* installiert so etwas nicht. Signiert wird beim Bauen von
* scripts/sign-release.js; die Signatur liegt auf einem zweiten Server
* (SIGNATUR_API) – woher sie kommt, ist für die Sicherheit aber egal, fälschen
* kann sie ohne den Schlüssel niemand.
*
* Unterschrieben wird nicht nur der Inhalt (SHA-256), sondern auch Dateiname
* und Version. So lässt sich weder eine Portable- als Setup-Datei ausgeben noch
* eine alte, signierte Fassung als "neues" Update unterschieben.
*/
const SIGNATUR_FORMAT = 'AeroMC-Update/1';
let oeffentlicherSchluessel = null;
function releaseSchluessel() {
if (!oeffentlicherSchluessel) {
const pem = fs.readFileSync(path.join(__dirname, 'update-schluessel.pem'), 'utf8');
oeffentlicherSchluessel = crypto.createPublicKey(pem);
}
return oeffentlicherSchluessel;
}
function normVersion(v) {
return String(v || '').trim().replace(/^v/i, '');
}
// Der Text, der unterschrieben wird (identisch in scripts/sign-release.js)
function signaturText(datei, version, sha256) {
return Buffer.from([SIGNATUR_FORMAT, datei, normVersion(version), String(sha256).toLowerCase()].join('\n'), 'utf8');
}
// Prüft eine Signatur (Inhalt der .sig-Datei) gegen Name, Version und Hash
function pruefeSignatur(sig, datei, version, sha256) {
if (!sig || sig.format !== SIGNATUR_FORMAT) throw new Error('Signaturdatei hat ein unbekanntes Format.');
if (sig.datei !== datei) throw new Error('Signatur gehört zu einer anderen Datei.');
if (normVersion(sig.version) !== normVersion(version)) throw new Error('Signatur gehört zu einer anderen Version.');
if (String(sig.sha256 || '').toLowerCase() !== String(sha256).toLowerCase()) {
throw new Error('Die heruntergeladene Datei passt nicht zur Signatur – Update abgebrochen.');
}
const ok = crypto.verify(null, signaturText(datei, version, sha256),
releaseSchluessel(), Buffer.from(String(sig.signatur || ''), 'base64'));
if (!ok) throw new Error('Ungültige Signatur – die Datei stammt nicht vom AeroMC-Entwickler. Update abgebrochen.');
}
function erlaubteAdresse(adresse) {
try {
const u = new URL(String(adresse || ''));
return u.protocol === 'https:' && u.hostname.toLowerCase() === ERLAUBTER_HOST;
} catch { return false; }
}
/*
* Signatur einer Release-Datei vom WordPress-Server holen.
* -> Signatur-Objekt, null wenn es (noch) keine gibt; wirft bei Netzwerkfehlern.
*/
async function holeSignatur(datei, version) {
const adresse = `${SIGNATUR_API}?datei=${encodeURIComponent(datei)}&version=${encodeURIComponent(normVersion(version))}`;
const res = await fetch(adresse, { headers: { 'User-Agent': UA, Accept: 'application/json' } });
if (res.status === 404) return null;
if (!res.ok) throw new Error('Signatur-Server antwortet mit HTTP ' + res.status);
const text = await res.text();
if (text.length > 16384) throw new Error('Signatur ist ungültig.');
const sig = JSON.parse(text);
if (!sig || typeof sig !== 'object' || sig.datei !== datei || normVersion(sig.version) !== normVersion(version)) {
throw new Error('Signatur passt nicht zu diesem Update.');
}
return { format: sig.format, datei: sig.datei, version: sig.version, sha256: sig.sha256, signatur: sig.signatur };
}
function sha256Datei(datei) {
return new Promise((resolve, reject) => {
const h = crypto.createHash('sha256');
fs.createReadStream(datei).on('error', reject).on('data', (d) => h.update(d)).on('end', () => resolve(h.digest('hex')));
});
}
/*
* 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: das Setup legt neben der EXE immer "Uninstall AeroMC.exe" 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 latestVersion = normVersion(latest.tag_name);
let asset = pickAsset(latest, installKind);
// ohne Signatur oder von fremder Adresse wird nichts automatisch installiert
let unsigniert = false;
let signaturHinweis = '';
let sig = null;
if (asset && !erlaubteAdresse(asset.browser_download_url)) {
asset = null;
unsigniert = true;
} else if (asset && available) {
try {
sig = await holeSignatur(asset.name, latestVersion);
} catch (err) {
signaturHinweis = 'Die Signatur des Updates ließ sich gerade nicht abrufen (' + (err.message || err) + '). Bitte später erneut versuchen.';
}
if (!sig) {
asset = null;
unsigniert = !signaturHinweis;
}
}
return {
available,
currentVersion,
latestVersion,
installKind,
unsigniert,
signaturHinweis,
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,
version: latestVersion, signatur: sig,
} : 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);
if (!erlaubteAdresse(asset.url)) {
throw new Error('Das Update kommt nicht vom AeroMC-Server – abgebrochen.');
}
// ohne Signatur wird gar nicht erst heruntergeladen
const signatur = asset.signatur;
if (!signatur || signatur.datei !== asset.name || normVersion(signatur.version) !== normVersion(asset.version)) {
throw new Error('Signatur passt nicht zu diesem Update – abgebrochen.');
}
// 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.');
}
const sha256 = hash.digest('hex');
try {
pruefeSignatur(signatur, asset.name, asset.version, sha256);
} catch (err) {
fs.rmSync(dest, { force: true });
throw err;
}
if (onProgress) onProgress({ phase: 'ready', loaded, total: loaded, percent: 100 });
return { path: dest, bytes: loaded, sha256, signatur, name: asset.name, version: asset.version, 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 -> AeroMC-Setup mit --update starten, es überschreibt die Installation selbst
* portable -> Skript starten, das die laufende EXE nach dem Beenden ersetzt
* Der Launcher muss sich danach beenden.
*/
async function applyUpdate(file) {
const installKind = detectInstallKind();
// letzte Kontrolle unmittelbar vor dem Start: passt die Datei auf der Platte
// wirklich zu dieser Paketart – und ist sie noch genau die signierte? (Zwischen
// Download und Start könnte sie im Temp-Ordner ausgetauscht worden sein.)
assertMatchingKind(path.basename(file.path || ''), installKind);
pruefeSignatur(file.signatur, file.name, file.version, await sha256Datei(file.path));
const kind = installKind;
if (kind !== 'portable') {
// --update: das AeroMC-Setup aktualisiert ohne Rückfragen und startet AeroMC danach neu
const child = spawn(file.path, ['--update'], { 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 };
}
module.exports = {
checkForUpdate,
downloadAndInstall,
applyUpdate,
pruefeSignatur,
signaturText,
holeSignatur,
SIGNATUR_API,
cleanupUpdateCache,
compareVersions,
parseVersion,
detectInstallKind,
assetKind,
portableTarget,
};