Upload via GUI (194 Dateien)

This commit is contained in:
2026-10-07 07:32:04 +00:00
parent 3ac5a6c14d
commit d976cb4595
8 changed files with 6244 additions and 6054 deletions
+495 -370
View File
@@ -1,370 +1,495 @@
'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: 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 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 -> 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.
*/
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') {
// --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 };
}
// 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,
};
'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,
};