Compare commits

...
7 Commits
Author SHA1 Message Date
M_Viper 6ef51edede Upload via GUI (146 Dateien) 2026-10-05 17:20:31 +00:00
M_Viper 93c74f94a1 Upload via GUI (146 Dateien) 2026-10-05 17:20:30 +00:00
M_Viper 384354f44e Upload via GUI (146 Dateien) 2026-10-05 17:20:29 +00:00
M_Viper 9afe91a592 Upload via GUI (150 Dateien) 2026-10-05 17:20:04 +00:00
M_Viper de81575bcf Upload via GUI (143 Dateien) 2026-10-04 18:46:18 +00:00
M_Viper 76762ab567 Upload via GUI (143 Dateien) 2026-10-04 18:46:08 +00:00
M_Viper 6a0e92eb0f Upload via GUI (143 Dateien) 2026-10-04 18:46:00 +00:00
36 changed files with 6047 additions and 3348 deletions
+61
View File
@@ -2,6 +2,67 @@
Alle nennenswerten Änderungen an AeroMC werden hier festgehalten.
## [1.2.1] - 2026-10-05
### Neu
- Garderobe im Skin-Fenster: Skins (samt Armform) und eigene AeroMC-Umhänge speichern und mit einem Klick wieder anziehen – vor jedem Skinwechsel im Launcher wird der bisherige Skin automatisch gesichert, denn Mojang merkt sich keine alten Skins
- Erfolge: 17 Erfolge (z. B. Spielzeit, Starts, Instanzen, Mods, Modpack, erster Freund, eigener Umhang, Skin aus dem Editor) mit „Erfolg erzielt!“-Meldung passend zum Design (im Minecraft-Design wie im Spiel); ein Klick auf die Meldung öffnet die Übersicht
- Freunde mit 3D-Figur: Neben der Freundesliste dreht sich groß die Figur des ausgewählten Freundes mit Skin, 2. Ebene und Umhang (online läuft sie, offline steht sie) – das Freunde-Fenster ist dafür breiter, schmal gezogen steht die Figur über der Liste; die Köpfe in der Liste zeigen ebenfalls die 2. Ebene
- Leistungs-Paket im Mod-Dialog: installiert mit einem Klick passende Performance-Mods – Sodium (bzw. Embeddium), Lithium (bzw. Radium), FerriteCore, Entity Culling, ImmediatelyFast, ModernFix und Dynamic FPS; Vorhandenes und für die Version nicht Verfügbares wird übersprungen, mit OptiFine bleibt Sodium weg
- Starten-Knopf mit Fortschritt: füllt sich während Download und Vorbereitung und zeigt die Phase (z. B. „Assets 57 %“), danach kurz „Läuft ✓“
- Mehr Leben in der Oberfläche (alles über „Bewegte Oberfläche“ abschaltbar):
- Laufende Instanzen haben einen pulsierenden Rand – man sieht sofort, was gerade gespielt wird
- Instanzen löschen, verschieben oder Gruppen auf-/zuklappen: Karten schrumpfen weg bzw. blenden ein, die übrigen rutschen weich nach
- Skin übernommen: Die Figur dreht sich einmal um sich selbst; nach einem Umhang-Wechsel weht der Umhang kurz auf
- Konto gewechselt: Der Spielerkopf oben rechts dreht sich wie eine Münze zum neuen Konto
- Erfolg erzielt: Funken um die Meldung, im Minecraft-Design grüne XP-Kugeln
- Freund kommt online: Sein Kopf hüpft in der Freundesliste, der grüne Punkt pulsiert
- AeroMC/Nachtlila: dezent treibende Lichtpunkte im Instanzbereich
- Minecraft-Design: Wolken ziehen durch die Werkzeugleiste, und beim Download zeigt das Block-Symbol der Instanz Abbau-Risse als Fortschritt
- Das Zahnrad dreht sich beim Darüberfahren, das Block-Symbol einer Instanz kippt leicht zur Maus
- Armform-Prüfung im Skin-Fenster: Passt die eingestellte Armform nicht zur Skin-Datei (gequetschte Arme oder dunkle Streifen), zeigt AeroMC einen Hinweis und stellt sie mit einem Klick richtig
- Absturz-Assistent erkennt zu alte Forge-Versionen, die mit aktuellem Java 8 abstürzen („ManifestEntryVerifier“), und aktualisiert Forge auf Wunsch auf die neueste Version für dieselbe Minecraft-Version
### Geändert
- Statistik-Fenster mit zwei Reitern: „Spielzeit“ und „Erfolge (x/17)“
- Update-Meldung zeigt die Änderungshinweise formatiert an (Überschriften, Listen, fett/kursiv, Code, Links) statt als rohen Markdown-Text; das Fenster ist dafür größer, Links öffnen im Browser
- Einstellungen aufgeräumt: jede Option als eigene Zeile mit kurzer Erklärung und Schalter statt Häkchen, alles einspaltig statt verstreuter Kacheln; neu sortiert in Allgemein, Darstellung, Spiel (inkl. Konsole), Java & Erweitert, Freunde & Umhänge (neu), Netzwerk, Konten, Backup (Welt-Sicherung jetzt bei „Automatisch sichern“)
### Behoben
- Alte Forge-Versionen vor Minecraft 1.13 (z. B. 1.12.2) starteten nicht („Hauptklasse net.minecraft.launchwrapper.Launch konnte nicht gefunden werden“) – Klassenpfad und Startargumente fehlten
- Bei Forge und NeoForge wurde ohne festgelegte Version teils die älteste statt der neuesten Version gewählt – die Versionsliste ist jetzt richtig sortiert
- Der Mod-Katalog zeigte bei von Hand kopierten oder importierten Mods „Installieren“, obwohl sie schon da waren – AeroMC erkennt sie jetzt per Prüfsumme über Modrinth
- Der ZIP-Import (AeroMC-Export oder gezippte MultiMC/Prism-Instanz) blockierte bei großen Archiven den Launcher – die Dateien werden jetzt im Hintergrund verschoben bzw. kopiert
## [1.2.0] - 2026-10-04
### Neu
- Untergruppen (optional): im Feld „Gruppe“ mit „/“ trennen, z. B. „Modpacks/Survival“ – auch mehrere Ebenen tief. Untergruppen erscheinen eingerückt unter ihrer Gruppe, lassen sich einzeln einklappen und per Drag & Drop befüllen; die Anzahl an einer Gruppe zählt ihre Untergruppen mit
- Importe aus anderen Launchern zeigen jetzt genauen Fortschritt: Instanz x von y, übertragene GB, Prozent, Tempo, Restzeit und die gerade kopierte Datei
- Import abbrechen: fertig übernommene Instanzen bleiben erhalten, die gerade halb kopierte wird wieder entfernt
- Vor dem Kopieren prüft AeroMC, ob genug Speicherplatz frei ist, und nennt sonst benötigte und freie GB
- Konto-Menü oben rechts (wie in MultiMC): Ein Klick auf den Kontonamen klappt alle Konten mit Spielerkopf auf – das aktive ist markiert, ein Klick wechselt sofort. Dazu „Konto hinzufügen…“, „Skin ändern…“ und „Konten verwalten…“
### Geändert
- Plugin „AeroMC Freunde“ 1.5.1: merkt sich den von Mojang bestätigten Namen schon bei der Anmeldung und beim Senden einer Anfrage – Spieler sind damit per Name auffindbar, bevor sie zum ersten Mal über AeroMC gespielt haben (Server-Update empfohlen, der Launcher funktioniert auch mit 1.5.0)
### Behoben
- Freundschaftsanfragen zeigten statt des Namens nur eine lange ID (UUID), wenn der Absender noch nie über AeroMC gespielt hatte – fehlende Namen holt der Launcher jetzt direkt bei Mojang (auch in der Freundesliste und bei gesendeten Anfragen)
- Große Importe (viele GB, z. B. 60 GB aus MultiMC/Prism, CurseForge, Modrinth App, Minecraft Launcher oder Technic) froren den Launcher komplett ein – kopiert wird jetzt im Hintergrund, der Launcher bleibt bedienbar
- Einzelne gesperrte Dateien (z. B. eine gerade geöffnete Welt) brechen den Import nicht mehr ab, sie werden übersprungen und gemeldet
- „Instanz kopieren“, „Löschen“ und das Ersetzen einer vorhandenen Instanz beim erneuten Import blockierten bei großen Instanzen den Launcher – das läuft jetzt im Hintergrund
- Der Spielerkopf oben neben dem Kontonamen und in der Kontoliste zeigte die 2. Skin-Ebene (Mütze, Haare, Kragen, Ärmel) nicht sichtbar – er wird jetzt selbst in 3D gezeichnet, die 2. Ebene steht wie im Spiel plastisch ab
## [1.1.9] - 2026-10-04
### Neu
- Startanimation: Beim Öffnen dreht sich das AeroMC-Logo mit Schriftzug, danach baut sich der Launcher von der Mitte nach außen auf – Schriftzug passend zum Design (Minecraft mit Pixel-Schrift), nicht beim Start im Infobereich
- Lebendige Skin-Figur im Hintergrund: atmet, wiegt sich leicht, der Umhang schwingt, und der Kopf folgt dem Mauszeiger – pausiert, solange der Launcher minimiert oder im Hintergrund ist
- Fenster wie Optionen, Mods, Skin und Freunde wachsen beim Öffnen aus dem angeklickten Knopf heraus
- Instanzen blenden beim Öffnen des Launchers nacheinander ein; beim Darüberfahren hebt sich das Block-Symbol und dreht sich ein Stück
- Designwechsel blenden weich über statt hart umzuschalten
- Optionen → Darstellung → Animationen: „Startanimation“ und „Bewegte Oberfläche“ einzeln abschaltbar; ist in Windows „Animationen anzeigen“ aus, bleibt die Oberfläche ruhig
## [1.1.8] - 2026-10-04
### Behoben
+11 -3
View File
@@ -6,7 +6,7 @@
Ein Minecraft-Launcher für Windows mit getrennten Instanzen, Mod-Unterstützung, Spielaufzeichnung, Freundesystem und Microsoft-Anmeldung.
Aktueller Stand: Version 1.1.8
Aktueller Stand: Version 1.2.0
<img src="docs/aeromc.png" alt="AeroMC – Instanzliste mit Seitenleiste und Skin-Vorschau" width="820" />
@@ -62,11 +62,13 @@ Der Launcher konzentriert sich auf den Alltag: Instanz anlegen, Konto auswählen
- Eigener Server auf Wunsch verborgen, Freundesystem komplett abschaltbar
**Konten, Welten und Backups**
- Microsoft-Login mit mehreren Konten
- Microsoft-Login mit mehreren Konten – Wechsel mit einem Klick über das Konto-Menü oben rechts
- Eigenen Skin im Launcher hochladen, mit Live-Vorschau anpassen (klassisch/schlank) und zurücksetzen
- **Skin-Editor mit zwei Schichten**: Skins selbst pixeln – auf der Vorlage oder direkt auf der 3D-Figur, mit Spiegeln, Füllen, Pipette und Rückgängig
- **Eigene Umhänge, auch HD bis 1024×512**: im Skin-Editor gestalten, im Spiel in jeder Version und mit jedem Loader sichtbar – für Spieler mit anderen Launchern gibt es den Mod „AeroMC Umhänge“
- Offiziellen Mojang-Umhang direkt im Launcher wechseln oder ablegen
- **Garderobe**: Skins und Umhänge speichern und mit einem Klick wieder anziehen – der bisherige Skin wird vor jedem Wechsel automatisch gesichert
- Armform-Prüfung: Hinweis und Ein-Klick-Korrektur, wenn die Armform nicht zur Skin-Datei passt
- Skin-Hintergrund des aktiven Kontos (mehrere Posen, passende Armform, einstellbare Deckkraft, optional wechselnd)
- Welten, Ressourcenpakete, Datenpakete, Screenshots, Serverliste und Logdateien verwalten
- Serverliste mit Live-Status und Desktop-Verknüpfung pro Server (startet die Instanz und verbindet direkt)
@@ -132,7 +134,7 @@ Automatische Updates bleiben getrennt: Setup erhält nur Setup-Updates, Portable
Jede Instanz hat:
- Namen und optionale Gruppe
- Namen und optionale Gruppe – Untergruppen mit „/“ trennen, z. B. `Modpacks/Survival` (eingerückt, einzeln einklappbar, per Drag & Drop befüllbar)
- Minecraft-Version und optionalen Mod-Loader
- optionales Icon (vorgegeben, aus Minecraft extrahiert oder eigenes Bild) und Notizen
- eigene Mods, Ressourcenpakete, Shader, Welten, Datenpakete, Screenshots, Aufzeichnungen und Serverliste
@@ -197,6 +199,8 @@ Im Spiel (solange ein über AeroMC gestartetes Minecraft läuft):
Existiert bereits eine Instanz mit demselben Namen, wird sie ersetzt.
**Große Importe:** Auch viele Gigabyte werden im Hintergrund kopiert – der Launcher bleibt bedienbar. Vorher prüft AeroMC, ob genug Speicherplatz frei ist. Oben im Import-Fenster stehen Instanz, übertragene GB, Prozent, Tempo, Restzeit und die aktuelle Datei; **Abbrechen** behält fertig übernommene Instanzen und entfernt die halb kopierte. Einzelne gesperrte Dateien werden übersprungen und gemeldet.
Beim offiziellen Minecraft Launcher werden nur benutzerdefinierte Profile angeboten (keine „Latest Release“/„Latest Snapshot“). Bei gemeinsamen Standard-Spielordnern übernimmt AeroMC spielrelevante Daten (Mods, Welten, Konfiguration), nicht den kompletten Cache.
**Modrinth App:** Übernommen werden Name, Gruppe, Symbol, Minecraft-Version, Loader, Arbeitsspeicher, Java-Einstellungen, Spielzeit sowie Mods, Welten und Einstellungen. Modrinth-Modpacks bleiben mit ihrem Paket verknüpft und lassen sich weiter aktualisieren. Ein in der App verlegter Datenordner wird automatisch gefunden, und die Modrinth App darf dabei geöffnet bleiben – AeroMC liest auch ihr Schreibprotokoll mit. An den Daten der Modrinth App wird nichts verändert.
@@ -337,6 +341,10 @@ Speicherort: `Dokumente\AeroMC Launcher\Backups`.
### Darstellung
- Design: Klassisch Dunkel, Klassisch Hell, AeroMC, Nachtlila oder Minecraft – mit Vorschau-Kacheln, gilt nach dem Speichern in allen Fenstern
- Block-Symbole räumlich (3D)
- Animationen:
- **Startanimation**: Beim Öffnen dreht sich das AeroMC-Logo, danach baut sich das Fenster von der Mitte aus auf (nicht beim Start im Infobereich)
- **Bewegte Oberfläche**: Die Skin-Figur im Hintergrund atmet und schaut dem Mauszeiger nach, Fenster wachsen aus dem angeklickten Knopf, Instanzen blenden beim Öffnen ein und heben sich beim Darüberfahren, Designs blenden weich über
- Beides einzeln abschaltbar; ist in Windows „Animationen anzeigen“ aus, bleibt die Oberfläche ruhig
- Skin-Hintergrund: Quelle, Pose, Armform (automatisch vom Konto, normal oder schmal), Deckkraft, optional automatischer Wechsel zwischen mehreren Konten
### Spiel
@@ -5,7 +5,7 @@
},
"projects": {
"C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj": {
"version": "1.1.8",
"version": "1.2.1",
"restore": {
"projectUniqueName": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj",
"projectName": "AeroMC-Setup",
Binary file not shown.
@@ -14,11 +14,11 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyConfigurationAttribute("Release")]
[assembly: System.Reflection.AssemblyCopyrightAttribute("Copyright (c) M_Viper. Alle Rechte vorbehalten.")]
[assembly: System.Reflection.AssemblyDescriptionAttribute("Installiert AeroMC")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.1.8.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.1.8")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.2.1.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.2.1")]
[assembly: System.Reflection.AssemblyProductAttribute("AeroMC Setup")]
[assembly: System.Reflection.AssemblyTitleAttribute("AeroMC Setup")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.1.8.0")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.2.1.0")]
[assembly: System.Resources.NeutralResourcesLanguageAttribute("de-DE")]
// Von der MSBuild WriteCodeFragment-Klasse generiert.
@@ -1 +1 @@
820d60fa6c1c1080954d3f18089a7c45daf7f39440b819f670e61aded52999a5
a17a2bc3b05635d3c29adc5c6884bde0a77826489d54b55a9b7744bfaa1ef206
+1 -1
View File
@@ -418,7 +418,7 @@
"C:\\Users\\Viper\\.nuget\\packages\\": {}
},
"project": {
"version": "1.1.8",
"version": "1.2.1",
"restore": {
"projectUniqueName": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj",
"projectName": "AeroMC-Setup",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"version": 2,
"dgSpecHash": "UguGrVny76E=",
"dgSpecHash": "zBMXx+S6DFU=",
"success": true,
"projectFilePath": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj",
"expectedPackageFiles": [
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aeromc",
"version": "1.1.8",
"version": "1.2.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aeromc",
"version": "1.1.8",
"version": "1.2.1",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"@xhayper/discord-rpc": "^1.3.4",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "aeromc",
"productName": "AeroMC",
"version": "1.1.8",
"version": "1.2.1",
"description": "AeroMC – Instanz-basierter Minecraft-Launcher (à la MultiMC/Prism)",
"homepage": "https://m-viper.de",
"launcherWebsite": "https://aeromc.viper.ipv64.net",
+51
View File
@@ -0,0 +1,51 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'" />
<title>AeroMC</title>
<style>
html, body { margin: 0; height: 100%; overflow: hidden; background: transparent; }
#bild {
position: absolute; display: none; transform-origin: 0 0; will-change: transform, opacity;
border-radius: 6px; box-shadow: 0 18px 48px rgba(0, 0, 0, .45);
}
</style>
</head>
<body>
<img id="bild" alt="" />
<script>
// Hilfsfenster für "Fenster wachsen aus dem Knopf" (siehe fensteraufbau.js) – wird nach einmaligem Gebrauch geschlossen
const bild = document.getElementById('bild');
let start = 1;
// Bild an die Zielstelle legen und auf Knopfgröße zusammenschrumpfen (noch unsichtbar)
window.vorbereiten = (adresse, nach, von) => new Promise((fertig) => {
bild.onerror = () => fertig(false);
bild.onload = () => {
Object.assign(bild.style, { left: nach.x + 'px', top: nach.y + 'px', width: nach.width + 'px', height: nach.height + 'px' });
// wächst aus der Mitte des angeklickten Knopfs heraus (darf außerhalb liegen)
bild.style.transformOrigin = `${von.x + von.width / 2 - nach.x}px ${von.y + von.height / 2 - nach.y}px`;
start = Math.max(0.06, Math.min(0.5, Math.max(von.width / nach.width, von.height / nach.height)));
bild.style.transform = `scale(${start})`;
bild.style.opacity = '0';
bild.style.display = 'block';
// zwei Bildtakte abwarten, damit der Anfangszustand gezeichnet ist (höchstens 120 ms)
let erledigt = false;
const fertigEinmal = () => { if (!erledigt) { erledigt = true; fertig(true); } };
requestAnimationFrame(() => requestAnimationFrame(fertigEinmal));
setTimeout(fertigEinmal, 120);
};
bild.src = adresse;
});
window.abspielen = (ms) => bild.animate([
{ transform: `scale(${start})`, opacity: 0 },
{ opacity: 1, offset: 0.3 },
{ transform: 'scale(1)', opacity: 1 },
], { duration: ms, easing: 'cubic-bezier(.2, .8, .2, 1)', fill: 'forwards' }).finished.then(() => true, () => false);
</script>
</body>
</html>
+16 -3
View File
@@ -154,14 +154,27 @@ function scan(inputPath) {
return { ok: true, instancesDir, count: list.length, instances: list };
}
function kopierNamen(sourceDir) {
return fs.readdirSync(sourceDir).filter((name) => !META_FILES.includes(name));
}
function copyInto(sourceDir, targetDir) {
fs.mkdirSync(targetDir, { recursive: true });
for (const name of fs.readdirSync(sourceDir)) {
if (META_FILES.includes(name)) continue;
for (const name of kopierNamen(sourceDir)) {
fs.cpSync(path.join(sourceDir, name), path.join(targetDir, name), { recursive: true });
}
}
/*
* Was beim Übernehmen kopiert wird: [{ von, name }] – `name` ist der Zielname
* im Spielordner der neuen Instanz. Das Kopieren selbst erledigt kopieren.js
* asynchron mit Fortschritt (main.js → fuehreImportAus).
*/
function kopierQuellen(entry) {
if (!entry.gameDir || !fs.existsSync(entry.gameDir)) return [];
return kopierNamen(entry.gameDir).map((name) => ({ von: path.join(entry.gameDir, name), name }));
}
function importOne(store, entry, copyData = true) {
const created = store.createOrReplaceImportedInstance({
name: entry.name,
@@ -193,4 +206,4 @@ function importOne(store, entry, copyData = true) {
return { id: created.id, name: created.name, copied };
}
module.exports = { scan, importOne, resolveInstancesDir };
module.exports = { scan, importOne, kopierQuellen, resolveInstancesDir };
+227 -218
View File
@@ -1,218 +1,227 @@
'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 };
'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' });
}
// --- Alte Forge-Version verträgt aktuelles Java 8 nicht ---
// Forge 1.16.5 vor 36.2.26 (und ähnlich alte Fassungen) nutzt eine interne
// Java-Klasse, die es seit Java 8u321 so nicht mehr gibt.
if (/sun\.security\.util\.ManifestEntryVerifier\.<init>/i.test(t) && /forge/i.test(String(ctx.loader || ''))) {
fund('forge-alt', 'Diese Forge-Version ist zu alt für das aktuelle Java',
'Ältere Forge-Versionen laufen mit aktuellem Java 8 nicht mehr (Fehler „ManifestEntryVerifier“). Eine neuere Forge-Version für dieselbe Minecraft-Version behebt das – Mods und Welten bleiben erhalten.',
{ typ: 'forge-aktualisieren' });
}
// --- 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 };
+41 -4
View File
@@ -9,8 +9,8 @@
/* ===================== Design-Auswahl in den Optionen ===================== */
.design-raster{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}
.settings-card-wide .design-raster{grid-template-columns:repeat(5,minmax(0,1fr))}
@media (max-width:900px){.settings-card-wide .design-raster{grid-template-columns:repeat(2,minmax(0,1fr))}}
.settings-card .design-raster{grid-template-columns:repeat(5,minmax(0,1fr))}
@media (max-width:900px){.settings-card .design-raster{grid-template-columns:repeat(2,minmax(0,1fr))}}
.design-wahl{display:flex;flex-direction:column;gap:5px;padding:6px;border:1px solid var(--border);border-radius:8px;background:none;color:var(--text);font-family:inherit;font-size:12px;cursor:pointer;text-align:left}
.design-wahl:hover{border-color:var(--sel-br)}
.design-wahl.on{border-color:var(--sel-br);box-shadow:0 0 0 1px var(--sel-br);background:var(--sel)}
@@ -94,6 +94,10 @@
:root:is([data-design="aero"],[data-design="lila"]) .card.sel{background:linear-gradient(180deg,rgba(var(--p-akzent),.24),rgba(var(--p-tief),.14));border-color:rgba(var(--p-akzent2),.55);box-shadow:0 10px 28px -14px rgba(var(--p-akzent),.95),0 0 0 1px rgba(var(--p-hell),.08) inset}
:root:is([data-design="aero"],[data-design="lila"]) .card.sel .card-name{background:linear-gradient(120deg,rgb(var(--p-akzent2)),rgb(var(--p-akzent)) 60%,rgb(var(--p-tief)));color:#fff;border-radius:999px}
:root:is([data-design="aero"],[data-design="lila"]) .card-icon-img{border-radius:10px}
:root:is([data-design="aero"],[data-design="lila"]) .card.laeuft::after{border-color:rgb(var(--p-akzent2));--accent:rgb(var(--p-akzent2))}
:root.ui-animiert:is([data-design="aero"],[data-design="lila"]) .lichtpunkte{display:block}
:root:is([data-design="aero"],[data-design="lila"]) .lichtpunkte i{background:rgb(var(--p-hell));box-shadow:0 0 calc(var(--g) * 3) 1px rgba(var(--p-akzent2),.85)}
:root:is([data-design="aero"],[data-design="lila"]) .erfolg .funke{box-shadow:0 0 5px 2px rgb(var(--p-hell)),0 0 14px 4px rgba(var(--p-akzent2),.8)}
:root:is([data-design="aero"],[data-design="lila"]) .card-taste{border-radius:999px;background:linear-gradient(120deg,rgb(var(--p-akzent2)),rgb(var(--p-akzent)))}
:root:is([data-design="aero"],[data-design="lila"]) .empty h2{background:linear-gradient(120deg,rgb(var(--p-hell)),rgb(var(--p-akzent)) 60%,rgb(var(--p-tief)));-webkit-background-clip:text;background-clip:text;color:transparent;font-size:26px}
@@ -132,7 +136,7 @@
:root:is([data-design="aero"],[data-design="lila"]) .mods-filters select,
:root:is([data-design="aero"],[data-design="lila"]) .mmc-bar select,
:root:is([data-design="aero"],[data-design="lila"]) .clip-length select,
:root:is([data-design="aero"],[data-design="lila"]) .path-row input,
:root:is([data-design="aero"],[data-design="lila"]) .path-row input,:root:is([data-design="aero"],[data-design="lila"]) .path-row select,:root:is([data-design="aero"],[data-design="lila"]) select.s-wert,:root:is([data-design="aero"],[data-design="lila"]) input.s-wert,:root:is([data-design="aero"],[data-design="lila"]) .s-paar input,:root:is([data-design="aero"],[data-design="lila"]) .s-einheit input,
:root:is([data-design="aero"],[data-design="lila"]) .se-farbzeile input[type=text],
:root:is([data-design="aero"],[data-design="lila"]) .se-feld select{background:var(--input);color:var(--text);border-color:var(--border);border-radius:10px}
:root:is([data-design="aero"],[data-design="lila"]) .search-wrap input{border-radius:999px}
@@ -145,6 +149,9 @@
:root:is([data-design="aero"],[data-design="lila"]) input[type=checkbox],:root:is([data-design="aero"],[data-design="lila"]) input[type=radio],:root:is([data-design="aero"],[data-design="lila"]) input[type=range]{accent-color:rgb(var(--p-akzent))}
:root:is([data-design="aero"],[data-design="lila"]) .login-usercode{background:var(--input);color:var(--text);border-color:var(--border);border-radius:12px}
:root:is([data-design="aero"],[data-design="lila"]) .upd-notes{background:var(--input);border-radius:10px}
:root:is([data-design="aero"],[data-design="lila"]) .upd-notes h5{color:rgb(var(--p-hell))}
:root:is([data-design="aero"],[data-design="lila"]) .upd-notes li::marker{color:rgb(var(--p-akzent2))}
:root:is([data-design="aero"],[data-design="lila"]) .upd-notes a{color:rgb(var(--p-hell))}
/* Knöpfe – rund wie auf der Webseite */
:root:is([data-design="aero"],[data-design="lila"]) .btn{border-radius:999px;padding:7px 15px;background:var(--surface);border-color:var(--border);color:var(--text);transition:background .15s,border-color .15s,transform .15s}
@@ -190,6 +197,7 @@
:root:is([data-design="aero"],[data-design="lila"]) .snav:hover{background:var(--surface)}
:root:is([data-design="aero"],[data-design="lila"]) .snav.on{background:linear-gradient(120deg,rgba(var(--p-akzent),.30),rgba(var(--p-tief),.18));border-color:rgba(var(--p-akzent2),.40);box-shadow:none}
:root:is([data-design="aero"],[data-design="lila"]) .settings-card{background:var(--surface);border-color:var(--border);border-radius:16px}
:root:is([data-design="aero"],[data-design="lila"]) .schalter input:checked+i{background:linear-gradient(120deg,rgb(var(--p-akzent)),rgb(var(--p-akzent2)));box-shadow:0 0 10px -2px rgba(var(--p-akzent),.8)}
:root:is([data-design="aero"],[data-design="lila"]) .settings-kv-row,
:root:is([data-design="aero"],[data-design="lila"]) .about-brand{border-bottom-color:var(--border2)}
:root:is([data-design="aero"],[data-design="lila"]) .appinfo-item{background:rgba(var(--p-linie),.04);border-color:var(--border2);border-radius:12px}
@@ -197,6 +205,12 @@
:root:is([data-design="aero"],[data-design="lila"]) .settings-hero h4{background:linear-gradient(120deg,rgb(var(--p-hell)),rgb(var(--p-akzent)) 70%);-webkit-background-clip:text;background-clip:text;color:transparent}
/* Kontextmenü, Hinweis, Bildlaufleisten */
/* Erfolg-Meldung: Glas-Karte mit Leuchtrand wie die Webseite */
:root:is([data-design="aero"],[data-design="lila"]) .erfolg{background:linear-gradient(135deg,rgba(var(--p-akzent),.22),var(--surface) 55%);border:1px solid rgba(var(--p-akzent),.45);border-radius:14px;
box-shadow:0 0 0 1px rgba(255,255,255,.04) inset,0 18px 40px -12px rgba(0,0,0,.8),0 0 24px -8px rgba(var(--p-akzent),.7)}
:root:is([data-design="aero"],[data-design="lila"]) .erfolg-symbol{border-radius:10px;background:rgba(var(--p-akzent),.18)}
:root:is([data-design="aero"],[data-design="lila"]) .erfolg-kopf{color:rgb(var(--p-hell))}
:root:is([data-design="aero"],[data-design="lila"]) .erfolg-karte{border-radius:12px;background:var(--surface)}
:root:is([data-design="aero"],[data-design="lila"]) .ctx{background:var(--surface);border-color:var(--border);border-radius:12px;box-shadow:0 18px 40px -12px rgba(0,0,0,.8)}
:root:is([data-design="aero"],[data-design="lila"]) .ctx-item{border-radius:8px}
:root:is([data-design="aero"],[data-design="lila"]) .ctx-item:hover{background:rgba(var(--p-akzent),.22)}
@@ -267,6 +281,10 @@
:root[data-design="minecraft"] .toolbar{
background:url(assets/design/gras.png) 0 0/32px 8px repeat-x,url(assets/design/schiefer.png) 0 0/64px 64px;
image-rendering:pixelated;border-bottom:2px solid #000;padding:11px 8px 6px}
:root[data-design="minecraft"] .toolbar{position:relative;isolation:isolate}
:root.ui-animiert[data-design="minecraft"] .toolbar::before{content:'';position:absolute;inset:0;z-index:-1;pointer-events:none;opacity:.18;
background:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='512' height='48' shape-rendering='crispEdges' fill='white'><rect x='20' y='8' width='56' height='8'/><rect x='8' y='16' width='96' height='8'/><rect x='170' y='22' width='40' height='6'/><rect x='158' y='28' width='80' height='8'/><rect x='184' y='36' width='36' height='4'/><rect x='300' y='4' width='72' height='8'/><rect x='288' y='12' width='120' height='8'/><rect x='312' y='20' width='64' height='6'/><rect x='438' y='26' width='48' height='8'/><rect x='426' y='34' width='70' height='6'/></svg>") 0 0/512px 100% repeat-x;animation:wolken-ziehen 70s steps(512) infinite}
@keyframes wolken-ziehen{to{background-position:512px 0}}
:root[data-design="minecraft"] .tbtn{color:#fff;border-radius:0;text-shadow:1px 1px 0 #1a1a1a;image-rendering:auto}
:root[data-design="minecraft"] .tbtn:hover{background:rgba(0,0,0,.35);border-color:#fff}
:root[data-design="minecraft"] .tbtn:active{background:rgba(0,0,0,.5)}
@@ -288,7 +306,7 @@
:root[data-design="minecraft"] .mods-filters select,
:root[data-design="minecraft"] .mmc-bar select,
:root[data-design="minecraft"] .clip-length select,
:root[data-design="minecraft"] .path-row input,
:root[data-design="minecraft"] .path-row input,:root[data-design="minecraft"] .path-row select,:root[data-design="minecraft"] select.s-wert,:root[data-design="minecraft"] input.s-wert,:root[data-design="minecraft"] .s-paar input,:root[data-design="minecraft"] .s-einheit input,
:root[data-design="minecraft"] .login-usercode,
:root[data-design="minecraft"] .upd-notes,
:root[data-design="minecraft"] .se-farbzeile input[type=text],
@@ -308,6 +326,8 @@
:root[data-design="minecraft"] .list{image-rendering:auto}
:root[data-design="minecraft"] .group-header{font-family:var(--pixel);font-size:16px;font-weight:400;color:#fff;text-shadow:var(--mc-schatten);padding:8px 4px 4px}
:root[data-design="minecraft"] .group-count{color:#b8b9bc}
:root[data-design="minecraft"] .group-sub > .group-header{font-size:14px}
:root[data-design="minecraft"] .group-sub{border-left:2px solid rgba(0,0,0,.45)}
:root[data-design="minecraft"] .group-header .chev{color:#fff;font-family:"Segoe UI",sans-serif;font-size:10px}
:root[data-design="minecraft"] .group-header::after{height:2px;background:rgba(0,0,0,.55);box-shadow:0 1px 0 rgba(255,255,255,.08)}
:root[data-design="minecraft"] .card{border-radius:0}
@@ -317,6 +337,11 @@
:root[data-design="minecraft"] .card.sel .card-name{background:none;color:#ffff7f;border-radius:0}
:root[data-design="minecraft"] .card-taste{border-radius:0;background:#3c8527;box-shadow:inset -1px -1px 0 #1d4d13}
:root[data-design="minecraft"] .card-icon-img{border-radius:0}
:root[data-design="minecraft"] .card.laeuft::after{border-color:#7fd858;--accent:#7fd858;border-radius:0}
/* Abbau-Risse: Download-Fortschritt auf dem Block, beim Fertigwerden springt er kurz */
:root[data-design="minecraft"] .card.abbau .card-riss{display:block}
:root.ui-animiert[data-design="minecraft"] .card.abgebaut .card-icon,:root.ui-animiert[data-design="minecraft"] .card.abgebaut .card-icon-img{animation:block-bricht .45s cubic-bezier(.3,.7,.4,1)}
@keyframes block-bricht{0%{transform:scale(.8)}45%{transform:scale(1.14)}100%{transform:none}}
:root[data-design="minecraft"] .empty h2{font-family:var(--pixel);font-weight:400;font-size:24px;color:#fff;text-shadow:3px 3px 0 #3f3f3f}
:root[data-design="minecraft"] .empty p{color:#d0d0d0}
@@ -330,6 +355,8 @@
:root[data-design="minecraft"] .hr{background:#000;height:2px;margin:6px 0}
:root[data-design="minecraft"] .sbtn{margin-bottom:3px;padding:5px 2px}
:root[data-design="minecraft"] .sbtn.play{font-family:var(--pixel);font-size:16px;font-weight:400;padding:6px 4px 7px;text-shadow:var(--mc-schatten)}
:root[data-design="minecraft"] .sbtn.play::before,:root:is([data-design="aero"],[data-design="lila"]) .sbtn.play::before{background:#fff;opacity:.28}
:root[data-design="minecraft"] .sbtn.play.laedt .play-text{font-size:13px}
/* Statusleiste */
:root[data-design="minecraft"] .statusbar{background:url(assets/design/schiefer.png) 0 0/64px 64px;image-rendering:pixelated;border-top:2px solid #000}
@@ -389,6 +416,10 @@
:root[data-design="minecraft"] .settings-hero h4{font-family:var(--pixel);font-size:24px;font-weight:400;text-shadow:3px 3px 0 #3f3f3f}
:root[data-design="minecraft"] .settings-hero p{color:#d0d0d0}
:root[data-design="minecraft"] .settings-card{background:#313233;border:2px solid #000;border-radius:0;box-shadow:inset 2px 2px 0 #5a5b5d,inset -2px -2px 0 #1e1e1f}
:root[data-design="minecraft"] .schalter i{border-radius:0;background:#1e1e1f;box-shadow:inset 2px 2px 0 #000}
:root[data-design="minecraft"] .schalter i::after{border-radius:0;background:#c6c6c6;box-shadow:inset -2px -2px 0 #555,inset 2px 2px 0 #fff}
:root[data-design="minecraft"] .schalter input:checked+i{background:#3c8527}
:root[data-design="minecraft"] .s-zeile{border-top-color:rgba(0,0,0,.55)}
:root[data-design="minecraft"] .settings-card h5{font-family:var(--pixel);font-size:16px;font-weight:400;text-shadow:var(--mc-schatten)}
:root[data-design="minecraft"] .settings-kv-row,
:root[data-design="minecraft"] .about-brand{border-bottom-color:#1a1a1b}
@@ -397,6 +428,12 @@
:root[data-design="minecraft"] .spane h4{font-family:var(--pixel);font-size:16px;font-weight:400;text-shadow:var(--mc-schatten)}
/* Kontextmenü, Hinweis, Bildlaufleisten */
/* Erfolg-Meldung wie im Spiel („Erfolg erzielt!“) */
:root[data-design="minecraft"] .erfolg{background:#212121;border:2px solid #555;border-radius:0;box-shadow:inset 2px 2px 0 #373737,inset -2px -2px 0 #141414,0 10px 24px rgba(0,0,0,.45);color:#fff;font-family:"AeroPixel","Segoe UI",sans-serif}
:root[data-design="minecraft"] .erfolg-symbol{border-radius:0;background:#8b8b8b;box-shadow:inset 2px 2px 0 #373737,inset -2px -2px 0 #fff}
:root[data-design="minecraft"] .erfolg-kopf{color:#ffff55;font-size:14px;font-weight:400;letter-spacing:0;text-transform:none}
:root[data-design="minecraft"] .erfolg-name{font-size:14px;font-weight:400}
:root[data-design="minecraft"] .erfolg-karte{border-radius:0;border:2px solid #000;background:rgba(0,0,0,.35)}
:root[data-design="minecraft"] .ctx{background:#1e1e1f;border:2px solid #000;border-radius:0;box-shadow:inset 1px 1px 0 #48494a,0 8px 24px rgba(0,0,0,.5)}
:root[data-design="minecraft"] .ctx-item{border-radius:0;text-shadow:1px 1px 0 #000}
:root[data-design="minecraft"] .ctx-item:hover{background:#3c8527}
+73
View File
@@ -0,0 +1,73 @@
/*
* Erfolge im Minecraft-Stil („Erfolg erzielt!“).
*
* Bewertet wird aus dem, was der Launcher ohnehin weiß (Instanzen, Spielzeit,
* Starts, Mods), plus ein paar Merkern für einzelne Aktionen (Umhang
* hochgeladen, Skin im Editor übernommen, Leistungs-Paket …). Freigeschaltete
* Erfolge landen mit Datum in userData/erfolge.json.
*/
const fs = require('fs');
const path = require('path');
const STUNDE = 3600;
const LISTE = [
{ id: 'erster-start', symbol: 'grass', titel: 'Los geht’s!', text: 'Starte zum ersten Mal ein Spiel mit AeroMC.', pruefe: (w) => w.starts >= 1 },
{ id: 'starts-10', symbol: 'pickaxe', titel: 'Stammgast', text: 'Starte 10-mal ein Spiel.', pruefe: (w) => w.starts >= 10 },
{ id: 'starts-100', symbol: 'gold', titel: 'Ohne mich geht nichts', text: 'Starte 100-mal ein Spiel.', pruefe: (w) => w.starts >= 100 },
{ id: 'zeit-1', symbol: 'map', titel: 'Erste Stunde', text: 'Spiele insgesamt eine Stunde.', pruefe: (w) => w.sekunden >= STUNDE },
{ id: 'zeit-10', symbol: 'sword', titel: 'Zeit vergeht wie im Flug', text: 'Spiele insgesamt 10 Stunden.', pruefe: (w) => w.sekunden >= 10 * STUNDE },
{ id: 'zeit-50', symbol: 'diamond', titel: 'Abenteurer', text: 'Spiele insgesamt 50 Stunden.', pruefe: (w) => w.sekunden >= 50 * STUNDE },
{ id: 'zeit-100', symbol: 'star', titel: 'Lebt in Minecraft', text: 'Spiele insgesamt 100 Stunden.', pruefe: (w) => w.sekunden >= 100 * STUNDE },
{ id: 'instanzen-5', symbol: 'chest', titel: 'Sammler', text: 'Lege 5 Instanzen an.', pruefe: (w) => w.instanzen >= 5 },
{ id: 'instanzen-10', symbol: 'book', titel: 'Archivar', text: 'Lege 10 Instanzen an.', pruefe: (w) => w.instanzen >= 10 },
{ id: 'versionen-3', symbol: 'end', titel: 'Zeitreisender', text: 'Spiele 3 verschiedene Minecraft-Versionen.', pruefe: (w) => w.versionen >= 3 },
{ id: 'erste-mod', symbol: 'redstone', titel: 'Bastler', text: 'Habe die erste Mod in einer Instanz.', pruefe: (w) => w.mods >= 1 },
{ id: 'mods-50', symbol: 'potion', titel: 'Mod-Meister', text: 'Habe 50 Mods in deinen Instanzen.', pruefe: (w) => w.mods >= 50 },
{ id: 'modpack', symbol: 'nether', titel: 'Das volle Paket', text: 'Installiere ein Modpack.', pruefe: (w) => w.modpacks >= 1 },
{ id: 'freund', symbol: 'creeper', titel: 'Gemeinsam statt einsam', text: 'Füge deinen ersten Freund hinzu.', pruefe: (w) => w.freunde >= 1 },
{ id: 'umhang', symbol: 'star', titel: 'Modeschöpfer', text: 'Lade einen eigenen AeroMC-Umhang hoch.', pruefe: (w) => w.merker.umhang },
{ id: 'skin-editor', symbol: 'map', titel: 'Pixelkünstler', text: 'Übernimm einen im Skin-Editor gemalten Skin.', pruefe: (w) => w.merker['skin-editor'] },
{ id: 'leistung', symbol: 'tnt', titel: 'Turbo', text: 'Installiere das Leistungs-Paket.', pruefe: (w) => w.merker.leistung },
];
let datei = null;
function init(userDataDir) { datei = path.join(userDataDir, 'erfolge.json'); }
function lies() {
try { return JSON.parse(fs.readFileSync(datei, 'utf8')); } catch { return null; }
}
function schreibe(stand) {
try { fs.writeFileSync(datei, JSON.stringify(stand, null, 1)); } catch { /* nicht schlimm */ }
}
/**
* werte: { starts, sekunden, instanzen, versionen, mods, modpacks, freunde }
* merker: zusätzliche Einzel-Aktionen ({ umhang: true } …)
* -> { neu: [Erfolge], ersteAuswertung }
*/
function pruefe(werte, merker = {}) {
const vorher = lies();
const stand = vorher || { freigeschaltet: {}, merker: {} };
Object.assign(stand.merker, merker);
const w = Object.assign({ starts: 0, sekunden: 0, instanzen: 0, versionen: 0, mods: 0, modpacks: 0, freunde: 0 }, werte, { merker: stand.merker });
const neu = [];
const jetzt = new Date().toISOString();
for (const e of LISTE) {
if (stand.freigeschaltet[e.id]) continue;
let erreicht = false;
try { erreicht = !!e.pruefe(w); } catch { /* Wert fehlt */ }
if (erreicht) { stand.freigeschaltet[e.id] = jetzt; neu.push(oeffentlich(e, jetzt)); }
}
if (neu.length || !vorher || Object.keys(merker).length) schreibe(stand);
return { neu, ersteAuswertung: !vorher };
}
const oeffentlich = (e, am) => ({ id: e.id, symbol: e.symbol, titel: e.titel, text: e.text, am: am || null });
/** Alle Erfolge mit Datum (null = noch offen). */
function liste() {
const stand = lies() || { freigeschaltet: {} };
return LISTE.map((e) => oeffentlich(e, stand.freigeschaltet[e.id] || null));
}
module.exports = { init, pruefe, liste };
+85
View File
@@ -0,0 +1,85 @@
/*
* "Fenster wachsen aus dem Knopf": Ein Popup-Fenster (Optionen, Mods, Skin,
* Freunde …) erscheint nicht schlagartig, sondern wächst aus dem angeklickten
* Knopf heraus. Dafür wird das noch unsichtbare Fenster abfotografiert und das
* Bild in einem durchsichtigen Hilfsfenster vom Knopf aus groß gezogen; am Ende
* übernimmt das echte Fenster an exakt derselben Stelle.
*
* Das Hilfsfenster entsteht jedes Mal neu und gleich in der richtigen Größe:
* Unter Windows färbt sich ein durchsichtiges Fenster schwarz, sobald es
* nachträglich vergrößert wird, und ein verstecktes Fenster zeichnet nicht mehr.
* Abschaltbar unter Optionen → Darstellung → Animationen („Bewegte Oberfläche“).
*/
const path = require('path');
const { BrowserWindow } = require('electron');
const DAUER_MS = 280;
const HOECHSTENS_MS = 2000; // spätestens dann ist das Fenster in jedem Fall zu sehen
const warte = (ms) => new Promise((r) => setTimeout(r, ms));
function vereinigung(a, b) {
const x = Math.min(a.x, b.x);
const y = Math.min(a.y, b.y);
return {
x, y,
width: Math.max(a.x + a.width, b.x + b.width) - x,
height: Math.max(a.y + a.height, b.y + b.height) - y,
};
}
// Rechteck in Koordinaten des Hilfsfensters umrechnen
const relativ = (r, box) => ({ x: r.x - box.x, y: r.y - box.y, width: r.width, height: r.height });
async function abspielen(ziel, von, merke) {
// unsichtbar zeigen, damit Chromium den Inhalt zeichnet, dann abfotografieren
ziel.setOpacity(0);
ziel.setIgnoreMouseEvents(true);
ziel.show();
await warte(90);
if (ziel.isDestroyed()) return;
const bild = await ziel.webContents.capturePage();
if (bild.isEmpty() || ziel.isDestroyed()) return;
const nach = ziel.getBounds();
const box = vereinigung(von, nach);
const hilfe = new BrowserWindow({
x: box.x, y: box.y, width: box.width, height: box.height,
show: false, frame: false, transparent: true, resizable: false, movable: false,
minimizable: false, maximizable: false, fullscreenable: false, focusable: false,
skipTaskbar: true, hasShadow: false, alwaysOnTop: true, backgroundColor: '#00000000',
title: 'AeroMC',
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
});
merke(hilfe);
hilfe.setIgnoreMouseEvents(true);
await hilfe.loadFile(path.join(__dirname, 'aufbau.html'));
const ok = await hilfe.webContents.executeJavaScript(
`vorbereiten(${JSON.stringify(bild.toDataURL())}, ${JSON.stringify(relativ(nach, box))}, ${JSON.stringify(relativ(von, box))})`);
if (!ok || hilfe.isDestroyed()) return;
hilfe.showInactive();
await hilfe.webContents.executeJavaScript(`abspielen(${DAUER_MS})`);
}
/**
* Zeigt `ziel` (geladen, aber noch nicht sichtbar) mit Wachs-Animation an.
* `von` ist das Rechteck des angeklickten Knopfs in Bildschirmkoordinaten.
*/
async function wachsen(ziel, von) {
let hilfe = null;
try {
await Promise.race([abspielen(ziel, von, (h) => { hilfe = h; }), warte(HOECHSTENS_MS)]);
} catch {
// Animation ist nur Zierde – im Fehlerfall das Fenster einfach zeigen
} finally {
if (!ziel.isDestroyed()) {
ziel.setOpacity(1);
ziel.setIgnoreMouseEvents(false);
if (!ziel.isVisible()) ziel.show();
ziel.focus();
}
// Hilfsfenster erst nach dem Umschalten schließen, sonst blitzt der Hintergrund durch
setTimeout(() => { if (hilfe && !hilfe.isDestroyed()) hilfe.destroy(); }, 60);
}
}
module.exports = { wachsen };
+106
View File
@@ -0,0 +1,106 @@
/*
* Garderobe: gespeicherte Skins und Umhänge zum schnellen Wechseln.
*
* Mojang merkt sich keine früheren Skins – wer einen neuen hochlädt, verliert
* den alten. Die Garderobe hält Skins (samt Armform) und eigene AeroMC-Umhänge
* als PNG-Dateien im Launcher-Datenordner fest; vor jedem Skinwechsel im
* Launcher wird der bisherige Skin automatisch gesichert. Gleiche Dateien
* (gleiche Prüfsumme) werden nicht doppelt abgelegt.
*/
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const MAX_AUTOMATISCH = 20; // ältere automatische Sicherungen fliegen raus
let ordner = null;
function init(userDataDir) {
ordner = path.join(userDataDir, 'garderobe');
}
const indexDatei = () => path.join(ordner, 'garderobe.json');
function lies() {
try { return JSON.parse(fs.readFileSync(indexDatei(), 'utf8')); } catch { return []; }
}
function schreibe(liste) {
fs.mkdirSync(ordner, { recursive: true });
fs.writeFileSync(indexDatei(), JSON.stringify(liste, null, 1));
}
/** Alle Einträge, neueste zuerst, jeweils mit Bild als data:-Adresse. */
function liste() {
return lies()
.filter((e) => fs.existsSync(path.join(ordner, e.datei)))
.sort((a, b) => String(b.erstellt).localeCompare(String(a.erstellt)))
.map((e) => Object.assign({}, e, {
dataUri: 'data:image/png;base64,' + fs.readFileSync(path.join(ordner, e.datei)).toString('base64'),
}));
}
/**
* Legt einen Skin/Umhang ab. art: 'skin' | 'umhang', daten: PNG-Buffer.
* Gibt es dieselbe Datei schon, wird nur ggf. aus "automatisch" ein fester Eintrag.
*/
function hinzufuegen({ art, daten, name, variante, automatisch }) {
const hash = crypto.createHash('sha1').update(daten).digest('hex');
const eintraege = lies();
const vorhanden = eintraege.find((e) => e.art === art && e.hash === hash);
if (vorhanden) {
if (!automatisch && vorhanden.automatisch) {
vorhanden.automatisch = false;
if (name) vorhanden.name = name;
schreibe(eintraege);
}
return { eintrag: vorhanden, neu: false };
}
fs.mkdirSync(ordner, { recursive: true });
const id = Date.now().toString(36) + crypto.randomBytes(3).toString('hex');
const datei = `${art}-${id}.png`;
fs.writeFileSync(path.join(ordner, datei), daten);
const eintrag = {
id, art, hash, datei,
name: String(name || '').trim().slice(0, 40) || (art === 'skin' ? 'Skin' : 'Umhang'),
variante: art === 'skin' ? (variante === 'slim' ? 'slim' : 'classic') : undefined,
automatisch: !!automatisch,
erstellt: new Date().toISOString(),
};
eintraege.push(eintrag);
// automatische Sicherungen begrenzen (feste Einträge bleiben immer)
const auto = eintraege.filter((e) => e.automatisch).sort((a, b) => String(b.erstellt).localeCompare(String(a.erstellt)));
for (const alt of auto.slice(MAX_AUTOMATISCH)) loescheIntern(eintraege, alt.id);
schreibe(eintraege);
return { eintrag, neu: true };
}
function loescheIntern(eintraege, id) {
const i = eintraege.findIndex((e) => e.id === id);
if (i < 0) return false;
try { fs.unlinkSync(path.join(ordner, eintraege[i].datei)); } catch { /* schon weg */ }
eintraege.splice(i, 1);
return true;
}
function loeschen(id) {
const eintraege = lies();
const ok = loescheIntern(eintraege, id);
if (ok) schreibe(eintraege);
return ok;
}
function umbenennen(id, name) {
const eintraege = lies();
const e = eintraege.find((x) => x.id === id);
if (!e) return false;
e.name = String(name || '').trim().slice(0, 40) || e.name;
e.automatisch = false; // umbenannt = bewusst behalten
schreibe(eintraege);
return true;
}
/** Eintrag samt Dateipfad (zum Anziehen). */
function holen(id) {
const e = lies().find((x) => x.id === id);
return e ? Object.assign({}, e, { pfad: path.join(ordner, e.datei) }) : null;
}
module.exports = { init, liste, hinzufuegen, loeschen, umbenennen, holen };
+290 -272
View File
@@ -31,13 +31,15 @@
<button id="btn-search" class="tbtn tb-lupe" data-ic="search" title="Instanzen suchen (Strg+F)"></button>
<input id="search" type="text" placeholder="Suchen… (Esc schließt)" autocomplete="off" title="Instanzen nach Name, Version oder Gruppe filtern" />
</div>
<button id="btn-account" class="tbtn acct-btn" title="Microsoft-Konto"><span id="account-av" class="acct-av"></span><span id="account-name">Konten</span></button>
<button id="btn-account" class="tbtn acct-btn" title="Konto wechseln, hinzufügen oder verwalten"><span id="account-av" class="acct-av"></span><span id="account-name">Konten</span><span class="acct-pfeil">▾</span></button>
<button id="btn-settings" class="tbtn" data-ic="settings" title="Einstellungen">Optionen</button>
</header>
<!-- Arbeitsbereich: Instanzliste + rechte Aktionsleiste -->
<main class="workspace">
<div class="list-area">
<div id="lichtpunkte" class="lichtpunkte" aria-hidden="true"></div>
<canvas id="skin-live" class="skin-live hidden"></canvas>
<div id="grid" class="list"></div>
<section id="empty" class="empty hidden">
<h2>Noch keine Instanzen</h2>
@@ -53,7 +55,7 @@
<div id="p-name" class="side-title">Instanz</div>
<div id="p-groupline" class="side-grp">Gruppe ändern</div>
<div class="hr"></div>
<button id="p-play" class="sbtn play">Starten</button>
<button id="p-play" class="sbtn play"><span class="play-text">Starten</span></button>
<button id="p-offline" class="sbtn">Offline starten</button>
<div class="hr"></div>
<button id="p-edit" class="sbtn">Instanz bearbeiten</button>
@@ -106,6 +108,7 @@
<span>Gruppe <small class="opt">(optional)</small></span>
<input id="f-group" type="text" list="group-list" placeholder="z. B. Modpacks, Server, Tests" autocomplete="off" />
<datalist id="group-list"></datalist>
<small class="hint">Untergruppe: mit „/“ trennen, z. B. „Modpacks/Survival“.</small>
</label>
<div class="field-row">
@@ -244,87 +247,62 @@
<button class="snav on" data-pane="pane-general">Allgemein</button>
<button class="snav" data-pane="pane-appearance">Darstellung</button>
<button class="snav" data-pane="pane-minecraft">Spiel</button>
<button class="snav" data-pane="pane-start">Start &amp; Java</button>
<button class="snav" data-pane="pane-start">Java &amp; Erweitert</button>
<button class="snav" data-pane="pane-online">Freunde &amp; Umhänge</button>
<button class="snav" data-pane="pane-network">Netzwerk</button>
<button class="snav" data-pane="pane-accounts">Konten</button>
<button class="snav" data-pane="pane-backup">Backup</button>
<button class="snav" data-pane="pane-info">Info</button>
</nav>
<div class="settings-content">
<!-- Aufbau: jede Karte enthält Zeilen – links Name + kurze Erklärung, rechts das Bedienelement -->
<div id="pane-general" class="spane">
<div class="settings-hero">
<h4>Allgemein</h4>
<p>Globale Launcher-Optionen, Instanzverwaltung und sichtbare Bereiche.</p>
<p>Wie sich der Launcher verhält und wo deine Instanzen liegen.</p>
</div>
<div class="settings-grid">
<section class="settings-card">
<h5>Instanzliste</h5>
<label class="field">
<span>Instanzen sortieren</span>
<select id="s-sortby">
<option value="lastPlayed">Nach letztem Start</option>
<option value="name">Nach Name</option>
</select>
</label>
<h5>Launcher</h5>
<div class="s-zeile">
<div class="s-text"><b>Mit Windows starten</b><small>Startet AeroMC beim Anmelden im Infobereich neben der Uhr. Solange aktiv, schließt das X den Launcher nicht, sondern legt ihn dort ab.</small></div>
<label class="schalter"><input id="s-autostart" type="checkbox" /><i></i></label>
</div>
<div class="s-zeile">
<div class="s-text"><b>Windows-Benachrichtigungen</b><small>Meldet, wenn ein großer Download oder ein Backup im Hintergrund fertig ist – nur, solange der Launcher nicht im Vordergrund ist.</small></div>
<label class="schalter"><input id="s-notify" type="checkbox" /><i></i></label>
</div>
<div class="s-zeile">
<div class="s-text"><b>Bedrock-Bereich anzeigen</b><small id="s-bedrock-hint">Wird automatisch ausgeblendet, wenn Bedrock nicht installiert ist.</small></div>
<label class="schalter"><input id="s-bedrock" type="checkbox" /><i></i></label>
</div>
</section>
<section class="settings-card settings-card-wide">
<section class="settings-card">
<h5>Instanzen</h5>
<label class="field">
<span>Instanz-Verzeichnis</span>
<div class="s-zeile s-voll">
<div class="s-text"><b>Instanz-Verzeichnis</b><small>Jede Instanz liegt hier als Unterordner mit ihrem Namen.</small></div>
<div class="path-row">
<input id="s-instances-dir" type="text" readonly />
<button id="s-pick-dir" class="btn">Wählen…</button>
</div>
<small class="hint">Hier werden alle Instanzen als Unterordner mit ihrem Namen gespeichert.</small>
</label>
</section>
<section class="settings-card">
<h5>Bereiche</h5>
<label class="checkbox-field">
<input id="s-bedrock" type="checkbox" /> <span>Bedrock-Bereich anzeigen</span>
</label>
<small id="s-bedrock-hint" class="hint">Wird automatisch ausgeblendet, wenn Bedrock nicht installiert ist.</small>
</section>
<section class="settings-card">
<h5>Benachrichtigungen</h5>
<label class="checkbox-field">
<input id="s-notify" type="checkbox" /> <span>Windows-Benachrichtigung, wenn ein großer Download oder ein Backup im Hintergrund fertig wird</span>
</label>
<small class="hint">Erscheint nur, während das Launcher-Fenster nicht im Vordergrund ist.</small>
</section>
<section class="settings-card">
<h5>Freundesystem</h5>
<label class="checkbox-field">
<input id="s-friends" type="checkbox" /> <span>Freundesystem aktivieren</span>
</label>
<small class="hint">Ausgeschaltet: du bist für niemanden sichtbar und kannst von niemandem als Freund gefunden/hinzugefügt werden. Bestehende Freundschaften bleiben erhalten, zeigen dich aber als offline.</small>
<label class="checkbox-field" style="margin-top:10px">
<input id="s-friends-server" type="checkbox" /> <span>Server, auf dem ich spiele, für Freunde sichtbar</span>
</label>
<small class="hint">Ausgeschaltet sehen Freunde nur, dass du spielst – nicht wo. „Beitreten“ ist dann bei dir nicht möglich, Einladungen kannst du aber weiterhin verschicken.</small>
</section>
<section class="settings-card">
<h5>Autostart</h5>
<label class="checkbox-field">
<input id="s-autostart" type="checkbox" /> <span>Mit Windows starten (im Infobereich)</span>
</label>
<small class="hint">Startet AeroMC beim Anmelden automatisch im Hintergrund. Solange aktiv, landet der Launcher beim Schließen (X) nur im Infobereich neben der Uhr, statt sich zu beenden – über das Symbol dort lässt er sich jederzeit wieder öffnen oder wirklich beenden.</small>
</div>
<div class="s-zeile">
<div class="s-text"><b>Sortierung</b><small>Reihenfolge der Instanzen in der Übersicht.</small></div>
<select id="s-sortby" class="s-wert">
<option value="lastPlayed">Nach letztem Start</option>
<option value="name">Nach Name</option>
</select>
</div>
</section>
</div>
</div>
<div id="pane-appearance" class="spane hidden">
<div class="settings-hero">
<h4>Darstellung</h4>
<p>Optik der Oberfläche und visuelle Extras für die Instanzansicht.</p>
<p>Design, Bewegung und der Skin im Hintergrund.</p>
</div>
<div class="settings-grid">
<section class="settings-card settings-card-wide">
<section class="settings-card">
<h5>Design</h5>
<div class="field">
<select id="s-theme" class="hidden">
@@ -354,119 +332,121 @@
</div>
<small class="hint">Ein Klick zeigt das Design sofort an – mit „Speichern“ gilt es in allen Fenstern.</small>
</section>
<section class="settings-card">
<h5>Icons</h5>
<label class="checkbox-field">
<input id="s-iso3d" type="checkbox" /> <span>Block-Symbole räumlich darstellen (3D)</span>
</label>
<h5>Bewegung &amp; Effekte</h5>
<div class="s-zeile">
<div class="s-text"><b>Startanimation</b><small>Das Logo dreht sich, danach baut sich das Fenster von der Mitte aus auf. Gilt ab dem nächsten Start.</small></div>
<label class="schalter"><input id="s-start-animation" type="checkbox" /><i></i></label>
</div>
<div class="s-zeile">
<div class="s-text"><b>Bewegte Oberfläche</b><small>Die Skin-Figur atmet und schaut zum Mauszeiger, Fenster wachsen aus dem Knopf, Designs blenden weich über.</small></div>
<label class="schalter"><input id="s-ui-animationen" type="checkbox" /><i></i></label>
</div>
<div class="s-zeile">
<div class="s-text"><b>Räumliche Block-Symbole</b><small>Zeigt die Block-Symbole der Instanzen in 3D.</small></div>
<label class="schalter"><input id="s-iso3d" type="checkbox" /><i></i></label>
</div>
</section>
<section class="settings-card settings-card-wide">
<section class="settings-card">
<h5>Skin-Hintergrund</h5>
<label class="field">
<span>Quelle <small class="opt">(optional)</small></span>
<select id="s-bgskin"><option value="">Aus</option></select>
<small class="hint">Zeigt den Minecraft-Skin des gewählten Kontos als Hintergrund im Instanzbereich.</small>
</label>
<div class="field-row">
<label class="field">
<span>Pose</span>
<select id="s-bgskin-pose">
<option value="walking">Laufend</option>
<option value="standing">Stehend</option>
<option value="legends">Heldenhaft</option>
<option value="bedwars">Kampfhaltung</option>
<option value="hoverboard">Schwebend</option>
<option value="sitting">Sitzend</option>
<option value="spooky">Unheimlich</option>
</select>
</label>
<label class="field">
<span>Armform</span>
<select id="s-bgskin-model" title="Bei „Automatisch“ wird die Armform übernommen, die beim Konto eingestellt ist">
<option value="auto">Automatisch (vom Konto)</option>
<option value="classic">Normal (breite Arme, Steve)</option>
<option value="slim">Schmal (Alex)</option>
</select>
</label>
<label class="field">
<span>Deckkraft: <span id="s-bgskin-val">55</span> %</span>
<input id="s-bgskin-opacity" type="range" min="10" max="100" step="5" />
</label>
<div class="s-zeile">
<div class="s-text"><b>Quelle</b><small>Zeigt den Skin eines Kontos groß im Instanzbereich.</small></div>
<select id="s-bgskin" class="s-wert"><option value="">Aus</option></select>
</div>
<div class="s-zeile">
<div class="s-text"><b>Pose</b></div>
<select id="s-bgskin-pose" class="s-wert">
<option value="walking">Laufend</option>
<option value="standing">Stehend</option>
<option value="legends">Heldenhaft</option>
<option value="bedwars">Kampfhaltung</option>
<option value="hoverboard">Schwebend</option>
<option value="sitting">Sitzend</option>
<option value="spooky">Unheimlich</option>
</select>
</div>
<div class="s-zeile">
<div class="s-text"><b>Armform</b><small>„Automatisch“ übernimmt die Einstellung des Kontos.</small></div>
<select id="s-bgskin-model" class="s-wert">
<option value="auto">Automatisch (vom Konto)</option>
<option value="classic">Normal (breite Arme, Steve)</option>
<option value="slim">Schmal (Alex)</option>
</select>
</div>
<div class="s-zeile">
<div class="s-text"><b>Deckkraft</b></div>
<div class="s-wert s-regler"><input id="s-bgskin-opacity" type="range" min="10" max="100" step="5" /><span><span id="s-bgskin-val">55</span> %</span></div>
</div>
<div class="s-zeile">
<div class="s-text"><b>Konten durchwechseln</b><small>Bei 2 oder mehr angemeldeten Konten wechselt der Skin der Reihe nach.</small></div>
<label class="schalter"><input id="s-bgskin-cycle" type="checkbox" /><i></i></label>
</div>
<div class="s-zeile">
<div class="s-text"><b>Wechsel alle</b></div>
<div class="s-wert s-einheit"><input id="s-bgskin-cycle-seconds" type="number" min="5" step="5" /><span>Sekunden</span></div>
</div>
<label class="checkbox-field">
<input id="s-bgskin-cycle" type="checkbox" /> <span>Bei mehreren angemeldeten Konten automatisch durchwechseln</span>
</label>
<label class="field">
<span>Wechsel alle <small class="opt">(Sekunden)</small></span>
<input id="s-bgskin-cycle-seconds" type="number" min="5" step="5" />
<small class="hint">Läuft nur, wenn 2 oder mehr Konten angemeldet sind, und beginnt nach dem letzten Konto wieder von vorn.</small>
</label>
</section>
</div>
</div>
<div id="pane-minecraft" class="spane hidden">
<div class="settings-hero">
<h4>Spiel</h4>
<p>Standardwerte für neue und bestehende Instanzen ohne eigene Überschreibung.</p>
<p>Standardwerte für alle Instanzen ohne eigene Einstellung – und was beim Spielen passiert.</p>
</div>
<div class="settings-grid">
<section class="settings-card">
<h5>Speicher</h5>
<div class="field-row">
<label class="field">
<span>Standard-RAM min. (MB)</span>
<input id="s-min-mem" type="number" min="512" step="256" />
</label>
<label class="field">
<span>Standard-RAM max. (MB)</span>
<input id="s-max-mem" type="number" min="512" step="256" />
</label>
<h5>Leistung &amp; Fenster</h5>
<div class="s-zeile">
<div class="s-text"><b>Arbeitsspeicher</b><small>Mindestens / höchstens, in MB.</small></div>
<div class="s-wert s-paar"><input id="s-min-mem" type="number" min="512" step="256" aria-label="RAM min. (MB)" /><span>–</span><input id="s-max-mem" type="number" min="512" step="256" aria-label="RAM max. (MB)" /></div>
</div>
<small class="hint">Gilt für alle Instanzen ohne eigene RAM-Einstellung.</small>
</section>
<section class="settings-card">
<h5>Fenster</h5>
<label class="checkbox-field">
<input id="s-mc-max" type="checkbox" /> <span>Minecraft maximiert starten</span>
</label>
<div class="field-row">
<label class="field"><span>Fensterbreite</span><input id="s-mc-width" type="number" min="320" step="10" /></label>
<label class="field"><span>Fensterhöhe</span><input id="s-mc-height" type="number" min="240" step="10" /></label>
<div class="s-zeile">
<div class="s-text"><b>Maximiert starten</b><small>Minecraft öffnet sich bildschirmfüllend.</small></div>
<label class="schalter"><input id="s-mc-max" type="checkbox" /><i></i></label>
</div>
<div class="s-zeile">
<div class="s-text"><b>Fenstergröße</b><small>Breite × Höhe, wenn nicht maximiert.</small></div>
<div class="s-wert s-paar"><input id="s-mc-width" type="number" min="320" step="10" aria-label="Fensterbreite" /><span>×</span><input id="s-mc-height" type="number" min="240" step="10" aria-label="Fensterhöhe" /></div>
</div>
</section>
<section class="settings-card">
<h5>Mods</h5>
<label class="checkbox-field">
<input id="s-auto-mod-updates" type="checkbox" /> <span>Vor jedem Start nach Mod-Updates suchen und installieren</span>
</label>
<small class="hint">Gilt für Mods aus Modrinth. Dauert die Suche länger als 20 Sekunden, startet Minecraft ohne Update. Abgeschaltete Mods bleiben abgeschaltet.</small>
<div class="s-zeile">
<div class="s-text"><b>Mod-Updates vor dem Start</b><small>Sucht vor jedem Start nach Updates für Modrinth-Mods und installiert sie. Nach 20 Sekunden startet Minecraft ohne Update; abgeschaltete Mods bleiben aus.</small></div>
<label class="schalter"><input id="s-auto-mod-updates" type="checkbox" /><i></i></label>
</div>
</section>
<section class="settings-card">
<h5>Umhänge</h5>
<label class="checkbox-field">
<input id="s-aeromc-capes" type="checkbox" /> <span>AeroMC-Umhänge im Spiel anzeigen</span>
</label>
<small class="hint">Zeigt deinen eigenen Umhang und die anderer AeroMC-Spieler – in jeder Minecraft-Version und mit jedem Loader, ohne Mod. Gestalten im Skin-Fenster unter „Umhang“. Gilt ab dem nächsten Start.</small>
<h5>Konsole</h5>
<div class="s-zeile">
<div class="s-text"><b>Während des Spiels anzeigen</b></div>
<label class="schalter"><input id="s-console-launch" type="checkbox" /><i></i></label>
</div>
<div class="s-zeile">
<div class="s-text"><b>Nach dem Beenden schließen</b></div>
<label class="schalter"><input id="s-console-autoclose" type="checkbox" /><i></i></label>
</div>
<div class="s-zeile">
<div class="s-text"><b>Bei einem Absturz anzeigen</b></div>
<label class="schalter"><input id="s-console-crash" type="checkbox" /><i></i></label>
</div>
<div class="s-zeile">
<div class="s-text"><b>Zeilen merken</b><small>Ältere Zeilen fallen aus der Konsole heraus.</small></div>
<input id="s-console-lines" class="s-wert s-zahl" type="number" min="1000" step="1000" />
</div>
</section>
</div>
</div>
<div id="pane-start" class="spane hidden">
<div class="settings-hero">
<h4>Start &amp; Java</h4>
<p>Runtime-Auswahl, Startverhalten und globale Befehle für alle Instanzen.</p>
<h4>Java &amp; Erweitert</h4>
<p>Welches Java startet und was zusätzlich vor oder nach dem Spiel läuft.</p>
</div>
<div class="settings-grid settings-grid-stack">
<section class="settings-card settings-card-wide">
<div class="settings-grid">
<section class="settings-card">
<h5>Java</h5>
<label class="field">
<span>Java (java.exe)</span>
<div class="s-zeile s-voll">
<div class="s-text"><b>Java-Programm</b><small id="s-java-status">–</small></div>
<div class="path-row">
<input id="s-java" type="text" placeholder="leer = java aus PATH" />
<button id="s-pick-java" class="btn">Wählen…</button>
@@ -475,102 +455,100 @@
<select id="s-java-detected"><option value="">– gefundene Java-Installationen –</option></select>
<button id="s-scan-java" class="btn">Erkennen</button>
</div>
<small id="s-java-status" class="hint">–</small>
</label>
<label class="checkbox-field">
<input id="s-auto-java" type="checkbox" /> <span>Fehlende Java-Version automatisch herunterladen</span>
</label>
<small class="hint">Holt beim Spielstart die passende Fassung von Eclipse Temurin, wenn keine geeignete Java-Installation gefunden wird. Ablage im Launcher-Ordner, rund 45 MB je Version.</small>
</div>
<div class="s-zeile">
<div class="s-text"><b>Fehlendes Java herunterladen</b><small>Holt beim Start die passende Version von Eclipse Temurin, wenn keine geeignete gefunden wird (rund 45 MB je Version).</small></div>
<label class="schalter"><input id="s-auto-java" type="checkbox" /><i></i></label>
</div>
</section>
<section class="settings-card">
<h5>Konsole</h5>
<label class="checkbox-field">
<input id="s-console-launch" type="checkbox" /> <span>Konsole anzeigen, während das Spiel läuft</span>
</label>
<label class="checkbox-field">
<input id="s-console-autoclose" type="checkbox" /> <span>Konsole automatisch schließen, nachdem das Spiel beendet wurde</span>
</label>
<label class="checkbox-field">
<input id="s-console-crash" type="checkbox" /> <span>Konsole anzeigen, wenn das Spiel abstürzt</span>
</label>
<label class="field">
<span>Historie-Limit (Zeilen)</span>
<input id="s-console-lines" type="number" min="1000" step="1000" />
</label>
<h5>Befehle</h5>
<small class="hint s-vorab">Für Fortgeschrittene. Befehle aus dem Bearbeiten-Dialog einer Instanz haben Vorrang.</small>
<div class="s-zeile">
<div class="s-text"><b>Vor dem Start</b></div>
<input id="s-global-pre" class="s-wert s-breit" type="text" placeholder="läuft vor jedem Spielstart" />
</div>
<div class="s-zeile">
<div class="s-text"><b>Nach dem Beenden</b></div>
<input id="s-global-post" class="s-wert s-breit" type="text" placeholder="läuft nach jedem Beenden" />
</div>
<div class="s-zeile">
<div class="s-text"><b>Wrapper</b><small>Startet Java über dieses Programm.</small></div>
<input id="s-wrapper" class="s-wert s-breit" type="text" placeholder="z. B. prime-run" />
</div>
</section>
</div>
</div>
<div id="pane-online" class="spane hidden">
<div class="settings-hero">
<h4>Freunde &amp; Umhänge</h4>
<p>Was andere AeroMC-Spieler von dir sehen.</p>
</div>
<div class="settings-grid">
<section class="settings-card">
<h5>Globale Befehle</h5>
<label class="field">
<span>Pre-Launch-Befehl</span>
<input id="s-global-pre" type="text" placeholder="läuft vor jedem Spielstart" />
</label>
<label class="field">
<span>Post-Exit-Befehl</span>
<input id="s-global-post" type="text" placeholder="läuft nach jedem Beenden" />
</label>
<small class="hint">Instanzspezifische Befehle aus dem Bearbeiten-Dialog haben Vorrang.</small>
<h5>Freundesystem</h5>
<div class="s-zeile">
<div class="s-text"><b>Freundesystem aktivieren</b><small>Aus: niemand kann dich finden oder hinzufügen. Bestehende Freunde bleiben, sehen dich aber als offline.</small></div>
<label class="schalter"><input id="s-friends" type="checkbox" /><i></i></label>
</div>
<div class="s-zeile">
<div class="s-text"><b>Server für Freunde sichtbar</b><small>Aus: Freunde sehen nur, dass du spielst, nicht wo – „Beitreten“ geht dann nicht, Einladungen schon.</small></div>
<label class="schalter"><input id="s-friends-server" type="checkbox" /><i></i></label>
</div>
</section>
<section class="settings-card settings-card-wide">
<h5>Externe Programme</h5>
<label class="field">
<span>Wrapper-Befehl</span>
<input id="s-wrapper" type="text" placeholder="z. B. prime-run (leer = kein Wrapper)" />
</label>
<small class="hint">Startet Java über dieses Programm. Java-Pfad und Argumente werden automatisch angehängt.</small>
<section class="settings-card">
<h5>Umhänge</h5>
<div class="s-zeile">
<div class="s-text"><b>AeroMC-Umhänge im Spiel</b><small>Zeigt deinen Umhang und den anderer AeroMC-Spieler – in jeder Version, mit jedem Loader, ohne Mod. Gestalten im Skin-Fenster unter „Umhang“. Gilt ab dem nächsten Start.</small></div>
<label class="schalter"><input id="s-aeromc-capes" type="checkbox" /><i></i></label>
</div>
</section>
</div>
</div>
<div id="pane-network" class="spane hidden">
<div class="settings-hero">
<h4>Netzwerk</h4>
<p>Verbindungsoptionen für Downloads und externe Dienste.</p>
<p>Proxy für Downloads und der Zugang zu CurseForge.</p>
</div>
<div class="settings-grid">
<section class="settings-card settings-card-wide">
<section class="settings-card">
<h5>Proxy</h5>
<label class="checkbox-field">
<input id="s-proxy-enabled" type="checkbox" /> <span>HTTP-Proxy für Downloads verwenden</span>
</label>
<div class="field-row">
<label class="field"><span>Host</span><input id="s-proxy-host" type="text" placeholder="proxy.firma.de" /></label>
<label class="field field-port"><span>Port</span><input id="s-proxy-port" type="text" placeholder="8080" /></label>
<div class="s-zeile">
<div class="s-text"><b>HTTP-Proxy verwenden</b><small>Nur nötig, wenn dein Netzwerk Downloads nur über einen Proxy erlaubt.</small></div>
<label class="schalter"><input id="s-proxy-enabled" type="checkbox" /><i></i></label>
</div>
<div class="field-row">
<label class="field"><span>Benutzer <small class="opt">(optional)</small></span><input id="s-proxy-user" type="text" /></label>
<label class="field"><span>Passwort <small class="opt">(optional)</small></span><input id="s-proxy-pass" type="password" /></label>
<div class="s-felder">
<div class="field-row">
<label class="field"><span>Host</span><input id="s-proxy-host" type="text" placeholder="proxy.firma.de" /></label>
<label class="field field-port"><span>Port</span><input id="s-proxy-port" type="text" placeholder="8080" /></label>
</div>
<div class="field-row">
<label class="field"><span>Benutzer <small class="opt">(optional)</small></span><input id="s-proxy-user" type="text" /></label>
<label class="field"><span>Passwort <small class="opt">(optional)</small></span><input id="s-proxy-pass" type="password" /></label>
</div>
</div>
</section>
<section class="settings-card settings-card-wide">
<section class="settings-card">
<h5>CurseForge</h5>
<label class="field">
<span>Eigener API-Key <small class="opt">(optional)</small></span>
<small class="hint s-vorab">Nur für CurseForge-Modpacks und -Mods nötig. Modrinth funktioniert immer ohne.</small>
<div class="s-zeile s-voll">
<div class="s-text"><b>Eigener API-Key</b><small>Von CurseForge for Studios (Profil → Schlüssel erstellen, beginnt mit cfc_pat_…) oder ein Organisationsschlüssel. Nach dem Eintragen speichern.</small></div>
<div class="path-row">
<input id="s-cf-key" type="password" placeholder="leer lassen, um die Weiterleitung unten zu nutzen" />
<button type="button" id="s-cf-test" class="btn">Prüfen</button>
</div>
<small class="hint">Schlüssel von CurseForge for Studios (Profil → Schlüssel erstellen, beginnt mit cfc_pat_…) oder klassischer Organisationsschlüssel. Nach dem Eintragen speichern. „Prüfen“ testet den Key bei CurseForge.</small>
<small id="s-cf-test-status" class="hint"></small>
</label>
<div class="field-row">
<label class="field">
<span>Weiterleitung <small class="opt">(optional)</small></span>
<input id="s-cf-proxy" type="text" placeholder="https://cf.example.de" />
</label>
<label class="field">
<span>Token <small class="opt">(falls verlangt)</small></span>
<input id="s-cf-proxy-token" type="password" />
</label>
</div>
<small class="hint">Ein Server, der den Schlüssel für dich hält. Ohne beides funktionieren Modrinth-Modpacks (.mrpack) weiterhin.</small>
<div class="s-zeile s-voll">
<div class="s-text"><b>Weiterleitung</b><small>Ein Server, der den Schlüssel für dich hält.</small></div>
<div class="field-row">
<label class="field"><span>Adresse</span><input id="s-cf-proxy" type="text" placeholder="https://cf.example.de" /></label>
<label class="field"><span>Token <small class="opt">(falls verlangt)</small></span><input id="s-cf-proxy-token" type="password" /></label>
</div>
</div>
</section>
</div>
</div>
<div id="pane-accounts" class="spane hidden">
<div class="settings-hero">
<h4>Konten</h4>
@@ -591,66 +569,58 @@
<div id="pane-backup" class="spane hidden">
<div class="settings-hero">
<h4>Backup</h4>
<p>Alle Instanzen gesammelt als ZIP sichern, bevor größere Änderungen anstehen.</p>
<p>Sicherungen aller Instanzen und deiner Welten.</p>
</div>
<div class="settings-grid">
<section class="settings-card settings-card-wide">
<h5>Globales Backup</h5>
<small class="hint">Backups werden automatisch unter Dokumente/AeroMC Launcher/Backups gespeichert. Instanzen und Launcher-Einstellungen werden gesichert. Konten und Tokens werden nicht mitgesichert.</small>
<div class="path-row" style="margin-top:14px">
<section class="settings-card">
<h5>Jetzt sichern</h5>
<div class="s-zeile">
<div class="s-text"><b>Komplett-Backup</b><small>Alle Instanzen und Launcher-Einstellungen als ZIP unter Dokumente\AeroMC Launcher\Backups. Konten und Anmeldedaten werden nicht gesichert.</small></div>
<button id="s-backup-global" class="btn btn-primary">Backup erstellen</button>
<span id="s-backup-status" class="hint">Noch kein Backup erstellt.</span>
</div>
<small id="s-backup-status" class="hint s-status">Noch kein Backup erstellt.</small>
<div id="s-backup-progress" class="backup-progress hidden" aria-hidden="true">
<div class="backup-progress-bar"><div id="s-backup-progress-fill" class="backup-progress-fill"></div></div>
<div id="s-backup-progress-text" class="backup-progress-text hint">0 %</div>
</div>
</section>
<section class="settings-card settings-card-wide">
<h5>Automatisch sichern</h5>
<label class="checkbox-field">
<input id="s-autobackup" type="checkbox" /> <span>Sicherungen nach Zeitplan erstellen</span>
</label>
<div class="field-row">
<label class="field">
<span>Wie oft</span>
<select id="s-autobackup-every">
<option value="daily">Täglich</option>
<option value="weekly">Wöchentlich</option>
</select>
</label>
<label class="field">
<span>Wie viele behalten</span>
<input id="s-autobackup-keep" type="number" min="1" max="50" />
</label>
</div>
<label class="checkbox-field">
<input id="s-autobackup-launch" type="checkbox" /> <span>Zusätzlich vor jedem Spielstart sichern</span>
</label>
<small class="hint">Eine Sicherung umfasst <em>alle</em> Instanzen und kann je nach Umfang einige Minuten dauern. Vor dem Spielstart wird sie abgewartet – der Start verzögert sich entsprechend. Ältere automatische Sicherungen werden gelöscht, sobald die eingestellte Anzahl überschritten ist.</small>
<small id="s-autobackup-last" class="hint">Noch keine automatische Sicherung.</small>
</section>
<section class="settings-card">
<h5>Welten</h5>
<label class="checkbox-field">
<input id="s-auto-world" type="checkbox" /> <span>Nach jeder Spielsitzung die zuletzt gespielte Welt sichern</span>
</label>
<small class="hint">Läuft im Hintergrund, sobald Minecraft beendet ist. Pro Welt bleiben die letzten 5 Stände erhalten – unter Dokumente\AeroMC Launcher\Welten-Sicherungen\&lt;Instanz&gt;\Automatisch.</small>
</section>
<section class="settings-card settings-card-wide">
<h5>Wiederherstellen</h5>
<small class="hint">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.</small>
<label class="field backup-select-field">
<span>Verfügbares Backup</span>
<select id="s-restore-backup"></select>
</label>
<div class="path-row" style="margin-top:14px">
<button id="s-restore-global" class="btn">Backup wiederherstellen</button>
<span id="s-restore-status" class="hint">Noch kein Backup wiederhergestellt.</span>
<h5>Automatisch sichern</h5>
<div class="s-zeile">
<div class="s-text"><b>Nach Zeitplan sichern</b><small id="s-autobackup-last">Noch keine automatische Sicherung.</small></div>
<label class="schalter"><input id="s-autobackup" type="checkbox" /><i></i></label>
</div>
<div class="s-zeile">
<div class="s-text"><b>Wie oft</b></div>
<select id="s-autobackup-every" class="s-wert">
<option value="daily">Täglich</option>
<option value="weekly">Wöchentlich</option>
</select>
</div>
<div class="s-zeile">
<div class="s-text"><b>Wie viele behalten</b><small>Ältere automatische Sicherungen werden gelöscht.</small></div>
<input id="s-autobackup-keep" class="s-wert s-zahl" type="number" min="1" max="50" />
</div>
<div class="s-zeile">
<div class="s-text"><b>Zusätzlich vor jedem Start</b><small>Der Start wartet, bis die Sicherung fertig ist – bei vielen Instanzen kann das einige Minuten dauern.</small></div>
<label class="schalter"><input id="s-autobackup-launch" type="checkbox" /><i></i></label>
</div>
<div class="s-zeile">
<div class="s-text"><b>Welt nach dem Spielen sichern</b><small>Sichert nach jeder Sitzung die zuletzt gespielte Welt im Hintergrund. Je Welt bleiben die letzten 5 Stände (Dokumente\AeroMC Launcher\Welten-Sicherungen).</small></div>
<label class="schalter"><input id="s-auto-world" type="checkbox" /><i></i></label>
</div>
</section>
<section class="settings-card">
<h5>Wiederherstellen</h5>
<small class="hint s-vorab s-warnung">Achtung: Alle vorhandenen Instanzen und die Launcher-Einstellungen werden mit dem Inhalt des Backups überschrieben. Konten bleiben unverändert.</small>
<div class="s-zeile s-voll">
<div class="s-text"><b>Verfügbares Backup</b></div>
<div class="path-row">
<select id="s-restore-backup"></select>
<button id="s-restore-global" class="btn">Wiederherstellen</button>
</div>
</div>
<small id="s-restore-status" class="hint s-status">Noch kein Backup wiederhergestellt.</small>
<div id="s-restore-progress" class="backup-progress hidden" aria-hidden="true">
<div class="backup-progress-bar"><div id="s-restore-progress-fill" class="backup-progress-fill"></div></div>
<div id="s-restore-progress-text" class="backup-progress-text hint">0 %</div>
@@ -658,7 +628,6 @@
</section>
</div>
</div>
<div id="pane-info" class="spane hidden">
<div class="settings-hero">
<h4>Info</h4>
@@ -808,6 +777,7 @@
</div>
<div class="modal-foot">
<button id="m-open-folder" class="btn" data-ic="folder">mods-Ordner</button>
<button id="m-leistung" class="btn" title="Bewährte Performance-Mods passend zu Version und Loader installieren (Sodium, Lithium, FerriteCore …)">⚡ Leistungs-Paket</button>
<div class="spacer"></div>
<button class="btn" data-close>Schließen</button>
</div>
@@ -1102,10 +1072,19 @@
<button class="modal-close" data-close>✕</button>
</div>
<div class="modal-body">
<div id="stats-tiles" class="stats-tiles"></div>
<div class="friends-section-title">Spielzeit pro Instanz</div>
<div id="stats-list" class="stats-list" role="list"></div>
<p id="stats-unplayed" class="hint"></p>
<div class="mods-tabs stats-tabs">
<button class="mtab on" data-stats-reiter="spielzeit">Spielzeit</button>
<button class="mtab" data-stats-reiter="erfolge">Erfolge <span id="erfolge-zahl"></span></button>
</div>
<div id="stats-reiter-spielzeit">
<div id="stats-tiles" class="stats-tiles"></div>
<div class="friends-section-title">Spielzeit pro Instanz</div>
<div id="stats-list" class="stats-list" role="list"></div>
<p id="stats-unplayed" class="hint"></p>
</div>
<div id="stats-reiter-erfolge" class="hidden">
<div id="erfolge-liste" class="erfolge-liste"></div>
</div>
</div>
<div class="modal-foot">
<div class="spacer"></div>
@@ -1123,13 +1102,20 @@
</div>
<div class="modal-body">
<p id="friends-disabled-hint" class="hint hidden">Das Freundesystem ist ausgeschaltet – aktiviere es in den Einstellungen, um Freunde zu sehen oder hinzuzufügen.</p>
<div id="friends-content">
<div class="field-row">
<input id="friends-add-input" type="text" placeholder="Minecraft-Name…" maxlength="16" />
<button id="friends-add-btn" class="btn btn-mini">Anfrage senden</button>
<div id="friends-content" class="freunde-raster">
<div class="freunde-spalte">
<div class="field-row">
<input id="friends-add-input" type="text" placeholder="Minecraft-Name…" maxlength="16" />
<button id="friends-add-btn" class="btn btn-mini">Anfrage senden</button>
</div>
<div id="friends-requests-box"></div>
<div id="friends-list-box"></div>
</div>
<!-- 3D-Figur des ausgewählten Freundes (Skin + Umhang), neben der Liste -->
<div id="freund-buehne" class="freund-buehne hidden">
<canvas id="freund-figur"></canvas>
<div class="freund-buehne-text"><b id="freund-buehne-name"></b><span id="freund-buehne-status"></span></div>
</div>
<div id="friends-requests-box"></div>
<div id="friends-list-box"></div>
</div>
<!-- Unterhaltung mit einem Freund -->
<div id="friends-chat" class="friends-chat hidden">
@@ -1229,6 +1215,10 @@
</div>
<div class="modal-body">
<p id="skin-konto" class="hint">–</p>
<div id="skin-armform-hinweis" class="skin-armform-hinweis hidden">
<span id="skin-armform-text"></span>
<button id="skin-armform-fix" class="btn btn-mini btn-primary"></button>
</div>
<div class="skin-vorschau">
<div class="skin-spalte">
<div class="skin-label">Aktuell</div>
@@ -1278,6 +1268,18 @@
</div>
<small id="skin-cape-status" class="hint">Den AeroMC-Umhang sehen im Spiel alle, die AeroMC nutzen oder den Mod „AeroMC Umhänge“ (Fabric/Quilt) installiert haben – dort hat er Vorrang vor dem offiziellen.</small>
</div>
<div class="skin-garderobe">
<div class="garderobe-kopf">
<div class="skin-label">Garderobe <small class="opt">(gespeicherte Skins und Umhänge – mit einem Klick wieder anziehen)</small></div>
<div class="garderobe-knoepfe">
<button id="gard-skin-speichern" class="btn btn-mini" title="Den Skin, den du gerade trägst, in der Garderobe ablegen">Aktuellen Skin speichern</button>
<button id="gard-umhang-speichern" class="btn btn-mini" title="Deinen eigenen AeroMC-Umhang in der Garderobe ablegen">Aktuellen Umhang speichern</button>
</div>
</div>
<div id="garderobe-liste" class="garderobe-liste"></div>
<small class="hint">Vor jedem Skinwechsel im Launcher wird der bisherige Skin automatisch hier gesichert – Mojang selbst merkt sich keine alten Skins.</small>
</div>
</div>
<div class="modal-foot">
<button id="skin-reset" class="btn btn-danger">Auf Standard zurücksetzen</button>
@@ -1548,6 +1550,16 @@
<button id="import-tab-code" class="mtab" data-kind="code">Per Code</button>
</div>
<div id="import-lauf" class="import-lauf hidden">
<div class="import-lauf-kopf">
<b id="import-lauf-titel">Wird vorbereitet …</b>
<button id="import-lauf-abbrechen" class="btn btn-mini btn-danger" title="Bricht den Import ab – die gerade halb kopierte Instanz wird wieder entfernt, fertig übernommene bleiben">Abbrechen</button>
</div>
<div class="import-balken"><i id="import-lauf-balken"></i></div>
<div id="import-lauf-zeile" class="import-lauf-zeile"></div>
<div id="import-lauf-datei" class="hint import-lauf-datei"></div>
</div>
<div id="import-pane-zip" class="import-pane">
<p class="import-copy">Importiert eine einzelne Instanz aus einem ZIP-Archiv.</p>
<p class="hint">Erkannt werden AeroMC-Exporte (instance.json) und einzelne MultiMC- oder Prism-Instanzen (instance.cfg) – letztere auch als gezippter Instanzordner. Der Launcher legt daraus automatisch eine neue Instanz an, samt Mods, Welten und Konfiguration.</p>
@@ -1828,6 +1840,12 @@
</div>
</div>
<!-- Erfolg-Meldungen im Minecraft-Stil (oben rechts) -->
<div id="erfolg-meldungen" class="erfolg-meldungen"></div>
<!-- Konto-Menü (Klick auf den Kontoknopf oben rechts) -->
<div id="konto-menu" class="ctx konto-menu hidden" role="menu"></div>
<div id="ctx-menu" class="ctx hidden">
<button class="ctx-item" data-act="p-edit">Symbol ändern</button>
<button class="ctx-item" data-act="p-edit">Umbenennen</button>
+182
View File
@@ -0,0 +1,182 @@
/*
* Große Ordner kopieren, ohne den Launcher einzufrieren.
*
* Früher liefen Importe mit fs.cpSync – synchron im Hauptprozess. Bei
* Spielständen mit vielen Gigabyte stand dann der komplette Launcher
* minutenlang still (keine Anzeige, kein Abbrechen, „Keine Rückmeldung“).
*
* Hier wird asynchron gearbeitet: erst Dateien und Größe zählen, dann mit
* mehreren Dateien gleichzeitig kopieren. Große Dateien gehen stückweise, damit
* der Fortschritt auch bei einer einzelnen 4-GB-Datei weiterläuft. Gesperrte
* Einzeldateien werden übersprungen und gemeldet statt alles abzubrechen.
*/
const fs = require('fs');
const fsp = fs.promises;
const path = require('path');
const { pipeline } = require('stream/promises');
const GLEICHZEITIG = 8; // Dateien parallel
const STUECKWEISE_AB = 64 * 1024 * 1024; // ab hier mit Zwischenständen kopieren
const MAX_TIEFE = 64; // Schutz vor Verknüpfungs-Schleifen
const MELDE_ALLE_MS = 200;
class Abgebrochen extends Error {
constructor() { super('Vom Nutzer abgebrochen.'); this.abgebrochen = true; }
}
// Ordner-/Dateieintrag auflösen (Verknüpfungen werden wie ihr Ziel behandelt –
// die Modrinth App verlinkt z. B. Dateien aus ihrem gemeinsamen Speicher)
async function art(voll, eintrag) {
if (!eintrag.isSymbolicLink()) return { ordner: eintrag.isDirectory(), datei: eintrag.isFile() };
const st = await fsp.stat(voll).catch(() => null);
return st ? { ordner: st.isDirectory(), datei: st.isFile() } : { ordner: false, datei: false };
}
/** Zählt Dateien und Bytes unter allen Quellen (Dateien oder Ordner). */
async function zaehle(quellen, job, melde) {
const summe = { dateien: 0, bytes: 0 };
let zuletzt = 0;
async function lauf(voll, tiefe) {
if (job && job.abgebrochen) throw new Abgebrochen();
const st = await fsp.stat(voll).catch(() => null);
if (!st) return;
if (st.isFile()) { summe.dateien++; summe.bytes += st.size; }
else if (st.isDirectory() && tiefe < MAX_TIEFE) {
const eintraege = await fsp.readdir(voll, { withFileTypes: true }).catch(() => []);
for (const e of eintraege) {
const p = path.join(voll, e.name);
const a = await art(p, e);
if (a.ordner) await lauf(p, tiefe + 1);
else if (a.datei) {
const s = await fsp.stat(p).catch(() => null);
if (s) { summe.dateien++; summe.bytes += s.size; }
}
}
}
if (melde && Date.now() - zuletzt > MELDE_ALLE_MS) { zuletzt = Date.now(); melde({ ...summe }); }
}
for (const q of quellen) await lauf(q, 0);
if (melde) melde({ ...summe });
return summe;
}
/** Freier Platz (Bytes) auf dem Laufwerk von `ordner`, null wenn unbekannt. */
async function freierPlatz(ordner) {
try {
let pruefe = ordner;
while (!fs.existsSync(pruefe) && path.dirname(pruefe) !== pruefe) pruefe = path.dirname(pruefe);
const s = await fsp.statfs(pruefe);
return s.bavail * s.bsize;
} catch { return null; }
}
/**
* Fortschritt über mehrere Kopiervorgänge hinweg (z. B. alle Instanzen eines
* Imports). `melde` bekommt höchstens alle 200 ms einen Zwischenstand.
*/
function neuerFortschritt(gesamt, melde) {
const f = {
gesamtBytes: gesamt.bytes || 0, gesamtDateien: gesamt.dateien || 0,
bytes: 0, dateien: 0, datei: '', uebersprungen: [],
beginn: Date.now(), zuletzt: 0, verlauf: [],
};
f.melden = (sofort) => {
const jetzt = Date.now();
if (!melde || (!sofort && jetzt - f.zuletzt < MELDE_ALLE_MS)) return;
f.zuletzt = jetzt;
// Tempo über die letzten ~5 Sekunden glätten
f.verlauf.push([jetzt, f.bytes]);
while (f.verlauf.length > 2 && jetzt - f.verlauf[0][0] > 5000) f.verlauf.shift();
const [t0, b0] = f.verlauf[0];
const tempo = jetzt > t0 ? ((f.bytes - b0) * 1000) / (jetzt - t0) : 0;
// erst nach 1,5 s schätzen – davor gibt es noch kein brauchbares Tempo
const eingeschwungen = jetzt - f.beginn > 1500;
const rest = eingeschwungen && tempo > 0 && f.gesamtBytes > f.bytes ? Math.round((f.gesamtBytes - f.bytes) / tempo) : null;
melde({
bytes: f.bytes, gesamtBytes: f.gesamtBytes, dateien: f.dateien, gesamtDateien: f.gesamtDateien,
datei: f.datei, tempo: eingeschwungen ? Math.round(tempo) : 0, restSekunden: rest, uebersprungen: f.uebersprungen.length,
});
};
return f;
}
async function kopiereDatei(von, nach, groesse, fortschritt, job) {
if (groesse >= STUECKWEISE_AB) {
// große Datei: stückweise, damit der Balken weiterläuft
const lesen = fs.createReadStream(von, { highWaterMark: 4 * 1024 * 1024 });
lesen.on('data', (stueck) => {
if (job && job.abgebrochen) { lesen.destroy(new Abgebrochen()); return; }
if (fortschritt) { fortschritt.bytes += stueck.length; fortschritt.melden(); }
});
await pipeline(lesen, fs.createWriteStream(nach));
} else {
await fsp.copyFile(von, nach);
if (fortschritt) fortschritt.bytes += groesse;
}
}
/**
* Kopiert `von` (Datei oder Ordner) nach `nach`. Läuft asynchron, meldet über
* `fortschritt` (siehe neuerFortschritt) und bricht ab, sobald job.abgebrochen.
* Gesperrte Einzeldateien landen in fortschritt.uebersprungen; volle Platte
* (ENOSPC) und Abbruch beenden den ganzen Vorgang mit Fehler.
*/
async function kopiere(von, nach, fortschritt, job) {
const laufend = new Set();
let schwererFehler = null;
async function platz(aufgabe) {
while (laufend.size >= GLEICHZEITIG) await Promise.race(laufend);
if (schwererFehler) throw schwererFehler;
const p = aufgabe().catch((err) => {
if (err && (err.abgebrochen || err.code === 'ENOSPC')) schwererFehler = schwererFehler || err;
else if (fortschritt) fortschritt.uebersprungen.push({ datei: err && err.path ? err.path : '?', grund: err && err.code ? err.code : String(err && err.message) });
}).finally(() => laufend.delete(p));
laufend.add(p);
}
const basis = von;
async function datei(q, z) {
if (job && job.abgebrochen) throw new Abgebrochen();
const st = await fsp.stat(q);
if (fortschritt) fortschritt.datei = path.relative(path.dirname(basis), q);
await kopiereDatei(q, z, st.size, fortschritt, job);
if (fortschritt) { fortschritt.dateien++; fortschritt.melden(); }
}
async function ordner(q, z, tiefe) {
if (job && job.abgebrochen) throw new Abgebrochen();
if (schwererFehler) throw schwererFehler;
await fsp.mkdir(z, { recursive: true });
if (tiefe >= MAX_TIEFE) return;
const eintraege = await fsp.readdir(q, { withFileTypes: true });
for (const e of eintraege) {
const qq = path.join(q, e.name);
const zz = path.join(z, e.name);
const a = await art(qq, e);
if (a.ordner) await ordner(qq, zz, tiefe + 1);
else if (a.datei) await platz(() => datei(qq, zz));
}
}
let fehler = null;
try {
const st = await fsp.stat(von);
if (st.isDirectory()) await ordner(von, nach, 0);
else {
await fsp.mkdir(path.dirname(nach), { recursive: true });
await platz(() => datei(von, nach));
}
} catch (err) {
fehler = err;
}
// erst zurückkehren, wenn keine Datei mehr geschrieben wird – sonst würde
// das Aufräumen nach einem Abbruch mit noch laufenden Kopien kollidieren
while (laufend.size) await Promise.allSettled([...laufend]);
if (fehler) throw fehler;
if (schwererFehler) throw schwererFehler;
if (job && job.abgebrochen) throw new Abgebrochen();
if (fortschritt) fortschritt.melden(true);
}
module.exports = { zaehle, kopiere, freierPlatz, neuerFortschritt, Abgebrochen };
+12 -4
View File
@@ -301,10 +301,18 @@ function collectArgs(argList, map) {
// führt Vanilla-Version + Loader-Profil zusammen (mainClass + Argumente)
function mergeLoader(vj, lp) {
const merged = Object.assign({}, vj, { mainClass: lp.mainClass || vj.mainClass });
merged.arguments = {
jvm: [...((vj.arguments && vj.arguments.jvm) || []), ...((lp.arguments && lp.arguments.jvm) || [])],
game: [...((vj.arguments && vj.arguments.game) || []), ...((lp.arguments && lp.arguments.game) || [])],
};
if (vj.arguments || lp.arguments) {
merged.arguments = {
jvm: [...((vj.arguments && vj.arguments.jvm) || []), ...((lp.arguments && lp.arguments.jvm) || [])],
game: [...((vj.arguments && vj.arguments.game) || []), ...((lp.arguments && lp.arguments.game) || [])],
};
} else if (lp.minecraftArguments) {
// altes Format (< 1.13, z. B. Forge 1.12.2): Die Loader-Fassung enthält die
// kompletten Spielargumente samt --tweakClass. Früher entstand hier eine
// leere "arguments"-Liste – dann fehlten Klassenpfad (-cp) und Natives im
// Startbefehl, und Java fand net.minecraft.launchwrapper.Launch nicht.
merged.minecraftArguments = lp.minecraftArguments;
}
return merged;
}
+117 -103
View File
@@ -1,103 +1,117 @@
'use strict';
/*
* loaders.js – Mod-Loader-Profile auflösen
* ----------------------------------------
* Fabric & Quilt liefern über ihre Meta-API ein fertiges Profil-JSON
* (mainClass, zusätzliche Libraries als Maven-Koordinaten, Argumente,
* inheritsFrom = Vanilla-Version). Das mergen wir beim Start mit der Vanilla-Version.
*
* Forge & NeoForge liefern KEIN einfaches Profil, sondern einen Installer,
* der Prozessoren ausführt und den Client patcht -> deutlich aufwändiger,
* daher hier noch als „in Arbeit" gekennzeichnet.
*/
const UA = 'AeroMC-Launcher/0.5';
const META = {
fabric: 'https://meta.fabricmc.net/v2',
quilt: 'https://meta.quiltmc.org/v3',
};
async function fetchJson(url) {
const res = await fetch(url, { headers: { 'User-Agent': UA, Accept: 'application/json' } });
if (!res.ok) throw new Error('HTTP ' + res.status + ' bei ' + url);
return res.json();
}
async function fetchText(url) {
const res = await fetch(url, { headers: { 'User-Agent': UA } });
if (!res.ok) throw new Error('HTTP ' + res.status + ' bei ' + url);
return res.text();
}
const FORGE_META = 'https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml';
const NEOFORGE_META = 'https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml';
// NeoForge kodiert die MC-Version im Loader-Namen: 1.21.4 -> 21.4.x
function neoforgePrefix(mcVersion) {
const p = mcVersion.split('.');
return `${p[1]}.${p[2] || '0'}.`;
}
// "group:artifact:version[:classifier]" -> { relPath, url }
function mavenToUrl(base, name) {
const parts = name.split(':');
const group = parts[0].replace(/\./g, '/');
const artifact = parts[1];
const version = parts[2];
const classifier = parts[3] ? '-' + parts[3] : '';
const file = `${artifact}-${version}${classifier}.jar`;
const relPath = `${group}/${artifact}/${version}/${file}`;
const root = (base || 'https://libraries.minecraft.net/').replace(/\/$/, '');
return { relPath, url: `${root}/${relPath}` };
}
// verfügbare Loader-Versionen (für die Anzeige/Auswahl)
async function listLoaderVersions(loader, mcVersion) {
if (loader === 'fabric' || loader === 'quilt') {
const list = await fetchJson(`${META[loader]}/versions/loader/${mcVersion}`);
return list.map((l) => ({ version: l.loader.version, stable: !!l.loader.stable }));
}
if (loader === 'forge') {
const xml = await fetchText(FORGE_META);
const all = [...xml.matchAll(/<version>([^<]+)<\/version>/g)].map((m) => m[1]);
const prefix = mcVersion + '-';
const versions = all.filter((v) => v.startsWith(prefix)).map((v) => v.slice(prefix.length)).reverse();
return versions.map((v, i) => ({ version: v, stable: i === 0 }));
}
if (loader === 'neoforge') {
const xml = await fetchText(NEOFORGE_META);
const all = [...xml.matchAll(/<version>([^<]+)<\/version>/g)].map((m) => m[1]);
const prefix = neoforgePrefix(mcVersion);
const versions = all.filter((v) => v.startsWith(prefix)).reverse();
return versions.map((v, i) => ({ version: v, stable: i === 0 && !/beta/i.test(v) }));
}
return [];
}
// löst das Profil (mainClass, libraries, arguments) auf
async function resolveLoaderProfile(loader, mcVersion, loaderVersion) {
if (loader === 'fabric' || loader === 'quilt') {
let lv = loaderVersion;
if (!lv) {
const list = await fetchJson(`${META[loader]}/versions/loader/${mcVersion}`);
if (!list.length) throw new Error(`Kein ${loader}-Loader für Minecraft ${mcVersion} verfügbar.`);
const stable = list.find((l) => l.loader && l.loader.stable) || list[0];
lv = stable.loader.version;
}
const profile = await fetchJson(`${META[loader]}/versions/loader/${mcVersion}/${lv}/profile/json`);
return { profile, loaderVersion: lv };
}
if (loader === 'forge' || loader === 'neoforge') {
const e = new Error(
`${loader === 'forge' ? 'Forge' : 'NeoForge'}-Installation ist noch in Arbeit ` +
'(braucht Installer + Prozessoren). Fabric und Quilt funktionieren bereits vollständig.',
);
e.notImplemented = true;
throw e;
}
throw new Error('Unbekannter Loader: ' + loader);
}
module.exports = { resolveLoaderProfile, listLoaderVersions, mavenToUrl };
'use strict';
/*
* loaders.js – Mod-Loader-Profile auflösen
* ----------------------------------------
* Fabric & Quilt liefern über ihre Meta-API ein fertiges Profil-JSON
* (mainClass, zusätzliche Libraries als Maven-Koordinaten, Argumente,
* inheritsFrom = Vanilla-Version). Das mergen wir beim Start mit der Vanilla-Version.
*
* Forge & NeoForge liefern KEIN einfaches Profil, sondern einen Installer,
* der Prozessoren ausführt und den Client patcht -> deutlich aufwändiger,
* daher hier noch als „in Arbeit" gekennzeichnet.
*/
const UA = 'AeroMC-Launcher/0.5';
const META = {
fabric: 'https://meta.fabricmc.net/v2',
quilt: 'https://meta.quiltmc.org/v3',
};
async function fetchJson(url) {
const res = await fetch(url, { headers: { 'User-Agent': UA, Accept: 'application/json' } });
if (!res.ok) throw new Error('HTTP ' + res.status + ' bei ' + url);
return res.json();
}
async function fetchText(url) {
const res = await fetch(url, { headers: { 'User-Agent': UA } });
if (!res.ok) throw new Error('HTTP ' + res.status + ' bei ' + url);
return res.text();
}
const FORGE_META = 'https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml';
const NEOFORGE_META = 'https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml';
// NeoForge kodiert die MC-Version im Loader-Namen: 1.21.4 -> 21.4.x
function neoforgePrefix(mcVersion) {
const p = mcVersion.split('.');
return `${p[1]}.${p[2] || '0'}.`;
}
// "group:artifact:version[:classifier]" -> { relPath, url }
function mavenToUrl(base, name) {
const parts = name.split(':');
const group = parts[0].replace(/\./g, '/');
const artifact = parts[1];
const version = parts[2];
const classifier = parts[3] ? '-' + parts[3] : '';
const file = `${artifact}-${version}${classifier}.jar`;
const relPath = `${group}/${artifact}/${version}/${file}`;
const root = (base || 'https://libraries.minecraft.net/').replace(/\/$/, '');
return { relPath, url: `${root}/${relPath}` };
}
// verfügbare Loader-Versionen (für die Anzeige/Auswahl)
// Versionsnummern zahlenweise vergleichen ("36.2.39" > "36.2.4"), neueste zuerst.
// Die Maven-Listen von Forge/NeoForge sind nicht verlässlich sortiert – früher
// landete bei Forge 1.16.5 dadurch die ÄLTESTE Version (36.0.0) vorn.
function neuesteZuerst(a, b) {
const ta = String(a).split(/[.\-+]/), tb = String(b).split(/[.\-+]/);
for (let i = 0; i < Math.max(ta.length, tb.length); i++) {
const x = ta[i] || '0', y = tb[i] || '0';
const nx = /^\d+$/.test(x), ny = /^\d+$/.test(y);
if (nx && ny && Number(x) !== Number(y)) return Number(y) - Number(x);
if (!(nx && ny) && x !== y) return nx ? -1 : ny ? 1 : y.localeCompare(x); // reine Zahl vor "beta"
}
return 0;
}
async function listLoaderVersions(loader, mcVersion) {
if (loader === 'fabric' || loader === 'quilt') {
const list = await fetchJson(`${META[loader]}/versions/loader/${mcVersion}`);
return list.map((l) => ({ version: l.loader.version, stable: !!l.loader.stable }));
}
if (loader === 'forge') {
const xml = await fetchText(FORGE_META);
const all = [...xml.matchAll(/<version>([^<]+)<\/version>/g)].map((m) => m[1]);
const prefix = mcVersion + '-';
const versions = all.filter((v) => v.startsWith(prefix)).map((v) => v.slice(prefix.length)).sort(neuesteZuerst);
return versions.map((v, i) => ({ version: v, stable: i === 0 }));
}
if (loader === 'neoforge') {
const xml = await fetchText(NEOFORGE_META);
const all = [...xml.matchAll(/<version>([^<]+)<\/version>/g)].map((m) => m[1]);
const prefix = neoforgePrefix(mcVersion);
const versions = all.filter((v) => v.startsWith(prefix)).sort(neuesteZuerst);
return versions.map((v, i) => ({ version: v, stable: i === 0 && !/beta/i.test(v) }));
}
return [];
}
// löst das Profil (mainClass, libraries, arguments) auf
async function resolveLoaderProfile(loader, mcVersion, loaderVersion) {
if (loader === 'fabric' || loader === 'quilt') {
let lv = loaderVersion;
if (!lv) {
const list = await fetchJson(`${META[loader]}/versions/loader/${mcVersion}`);
if (!list.length) throw new Error(`Kein ${loader}-Loader für Minecraft ${mcVersion} verfügbar.`);
const stable = list.find((l) => l.loader && l.loader.stable) || list[0];
lv = stable.loader.version;
}
const profile = await fetchJson(`${META[loader]}/versions/loader/${mcVersion}/${lv}/profile/json`);
return { profile, loaderVersion: lv };
}
if (loader === 'forge' || loader === 'neoforge') {
const e = new Error(
`${loader === 'forge' ? 'Forge' : 'NeoForge'}-Installation ist noch in Arbeit ` +
'(braucht Installer + Prozessoren). Fabric und Quilt funktionieren bereits vollständig.',
);
e.notImplemented = true;
throw e;
}
throw new Error('Unbekannter Loader: ' + loader);
}
module.exports = { resolveLoaderProfile, listLoaderVersions, mavenToUrl };
+436 -93
View File
@@ -18,6 +18,8 @@ let ffmpegPfad = null;
try { ffmpegPfad = require('ffmpeg-static'); } catch { /* egal, siehe Rückfall in recording:save */ }
const pkg = require('../package.json');
const store = require('./store');
const startanimation = require('./startanimation');
const fensteraufbau = require('./fensteraufbau');
const services = require('./services');
const launcher = require('./launcher');
const loaders = require('./loaders');
@@ -28,6 +30,10 @@ const icons = require('./icons');
const bedrock = require('./bedrock');
const backup = require('./backup');
const mmcimport = require('./mmcimport');
const kopieren = require('./kopieren');
const moderkennung = require('./moderkennung');
const garderobe = require('./garderobe');
const erfolge = require('./erfolge');
const cfimport = require('./cfimport');
const mrimport = require('./mrimport');
const mcimport = require('./mcimport');
@@ -118,9 +124,9 @@ const POPOUT_SPEC = {
login: { title: 'AeroMC – Konten', width: 460, height: 460, minWidth: 420, minHeight: 380 },
import: { title: 'AeroMC – Import', width: 860, height: 680, minWidth: 640, minHeight: 480 },
instance: { title: 'AeroMC – Instanz', width: 640, height: 760, minWidth: 520, minHeight: 500 },
update: { title: 'AeroMC – Update', width: 460, height: 460, minWidth: 420, minHeight: 380 },
update: { title: 'AeroMC – Update', width: 600, height: 640, minWidth: 440, minHeight: 440 },
record: { title: 'AeroMC – Spielaufzeichnung', width: 960, height: 800, minWidth: 640, minHeight: 560 },
friends: { title: 'AeroMC – Freunde', width: 420, height: 560, minWidth: 360, minHeight: 420 },
friends: { title: 'AeroMC – Freunde', width: 780, height: 620, minWidth: 360, minHeight: 480 },
stats: { title: 'AeroMC – Spielzeit-Statistik', width: 640, height: 560, minWidth: 520, minHeight: 420 },
};
const POPOUT_DEFAULT = { title: 'AeroMC', width: 860, height: 680, minWidth: 640, minHeight: 480 };
@@ -154,14 +160,18 @@ function broadcast(channel, payload) {
}
}
function openPopout(modalId, params) {
// von: Rechteck des angeklickten Knopfs (Bildschirmkoordinaten) – dann wächst
// das Fenster daraus hervor (Optionen → Darstellung → Animationen)
function openPopout(modalId, params, von) {
const wachsen = !!von && store.getSettings().uiAnimationen !== false;
let win = popoutWindows.get(modalId);
if (win && !win.isDestroyed()) {
win.show();
win.focus();
// erneut mit (evtl. neuen) Parametern anzeigen, ohne die Seite neu zu
// laden – sonst ginge z. B. der bisher gesammelte Konsolentext verloren
win.webContents.send('popout:reopen', params || {});
if (wachsen && !win.isVisible()) { fensteraufbau.wachsen(win, von); return; }
win.show();
win.focus();
return;
}
const query = { popout: modalId };
@@ -189,7 +199,7 @@ function openPopout(modalId, params) {
});
popoutWindows.set(modalId, win);
win.loadFile(path.join(__dirname, 'index.html'), { query });
win.once('ready-to-show', () => win.show());
win.once('ready-to-show', () => { if (wachsen) fensteraufbau.wachsen(win, von); else win.show(); });
win.webContents.on('did-fail-load', (_e, code, desc) => {
console.error('Popup-Fenster "' + modalId + '" konnte nicht laden:', code, desc);
});
@@ -212,7 +222,18 @@ let recordingGuardActive = false;
ipcMain.on('recording:guard', (_e, active) => { recordingGuardActive = !!active; });
ipcMain.on('debug:rendererError', (_e, msg) => console.error('[Renderer-Fehler]', msg));
ipcMain.on('popout:open', (_e, modalId, params) => openPopout(modalId, params));
ipcMain.on('popout:open', (e, modalId, params, knopf) => {
// Herkunft fürs Aufwachsen: angeklickter Knopf, sonst die Mitte des Fensters
let von = null;
const quelle = BrowserWindow.fromWebContents(e.sender);
if (quelle && !quelle.isDestroyed() && quelle.isVisible()) {
const cb = quelle.getContentBounds();
von = knopf && Number.isFinite(knopf.x) && Number.isFinite(knopf.y)
? { x: Math.round(cb.x + knopf.x), y: Math.round(cb.y + knopf.y), width: Math.max(8, Math.round(knopf.width)), height: Math.max(8, Math.round(knopf.height)) }
: { x: Math.round(cb.x + cb.width / 2 - 20), y: Math.round(cb.y + cb.height / 2 - 20), width: 40, height: 40 };
}
openPopout(modalId, params, von);
});
// Design gewechselt -> Fensterknöpfe oben rechts umfärben (nur Hauptfenster,
// die Popup-Fenster haben ihren eigenen Kopf)
ipcMain.on('window:titleBar', (e, design) => {
@@ -244,6 +265,10 @@ const VERBINDUNGS_ZEILE = /Connecting to ([^,]+),\s*(\d+)/i;
ipcMain.handle('recording:getServer', (_e, id) => letzteQuickPlayServer.get(id) || null);
const laufendePid = new Map(); // instanceId -> pid des zuletzt gestarteten Minecraft-Prozesses
// Instanzen, deren Spiel gerade läuft (für den pulsierenden Rand der Karte)
const laufendeInstanzen = new Set();
function meldeLaufende() { broadcast('game:laufend', [...laufendeInstanzen]); }
ipcMain.handle('game:laufend', () => [...laufendeInstanzen]);
const kontoPid = new Map(); // kontoId -> pid, für den Ingame-Toast: der muss auf DEM Fenster
// des prüfenden Kontos erscheinen, nicht auf irgendeinem laufenden Minecraft-Fenster
// (sonst sieht z. B. M_Viper eine Meldung über sich selbst, weil zufällig sein
@@ -752,7 +777,7 @@ function windowIcon() {
return undefined; // Electron-Standard, solange kein Logo hinterlegt ist
}
function createWindow(startHidden) {
function createWindow(startHidden, mitStartanimation) {
mainWindow = new BrowserWindow({
width: 1200,
height: 820,
@@ -773,7 +798,7 @@ function createWindow(startHidden) {
},
});
mainWindow.loadFile(path.join(__dirname, 'index.html'));
mainWindow.loadFile(path.join(__dirname, 'index.html'), mitStartanimation ? { query: { startanimation: '1' } } : undefined);
// Bei aktivem Autostart landet das Fenster beim Schließen (X) nur im
// Infobereich, statt AeroMC wirklich zu beenden – sonst wäre der
@@ -928,6 +953,9 @@ app.whenReady().then(() => {
// selbst geholte Laufzeiten: ablegen und bei der Java-Suche mitberücksichtigen
services.setManagedJavaDir(javadl.setJavaDir(path.join(app.getPath('userData'), 'java')));
// Konten verschlüsselt speichern (Windows-DPAPI, an das Benutzerkonto gebunden)
moderkennung.init(app.getPath('userData'));
garderobe.init(app.getPath('userData'));
erfolge.init(app.getPath('userData'));
auth.init(app.getPath('userData'), {
available: () => { try { return safeStorage.isEncryptionAvailable(); } catch { return false; } },
encrypt: (s) => safeStorage.encryptString(s).toString('base64'),
@@ -939,8 +967,13 @@ app.whenReady().then(() => {
// Autostart-Eintrag/Tray mit der Einstellung synchron halten (falls z. B.
// von Hand an der Registry gedreht wurde oder nach einem Update)
applyAutostart(autostartAn);
// Reste von im Hintergrund gelöschten Instanzen wegräumen (siehe store.entsorgeOrdner)
store.raeumePapierkorbAuf();
const startHidden = autostartAn && process.argv.includes('--hidden');
createWindow(startHidden);
// Startanimation: Fenster erst verdeckt anlegen, die Animation zeigt es dann
const mitAnimation = !startHidden && store.getSettings().startAnimation !== false;
createWindow(startHidden || mitAnimation, mitAnimation);
if (mitAnimation) startanimation.spielen(mainWindow, hauptfensterBereit, store.getSettings().theme);
// Fenstererfassung für die Spielaufzeichnung ohne nativen Auswahldialog:
// das vom Renderer per recording:setCaptureTarget gewählte Fenster wird
@@ -1108,7 +1141,14 @@ ipcMain.handle('mods:search', async (_e, opts) => {
}
});
ipcMain.handle('mods:list', (_e, id, art) => store.listMods(id, art || 'mod'));
// Liste der installierten Inhalte – von Hand kopierte/importierte Mods werden
// per Prüfsumme bei Modrinth erkannt (Katalog zeigt dann „✓ Installiert“)
async function modsMitErkennung(id, art) {
const liste = store.listMods(id, art);
try { return await moderkennung.ergaenze(liste, store.inhaltsDir(id, art)); }
catch { return liste; }
}
ipcMain.handle('mods:list', (_e, id, art) => modsMitErkennung(id, art || 'mod'));
ipcMain.handle('mods:install', (_e, id, project, art) => installiereInhalt(id, project, art));
@@ -1128,7 +1168,7 @@ async function installiereInhalt(id, project, art) {
}
const modsDir = store.inhaltsDir(id, art || 'mod');
const done = new Set(store.listMods(id, art || 'mod').map((m) => m.projectId));
const done = new Set((await modsMitErkennung(id, art || 'mod')).map((m) => m.projectId));
const added = [];
let firstError = null;
const istCf = !!(project && (project.source === 'curseforge' || String(project.projectId || '').startsWith('cf-')));
@@ -1187,6 +1227,7 @@ async function installiereInhalt(id, project, art) {
await installOne(project, false);
if (!added.length) return { ok: false, message: firstError || 'Installation fehlgeschlagen.' };
pruefeErfolge();
return { ok: true, entry: added[0], added, depCount: added.length - 1 };
}
@@ -1545,6 +1586,16 @@ ipcMain.handle('crash:fix', async (_e, id, aktion) => {
broadcast('instances:changed');
return { ok: true, text: 'Beim nächsten Start wählt AeroMC das passende Java und lädt es bei Bedarf herunter.' };
}
case 'forge-aktualisieren': {
// neueste Forge-Version für dieselbe Minecraft-Version eintragen
const liste = await loaders.listLoaderVersions(inst.minecraft.loader || 'forge', inst.minecraft.version);
const neueste = liste && liste[0] && liste[0].version;
if (!neueste) return { ok: false, message: 'Keine neuere Forge-Version gefunden.' };
if (neueste === inst.minecraft.loaderVersion) return { ok: false, message: `Forge ${neueste} ist bereits die neueste Version für Minecraft ${inst.minecraft.version}.` };
store.updateInstance(id, { minecraft: { loaderVersion: neueste } });
broadcast('instances:changed');
return { ok: true, text: `Forge auf ${neueste} aktualisiert (vorher ${inst.minecraft.loaderVersion || 'unbekannt'}). Beim nächsten Start wird es eingerichtet.` };
}
case 'mods-ordner': {
const dir = store.inhaltsDir(id, 'mod');
fs.mkdirSync(dir, { recursive: true });
@@ -1559,6 +1610,115 @@ ipcMain.handle('crash:fix', async (_e, id, aktion) => {
}
});
// ---------- Erfolge ----------
// Werte aus dem, was der Launcher weiß; mehrere Anlässe kurz hintereinander
// werden zu einer Prüfung zusammengefasst.
let erfolgMerker = {};
let erfolgFreunde = 0;
let erfolgTimer = null;
function pruefeErfolge(merker) {
Object.assign(erfolgMerker, merker || {});
clearTimeout(erfolgTimer);
erfolgTimer = setTimeout(() => {
try {
const alle = store.listInstances();
const gespielt = alle.filter((i) => (i.launchCount || 0) > 0);
let mods = 0;
for (const i of alle) {
if (!i.minecraft || !i.minecraft.loader || i.minecraft.loader === 'vanilla') continue;
try { mods += store.listMods(i.id, 'mod').filter((m) => !m.fehlt).length; } catch { /* egal */ }
}
const werte = {
starts: alle.reduce((s, i) => s + (i.launchCount || 0), 0),
sekunden: alle.reduce((s, i) => s + (i.totalPlaySeconds || 0), 0),
instanzen: alle.length,
versionen: new Set(gespielt.map((i) => i.minecraft && i.minecraft.version).filter(Boolean)).size,
mods,
modpacks: alle.filter((i) => i.modpack && (i.modpack.projectId || i.modpack.name)).length,
freunde: erfolgFreunde,
};
const merkerJetzt = erfolgMerker;
erfolgMerker = {};
const { neu, ersteAuswertung } = erfolge.pruefe(werte, merkerJetzt);
if (!neu.length) return;
broadcast('erfolge:neu', { liste: neu, ersteAuswertung });
if (neu.length === 1) notify('Erfolg erzielt!', `${neu[0].titel} – ${neu[0].text}`);
else notify(`${neu.length} Erfolge erzielt!`, neu.map((e) => e.titel).join(', '));
} catch { /* Erfolge sind Zierde */ }
}, 1500);
}
ipcMain.handle('erfolge:liste', () => erfolge.liste());
// Hauptfenster bereit -> einmal prüfen (u. a. für bestehende Nutzer nach dem Update)
hauptfensterBereit.then(() => setTimeout(async () => {
// Freunde einmal zählen (sonst erst beim Öffnen des Freunde-Fensters)
try {
const konto = store.getSettings().friendsEnabled !== false ? aktivesFreundeKonto() : null;
if (konto) {
const res = await friends.getFriends(konto.uuid);
if (res.ok && Array.isArray(res.data)) erfolgFreunde = res.data.length;
}
} catch { /* offline */ }
pruefeErfolge();
}, 4000));
// ---------- Leistungs-Paket: bewährte Performance-Mods mit einem Klick ----------
// Je Platz die erste Mod, die es für Version + Loader gibt (z. B. Sodium, sonst
// Embeddium). Kurznamen werden erst in Modrinth-IDs umgewandelt – installierte
// Mods (auch von Hand kopierte, siehe moderkennung) tragen diese IDs.
const LEISTUNGS_PAKET = [
{ zweck: 'Grafik', mods: ['sodium', 'embeddium', 'rubidium'], grafik: true },
{ zweck: 'Spiellogik', mods: ['lithium', 'radium'] },
{ zweck: 'Arbeitsspeicher', mods: ['ferrite-core'] },
{ zweck: 'Unsichtbares nicht zeichnen', mods: ['entityculling'] },
{ zweck: 'Oberfläche/Text', mods: ['immediatelyfast'] },
{ zweck: 'Ladezeit', mods: ['modernfix'] },
{ zweck: 'Weniger Last im Hintergrund', mods: ['dynamic-fps'] },
];
ipcMain.handle('mods:leistungsPaket', async (_e, id) => {
const inst = store.getInstance(id);
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
const loader = inst.minecraft.loader;
if (!loader || loader === 'vanilla' || loader === 'optifine') {
return { ok: false, message: 'Das Leistungs-Paket braucht einen Mod-Loader (Fabric, Quilt, Forge oder NeoForge).' };
}
// Kurznamen -> Modrinth-IDs (ein Abruf für alle)
const slugs = [...new Set(LEISTUNGS_PAKET.flatMap((p) => p.mods))];
let ids = {};
try {
const res = await fetch(`https://api.modrinth.com/v2/projects?ids=${encodeURIComponent(JSON.stringify(slugs))}`, { headers: { 'User-Agent': 'AeroMC-Launcher' } });
for (const p of await res.json()) ids[p.slug] = { id: p.id, title: p.title };
} catch { return { ok: false, message: 'Modrinth ist gerade nicht erreichbar.' }; }
const vorhanden = await modsMitErkennung(id, 'mod');
const hatOptifine = vorhanden.some((m) => m.enabled !== false && /optifine|optifabric/i.test(String(m.filename) + ' ' + String(m.title)));
const installiert = new Set(vorhanden.map((m) => m.projectId).filter(Boolean));
const ergebnis = { neu: [], schonDa: [], nichtVerfuegbar: [], uebersprungen: [] };
let nr = 0;
for (const platz of LEISTUNGS_PAKET) {
nr++;
if (platz.grafik && hatOptifine) { ergebnis.uebersprungen.push(`${platz.zweck} (OptiFine ist installiert)`); continue; }
const kandidaten = platz.mods.map((s) => ids[s]).filter(Boolean);
const da = kandidaten.find((k) => installiert.has(k.id));
if (da) { ergebnis.schonDa.push(da.title); continue; }
let geschafft = false;
for (const k of kandidaten) {
broadcast('mods:paketFortschritt', { nr, von: LEISTUNGS_PAKET.length, titel: k.title });
const r = await installiereInhalt(id, { projectId: k.id, title: k.title }, 'mod').catch((err) => ({ ok: false, message: err.message }));
if (r.ok) {
ergebnis.neu.push(k.title);
for (const a of r.added || []) if (a.projectId) installiert.add(a.projectId);
geschafft = true;
break;
}
}
if (!geschafft) ergebnis.nichtVerfuegbar.push(kandidaten.map((k) => k.title).join(' / ') || platz.mods[0]);
}
if (ergebnis.neu.length) broadcast('instances:changed');
if (ergebnis.neu.length || ergebnis.schonDa.length) pruefeErfolge({ leistung: true });
return Object.assign({ ok: true }, ergebnis);
});
// nach einer Lösung gleich erneut starten (Start läuft im Hauptfenster)
ipcMain.handle('crash:relaunch', (_e, id) => {
if (!store.getInstance(id)) return false;
@@ -1833,6 +1993,7 @@ ipcMain.handle('skin:apply', async (_e, dateiPfad, variante) => {
const token = await aktivesToken();
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
try {
await sichereAktuellenSkin(token);
const res = await skins.setzeSkin(token, dateiPfad, variante);
if (res && res.ok) { letzterSkinWechsel = Date.now(); armformCache.clear(); broadcast('skin:changed'); }
return res;
@@ -1844,6 +2005,7 @@ ipcMain.handle('skin:reset', async () => {
const token = await aktivesToken();
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
try {
await sichereAktuellenSkin(token);
const res = await skins.setzeSkinZurueck(token);
if (res && res.ok) { letzterSkinWechsel = Date.now(); armformCache.clear(); broadcast('skin:changed'); }
return res;
@@ -1851,6 +2013,106 @@ ipcMain.handle('skin:reset', async () => {
catch (err) { return { ok: false, message: 'Zurücksetzen fehlgeschlagen: ' + err.message }; }
});
// ---------- Passt die Armform zum Skin? ----------
// Steht das Konto auf "schmal", die Skin-Datei ist aber für breite Arme gemalt
// (oder umgekehrt), sieht man im Spiel gequetschte Arme bzw. dunkle Streifen.
async function aktuelleSkinDatei(token) {
const p = await profilMitSpielSkin(token);
if (!p.ok || !p.skinUrl) return null;
const res = await fetch(p.skinUrl);
if (!res.ok) return null;
const daten = Buffer.from(await res.arrayBuffer());
const bild = nativeImage.createFromBuffer(daten);
const { width, height } = bild.getSize();
return { daten, konto: p.variante === 'slim' ? 'slim' : 'classic', datei: skins.armformDerTextur(bild.toBitmap(), width, height) };
}
ipcMain.handle('skin:armformPruefen', async () => {
const token = await aktivesToken();
if (!token) return { ok: false };
try {
const s = await aktuelleSkinDatei(token);
if (!s) return { ok: false };
return { ok: true, konto: s.konto, datei: s.datei, passt: !s.datei || s.datei === s.konto };
} catch { return { ok: false }; }
});
ipcMain.handle('skin:armformKorrigieren', async () => {
const token = await aktivesToken();
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
const tmp = path.join(app.getPath('temp'), 'aeromc-skin-armform.png');
try {
const s = await aktuelleSkinDatei(token);
if (!s || !s.datei) return { ok: false, message: 'Die Armform der Skin-Datei ließ sich nicht eindeutig bestimmen.' };
if (s.datei === s.konto) return { ok: true, variante: s.konto, unveraendert: true };
fs.writeFileSync(tmp, s.daten);
// derselbe Skin, nur mit der passenden Armform neu hochgeladen
const res = await skins.setzeSkin(token, tmp, s.datei);
if (res && res.ok) { letzterSkinWechsel = Date.now(); armformCache.clear(); broadcast('skin:changed', { quelle: 'armform' }); }
return res && res.ok ? { ok: true, variante: s.datei } : res;
} catch (err) {
return { ok: false, message: 'Umstellen fehlgeschlagen: ' + err.message };
} finally {
try { fs.unlinkSync(tmp); } catch { /* egal */ }
}
});
// ---------- Garderobe (gespeicherte Skins und Umhänge) ----------
// Bisherigen Skin vor einem Wechsel sichern – Mojang merkt sich keine alten Skins.
// Klappt das nicht (offline …), wird trotzdem gewechselt.
async function sichereAktuellenSkin(token, name, automatisch = true) {
try {
const s = await aktuelleSkinDatei(token);
if (!s) return null;
// Armform aus der Datei, wenn eindeutig – sonst die des Kontos (so kommt beim
// Wieder-Anziehen nicht ein falsch eingestellter, gequetschter Zustand zurück)
return garderobe.hinzufuegen({ art: 'skin', daten: s.daten, variante: s.datei || s.konto, automatisch,
name: name || 'Gesichert am ' + new Date().toLocaleDateString('de-DE') });
} catch { return null; }
}
ipcMain.handle('garderobe:liste', () => garderobe.liste());
ipcMain.handle('garderobe:skinSpeichern', async (_e, name) => {
const token = await aktivesToken();
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
const r = await sichereAktuellenSkin(token, name, false);
if (!r) return { ok: false, message: 'Dein Konto nutzt den Standard-Skin oder er ist gerade nicht abrufbar.' };
return { ok: true, neu: r.neu, name: r.eintrag.name };
});
ipcMain.handle('garderobe:umhangSpeichern', async (_e, name) => {
const konto = aktivesFreundeKonto();
if (!konto) return { ok: false, message: 'Kein angemeldetes Konto.' };
const r = await umhangFuer(konto.uuid);
if (!r || !r.dataUri || r.quelle !== 'aeromc') return { ok: false, message: 'Du trägst gerade keinen eigenen AeroMC-Umhang.' };
const daten = Buffer.from(r.dataUri.split(',')[1], 'base64');
const e = garderobe.hinzufuegen({ art: 'umhang', daten, name: name || 'Umhang', automatisch: false });
return { ok: true, neu: e.neu, name: e.eintrag.name };
});
ipcMain.handle('garderobe:anziehen', async (_e, id) => {
const eintrag = garderobe.holen(id);
if (!eintrag) return { ok: false, message: 'Eintrag nicht gefunden.' };
if (eintrag.art === 'umhang') {
const konto = aktivesFreundeKonto();
if (!konto) return { ok: false, message: 'Kein angemeldetes Konto.' };
const base64 = fs.readFileSync(eintrag.pfad).toString('base64');
const res = await friends.uploadCape(konto.uuid, base64);
if (res.ok) { umhangCache.clear(); broadcast('skin:changed', { quelle: 'umhang' }); }
return res.ok ? { ok: true } : umhangFehler(res);
}
const token = await aktivesToken();
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
await sichereAktuellenSkin(token);
const res = await skins.setzeSkin(token, eintrag.pfad, eintrag.variante);
if (res && res.ok) { letzterSkinWechsel = Date.now(); armformCache.clear(); broadcast('skin:changed', { quelle: 'garderobe' }); }
return res && res.ok ? { ok: true } : (res || { ok: false, message: 'Skin konnte nicht gesetzt werden.' });
});
ipcMain.handle('garderobe:umbenennen', (_e, id, name) => ({ ok: garderobe.umbenennen(id, name) }));
ipcMain.handle('garderobe:loeschen', (_e, id) => ({ ok: garderobe.loeschen(id) }));
// ---------- Skin-Editor ----------
// Der Editor malt in eine Canvas – fremde Bildadressen (textures.minecraft.net)
// würden sie "verunreinigen" und das Auslesen der Pixel verbieten. Deshalb holt
@@ -1994,7 +2256,7 @@ ipcMain.handle('cape:upload', async (_e, base64) => {
if (!konto) return { ok: false, message: 'Kein angemeldetes Konto.' };
if (!umhangPngAusBase64(base64)) return { ok: false, message: 'Ungültiger Umhang (PNG mit 64×32 bis 1024×512 Pixeln nötig).' };
const res = await friends.uploadCape(konto.uuid, base64);
if (res.ok) { umhangCache.clear(); broadcast('skin:changed', { quelle: 'umhang' }); }
if (res.ok) { umhangCache.clear(); broadcast('skin:changed', { quelle: 'umhang' }); pruefeErfolge({ umhang: true }); }
return res.ok ? { ok: true, cape: res.data && res.data.cape } : umhangFehler(res);
});
@@ -2077,8 +2339,9 @@ ipcMain.handle('skin:applyData', async (_e, base64, variante) => {
const tmp = path.join(app.getPath('temp'), 'aeromc-skin-editor.png');
try {
fs.writeFileSync(tmp, daten);
await sichereAktuellenSkin(token);
const res = await skins.setzeSkin(token, tmp, variante);
if (res && res.ok) { letzterSkinWechsel = Date.now(); armformCache.clear(); broadcast('skin:changed', { quelle: 'editor' }); }
if (res && res.ok) { letzterSkinWechsel = Date.now(); armformCache.clear(); broadcast('skin:changed', { quelle: 'editor' }); pruefeErfolge({ 'skin-editor': true }); }
return res;
} catch (err) {
return { ok: false, message: 'Skin konnte nicht gesetzt werden: ' + err.message };
@@ -2660,18 +2923,49 @@ function aktivesFreundeKonto() {
ipcMain.handle('friends:enabled', () => store.getSettings().friendsEnabled !== false);
/*
* Der Server kennt den Namen eines Spielers nur, wenn dieser schon einmal über
* AeroMC gespielt hat (Herzschlag). Wer vorher eine Anfrage schickt, käme beim
* Empfänger nur als UUID an – deshalb fehlende Namen bei Mojang nachschlagen
* (öffentliches Profil, 1 Stunde gemerkt).
*/
const spielerNamen = new Map(); // uuid -> { name, zeit }
async function spielerName(uuid) {
const id = String(uuid || '').replace(/-/g, '').toLowerCase();
if (!/^[0-9a-f]{32}$/.test(id)) return null;
const gemerkt = spielerNamen.get(id);
if (gemerkt && Date.now() - gemerkt.zeit < 60 * 60 * 1000) return gemerkt.name;
try {
const res = await fetch('https://sessionserver.mojang.com/session/minecraft/profile/' + id, { headers: { 'User-Agent': 'AeroMC-Launcher' } });
if (!res.ok) return gemerkt ? gemerkt.name : null;
const name = (await res.json()).name || null;
spielerNamen.set(id, { name, zeit: Date.now() });
return name;
} catch {
return gemerkt ? gemerkt.name : null;
}
}
async function mitNamen(liste) {
const eintraege = Array.isArray(liste) ? liste : [];
await Promise.all(eintraege.map(async (e) => {
if (e && !e.username && e.uuid) e.username = await spielerName(e.uuid);
}));
return eintraege;
}
ipcMain.handle('friends:list', async () => {
const konto = aktivesFreundeKonto();
if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' };
const res = await friends.getFriends(konto.uuid);
return res.ok ? { ok: true, list: res.data || [] } : res;
if (res.ok && Array.isArray(res.data) && res.data.length > erfolgFreunde) { erfolgFreunde = res.data.length; pruefeErfolge(); }
return res.ok ? { ok: true, list: await mitNamen(res.data) } : res;
});
ipcMain.handle('friends:requests', async () => {
const konto = aktivesFreundeKonto();
if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' };
const res = await friends.getRequests(konto.uuid);
return res.ok ? { ok: true, list: res.data || [] } : res;
return res.ok ? { ok: true, list: await mitNamen(res.data) } : res;
});
ipcMain.handle('friends:add', async (_e, targetUsername) => {
@@ -2685,7 +2979,7 @@ ipcMain.handle('friends:sentRequests', async () => {
const konto = aktivesFreundeKonto();
if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' };
const res = await friends.getSentRequests(konto.uuid);
return res.ok ? { ok: true, list: res.data || [] } : res;
return res.ok ? { ok: true, list: await mitNamen(res.data) } : res;
});
ipcMain.handle('friends:cancel', async (_e, otherUuid) => {
@@ -2758,7 +3052,12 @@ ipcMain.handle('instances:update', (_e, id, patch) => {
ipcMain.handle('instances:delete', (_e, id) => store.deleteInstance(id));
ipcMain.handle('instances:duplicate', (_e, id) => store.duplicateInstance(id));
ipcMain.handle('instances:duplicate', async (_e, id) => {
const kopie = store.duplicateInstance(id, true);
try { await kopieren.kopiere(store.gameDir(id), store.gameDir(kopie.id), null, null); }
catch { /* Quelle evtl. leer */ }
return kopie;
});
ipcMain.handle('instances:openFolder', (_e, id) => {
const p = store.instancePath(id);
@@ -3479,6 +3778,22 @@ ipcMain.handle('servers:shortcut', (_e, id, ip, name, wunschName) => {
* Instanz), unabhängig davon ob der Pfad aus einem Dateidialog oder per
* Drag&Drop aufs Fenster kommt.
*/
// Inhalt eines (eigenen, temporären) Ordners in den Zielordner übernehmen:
// verschieben statt kopieren – auf demselben Laufwerk ist das sofort erledigt,
// sonst (anderes Laufwerk) wird im Hintergrund kopiert. Nichts davon blockiert.
async function verschiebeInhalt(quelle, ziel, nur) {
await fs.promises.mkdir(ziel, { recursive: true });
for (const name of nur || await fs.promises.readdir(quelle)) {
const von = path.join(quelle, name);
const nach = path.join(ziel, name);
try {
await fs.promises.rename(von, nach);
} catch {
await kopieren.kopiere(von, nach, null, null);
}
}
}
async function importInstanceZipFromPath(filePath) {
const tmp = path.join(app.getPath('temp'), 'vn-import-' + Date.now());
try {
@@ -3498,8 +3813,10 @@ async function importInstanceZipFromPath(filePath) {
if (!fs.existsSync(path.join(base, 'instance.json'))) {
const mmc = mmcimport.scanSingle(tmp);
if (mmc.ok) {
const done = mmcimport.importOne(store, mmc.instance, true);
return { ok: true, instance: store.getInstance(done.id), quelle: 'multimc', copied: done.copied };
const done = mmcimport.importOne(store, mmc.instance, false);
const quellen = mmcimport.kopierQuellen(mmc.instance);
if (quellen.length) await verschiebeInhalt(mmc.instance.gameDir, store.gameDir(done.id), quellen.map((q) => q.name));
return { ok: true, instance: store.getInstance(done.id), quelle: 'multimc', copied: quellen.length > 0 };
}
throw new Error('Keine gültige Instanz im Archiv (weder instance.json noch instance.cfg).');
}
@@ -3513,7 +3830,7 @@ async function importInstanceZipFromPath(filePath) {
if (imported.java) store.updateInstance(created.id, { java: imported.java });
const mcSrc = path.join(base, 'minecraft');
if (fs.existsSync(mcSrc)) fs.cpSync(mcSrc, store.gameDir(created.id), { recursive: true });
if (fs.existsSync(mcSrc)) await verschiebeInhalt(mcSrc, store.gameDir(created.id));
const modsSrc = path.join(base, 'mods.json');
if (fs.existsSync(modsSrc)) fs.copyFileSync(modsSrc, path.join(store.instancePath(created.id), 'mods.json'));
if (imported.customImage && fs.existsSync(path.join(base, imported.customImage))) {
@@ -3524,7 +3841,8 @@ async function importInstanceZipFromPath(filePath) {
} catch (err) {
return { ok: false, message: 'Import fehlgeschlagen: ' + err.message };
} finally {
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* egal */ }
// Reste im Hintergrund löschen (bei großen Archiven sonst minutenlang blockiert)
fs.promises.rm(tmp, { recursive: true, force: true }).catch(() => { /* egal */ });
}
}
@@ -3837,6 +4155,8 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
const res = await launcher.prepareAndLaunch(opts, (evt) => {
if (evt.phase === 'launch') {
launchStart = Date.now();
laufendeInstanzen.add(id);
meldeLaufende();
if (!gezaehlt) { gezaehlt = true; kontoSpieltJetzt(kontoId); }
discordpresence.setActivity({
details: 'Spielt Minecraft',
@@ -3872,6 +4192,8 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
herzschlagJetzt = null;
if (kontoId && kontoInstanz.get(kontoId) === id) kontoInstanz.delete(kontoId);
laufendePid.delete(id); // für die "Nur Minecraft"-Tonaufnahme: Prozess ist weg
laufendeInstanzen.delete(id);
meldeLaufende();
// beim nächsten Start dieses Kontos sollen bereits online befindliche
// Freunde wieder gemeldet werden
if (kontoId) { kontoPid.delete(kontoId); freundeOnlineBaseline.delete(kontoId); }
@@ -3886,6 +4208,7 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
}
const cur = store.getInstance(id);
if (cur) store.updateInstance(id, { totalPlaySeconds: (cur.totalPlaySeconds || 0) + secs });
pruefeErfolge();
}
}
if (evt.phase === 'log') {
@@ -3912,12 +4235,14 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
if (res.ok) {
const aktuell = store.getInstance(id);
store.updateInstance(id, { lastPlayed: new Date().toISOString(), launchCount: ((aktuell && aktuell.launchCount) || 0) + 1 });
pruefeErfolge();
}
if (res.ok && modUpdates && modUpdates.erneuert && modUpdates.erneuert.length) res.modUpdates = modUpdates.erneuert.length;
if (res.ok && res.pid) laufendePid.set(id, res.pid);
if (res.ok && res.pid && kontoId) { kontoPid.set(kontoId, res.pid); kontoInstanz.set(kontoId, id); aktualisiereFreundeTastenkuerzel(); }
return res;
} catch (err) {
if (laufendeInstanzen.delete(id)) meldeLaufende();
if (gezaehlt) { gezaehlt = false; kontoSpieltNichtMehr(kontoId); discordpresence.clearActivity(); }
if (freundeHerzschlag) { clearInterval(freundeHerzschlag); freundeHerzschlag = null; }
if (herzschlagJetzt) { laufendeHerzschlaege.delete(herzschlagJetzt); herzschlagJetzt = null; }
@@ -3997,20 +4322,91 @@ ipcMain.handle('mmc:scan', (_e, folder) => {
catch (err) { return { ok: false, reason: err.message }; }
});
ipcMain.handle('mmc:import', (_e, entries, copyData) => {
// ---------- Instanzen aus anderen Launchern übernehmen ----------
/*
* Gemeinsamer Ablauf für alle Launcher-Importe. Früher wurde synchron kopiert –
* bei vielen Gigabyte stand der Launcher dann komplett still. Jetzt:
* 1. Größe aller ausgewählten Instanzen ermitteln (asynchron)
* 2. prüfen, ob auf dem Laufwerk genug Platz frei ist
* 3. Instanz für Instanz anlegen und im Hintergrund kopieren
* Fortschritt geht als 'import:fortschritt' an alle Fenster; 'import:abbrechen'
* stoppt den Lauf, die gerade halb kopierte Instanz wird wieder entfernt.
*/
let laufenderImport = null;
const GB = 1024 * 1024 * 1024;
const zuGb = (b) => (b / GB).toLocaleString('de-DE', { maximumFractionDigits: 1 });
ipcMain.handle('import:abbrechen', () => {
if (laufenderImport) laufenderImport.abgebrochen = true;
return !!laufenderImport;
});
async function fuehreImportAus(modul, entries, copyData, nachher) {
if (laufenderImport) return { ok: false, message: 'Es läuft bereits ein Import – bitte warten oder ihn abbrechen.' };
const job = { abgebrochen: false };
laufenderImport = job;
const liste = entries || [];
const done = [];
const failed = [];
for (const entry of entries || []) {
try {
const imported = mmcimport.importOne(store, entry, copyData !== false);
done.push(imported);
const melde = (daten) => broadcast('import:fortschritt', daten);
let fortschritt = null;
try {
// 1) Was wird kopiert und wie viel?
const plaene = liste.map((entry) => ({ entry, quellen: copyData ? modul.kopierQuellen(entry) : [] }));
let gesamt = { bytes: 0, dateien: 0 };
if (copyData) {
melde({ phase: 'zaehlen', dateien: 0, bytes: 0, instanzen: liste.length });
try {
gesamt = await kopieren.zaehle(plaene.flatMap((p) => p.quellen.map((q) => q.von)), job,
(s) => melde({ phase: 'zaehlen', dateien: s.dateien, bytes: s.bytes, instanzen: liste.length }));
} catch (err) {
if (err.abgebrochen) return { ok: true, imported: [], failed: [], abgebrochen: true, uebersprungen: [] };
throw err;
}
// 2) Reicht der Platz? (etwas Luft für Windows lassen)
const frei = await kopieren.freierPlatz(store.getInstancesDir());
if (frei !== null && gesamt.bytes > frei - 512 * 1024 * 1024) {
return { ok: false, message: `Nicht genug Speicherplatz: Die Instanzen brauchen ${zuGb(gesamt.bytes)} GB, auf dem Laufwerk sind nur ${zuGb(frei)} GB frei. Weniger Instanzen auswählen oder unter Optionen → Allgemein einen anderen Instanz-Ordner wählen.` };
}
}
catch (err) { failed.push({ name: entry.name, message: err.message }); }
broadcast('mmc:progress', { current: done.length + failed.length, total: entries.length });
// 3) Instanz für Instanz – ein gemeinsamer Zähler über alle Instanzen
let kopf = { phase: 'kopieren' };
fortschritt = kopieren.neuerFortschritt(gesamt, (p) => melde(Object.assign({}, kopf, p)));
const f = fortschritt;
for (let i = 0; i < plaene.length; i++) {
if (job.abgebrochen) break;
const { entry, quellen } = plaene[i];
kopf = { phase: 'kopieren', instanz: entry.name, nr: i + 1, von: plaene.length };
f.melden(true);
let angelegt = null;
try {
angelegt = modul.importOne(store, entry, false); // nur Angaben – kopiert wird hier
for (const q of quellen) {
await kopieren.kopiere(q.von, path.join(store.gameDir(angelegt.id), q.name), f, job);
}
angelegt.copied = quellen.length > 0;
if (nachher) await nachher(entry, angelegt, kopf);
done.push(angelegt);
} catch (err) {
// halb kopierte Instanz nicht liegen lassen
if (angelegt) { try { store.deleteInstance(angelegt.id); } catch { /* Aufräumen ist kein Muss */ } }
if (!err.abgebrochen) {
failed.push({ name: entry.name, message: err.code === 'ENOSPC' ? 'Speicherplatz voll' : err.message });
if (err.code === 'ENOSPC') break;
}
}
}
} finally {
laufenderImport = null;
}
if (done.length) broadcast('instances:changed');
return { ok: true, imported: done, failed };
});
return {
ok: true, imported: done, failed, abgebrochen: job.abgebrochen,
uebersprungen: fortschritt ? fortschritt.uebersprungen.slice(0, 50) : [],
};
}
ipcMain.handle('mmc:import', (_e, entries, copyData) => fuehreImportAus(mmcimport, entries, copyData !== false));
ipcMain.handle('cf:pickFolder', async (e) => {
const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
@@ -4025,20 +4421,7 @@ ipcMain.handle('cf:scan', (_e, folder) => {
catch (err) { return { ok: false, reason: err.message }; }
});
ipcMain.handle('cf:import', (_e, entries, copyData) => {
const done = [];
const failed = [];
for (const entry of entries || []) {
try {
const imported = cfimport.importOne(store, entry, copyData !== false);
done.push(imported);
}
catch (err) { failed.push({ name: entry.name, message: err.message }); }
broadcast('cf:progress', { current: done.length + failed.length, total: entries.length });
}
if (done.length) broadcast('instances:changed');
return { ok: true, imported: done, failed };
});
ipcMain.handle('cf:import', (_e, entries, copyData) => fuehreImportAus(cfimport, entries, copyData !== false));
// ---------- Modrinth App ----------
ipcMain.handle('mr:pickFolder', async (e) => {
@@ -4100,26 +4483,12 @@ async function verknuepfeModrinthModpack(instId, link) {
return true;
}
ipcMain.handle('mr:import', async (_e, entries, copyData) => {
const done = [];
const failed = [];
const liste = entries || [];
for (const entry of liste) {
try {
const imported = mrimport.importOne(store, entry, copyData !== false);
if (entry.modpack && entry.modpack.projectId && entry.modpack.versionId) {
broadcast('mr:progress', { current: done.length + failed.length, total: liste.length, detail: 'Modpack-Verknüpfung für ' + entry.name });
try { imported.modpack = await verknuepfeModrinthModpack(imported.id, entry.modpack); }
catch { imported.modpack = false; }
}
done.push(imported);
}
catch (err) { failed.push({ name: entry.name, message: err.message }); }
broadcast('mr:progress', { current: done.length + failed.length, total: liste.length });
}
if (done.length) broadcast('instances:changed');
return { ok: true, imported: done, failed };
});
ipcMain.handle('mr:import', (_e, entries, copyData) => fuehreImportAus(mrimport, entries, copyData !== false, async (entry, imported, kopf) => {
if (!(entry.modpack && entry.modpack.projectId && entry.modpack.versionId)) return;
broadcast('import:fortschritt', Object.assign({}, kopf, { phase: 'modpack' }));
try { imported.modpack = await verknuepfeModrinthModpack(imported.id, entry.modpack); }
catch { imported.modpack = false; }
}));
ipcMain.handle('mc:pickFolder', async (e) => {
const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
@@ -4134,20 +4503,7 @@ ipcMain.handle('mc:scan', (_e, folder) => {
catch (err) { return { ok: false, reason: err.message }; }
});
ipcMain.handle('mc:import', (_e, entries, copyData) => {
const done = [];
const failed = [];
for (const entry of entries || []) {
try {
const imported = mcimport.importOne(store, entry, copyData !== false);
done.push(imported);
}
catch (err) { failed.push({ name: entry.name, message: err.message }); }
broadcast('mc:progress', { current: done.length + failed.length, total: entries.length });
}
if (done.length) broadcast('instances:changed');
return { ok: true, imported: done, failed };
});
ipcMain.handle('mc:import', (_e, entries, copyData) => fuehreImportAus(mcimport, entries, copyData !== false));
ipcMain.handle('technic:pickFolder', async (e) => {
const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
@@ -4162,20 +4518,7 @@ ipcMain.handle('technic:scan', (_e, folder) => {
catch (err) { return { ok: false, reason: err.message }; }
});
ipcMain.handle('technic:import', (_e, entries, copyData) => {
const done = [];
const failed = [];
for (const entry of entries || []) {
try {
const imported = technicimport.importOne(store, entry, copyData !== false);
done.push(imported);
}
catch (err) { failed.push({ name: entry.name, message: err.message }); }
broadcast('technic:progress', { current: done.length + failed.length, total: entries.length });
}
if (done.length) broadcast('instances:changed');
return { ok: true, imported: done, failed };
});
ipcMain.handle('technic:import', (_e, entries, copyData) => fuehreImportAus(technicimport, entries, copyData !== false));
// ---- Technic-Plattform: Modpack-Katalog durchsuchen und direkt installieren ----
+20 -5
View File
@@ -173,16 +173,31 @@ function scan(inputPath) {
return { ok: true, rootDir, count: list.length, instances: list };
}
function kopierNamen(sourceDir, usesDefaultGameDir) {
return fs.readdirSync(sourceDir).filter((name) => {
if (/^launcher_profiles.*\.json$/i.test(name)) return false;
if (name === 'versions' || name === 'libraries' || name === 'assets' || name === 'runtime' || name === 'webcache2') return false;
return !(usesDefaultGameDir && !shouldCopyRootEntry(name));
});
}
function copyInto(sourceDir, targetDir, usesDefaultGameDir) {
fs.mkdirSync(targetDir, { recursive: true });
for (const name of fs.readdirSync(sourceDir)) {
if (/^launcher_profiles.*\.json$/i.test(name)) continue;
if (name === 'versions' || name === 'libraries' || name === 'assets' || name === 'runtime' || name === 'webcache2') continue;
if (usesDefaultGameDir && !shouldCopyRootEntry(name)) continue;
for (const name of kopierNamen(sourceDir, usesDefaultGameDir)) {
fs.cpSync(path.join(sourceDir, name), path.join(targetDir, name), { recursive: true });
}
}
/*
* Was beim Übernehmen kopiert wird: [{ von, name }] – `name` ist der Zielname
* im Spielordner der neuen Instanz. Das Kopieren selbst erledigt kopieren.js
* asynchron mit Fortschritt (main.js → fuehreImportAus).
*/
function kopierQuellen(entry) {
if (!entry.gameDir || !fs.existsSync(entry.gameDir)) return [];
return kopierNamen(entry.gameDir, !!entry.usesDefaultGameDir).map((name) => ({ von: path.join(entry.gameDir, name), name }));
}
function importOne(store, entry, copyData = true) {
const created = store.createOrReplaceImportedInstance({
name: entry.name,
@@ -214,4 +229,4 @@ function importOne(store, entry, copyData = true) {
return { id: created.id, name: created.name, copied };
}
module.exports = { scan, importOne, parseLastVersionId, resolveLauncherRoot };
module.exports = { scan, importOne, kopierQuellen, parseLastVersionId, resolveLauncherRoot };
+199 -188
View File
@@ -1,188 +1,199 @@
'use strict';
/*
* mmcimport.js – Instanzen aus MultiMC/Prism übernehmen
* ------------------------------------------------------
* Es genügt der Pfad zum MultiMC-Ordner (oder direkt zum instances-Ordner).
* Ausgelesen werden:
* instance.cfg – Name, Gruppe, Java-Pfad, Arbeitsspeicher, Notizen
* mmc-pack.json – Minecraft-Version und Mod-Loader
* .minecraft/ – Spielverzeichnis (Welten, Mods, Konfigurationen)
*/
const fs = require('fs');
const path = require('path');
// uid der Loader in mmc-pack.json -> unsere Bezeichnung
const LOADER_UIDS = {
'net.fabricmc.fabric-loader': 'fabric',
'org.quiltmc.quilt-loader': 'quilt',
'net.minecraftforge': 'forge',
'net.neoforged': 'neoforge',
};
function parseCfg(file) {
const out = {};
try {
for (const line of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
const i = line.indexOf('=');
if (i > 0) out[line.slice(0, i).trim()] = line.slice(i + 1).trim();
}
} catch { /* Datei fehlt */ }
return out;
}
// Gruppen stehen zentral in instances/instgroups.json
function readGroups(instancesDir) {
const map = {};
try {
const raw = JSON.parse(fs.readFileSync(path.join(instancesDir, 'instgroups.json'), 'utf8'));
for (const [group, data] of Object.entries(raw.groups || {})) {
for (const inst of data.instances || []) map[inst] = group;
}
} catch { /* keine Gruppen hinterlegt */ }
return map;
}
function readPack(dir) {
const res = { version: '', loader: 'vanilla', loaderVersion: '' };
try {
const pack = JSON.parse(fs.readFileSync(path.join(dir, 'mmc-pack.json'), 'utf8'));
for (const c of pack.components || []) {
if (c.uid === 'net.minecraft') res.version = c.version || c.cachedVersion || '';
const loader = LOADER_UIDS[c.uid];
if (loader) { res.loader = loader; res.loaderVersion = c.version || ''; }
}
} catch { /* alte Instanz ohne mmc-pack.json */ }
return res;
}
// findet den instances-Ordner, egal ob MultiMC-Wurzel oder instances direkt angegeben wurde
function resolveInstancesDir(inputPath) {
if (!inputPath) return null;
const direct = path.join(inputPath, 'instances');
if (fs.existsSync(direct)) return direct;
if (path.basename(inputPath).toLowerCase() === 'instances' && fs.existsSync(inputPath)) return inputPath;
return fs.existsSync(inputPath) ? inputPath : null;
}
/*
* Liest einen einzelnen Instanzordner aus. null, wenn dort keine Instanz liegt.
* Wird sowohl beim Ordner-Import als auch beim ZIP-Import verwendet.
*/
function readInstance(dir, folderName, group) {
const cfgFile = path.join(dir, 'instance.cfg');
if (!fs.existsSync(cfgFile)) return null; // kein Instanzordner
const cfg = parseCfg(cfgFile);
const pack = readPack(dir);
// MultiMC nutzt .minecraft, Prism minecraft
const gameDir = ['.minecraft', 'minecraft']
.map((n) => path.join(dir, n))
.find((p) => fs.existsSync(p)) || null;
return {
folder: folderName,
dir,
name: cfg.name || folderName,
group: group || '',
notes: cfg.notes || '',
version: pack.version || cfg.IntendedVersion || '',
loader: pack.loader,
loaderVersion: pack.loaderVersion,
javaPath: cfg.OverrideJavaLocation === 'true' ? (cfg.JavaPath || '') : '',
minMemMb: cfg.OverrideMemory === 'true' ? Number(cfg.MinMemAlloc) || null : null,
maxMemMb: cfg.OverrideMemory === 'true' ? Number(cfg.MaxMemAlloc) || null : null,
gameDir,
hasGameData: !!gameDir,
};
}
/*
* Listet die gefundenen Instanzen auf (ohne etwas zu kopieren).
*/
function scan(inputPath) {
const instancesDir = resolveInstancesDir(inputPath);
if (!instancesDir) return { ok: false, reason: 'not-found' };
const groups = readGroups(instancesDir);
const list = [];
for (const entry of fs.readdirSync(instancesDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const inst = readInstance(path.join(instancesDir, entry.name), entry.name, groups[entry.name]);
if (inst) list.push(inst);
}
list.sort((a, b) => a.name.localeCompare(b.name));
return { ok: true, instancesDir, count: list.length, instances: list };
}
/*
* Sucht einen Instanzordner unterhalb von root. Deckt beide Zip-Varianten ab:
* mit umgebendem Ordner ("26.2/instance.cfg") und ohne ("instance.cfg").
* Die Tiefe ist begrenzt, damit nicht das ganze .minecraft durchsucht wird.
*/
function findInstanceDir(root, tiefe = 2) {
if (!root || !fs.existsSync(root)) return null;
if (fs.existsSync(path.join(root, 'instance.cfg'))) return root;
if (tiefe <= 0) return null;
let eintraege = [];
try { eintraege = fs.readdirSync(root, { withFileTypes: true }); } catch { return null; }
for (const e of eintraege) {
if (!e.isDirectory()) continue;
// .minecraft enthält nie eine Instanz und ist der mit Abstand größte Ordner
if (e.name === '.minecraft' || e.name === 'minecraft') continue;
const treffer = findInstanceDir(path.join(root, e.name), tiefe - 1);
if (treffer) return treffer;
}
return null;
}
/*
* Liest eine einzelne, ausgepackte MultiMC/Prism-Instanz.
* -> { ok, instance } oder { ok: false, reason: 'not-found' }
*/
function scanSingle(root) {
const dir = findInstanceDir(root);
if (!dir) return { ok: false, reason: 'not-found' };
const inst = readInstance(dir, path.basename(dir), '');
return inst ? { ok: true, instance: inst } : { ok: false, reason: 'not-found' };
}
/*
* Übernimmt eine Instanz in den Launcher.
* store: unser Datenspeicher; copyData: Spielverzeichnis mitkopieren
*/
function importOne(store, entry, copyData = true) {
const created = store.createOrReplaceImportedInstance({
name: entry.name,
group: entry.group,
notes: entry.notes,
minecraft: {
version: entry.version,
loader: entry.loader,
loaderVersion: entry.loaderVersion,
},
});
const patch = {};
if (entry.javaPath || entry.minMemMb || entry.maxMemMb) {
patch.java = {
path: entry.javaPath || '',
minMemMb: entry.minMemMb || null,
maxMemMb: entry.maxMemMb || null,
extraArgs: '',
};
}
if (Object.keys(patch).length) store.updateInstance(created.id, patch);
let copied = false;
if (copyData && entry.gameDir && fs.existsSync(entry.gameDir)) {
try {
fs.cpSync(entry.gameDir, store.gameDir(created.id), { recursive: true });
copied = true;
} catch { /* Spieldaten konnten nicht kopiert werden */ }
}
return { id: created.id, name: created.name, copied };
}
module.exports = { scan, scanSingle, importOne, resolveInstancesDir, readInstance, findInstanceDir };
'use strict';
/*
* mmcimport.js – Instanzen aus MultiMC/Prism übernehmen
* ------------------------------------------------------
* Es genügt der Pfad zum MultiMC-Ordner (oder direkt zum instances-Ordner).
* Ausgelesen werden:
* instance.cfg – Name, Gruppe, Java-Pfad, Arbeitsspeicher, Notizen
* mmc-pack.json – Minecraft-Version und Mod-Loader
* .minecraft/ – Spielverzeichnis (Welten, Mods, Konfigurationen)
*/
const fs = require('fs');
const path = require('path');
// uid der Loader in mmc-pack.json -> unsere Bezeichnung
const LOADER_UIDS = {
'net.fabricmc.fabric-loader': 'fabric',
'org.quiltmc.quilt-loader': 'quilt',
'net.minecraftforge': 'forge',
'net.neoforged': 'neoforge',
};
function parseCfg(file) {
const out = {};
try {
for (const line of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
const i = line.indexOf('=');
if (i > 0) out[line.slice(0, i).trim()] = line.slice(i + 1).trim();
}
} catch { /* Datei fehlt */ }
return out;
}
// Gruppen stehen zentral in instances/instgroups.json
function readGroups(instancesDir) {
const map = {};
try {
const raw = JSON.parse(fs.readFileSync(path.join(instancesDir, 'instgroups.json'), 'utf8'));
for (const [group, data] of Object.entries(raw.groups || {})) {
for (const inst of data.instances || []) map[inst] = group;
}
} catch { /* keine Gruppen hinterlegt */ }
return map;
}
function readPack(dir) {
const res = { version: '', loader: 'vanilla', loaderVersion: '' };
try {
const pack = JSON.parse(fs.readFileSync(path.join(dir, 'mmc-pack.json'), 'utf8'));
for (const c of pack.components || []) {
if (c.uid === 'net.minecraft') res.version = c.version || c.cachedVersion || '';
const loader = LOADER_UIDS[c.uid];
if (loader) { res.loader = loader; res.loaderVersion = c.version || ''; }
}
} catch { /* alte Instanz ohne mmc-pack.json */ }
return res;
}
// findet den instances-Ordner, egal ob MultiMC-Wurzel oder instances direkt angegeben wurde
function resolveInstancesDir(inputPath) {
if (!inputPath) return null;
const direct = path.join(inputPath, 'instances');
if (fs.existsSync(direct)) return direct;
if (path.basename(inputPath).toLowerCase() === 'instances' && fs.existsSync(inputPath)) return inputPath;
return fs.existsSync(inputPath) ? inputPath : null;
}
/*
* Liest einen einzelnen Instanzordner aus. null, wenn dort keine Instanz liegt.
* Wird sowohl beim Ordner-Import als auch beim ZIP-Import verwendet.
*/
function readInstance(dir, folderName, group) {
const cfgFile = path.join(dir, 'instance.cfg');
if (!fs.existsSync(cfgFile)) return null; // kein Instanzordner
const cfg = parseCfg(cfgFile);
const pack = readPack(dir);
// MultiMC nutzt .minecraft, Prism minecraft
const gameDir = ['.minecraft', 'minecraft']
.map((n) => path.join(dir, n))
.find((p) => fs.existsSync(p)) || null;
return {
folder: folderName,
dir,
name: cfg.name || folderName,
group: group || '',
notes: cfg.notes || '',
version: pack.version || cfg.IntendedVersion || '',
loader: pack.loader,
loaderVersion: pack.loaderVersion,
javaPath: cfg.OverrideJavaLocation === 'true' ? (cfg.JavaPath || '') : '',
minMemMb: cfg.OverrideMemory === 'true' ? Number(cfg.MinMemAlloc) || null : null,
maxMemMb: cfg.OverrideMemory === 'true' ? Number(cfg.MaxMemAlloc) || null : null,
gameDir,
hasGameData: !!gameDir,
};
}
/*
* Listet die gefundenen Instanzen auf (ohne etwas zu kopieren).
*/
function scan(inputPath) {
const instancesDir = resolveInstancesDir(inputPath);
if (!instancesDir) return { ok: false, reason: 'not-found' };
const groups = readGroups(instancesDir);
const list = [];
for (const entry of fs.readdirSync(instancesDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const inst = readInstance(path.join(instancesDir, entry.name), entry.name, groups[entry.name]);
if (inst) list.push(inst);
}
list.sort((a, b) => a.name.localeCompare(b.name));
return { ok: true, instancesDir, count: list.length, instances: list };
}
/*
* Sucht einen Instanzordner unterhalb von root. Deckt beide Zip-Varianten ab:
* mit umgebendem Ordner ("26.2/instance.cfg") und ohne ("instance.cfg").
* Die Tiefe ist begrenzt, damit nicht das ganze .minecraft durchsucht wird.
*/
function findInstanceDir(root, tiefe = 2) {
if (!root || !fs.existsSync(root)) return null;
if (fs.existsSync(path.join(root, 'instance.cfg'))) return root;
if (tiefe <= 0) return null;
let eintraege = [];
try { eintraege = fs.readdirSync(root, { withFileTypes: true }); } catch { return null; }
for (const e of eintraege) {
if (!e.isDirectory()) continue;
// .minecraft enthält nie eine Instanz und ist der mit Abstand größte Ordner
if (e.name === '.minecraft' || e.name === 'minecraft') continue;
const treffer = findInstanceDir(path.join(root, e.name), tiefe - 1);
if (treffer) return treffer;
}
return null;
}
/*
* Liest eine einzelne, ausgepackte MultiMC/Prism-Instanz.
* -> { ok, instance } oder { ok: false, reason: 'not-found' }
*/
function scanSingle(root) {
const dir = findInstanceDir(root);
if (!dir) return { ok: false, reason: 'not-found' };
const inst = readInstance(dir, path.basename(dir), '');
return inst ? { ok: true, instance: inst } : { ok: false, reason: 'not-found' };
}
/*
* Übernimmt eine Instanz in den Launcher.
* store: unser Datenspeicher; copyData: Spielverzeichnis mitkopieren
*/
function importOne(store, entry, copyData = true) {
const created = store.createOrReplaceImportedInstance({
name: entry.name,
group: entry.group,
notes: entry.notes,
minecraft: {
version: entry.version,
loader: entry.loader,
loaderVersion: entry.loaderVersion,
},
});
const patch = {};
if (entry.javaPath || entry.minMemMb || entry.maxMemMb) {
patch.java = {
path: entry.javaPath || '',
minMemMb: entry.minMemMb || null,
maxMemMb: entry.maxMemMb || null,
extraArgs: '',
};
}
if (Object.keys(patch).length) store.updateInstance(created.id, patch);
let copied = false;
if (copyData && entry.gameDir && fs.existsSync(entry.gameDir)) {
try {
fs.cpSync(entry.gameDir, store.gameDir(created.id), { recursive: true });
copied = true;
} catch { /* Spieldaten konnten nicht kopiert werden */ }
}
return { id: created.id, name: created.name, copied };
}
/*
* Was beim Übernehmen kopiert wird: [{ von, name }] – `name` ist der Zielname
* im Spielordner der neuen Instanz. Das Kopieren selbst erledigt kopieren.js
* asynchron mit Fortschritt (main.js → fuehreImportAus).
*/
function kopierQuellen(entry) {
if (!entry.gameDir || !fs.existsSync(entry.gameDir)) return [];
return fs.readdirSync(entry.gameDir).map((name) => ({ von: path.join(entry.gameDir, name), name }));
}
module.exports = { scan, scanSingle, importOne, kopierQuellen, resolveInstancesDir, readInstance, findInstanceDir };
+117
View File
@@ -0,0 +1,117 @@
/*
* Erkennt Mods, die nicht über AeroMC installiert wurden (von Hand kopiert,
* aus MultiMC/CurseForge/Modrinth App übernommen, aus einem Modpack …).
*
* Solche Dateien haben keine Projekt-Zuordnung, deshalb zeigte der Mod-Katalog
* z. B. bei Fabric API „Installieren“, obwohl sie längst im mods-Ordner lag.
* Hier wird je Datei die SHA-1-Prüfsumme gebildet und gesammelt bei Modrinth
* nachgeschlagen (POST /v2/version_files). Ergebnisse werden zwischengespeichert
* (Pfad + Größe + Änderungszeit), damit nicht bei jedem Öffnen neu gerechnet wird.
* Ohne Netz bleibt einfach alles wie bisher.
*/
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const API = 'https://api.modrinth.com/v2';
const UA = 'AeroMC-Launcher';
const NICHT_GEFUNDEN_NEU_PRUEFEN_MS = 7 * 24 * 60 * 60 * 1000;
let cacheDatei = null;
let cache = null;
function init(userDataDir) {
cacheDatei = path.join(userDataDir, 'mod-erkennung.json');
}
function ladeCache() {
if (cache) return cache;
try { cache = JSON.parse(fs.readFileSync(cacheDatei, 'utf8')); } catch { cache = {}; }
return cache;
}
function speichereCache() {
try { fs.writeFileSync(cacheDatei, JSON.stringify(cache)); } catch { /* nur Zwischenspeicher */ }
}
function sha1(datei) {
return new Promise((resolve, reject) => {
const h = crypto.createHash('sha1');
fs.createReadStream(datei).on('data', (d) => h.update(d)).on('error', reject).on('end', () => resolve(h.digest('hex')));
});
}
async function holeJson(url, optionen) {
const res = await fetch(url, Object.assign({ headers: { 'User-Agent': UA, 'Content-Type': 'application/json', Accept: 'application/json' } }, optionen));
if (!res.ok) throw new Error('Modrinth ' + res.status);
return res.json();
}
/**
* Ergänzt Einträge ohne projectId um projectId, Titel und Versionsnummer, sofern
* Modrinth die Datei kennt. `dir` ist der Ordner, in dem die Dateien liegen.
*/
async function ergaenze(eintraege, dir) {
const offen = eintraege.filter((e) => e && !e.projectId && e.filename && !e.fehlt);
if (!offen.length || !cacheDatei) return eintraege;
const c = ladeCache();
const jetzt = Date.now();
const zuFragen = new Map(); // sha1 -> [schlüssel]
const schluesselVon = new Map();
for (const e of offen) {
const voll = path.join(dir, e.enabled === false ? e.filename + '.disabled' : e.filename);
let st;
try { st = fs.statSync(voll); } catch { continue; }
const schluessel = `${voll}|${st.size}|${Math.round(st.mtimeMs)}`;
schluesselVon.set(e, schluessel);
const bekannt = c[schluessel];
if (bekannt && (bekannt.projectId || jetzt - bekannt.geprueft < NICHT_GEFUNDEN_NEU_PRUEFEN_MS)) continue;
try {
const hash = (bekannt && bekannt.sha1) || await sha1(voll);
c[schluessel] = { sha1: hash, projectId: null, geprueft: 0 };
if (!zuFragen.has(hash)) zuFragen.set(hash, []);
zuFragen.get(hash).push(schluessel);
} catch { /* Datei gerade gesperrt – beim nächsten Mal */ }
}
if (zuFragen.size) {
try {
const versionen = await holeJson(`${API}/version_files`, {
method: 'POST', body: JSON.stringify({ hashes: [...zuFragen.keys()], algorithm: 'sha1' }),
});
const projektIds = [...new Set(Object.values(versionen).map((v) => v.project_id))];
const titel = {};
for (let i = 0; i < projektIds.length; i += 100) {
const teil = projektIds.slice(i, i + 100);
const projekte = await holeJson(`${API}/projects?ids=${encodeURIComponent(JSON.stringify(teil))}`);
for (const p of projekte) titel[p.id] = p.title;
}
for (const [hash, schluessel] of zuFragen) {
const v = versionen[hash];
for (const s of schluessel) {
c[s] = v
? { sha1: hash, projectId: v.project_id, versionId: v.id, versionNumber: v.version_number, title: titel[v.project_id] || null, geprueft: jetzt }
: { sha1: hash, projectId: null, geprueft: jetzt };
}
}
speichereCache();
} catch {
return eintraege.map((e) => anwenden(e, c, schluesselVon));
}
}
return eintraege.map((e) => anwenden(e, c, schluesselVon));
}
function anwenden(e, c, schluesselVon) {
const s = schluesselVon.get(e);
const t = s && c[s];
if (!t || !t.projectId) return e;
return Object.assign({}, e, {
projectId: t.projectId,
title: t.title || e.title,
versionNumber: e.versionNumber === 'Importiert' && t.versionNumber ? t.versionNumber : e.versionNumber,
erkannt: true, // per Prüfsumme bei Modrinth erkannt, nicht über AeroMC installiert
});
}
module.exports = { init, ergaenze };
+448 -437
View File
@@ -1,437 +1,448 @@
'use strict';
/*
* mrimport.js – Instanzen aus der Modrinth App übernehmen
* --------------------------------------------------------
* Die Modrinth App legt ihre Daten unter %APPDATA%\ModrinthApp ab (sehr alte
* Fassungen: %APPDATA%\com.modrinth.theseus). Die Spielordner liegen in
* "profiles\<Ordner>", die Angaben dazu (Name, Version, Loader, Gruppen,
* Arbeitsspeicher …) in der SQLite-Datenbank "app.db". Wurde der Datenordner in
* der App verlegt, steht der neue Ort in settings.custom_dir.
*
* Unterstützte Datenbank-Fassungen:
* - ab Mitte 2026: Tabelle "instances" + "instance_content_sets" (Version/Loader),
* Gruppen in "instance_group_memberships"/"instance_groups",
* Starteinstellungen als JSONB in "instance_launch_overrides"
* - 2024 bis Mitte 2026: eine Tabelle "profiles"
* - vorher (ohne app.db): je Instanz eine profile.json
*
* Gelesen wird mit sql.js (SQLite als WebAssembly) auf einer Kopie im Speicher –
* die Datenbank der Modrinth App wird nie verändert.
*/
const fs = require('fs');
const os = require('os');
const path = require('path');
const LOADER = new Set(['vanilla', 'fabric', 'forge', 'neoforge', 'quilt']);
let sqlPromise = null;
function sqlJs() {
if (!sqlPromise) {
const initSqlJs = require('sql.js');
const wasmDir = path.dirname(require.resolve('sql.js'));
sqlPromise = initSqlJs({ locateFile: (datei) => path.join(wasmDir, datei) });
}
return sqlPromise;
}
function appDatenOrdner() {
const appdata = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
return [path.join(appdata, 'ModrinthApp'), path.join(appdata, 'com.modrinth.theseus')];
}
function standardOrdner() {
return appDatenOrdner().find((d) => fs.existsSync(d)) || appDatenOrdner()[0];
}
function readJson(file) {
try { return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^/, '')); } catch { return null; }
}
// Aus der Eingabe (App-Ordner, profiles-Ordner oder leer) die Orte ermitteln
function ermittleOrte(eingabe) {
let basis = eingabe && eingabe.trim() ? eingabe.trim() : standardOrdner();
if (!fs.existsSync(basis)) return null;
// direkt den profiles-Ordner angegeben -> eine Ebene höher liegt die app.db
if (path.basename(basis).toLowerCase() === 'profiles' && !fs.existsSync(path.join(basis, 'app.db'))) {
const hoeher = path.dirname(basis);
if (fs.existsSync(path.join(hoeher, 'app.db')) || fs.existsSync(path.join(hoeher, 'profiles'))) basis = hoeher;
}
const db = path.join(basis, 'app.db');
return { basis, db: fs.existsSync(db) ? db : null };
}
// ---------- Datenbank ----------
/*
* Läuft die Modrinth App, stehen die neuesten Änderungen oft noch im
* Schreibprotokoll (app.db-wal) statt in app.db. Dessen abgeschlossene
* Transaktionen spielen wir hier selbst auf die Kopie im Speicher ein – nach
* dem SQLite-Dateiformat (Kopf 32 Bytes, je Rahmen 24 Bytes Kopf + eine Seite),
* mit Prüfung von Salz und laufender Prüfsumme wie SQLite selbst.
* -> { daten, rahmen } oder null, wenn das Protokoll nicht passt
*/
function spieleWalEin(daten, walPfad) {
let wal;
try { wal = fs.readFileSync(walPfad); } catch { return null; }
if (wal.length < 32) return null;
const magie = wal.readUInt32BE(0);
if (magie !== 0x377f0682 && magie !== 0x377f0683) return null;
const bigEndian = magie === 0x377f0683;
const seite = wal.readUInt32BE(8);
const dbSeite = daten.readUInt16BE(16) === 1 ? 65536 : daten.readUInt16BE(16);
if (!seite || seite !== dbSeite) return null;
const salz1 = wal.readUInt32BE(16), salz2 = wal.readUInt32BE(20);
const wort = (buf, i) => (bigEndian ? buf.readUInt32BE(i) : buf.readUInt32LE(i));
let s0 = 0, s1 = 0;
const pruefe = (buf, start, ende) => {
for (let i = start; i < ende; i += 8) {
s0 = (s0 + wort(buf, i) + s1) >>> 0;
s1 = (s1 + wort(buf, i + 4) + s0) >>> 0;
}
};
pruefe(wal, 0, 24);
if (s0 !== wal.readUInt32BE(24) || s1 !== wal.readUInt32BE(28)) return null;
const fertig = new Map(); // Seitennummer -> Inhalt (abgeschlossene Transaktionen)
let offen = new Map();
let seiten = Math.floor(daten.length / seite);
let rahmen = 0;
for (let pos = 32; pos + 24 + seite <= wal.length; pos += 24 + seite) {
if (wal.readUInt32BE(pos + 8) !== salz1 || wal.readUInt32BE(pos + 12) !== salz2) break;
pruefe(wal, pos, pos + 8);
pruefe(wal, pos + 24, pos + 24 + seite);
if (s0 !== wal.readUInt32BE(pos + 16) || s1 !== wal.readUInt32BE(pos + 20)) break;
offen.set(wal.readUInt32BE(pos), wal.subarray(pos + 24, pos + 24 + seite));
const nachCommit = wal.readUInt32BE(pos + 4);
if (nachCommit) { // Transaktion abgeschlossen
for (const [nr, inhalt] of offen) fertig.set(nr, inhalt);
offen = new Map();
seiten = nachCommit;
rahmen++;
}
}
if (!rahmen) return null;
const neu = Buffer.alloc(seiten * seite);
daten.copy(neu, 0, 0, Math.min(daten.length, neu.length));
for (const [nr, inhalt] of fertig) if (nr >= 1 && nr <= seiten) inhalt.copy(neu, (nr - 1) * seite);
return { daten: neu, rahmen };
}
async function oeffneDb(dbPfad) {
const SQL = await sqlJs();
let daten = fs.readFileSync(dbPfad);
let walEingespielt = false;
if (daten.length > 100 && daten[18] === 2) {
const mitWal = spieleWalEin(daten, dbPfad + '-wal');
if (mitWal) { daten = mitWal.daten; walEingespielt = true; }
// WAL-Datenbanken lassen sich ohne geteilten Speicher nicht öffnen – die
// Kopie im Speicher als normale Datenbank kennzeichnen (Bytes 18/19)
daten[18] = 1; daten[19] = 1;
}
const db = new SQL.Database(daten);
db.walEingespielt = walEingespielt;
return db;
}
function zeilen(db, sql, params) {
const st = db.prepare(sql);
try {
if (params) st.bind(params);
const out = [];
while (st.step()) out.push(st.getAsObject());
return out;
} finally {
st.free();
}
}
function tabellen(db) {
return new Set(zeilen(db, "SELECT name FROM sqlite_master WHERE type = 'table'").map((r) => r.name));
}
function spalten(db, tabelle) {
return new Set(zeilen(db, `PRAGMA table_info(${tabelle})`).map((r) => r.name));
}
function jsonFeld(wert) {
if (wert === null || wert === undefined || wert === '') return null;
try { return JSON.parse(wert); } catch { return null; }
}
// Spalte nur abfragen, wenn es sie gibt (ältere Fassungen kennen nicht alle)
function sp(vorhanden, ausdruck, name, ersatz = 'NULL') {
return vorhanden.has(name) ? `${ausdruck} AS ${name}` : `${ersatz} AS ${name}`;
}
function eigenerOrdner(db, t) {
if (!t.has('settings') || !spalten(db, 'settings').has('custom_dir')) return null;
const r = zeilen(db, 'SELECT custom_dir FROM settings LIMIT 1')[0];
return r && r.custom_dir ? String(r.custom_dir) : null;
}
function leseNeu(db, t) {
const si = spalten(db, 'instances');
const liste = zeilen(db, `
SELECT i.id AS id, i.path AS path, i.name AS name,
${sp(si, 'i.icon_path', 'icon_path')}, ${sp(si, 'i.install_stage', 'install_stage')},
${sp(si, 'i.last_played', 'last_played')},
${sp(si, 'i.submitted_time_played', 'submitted_time_played', '0')},
${sp(si, 'i.recent_time_played', 'recent_time_played', '0')},
cs.game_version AS game_version, cs.loader AS loader, cs.loader_version AS loader_version
FROM instances i
LEFT JOIN instance_content_sets cs ON cs.id = i.applied_content_set_id`);
const overrides = new Map();
if (t.has('instance_launch_overrides')) {
for (const r of zeilen(db, 'SELECT instance_id, json(overrides) AS o FROM instance_launch_overrides')) {
overrides.set(r.instance_id, jsonFeld(r.o) || {});
}
}
const gruppen = new Map();
const merke = (id, name) => { if (name && !gruppen.has(id)) gruppen.set(id, String(name)); };
if (t.has('instance_group_memberships') && t.has('instance_groups')) {
for (const r of zeilen(db, `SELECT m.instance_id AS id, g.name AS name FROM instance_group_memberships m
JOIN instance_groups g ON g.id = m.group_id ORDER BY g.display_order`)) merke(r.id, r.name);
} else if (t.has('instance_groups') && spalten(db, 'instance_groups').has('group_name')) {
for (const r of zeilen(db, 'SELECT instance_id AS id, group_name AS name FROM instance_groups ORDER BY group_name')) merke(r.id, r.name);
}
const links = new Map();
if (t.has('instance_links')) {
for (const r of zeilen(db, 'SELECT instance_id, link_kind, modrinth_project_id, modrinth_version_id FROM instance_links')) {
if (r.modrinth_project_id && r.modrinth_version_id) links.set(r.instance_id, { projectId: r.modrinth_project_id, versionId: r.modrinth_version_id });
}
}
return liste.map((r) => {
const o = overrides.get(r.id) || {};
const hooks = o.hooks || {};
return {
path: r.path,
name: r.name,
iconPath: r.icon_path,
installiert: !r.install_stage || r.install_stage === 'installed',
lastPlayed: r.last_played,
sekunden: (Number(r.submitted_time_played) || 0) + (Number(r.recent_time_played) || 0),
gameVersion: r.game_version,
loader: r.loader,
loaderVersion: r.loader_version,
gruppe: gruppen.get(r.id) || '',
javaPath: o.java_path || '',
maxMemMb: o.memory && Number(o.memory.maximum) ? Number(o.memory.maximum) : null,
extraArgs: Array.isArray(o.extra_launch_args) ? o.extra_launch_args.join(' ') : '',
vorStart: hooks.pre_launch || '',
nachEnde: hooks.post_exit || '',
modpack: links.get(r.id) || null,
};
});
}
function leseProfile(db) {
const s = spalten(db, 'profiles');
const liste = zeilen(db, `SELECT path, name,
${sp(s, 'icon_path', 'icon_path')}, ${sp(s, 'install_stage', 'install_stage')},
${sp(s, 'game_version', 'game_version')}, ${sp(s, 'mod_loader', 'mod_loader')},
${sp(s, 'mod_loader_version', 'mod_loader_version')}, ${sp(s, 'json(groups)', 'groups')},
${sp(s, 'last_played', 'last_played')},
${sp(s, 'submitted_time_played', 'submitted_time_played', '0')},
${sp(s, 'recent_time_played', 'recent_time_played', '0')},
${sp(s, 'override_java_path', 'override_java_path')},
${sp(s, 'json(override_extra_launch_args)', 'override_extra_launch_args')},
${sp(s, 'override_mc_memory_max', 'override_mc_memory_max')},
${sp(s, 'override_hook_pre_launch', 'override_hook_pre_launch')},
${sp(s, 'override_hook_post_exit', 'override_hook_post_exit')},
${sp(s, 'linked_project_id', 'linked_project_id')}, ${sp(s, 'linked_version_id', 'linked_version_id')}
FROM profiles`);
return liste.map((r) => {
const gruppen = jsonFeld(r.groups);
const args = jsonFeld(r.override_extra_launch_args);
return {
path: r.path,
name: r.name,
iconPath: r.icon_path,
installiert: !r.install_stage || r.install_stage === 'installed',
lastPlayed: r.last_played,
sekunden: (Number(r.submitted_time_played) || 0) + (Number(r.recent_time_played) || 0),
gameVersion: r.game_version,
loader: r.mod_loader,
loaderVersion: r.mod_loader_version,
gruppe: Array.isArray(gruppen) && gruppen.length ? String(gruppen[0]) : '',
javaPath: r.override_java_path || '',
maxMemMb: Number(r.override_mc_memory_max) || null,
extraArgs: Array.isArray(args) ? args.join(' ') : '',
vorStart: r.override_hook_pre_launch || '',
nachEnde: r.override_hook_post_exit || '',
modpack: r.linked_project_id && r.linked_version_id ? { projectId: r.linked_project_id, versionId: r.linked_version_id } : null,
};
});
}
// sehr alte Fassungen: profiles\<Ordner>\profile.json
function leseProfileJson(profilesDir) {
const liste = [];
for (const e of fs.readdirSync(profilesDir, { withFileTypes: true })) {
if (!e.isDirectory()) continue;
const p = readJson(path.join(profilesDir, e.name, 'profile.json'));
if (!p) continue;
const m = p.metadata || p;
const lv = m.loader_version;
liste.push({
path: e.name,
name: m.name || e.name,
iconPath: m.icon || null,
installiert: !p.install_stage || p.install_stage === 'installed',
lastPlayed: m.last_played || null,
sekunden: 0,
gameVersion: m.game_version,
loader: m.loader,
loaderVersion: lv && typeof lv === 'object' ? lv.id : lv,
gruppe: Array.isArray(m.groups) && m.groups.length ? String(m.groups[0]) : '',
javaPath: (p.java && (p.java.override_version && p.java.override_version.path)) || '',
maxMemMb: p.memory && Number(p.memory.maximum) ? Number(p.memory.maximum) : null,
extraArgs: p.java && Array.isArray(p.java.extra_arguments) ? p.java.extra_arguments.join(' ') : '',
vorStart: (p.hooks && p.hooks.pre_launch) || '',
nachEnde: (p.hooks && p.hooks.post_exit) || '',
modpack: m.linked_data && m.linked_data.project_id && m.linked_data.version_id
? { projectId: m.linked_data.project_id, versionId: m.linked_data.version_id } : null,
});
}
return liste;
}
// "1.20.1-47.2.0" -> "47.2.0" usw. – AeroMC erwartet die reine Loader-Version
function loaderVersion(loader, wert, mc) {
let v = String(wert || '').trim();
if (!v || v === 'stable' || v === 'latest') return '';
if (loader === 'forge') {
v = v.replace(/^forge-/i, '');
if (mc && v.startsWith(mc + '-')) v = v.slice(mc.length + 1);
} else if (loader === 'neoforge') {
v = v.replace(/^neoforge-/i, '');
} else if (loader === 'fabric' || loader === 'quilt') {
v = v.replace(/^(fabric|quilt)(-loader)?-/i, '');
if (mc && v.endsWith('-' + mc)) v = v.slice(0, -(mc.length + 1));
}
return v;
}
function zeitstempel(wert) {
if (!wert) return null;
const n = Number(wert);
if (Number.isFinite(n) && n > 0) return new Date(n < 1e12 ? n * 1000 : n).toISOString();
const d = new Date(wert);
return Number.isNaN(d.getTime()) ? null : d.toISOString();
}
/*
* Durchsucht die Modrinth App. Leere Eingabe = Standardort.
* -> { ok, basis, count, instances, hinweis }
*/
async function scan(eingabe) {
const orte = ermittleOrte(eingabe);
if (!orte) return { ok: false, reason: 'not-found' };
let roh = [];
let profilesDir = path.join(orte.basis, 'profiles');
let hinweis = '';
if (orte.db) {
const db = await oeffneDb(orte.db);
try {
const t = tabellen(db);
const eigener = eigenerOrdner(db, t);
if (eigener && fs.existsSync(path.join(eigener, 'profiles'))) profilesDir = path.join(eigener, 'profiles');
if (t.has('instances')) roh = leseNeu(db, t);
else if (t.has('profiles')) roh = leseProfile(db);
} finally {
db.close();
}
// Schreibprotokoll vorhanden, ließ sich aber nicht einspielen -> Kopie
// ist evtl. nicht ganz aktuell
try {
const wal = orte.db + '-wal';
if (!db.walEingespielt && fs.existsSync(wal) && fs.statSync(wal).size > 32) {
hinweis = 'Die Modrinth App scheint gerade zu laufen – schließe sie, damit auch die neuesten Änderungen übernommen werden.';
}
} catch { /* egal */ }
} else if (fs.existsSync(profilesDir)) {
roh = leseProfileJson(profilesDir);
} else {
return { ok: false, reason: 'not-found' };
}
const instances = [];
for (const r of roh) {
const dir = path.join(profilesDir, String(r.path || ''));
if (!r.path || !fs.existsSync(dir)) continue;
const loader = LOADER.has(String(r.loader || '').toLowerCase()) ? String(r.loader).toLowerCase() : 'vanilla';
const version = String(r.gameVersion || '').trim();
const icon = r.iconPath && fs.existsSync(String(r.iconPath)) ? String(r.iconPath) : '';
instances.push({
folder: r.path,
dir,
gameDir: dir,
name: String(r.name || r.path),
group: r.gruppe || '',
version,
loader,
loaderVersion: loader === 'vanilla' ? '' : loaderVersion(loader, r.loaderVersion, version),
installiert: r.installiert,
iconPath: icon,
lastPlayed: zeitstempel(r.lastPlayed),
playSeconds: Math.max(0, Math.round(r.sekunden || 0)),
javaPath: String(r.javaPath || '').trim(),
maxMemMb: r.maxMemMb || null,
extraArgs: String(r.extraArgs || '').trim(),
preLaunchCommand: String(r.vorStart || '').trim(),
postExitCommand: String(r.nachEnde || '').trim(),
modpack: r.modpack,
});
}
instances.sort((a, b) => a.name.localeCompare(b.name, 'de'));
return { ok: true, basis: orte.basis, profilesDir, count: instances.length, instances, hinweis };
}
// gehört zur Modrinth App bzw. wird ohnehin neu erzeugt
const NICHT_KOPIEREN = new Set(['profile.json', 'logs', '.fabric', '.quilt']);
function copyInto(quelle, ziel) {
fs.mkdirSync(ziel, { recursive: true });
for (const name of fs.readdirSync(quelle)) {
if (NICHT_KOPIEREN.has(name.toLowerCase())) continue;
// dereference: die App verlinkt Dateien teils aus ihrem gemeinsamen Speicher
fs.cpSync(path.join(quelle, name), path.join(ziel, name), { recursive: true, dereference: true });
}
}
function importOne(store, entry, copyData = true) {
const created = store.createOrReplaceImportedInstance({
name: entry.name,
group: entry.group,
minecraft: { version: entry.version, loader: entry.loader, loaderVersion: entry.loaderVersion },
});
const patch = {};
if (entry.javaPath || entry.maxMemMb || entry.extraArgs) {
patch.java = { path: entry.javaPath || '', minMemMb: null, maxMemMb: entry.maxMemMb || null, extraArgs: entry.extraArgs || '' };
}
if (entry.preLaunchCommand) patch.preLaunchCommand = entry.preLaunchCommand;
if (entry.postExitCommand) patch.postExitCommand = entry.postExitCommand;
// Spielzeit und letzten Start mitnehmen (falls es die Instanz schon gab: das Größere)
const vorher = store.getInstance(created.id) || {};
if (entry.playSeconds > (vorher.totalPlaySeconds || 0)) patch.totalPlaySeconds = entry.playSeconds;
if (entry.lastPlayed && (!vorher.lastPlayed || entry.lastPlayed > vorher.lastPlayed)) patch.lastPlayed = entry.lastPlayed;
if (Object.keys(patch).length) store.updateInstance(created.id, patch);
let copied = false;
if (copyData && entry.gameDir && fs.existsSync(entry.gameDir)) {
copyInto(entry.gameDir, store.gameDir(created.id));
copied = true;
}
if (entry.iconPath) {
try { store.setCustomImage(created.id, entry.iconPath); } catch { /* Bild ist kein Muss */ }
}
return { id: created.id, name: created.name, copied };
}
module.exports = { scan, importOne, standardOrdner };
'use strict';
/*
* mrimport.js – Instanzen aus der Modrinth App übernehmen
* --------------------------------------------------------
* Die Modrinth App legt ihre Daten unter %APPDATA%\ModrinthApp ab (sehr alte
* Fassungen: %APPDATA%\com.modrinth.theseus). Die Spielordner liegen in
* "profiles\<Ordner>", die Angaben dazu (Name, Version, Loader, Gruppen,
* Arbeitsspeicher …) in der SQLite-Datenbank "app.db". Wurde der Datenordner in
* der App verlegt, steht der neue Ort in settings.custom_dir.
*
* Unterstützte Datenbank-Fassungen:
* - ab Mitte 2026: Tabelle "instances" + "instance_content_sets" (Version/Loader),
* Gruppen in "instance_group_memberships"/"instance_groups",
* Starteinstellungen als JSONB in "instance_launch_overrides"
* - 2024 bis Mitte 2026: eine Tabelle "profiles"
* - vorher (ohne app.db): je Instanz eine profile.json
*
* Gelesen wird mit sql.js (SQLite als WebAssembly) auf einer Kopie im Speicher –
* die Datenbank der Modrinth App wird nie verändert.
*/
const fs = require('fs');
const os = require('os');
const path = require('path');
const LOADER = new Set(['vanilla', 'fabric', 'forge', 'neoforge', 'quilt']);
let sqlPromise = null;
function sqlJs() {
if (!sqlPromise) {
const initSqlJs = require('sql.js');
const wasmDir = path.dirname(require.resolve('sql.js'));
sqlPromise = initSqlJs({ locateFile: (datei) => path.join(wasmDir, datei) });
}
return sqlPromise;
}
function appDatenOrdner() {
const appdata = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
return [path.join(appdata, 'ModrinthApp'), path.join(appdata, 'com.modrinth.theseus')];
}
function standardOrdner() {
return appDatenOrdner().find((d) => fs.existsSync(d)) || appDatenOrdner()[0];
}
function readJson(file) {
try { return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^/, '')); } catch { return null; }
}
// Aus der Eingabe (App-Ordner, profiles-Ordner oder leer) die Orte ermitteln
function ermittleOrte(eingabe) {
let basis = eingabe && eingabe.trim() ? eingabe.trim() : standardOrdner();
if (!fs.existsSync(basis)) return null;
// direkt den profiles-Ordner angegeben -> eine Ebene höher liegt die app.db
if (path.basename(basis).toLowerCase() === 'profiles' && !fs.existsSync(path.join(basis, 'app.db'))) {
const hoeher = path.dirname(basis);
if (fs.existsSync(path.join(hoeher, 'app.db')) || fs.existsSync(path.join(hoeher, 'profiles'))) basis = hoeher;
}
const db = path.join(basis, 'app.db');
return { basis, db: fs.existsSync(db) ? db : null };
}
// ---------- Datenbank ----------
/*
* Läuft die Modrinth App, stehen die neuesten Änderungen oft noch im
* Schreibprotokoll (app.db-wal) statt in app.db. Dessen abgeschlossene
* Transaktionen spielen wir hier selbst auf die Kopie im Speicher ein – nach
* dem SQLite-Dateiformat (Kopf 32 Bytes, je Rahmen 24 Bytes Kopf + eine Seite),
* mit Prüfung von Salz und laufender Prüfsumme wie SQLite selbst.
* -> { daten, rahmen } oder null, wenn das Protokoll nicht passt
*/
function spieleWalEin(daten, walPfad) {
let wal;
try { wal = fs.readFileSync(walPfad); } catch { return null; }
if (wal.length < 32) return null;
const magie = wal.readUInt32BE(0);
if (magie !== 0x377f0682 && magie !== 0x377f0683) return null;
const bigEndian = magie === 0x377f0683;
const seite = wal.readUInt32BE(8);
const dbSeite = daten.readUInt16BE(16) === 1 ? 65536 : daten.readUInt16BE(16);
if (!seite || seite !== dbSeite) return null;
const salz1 = wal.readUInt32BE(16), salz2 = wal.readUInt32BE(20);
const wort = (buf, i) => (bigEndian ? buf.readUInt32BE(i) : buf.readUInt32LE(i));
let s0 = 0, s1 = 0;
const pruefe = (buf, start, ende) => {
for (let i = start; i < ende; i += 8) {
s0 = (s0 + wort(buf, i) + s1) >>> 0;
s1 = (s1 + wort(buf, i + 4) + s0) >>> 0;
}
};
pruefe(wal, 0, 24);
if (s0 !== wal.readUInt32BE(24) || s1 !== wal.readUInt32BE(28)) return null;
const fertig = new Map(); // Seitennummer -> Inhalt (abgeschlossene Transaktionen)
let offen = new Map();
let seiten = Math.floor(daten.length / seite);
let rahmen = 0;
for (let pos = 32; pos + 24 + seite <= wal.length; pos += 24 + seite) {
if (wal.readUInt32BE(pos + 8) !== salz1 || wal.readUInt32BE(pos + 12) !== salz2) break;
pruefe(wal, pos, pos + 8);
pruefe(wal, pos + 24, pos + 24 + seite);
if (s0 !== wal.readUInt32BE(pos + 16) || s1 !== wal.readUInt32BE(pos + 20)) break;
offen.set(wal.readUInt32BE(pos), wal.subarray(pos + 24, pos + 24 + seite));
const nachCommit = wal.readUInt32BE(pos + 4);
if (nachCommit) { // Transaktion abgeschlossen
for (const [nr, inhalt] of offen) fertig.set(nr, inhalt);
offen = new Map();
seiten = nachCommit;
rahmen++;
}
}
if (!rahmen) return null;
const neu = Buffer.alloc(seiten * seite);
daten.copy(neu, 0, 0, Math.min(daten.length, neu.length));
for (const [nr, inhalt] of fertig) if (nr >= 1 && nr <= seiten) inhalt.copy(neu, (nr - 1) * seite);
return { daten: neu, rahmen };
}
async function oeffneDb(dbPfad) {
const SQL = await sqlJs();
let daten = fs.readFileSync(dbPfad);
let walEingespielt = false;
if (daten.length > 100 && daten[18] === 2) {
const mitWal = spieleWalEin(daten, dbPfad + '-wal');
if (mitWal) { daten = mitWal.daten; walEingespielt = true; }
// WAL-Datenbanken lassen sich ohne geteilten Speicher nicht öffnen – die
// Kopie im Speicher als normale Datenbank kennzeichnen (Bytes 18/19)
daten[18] = 1; daten[19] = 1;
}
const db = new SQL.Database(daten);
db.walEingespielt = walEingespielt;
return db;
}
function zeilen(db, sql, params) {
const st = db.prepare(sql);
try {
if (params) st.bind(params);
const out = [];
while (st.step()) out.push(st.getAsObject());
return out;
} finally {
st.free();
}
}
function tabellen(db) {
return new Set(zeilen(db, "SELECT name FROM sqlite_master WHERE type = 'table'").map((r) => r.name));
}
function spalten(db, tabelle) {
return new Set(zeilen(db, `PRAGMA table_info(${tabelle})`).map((r) => r.name));
}
function jsonFeld(wert) {
if (wert === null || wert === undefined || wert === '') return null;
try { return JSON.parse(wert); } catch { return null; }
}
// Spalte nur abfragen, wenn es sie gibt (ältere Fassungen kennen nicht alle)
function sp(vorhanden, ausdruck, name, ersatz = 'NULL') {
return vorhanden.has(name) ? `${ausdruck} AS ${name}` : `${ersatz} AS ${name}`;
}
function eigenerOrdner(db, t) {
if (!t.has('settings') || !spalten(db, 'settings').has('custom_dir')) return null;
const r = zeilen(db, 'SELECT custom_dir FROM settings LIMIT 1')[0];
return r && r.custom_dir ? String(r.custom_dir) : null;
}
function leseNeu(db, t) {
const si = spalten(db, 'instances');
const liste = zeilen(db, `
SELECT i.id AS id, i.path AS path, i.name AS name,
${sp(si, 'i.icon_path', 'icon_path')}, ${sp(si, 'i.install_stage', 'install_stage')},
${sp(si, 'i.last_played', 'last_played')},
${sp(si, 'i.submitted_time_played', 'submitted_time_played', '0')},
${sp(si, 'i.recent_time_played', 'recent_time_played', '0')},
cs.game_version AS game_version, cs.loader AS loader, cs.loader_version AS loader_version
FROM instances i
LEFT JOIN instance_content_sets cs ON cs.id = i.applied_content_set_id`);
const overrides = new Map();
if (t.has('instance_launch_overrides')) {
for (const r of zeilen(db, 'SELECT instance_id, json(overrides) AS o FROM instance_launch_overrides')) {
overrides.set(r.instance_id, jsonFeld(r.o) || {});
}
}
const gruppen = new Map();
const merke = (id, name) => { if (name && !gruppen.has(id)) gruppen.set(id, String(name)); };
if (t.has('instance_group_memberships') && t.has('instance_groups')) {
for (const r of zeilen(db, `SELECT m.instance_id AS id, g.name AS name FROM instance_group_memberships m
JOIN instance_groups g ON g.id = m.group_id ORDER BY g.display_order`)) merke(r.id, r.name);
} else if (t.has('instance_groups') && spalten(db, 'instance_groups').has('group_name')) {
for (const r of zeilen(db, 'SELECT instance_id AS id, group_name AS name FROM instance_groups ORDER BY group_name')) merke(r.id, r.name);
}
const links = new Map();
if (t.has('instance_links')) {
for (const r of zeilen(db, 'SELECT instance_id, link_kind, modrinth_project_id, modrinth_version_id FROM instance_links')) {
if (r.modrinth_project_id && r.modrinth_version_id) links.set(r.instance_id, { projectId: r.modrinth_project_id, versionId: r.modrinth_version_id });
}
}
return liste.map((r) => {
const o = overrides.get(r.id) || {};
const hooks = o.hooks || {};
return {
path: r.path,
name: r.name,
iconPath: r.icon_path,
installiert: !r.install_stage || r.install_stage === 'installed',
lastPlayed: r.last_played,
sekunden: (Number(r.submitted_time_played) || 0) + (Number(r.recent_time_played) || 0),
gameVersion: r.game_version,
loader: r.loader,
loaderVersion: r.loader_version,
gruppe: gruppen.get(r.id) || '',
javaPath: o.java_path || '',
maxMemMb: o.memory && Number(o.memory.maximum) ? Number(o.memory.maximum) : null,
extraArgs: Array.isArray(o.extra_launch_args) ? o.extra_launch_args.join(' ') : '',
vorStart: hooks.pre_launch || '',
nachEnde: hooks.post_exit || '',
modpack: links.get(r.id) || null,
};
});
}
function leseProfile(db) {
const s = spalten(db, 'profiles');
const liste = zeilen(db, `SELECT path, name,
${sp(s, 'icon_path', 'icon_path')}, ${sp(s, 'install_stage', 'install_stage')},
${sp(s, 'game_version', 'game_version')}, ${sp(s, 'mod_loader', 'mod_loader')},
${sp(s, 'mod_loader_version', 'mod_loader_version')}, ${sp(s, 'json(groups)', 'groups')},
${sp(s, 'last_played', 'last_played')},
${sp(s, 'submitted_time_played', 'submitted_time_played', '0')},
${sp(s, 'recent_time_played', 'recent_time_played', '0')},
${sp(s, 'override_java_path', 'override_java_path')},
${sp(s, 'json(override_extra_launch_args)', 'override_extra_launch_args')},
${sp(s, 'override_mc_memory_max', 'override_mc_memory_max')},
${sp(s, 'override_hook_pre_launch', 'override_hook_pre_launch')},
${sp(s, 'override_hook_post_exit', 'override_hook_post_exit')},
${sp(s, 'linked_project_id', 'linked_project_id')}, ${sp(s, 'linked_version_id', 'linked_version_id')}
FROM profiles`);
return liste.map((r) => {
const gruppen = jsonFeld(r.groups);
const args = jsonFeld(r.override_extra_launch_args);
return {
path: r.path,
name: r.name,
iconPath: r.icon_path,
installiert: !r.install_stage || r.install_stage === 'installed',
lastPlayed: r.last_played,
sekunden: (Number(r.submitted_time_played) || 0) + (Number(r.recent_time_played) || 0),
gameVersion: r.game_version,
loader: r.mod_loader,
loaderVersion: r.mod_loader_version,
gruppe: Array.isArray(gruppen) && gruppen.length ? String(gruppen[0]) : '',
javaPath: r.override_java_path || '',
maxMemMb: Number(r.override_mc_memory_max) || null,
extraArgs: Array.isArray(args) ? args.join(' ') : '',
vorStart: r.override_hook_pre_launch || '',
nachEnde: r.override_hook_post_exit || '',
modpack: r.linked_project_id && r.linked_version_id ? { projectId: r.linked_project_id, versionId: r.linked_version_id } : null,
};
});
}
// sehr alte Fassungen: profiles\<Ordner>\profile.json
function leseProfileJson(profilesDir) {
const liste = [];
for (const e of fs.readdirSync(profilesDir, { withFileTypes: true })) {
if (!e.isDirectory()) continue;
const p = readJson(path.join(profilesDir, e.name, 'profile.json'));
if (!p) continue;
const m = p.metadata || p;
const lv = m.loader_version;
liste.push({
path: e.name,
name: m.name || e.name,
iconPath: m.icon || null,
installiert: !p.install_stage || p.install_stage === 'installed',
lastPlayed: m.last_played || null,
sekunden: 0,
gameVersion: m.game_version,
loader: m.loader,
loaderVersion: lv && typeof lv === 'object' ? lv.id : lv,
gruppe: Array.isArray(m.groups) && m.groups.length ? String(m.groups[0]) : '',
javaPath: (p.java && (p.java.override_version && p.java.override_version.path)) || '',
maxMemMb: p.memory && Number(p.memory.maximum) ? Number(p.memory.maximum) : null,
extraArgs: p.java && Array.isArray(p.java.extra_arguments) ? p.java.extra_arguments.join(' ') : '',
vorStart: (p.hooks && p.hooks.pre_launch) || '',
nachEnde: (p.hooks && p.hooks.post_exit) || '',
modpack: m.linked_data && m.linked_data.project_id && m.linked_data.version_id
? { projectId: m.linked_data.project_id, versionId: m.linked_data.version_id } : null,
});
}
return liste;
}
// "1.20.1-47.2.0" -> "47.2.0" usw. – AeroMC erwartet die reine Loader-Version
function loaderVersion(loader, wert, mc) {
let v = String(wert || '').trim();
if (!v || v === 'stable' || v === 'latest') return '';
if (loader === 'forge') {
v = v.replace(/^forge-/i, '');
if (mc && v.startsWith(mc + '-')) v = v.slice(mc.length + 1);
} else if (loader === 'neoforge') {
v = v.replace(/^neoforge-/i, '');
} else if (loader === 'fabric' || loader === 'quilt') {
v = v.replace(/^(fabric|quilt)(-loader)?-/i, '');
if (mc && v.endsWith('-' + mc)) v = v.slice(0, -(mc.length + 1));
}
return v;
}
function zeitstempel(wert) {
if (!wert) return null;
const n = Number(wert);
if (Number.isFinite(n) && n > 0) return new Date(n < 1e12 ? n * 1000 : n).toISOString();
const d = new Date(wert);
return Number.isNaN(d.getTime()) ? null : d.toISOString();
}
/*
* Durchsucht die Modrinth App. Leere Eingabe = Standardort.
* -> { ok, basis, count, instances, hinweis }
*/
async function scan(eingabe) {
const orte = ermittleOrte(eingabe);
if (!orte) return { ok: false, reason: 'not-found' };
let roh = [];
let profilesDir = path.join(orte.basis, 'profiles');
let hinweis = '';
if (orte.db) {
const db = await oeffneDb(orte.db);
try {
const t = tabellen(db);
const eigener = eigenerOrdner(db, t);
if (eigener && fs.existsSync(path.join(eigener, 'profiles'))) profilesDir = path.join(eigener, 'profiles');
if (t.has('instances')) roh = leseNeu(db, t);
else if (t.has('profiles')) roh = leseProfile(db);
} finally {
db.close();
}
// Schreibprotokoll vorhanden, ließ sich aber nicht einspielen -> Kopie
// ist evtl. nicht ganz aktuell
try {
const wal = orte.db + '-wal';
if (!db.walEingespielt && fs.existsSync(wal) && fs.statSync(wal).size > 32) {
hinweis = 'Die Modrinth App scheint gerade zu laufen – schließe sie, damit auch die neuesten Änderungen übernommen werden.';
}
} catch { /* egal */ }
} else if (fs.existsSync(profilesDir)) {
roh = leseProfileJson(profilesDir);
} else {
return { ok: false, reason: 'not-found' };
}
const instances = [];
for (const r of roh) {
const dir = path.join(profilesDir, String(r.path || ''));
if (!r.path || !fs.existsSync(dir)) continue;
const loader = LOADER.has(String(r.loader || '').toLowerCase()) ? String(r.loader).toLowerCase() : 'vanilla';
const version = String(r.gameVersion || '').trim();
const icon = r.iconPath && fs.existsSync(String(r.iconPath)) ? String(r.iconPath) : '';
instances.push({
folder: r.path,
dir,
gameDir: dir,
name: String(r.name || r.path),
group: r.gruppe || '',
version,
loader,
loaderVersion: loader === 'vanilla' ? '' : loaderVersion(loader, r.loaderVersion, version),
installiert: r.installiert,
iconPath: icon,
lastPlayed: zeitstempel(r.lastPlayed),
playSeconds: Math.max(0, Math.round(r.sekunden || 0)),
javaPath: String(r.javaPath || '').trim(),
maxMemMb: r.maxMemMb || null,
extraArgs: String(r.extraArgs || '').trim(),
preLaunchCommand: String(r.vorStart || '').trim(),
postExitCommand: String(r.nachEnde || '').trim(),
modpack: r.modpack,
});
}
instances.sort((a, b) => a.name.localeCompare(b.name, 'de'));
return { ok: true, basis: orte.basis, profilesDir, count: instances.length, instances, hinweis };
}
// gehört zur Modrinth App bzw. wird ohnehin neu erzeugt
const NICHT_KOPIEREN = new Set(['profile.json', 'logs', '.fabric', '.quilt']);
/*
* Was beim Übernehmen kopiert wird: [{ von, name }] – `name` ist der Zielname
* im Spielordner der neuen Instanz. Das Kopieren selbst erledigt kopieren.js
* asynchron mit Fortschritt (main.js → fuehreImportAus).
*/
function kopierQuellen(entry) {
if (!entry.gameDir || !fs.existsSync(entry.gameDir)) return [];
return fs.readdirSync(entry.gameDir).filter((name) => !NICHT_KOPIEREN.has(name.toLowerCase()))
.map((name) => ({ von: path.join(entry.gameDir, name), name }));
}
function copyInto(quelle, ziel) {
fs.mkdirSync(ziel, { recursive: true });
for (const name of fs.readdirSync(quelle)) {
if (NICHT_KOPIEREN.has(name.toLowerCase())) continue;
// dereference: die App verlinkt Dateien teils aus ihrem gemeinsamen Speicher
fs.cpSync(path.join(quelle, name), path.join(ziel, name), { recursive: true, dereference: true });
}
}
function importOne(store, entry, copyData = true) {
const created = store.createOrReplaceImportedInstance({
name: entry.name,
group: entry.group,
minecraft: { version: entry.version, loader: entry.loader, loaderVersion: entry.loaderVersion },
});
const patch = {};
if (entry.javaPath || entry.maxMemMb || entry.extraArgs) {
patch.java = { path: entry.javaPath || '', minMemMb: null, maxMemMb: entry.maxMemMb || null, extraArgs: entry.extraArgs || '' };
}
if (entry.preLaunchCommand) patch.preLaunchCommand = entry.preLaunchCommand;
if (entry.postExitCommand) patch.postExitCommand = entry.postExitCommand;
// Spielzeit und letzten Start mitnehmen (falls es die Instanz schon gab: das Größere)
const vorher = store.getInstance(created.id) || {};
if (entry.playSeconds > (vorher.totalPlaySeconds || 0)) patch.totalPlaySeconds = entry.playSeconds;
if (entry.lastPlayed && (!vorher.lastPlayed || entry.lastPlayed > vorher.lastPlayed)) patch.lastPlayed = entry.lastPlayed;
if (Object.keys(patch).length) store.updateInstance(created.id, patch);
let copied = false;
if (copyData && entry.gameDir && fs.existsSync(entry.gameDir)) {
copyInto(entry.gameDir, store.gameDir(created.id));
copied = true;
}
if (entry.iconPath) {
try { store.setCustomImage(created.id, entry.iconPath); } catch { /* Bild ist kein Muss */ }
}
return { id: created.id, name: created.name, copied };
}
module.exports = { scan, importOne, kopierQuellen, standardOrdner };
+49 -1
View File
@@ -16,6 +16,21 @@ window.addEventListener('unhandledrejection', (e) => {
ipcRenderer.send('debug:rendererError', 'unhandledrejection: ' + ((e.reason && e.reason.stack) || e.reason));
});
// Zuletzt angeklickter Knopf: Popup-Fenster wachsen daraus hervor
// (main.js → openPopout). Gilt nur kurz, sonst kämen sie aus einem alten Klick.
let letzterKlick = null;
window.addEventListener('pointerdown', (e) => {
const t = e.target && e.target.closest ? (e.target.closest('button, a, [role="button"], .card, .snav') || e.target) : null;
if (!t || !t.getBoundingClientRect) return;
const r = t.getBoundingClientRect();
letzterKlick = { x: r.left, y: r.top, width: r.width, height: r.height, zeit: Date.now() };
}, true);
function frischerKlick() {
if (!letzterKlick || Date.now() - letzterKlick.zeit > 2000) return null;
const { x, y, width, height } = letzterKlick;
return { x, y, width, height };
}
contextBridge.exposeInMainWorld('api', {
// Einstellungen
getSettings: () => ipcRenderer.invoke('settings:get'),
@@ -151,6 +166,32 @@ contextBridge.exposeInMainWorld('api', {
authAccounts: () => ipcRenderer.invoke('auth:accounts'),
authSetActive: (id) => ipcRenderer.invoke('auth:setActive', id),
skinProfile: () => ipcRenderer.invoke('skin:profile'),
skinArmformPruefen: () => ipcRenderer.invoke('skin:armformPruefen'),
garderobeListe: () => ipcRenderer.invoke('garderobe:liste'),
erfolgeListe: () => ipcRenderer.invoke('erfolge:liste'),
laufendeInstanzen: () => ipcRenderer.invoke('game:laufend'),
onLaufendeInstanzen: (cb) => {
const l = (_e, d) => cb(d || []);
ipcRenderer.on('game:laufend', l);
return () => ipcRenderer.removeListener('game:laufend', l);
},
onErfolgeNeu: (cb) => {
const l = (_e, d) => cb(d);
ipcRenderer.on('erfolge:neu', l);
return () => ipcRenderer.removeListener('erfolge:neu', l);
},
modsLeistungsPaket: (id) => ipcRenderer.invoke('mods:leistungsPaket', id),
onPaketFortschritt: (cb) => {
const l = (_e, d) => cb(d);
ipcRenderer.on('mods:paketFortschritt', l);
return () => ipcRenderer.removeListener('mods:paketFortschritt', l);
},
garderobeSkinSpeichern: (name) => ipcRenderer.invoke('garderobe:skinSpeichern', name),
garderobeUmhangSpeichern: (name) => ipcRenderer.invoke('garderobe:umhangSpeichern', name),
garderobeAnziehen: (id) => ipcRenderer.invoke('garderobe:anziehen', id),
garderobeUmbenennen: (id, name) => ipcRenderer.invoke('garderobe:umbenennen', id, name),
garderobeLoeschen: (id) => ipcRenderer.invoke('garderobe:loeschen', id),
skinArmformKorrigieren: () => ipcRenderer.invoke('skin:armformKorrigieren'),
skinChoose: () => ipcRenderer.invoke('skin:choose'),
skinApply: (pfad, variante) => ipcRenderer.invoke('skin:apply', pfad, variante),
skinReset: () => ipcRenderer.invoke('skin:reset'),
@@ -196,6 +237,13 @@ contextBridge.exposeInMainWorld('api', {
// Dialoge / App
// MultiMC-Import
// gemeinsamer Fortschritt aller Launcher-Importe (main.js → fuehreImportAus)
onImportFortschritt: (cb) => {
const l = (_e, d) => cb(d);
ipcRenderer.on('import:fortschritt', l);
return () => ipcRenderer.removeListener('import:fortschritt', l);
},
importAbbrechen: () => ipcRenderer.invoke('import:abbrechen'),
mmcPickFolder: () => ipcRenderer.invoke('mmc:pickFolder'),
mmcScan: (folder) => ipcRenderer.invoke('mmc:scan', folder),
mmcImport: (entries, copyData) => ipcRenderer.invoke('mmc:import', entries, copyData),
@@ -288,7 +336,7 @@ contextBridge.exposeInMainWorld('api', {
appLicense: () => ipcRenderer.invoke('app:license'),
// Einstellungen/Konsole als eigenes, frei verschiebbares Fenster
popoutOpen: (modalId, params) => ipcRenderer.send('popout:open', modalId, params),
popoutOpen: (modalId, params) => ipcRenderer.send('popout:open', modalId, params, frischerKlick()),
popoutHide: () => ipcRenderer.send('popout:hide'),
setTitleBarDesign: (design) => ipcRenderer.send('window:titleBar', design),
onPopoutReopen: (cb) => {
+1120 -75
View File
File diff suppressed because it is too large Load Diff
+29 -1
View File
@@ -176,4 +176,32 @@ function waehleAnzeigeSkin(kontoProfil, spiel, kuerzlichGeaendert) {
return Object.assign({}, kontoProfil, { skinUrl: spiel.skinUrl, variante: spiel.variante, skinAusSpiel: true });
}
module.exports = { setzeSkin, setzeSkinZurueck, holeProfil, pruefeDatei, aufHttps, setzeUmhang, holeSpielTexturen, waehleAnzeigeSkin };
/*
* Für welche Armform ist eine Skin-Datei gemalt? Breite Arme (Steve) sind 4
* Pixel breit, schmale (Alex) 3 – bei schmalen bleiben deshalb je Arm zwei
* Pixelspalten der Rückseite leer: rechter Arm x 54–55 / y 20–31, linker Arm
* x 46–47 / y 52–63. bitmap: Rohdaten mit 4 Byte je Pixel (Alpha an Stelle 3).
* -> 'classic' | 'slim' | null (nicht eindeutig, alte 64×32-Skins: classic)
*/
function armformDerTextur(bitmap, breite, hoehe) {
if (!bitmap || !breite || breite % 64 !== 0 || hoehe % 32 !== 0) return null;
const f = breite / 64; // HD-Skins (128×128 …) mitskalieren
if (Math.round(hoehe / f) === 32) return 'classic';
if (Math.round(hoehe / f) !== 64) return null;
let deckend = 0, gesamt = 0;
const pruefe = (x0, y0) => {
for (let y = y0 * f; y < (y0 + 12) * f; y++) {
for (let x = x0 * f; x < (x0 + 2) * f; x++) {
gesamt++;
if (bitmap[(y * breite + x) * 4 + 3] > 0) deckend++;
}
}
};
pruefe(54, 20);
pruefe(46, 52);
if (deckend >= gesamt * 0.85) return 'classic';
if (deckend <= gesamt * 0.1) return 'slim';
return null;
}
module.exports = { setzeSkin, setzeSkinZurueck, holeProfil, pruefeDatei, aufHttps, setzeUmhang, holeSpielTexturen, waehleAnzeigeSkin, armformDerTextur };
+79
View File
@@ -0,0 +1,79 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; font-src 'self'" />
<title>AeroMC</title>
<style>
@font-face { font-family: 'AeroPixel'; src: url('assets/aeropixel.ttf'); }
html, body { margin: 0; height: 100%; overflow: hidden; background: transparent; user-select: none; cursor: default; }
body { font-family: 'Segoe UI', system-ui, sans-serif; }
/* Logo + Schriftzug in der Mitte */
.marke {
position: fixed; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px;
transition: opacity .28s ease, transform .32s ease;
}
.buehne { perspective: 700px; animation: rein .45s cubic-bezier(.2, .9, .3, 1.2) both; }
.logo {
width: 132px; height: 132px; display: block;
filter: drop-shadow(0 10px 26px rgba(0, 0, 0, .55)) drop-shadow(0 0 22px rgba(90, 160, 255, .35));
animation: drehen 1.3s cubic-bezier(.45, .05, .25, 1) .15s infinite;
}
.name {
font-size: 34px; font-weight: 600; letter-spacing: .06em; color: #fff;
text-shadow: 0 2px 10px rgba(0, 0, 0, .75), 0 0 2px rgba(0, 0, 0, .9);
animation: schrift .5s ease .25s both;
}
@keyframes rein { from { opacity: 0; transform: scale(.6); } to { opacity: 1; transform: none; } }
@keyframes drehen { from { transform: rotateY(0deg); } to { transform: rotateY(360deg); } }
@keyframes schrift { from { opacity: 0; transform: translateY(8px); letter-spacing: .3em; } to { opacity: 1; transform: none; letter-spacing: .06em; } }
/* Schriftzug je Design */
body[data-design="aero"] .name, body[data-design="lila"] .name {
background: linear-gradient(90deg, #7fe3ff, #6c9cff); -webkit-background-clip: text; background-clip: text; color: transparent;
text-shadow: none; filter: drop-shadow(0 2px 8px rgba(0, 0, 0, .8));
}
body[data-design="lila"] .name { background-image: linear-gradient(90deg, #e3b8ff, #9d7bff); }
body[data-design="lila"] .logo { filter: drop-shadow(0 10px 26px rgba(0, 0, 0, .55)) drop-shadow(0 0 22px rgba(170, 120, 255, .4)); }
body[data-design="minecraft"] .name {
font-family: 'AeroPixel', 'Segoe UI', sans-serif; font-weight: 400; font-size: 30px; letter-spacing: .04em;
text-shadow: 3px 3px 0 #3f3f3f, 0 0 10px rgba(0, 0, 0, .8);
}
/* das Launcher-Fenster als Bild, wächst von der Mitte nach außen */
#fenster { position: fixed; inset: 0; width: 100%; height: 100%; visibility: hidden; }
body.aufbau .marke { opacity: 0; transform: scale(.82); }
</style>
</head>
<body>
<div class="marke">
<div class="buehne"><img class="logo" src="assets/logo.png" alt="" /></div>
<div class="name">AeroMC</div>
</div>
<img id="fenster" alt="" />
<script>
document.body.dataset.design = new URLSearchParams(location.search).get('design') || 'dark';
// vom Hauptprozess aufgerufen: Bild des fertigen Launchers von der Mitte aus aufziehen
window.aufbauen = (bildAdresse) => new Promise((fertig) => {
const bild = document.getElementById('fenster');
bild.onerror = () => fertig(false);
bild.onload = () => {
const v = Math.max(0, innerHeight / 2 - 66);
const h = Math.max(0, innerWidth / 2 - 66);
bild.style.visibility = 'visible';
document.body.classList.add('aufbau');
bild.animate([
{ clipPath: `inset(${v}px ${h}px round 30px)`, opacity: 0.2 },
{ opacity: 1, offset: 0.25 },
{ clipPath: 'inset(0px 0px round 0px)', opacity: 1 },
], { duration: 560, easing: 'cubic-bezier(.2, .8, .2, 1)', fill: 'forwards' })
.finished.then(() => fertig(true), () => fertig(false));
};
bild.src = bildAdresse;
});
</script>
</body>
</html>
+76
View File
@@ -0,0 +1,76 @@
/*
* Startanimation des Hauptfensters (abschaltbar unter Optionen → Darstellung).
*
* Ablauf: Ein randloses, durchsichtiges Fenster liegt genau dort, wo der
* Launcher erscheinen wird, und zeigt das sich drehende Logo samt Schriftzug.
* Ist die Oberfläche fertig (renderer:ready), wird das noch unsichtbare
* Hauptfenster abfotografiert und dieses Bild im Startfenster von der Mitte
* nach außen aufgezogen. Am Ende übernimmt das echte Fenster an exakt
* derselben Stelle – dadurch wirkt es, als baue sich der Launcher auf.
*/
const path = require('path');
const { BrowserWindow } = require('electron');
const MINDESTENS_DREHEN_MS = 1600; // so lange dreht sich das Logo mindestens
const HOECHSTENS_WARTEN_MS = 8000; // spätestens dann wird der Launcher gezeigt
const warte = (ms) => new Promise((r) => setTimeout(r, ms));
// "renderer:ready" kommt, bevor Instanzliste und Kontoname gezeichnet sind –
// erst darauf warten, sonst wächst ein halbleeres Fenster auf
async function warteAufGezeichneteOberflaeche(wc) {
const pruefe = (ausdruck) => wc.executeJavaScript(ausdruck, true).catch(() => true);
// Liste gezeichnet – oder es gibt (noch) keine Instanzen, dann steht der Leer-Hinweis da
for (let i = 0; i < 30 && !(await pruefe("document.querySelectorAll('#grid > *').length > 0 || !document.getElementById('empty').classList.contains('hidden')")); i++) await warte(100);
for (let i = 0; i < 12 && !(await pruefe("(document.getElementById('account-name') || {}).textContent !== 'Konten'")); i++) await warte(100);
await warte(200);
}
async function spielen(hauptfenster, oberflaecheBereit, design) {
const b = hauptfenster.getContentBounds();
const start = new BrowserWindow({
x: b.x, y: b.y, width: b.width, height: b.height,
frame: false, transparent: true, resizable: false, movable: false,
minimizable: false, maximizable: false, fullscreenable: false,
skipTaskbar: true, hasShadow: false, focusable: false, show: false,
backgroundColor: '#00000000', title: 'AeroMC',
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
});
const launcherZeigen = () => {
if (hauptfenster.isDestroyed()) return;
hauptfenster.setOpacity(1);
hauptfenster.setIgnoreMouseEvents(false);
if (!hauptfenster.isVisible()) hauptfenster.show();
hauptfenster.focus();
};
try {
await start.loadFile(path.join(__dirname, 'splash.html'), { query: { design: design || 'dark' } });
start.showInactive();
const beginn = Date.now();
await Promise.race([oberflaecheBereit, warte(HOECHSTENS_WARTEN_MS)]);
const rest = MINDESTENS_DREHEN_MS - (Date.now() - beginn);
if (rest > 0) await warte(rest);
if (hauptfenster.isDestroyed()) return;
// Unsichtbar zeigen, damit Chromium die Oberfläche zeichnet, dann abfotografieren
hauptfenster.setOpacity(0);
hauptfenster.setIgnoreMouseEvents(true); // unsichtbar: Klicks gehen nicht versehentlich hinein
hauptfenster.show();
await warteAufGezeichneteOberflaeche(hauptfenster.webContents);
if (hauptfenster.isDestroyed()) return;
const bild = await hauptfenster.webContents.capturePage();
if (!bild.isEmpty() && !start.isDestroyed()) {
// höchstens 2,5 s – das Hauptfenster darf nie unsichtbar hängen bleiben
await Promise.race([start.webContents.executeJavaScript(`aufbauen(${JSON.stringify(bild.toDataURL())})`), warte(2500)]);
}
} catch {
// Animation ist nur Zierde – im Fehlerfall einfach den Launcher zeigen
} finally {
launcherZeigen();
setTimeout(() => { if (!start.isDestroyed()) start.destroy(); }, 80);
}
}
module.exports = { spielen };
+40 -8
View File
@@ -88,6 +88,8 @@ const DEFAULT_SETTINGS = {
bgSkin: '', // '' = aus, 'active' = aktives Konto, sonst Konto-UUID
bgSkinOpacity: 55, // Deckkraft des Hintergrund-Skins in %
bgSkinPose: 'walking', // Pose des 3D-Renders (falls der Pose-Dienst erreichbar ist)
startAnimation: true, // drehendes Logo + Fensteraufbau beim Öffnen des Launchers
uiAnimationen: true, // bewegte Oberfläche: lebende Skin-Figur, aufwachsende Fenster, Einblenden, Überblenden
bgSkinModel: 'auto', // Armform der Figur: 'auto' (vom Konto), 'classic' (Steve), 'slim' (Alex)
bgSkinCycle: false, // bei 2+ angemeldeten Konten automatisch durchwechseln
bgSkinCycleSeconds: 30, // Sekunden zwischen zwei Kontowechseln
@@ -424,6 +426,35 @@ function createInstance(data) {
return writeInstance(inst);
}
/*
* Große Instanzordner nicht synchron löschen (das fror den Launcher bei vielen
* GB minutenlang ein): sofort in einen versteckten Papierkorb im Instanzordner
* verschieben – das ist nur eine Umbenennung – und dort im Hintergrund löschen.
* Reste (z. B. nach einem Absturz) räumt raeumePapierkorbAuf() beim Start weg.
*/
const PAPIERKORB = '.aeromc-papierkorb';
function entsorgeOrdner(p) {
if (!fs.existsSync(p)) return;
const korb = path.join(path.dirname(p), PAPIERKORB);
const ziel = path.join(korb, path.basename(p) + '-' + Date.now());
try {
ensureDir(korb);
fs.renameSync(p, ziel);
} catch {
// Umbenennen klappt nicht (z. B. Datei gerade geöffnet): instance.json sofort
// weg, damit die Instanz aus der Liste verschwindet – den Rest im Hintergrund
try { fs.rmSync(path.join(p, 'instance.json'), { force: true }); } catch { /* egal */ }
fs.promises.rm(p, { recursive: true, force: true }).catch(() => { /* bleibt liegen, stört nicht */ });
return;
}
fs.promises.rm(ziel, { recursive: true, force: true }).catch(() => { /* beim nächsten Start erneut */ });
}
function raeumePapierkorbAuf() {
const korb = path.join(getInstancesDir(), PAPIERKORB);
if (fs.existsSync(korb)) fs.promises.rm(korb, { recursive: true, force: true }).catch(() => {});
}
function createOrReplaceImportedInstance(data) {
const existing = findInstanceByName(data && data.name);
if (!existing) return createInstance(data);
@@ -435,8 +466,7 @@ function createOrReplaceImportedInstance(data) {
inst.totalPlaySeconds = existing.totalPlaySeconds || 0;
inst.launchCount = existing.launchCount || 0;
const p = instancePath(existing.id);
if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true });
entsorgeOrdner(instancePath(existing.id));
return writeInstance(inst);
}
@@ -452,12 +482,11 @@ function updateInstance(id, patch) {
}
function deleteInstance(id) {
const p = instancePath(id);
if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true });
entsorgeOrdner(instancePath(id));
return true;
}
function duplicateInstance(id) {
function duplicateInstance(id, ohneSpieldaten) {
const src = getInstance(id);
if (!src) throw new Error('Instanz nicht gefunden: ' + id);
const copy = JSON.parse(JSON.stringify(src));
@@ -469,9 +498,11 @@ function duplicateInstance(id) {
copy.launchCount = 0;
ensureDir(gameDir(copy.id));
// Spielinhalte mitkopieren (Mods/Configs) – ohne Fehler wenn leer
try {
fs.cpSync(gameDir(id), gameDir(copy.id), { recursive: true });
} catch { /* Quelle evtl. leer */ }
if (!ohneSpieldaten) {
try {
fs.cpSync(gameDir(id), gameDir(copy.id), { recursive: true });
} catch { /* Quelle evtl. leer */ }
}
writeJson(instanceFile(copy.id), copy);
return copy;
}
@@ -500,5 +531,6 @@ module.exports = {
createOrReplaceImportedInstance,
updateInstance,
deleteInstance,
raeumePapierkorbAuf,
duplicateInstance,
};
+917 -722
View File
File diff suppressed because it is too large Load Diff
+183 -172
View File
@@ -1,172 +1,183 @@
'use strict';
/*
* technicimport.js – Instanzen aus dem Technic Launcher übernehmen
* -------------------------------------------------------------------
* Der Technic Launcher legt jedes installierte Modpack als eigenen Ordner
* unter "<Technic-Wurzel>/modpacks/<name>" an (Standard unter Windows:
* %APPDATA%\.technic\modpacks). Darin liegen "bin" (Launcher-interner
* Jar-/Versions-Cache), "config" und "mods" als Geschwisterordner – Welten,
* Ressourcenpakete usw. landen ebenfalls direkt im Pack-Ordner, genau wie
* bei einem klassischen .minecraft.
*
* Es gibt keine für uns nutzbare, stabil dokumentierte JSON-Datei mit einem
* schönen Anzeigenamen – "installedPacks" im Wurzelordner listet Packs nur
* über ihren Ordnernamen (Slug). Wir lesen sie deshalb nur als optionale,
* fehlertolerante Ergänzung; die eigentliche Erkennung läuft – wie bei den
* anderen Importern – über die tatsächlich vorhandenen Ordner/Dateien.
*/
const fs = require('fs');
const path = require('path');
const forge = require('./forge');
// Ordner, die zum Technic-internen Verwaltungs-/Jar-Cache gehören und beim
// Übernehmen NICHT mit ins Spielverzeichnis der neuen Instanz sollen.
const SKIP_DIRS = new Set(['bin']);
function technicRoot() {
return path.join(process.env.APPDATA || '', '.technic');
}
function readJson(file) {
try { return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^/, '')); }
catch { return null; }
}
// findet den modpacks-Ordner, egal ob Technic-Wurzel, modpacks-Ordner direkt
// oder ein einzelner Pack-Ordner angegeben wurde
function resolveModpacksDir(inputPath) {
const root = inputPath && inputPath.trim() ? inputPath.trim() : technicRoot();
if (!fs.existsSync(root)) return null;
const direct = path.join(root, 'modpacks');
if (fs.existsSync(direct)) return direct;
if (path.basename(root).toLowerCase() === 'modpacks') return root;
return root; // z. B. ein selbst gewählter Ordner mit Pack-Unterordnern
}
// ein Pack-Ordner hat i. d. R. bin/, config/ und/oder mods/ als Geschwister
function looksLikePack(dir) {
const markers = ['bin', 'mods', 'config', 'saves', 'resourcepacks', 'options.txt'];
return markers.some((name) => fs.existsSync(path.join(dir, name)));
}
// optionale Anreicherung aus der "installedPacks"-Datei der Technic-Wurzel;
// bewusst rein additiv und fehlertolerant, da das Format nicht offiziell
// als stabile API dokumentiert ist
function readInstalledPacksMeta(root) {
const data = readJson(path.join(root, 'installedPacks'));
const packs = data && data.installedPacks;
return packs && typeof packs === 'object' ? packs : {};
}
// versucht, aus bin/modpack.jar (version.json) oder der alten bin/version
// -Textdatei Minecraft-Version und Mod-Loader zu bestimmen. Rein
// bestmöglich – schlägt die Erkennung fehl, bleibt die Instanz einfach
// ohne Version/Loader stehen und lässt sich manuell nachtragen.
function detectVersion(packDir) {
const bin = path.join(packDir, 'bin');
const jarFile = path.join(bin, 'modpack.jar');
if (fs.existsSync(jarFile)) {
try {
const buf = fs.readFileSync(jarFile);
const idx = forge.readZipIndex(buf);
const entry = idx['version.json'];
if (entry) {
// manche Jars speichern JSON mit BOM – ohne Entfernen bricht JSON.parse
const text = forge.readZipEntry(buf, entry).toString('utf8').replace(/^/, '');
const profile = JSON.parse(text);
const libs = Array.isArray(profile.libraries) ? profile.libraries : [];
let loader = 'vanilla';
let loaderVersion = '';
for (const lib of libs) {
const name = String((lib && lib.name) || '');
if (/net\.minecraftforge:forge:/i.test(name)) {
loader = 'forge';
loaderVersion = (name.split(':')[2] || '').split('-').pop() || '';
} else if (/net\.neoforged:neoforge:/i.test(name) && loader === 'vanilla') {
loader = 'neoforge';
loaderVersion = name.split(':')[2] || '';
}
}
const id = String(profile.id || profile.inheritsFrom || '');
const version = String(profile.inheritsFrom || id.split('-')[0] || '').trim();
if (version) return { version, loader, loaderVersion };
}
} catch { /* modpack.jar nicht lesbar/kein gültiges ZIP – weiter unten Fallback */ }
}
// ältere Packs hinterlegen nur eine reine Textdatei mit der MC-Version
const legacy = path.join(bin, 'version');
try {
if (fs.existsSync(legacy)) {
const version = fs.readFileSync(legacy, 'utf8').trim();
if (version) return { version, loader: 'vanilla', loaderVersion: '' };
}
} catch { /* egal, bleibt leer */ }
return { version: '', loader: 'vanilla', loaderVersion: '' };
}
function scan(inputPath) {
const modpacksDir = resolveModpacksDir(inputPath);
if (!modpacksDir || !fs.existsSync(modpacksDir)) return { ok: false, reason: 'not-found' };
const root = path.basename(modpacksDir).toLowerCase() === 'modpacks'
? path.dirname(modpacksDir) : modpacksDir;
const meta = readInstalledPacksMeta(root);
const list = [];
for (const entry of fs.readdirSync(modpacksDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const dir = path.join(modpacksDir, entry.name);
if (!looksLikePack(dir)) continue;
const { version, loader, loaderVersion } = detectVersion(dir);
const info = meta[entry.name] || {};
list.push({
folder: entry.name,
dir,
name: info.displayName || entry.name,
group: '',
notes: '',
version,
loader,
loaderVersion,
gameDir: dir,
hasGameData: true,
});
}
list.sort((a, b) => a.name.localeCompare(b.name));
return { ok: true, instancesDir: modpacksDir, count: list.length, instances: list };
}
// kopiert den Pack-Ordner, lässt aber den Technic-internen bin-Cache weg
function copyInto(sourceDir, targetDir) {
fs.mkdirSync(targetDir, { recursive: true });
for (const name of fs.readdirSync(sourceDir)) {
if (SKIP_DIRS.has(name)) continue;
fs.cpSync(path.join(sourceDir, name), path.join(targetDir, name), { recursive: true });
}
}
function importOne(store, entry, copyData = true) {
const created = store.createOrReplaceImportedInstance({
name: entry.name,
group: entry.group,
notes: entry.notes,
minecraft: {
version: entry.version,
loader: entry.loader,
loaderVersion: entry.loaderVersion,
},
});
let copied = false;
if (copyData && entry.gameDir && fs.existsSync(entry.gameDir)) {
copyInto(entry.gameDir, store.gameDir(created.id));
copied = true;
}
return { id: created.id, name: created.name, copied };
}
module.exports = { scan, importOne, resolveModpacksDir, technicRoot, detectVersion };
'use strict';
/*
* technicimport.js – Instanzen aus dem Technic Launcher übernehmen
* -------------------------------------------------------------------
* Der Technic Launcher legt jedes installierte Modpack als eigenen Ordner
* unter "<Technic-Wurzel>/modpacks/<name>" an (Standard unter Windows:
* %APPDATA%\.technic\modpacks). Darin liegen "bin" (Launcher-interner
* Jar-/Versions-Cache), "config" und "mods" als Geschwisterordner – Welten,
* Ressourcenpakete usw. landen ebenfalls direkt im Pack-Ordner, genau wie
* bei einem klassischen .minecraft.
*
* Es gibt keine für uns nutzbare, stabil dokumentierte JSON-Datei mit einem
* schönen Anzeigenamen – "installedPacks" im Wurzelordner listet Packs nur
* über ihren Ordnernamen (Slug). Wir lesen sie deshalb nur als optionale,
* fehlertolerante Ergänzung; die eigentliche Erkennung läuft – wie bei den
* anderen Importern – über die tatsächlich vorhandenen Ordner/Dateien.
*/
const fs = require('fs');
const path = require('path');
const forge = require('./forge');
// Ordner, die zum Technic-internen Verwaltungs-/Jar-Cache gehören und beim
// Übernehmen NICHT mit ins Spielverzeichnis der neuen Instanz sollen.
const SKIP_DIRS = new Set(['bin']);
function technicRoot() {
return path.join(process.env.APPDATA || '', '.technic');
}
function readJson(file) {
try { return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^/, '')); }
catch { return null; }
}
// findet den modpacks-Ordner, egal ob Technic-Wurzel, modpacks-Ordner direkt
// oder ein einzelner Pack-Ordner angegeben wurde
function resolveModpacksDir(inputPath) {
const root = inputPath && inputPath.trim() ? inputPath.trim() : technicRoot();
if (!fs.existsSync(root)) return null;
const direct = path.join(root, 'modpacks');
if (fs.existsSync(direct)) return direct;
if (path.basename(root).toLowerCase() === 'modpacks') return root;
return root; // z. B. ein selbst gewählter Ordner mit Pack-Unterordnern
}
// ein Pack-Ordner hat i. d. R. bin/, config/ und/oder mods/ als Geschwister
function looksLikePack(dir) {
const markers = ['bin', 'mods', 'config', 'saves', 'resourcepacks', 'options.txt'];
return markers.some((name) => fs.existsSync(path.join(dir, name)));
}
// optionale Anreicherung aus der "installedPacks"-Datei der Technic-Wurzel;
// bewusst rein additiv und fehlertolerant, da das Format nicht offiziell
// als stabile API dokumentiert ist
function readInstalledPacksMeta(root) {
const data = readJson(path.join(root, 'installedPacks'));
const packs = data && data.installedPacks;
return packs && typeof packs === 'object' ? packs : {};
}
// versucht, aus bin/modpack.jar (version.json) oder der alten bin/version
// -Textdatei Minecraft-Version und Mod-Loader zu bestimmen. Rein
// bestmöglich – schlägt die Erkennung fehl, bleibt die Instanz einfach
// ohne Version/Loader stehen und lässt sich manuell nachtragen.
function detectVersion(packDir) {
const bin = path.join(packDir, 'bin');
const jarFile = path.join(bin, 'modpack.jar');
if (fs.existsSync(jarFile)) {
try {
const buf = fs.readFileSync(jarFile);
const idx = forge.readZipIndex(buf);
const entry = idx['version.json'];
if (entry) {
// manche Jars speichern JSON mit BOM – ohne Entfernen bricht JSON.parse
const text = forge.readZipEntry(buf, entry).toString('utf8').replace(/^/, '');
const profile = JSON.parse(text);
const libs = Array.isArray(profile.libraries) ? profile.libraries : [];
let loader = 'vanilla';
let loaderVersion = '';
for (const lib of libs) {
const name = String((lib && lib.name) || '');
if (/net\.minecraftforge:forge:/i.test(name)) {
loader = 'forge';
loaderVersion = (name.split(':')[2] || '').split('-').pop() || '';
} else if (/net\.neoforged:neoforge:/i.test(name) && loader === 'vanilla') {
loader = 'neoforge';
loaderVersion = name.split(':')[2] || '';
}
}
const id = String(profile.id || profile.inheritsFrom || '');
const version = String(profile.inheritsFrom || id.split('-')[0] || '').trim();
if (version) return { version, loader, loaderVersion };
}
} catch { /* modpack.jar nicht lesbar/kein gültiges ZIP – weiter unten Fallback */ }
}
// ältere Packs hinterlegen nur eine reine Textdatei mit der MC-Version
const legacy = path.join(bin, 'version');
try {
if (fs.existsSync(legacy)) {
const version = fs.readFileSync(legacy, 'utf8').trim();
if (version) return { version, loader: 'vanilla', loaderVersion: '' };
}
} catch { /* egal, bleibt leer */ }
return { version: '', loader: 'vanilla', loaderVersion: '' };
}
function scan(inputPath) {
const modpacksDir = resolveModpacksDir(inputPath);
if (!modpacksDir || !fs.existsSync(modpacksDir)) return { ok: false, reason: 'not-found' };
const root = path.basename(modpacksDir).toLowerCase() === 'modpacks'
? path.dirname(modpacksDir) : modpacksDir;
const meta = readInstalledPacksMeta(root);
const list = [];
for (const entry of fs.readdirSync(modpacksDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const dir = path.join(modpacksDir, entry.name);
if (!looksLikePack(dir)) continue;
const { version, loader, loaderVersion } = detectVersion(dir);
const info = meta[entry.name] || {};
list.push({
folder: entry.name,
dir,
name: info.displayName || entry.name,
group: '',
notes: '',
version,
loader,
loaderVersion,
gameDir: dir,
hasGameData: true,
});
}
list.sort((a, b) => a.name.localeCompare(b.name));
return { ok: true, instancesDir: modpacksDir, count: list.length, instances: list };
}
// kopiert den Pack-Ordner, lässt aber den Technic-internen bin-Cache weg
function copyInto(sourceDir, targetDir) {
fs.mkdirSync(targetDir, { recursive: true });
for (const name of fs.readdirSync(sourceDir)) {
if (SKIP_DIRS.has(name)) continue;
fs.cpSync(path.join(sourceDir, name), path.join(targetDir, name), { recursive: true });
}
}
/*
* Was beim Übernehmen kopiert wird: [{ von, name }] – `name` ist der Zielname
* im Spielordner der neuen Instanz. Das Kopieren selbst erledigt kopieren.js
* asynchron mit Fortschritt (main.js → fuehreImportAus).
*/
function kopierQuellen(entry) {
if (!entry.gameDir || !fs.existsSync(entry.gameDir)) return [];
return fs.readdirSync(entry.gameDir).filter((name) => !SKIP_DIRS.has(name))
.map((name) => ({ von: path.join(entry.gameDir, name), name }));
}
function importOne(store, entry, copyData = true) {
const created = store.createOrReplaceImportedInstance({
name: entry.name,
group: entry.group,
notes: entry.notes,
minecraft: {
version: entry.version,
loader: entry.loader,
loaderVersion: entry.loaderVersion,
},
});
let copied = false;
if (copyData && entry.gameDir && fs.existsSync(entry.gameDir)) {
copyInto(entry.gameDir, store.gameDir(created.id));
copied = true;
}
return { id: created.id, name: created.name, copied };
}
module.exports = { scan, importOne, kopierQuellen, resolveModpacksDir, technicRoot, detectVersion };
@@ -2,7 +2,7 @@
/**
* Plugin Name: AeroMC Freunde
* Description: Backend für das Freundesystem des AeroMC Launchers - meldet, wer gerade online/im Spiel ist, verwaltet Freundschaftsanfragen, Einladungen, Kurznachrichten, per Code geteilte Instanzen und Fehlerberichte aus dem Absturz-Assistenten sowie eigene Umhänge (auch HD) aus dem Skin-Editor. Anmeldung über Mojangs Sitzungsprüfung (wie bei Minecraft-Servern) - ohne eigenes Passwort.
* Version: 1.5.0
* Version: 1.5.1
* Author: M_Viper
*/
@@ -10,7 +10,7 @@ if (!defined('ABSPATH')) {
exit; // kein direkter Aufruf der Datei
}
define('AEROMC_FRIENDS_VERSION', '1.5.0');
define('AEROMC_FRIENDS_VERSION', '1.5.1');
define('AEROMC_FRIENDS_PRESENCE_TABLE', 'aeromc_presence');
define('AEROMC_FRIENDS_FRIENDS_TABLE', 'aeromc_friends');
define('AEROMC_FRIENDS_INVITES_TABLE', 'aeromc_invites');
File diff suppressed because it is too large Load Diff