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