'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, };