diff --git a/src/crashhelp.js b/src/crashhelp.js new file mode 100644 index 0000000..60116fc --- /dev/null +++ b/src/crashhelp.js @@ -0,0 +1,218 @@ +'use strict'; + +/* + * crashhelp.js – Absturz-Assistent + * --------------------------------- + * Liest Absturzbericht, latest.log und die letzte Konsolenausgabe eines Starts + * und erkennt die häufigsten Ursachen. Jeder Fund bekommt eine verständliche + * Erklärung und – wo möglich – eine Aktion, die der Launcher mit einem Klick + * ausführen kann: + * + * { typ: 'mod-installieren', projekt, name } fehlende Abhängigkeit von Modrinth holen + * { typ: 'mod-deaktivieren', dateiname, name } Mod abschalten (umbenennen, nichts gelöscht) + * { typ: 'ram', mb } Arbeitsspeicher der Instanz ändern + * { typ: 'java-auto' } passendes Java automatisch wählen lassen + * { typ: 'mods-ordner' } Mods-Ordner öffnen + * + * Bewusst nur Muster, die eindeutig sind – lieber keinen Fund als einen falschen. + */ + +// Mod-IDs, die keine installierbaren Mods sind +const KEINE_MODS = new Set(['minecraft', 'java', 'fabricloader', 'fabric-loader', 'forge', 'neoforge', 'quilt_loader', 'quilt-loader', 'fml', 'mixinextras']); + +// Mod-ID -> Modrinth-Projekt, wo sich beides unterscheidet +const PROJEKT_FUER_ID = { + fabric: 'fabric-api', + 'fabric-api-base': 'fabric-api', + quilted_fabric_api: 'qsl', + cloth_config: 'cloth-config', + 'cloth-config2': 'cloth-config', + architectury: 'architectury-api', + geckolib3: 'geckolib', +}; + +// Java-Hauptversion aus der "class file version" (52 = Java 8, 61 = 17, 65 = 21 …) +const javaAusKlassenversion = (v) => Number(v) - 44; + +function projektFuer(modId) { + const id = String(modId || '').toLowerCase(); + return PROJEKT_FUER_ID[id] || id.replace(/_/g, '-'); +} + +// installierte Mod zu einer ID/einem Namen finden (für "abschalten") +function findeMod(mods, modId, name) { + const id = String(modId || '').toLowerCase(); + const kurz = id.replace(/[-_]/g, ''); + const n = String(name || '').toLowerCase().replace(/[^a-z0-9]/g, ''); + return (mods || []).find((m) => { + if (m.enabled === false) return false; + const datei = String(m.filename || '').toLowerCase(); + const titel = String(m.title || '').toLowerCase().replace(/[^a-z0-9]/g, ''); + return (id && (datei.startsWith(id) || datei.replace(/[-_]/g, '').startsWith(kurz))) + || (n && titel && (titel === n || datei.replace(/[^a-z0-9]/g, '').startsWith(n))); + }) || null; +} + +/* + * text: zusammengeführter Text aus Bericht, Log und Konsole + * ctx: { loader, version, maxMemMb, gesamtMb, mods: [{ filename, title, projectId, enabled }] } + * -> [{ schluessel, titel, text, aktion? }] + */ +function analysiere(text, ctx = {}) { + const t = String(text || ''); + const funde = []; + const schonDa = new Set(); + const fund = (schluessel, titel, erklaerung, aktion) => { + if (schonDa.has(schluessel)) return; + schonDa.add(schluessel); + funde.push({ schluessel, titel, text: erklaerung, aktion: aktion || null }); + }; + const mods = ctx.mods || []; + + // --- Fehlende Abhängigkeiten (Fabric/Quilt) --- + // "Mod 'Mod Menu' (modmenu) 7.2.2 requires any version of fabric-api, which is missing!" + // neuere Fassung: "... requires any version of mod 'Fabric API' (fabric-api), which is missing!" + for (const m of t.matchAll(/Mod '([^']+)' \(([\w.-]+)\)[^\n]*? requires (?:version ([^\n,]+?)|any version) of (?:mod '([^']+)' \(([\w.-]+)\)|([\w.-]+)), which is missing/gi)) { + const [, name, , version, fehltName, fehltIdNeu, fehltIdAlt] = m; + const fehlt = fehltIdNeu || fehltIdAlt; + if (KEINE_MODS.has(fehlt.toLowerCase())) continue; + const anzeige = fehltName || fehlt; + fund(`fehlt:${fehlt}`, `„${name}“ braucht ${anzeige}`, + `Der Mod „${name}“ funktioniert nur zusammen mit „${anzeige}“${version ? ` (Version ${version.trim()})` : ''} – und der fehlt in dieser Instanz.`, + { typ: 'mod-installieren', projekt: projektFuer(fehlt), name: anzeige }); + } + // Lösungsvorschlag von Fabric: "- Install fabric-api, any version." + for (const m of t.matchAll(/^\s*-\s*Install ([\w.-]+), (?:any version|version [^\n.]+)/gim)) { + const fehlt = m[1]; + if (KEINE_MODS.has(fehlt.toLowerCase())) continue; + fund(`fehlt:${fehlt}`, `Es fehlt „${fehlt}“`, + `Ein installierter Mod braucht „${fehlt}“, der in dieser Instanz nicht installiert ist.`, + { typ: 'mod-installieren', projekt: projektFuer(fehlt), name: fehlt }); + } + + // --- Fehlende Abhängigkeiten (Forge/NeoForge) --- + // "Mod ID: 'architectury', Requested by: 'rei', Expected range: '[9.1.12,)', Actual version: '[MISSING]'" + for (const m of t.matchAll(/Mod ID: '([\w.-]+)', Requested by: '([\w.-]+)', Expected range: '([^']*)', Actual version: '([^']*)'/gi)) { + const [, fehlt, von, bereich, ist] = m; + if (fehlt.toLowerCase() === 'minecraft') { + const mod = findeMod(mods, von); + fund(`mcversion:${von}`, `„${von}“ passt nicht zu dieser Minecraft-Version`, + `Der Mod „${von}“ ist für Minecraft ${bereich} gemacht, deine Instanz hat ${ctx.version || ist}.`, + mod ? { typ: 'mod-deaktivieren', dateiname: mod.filename, name: mod.title || von } : null); + continue; + } + if (KEINE_MODS.has(fehlt.toLowerCase())) continue; + if (/MISSING/i.test(ist)) { + fund(`fehlt:${fehlt}`, `„${von}“ braucht ${fehlt}`, + `Der Mod „${von}“ funktioniert nur zusammen mit „${fehlt}“ – und der fehlt in dieser Instanz.`, + { typ: 'mod-installieren', projekt: projektFuer(fehlt), name: fehlt }); + } else { + fund(`alt:${fehlt}`, `„${fehlt}“ ist zu alt oder zu neu`, + `„${von}“ braucht „${fehlt}“ in der Version ${bereich}, installiert ist ${ist}. Aktualisiere „${fehlt}“ im Inhalts-Dialog („Nach Updates suchen“).`); + } + } + + // --- Mod für eine andere Minecraft-Version (Fabric) --- + // "Mod 'X' (x) 1.0 requires version 1.20.4 of minecraft, but only the wrong version is present: 1.21!" + for (const m of t.matchAll(/Mod '([^']+)' \(([\w.-]+)\)[^\n]*? requires (?:version |any version of )?([^\n]*?) of (?:mod )?'?minecraft'?/gi)) { + const [, name, id, fuer] = m; + const mod = findeMod(mods, id, name); + fund(`mcversion:${id}`, `„${name}“ passt nicht zu dieser Minecraft-Version`, + `„${name}“ ist für Minecraft ${fuer.replace(/^version\s+/i, '').trim() || 'eine andere Version'} gemacht, deine Instanz hat ${ctx.version || 'eine andere'}. Abschalten oder eine passende Version des Mods installieren.`, + mod ? { typ: 'mod-deaktivieren', dateiname: mod.filename, name } : null); + } + // Lösungsvorschlag: "- Remove mod 'X' (x)." + for (const m of t.matchAll(/^\s*-\s*Remove mod '([^']+)' \(([\w.-]+)\)/gim)) { + const [, name, id] = m; + const mod = findeMod(mods, id, name); + fund(`entfernen:${id}`, `„${name}“ verursacht den Fehler`, + `Der Mod-Loader schlägt vor, „${name}“ zu entfernen – er verträgt sich nicht mit den anderen Mods oder dieser Version.`, + mod ? { typ: 'mod-deaktivieren', dateiname: mod.filename, name } : null); + } + // "Mod 'A' (a) is incompatible with any version of mod 'B' (b)" + for (const m of t.matchAll(/Mod '([^']+)' \(([\w.-]+)\)[^\n]*? is incompatible with [^\n]*?mod '([^']+)' \(([\w.-]+)\)/gi)) { + const [, a, idA, b] = m; + const mod = findeMod(mods, idA, a); + fund(`inkompatibel:${idA}`, `„${a}“ und „${b}“ vertragen sich nicht`, + `Diese beiden Mods können nicht zusammen installiert sein. Schalte einen der beiden ab.`, + mod ? { typ: 'mod-deaktivieren', dateiname: mod.filename, name: a } : null); + } + + // --- Doppelte Mods --- + if (/Duplicate mods found|Found duplicate mods|is provided by multiple mods|Mod ID ['"]?[\w.-]+['"]? is (?:provided|present) multiple times/i.test(t)) { + fund('doppelt', 'Ein Mod ist doppelt installiert', + 'Mindestens ein Mod liegt zweimal im Mods-Ordner (z. B. zwei Versionen desselben Mods). Lösche die ältere Datei.', + { typ: 'mods-ordner' }); + } + + // --- Falsche Java-Version --- + const klasse = t.match(/UnsupportedClassVersionError[\s\S]{0,300}?class file version (\d+)\.\d+/i); + const javaPflicht = t.match(/requires (?:at least )?Java (\d+)|Java (\d+) (?:or (?:newer|later|above)) is required|Unsupported Java detected \((\d+)/i); + if (klasse || javaPflicht) { + const noetig = klasse ? javaAusKlassenversion(klasse[1]) : Number(javaPflicht[1] || javaPflicht[2] || 0); + fund('java', noetig ? `Diese Instanz braucht Java ${noetig}` : 'Falsche Java-Version', + `Minecraft oder ein Mod wurde für ${noetig ? `Java ${noetig}` : 'eine andere Java-Version'} gebaut, gestartet wurde mit einer älteren. AeroMC kann das passende Java automatisch wählen und bei Bedarf herunterladen.`, + { typ: 'java-auto' }); + } + + // --- Zu wenig Arbeitsspeicher --- + if (/java\.lang\.OutOfMemoryError|There is insufficient memory for the Java Runtime/i.test(t)) { + const jetzt = Number(ctx.maxMemMb) || 2048; + const grenze = Math.max(2048, Math.floor((Number(ctx.gesamtMb) || 8192) * 0.6)); + const neu = Math.min(grenze, jetzt + 2048); + fund('ram', 'Minecraft hatte zu wenig Arbeitsspeicher', + `Der Instanz waren ${Math.round(jetzt / 102.4) / 10} GB zugewiesen – zu wenig für diese Mods oder Welt.` + + (neu > jetzt ? ` Empfohlen: ${Math.round(neu / 102.4) / 10} GB.` : ' Mehr lässt dein PC kaum zu – weniger Mods oder eine kleinere Sichtweite helfen.'), + neu > jetzt ? { typ: 'ram', mb: neu } : null); + } + if (/Could not reserve enough space for (?:\d+KB )?object heap|Invalid maximum heap size|Initial heap size set to a larger value than the maximum/i.test(t)) { + const jetzt = Number(ctx.maxMemMb) || 4096; + const neu = Math.max(2048, Math.min(jetzt - 1024, Math.floor((Number(ctx.gesamtMb) || 8192) * 0.5))); + fund('ram-zuviel', 'Zu viel Arbeitsspeicher eingestellt', + `Java konnte die eingestellten ${Math.round(jetzt / 102.4) / 10} GB nicht reservieren – entweder ist gerade nicht genug frei oder es läuft ein 32-Bit-Java.`, + neu < jetzt ? { typ: 'ram', mb: neu } : { typ: 'java-auto' }); + } + + // --- Mixin-Fehler: ein bestimmter Mod passt nicht --- + for (const m of t.matchAll(/Mixin (?:apply|transformation)[^\n]*? (?:for|from) mod ([\w.-]+) failed|from mod ([\w.-]+) failed injection check|mixins?\.json:[^\n]*? from mod ([\w.-]+)/gi)) { + const id = m[1] || m[2] || m[3]; + if (!id || KEINE_MODS.has(id.toLowerCase())) continue; + const mod = findeMod(mods, id); + fund(`mixin:${id}`, `„${mod ? (mod.title || id) : id}“ passt nicht zu dieser Version`, + `Der Mod „${id}“ greift tief in Minecraft ein und ist mit dieser Minecraft-Version oder einem anderen Mod nicht kompatibel. Schalte ihn ab oder installiere eine passende Version.`, + mod ? { typ: 'mod-deaktivieren', dateiname: mod.filename, name: mod.title || id } : null); + } + + // --- Grafiktreiber --- + if (/Pixel format not accelerated|WGL: The driver does not appear to support OpenGL|GLFW error 65542|OpenGL 3\.2 (?:is )?not supported|Couldn't set pixel format|No OpenGL context found/i.test(t)) { + fund('treiber', 'Problem mit dem Grafiktreiber', + 'Minecraft konnte OpenGL nicht starten. Meist hilft es, den Grafiktreiber direkt vom Hersteller (NVIDIA, AMD oder Intel) zu aktualisieren. Bei Laptops: in den Windows-Grafikeinstellungen Java auf „Hohe Leistung“ stellen.'); + } + + // --- Bekannte Unverträglichkeiten anhand der installierten Mods --- + const aktive = mods.filter((m) => m.enabled !== false); + const hat = (re) => aktive.find((m) => re.test(String(m.filename || '') + ' ' + String(m.title || ''))); + const optifine = hat(/optifine|optifabric/i); + const sodium = hat(/sodium|embeddium|rubidium/i); + if (optifine && sodium) { + fund('optifine-sodium', 'OptiFine und Sodium zusammen installiert', + 'OptiFine und Sodium (bzw. Embeddium/Rubidium) machen dasselbe und vertragen sich nicht. Schalte OptiFine ab – für Shader gibt es Iris als Ergänzung zu Sodium.', + { typ: 'mod-deaktivieren', dateiname: optifine.filename, name: optifine.title || 'OptiFine' }); + } + + // --- Forge nennt einen Verdächtigen, aber sonst nichts Eindeutiges --- + if (!funde.length) { + const verdacht = t.match(/^Suspected Mods?:\s*([^\n]+)$/im); + if (verdacht && !/^\s*NONE\s*$/i.test(verdacht[1])) { + const name = verdacht[1].split(/[,(]/)[0].trim(); + const mod = findeMod(mods, '', name); + fund('verdacht', `Vermutlich verursacht von „${name}“`, + `Laut Absturzbericht steckt wahrscheinlich „${verdacht[1].trim()}“ dahinter. Schalte den Mod testweise ab und starte erneut.`, + mod ? { typ: 'mod-deaktivieren', dateiname: mod.filename, name: mod.title || name } : null); + } + } + + return funde; +} + +module.exports = { analysiere, projektFuer }; diff --git a/src/friends.js b/src/friends.js index 79e8f16..c29e487 100644 --- a/src/friends.js +++ b/src/friends.js @@ -4,8 +4,14 @@ * friends.js – Anbindung an das AeroMC-Freundesystem (WordPress-Plugin) * ----------------------------------------------------------------------------- * Schlanke Fetch-Wrapper gegen die REST-Routen des "AeroMC Freunde"-Plugins - * (siehe wordpress-plugin/aeromc-friends/). Keine eigene Anmeldung - jeder - * Aufruf trägt einfach die Minecraft-UUID des aktiven Kontos. + * (siehe wordpress-plugin/aeromc-friends/). Jeder Aufruf trägt die + * Minecraft-UUID des Kontos und – sobald angemeldet – ein Sitzungs-Token. + * + * Anmeldung wie bei einem Minecraft-Server, ohne eigenes Passwort: Aufgabe + * vom Plugin holen, mit dem Minecraft-Zugang des Kontos bei Mojang "beitreten", + * Plugin prüft bei Mojang nach und gibt ein Token aus. Läuft im Hintergrund + * vor der ersten Anfrage eines Kontos; kennt das Plugin die Anmeldung noch + * nicht (ältere Fassung), geht es ohne Token weiter wie bisher. * * Genau wie bei discordpresence.js: jeder Fehler (Plugin nicht installiert, * kein Internet, Seite down) wird abgefangen und nach oben als { ok:false, @@ -14,19 +20,84 @@ */ const BASIS_URL = 'https://viper-network.de/wp-json/aeromc/v1'; +const MOJANG_JOIN = 'https://sessionserver.mojang.com/session/minecraft/join'; + +// liefert für eine Konto-UUID { accessToken, name } (vom Hauptprozess gesetzt) +let zugangsHelfer = null; +const sitzungen = new Map(); // uuid -> Token +const laufendeAnmeldung = new Map(); // uuid -> Promise (nicht doppelt anmelden) +const fehlversuch = new Map(); // uuid -> Zeitpunkt, um nicht ständig neu zu versuchen +const NEUVERSUCH_MS = 5 * 60 * 1000; + +function setzeZugangsHelfer(fn) { zugangsHelfer = fn; } + +async function roheAnfrage(pfad, { method = 'GET', body, token } = {}) { + const url = method === 'GET' && body + ? `${BASIS_URL}${pfad}?${new URLSearchParams(body).toString()}` + : `${BASIS_URL}${pfad}`; + const headers = { 'Content-Type': 'application/json' }; + if (token) headers['X-AeroMC-Token'] = token; + const res = await fetch(url, { + method, + headers, + body: method === 'GET' ? undefined : JSON.stringify(body || {}), + }); + const daten = await res.json().catch(() => null); + return { res, daten }; +} + +// Mojang-Sitzungsprüfung durchlaufen und ein Token holen; null bei Fehlschlag +async function anmelden(uuid) { + if (!zugangsHelfer) return null; + const letzter = fehlversuch.get(uuid); + if (letzter && Date.now() - letzter < NEUVERSUCH_MS) return null; + if (laufendeAnmeldung.has(uuid)) return laufendeAnmeldung.get(uuid); + const ablauf = (async () => { + try { + const zugang = await zugangsHelfer(uuid); + if (!zugang || !zugang.accessToken || !zugang.name) throw new Error('kein Minecraft-Zugang'); + const aufgabe = await roheAnfrage('/auth/challenge', { method: 'POST', body: { uuid, username: zugang.name } }); + if (!aufgabe.res.ok || !aufgabe.daten || !aufgabe.daten.serverId) throw new Error('keine Aufgabe'); + const join = await fetch(MOJANG_JOIN, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + accessToken: zugang.accessToken, + selectedProfile: String(uuid).replace(/-/g, ''), + serverId: aufgabe.daten.serverId, + }), + }); + if (!join.ok) throw new Error('Mojang ' + join.status); + const bestaetigt = await roheAnfrage('/auth/verify', { method: 'POST', body: { uuid } }); + if (!bestaetigt.res.ok || !bestaetigt.daten || !bestaetigt.daten.token) throw new Error('nicht bestätigt'); + sitzungen.set(uuid, bestaetigt.daten.token); + fehlversuch.delete(uuid); + return bestaetigt.daten.token; + } catch { + fehlversuch.set(uuid, Date.now()); + return null; + } finally { + laufendeAnmeldung.delete(uuid); + } + })(); + laufendeAnmeldung.set(uuid, ablauf); + return ablauf; +} async function anfrage(pfad, { method = 'GET', body } = {}) { try { - const url = method === 'GET' && body - ? `${BASIS_URL}${pfad}?${new URLSearchParams(body).toString()}` - : `${BASIS_URL}${pfad}`; - const res = await fetch(url, { - method, - headers: { 'Content-Type': 'application/json' }, - body: method === 'GET' ? undefined : JSON.stringify(body || {}), - }); - const daten = await res.json().catch(() => null); - if (!res.ok) return { ok: false, message: (daten && daten.message) || `Fehler ${res.status}` }; + const uuid = body && body.uuid; + let token = uuid ? sitzungen.get(uuid) : null; + if (uuid && !token) token = await anmelden(uuid); + let { res, daten } = await roheAnfrage(pfad, { method, body, token }); + // Sitzung abgelaufen oder Anmeldung jetzt Pflicht -> einmal neu anmelden + if (res.status === 401 && daten && daten.code === 'aeromc_auth' && uuid) { + sitzungen.delete(uuid); + fehlversuch.delete(uuid); + token = await anmelden(uuid); + if (token) ({ res, daten } = await roheAnfrage(pfad, { method, body, token })); + } + if (!res.ok) return { ok: false, message: (daten && daten.message) || `Fehler ${res.status}`, code: daten && daten.code }; return { ok: true, data: daten }; } catch (err) { return { ok: false, message: 'Freundesystem nicht erreichbar: ' + err.message }; @@ -79,7 +150,58 @@ function dismissInvite(uuid, id) { return anfrage('/friends/invites/dismiss', { method: 'POST', body: { uuid, id } }); } +// Instanz per Code teilen: Beschreibung (Version, Loader, Mod-Liste) hinterlegen -> { code, days } +function createShare(uuid, manifest) { + return anfrage('/share', { method: 'POST', body: { uuid, manifest } }); +} + +// geteilte Instanz abrufen (ohne Anmeldung – wer den Code hat, darf sie sehen) +async function getShare(code) { + try { + const { res, daten } = await roheAnfrage('/share', { method: 'GET', body: { code } }); + if (!res.ok) return { ok: false, message: (daten && daten.message) || `Fehler ${res.status}` }; + return { ok: true, data: daten }; + } catch (err) { + return { ok: false, message: 'Server nicht erreichbar: ' + err.message }; + } +} + +// ---- Kurznachrichten ---- +// image: optional base64-JPEG (geteilter Screenshot) +function sendMessage(uuid, targetUuid, text, image) { + const body = { uuid, targetUuid, text: text || '' }; + if (image) body.image = image; + return anfrage('/friends/message', { method: 'POST', body }); +} + +// Unterhaltung mit einem Freund (älteste zuerst) +function getMessages(uuid, withUuid) { + return anfrage('/friends/messages', { method: 'GET', body: { uuid, with: withUuid } }); +} + +// ungelesene Nachrichten an mich +function getUnreadMessages(uuid) { + return anfrage('/friends/messages/unread', { method: 'GET', body: { uuid } }); +} + +function markMessagesRead(uuid, fromUuid) { + return anfrage('/friends/messages/read', { method: 'POST', body: { uuid, fromUuid } }); +} + +// Fehlerbericht aus dem Absturz-Assistenten; mit uuid wird angemeldet gesendet, sonst anonym +async function sendReport(daten) { + if (daten && daten.uuid) return anfrage('/report', { method: 'POST', body: daten }); + try { + const { res, daten: antwort } = await roheAnfrage('/report', { method: 'POST', body: daten }); + if (!res.ok) return { ok: false, message: (antwort && antwort.message) || `Fehler ${res.status}` }; + return { ok: true, data: antwort }; + } catch (err) { + return { ok: false, message: 'Server nicht erreichbar: ' + err.message }; + } +} + module.exports = { heartbeat, getFriends, getRequests, getSentRequests, sendRequest, respond, cancelRequest, removeFriend, - sendInvite, getInvites, dismissInvite, + sendInvite, getInvites, dismissInvite, createShare, getShare, setzeZugangsHelfer, + sendMessage, getMessages, getUnreadMessages, markMessagesRead, sendReport, }; diff --git a/src/index.html b/src/index.html index 03c1835..2670726 100644 --- a/src/index.html +++ b/src/index.html @@ -64,6 +64,7 @@
+ @@ -583,6 +584,14 @@ Noch keine automatische Sicherung. +
+
Welten
+ + Läuft im Hintergrund, sobald Minecraft beendet ist. Pro Welt bleiben die letzten 5 Stände erhalten – unter Dokumente\AeroMC Launcher\Welten-Sicherungen\<Instanz>\Automatisch. +
+
Wiederherstellen
Wähle ein Backup aus Dokumente/AeroMC Launcher/Backups aus. Achtung: Alle vorhandenen Instanzen werden beim Wiederherstellen mit dem Inhalt des Backups überschrieben. Launcher-Einstellungen werden ebenfalls wiederhergestellt. Konten und Tokens bleiben unverändert. @@ -1011,6 +1020,32 @@ + + + + + + + + @@ -1494,6 +1599,29 @@ + + +