Upload via GUI (194 Dateien)
This commit is contained in:
+495
-370
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user