Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de4db5130c | ||
|
|
4a608d5849 | ||
|
|
70730d2492 | ||
|
|
72f4812a23 | ||
|
|
645bf4d2a0 | ||
|
|
2d7ff95c4d | ||
|
|
b5dd76614e | ||
|
|
beaf865c5f | ||
|
|
cd85f9d0ac | ||
|
|
27c5fbb856 | ||
|
|
67337c5b1e | ||
|
|
1e5d8b5b95 | ||
|
|
ceb37566c6 | ||
|
|
c56b41de34 | ||
|
|
4a7a619f35 | ||
|
|
e7f8371b5f | ||
|
|
15d868519e | ||
|
|
5d6117f8b5 | ||
|
|
009038d48d | ||
|
|
3a7822302f | ||
|
|
aae442ae48 | ||
|
|
5611c5883a | ||
|
|
d976cb4595 | ||
|
|
3ac5a6c14d | ||
|
|
7e7a2021ed | ||
|
|
7e5d743367 | ||
|
|
6ef51edede | ||
|
|
93c74f94a1 | ||
|
|
384354f44e | ||
|
|
9afe91a592 | ||
|
|
de81575bcf | ||
|
|
76762ab567 | ||
|
|
6a0e92eb0f |
@@ -2,6 +2,102 @@
|
|||||||
|
|
||||||
Alle nennenswerten Änderungen an AeroMC werden hier festgehalten.
|
Alle nennenswerten Änderungen an AeroMC werden hier festgehalten.
|
||||||
|
|
||||||
|
## [1.2.4] - 2026-10-11
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
- Beta-Kanal: Unter Optionen → Allgemein gibt es den Schalter „An der Beta teilnehmen“. Wer ihn einschaltet, bekommt Vorabfassungen (Beta, RC) als Update; wer ihn ausschaltet, bekommt ab der nächsten fertigen Version wieder nur fertige Versionen. Wer eine Beta von Hand installiert, nimmt automatisch teil
|
||||||
|
- Vorabfassungen sind oben in der Titelleiste gekennzeichnet (z. B. „Beta 1“)
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
- Beta-Nutzern wurde die fertige Version derselben Nummer nicht als Update angeboten (z. B. von 1.3.0-beta.2 auf 1.3.0)
|
||||||
|
- Bei vielen Vorabfassungen hintereinander konnte die neueste fertige Version aus der Update-Suche fallen
|
||||||
|
|
||||||
|
## [1.2.3] - 2026-10-07
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
- Nach der Installation hing das Setup beim Klick auf „Fertigstellen“ einige Sekunden, bevor AeroMC startete – AeroMC öffnet sich jetzt sofort
|
||||||
|
|
||||||
|
## [1.2.2] - 2026-10-05
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
- AeroMC-Look im Spiel (Minecraft 1.14 bis 26.x, Vanilla und mit Loadern – ohne Mod):
|
||||||
|
- Ladebildschirm im Vollbild mit Landschaftsbild, großem AeroMC-Logo samt Pyramide, „DEIN MINECRAFT ERLEBNIS“, hellblauem Fortschrittsbalken und „Wird geladen …“ statt des roten Mojang-Bildschirms (bei 1.14/1.15 AeroMC-Logo auf Nachtblau)
|
||||||
|
- Hauptmenü mit großem AeroMC-Logo, Landschaft als drehendem Hintergrund, eigenen Sprüchen („Mehr als nur Minecraft!“) und Knöpfen im AeroMC-Stil – dunkles Schiefer, beim Darüberfahren leuchtend blau, mit Symbolen vor „Einzelspieler“, „Mehrspieler“, „Minecraft Realms“, „Optionen …“ und „Spiel beenden“
|
||||||
|
- Fenstertitel „AeroMC - Minecraft …“ und AeroMC-Fenstersymbol
|
||||||
|
- Abschaltbar unter Optionen → Spiel → AeroMC-Look; eigene Ressourcenpakete des Spielers haben immer Vorrang
|
||||||
|
- Mods im Spiel verwalten (Instanzen mit Fabric, Quilt, Forge oder NeoForge, Minecraft 1.14 bis 26.x): Im Pausemenü wird „Feedback geben“ zu „Mods verwalten“ (ab 1.21 unter „Mods & Feedback“, in 26.x als Puzzle-Symbol). Es öffnet sich ein AeroMC-Fenster über dem Spiel mit zwei Listen wie bei den Ressourcenpaketen – „Aktive Mods“ und „Deaktivierte Mods“, ein Klick verschiebt eine Mod in die andere Liste, mit Suche und Warnung, wenn andere Mods sie brauchen
|
||||||
|
- Das Spiel läuft dabei weiter: Geladene Mods lassen sich nicht im laufenden Spiel entladen, die Änderung wird vorgemerkt und beim Beenden übernommen
|
||||||
|
- „Übernehmen & neu starten“: Minecraft speichert, startet mit den neuen Mods neu und verbindet automatisch wieder mit demselben Server (ab 1.20 auch zurück in dieselbe Einzelspieler-Welt) – mit demselben Konto wie vorher; im Mod-Fenster steht, wohin der Neustart führt
|
||||||
|
- Neustart-Bildschirm: Während des Neustarts liegt der AeroMC-Ladebildschirm über dem Spielfenster und zeigt, was gerade passiert („Mods werden übernommen …“, „Minecraft startet …“, „Verbinde mit … “) – bis man wieder auf dem Server bzw. in der Welt ist
|
||||||
|
- Nach dem Neustart hat das Spielfenster wieder dieselbe Größe und Lage wie vorher – war es maximiert, ist es wieder maximiert
|
||||||
|
- Auch im Mod-Dialog des Launchers lassen sich Mods jetzt umschalten, während das Spiel läuft („ab nächstem Start“) – vorher kam eine Fehlermeldung, weil Windows die Dateien sperrt
|
||||||
|
- Abschaltbar unter Optionen → Spiel → Mods im Spiel verwalten
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
- Mod-Dateien, die aus dem Explorer auf eine Gruppe in der Instanzliste gezogen wurden, wurden stillschweigend ignoriert
|
||||||
|
- Ingame-Meldungen und Fenster über dem Spiel fanden in manchen Fällen das versteckte Java-Konsolenfenster statt des Spielfensters
|
||||||
|
|
||||||
|
## [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
|
## [1.1.8] - 2026-10-04
|
||||||
|
|
||||||
### Behoben
|
### Behoben
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
DATENSCHUTZERKLÄRUNG FÜR AEROMC
|
||||||
|
Stand: 7. Oktober 2026
|
||||||
|
|
||||||
|
Diese Datenschutzerklärung gilt für den Minecraft-Launcher AeroMC und die zugehörigen Online-Dienste. AeroMC ist ein inoffizielles Programm und steht in keiner Verbindung zu Mojang Studios oder Microsoft.
|
||||||
|
|
||||||
|
|
||||||
|
1. VERANTWORTLICHER
|
||||||
|
|
||||||
|
[Vor- und Nachname]
|
||||||
|
[Straße und Hausnummer]
|
||||||
|
[PLZ und Ort]
|
||||||
|
[Land]
|
||||||
|
E-Mail: [E-Mail-Adresse]
|
||||||
|
|
||||||
|
|
||||||
|
2. GRUNDSATZ
|
||||||
|
|
||||||
|
AeroMC läuft auf deinem Computer. Instanzen, Mods, Welten, Screenshots, Aufnahmen, Einstellungen und Sicherungen werden nur lokal gespeichert. Diese Daten erhalten wir nicht.
|
||||||
|
|
||||||
|
Personenbezogene Daten werden nur übertragen, wenn du eine Funktion nutzt, die das erfordert, zum Beispiel die Anmeldung mit deinem Microsoft-Konto oder das Freundesystem. Es gibt keine Werbung, kein Tracking und keine Analyse-Dienste.
|
||||||
|
|
||||||
|
|
||||||
|
3. ANMELDUNG MIT MICROSOFT / MINECRAFT
|
||||||
|
|
||||||
|
Um Minecraft: Java Edition zu starten, meldest du dich mit deinem Microsoft-Konto an. Die Anmeldung läuft direkt zwischen deinem Computer und den Diensten von Microsoft, Xbox Live und Mojang.
|
||||||
|
|
||||||
|
- Dein Passwort gibst du nur auf der Seite von Microsoft ein. AeroMC sieht es nicht.
|
||||||
|
- Die erhaltenen Zugangsschlüssel, dein Minecraft-Name und deine Spieler-ID (UUID) werden verschlüsselt auf deinem Computer gespeichert.
|
||||||
|
- Änderst du Skin oder Umhang über AeroMC, wird das an Mojang übertragen.
|
||||||
|
|
||||||
|
Es gelten die Datenschutzbestimmungen von Microsoft: https://privacy.microsoft.com/de-de/privacystatement
|
||||||
|
Rechtsgrundlage: Art. 6 Abs. 1 lit. b DSGVO.
|
||||||
|
|
||||||
|
|
||||||
|
4. FREUNDESYSTEM UND AEROMC-DIENSTE
|
||||||
|
|
||||||
|
Das Freundesystem ist optional und lässt sich unter Optionen → Freunde & Umhänge jederzeit abschalten. Es läuft auf unserem Server viper-network.de.
|
||||||
|
|
||||||
|
Die Anmeldung erfolgt wie bei einem Minecraft-Server über die Sitzungsprüfung von Mojang. Ein eigenes Passwort gibt es nicht. Deine Microsoft-Zugangsdaten erhalten wir nicht.
|
||||||
|
|
||||||
|
Wir speichern:
|
||||||
|
- Minecraft-Spieler-ID und Minecraft-Name – um dich im Freundesystem zu erkennen und auffindbar zu machen; bis zur Löschung auf Anfrage.
|
||||||
|
- Sitzungsschlüssel (nur als Prüfwert) – um angemeldet zu bleiben; 30 Tage nach letzter Nutzung gelöscht.
|
||||||
|
- Online-Status: Zeitpunkt, Instanzname, Minecraft-Version und – sofern nicht abgeschaltet – die Server-Adresse, auf der du spielst; wird bei jeder Nutzung überschrieben.
|
||||||
|
- Freundschaften und Freundschaftsanfragen – bis du sie entfernst.
|
||||||
|
- Einladungen auf einen Server – 10 Minuten.
|
||||||
|
- Kurznachrichten und mitgeschickte Screenshots – 7 Tage.
|
||||||
|
- Eigene Umhänge (Bilddatei) – bis du sie entfernst.
|
||||||
|
- Geteilte Instanzen (Liste von Version, Loader und Mods, keine Dateien) – 30 Tage.
|
||||||
|
|
||||||
|
Diese Daten sehen nur deine Freunde bzw. die Personen, denen du etwas schickst oder einen Code gibst. Eigene Umhänge sind für alle AeroMC-Spieler sichtbar, die dir im Spiel begegnen.
|
||||||
|
Rechtsgrundlage: Art. 6 Abs. 1 lit. b DSGVO.
|
||||||
|
|
||||||
|
|
||||||
|
5. FEHLERBERICHTE (FREIWILLIG)
|
||||||
|
|
||||||
|
Stürzt Minecraft ab, kann der Absturz-Assistent auf deinen ausdrücklichen Wunsch einen Fehlerbericht senden. Er enthält Launcher-, Minecraft- und Loader-Version, Windows-Version, Arbeitsspeicher, die Mod-Liste der Instanz, das Ende von Absturzbericht, Spielprotokoll und Konsolenausgabe sowie deinen optionalen Kommentar.
|
||||||
|
|
||||||
|
Dein Minecraft-Name und deine Spieler-ID werden vor dem Senden entfernt, außer du wählst ausdrücklich, den Bericht mit Namen zu senden. Fehlerberichte nutzen wir nur zur Fehlerbehebung und löschen sie, sobald sie nicht mehr benötigt werden.
|
||||||
|
Rechtsgrundlage: Art. 6 Abs. 1 lit. a DSGVO (Einwilligung durch das Absenden).
|
||||||
|
|
||||||
|
|
||||||
|
6. DOWNLOADS VON SPIELDATEIEN UND INHALTEN
|
||||||
|
|
||||||
|
Für Minecraft, Java und die von dir gewählten Inhalte lädt AeroMC Dateien direkt bei den jeweiligen Anbietern herunter. Diese erhalten technisch bedingt deine IP-Adresse und übliche Verbindungsdaten, aber keine weiteren personenbezogenen Daten:
|
||||||
|
- Mojang / Microsoft – Minecraft-Dateien, Skins und Umhänge
|
||||||
|
- Eclipse Adoptium – Java-Laufzeit
|
||||||
|
- Modrinth – Mods, Ressourcenpakete, Shader, Modpacks
|
||||||
|
- CurseForge – Mods und Modpacks (die Suche läuft über unseren Weiterleitungs-Server)
|
||||||
|
- Fabric, Quilt, Forge, NeoForge – Mod-Loader
|
||||||
|
- Technic – Technic-Modpacks
|
||||||
|
|
||||||
|
Es gelten die Datenschutzbestimmungen des jeweiligen Anbieters.
|
||||||
|
Rechtsgrundlage: Art. 6 Abs. 1 lit. b DSGVO.
|
||||||
|
|
||||||
|
|
||||||
|
7. ANZEIGE VON SKINS
|
||||||
|
|
||||||
|
Zur Darstellung von Spielerfiguren ruft AeroMC Bilder anhand der Spieler-ID bei crafthead.net, visage.surgeplay.com und nmsr.nickac.dev ab. Diese erhalten die Spieler-ID und deine IP-Adresse.
|
||||||
|
Rechtsgrundlage: Art. 6 Abs. 1 lit. f DSGVO.
|
||||||
|
|
||||||
|
|
||||||
|
8. UPDATES
|
||||||
|
|
||||||
|
Die Fassung aus dem Microsoft Store wird ausschließlich über den Microsoft Store aktualisiert. Die Fassung von unserer Webseite prüft beim Start auf unseren Servern, ob es eine neue Version gibt. Dabei wird außer der IP-Adresse nichts übertragen.
|
||||||
|
Rechtsgrundlage: Art. 6 Abs. 1 lit. f DSGVO.
|
||||||
|
|
||||||
|
|
||||||
|
9. DISCORD
|
||||||
|
|
||||||
|
Läuft auf deinem Computer Discord, kann AeroMC dort anzeigen, welche Instanz du spielst. Diese Information geht nur an das Discord-Programm auf deinem Computer und von dort nach den Bestimmungen von Discord an deine Discord-Kontakte.
|
||||||
|
|
||||||
|
|
||||||
|
10. SERVER-PROTOKOLLE
|
||||||
|
|
||||||
|
Beim Zugriff auf unsere Server werden technisch bedingt IP-Adresse, Zeitpunkt und angefragte Adresse kurzzeitig protokolliert, um Betrieb und Sicherheit zu gewährleisten.
|
||||||
|
Rechtsgrundlage: Art. 6 Abs. 1 lit. f DSGVO.
|
||||||
|
|
||||||
|
|
||||||
|
11. DEINE RECHTE
|
||||||
|
|
||||||
|
Du hast das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung, Datenübertragbarkeit und Widerspruch. Eine Einwilligung kannst du jederzeit für die Zukunft widerrufen. Wende dich dafür an die oben genannte E-Mail-Adresse und gib deinen Minecraft-Namen an. Du kannst dich außerdem bei einer Datenschutz-Aufsichtsbehörde beschweren.
|
||||||
|
|
||||||
|
|
||||||
|
12. KINDER
|
||||||
|
|
||||||
|
AeroMC richtet sich nicht an Kinder unter 16 Jahren. Jüngere sollten das Freundesystem nur mit Zustimmung der Eltern nutzen.
|
||||||
|
|
||||||
|
|
||||||
|
13. ÄNDERUNGEN
|
||||||
|
|
||||||
|
Wir passen diese Datenschutzerklärung an, wenn sich AeroMC oder die Rechtslage ändert. Es gilt die jeweils aktuelle Fassung.
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
# Datenschutzerklärung für AeroMC
|
||||||
|
|
||||||
|
Stand: 7. Oktober 2026
|
||||||
|
|
||||||
|
Diese Datenschutzerklärung gilt für den Minecraft-Launcher **AeroMC** (Windows-Programm, auch
|
||||||
|
aus dem Microsoft Store) und die zugehörigen Online-Dienste. AeroMC ist ein inoffizielles
|
||||||
|
Programm und steht in keiner Verbindung zu Mojang Studios oder Microsoft.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Verantwortlicher
|
||||||
|
|
||||||
|
**[Vor- und Nachname]**
|
||||||
|
[Straße und Hausnummer]
|
||||||
|
[PLZ und Ort]
|
||||||
|
[Land]
|
||||||
|
E-Mail: **[E-Mail-Adresse]**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Grundsatz
|
||||||
|
|
||||||
|
AeroMC läuft auf deinem Computer. Instanzen, Mods, Welten, Screenshots, Aufnahmen,
|
||||||
|
Einstellungen und Sicherungen werden **nur lokal** gespeichert, im Ordner
|
||||||
|
`%APPDATA%\AeroMC` bzw. `Dokumente\AeroMC Launcher`. Diese Daten erhalten wir nicht.
|
||||||
|
|
||||||
|
Personenbezogene Daten werden nur übertragen, wenn du eine Funktion nutzt, die das
|
||||||
|
erfordert – zum Beispiel die Anmeldung mit deinem Microsoft-Konto oder das Freundesystem.
|
||||||
|
Es gibt **keine Werbung, kein Tracking und keine Analyse-Dienste**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Anmeldung mit Microsoft / Minecraft
|
||||||
|
|
||||||
|
Um Minecraft: Java Edition zu starten, meldest du dich mit deinem Microsoft-Konto an.
|
||||||
|
Die Anmeldung läuft direkt zwischen deinem Computer und den Diensten von Microsoft, Xbox Live
|
||||||
|
und Mojang (`login.microsoftonline.com`, `user.auth.xboxlive.com`, `xsts.auth.xboxlive.com`,
|
||||||
|
`api.minecraftservices.com`, `sessionserver.mojang.com`).
|
||||||
|
|
||||||
|
- Dein Passwort gibst du nur auf der Seite von Microsoft ein. AeroMC sieht es nicht.
|
||||||
|
- Die erhaltenen Zugangsschlüssel (Tokens), dein Minecraft-Name und deine Spieler-ID (UUID)
|
||||||
|
werden **verschlüsselt auf deinem Computer** gespeichert (Windows-Datenschutz-API).
|
||||||
|
- Änderst du Skin oder Umhang über AeroMC, wird das an Mojang übertragen.
|
||||||
|
|
||||||
|
Für diese Datenverarbeitung gelten die Datenschutzbestimmungen von Microsoft:
|
||||||
|
https://privacy.microsoft.com/de-de/privacystatement
|
||||||
|
|
||||||
|
**Rechtsgrundlage:** Art. 6 Abs. 1 lit. b DSGVO (Bereitstellung der von dir gewünschten
|
||||||
|
Funktion).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Freundesystem und AeroMC-Dienste
|
||||||
|
|
||||||
|
Das Freundesystem ist optional und lässt sich in den Einstellungen
|
||||||
|
(Optionen → Freunde & Umhänge) jederzeit abschalten. Es läuft auf unserem Server
|
||||||
|
`viper-network.de`.
|
||||||
|
|
||||||
|
### Anmeldung am Freundesystem
|
||||||
|
AeroMC meldet dich wie ein Minecraft-Server über die Sitzungsprüfung von Mojang an. Dabei
|
||||||
|
wird bestätigt, dass du der Besitzer deines Minecraft-Kontos bist. Ein eigenes Passwort gibt
|
||||||
|
es nicht. Wir erhalten **nicht** deine Microsoft-Zugangsdaten.
|
||||||
|
|
||||||
|
### Welche Daten wir speichern
|
||||||
|
|
||||||
|
| Daten | Zweck | Speicherdauer |
|
||||||
|
|---|---|---|
|
||||||
|
| Minecraft-Spieler-ID (UUID) und Minecraft-Name | dich im Freundesystem erkennen und auffindbar machen | bis zur Löschung auf Anfrage |
|
||||||
|
| Sitzungsschlüssel (nur als Prüfwert) | angemeldet bleiben | 30 Tage nach letzter Nutzung |
|
||||||
|
| Online-Status: Zeitpunkt, Instanzname, Minecraft-Version und – falls du es nicht abgeschaltet hast – die Server-Adresse, auf der du spielst | Freunden zeigen, dass und wo du spielst | wird bei jeder Nutzung überschrieben |
|
||||||
|
| Freundschaften und Freundschaftsanfragen | Freundesliste | bis du sie entfernst |
|
||||||
|
| Einladungen auf einen Server | „Komm auf meinen Server“ | 10 Minuten |
|
||||||
|
| Kurznachrichten und mitgeschickte Screenshots | Nachrichten zwischen Freunden | 7 Tage |
|
||||||
|
| Eigene Umhänge (Bilddatei) | deinen Umhang anderen AeroMC-Spielern anzeigen | bis du ihn entfernst |
|
||||||
|
| Geteilte Instanzen (Liste von Version, Loader und Mods, keine Dateien) | Instanz per Code mit Freunden teilen | 30 Tage |
|
||||||
|
|
||||||
|
Diese Daten sehen nur deine Freunde bzw. die Personen, denen du etwas schickst oder einen
|
||||||
|
Code gibst. Eigene Umhänge sind für alle AeroMC-Spieler sichtbar, die dir im Spiel begegnen.
|
||||||
|
|
||||||
|
**Rechtsgrundlage:** Art. 6 Abs. 1 lit. b DSGVO.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Fehlerberichte (freiwillig)
|
||||||
|
|
||||||
|
Stürzt Minecraft ab, kann der Absturz-Assistent auf **deinen ausdrücklichen Wunsch** einen
|
||||||
|
Fehlerbericht an uns senden. Er enthält:
|
||||||
|
|
||||||
|
- Launcher-, Minecraft- und Loader-Version
|
||||||
|
- Windows-Version, Größe des Arbeitsspeichers und den eingestellten Speicher der Instanz
|
||||||
|
- die Liste der Mods der Instanz
|
||||||
|
- das Ende von Absturzbericht, Spielprotokoll und Konsolenausgabe
|
||||||
|
- deinen optionalen Kommentar
|
||||||
|
|
||||||
|
Dein Minecraft-Name und deine Spieler-ID werden vor dem Senden aus dem Bericht entfernt,
|
||||||
|
außer du wählst ausdrücklich, den Bericht mit deinem Namen zu senden.
|
||||||
|
|
||||||
|
Fehlerberichte nutzen wir nur, um Fehler in AeroMC zu finden und zu beheben. Sie werden
|
||||||
|
gelöscht, sobald sie nicht mehr benötigt werden.
|
||||||
|
|
||||||
|
**Rechtsgrundlage:** Art. 6 Abs. 1 lit. a DSGVO (Einwilligung durch das Absenden).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Downloads von Spieldateien und Inhalten
|
||||||
|
|
||||||
|
Für Minecraft, Java und die Inhalte, die du auswählst, lädt AeroMC Dateien direkt bei den
|
||||||
|
jeweiligen Anbietern herunter. Dabei erhält der Anbieter technisch bedingt deine
|
||||||
|
**IP-Adresse** und übliche Verbindungsdaten. Weitere personenbezogene Daten werden dabei
|
||||||
|
nicht übertragen.
|
||||||
|
|
||||||
|
| Anbieter | Wofür |
|
||||||
|
|---|---|
|
||||||
|
| Mojang / Microsoft (`piston-meta.mojang.com`, `libraries.minecraft.net`, `resources.download.minecraft.net`, `textures.minecraft.net`) | Minecraft-Dateien, Skins und Umhänge |
|
||||||
|
| Eclipse Adoptium (`api.adoptium.net`) | Java-Laufzeit |
|
||||||
|
| Modrinth (`api.modrinth.com`) | Mods, Ressourcenpakete, Shader, Modpacks |
|
||||||
|
| CurseForge (`api.curseforge.com`, `edge.forgecdn.net`) | Mods und Modpacks – die Suche läuft über unseren Weiterleitungs-Server `curse.viper.ipv64.net` |
|
||||||
|
| Fabric, Quilt, Forge, NeoForge (`meta.fabricmc.net`, `meta.quiltmc.org`, `maven.minecraftforge.net`, `maven.neoforged.net`) | Mod-Loader |
|
||||||
|
| Technic (`api.technicpack.net`) | Technic-Modpacks |
|
||||||
|
|
||||||
|
Es gelten die Datenschutzbestimmungen des jeweiligen Anbieters.
|
||||||
|
|
||||||
|
**Rechtsgrundlage:** Art. 6 Abs. 1 lit. b DSGVO.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Anzeige von Skins
|
||||||
|
|
||||||
|
Für die Darstellung von Spielerfiguren und Köpfen im Launcher ruft AeroMC Bilder anhand der
|
||||||
|
Spieler-ID (UUID) bei folgenden Diensten ab: `crafthead.net`, `visage.surgeplay.com` und
|
||||||
|
`nmsr.nickac.dev`. Diese erhalten dabei die Spieler-ID und deine IP-Adresse.
|
||||||
|
|
||||||
|
**Rechtsgrundlage:** Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse an der Darstellung
|
||||||
|
der Spielfiguren).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Updates
|
||||||
|
|
||||||
|
Die Fassung von unserer Webseite prüft beim Start, ob es eine neue Version gibt
|
||||||
|
(`git.viper.ipv64.net` und `viper-network.de`). Die Fassung aus dem Microsoft Store wird
|
||||||
|
ausschließlich über den Microsoft Store aktualisiert. Dabei werden keine personenbezogenen
|
||||||
|
Daten außer deiner IP-Adresse übertragen.
|
||||||
|
|
||||||
|
**Rechtsgrundlage:** Art. 6 Abs. 1 lit. f DSGVO (Sicherheit und Aktualität des Programms).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Discord
|
||||||
|
|
||||||
|
Läuft auf deinem Computer Discord, kann AeroMC dort anzeigen, dass und welche Instanz du
|
||||||
|
spielst („Rich Presence“). Diese Information geht nur an das Discord-Programm auf deinem
|
||||||
|
Computer und von dort nach den Bestimmungen von Discord an deine Discord-Kontakte.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Server-Protokolle
|
||||||
|
|
||||||
|
Beim Zugriff auf unsere Server (`viper-network.de`, `git.viper.ipv64.net`,
|
||||||
|
`curse.viper.ipv64.net`, `aeromc.viper.ipv64.net`) werden technisch bedingt IP-Adresse,
|
||||||
|
Zeitpunkt und angefragte Adresse kurzzeitig protokolliert, um den Betrieb und die Sicherheit
|
||||||
|
der Server zu gewährleisten.
|
||||||
|
|
||||||
|
**Rechtsgrundlage:** Art. 6 Abs. 1 lit. f DSGVO.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Deine Rechte
|
||||||
|
|
||||||
|
Du hast das Recht auf **Auskunft** über deine gespeicherten Daten, auf **Berichtigung**,
|
||||||
|
**Löschung**, **Einschränkung der Verarbeitung**, **Datenübertragbarkeit** sowie auf
|
||||||
|
**Widerspruch** gegen die Verarbeitung. Eine erteilte Einwilligung kannst du jederzeit für
|
||||||
|
die Zukunft widerrufen.
|
||||||
|
|
||||||
|
Wende dich dafür an die oben genannte E-Mail-Adresse. Gib dabei bitte deinen Minecraft-Namen
|
||||||
|
an, damit wir deine Daten finden.
|
||||||
|
|
||||||
|
Du kannst dich außerdem bei einer Datenschutz-Aufsichtsbehörde beschweren.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Kinder
|
||||||
|
|
||||||
|
AeroMC richtet sich nicht an Kinder unter 16 Jahren. Wer jünger ist, sollte das Freundesystem
|
||||||
|
nur mit Zustimmung der Eltern nutzen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Änderungen
|
||||||
|
|
||||||
|
Wir passen diese Datenschutzerklärung an, wenn sich AeroMC oder die Rechtslage ändert. Es
|
||||||
|
gilt die jeweils auf dieser Seite veröffentlichte Fassung.
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
Ein Minecraft-Launcher für Windows mit getrennten Instanzen, Mod-Unterstützung, Spielaufzeichnung, Freundesystem und Microsoft-Anmeldung.
|
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" />
|
<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
|
- Eigener Server auf Wunsch verborgen, Freundesystem komplett abschaltbar
|
||||||
|
|
||||||
**Konten, Welten und Backups**
|
**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
|
- 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
|
- **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“
|
- **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
|
- 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)
|
- Skin-Hintergrund des aktiven Kontos (mehrere Posen, passende Armform, einstellbare Deckkraft, optional wechselnd)
|
||||||
- Welten, Ressourcenpakete, Datenpakete, Screenshots, Serverliste und Logdateien verwalten
|
- Welten, Ressourcenpakete, Datenpakete, Screenshots, Serverliste und Logdateien verwalten
|
||||||
- Serverliste mit Live-Status und Desktop-Verknüpfung pro Server (startet die Instanz und verbindet direkt)
|
- 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:
|
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
|
- Minecraft-Version und optionalen Mod-Loader
|
||||||
- optionales Icon (vorgegeben, aus Minecraft extrahiert oder eigenes Bild) und Notizen
|
- optionales Icon (vorgegeben, aus Minecraft extrahiert oder eigenes Bild) und Notizen
|
||||||
- eigene Mods, Ressourcenpakete, Shader, Welten, Datenpakete, Screenshots, Aufzeichnungen und Serverliste
|
- 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.
|
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.
|
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.
|
**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
|
### Darstellung
|
||||||
- Design: Klassisch Dunkel, Klassisch Hell, AeroMC, Nachtlila oder Minecraft – mit Vorschau-Kacheln, gilt nach dem Speichern in allen Fenstern
|
- Design: Klassisch Dunkel, Klassisch Hell, AeroMC, Nachtlila oder Minecraft – mit Vorschau-Kacheln, gilt nach dem Speichern in allen Fenstern
|
||||||
- Block-Symbole räumlich (3D)
|
- 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
|
- Skin-Hintergrund: Quelle, Pose, Armform (automatisch vom Konto, normal oder schmal), Deckkraft, optional automatischer Wechsel zwischen mehreren Konten
|
||||||
|
|
||||||
### Spiel
|
### Spiel
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# AeroMC – Sicherheit
|
||||||
|
|
||||||
|
Überblick über die Sicherheitsmaßnahmen im AeroMC Launcher (Stand: Version 1.2.3).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Sichere Updates
|
||||||
|
|
||||||
|
- **Digitale Signatur:** Jedes Update ist mit dem Release-Schlüssel des Entwicklers
|
||||||
|
unterschrieben (Ed25519). Der Launcher installiert nur Dateien mit gültiger Signatur.
|
||||||
|
Selbst wer den Update-Server übernimmt, kann so keine manipulierte EXE verteilen.
|
||||||
|
- **Mehr als nur der Inhalt:** Unterschrieben werden Inhalt (SHA-256), Dateiname und Version.
|
||||||
|
Eine alte Version kann nicht als neue ausgegeben werden, Setup und Portable lassen sich
|
||||||
|
nicht vertauschen.
|
||||||
|
- **Doppelte Prüfung:** einmal nach dem Download und noch einmal direkt vor dem Start.
|
||||||
|
- **Getrennte Server:** Die Programmdatei und ihre Signatur liegen auf zwei verschiedenen
|
||||||
|
Servern. Ein Update ohne gültige Signatur wird nicht automatisch installiert.
|
||||||
|
- **Feste Quelle:** Updates werden nur vom offiziellen AeroMC-Server geladen.
|
||||||
|
|
||||||
|
## 2. Geschützte Programmdatei
|
||||||
|
|
||||||
|
- **Aktuelle Technik:** AeroMC basiert auf Electron 44 mit aktuellem Chromium und Node.js
|
||||||
|
und erhält damit laufende Sicherheitsupdates.
|
||||||
|
- **Keine Fernsteuerung:** Debug-Schnittstellen und der Node-Modus sind fest in der EXE
|
||||||
|
gesperrt. Der Launcher lässt sich weder von außen steuern noch für fremde Skripte
|
||||||
|
missbrauchen.
|
||||||
|
- **Integritätsprüfung:** Beim Start prüft AeroMC, ob sein Programmpaket verändert wurde.
|
||||||
|
Ist das der Fall, startet es nicht.
|
||||||
|
- **Keine fremden Webseiten:** Launcher-Fenster laden nie fremde Seiten, Links öffnen sich
|
||||||
|
immer im Browser.
|
||||||
|
- **Geprüfte Abhängigkeiten:** Alle eingesetzten Programmbibliotheken sind ohne bekannte
|
||||||
|
Sicherheitslücken.
|
||||||
|
|
||||||
|
## 3. Schutz der Instanzen
|
||||||
|
|
||||||
|
- **Manipulationsschutz:** AeroMC merkt sich für jede Instanz einen Fingerabdruck aller
|
||||||
|
Mod-Dateien und der Starteinstellungen (Java, JVM-Argumente, Startbefehle).
|
||||||
|
- **Automatische Sperre:** Wird eine Mod-Datei verändert (so arbeitet Schadsoftware, die
|
||||||
|
Mods infiziert) oder werden Starteinstellungen außerhalb des Launchers geändert, wird die
|
||||||
|
Instanz gesperrt und mit einem Verbotszeichen markiert.
|
||||||
|
- **Gesperrte Instanzen** lassen sich nicht mehr starten, bearbeiten, kopieren, exportieren
|
||||||
|
oder teilen. Sie bleiben erhalten, bis man sie selbst löscht.
|
||||||
|
- **Wiederherstellen:** Mit einem Klick wird die Instanz aus der neuesten sauberen
|
||||||
|
Sicherung als neue Instanz angelegt. Sicherungen, die bereits die veränderte Datei
|
||||||
|
enthalten, werden übersprungen.
|
||||||
|
- **Normales Arbeiten bleibt möglich:** Neue, gelöschte, aktivierte oder deaktivierte Mods
|
||||||
|
gelten nicht als Manipulation. Änderungen über den Launcher werden automatisch übernommen.
|
||||||
|
|
||||||
|
## 4. Geprüfte Downloads
|
||||||
|
|
||||||
|
Jede heruntergeladene Datei wird mit der Prüfsumme der Quelle verglichen, bevor sie
|
||||||
|
gespeichert wird. Eine veränderte oder beschädigte Datei wird verworfen.
|
||||||
|
|
||||||
|
| Quelle | Prüfung |
|
||||||
|
|---|---|
|
||||||
|
| Minecraft, Bibliotheken und Assets (Mojang) | SHA-1 |
|
||||||
|
| Java-Laufzeiten (Eclipse Temurin) | SHA-256 |
|
||||||
|
| Mods, Ressourcenpakete, Shader (Modrinth) | SHA-512 / SHA-1 |
|
||||||
|
| Mods, Ressourcenpakete, Shader (CurseForge) | SHA-1 |
|
||||||
|
| Modpacks und ihre Dateien | Prüfsumme der Quelle, nur HTTPS |
|
||||||
|
|
||||||
|
## 5. Sichere Importe
|
||||||
|
|
||||||
|
- Beim Import von Instanzen aus ZIP-Archiven werden keine Java-Pfade und keine
|
||||||
|
JVM-Argumente aus dem Archiv übernommen – so kann ein präpariertes Archiv beim nächsten
|
||||||
|
Start keinen fremden Code ausführen.
|
||||||
|
- Dateinamen aus Archiven werden geprüft, damit nichts außerhalb des Instanzordners landet.
|
||||||
|
|
||||||
|
## 6. Geschützte Daten
|
||||||
|
|
||||||
|
- **Konten:** Anmeldedaten von Microsoft und Minecraft werden mit der Windows-Verschlüsselung
|
||||||
|
(DPAPI) gespeichert – nur das eigene Windows-Konto kann sie lesen.
|
||||||
|
- **Weitere Zugangsdaten:** Proxy-Passwort und CurseForge-Schlüssel werden ebenso
|
||||||
|
verschlüsselt gespeichert.
|
||||||
|
- **Spielerweiterung:** Die Datei, die AeroMC für Umhänge und den AeroMC-Look ins Spiel
|
||||||
|
einbindet, wird vor jedem Spielstart mit dem Original verglichen und bei Abweichung
|
||||||
|
ersetzt.
|
||||||
|
|
||||||
|
## 7. Freundesystem
|
||||||
|
|
||||||
|
- **Anmeldung über Mojang:** Der Launcher meldet sich wie ein Minecraft-Server über Mojangs
|
||||||
|
Sitzungsprüfung an. Ein eigenes Passwort ist nicht nötig.
|
||||||
|
- **Keine Identitätsfälschung:** Jede Anfrage im Namen eines Spielers muss angemeldet sein.
|
||||||
|
Niemand kann sich als ein anderer Spieler ausgeben.
|
||||||
|
- **Privatsphäre:** Der eigene Server kann verborgen oder das Freundesystem ganz
|
||||||
|
abgeschaltet werden.
|
||||||
@@ -3,15 +3,22 @@ package de.aeromc.capes;
|
|||||||
import java.lang.instrument.Instrumentation;
|
import java.lang.instrument.Instrumentation;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AeroMC-Umhang-Agent.
|
* AeroMC-Agent.
|
||||||
*
|
*
|
||||||
* Wird vom Launcher per -javaagent an Minecraft angehängt (jede Version, jeder
|
* Wird vom Launcher per -javaagent an Minecraft angehängt (jede Version, jeder
|
||||||
* Loader). Er klinkt sich in Mojangs authlib ein – über sie lädt Minecraft für
|
* Loader) und kann zwei Dinge, einzeln zuschaltbar:
|
||||||
* jeden Spieler Skin und Umhang – und setzt dort den AeroMC-Umhang des Spielers
|
|
||||||
* ein, falls er einen hat. Alles Weitere (Herunterladen, Zwischenspeichern,
|
|
||||||
* Anzeigen, auch HD) erledigt Minecraft selbst wie bei einem offiziellen Umhang.
|
|
||||||
*
|
*
|
||||||
* Argument: Basisadresse des Umhang-Servers (…/wp-json/aeromc/v1).
|
* Umhänge: klinkt sich in Mojangs authlib ein – über sie lädt Minecraft für
|
||||||
|
* jeden Spieler Skin und Umhang – und setzt dort den AeroMC-Umhang
|
||||||
|
* des Spielers ein, falls er einen hat. Alles Weitere (Herunterladen,
|
||||||
|
* Zwischenspeichern, Anzeigen, auch HD) erledigt Minecraft selbst.
|
||||||
|
* Spieldesign: AeroMC-Ladebildschirm, Fenstertitel und -symbol (siehe Design).
|
||||||
|
*
|
||||||
|
* Argument, Teile durch "|" getrennt:
|
||||||
|
* <Basisadresse des Umhang-Servers> (…/wp-json/aeromc/v1)
|
||||||
|
* capes=0 Umhänge aus
|
||||||
|
* design=<Ordner> Spieldesign an, Grafiken aus diesem Ordner
|
||||||
|
* Ein Argument nur mit Adresse (ältere Launcher) heißt: nur Umhänge.
|
||||||
*/
|
*/
|
||||||
public final class Agent {
|
public final class Agent {
|
||||||
/** Unter diesem Schlüssel liegt der Nachschlage-Dienst in den System-Properties. */
|
/** Unter diesem Schlüssel liegt der Nachschlage-Dienst in den System-Properties. */
|
||||||
@@ -28,15 +35,42 @@ public final class Agent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static void starte(String argument, Instrumentation inst) {
|
private static void starte(String argument, Instrumentation inst) {
|
||||||
try {
|
String basis = Nachschlagen.STANDARD_BASIS;
|
||||||
String basis = argument != null && argument.startsWith("http") ? argument : Nachschlagen.STANDARD_BASIS;
|
boolean umhaenge = true;
|
||||||
// Die eingefügten Befehle in authlib holen sich den Dienst von hier – nur
|
String design = null;
|
||||||
// über JDK-Typen, damit es mit jedem Classloader und Modulsystem klappt.
|
String mods = null;
|
||||||
System.getProperties().put(SCHLUESSEL, new Nachschlagen(basis));
|
if (argument != null) {
|
||||||
inst.addTransformer(new Umbau());
|
for (String teil : argument.split("\\|")) {
|
||||||
System.out.println("[AeroMC] Umhang-Agent aktiv");
|
if (teil.startsWith("http")) basis = teil;
|
||||||
} catch (Throwable t) {
|
else if (teil.equals("capes=0")) umhaenge = false;
|
||||||
System.out.println("[AeroMC] Umhang-Agent konnte nicht starten: " + t);
|
else if (teil.startsWith("design=") && teil.length() > 7) design = teil.substring(7);
|
||||||
|
else if (teil.startsWith("mods=") && teil.length() > 5) mods = teil.substring(5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mods != null) {
|
||||||
|
try {
|
||||||
|
Design.installiereMods(mods, inst);
|
||||||
|
} catch (Throwable t) {
|
||||||
|
System.out.println("[AeroMC] Mod-Schalter konnte nicht starten: " + t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (umhaenge) {
|
||||||
|
try {
|
||||||
|
// Die eingefügten Befehle in authlib holen sich den Dienst von hier – nur
|
||||||
|
// über JDK-Typen, damit es mit jedem Classloader und Modulsystem klappt.
|
||||||
|
System.getProperties().put(SCHLUESSEL, new Nachschlagen(basis));
|
||||||
|
inst.addTransformer(new Umbau());
|
||||||
|
System.out.println("[AeroMC] Umhang-Agent aktiv");
|
||||||
|
} catch (Throwable t) {
|
||||||
|
System.out.println("[AeroMC] Umhang-Agent konnte nicht starten: " + t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (design != null) {
|
||||||
|
try {
|
||||||
|
Design.installiere(design, inst);
|
||||||
|
} catch (Throwable t) {
|
||||||
|
System.out.println("[AeroMC] Spieldesign konnte nicht starten: " + t);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package de.aeromc.capes;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.File;
|
||||||
|
import java.lang.instrument.Instrumentation;
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Properties;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AeroMC-Look im Spiel (Minecraft 1.14 … 26.x):
|
||||||
|
*
|
||||||
|
* – Ladebildschirm: AeroMC-Nachtblau statt Mojang-Rot, hellblauer Balken und
|
||||||
|
* AeroMC-Logo statt Mojang-Logo. Das Logo lädt Minecraft am Ressourcenpaket
|
||||||
|
* vorbei direkt aus dem Spiel – deshalb tauscht der Agent den Datenstrom aus.
|
||||||
|
* – Fenster: Titel "AeroMC - Minecraft …" und AeroMC-Symbol.
|
||||||
|
*
|
||||||
|
* Titel-Logo, Untertitel, Sprüche und Knöpfe kommen über ein Ressourcenpaket,
|
||||||
|
* das der Launcher anlegt (src/spieldesign.js).
|
||||||
|
*
|
||||||
|
* Die umgebauten Stellen im Spiel holen sich ihre Helfer – wie beim Umhang –
|
||||||
|
* über die System-Properties und nur über JDK-Typen (java.util.function.Function),
|
||||||
|
* damit es mit jedem Classloader klappt (Vanilla, Fabric, Forge, NeoForge).
|
||||||
|
*/
|
||||||
|
final class Design {
|
||||||
|
static final String LOGO = "aeromc.design.logo";
|
||||||
|
static final String LOGO_ALT = "aeromc.design.logoAlt";
|
||||||
|
static final String TITEL = "aeromc.design.titel";
|
||||||
|
static final String ICON = "aeromc.design.icon";
|
||||||
|
static final String MODS = "aeromc.mods";
|
||||||
|
|
||||||
|
/** Hintergrund des Ladebildschirms (wie das AeroMC-Design im Launcher). */
|
||||||
|
static final int HINTERGRUND = 0x0A0E1A;
|
||||||
|
/** Fortschrittsbalken. */
|
||||||
|
static final int BALKEN = 0x4FD2FF;
|
||||||
|
/** Breite des Balkens als Anteil der Bildschirmbreite (das Logo ist jetzt bildschirmbreit). */
|
||||||
|
static final float BALKEN_ANTEIL = 0.56f;
|
||||||
|
|
||||||
|
private static final int[] ICON_GROESSEN = {16, 32, 48, 64, 128, 256};
|
||||||
|
/** Pixelspeicher der Symbole festhalten, bis GLFW sie übernommen hat. */
|
||||||
|
private static final List<ByteBuffer> FESTHALTEN = new ArrayList<>();
|
||||||
|
|
||||||
|
private Design() {}
|
||||||
|
|
||||||
|
static void installiere(String ordner, Instrumentation inst) {
|
||||||
|
File dir = new File(ordner);
|
||||||
|
Properties p = System.getProperties();
|
||||||
|
p.put(LOGO, strom(new File(dir, "ladelogo.png")));
|
||||||
|
p.put(LOGO_ALT, strom(new File(dir, "ladelogo_alt.png")));
|
||||||
|
p.put(TITEL, (Function<Object, Object>) Design::titel);
|
||||||
|
p.put(ICON, (Function<Object, Object>) bilder -> symbol(dir, bilder));
|
||||||
|
inst.addTransformer(new DesignUmbau());
|
||||||
|
System.out.println("[AeroMC] Spieldesign aktiv");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mod-Schalter: Klick auf „Mods verwalten“ im Pausemenü (Adresse aeromc://mods)
|
||||||
|
* meldet sich beim Launcher, der dann das Mod-Fenster über dem Spiel öffnet.
|
||||||
|
* Rückgabe TRUE = erledigt (Minecraft öffnet keinen Link), sonst FALSE.
|
||||||
|
*/
|
||||||
|
static void installiereMods(String adresse, Instrumentation inst) {
|
||||||
|
System.getProperties().put(MODS, (Function<Object, Object>) link -> {
|
||||||
|
if (link == null || !link.toString().startsWith("aeromc:")) return Boolean.FALSE;
|
||||||
|
Thread t = new Thread(() -> {
|
||||||
|
try {
|
||||||
|
java.net.HttpURLConnection c = (java.net.HttpURLConnection) new java.net.URL(adresse).openConnection();
|
||||||
|
c.setConnectTimeout(2000);
|
||||||
|
c.setReadTimeout(4000);
|
||||||
|
c.getResponseCode();
|
||||||
|
c.disconnect();
|
||||||
|
} catch (Throwable e) {
|
||||||
|
System.out.println("[AeroMC] Launcher fuer den Mod-Schalter nicht erreichbar: " + e);
|
||||||
|
}
|
||||||
|
}, "AeroMC-Mod-Schalter");
|
||||||
|
t.setDaemon(true);
|
||||||
|
t.start();
|
||||||
|
return Boolean.TRUE;
|
||||||
|
});
|
||||||
|
inst.addTransformer(new ModsUmbau());
|
||||||
|
System.out.println("[AeroMC] Mod-Schalter aktiv");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ersetzt den Datenstrom des Mojang-Logos durch das AeroMC-Logo (bei Fehlern bleibt das Original). */
|
||||||
|
private static Function<Object, Object> strom(File datei) {
|
||||||
|
return original -> {
|
||||||
|
try {
|
||||||
|
return new ByteArrayInputStream(Files.readAllBytes(datei.toPath()));
|
||||||
|
} catch (Throwable t) {
|
||||||
|
System.out.println("[AeroMC] Ladelogo nicht lesbar: " + t);
|
||||||
|
return original;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Object titel(Object original) {
|
||||||
|
if (original == null) return null;
|
||||||
|
String s = original.toString();
|
||||||
|
return s.startsWith("AeroMC") ? original : "AeroMC - " + s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut aus den mitgelieferten Rohdaten (icon_N.rgba) einen neuen
|
||||||
|
* GLFWImage.Buffer. LWJGL wird über den Classloader des übergebenen Buffers
|
||||||
|
* angesprochen – der Agent selbst kennt LWJGL nicht.
|
||||||
|
*/
|
||||||
|
private static Object symbol(File dir, Object original) {
|
||||||
|
if (original == null) return null;
|
||||||
|
try {
|
||||||
|
List<int[]> groessen = new ArrayList<>();
|
||||||
|
List<byte[]> daten = new ArrayList<>();
|
||||||
|
for (int n : ICON_GROESSEN) {
|
||||||
|
File f = new File(dir, "icon_" + n + ".rgba");
|
||||||
|
if (!f.isFile()) continue;
|
||||||
|
byte[] b = Files.readAllBytes(f.toPath());
|
||||||
|
if (b.length != n * n * 4) continue;
|
||||||
|
groessen.add(new int[] {n, n});
|
||||||
|
daten.add(b);
|
||||||
|
}
|
||||||
|
if (daten.isEmpty()) return original;
|
||||||
|
ClassLoader cl = original.getClass().getClassLoader();
|
||||||
|
Class<?> bild = Class.forName("org.lwjgl.glfw.GLFWImage", true, cl);
|
||||||
|
Object puffer = bild.getMethod("malloc", int.class).invoke(null, daten.size());
|
||||||
|
Method hole = puffer.getClass().getMethod("get", int.class);
|
||||||
|
Method setze = bild.getMethod("set", int.class, int.class, ByteBuffer.class);
|
||||||
|
for (int i = 0; i < daten.size(); i++) {
|
||||||
|
ByteBuffer px = ByteBuffer.allocateDirect(daten.get(i).length);
|
||||||
|
px.put(daten.get(i)).flip();
|
||||||
|
synchronized (FESTHALTEN) { FESTHALTEN.add(px); }
|
||||||
|
setze.invoke(hole.invoke(puffer, i), groessen.get(i)[0], groessen.get(i)[1], px);
|
||||||
|
}
|
||||||
|
return puffer;
|
||||||
|
} catch (Throwable t) {
|
||||||
|
System.out.println("[AeroMC] Fenstersymbol nicht gesetzt: " + t);
|
||||||
|
return original;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
package de.aeromc.capes;
|
||||||
|
|
||||||
|
import java.lang.instrument.ClassFileTransformer;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.ProtectionDomain;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.objectweb.asm.ClassReader;
|
||||||
|
import org.objectweb.asm.ClassWriter;
|
||||||
|
import org.objectweb.asm.Opcodes;
|
||||||
|
import org.objectweb.asm.tree.AbstractInsnNode;
|
||||||
|
import org.objectweb.asm.tree.ClassNode;
|
||||||
|
import org.objectweb.asm.tree.InsnList;
|
||||||
|
import org.objectweb.asm.tree.InsnNode;
|
||||||
|
import org.objectweb.asm.tree.IntInsnNode;
|
||||||
|
import org.objectweb.asm.tree.LdcInsnNode;
|
||||||
|
import org.objectweb.asm.tree.MethodInsnNode;
|
||||||
|
import org.objectweb.asm.tree.MethodNode;
|
||||||
|
import org.objectweb.asm.tree.TypeInsnNode;
|
||||||
|
import org.objectweb.asm.tree.VarInsnNode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut beim Laden um (siehe Design):
|
||||||
|
*
|
||||||
|
* 1. Ladebildschirm – erkannt am Text "textures/gui/title/mojangstudios.png"
|
||||||
|
* (ab 1.16) bzw. "…/mojang.png" (1.14/1.15), denn die Klassennamen sind je
|
||||||
|
* Version und Loader anders (verschleiert, Intermediary, SRG, Mojang-Namen):
|
||||||
|
* ab 1.16: color(255, 239, 50, 61) -> AeroMC-Nachtblau
|
||||||
|
* color(alpha, 255, 255, 255) -> Balkenfarbe
|
||||||
|
* 1.14/1.15: weiße Füllung (0xFFFFFF / -1) -> Nachtblau,
|
||||||
|
* Balken-Rot (226, 40, 55) -> Balkenfarbe
|
||||||
|
* 2. Logo-Textur – die innere Klasse des Ladebildschirms ("…$…") liest das
|
||||||
|
* Logo mit NativeImage.read(InputStream); davor wird der Strom getauscht.
|
||||||
|
* 3. LWJGL-GLFW – Fenstertitel (glfwCreateWindow/glfwSetWindowTitle) und
|
||||||
|
* Fenstersymbol (glfwSetWindowIcon).
|
||||||
|
*
|
||||||
|
* Eingefügt bzw. ersetzt wird nur geradliniger Code ohne Sprünge mit gleicher
|
||||||
|
* Stapelwirkung – die vorhandenen Stack-Map-Frames bleiben gültig.
|
||||||
|
*/
|
||||||
|
final class DesignUmbau implements ClassFileTransformer {
|
||||||
|
private static final byte[] MERKMAL = "textures/gui/title/mojang".getBytes(StandardCharsets.UTF_8);
|
||||||
|
private static final String LOGO_NEU = "textures/gui/title/mojangstudios.png";
|
||||||
|
private static final String LOGO_ALT = "textures/gui/title/mojang.png";
|
||||||
|
private static final byte[] TITEL_MERKMAL = "textures/gui/title/minecraft.png".getBytes(StandardCharsets.UTF_8);
|
||||||
|
private static final int KEIN_WERT = Integer.MIN_VALUE;
|
||||||
|
|
||||||
|
/** Name der Ladebildschirm-Klasse, sobald sie gesehen wurde. */
|
||||||
|
private volatile String ladeKlasse;
|
||||||
|
private volatile boolean ladeAlt;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public byte[] transform(ClassLoader loader, String name, Class<?> umdefiniert, ProtectionDomain pd, byte[] klasse) {
|
||||||
|
if (name == null || umdefiniert != null) return null;
|
||||||
|
try {
|
||||||
|
if ("org/lwjgl/glfw/GLFW".equals(name)) return glfw(klasse);
|
||||||
|
if (name.startsWith("java/") || name.startsWith("jdk/") || name.startsWith("sun/")
|
||||||
|
|| name.startsWith("de/aeromc/") || name.startsWith("org/lwjgl/")) return null;
|
||||||
|
String lk = ladeKlasse;
|
||||||
|
if (lk != null && name.startsWith(lk + "$")) return logoStrom(klasse, ladeAlt);
|
||||||
|
if (enthaelt(klasse, MERKMAL)) return ladeBildschirm(name, klasse);
|
||||||
|
if (enthaelt(klasse, TITEL_MERKMAL)) return titelLogo(name, klasse);
|
||||||
|
} catch (Throwable t) {
|
||||||
|
System.out.println("[AeroMC] Spieldesign: " + name + " nicht angepasst: " + t);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 1. Ladebildschirm ----------
|
||||||
|
|
||||||
|
private byte[] ladeBildschirm(String name, byte[] klasse) {
|
||||||
|
ClassNode cn = lies(klasse);
|
||||||
|
boolean neu = hatText(cn, LOGO_NEU), alt = hatText(cn, LOGO_ALT);
|
||||||
|
if (!neu && !alt) return null;
|
||||||
|
ladeKlasse = name;
|
||||||
|
ladeAlt = alt && !neu;
|
||||||
|
int r = Design.HINTERGRUND >> 16 & 255, g = Design.HINTERGRUND >> 8 & 255, b = Design.HINTERGRUND & 255;
|
||||||
|
int br = Design.BALKEN >> 16 & 255, bg = Design.BALKEN >> 8 & 255, bb = Design.BALKEN & 255;
|
||||||
|
int hintergrund = 0, balken = 0, vollbild = 0, balkenBreite = 0;
|
||||||
|
for (MethodNode m : cn.methods) {
|
||||||
|
List<AbstractInsnNode> a = befehle(m);
|
||||||
|
for (int i = 0; i < a.size(); i++) {
|
||||||
|
if (!ladeAlt) {
|
||||||
|
// color(255, 239, 50, 61) – Mojang-Rot
|
||||||
|
if (i + 4 < a.size() && wert(a.get(i)) == 255 && wert(a.get(i + 1)) == 239
|
||||||
|
&& wert(a.get(i + 2)) == 50 && wert(a.get(i + 3)) == 61 && aufruf(a.get(i + 4), "(IIII)I")) {
|
||||||
|
setze(m, a, i + 1, r); setze(m, a, i + 2, g); setze(m, a, i + 3, b);
|
||||||
|
hintergrund++;
|
||||||
|
}
|
||||||
|
// color(alpha, 255, 255, 255) – weißer Balken
|
||||||
|
if (i + 4 < a.size() && a.get(i).getOpcode() == Opcodes.ILOAD && wert(a.get(i + 1)) == 255
|
||||||
|
&& wert(a.get(i + 2)) == 255 && wert(a.get(i + 3)) == 255 && aufruf(a.get(i + 4), "(IIII)I")) {
|
||||||
|
setze(m, a, i + 1, br); setze(m, a, i + 2, bg); setze(m, a, i + 3, bb);
|
||||||
|
balken++;
|
||||||
|
}
|
||||||
|
// Logo-Fläche auf Vollbild: Höhe = min(Breite·0,75; Höhe)·0,25 und Breite = 4·Höhe
|
||||||
|
// wird zu Höhe = min(Breite·9/16; Höhe) und Breite = 16/9·Höhe (Bild im 16:9-Format)
|
||||||
|
AbstractInsnNode n = a.get(i);
|
||||||
|
if (n instanceof LdcInsnNode && ((LdcInsnNode) n).cst instanceof Double) {
|
||||||
|
double d = (Double) ((LdcInsnNode) n).cst;
|
||||||
|
double ersatzWert = d == 0.75 ? 9.0 / 16.0 : d == 0.25 ? 1.0 : d == 4.0 ? 16.0 / 9.0 : -1;
|
||||||
|
if (ersatzWert > 0) {
|
||||||
|
LdcInsnNode e = new LdcInsnNode(ersatzWert);
|
||||||
|
m.instructions.set(n, e);
|
||||||
|
a.set(i, e);
|
||||||
|
vollbild++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fortschrittsbalken: Minecraft macht ihn so breit wie das Logo – jetzt also
|
||||||
|
// bildschirmbreit; beim Aufruf auf einen Teil der Breite in der Mitte kürzen
|
||||||
|
if (n instanceof MethodInsnNode && ((MethodInsnNode) n).owner.equals(name)
|
||||||
|
&& ((MethodInsnNode) n).desc.endsWith("IIIIF)V")) {
|
||||||
|
m.instructions.insertBefore(n, balkenKuerzen(m));
|
||||||
|
balkenBreite++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
AbstractInsnNode n = a.get(i);
|
||||||
|
// weiße Füllung beim Ein-/Ausblenden: 0xFFFFFF | alpha << 24
|
||||||
|
if (n instanceof LdcInsnNode && Integer.valueOf(0xFFFFFF).equals(((LdcInsnNode) n).cst)) {
|
||||||
|
m.instructions.set(n, ersatz(Design.HINTERGRUND, a, i));
|
||||||
|
hintergrund++;
|
||||||
|
}
|
||||||
|
// fill(0, 0, breite, hoehe, -1) – weißer Hintergrund
|
||||||
|
if (i + 5 < a.size() && n.getOpcode() == Opcodes.ICONST_0 && a.get(i + 1).getOpcode() == Opcodes.ICONST_0
|
||||||
|
&& a.get(i + 2).getOpcode() == Opcodes.ILOAD && a.get(i + 3).getOpcode() == Opcodes.ILOAD
|
||||||
|
&& a.get(i + 4).getOpcode() == Opcodes.ICONST_M1 && aufruf(a.get(i + 5), "(IIIII)V")) {
|
||||||
|
m.instructions.set(a.get(i + 4), ersatz(0xFF000000 | Design.HINTERGRUND, a, i + 4));
|
||||||
|
hintergrund++;
|
||||||
|
}
|
||||||
|
// Balken-Rot (226, 40, 55) als Gleitkommazahlen
|
||||||
|
if (n instanceof LdcInsnNode && ((LdcInsnNode) n).cst instanceof Float) {
|
||||||
|
float f = (Float) ((LdcInsnNode) n).cst;
|
||||||
|
float neuWert = f == 226f ? br : f == 40f ? bg : f == 55f ? bb : -1f;
|
||||||
|
if (neuWert >= 0) {
|
||||||
|
LdcInsnNode e = new LdcInsnNode(neuWert);
|
||||||
|
m.instructions.set(n, e);
|
||||||
|
a.set(i, e);
|
||||||
|
balken++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
System.out.println("[AeroMC] Ladebildschirm angepasst (" + (ladeAlt ? "1.14/1.15" : "ab 1.16")
|
||||||
|
+ ", Hintergrund " + hintergrund + ", Balken " + balken + ", Vollbild " + vollbild + ", Balkenbreite " + balkenBreite + ")");
|
||||||
|
return schreibe(cn);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vor dem Aufruf drawProgressBar(…, x1, y1, x2, y2, f): die vier Zahlen und f in
|
||||||
|
* neue lokale Variablen legen, x1/x2 um die Mitte auf BALKEN_ANTEIL der Breite
|
||||||
|
* zusammenziehen und alles wieder auf den Stapel legen.
|
||||||
|
*/
|
||||||
|
private static InsnList balkenKuerzen(MethodNode m) {
|
||||||
|
int b = m.maxLocals;
|
||||||
|
m.maxLocals += 7;
|
||||||
|
int x1 = b, y1 = b + 1, x2 = b + 2, y2 = b + 3, f = b + 4, mitte = b + 5, halb = b + 6;
|
||||||
|
InsnList l = new InsnList();
|
||||||
|
l.add(new VarInsnNode(Opcodes.FSTORE, f));
|
||||||
|
l.add(new VarInsnNode(Opcodes.ISTORE, y2));
|
||||||
|
l.add(new VarInsnNode(Opcodes.ISTORE, x2));
|
||||||
|
l.add(new VarInsnNode(Opcodes.ISTORE, y1));
|
||||||
|
l.add(new VarInsnNode(Opcodes.ISTORE, x1));
|
||||||
|
// mitte = (x1 + x2) / 2
|
||||||
|
l.add(new VarInsnNode(Opcodes.ILOAD, x1));
|
||||||
|
l.add(new VarInsnNode(Opcodes.ILOAD, x2));
|
||||||
|
l.add(new InsnNode(Opcodes.IADD));
|
||||||
|
l.add(new InsnNode(Opcodes.ICONST_2));
|
||||||
|
l.add(new InsnNode(Opcodes.IDIV));
|
||||||
|
l.add(new VarInsnNode(Opcodes.ISTORE, mitte));
|
||||||
|
// halb = (int) ((x2 - x1) * anteil / 2)
|
||||||
|
l.add(new VarInsnNode(Opcodes.ILOAD, x2));
|
||||||
|
l.add(new VarInsnNode(Opcodes.ILOAD, x1));
|
||||||
|
l.add(new InsnNode(Opcodes.ISUB));
|
||||||
|
l.add(new InsnNode(Opcodes.I2F));
|
||||||
|
l.add(new LdcInsnNode(Design.BALKEN_ANTEIL / 2f));
|
||||||
|
l.add(new InsnNode(Opcodes.FMUL));
|
||||||
|
l.add(new InsnNode(Opcodes.F2I));
|
||||||
|
l.add(new VarInsnNode(Opcodes.ISTORE, halb));
|
||||||
|
// (mitte - halb, y1, mitte + halb, y2, f)
|
||||||
|
l.add(new VarInsnNode(Opcodes.ILOAD, mitte));
|
||||||
|
l.add(new VarInsnNode(Opcodes.ILOAD, halb));
|
||||||
|
l.add(new InsnNode(Opcodes.ISUB));
|
||||||
|
l.add(new VarInsnNode(Opcodes.ILOAD, y1));
|
||||||
|
l.add(new VarInsnNode(Opcodes.ILOAD, mitte));
|
||||||
|
l.add(new VarInsnNode(Opcodes.ILOAD, halb));
|
||||||
|
l.add(new InsnNode(Opcodes.IADD));
|
||||||
|
l.add(new VarInsnNode(Opcodes.ILOAD, y2));
|
||||||
|
l.add(new VarInsnNode(Opcodes.FLOAD, f));
|
||||||
|
return l;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 1b. Titel-Logo im Hauptmenü ----------
|
||||||
|
// Minecraft zeichnet vom Logo nur einen 44 Punkte hohen Streifen – zu knapp für
|
||||||
|
// Pyramide, großen Schriftzug und Untertitel. Hier wird er auf 64 Punkte erweitert:
|
||||||
|
// bis 1.19.4: blit(u 0, v 0, 155, 44) + blit(u 0, v 45, 155, 44) -> Höhe 64, zweites Stück ab v 65
|
||||||
|
// ab 1.20: blit(…, 256, 44, Textur 256, 64) -> Höhe 64
|
||||||
|
private byte[] titelLogo(String name, byte[] klasse) {
|
||||||
|
ClassNode cn = lies(klasse);
|
||||||
|
int n = 0;
|
||||||
|
for (MethodNode m : cn.methods) {
|
||||||
|
List<AbstractInsnNode> a = befehle(m);
|
||||||
|
for (int i = 0; i + 4 < a.size(); i++) {
|
||||||
|
int w0 = wert(a.get(i)), w1 = wert(a.get(i + 1)), w2 = wert(a.get(i + 2)), w3 = wert(a.get(i + 3));
|
||||||
|
boolean aufrufDanach = a.get(i + 4) instanceof MethodInsnNode;
|
||||||
|
if (w0 == 0 && w1 == 0 && w2 == 155 && w3 == 44 && aufrufDanach) {
|
||||||
|
setze(m, a, i + 3, 64); n++;
|
||||||
|
} else if (w0 == 0 && w1 == 45 && w2 == 155 && w3 == 44 && aufrufDanach) {
|
||||||
|
setze(m, a, i + 1, 65); setze(m, a, i + 3, 64); n++;
|
||||||
|
} else if (w0 == 256 && w1 == 44 && w2 == 256 && w3 == 64) {
|
||||||
|
setze(m, a, i + 1, 64); n++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (n == 0) return null;
|
||||||
|
System.out.println("[AeroMC] Titel-Logo vergroessert (" + n + ")");
|
||||||
|
return schreibe(cn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 2. Logo-Textur ----------
|
||||||
|
|
||||||
|
private byte[] logoStrom(byte[] klasse, boolean alt) {
|
||||||
|
ClassNode cn = lies(klasse);
|
||||||
|
int n = 0;
|
||||||
|
for (MethodNode m : cn.methods) {
|
||||||
|
for (AbstractInsnNode i : befehle(m)) {
|
||||||
|
if (i.getOpcode() != Opcodes.INVOKESTATIC) continue;
|
||||||
|
MethodInsnNode mi = (MethodInsnNode) i;
|
||||||
|
if (!mi.desc.startsWith("(Ljava/io/InputStream;)L") || mi.owner.startsWith("java/")) continue;
|
||||||
|
// Stapel: [strom] -> [strom, helfer] -> [helfer, strom] -> apply -> [neuer strom]
|
||||||
|
InsnList neu = new InsnList();
|
||||||
|
helfer(neu, alt ? Design.LOGO_ALT : Design.LOGO);
|
||||||
|
neu.add(new InsnNode(Opcodes.SWAP));
|
||||||
|
neu.add(new MethodInsnNode(Opcodes.INVOKEINTERFACE, "java/util/function/Function", "apply",
|
||||||
|
"(Ljava/lang/Object;)Ljava/lang/Object;", true));
|
||||||
|
neu.add(new TypeInsnNode(Opcodes.CHECKCAST, "java/io/InputStream"));
|
||||||
|
m.instructions.insertBefore(i, neu);
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (n == 0) return null;
|
||||||
|
System.out.println("[AeroMC] Ladelogo ersetzt");
|
||||||
|
return schreibe(cn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 3. Fenstertitel und -symbol ----------
|
||||||
|
|
||||||
|
private byte[] glfw(byte[] klasse) {
|
||||||
|
ClassNode cn = lies(klasse);
|
||||||
|
int n = 0;
|
||||||
|
for (MethodNode m : cn.methods) {
|
||||||
|
String schluessel = null, typ = null;
|
||||||
|
if ("glfwCreateWindow".equals(m.name) && "(IILjava/lang/CharSequence;JJ)J".equals(m.desc)) {
|
||||||
|
schluessel = Design.TITEL; typ = "java/lang/CharSequence";
|
||||||
|
} else if ("glfwSetWindowTitle".equals(m.name) && "(JLjava/lang/CharSequence;)V".equals(m.desc)) {
|
||||||
|
schluessel = Design.TITEL; typ = "java/lang/CharSequence";
|
||||||
|
} else if ("glfwSetWindowIcon".equals(m.name) && "(JLorg/lwjgl/glfw/GLFWImage$Buffer;)V".equals(m.desc)) {
|
||||||
|
schluessel = Design.ICON; typ = "org/lwjgl/glfw/GLFWImage$Buffer";
|
||||||
|
}
|
||||||
|
if (schluessel == null) continue;
|
||||||
|
// am Anfang: argument = helfer.apply(argument) (Argument liegt in beiden Fällen in Slot 2)
|
||||||
|
InsnList neu = new InsnList();
|
||||||
|
helfer(neu, schluessel);
|
||||||
|
neu.add(new VarInsnNode(Opcodes.ALOAD, 2));
|
||||||
|
neu.add(new MethodInsnNode(Opcodes.INVOKEINTERFACE, "java/util/function/Function", "apply",
|
||||||
|
"(Ljava/lang/Object;)Ljava/lang/Object;", true));
|
||||||
|
neu.add(new TypeInsnNode(Opcodes.CHECKCAST, typ));
|
||||||
|
neu.add(new VarInsnNode(Opcodes.ASTORE, 2));
|
||||||
|
m.instructions.insert(neu);
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
if (n == 0) return null;
|
||||||
|
System.out.println("[AeroMC] Fenstertitel und -symbol vorbereitet (" + n + ")");
|
||||||
|
return schreibe(cn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Hilfen ----------
|
||||||
|
|
||||||
|
/** legt (Function) System.getProperties().get(schluessel) auf den Stapel */
|
||||||
|
private static void helfer(InsnList l, String schluessel) {
|
||||||
|
l.add(new MethodInsnNode(Opcodes.INVOKESTATIC, "java/lang/System", "getProperties", "()Ljava/util/Properties;", false));
|
||||||
|
l.add(new LdcInsnNode(schluessel));
|
||||||
|
l.add(new MethodInsnNode(Opcodes.INVOKEVIRTUAL, "java/util/Properties", "get", "(Ljava/lang/Object;)Ljava/lang/Object;", false));
|
||||||
|
l.add(new TypeInsnNode(Opcodes.CHECKCAST, "java/util/function/Function"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ClassNode lies(byte[] klasse) {
|
||||||
|
ClassNode cn = new ClassNode();
|
||||||
|
new ClassReader(klasse).accept(cn, 0);
|
||||||
|
return cn;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] schreibe(ClassNode cn) {
|
||||||
|
ClassWriter w = new ClassWriter(ClassWriter.COMPUTE_MAXS);
|
||||||
|
cn.accept(w);
|
||||||
|
return w.toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** echte Befehle eines Methodenrumpfs (ohne Labels, Zeilennummern, Frames) */
|
||||||
|
private static List<AbstractInsnNode> befehle(MethodNode m) {
|
||||||
|
List<AbstractInsnNode> l = new ArrayList<>();
|
||||||
|
for (AbstractInsnNode i = m.instructions.getFirst(); i != null; i = i.getNext()) {
|
||||||
|
if (i.getOpcode() >= 0) l.add(i);
|
||||||
|
}
|
||||||
|
return l;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean hatText(ClassNode cn, String text) {
|
||||||
|
for (MethodNode m : cn.methods) {
|
||||||
|
for (AbstractInsnNode i : befehle(m)) {
|
||||||
|
if (i instanceof LdcInsnNode && text.equals(((LdcInsnNode) i).cst)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zahlenwert eines Konstanten-Befehls, sonst KEIN_WERT */
|
||||||
|
private static int wert(AbstractInsnNode i) {
|
||||||
|
int op = i.getOpcode();
|
||||||
|
if (op >= Opcodes.ICONST_M1 && op <= Opcodes.ICONST_5) return op - Opcodes.ICONST_0;
|
||||||
|
if (op == Opcodes.BIPUSH || op == Opcodes.SIPUSH) return ((IntInsnNode) i).operand;
|
||||||
|
if (i instanceof LdcInsnNode && ((LdcInsnNode) i).cst instanceof Integer) return (Integer) ((LdcInsnNode) i).cst;
|
||||||
|
return KEIN_WERT;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean aufruf(AbstractInsnNode i, String desc) {
|
||||||
|
return (i.getOpcode() == Opcodes.INVOKESTATIC || i.getOpcode() == Opcodes.INVOKEVIRTUAL)
|
||||||
|
&& desc.equals(((MethodInsnNode) i).desc);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void setze(MethodNode m, List<AbstractInsnNode> a, int index, int v) {
|
||||||
|
m.instructions.set(a.get(index), ersatz(v, a, index));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** neuer Konstanten-Befehl; ersetzt auch den Eintrag in der Befehlsliste */
|
||||||
|
private static AbstractInsnNode ersatz(int v, List<AbstractInsnNode> a, int index) {
|
||||||
|
AbstractInsnNode n = v >= -32768 && v <= 32767 ? new IntInsnNode(Opcodes.SIPUSH, v) : new LdcInsnNode(v);
|
||||||
|
a.set(index, n);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean enthaelt(byte[] daten, byte[] muster) {
|
||||||
|
outer:
|
||||||
|
for (int i = 0, ende = daten.length - muster.length; i <= ende; i++) {
|
||||||
|
for (int k = 0; k < muster.length; k++) {
|
||||||
|
if (daten[i + k] != muster[k]) continue outer;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package de.aeromc.capes;
|
||||||
|
|
||||||
|
import java.lang.instrument.ClassFileTransformer;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.ProtectionDomain;
|
||||||
|
|
||||||
|
import org.objectweb.asm.ClassReader;
|
||||||
|
import org.objectweb.asm.ClassWriter;
|
||||||
|
import org.objectweb.asm.Opcodes;
|
||||||
|
import org.objectweb.asm.Type;
|
||||||
|
import org.objectweb.asm.tree.AbstractInsnNode;
|
||||||
|
import org.objectweb.asm.tree.ClassNode;
|
||||||
|
import org.objectweb.asm.tree.FieldInsnNode;
|
||||||
|
import org.objectweb.asm.tree.FrameNode;
|
||||||
|
import org.objectweb.asm.tree.InsnList;
|
||||||
|
import org.objectweb.asm.tree.InsnNode;
|
||||||
|
import org.objectweb.asm.tree.JumpInsnNode;
|
||||||
|
import org.objectweb.asm.tree.LabelNode;
|
||||||
|
import org.objectweb.asm.tree.LdcInsnNode;
|
||||||
|
import org.objectweb.asm.tree.MethodInsnNode;
|
||||||
|
import org.objectweb.asm.tree.MethodNode;
|
||||||
|
import org.objectweb.asm.tree.TypeInsnNode;
|
||||||
|
import org.objectweb.asm.tree.VarInsnNode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mod-Schalter im Pausemenü (Minecraft 1.14 … 26.x, jeder Loader):
|
||||||
|
* Der Knopf „Feedback geben“ wird zum Knopf „Mods verwalten“.
|
||||||
|
*
|
||||||
|
* 1. Seine Adresse (aka.ms/javafeedback bzw. snapshotfeedback) wird zu
|
||||||
|
* "aeromc://mods" – je nach Version steht sie im Pausemenü selbst
|
||||||
|
* (bis 1.20) oder in CommonLinks (ab 1.21).
|
||||||
|
* 2. Die Lambdas, über die Minecraft einen Link öffnet (im Pausemenü bzw. in
|
||||||
|
* ConfirmLinkScreen), fragen vorher den Helfer: Ist es "aeromc://mods",
|
||||||
|
* öffnet der Launcher das Mod-Fenster und Minecraft zeigt keine Rückfrage.
|
||||||
|
* Alle anderen Links laufen unverändert weiter.
|
||||||
|
*
|
||||||
|
* Beschriftung bzw. Symbol des Knopfs kommen aus dem Ressourcenpaket.
|
||||||
|
*/
|
||||||
|
final class ModsUmbau implements ClassFileTransformer {
|
||||||
|
static final String ZIEL = "aeromc://mods";
|
||||||
|
private static final String[] ADRESSEN = {"https://aka.ms/javafeedback?ref=game", "https://aka.ms/snapshotfeedback?ref=game"};
|
||||||
|
private static final byte[] MERKMAL_FEEDBACK = "aka.ms/".getBytes(StandardCharsets.UTF_8);
|
||||||
|
private static final byte[] MERKMAL_LINK = "chat.link.confirm".getBytes(StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public byte[] transform(ClassLoader loader, String name, Class<?> umdefiniert, ProtectionDomain pd, byte[] klasse) {
|
||||||
|
if (name == null || umdefiniert != null || name.startsWith("java/") || name.startsWith("jdk/")
|
||||||
|
|| name.startsWith("sun/") || name.startsWith("de/aeromc/") || name.startsWith("org/lwjgl/")) return null;
|
||||||
|
try {
|
||||||
|
boolean feedback = enthaelt(klasse, MERKMAL_FEEDBACK);
|
||||||
|
boolean link = enthaelt(klasse, MERKMAL_LINK);
|
||||||
|
if (!feedback && !link) return null;
|
||||||
|
ClassNode cn = new ClassNode();
|
||||||
|
new ClassReader(klasse).accept(cn, 0);
|
||||||
|
int adressen = 0, waechter = 0;
|
||||||
|
for (MethodNode m : cn.methods) {
|
||||||
|
for (AbstractInsnNode i = m.instructions.getFirst(); i != null; i = i.getNext()) {
|
||||||
|
if (i instanceof LdcInsnNode && istFeedbackAdresse(((LdcInsnNode) i).cst)) {
|
||||||
|
((LdcInsnNode) i).cst = ZIEL;
|
||||||
|
adressen++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (adressen == 0 && !link) return null;
|
||||||
|
for (MethodNode m : cn.methods) {
|
||||||
|
if ((m.access & Opcodes.ACC_SYNTHETIC) == 0 || Type.getReturnType(m.desc) != Type.VOID_TYPE) continue;
|
||||||
|
int slot = adressParameter(m);
|
||||||
|
boolean konstante = slot < 0 && enthaeltZiel(m);
|
||||||
|
if (slot < 0 && !konstante) continue;
|
||||||
|
if (frameAmAnfang(m)) continue; // nicht sicher erweiterbar
|
||||||
|
InsnList w = new InsnList();
|
||||||
|
w.add(new MethodInsnNode(Opcodes.INVOKESTATIC, "java/lang/System", "getProperties", "()Ljava/util/Properties;", false));
|
||||||
|
w.add(new LdcInsnNode(Design.MODS));
|
||||||
|
w.add(new MethodInsnNode(Opcodes.INVOKEVIRTUAL, "java/util/Properties", "get", "(Ljava/lang/Object;)Ljava/lang/Object;", false));
|
||||||
|
w.add(new TypeInsnNode(Opcodes.CHECKCAST, "java/util/function/Function"));
|
||||||
|
if (slot >= 0) w.add(new VarInsnNode(Opcodes.ALOAD, slot)); else w.add(new LdcInsnNode(ZIEL));
|
||||||
|
w.add(new MethodInsnNode(Opcodes.INVOKEINTERFACE, "java/util/function/Function", "apply", "(Ljava/lang/Object;)Ljava/lang/Object;", true));
|
||||||
|
w.add(new FieldInsnNode(Opcodes.GETSTATIC, "java/lang/Boolean", "TRUE", "Ljava/lang/Boolean;"));
|
||||||
|
LabelNode weiter = new LabelNode();
|
||||||
|
w.add(new JumpInsnNode(Opcodes.IF_ACMPNE, weiter));
|
||||||
|
w.add(new InsnNode(Opcodes.RETURN));
|
||||||
|
w.add(weiter);
|
||||||
|
w.add(new FrameNode(Opcodes.F_SAME, 0, null, 0, null));
|
||||||
|
m.instructions.insert(w);
|
||||||
|
waechter++;
|
||||||
|
}
|
||||||
|
if (adressen == 0 && waechter == 0) return null;
|
||||||
|
System.out.println("[AeroMC] Mod-Schalter vorbereitet (Adressen " + adressen + ", Links " + waechter + ")");
|
||||||
|
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS);
|
||||||
|
cn.accept(cw);
|
||||||
|
return cw.toByteArray();
|
||||||
|
} catch (Throwable t) {
|
||||||
|
System.out.println("[AeroMC] Mod-Schalter: " + name + " nicht angepasst: " + t);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean istFeedbackAdresse(Object cst) {
|
||||||
|
for (String a : ADRESSEN) if (a.equals(cst)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Slot des ersten Parameters vom Typ String oder URI, sonst -1 */
|
||||||
|
private static int adressParameter(MethodNode m) {
|
||||||
|
int slot = (m.access & Opcodes.ACC_STATIC) != 0 ? 0 : 1;
|
||||||
|
for (Type t : Type.getArgumentTypes(m.desc)) {
|
||||||
|
String d = t.getDescriptor();
|
||||||
|
if ("Ljava/lang/String;".equals(d) || "Ljava/net/URI;".equals(d)) return slot;
|
||||||
|
slot += t.getSize();
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean enthaeltZiel(MethodNode m) {
|
||||||
|
for (AbstractInsnNode i = m.instructions.getFirst(); i != null; i = i.getNext()) {
|
||||||
|
if (i instanceof LdcInsnNode && ZIEL.equals(((LdcInsnNode) i).cst)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** steht vor dem ersten echten Befehl schon ein Frame (Sprungziel am Anfang)? */
|
||||||
|
private static boolean frameAmAnfang(MethodNode m) {
|
||||||
|
for (AbstractInsnNode i = m.instructions.getFirst(); i != null; i = i.getNext()) {
|
||||||
|
if (i instanceof FrameNode) return true;
|
||||||
|
if (i.getOpcode() >= 0) return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean enthaelt(byte[] daten, byte[] muster) {
|
||||||
|
outer:
|
||||||
|
for (int i = 0, ende = daten.length - muster.length; i <= ende; i++) {
|
||||||
|
for (int k = 0; k < muster.length; k++) if (daten[i + k] != muster[k]) continue outer;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,80 +1,80 @@
|
|||||||
import java.io.ByteArrayOutputStream;
|
import java.io.ByteArrayOutputStream;
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.FileOutputStream;
|
import java.io.FileOutputStream;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.util.Enumeration;
|
import java.util.Enumeration;
|
||||||
import java.util.jar.Attributes;
|
import java.util.jar.Attributes;
|
||||||
import java.util.jar.JarEntry;
|
import java.util.jar.JarEntry;
|
||||||
import java.util.jar.JarFile;
|
import java.util.jar.JarFile;
|
||||||
import java.util.jar.JarOutputStream;
|
import java.util.jar.JarOutputStream;
|
||||||
import java.util.jar.Manifest;
|
import java.util.jar.Manifest;
|
||||||
|
|
||||||
import org.objectweb.asm.ClassReader;
|
import org.objectweb.asm.ClassReader;
|
||||||
import org.objectweb.asm.ClassWriter;
|
import org.objectweb.asm.ClassWriter;
|
||||||
import org.objectweb.asm.commons.ClassRemapper;
|
import org.objectweb.asm.commons.ClassRemapper;
|
||||||
import org.objectweb.asm.commons.Remapper;
|
import org.objectweb.asm.commons.Remapper;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Baut die Agent-Datei: eigene Klassen + ASM, dabei org/objectweb/asm nach
|
* Baut die Agent-Datei: eigene Klassen + ASM, dabei org/objectweb/asm nach
|
||||||
* de/aeromc/capes/asm umbenannt. So kann das ASM von Fabric/Forge (das auf
|
* de/aeromc/capes/asm umbenannt. So kann das ASM von Fabric/Forge (das auf
|
||||||
* demselben Klassenpfad liegt) nie mit unserem verwechselt werden.
|
* demselben Klassenpfad liegt) nie mit unserem verwechselt werden.
|
||||||
*
|
*
|
||||||
* Aufruf: java Shade <ziel.jar> <klassenordner> <asm.jar>
|
* Aufruf: java Shade <ziel.jar> <klassenordner> <asm.jar> [weitere ASM-Jars, z. B. asm-tree]
|
||||||
*/
|
*/
|
||||||
public class Shade {
|
public class Shade {
|
||||||
static final Remapper UMBENENNEN = new Remapper() {
|
static final Remapper UMBENENNEN = new Remapper() {
|
||||||
@Override
|
@Override
|
||||||
public String map(String name) {
|
public String map(String name) {
|
||||||
return name.startsWith("org/objectweb/asm/") ? "de/aeromc/capes/asm/" + name.substring(18) : name;
|
return name.startsWith("org/objectweb/asm/") ? "de/aeromc/capes/asm/" + name.substring(18) : name;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
static byte[] umbenennen(byte[] klasse) {
|
static byte[] umbenennen(byte[] klasse) {
|
||||||
ClassReader r = new ClassReader(klasse);
|
ClassReader r = new ClassReader(klasse);
|
||||||
ClassWriter w = new ClassWriter(0);
|
ClassWriter w = new ClassWriter(0);
|
||||||
r.accept(new ClassRemapper(w, UMBENENNEN), 0);
|
r.accept(new ClassRemapper(w, UMBENENNEN), 0);
|
||||||
return w.toByteArray();
|
return w.toByteArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
static byte[] lies(InputStream in) throws Exception {
|
static byte[] lies(InputStream in) throws Exception {
|
||||||
ByteArrayOutputStream b = new ByteArrayOutputStream();
|
ByteArrayOutputStream b = new ByteArrayOutputStream();
|
||||||
byte[] p = new byte[8192];
|
byte[] p = new byte[8192];
|
||||||
int n;
|
int n;
|
||||||
while ((n = in.read(p)) > 0) b.write(p, 0, n);
|
while ((n = in.read(p)) > 0) b.write(p, 0, n);
|
||||||
return b.toByteArray();
|
return b.toByteArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void main(String[] a) throws Exception {
|
public static void main(String[] a) throws Exception {
|
||||||
Manifest mf = new Manifest();
|
Manifest mf = new Manifest();
|
||||||
Attributes at = mf.getMainAttributes();
|
Attributes at = mf.getMainAttributes();
|
||||||
at.put(Attributes.Name.MANIFEST_VERSION, "1.0");
|
at.put(Attributes.Name.MANIFEST_VERSION, "1.0");
|
||||||
at.putValue("Premain-Class", "de.aeromc.capes.Agent");
|
at.putValue("Premain-Class", "de.aeromc.capes.Agent");
|
||||||
at.putValue("Agent-Class", "de.aeromc.capes.Agent");
|
at.putValue("Agent-Class", "de.aeromc.capes.Agent");
|
||||||
at.putValue("Can-Retransform-Classes", "false");
|
at.putValue("Can-Retransform-Classes", "false");
|
||||||
at.putValue("Implementation-Title", "AeroMC Umhang-Agent");
|
at.putValue("Implementation-Title", "AeroMC Agent (Umhaenge, Spieldesign)");
|
||||||
try (JarOutputStream out = new JarOutputStream(new FileOutputStream(a[0]), mf)) {
|
try (JarOutputStream out = new JarOutputStream(new FileOutputStream(a[0]), mf)) {
|
||||||
// eigene Klassen
|
// eigene Klassen
|
||||||
File basis = new File(a[1]);
|
File basis = new File(a[1]);
|
||||||
for (File f : Files.walk(basis.toPath()).map(p -> p.toFile()).filter(f -> f.getName().endsWith(".class")).toArray(File[]::new)) {
|
for (File f : Files.walk(basis.toPath()).map(p -> p.toFile()).filter(f -> f.getName().endsWith(".class")).toArray(File[]::new)) {
|
||||||
String name = basis.toPath().relativize(f.toPath()).toString().replace('\\', '/');
|
String name = basis.toPath().relativize(f.toPath()).toString().replace('\\', '/');
|
||||||
out.putNextEntry(new JarEntry(name));
|
out.putNextEntry(new JarEntry(name));
|
||||||
out.write(umbenennen(Files.readAllBytes(f.toPath())));
|
out.write(umbenennen(Files.readAllBytes(f.toPath())));
|
||||||
out.closeEntry();
|
out.closeEntry();
|
||||||
}
|
}
|
||||||
// ASM (umbenannt), ohne Modul-Infos und Signaturen
|
// ASM (umbenannt), ohne Modul-Infos und Signaturen
|
||||||
try (JarFile asm = new JarFile(a[2])) {
|
for (int j = 2; j < a.length; j++) try (JarFile asm = new JarFile(a[j])) {
|
||||||
Enumeration<JarEntry> e = asm.entries();
|
Enumeration<JarEntry> e = asm.entries();
|
||||||
while (e.hasMoreElements()) {
|
while (e.hasMoreElements()) {
|
||||||
JarEntry je = e.nextElement();
|
JarEntry je = e.nextElement();
|
||||||
String n = je.getName();
|
String n = je.getName();
|
||||||
if (!n.startsWith("org/objectweb/asm/") || !n.endsWith(".class")) continue;
|
if (!n.startsWith("org/objectweb/asm/") || !n.endsWith(".class")) continue;
|
||||||
byte[] neu = umbenennen(lies(asm.getInputStream(je)));
|
byte[] neu = umbenennen(lies(asm.getInputStream(je)));
|
||||||
out.putNextEntry(new JarEntry("de/aeromc/capes/asm/" + n.substring(18)));
|
out.putNextEntry(new JarEntry("de/aeromc/capes/asm/" + n.substring(18)));
|
||||||
out.write(neu);
|
out.write(neu);
|
||||||
out.closeEntry();
|
out.closeEntry();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,9 +181,35 @@ public static class Installer
|
|||||||
k.SetValue("NoRepair", 1, RegistryValueKind.DWord);
|
k.SetValue("NoRepair", 1, RegistryValueKind.DWord);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
progress.Report((0.98, "Installation wird abgeschlossen …"));
|
||||||
|
Vorwaermen(exe);
|
||||||
|
|
||||||
progress.Report((1.0, "Fertig"));
|
progress.Report((1.0, "Fertig"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Startet AeroMC einmal kurz mit "--aeromc-vorwaermen" – die App beendet sich
|
||||||
|
/// damit sofort wieder (siehe src/main.js). Windows Defender prüft eine neue,
|
||||||
|
/// noch unbekannte EXE beim ersten Start mehrere Sekunden lang (gemessen: ~8 s);
|
||||||
|
/// das passiert so während des Fortschrittsbalkens und nicht erst beim Klick
|
||||||
|
/// auf "Fertigstellen". Das Ergebnis merkt sich Windows, der echte Start ist
|
||||||
|
/// danach sofort da.
|
||||||
|
/// </summary>
|
||||||
|
private static void Vorwaermen(string exe)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var p = Process.Start(new ProcessStartInfo(exe, "--aeromc-vorwaermen")
|
||||||
|
{
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
WorkingDirectory = Path.GetDirectoryName(exe)!,
|
||||||
|
});
|
||||||
|
if (p != null && !p.WaitForExit(30000)) { try { p.Kill(); } catch { } }
|
||||||
|
}
|
||||||
|
catch { /* nur eine Beschleunigung – Fehler sind egal */ }
|
||||||
|
}
|
||||||
|
|
||||||
private static List<string> TopLevelNames(ZipArchive zip) =>
|
private static List<string> TopLevelNames(ZipArchive zip) =>
|
||||||
zip.Entries
|
zip.Entries
|
||||||
.Select(e => e.FullName.Replace('\\', '/').Split('/')[0])
|
.Select(e => e.FullName.Replace('\\', '/').Split('/')[0])
|
||||||
|
|||||||
@@ -300,7 +300,16 @@ public partial class MainWindow : Window
|
|||||||
await RunInstall(DirBox.Text.Trim(), DesktopLinkBox.IsChecked == true, update: false);
|
await RunInstall(DirBox.Text.Trim(), DesktopLinkBox.IsChecked == true, update: false);
|
||||||
break;
|
break;
|
||||||
case PageNumProgress:
|
case PageNumProgress:
|
||||||
if (LaunchBox.IsChecked == true) StartApp();
|
// Start im Hintergrund: Process.Start kann blockieren, solange Windows
|
||||||
|
// die EXE prüft – das Fenster darf dabei nie einfrieren
|
||||||
|
if (LaunchBox.IsChecked == true)
|
||||||
|
{
|
||||||
|
_busy = true;
|
||||||
|
NextButton.IsEnabled = false;
|
||||||
|
NextButton.Content = "AeroMC startet …";
|
||||||
|
await Task.Run(StartApp);
|
||||||
|
_busy = false;
|
||||||
|
}
|
||||||
Close();
|
Close();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
},
|
},
|
||||||
"projects": {
|
"projects": {
|
||||||
"C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj": {
|
"C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj": {
|
||||||
"version": "1.1.8",
|
"version": "0.0.0",
|
||||||
"restore": {
|
"restore": {
|
||||||
"projectUniqueName": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj",
|
"projectUniqueName": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj",
|
||||||
"projectName": "AeroMC-Setup",
|
"projectName": "AeroMC-Setup",
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ using System.Reflection;
|
|||||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Release")]
|
[assembly: System.Reflection.AssemblyConfigurationAttribute("Release")]
|
||||||
[assembly: System.Reflection.AssemblyCopyrightAttribute("Copyright (c) M_Viper. Alle Rechte vorbehalten.")]
|
[assembly: System.Reflection.AssemblyCopyrightAttribute("Copyright (c) M_Viper. Alle Rechte vorbehalten.")]
|
||||||
[assembly: System.Reflection.AssemblyDescriptionAttribute("Installiert AeroMC")]
|
[assembly: System.Reflection.AssemblyDescriptionAttribute("Installiert AeroMC")]
|
||||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.1.8.0")]
|
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.2.4.0")]
|
||||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.1.8")]
|
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.2.4")]
|
||||||
[assembly: System.Reflection.AssemblyProductAttribute("AeroMC Setup")]
|
[assembly: System.Reflection.AssemblyProductAttribute("AeroMC Setup")]
|
||||||
[assembly: System.Reflection.AssemblyTitleAttribute("AeroMC Setup")]
|
[assembly: System.Reflection.AssemblyTitleAttribute("AeroMC Setup")]
|
||||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.1.8.0")]
|
[assembly: System.Reflection.AssemblyVersionAttribute("1.2.4.0")]
|
||||||
[assembly: System.Resources.NeutralResourcesLanguageAttribute("de-DE")]
|
[assembly: System.Resources.NeutralResourcesLanguageAttribute("de-DE")]
|
||||||
|
|
||||||
// Von der MSBuild WriteCodeFragment-Klasse generiert.
|
// Von der MSBuild WriteCodeFragment-Klasse generiert.
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
820d60fa6c1c1080954d3f18089a7c45daf7f39440b819f670e61aded52999a5
|
c285113a921613345252c7c4e9c903f219339ded7e116f59ea5c382b3fb91383
|
||||||
|
|||||||
@@ -418,7 +418,7 @@
|
|||||||
"C:\\Users\\Viper\\.nuget\\packages\\": {}
|
"C:\\Users\\Viper\\.nuget\\packages\\": {}
|
||||||
},
|
},
|
||||||
"project": {
|
"project": {
|
||||||
"version": "1.1.8",
|
"version": "0.0.0",
|
||||||
"restore": {
|
"restore": {
|
||||||
"projectUniqueName": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj",
|
"projectUniqueName": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj",
|
||||||
"projectName": "AeroMC-Setup",
|
"projectName": "AeroMC-Setup",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"dgSpecHash": "UguGrVny76E=",
|
"dgSpecHash": "FnRoda2hVew=",
|
||||||
"success": true,
|
"success": true,
|
||||||
"projectFilePath": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj",
|
"projectFilePath": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj",
|
||||||
"expectedPackageFiles": [
|
"expectedPackageFiles": [
|
||||||
|
|||||||
@@ -1,54 +1,64 @@
|
|||||||
{
|
{
|
||||||
"name": "aeromc",
|
"name": "aeromc",
|
||||||
"productName": "AeroMC",
|
"productName": "AeroMC",
|
||||||
"version": "1.1.8",
|
"version": "1.2.4",
|
||||||
"description": "AeroMC – Instanz-basierter Minecraft-Launcher (à la MultiMC/Prism)",
|
"description": "AeroMC – Instanz-basierter Minecraft-Launcher (à la MultiMC/Prism)",
|
||||||
"homepage": "https://m-viper.de",
|
"homepage": "https://m-viper.de",
|
||||||
"launcherWebsite": "https://aeromc.viper.ipv64.net",
|
"launcherWebsite": "https://aeromc.viper.ipv64.net",
|
||||||
"main": "src/main.js",
|
"main": "src/main.js",
|
||||||
"author": "M_Viper",
|
"author": "M_Viper",
|
||||||
"license": "SEE LICENSE IN LICENSE",
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "electron .",
|
"start": "electron .",
|
||||||
"prebuild": "node scripts/write-build-config.js",
|
"prebuild": "node scripts/pruefe-version.js && node scripts/write-build-config.js",
|
||||||
"prebuild:portable": "node scripts/write-build-config.js",
|
"prebuild:portable": "node scripts/pruefe-version.js && node scripts/write-build-config.js",
|
||||||
"build": "electron-builder --win dir && node scripts/build-setup.js",
|
"build": "electron-builder --win dir && node scripts/build-setup.js && node scripts/sign-release.js setup",
|
||||||
"build:portable": "electron-builder --win portable --x64 && node -e \"const fs=require('fs');const path=require('path');['dist/builder-debug.yml','dist/builder-effective-config.yaml','dist/win-unpacked'].forEach((target)=>fs.rmSync(target,{recursive:true,force:true}));if(fs.existsSync('dist')){const source=path.join('dist','AeroMC.exe');const target=path.join('dist','AeroMC-Portable.exe');if(fs.existsSync(target))fs.rmSync(target,{force:true});if(fs.existsSync(source))fs.renameSync(source,target);for(const name of fs.readdirSync('dist')){if((name.endsWith('.exe')&&name!=='AeroMC-Setup.exe'&&name!=='AeroMC-Portable.exe')||name.endsWith('.blockmap')){fs.rmSync(path.join('dist',name),{force:true})}}}\""
|
"build:portable": "electron-builder --win portable --x64 && node -e \"const fs=require('fs');const path=require('path');['dist/builder-debug.yml','dist/builder-effective-config.yaml','dist/win-unpacked'].forEach((target)=>fs.rmSync(target,{recursive:true,force:true}));if(fs.existsSync('dist')){const source=path.join('dist','AeroMC.exe');const target=path.join('dist','AeroMC-Portable.exe');if(fs.existsSync(target))fs.rmSync(target,{force:true});if(fs.existsSync(source))fs.renameSync(source,target);for(const name of fs.readdirSync('dist')){if((name.endsWith('.exe')&&name!=='AeroMC-Setup.exe'&&name!=='AeroMC-Portable.exe')||name.endsWith('.blockmap')){fs.rmSync(path.join('dist',name),{force:true})}}}\" && node scripts/sign-release.js portable"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"appId": "com.aeromc.launcher",
|
"appId": "com.aeromc.launcher",
|
||||||
"productName": "AeroMC",
|
"productName": "AeroMC",
|
||||||
"artifactName": "AeroMC.${ext}",
|
"artifactName": "AeroMC.${ext}",
|
||||||
"compression": "store",
|
"compression": "store",
|
||||||
"files": [
|
"files": [
|
||||||
"src/**/*",
|
"src/**/*",
|
||||||
"package.json",
|
"package.json",
|
||||||
"LICENSE"
|
"LICENSE"
|
||||||
],
|
],
|
||||||
"asarUnpack": [
|
"asarUnpack": [
|
||||||
"node_modules/loopback-capture/**/*",
|
"node_modules/loopback-capture/**/*",
|
||||||
"node_modules/node-window-manager/**/*",
|
"node_modules/node-window-manager/**/*",
|
||||||
"node_modules/ffmpeg-static/**/*"
|
"node_modules/ffmpeg-static/**/*"
|
||||||
],
|
],
|
||||||
"win": {
|
"win": {
|
||||||
"target": "dir",
|
"target": "dir",
|
||||||
"icon": "src/assets/icon.ico"
|
"icon": "src/assets/icon.ico"
|
||||||
}
|
},
|
||||||
},
|
"electronFuses": {
|
||||||
"devDependencies": {
|
"runAsNode": false,
|
||||||
"electron": "^28.0.0",
|
"enableNodeOptionsEnvironmentVariable": false,
|
||||||
"electron-builder": "^24.0.0"
|
"enableNodeCliInspectArguments": false,
|
||||||
},
|
"enableEmbeddedAsarIntegrityValidation": true,
|
||||||
"dependencies": {
|
"onlyLoadAppFromAsar": true
|
||||||
"@xhayper/discord-rpc": "^1.3.4",
|
}
|
||||||
"archiver": "^7.0.1",
|
},
|
||||||
"ffmpeg-static": "^5.3.0",
|
"devDependencies": {
|
||||||
"loopback-capture": "^3.0.2",
|
"electron": "^44.6.0",
|
||||||
"node-window-manager": "^2.2.4",
|
"electron-builder": "^26.15.3"
|
||||||
"skinview3d": "^3.4.2",
|
},
|
||||||
"sql.js": "^1.14.2",
|
"dependencies": {
|
||||||
"undici": "^6.28.0",
|
"@xhayper/discord-rpc": "^1.3.4",
|
||||||
"unzipper": "^0.12.5"
|
"archiver": "^7.0.1",
|
||||||
}
|
"ffmpeg-static": "^5.3.0",
|
||||||
}
|
"loopback-capture": "^3.0.2",
|
||||||
|
"node-window-manager": "^2.2.4",
|
||||||
|
"skinview3d": "^3.4.2",
|
||||||
|
"sql.js": "^1.14.2",
|
||||||
|
"undici": "^6.28.0",
|
||||||
|
"unzipper": "^0.12.5"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"brace-expansion@^2.0.0": "^2.1.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,72 +1,73 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* build-agent.js – baut src/assets/agent/aeromc-capes.jar (AeroMC-Umhang-Agent)
|
* build-agent.js – baut src/assets/agent/aeromc-capes.jar (AeroMC-Umhang-Agent)
|
||||||
* ------------------------------------------------------------------------------
|
* ------------------------------------------------------------------------------
|
||||||
* Braucht ein JDK (javac/java, ab 11) und ASM 9 – beides wird nur beim Bauen
|
* Braucht ein JDK (javac/java, ab 11) und ASM 9 – beides wird nur beim Bauen
|
||||||
* gebraucht, die fertige Datei liegt im Projekt. Gesucht wird das JDK über
|
* gebraucht, die fertige Datei liegt im Projekt. Gesucht wird das JDK über
|
||||||
* JAVA_HOME bzw. unter "C:\Program Files\Eclipse Adoptium", ASM im
|
* JAVA_HOME bzw. unter "C:\Program Files\Eclipse Adoptium", ASM im
|
||||||
* Bibliotheksordner von AeroMC (dort legen Fabric/Forge es ohnehin ab).
|
* Bibliotheksordner von AeroMC (dort legen Fabric/Forge es ohnehin ab).
|
||||||
* Der Agent wird für Java 8 übersetzt, läuft also mit jeder Minecraft-Version.
|
* Der Agent wird für Java 8 übersetzt, läuft also mit jeder Minecraft-Version.
|
||||||
*
|
*
|
||||||
* Aufruf: node scripts/build-agent.js
|
* Aufruf: node scripts/build-agent.js
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { execFileSync } = require('child_process');
|
const { execFileSync } = require('child_process');
|
||||||
|
|
||||||
const WURZEL = path.join(__dirname, '..');
|
const WURZEL = path.join(__dirname, '..');
|
||||||
const ZIEL = path.join(WURZEL, 'src', 'assets', 'agent', 'aeromc-capes.jar');
|
const ZIEL = path.join(WURZEL, 'src', 'assets', 'agent', 'aeromc-capes.jar');
|
||||||
|
|
||||||
function findeJdk() {
|
function findeJdk() {
|
||||||
const kandidaten = [];
|
const kandidaten = [];
|
||||||
if (process.env.JAVA_HOME) kandidaten.push(process.env.JAVA_HOME);
|
if (process.env.JAVA_HOME) kandidaten.push(process.env.JAVA_HOME);
|
||||||
const adoptium = 'C:\\Program Files\\Eclipse Adoptium';
|
const adoptium = 'C:\\Program Files\\Eclipse Adoptium';
|
||||||
if (fs.existsSync(adoptium)) {
|
if (fs.existsSync(adoptium)) {
|
||||||
for (const d of fs.readdirSync(adoptium).filter((n) => /^jdk-/.test(n)).sort().reverse()) kandidaten.push(path.join(adoptium, d));
|
for (const d of fs.readdirSync(adoptium).filter((n) => /^jdk-/.test(n)).sort().reverse()) kandidaten.push(path.join(adoptium, d));
|
||||||
}
|
}
|
||||||
const jdk = kandidaten.find((d) => fs.existsSync(path.join(d, 'bin', process.platform === 'win32' ? 'javac.exe' : 'javac')));
|
const jdk = kandidaten.find((d) => fs.existsSync(path.join(d, 'bin', process.platform === 'win32' ? 'javac.exe' : 'javac')));
|
||||||
if (!jdk) throw new Error('Kein JDK gefunden (JAVA_HOME setzen).');
|
if (!jdk) throw new Error('Kein JDK gefunden (JAVA_HOME setzen).');
|
||||||
return jdk;
|
return jdk;
|
||||||
}
|
}
|
||||||
|
|
||||||
function findeAsm(name) {
|
function findeAsm(name) {
|
||||||
const basis = path.join(process.env.APPDATA || os.homedir(), 'aeromc', 'shared', 'libraries', 'org', 'ow2', 'asm', name);
|
const basis = path.join(process.env.APPDATA || os.homedir(), 'aeromc', 'shared', 'libraries', 'org', 'ow2', 'asm', name);
|
||||||
if (!fs.existsSync(basis)) throw new Error(`ASM (${name}) nicht gefunden unter ${basis}`);
|
if (!fs.existsSync(basis)) throw new Error(`ASM (${name}) nicht gefunden unter ${basis}`);
|
||||||
const versionen = fs.readdirSync(basis).filter((v) => /^9\./.test(v))
|
const versionen = fs.readdirSync(basis).filter((v) => /^9\./.test(v))
|
||||||
.sort((a, b) => b.split('.').map(Number).reduce((s, x, i) => s || x - (a.split('.').map(Number)[i] || 0), 0));
|
.sort((a, b) => b.split('.').map(Number).reduce((s, x, i) => s || x - (a.split('.').map(Number)[i] || 0), 0));
|
||||||
const datei = path.join(basis, versionen[0], `${name}-${versionen[0]}.jar`);
|
const datei = path.join(basis, versionen[0], `${name}-${versionen[0]}.jar`);
|
||||||
if (!fs.existsSync(datei)) throw new Error('ASM-Datei fehlt: ' + datei);
|
if (!fs.existsSync(datei)) throw new Error('ASM-Datei fehlt: ' + datei);
|
||||||
return datei;
|
return datei;
|
||||||
}
|
}
|
||||||
|
|
||||||
function quellen(ordner) {
|
function quellen(ordner) {
|
||||||
return fs.readdirSync(ordner, { withFileTypes: true }).flatMap((e) => {
|
return fs.readdirSync(ordner, { withFileTypes: true }).flatMap((e) => {
|
||||||
const p = path.join(ordner, e.name);
|
const p = path.join(ordner, e.name);
|
||||||
return e.isDirectory() ? quellen(p) : (e.name.endsWith('.java') ? [p] : []);
|
return e.isDirectory() ? quellen(p) : (e.name.endsWith('.java') ? [p] : []);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const jdk = findeJdk();
|
const jdk = findeJdk();
|
||||||
const javac = path.join(jdk, 'bin', 'javac');
|
const javac = path.join(jdk, 'bin', 'javac');
|
||||||
const java = path.join(jdk, 'bin', 'java');
|
const java = path.join(jdk, 'bin', 'java');
|
||||||
const asm = findeAsm('asm');
|
const asm = findeAsm('asm');
|
||||||
const asmCommons = findeAsm('asm-commons');
|
const asmCommons = findeAsm('asm-commons');
|
||||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'aeromc-agent-'));
|
const asmTree = findeAsm('asm-tree');
|
||||||
const klassen = path.join(tmp, 'klassen');
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'aeromc-agent-'));
|
||||||
const werkzeug = path.join(tmp, 'werkzeug');
|
const klassen = path.join(tmp, 'klassen');
|
||||||
console.log('JDK:', jdk);
|
const werkzeug = path.join(tmp, 'werkzeug');
|
||||||
console.log('ASM:', asm);
|
console.log('JDK:', jdk);
|
||||||
try {
|
console.log('ASM:', asm);
|
||||||
execFileSync(javac, ['--release', '8', '-nowarn', '-encoding', 'UTF-8', '-cp', asm, '-d', klassen,
|
try {
|
||||||
...quellen(path.join(WURZEL, 'agent', 'src'))], { stdio: 'inherit' });
|
execFileSync(javac, ['--release', '8', '-nowarn', '-encoding', 'UTF-8', '-cp', [asm, asmTree].join(path.delimiter), '-d', klassen,
|
||||||
execFileSync(javac, ['-encoding', 'UTF-8', '-cp', [asm, asmCommons].join(path.delimiter), '-d', werkzeug,
|
...quellen(path.join(WURZEL, 'agent', 'src'))], { stdio: 'inherit' });
|
||||||
path.join(WURZEL, 'agent', 'tools', 'Shade.java')], { stdio: 'inherit' });
|
execFileSync(javac, ['-encoding', 'UTF-8', '-cp', [asm, asmCommons].join(path.delimiter), '-d', werkzeug,
|
||||||
fs.mkdirSync(path.dirname(ZIEL), { recursive: true });
|
path.join(WURZEL, 'agent', 'tools', 'Shade.java')], { stdio: 'inherit' });
|
||||||
execFileSync(java, ['-cp', [werkzeug, asm, asmCommons].join(path.delimiter), 'Shade', ZIEL, klassen, asm], { stdio: 'inherit' });
|
fs.mkdirSync(path.dirname(ZIEL), { recursive: true });
|
||||||
console.log(`Fertig: ${ZIEL} (${fs.statSync(ZIEL).size} Bytes)`);
|
execFileSync(java, ['-cp', [werkzeug, asm, asmCommons].join(path.delimiter), 'Shade', ZIEL, klassen, asm, asmTree], { stdio: 'inherit' });
|
||||||
} finally {
|
console.log(`Fertig: ${ZIEL} (${fs.statSync(ZIEL).size} Bytes)`);
|
||||||
fs.rmSync(tmp, { recursive: true, force: true });
|
} finally {
|
||||||
}
|
fs.rmSync(tmp, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,712 @@
|
|||||||
|
"""
|
||||||
|
make-spieldesign.py – erzeugt die Grafiken für den AeroMC-Look im Spiel
|
||||||
|
=======================================================================
|
||||||
|
Ausgabe nach src/assets/spieldesign/:
|
||||||
|
|
||||||
|
titel_alt.png / titel_neu.png Titel-Logo (textures/gui/title/minecraft.png)
|
||||||
|
alt = bis 1.19.4 (zweigeteilt im 256×256-Atlas),
|
||||||
|
neu = ab 1.20 (ein Stück, 256×64)
|
||||||
|
edition_alt.png / edition_neu.png Untertitel "DEIN MINECRAFT ERLEBNIS" (edition.png)
|
||||||
|
ladelogo.png Ladebildschirm ab 1.16 als Vollbild (der Agent zieht
|
||||||
|
die Logo-Fläche auf Bildschirmgröße); im Format von
|
||||||
|
mojangstudios.png: linke Bildhälfte oben, rechte unten
|
||||||
|
ladelogo_alt.png Logo im Ladebildschirm 1.14/1.15 (ersetzt mojang.png)
|
||||||
|
panorama_0..5.png Hintergrund des Hauptmenüs (Würfelseiten)
|
||||||
|
pack.png, icon_<n>.rgba Paket- und Fenstersymbol
|
||||||
|
|
||||||
|
Eigene Bilder (optional) in scripts/spieldesign-quelle/:
|
||||||
|
hintergrund.png|jpg Ladebildschirm-Hintergrund (am besten 16:9, z. B. 1920×1080)
|
||||||
|
panorama.png|jpg Rundum-Panorama (360° × 180°, equirektangular, Seitenverhältnis 2:1)
|
||||||
|
Fehlen sie, wird eine stilisierte Block-Landschaft in AeroMC-Blau gezeichnet.
|
||||||
|
|
||||||
|
Alle Grafiken sind eigene Entwürfe – kein Mojang-Material.
|
||||||
|
Aufruf: python scripts/make-spieldesign.py
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image, ImageChops, ImageDraw, ImageFilter, ImageFont
|
||||||
|
|
||||||
|
WURZEL = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..')
|
||||||
|
ZIEL = os.path.join(WURZEL, 'src', 'assets', 'spieldesign')
|
||||||
|
QUELLE = os.path.join(WURZEL, 'scripts', 'spieldesign-quelle')
|
||||||
|
LOGO = os.path.join(WURZEL, 'src', 'assets', 'logo.png')
|
||||||
|
SCHRIFT = 'C:/Windows/Fonts/seguisb.ttf'
|
||||||
|
NACHTBLAU = (10, 14, 26) # Hintergrund des Ladebildschirms (muss zu Design.HINTERGRUND passen)
|
||||||
|
KONTUR = (7, 14, 36)
|
||||||
|
|
||||||
|
# ---------- Block-Buchstaben (7 Zeilen hoch) ----------
|
||||||
|
GLYPHEN = {
|
||||||
|
'A': ['.####.', '##..##', '##..##', '######', '##..##', '##..##', '##..##'],
|
||||||
|
'E': ['######', '##....', '##....', '#####.', '##....', '##....', '######'],
|
||||||
|
'R': ['#####.', '##..##', '##..##', '#####.', '##.##.', '##..##', '##..##'],
|
||||||
|
'O': ['.####.', '##..##', '##..##', '##..##', '##..##', '##..##', '.####.'],
|
||||||
|
'M': ['##...##', '###.###', '#######', '##.#.##', '##...##', '##...##', '##...##'],
|
||||||
|
'C': ['.#####', '##....', '##....', '##....', '##....', '##....', '.#####'],
|
||||||
|
}
|
||||||
|
# kleine Pixelschrift für den Untertitel (5 Zeilen hoch)
|
||||||
|
KLEIN = {
|
||||||
|
'D': ['###.', '#..#', '#..#', '#..#', '###.'], 'E': ['###', '#..', '##.', '#..', '###'],
|
||||||
|
'I': ['#', '#', '#', '#', '#'], 'N': ['#..#', '##.#', '#.##', '#..#', '#..#'],
|
||||||
|
'M': ['#...#', '##.##', '#.#.#', '#...#', '#...#'], 'C': ['.##', '#..', '#..', '#..', '.##'],
|
||||||
|
'R': ['##.', '#.#', '##.', '#.#', '#.#'], 'A': ['.#.', '#.#', '###', '#.#', '#.#'],
|
||||||
|
'F': ['###', '#..', '##.', '#..', '#..'], 'T': ['###', '.#.', '.#.', '.#.', '.#.'],
|
||||||
|
'L': ['#..', '#..', '#..', '#..', '###'], 'B': ['##.', '#.#', '##.', '#.#', '##.'],
|
||||||
|
'S': ['.##', '#..', '.#.', '..#', '##.'],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def raster(text, glyphen, abstand=1, wortabstand=3):
|
||||||
|
punkte, x = [], 0
|
||||||
|
for i, z in enumerate(text):
|
||||||
|
if z == ' ':
|
||||||
|
x += wortabstand
|
||||||
|
continue
|
||||||
|
g = glyphen[z]
|
||||||
|
for y, zeile in enumerate(g):
|
||||||
|
for dx, c in enumerate(zeile):
|
||||||
|
if c == '#':
|
||||||
|
punkte.append((x + dx, y, i))
|
||||||
|
x += len(g[0]) + abstand
|
||||||
|
return punkte, x - abstand, len(next(iter(glyphen.values())))
|
||||||
|
|
||||||
|
|
||||||
|
def verlauf(h, w, oben, unten):
|
||||||
|
t = np.linspace(0, 1, h)[:, None, None]
|
||||||
|
return np.broadcast_to(np.array(oben, float) * (1 - t) + np.array(unten, float) * t, (h, w, 3)).copy()
|
||||||
|
|
||||||
|
|
||||||
|
def rgba(arr, maske):
|
||||||
|
a = np.dstack([np.clip(arr, 0, 255), maske]).astype(np.uint8)
|
||||||
|
return Image.fromarray(a, 'RGBA')
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Schriftzug ----------
|
||||||
|
|
||||||
|
def schriftzug(b, flach=False):
|
||||||
|
"""'AERO MC' aus Blöcken der Größe b (Pixel): Vorderseite, Tiefe nach unten, Kontur."""
|
||||||
|
punkte, breite, hoehe = raster('AERO MC', GLYPHEN, 1, 2)
|
||||||
|
tiefe = 0 if flach else max(2, int(b * 0.55))
|
||||||
|
k = 0 if flach else max(2, b // 5)
|
||||||
|
W, H = breite * b + 2 * k, hoehe * b + tiefe + 2 * k
|
||||||
|
front = np.zeros((H, W), bool)
|
||||||
|
blau = np.zeros((H, W), bool)
|
||||||
|
kante = np.zeros((H, W), float)
|
||||||
|
belegt = {(x, y) for x, y, _ in punkte}
|
||||||
|
rnd = np.random.RandomState(7)
|
||||||
|
rauschen = np.zeros((H, W))
|
||||||
|
zell = max(1, b // 4)
|
||||||
|
for bx, by, i in punkte:
|
||||||
|
x0, y0 = k + bx * b, k + by * b
|
||||||
|
front[y0:y0 + b, x0:x0 + b] = True
|
||||||
|
if i >= 5:
|
||||||
|
blau[y0:y0 + b, x0:x0 + b] = True
|
||||||
|
# Stein-Struktur in kleinen Zellen
|
||||||
|
for yy in range(0, b, zell):
|
||||||
|
for xx in range(0, b, zell):
|
||||||
|
rauschen[y0 + yy:y0 + yy + zell, x0 + xx:x0 + xx + zell] = rnd.randint(-9, 6)
|
||||||
|
if (bx, by - 1) not in belegt:
|
||||||
|
kante[y0:y0 + max(1, b // 6), x0:x0 + b] = 34 # helle Oberkante
|
||||||
|
if (bx + 1, by) not in belegt:
|
||||||
|
kante[y0:y0 + b, x0 + b - max(1, b // 7):x0 + b] -= 26 # dunklere rechte Kante
|
||||||
|
hell = verlauf(H, W, (255, 255, 255), (176, 190, 214))
|
||||||
|
mc = verlauf(H, W, (170, 236, 255), (34, 108, 232))
|
||||||
|
farbe = np.where(blau[..., None], mc, hell) + (rauschen + kante)[..., None]
|
||||||
|
if flach:
|
||||||
|
return rgba(farbe, front * 255)
|
||||||
|
seite = np.zeros((H, W), bool)
|
||||||
|
for t in range(1, tiefe + 1):
|
||||||
|
seite[t:, :] |= front[:-t, :]
|
||||||
|
seite &= ~front
|
||||||
|
blau_seite = np.zeros((H, W), bool)
|
||||||
|
for t in range(1, tiefe + 1):
|
||||||
|
blau_seite[t:, :] |= blau[:-t, :]
|
||||||
|
seite_farbe = np.where(blau_seite[..., None], verlauf(H, W, (30, 84, 190), (14, 44, 120)),
|
||||||
|
verlauf(H, W, (112, 126, 156), (66, 78, 104)))
|
||||||
|
alles = Image.fromarray(((front | seite) * 255).astype(np.uint8))
|
||||||
|
kontur = np.array(alles.filter(ImageFilter.MaxFilter(2 * k + 1))) > 0
|
||||||
|
bild = np.zeros((H, W, 3))
|
||||||
|
bild[kontur] = KONTUR
|
||||||
|
bild[seite] = seite_farbe[seite]
|
||||||
|
bild[front] = farbe[front]
|
||||||
|
return rgba(bild, kontur * 255)
|
||||||
|
|
||||||
|
|
||||||
|
def pyramide(w, h):
|
||||||
|
"""Blaue Pyramide (linke Seite hell, rechte dunkel, leuchtender Grat)."""
|
||||||
|
w, h = int(w), int(h)
|
||||||
|
links = Image.new('L', (w, h), 0)
|
||||||
|
rechts = Image.new('L', (w, h), 0)
|
||||||
|
spitze, mitte = (w * 0.5, 0), (w * 0.56, h)
|
||||||
|
ImageDraw.Draw(links).polygon([spitze, (0, h), mitte], fill=255)
|
||||||
|
ImageDraw.Draw(rechts).polygon([spitze, mitte, (w, h)], fill=255)
|
||||||
|
hell = verlauf(h, w, (150, 232, 255), (40, 126, 236))
|
||||||
|
dunkel = verlauf(h, w, (52, 130, 236), (14, 48, 140))
|
||||||
|
l, r = np.array(links) > 0, np.array(rechts) > 0
|
||||||
|
bild = np.zeros((h, w, 3))
|
||||||
|
bild[l] = hell[l]
|
||||||
|
bild[r] = dunkel[r]
|
||||||
|
img = rgba(bild, np.maximum(np.array(links), np.array(rechts)))
|
||||||
|
d = ImageDraw.Draw(img)
|
||||||
|
d.line([spitze, mitte], fill=(215, 248, 255, 255), width=max(2, w // 70))
|
||||||
|
d.line([spitze, (0, h)], fill=(120, 215, 255, 255), width=max(1, w // 140))
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def leuchten(bild, radius, farbe=(70, 190, 255), staerke=0.75):
|
||||||
|
a = bild.split()[3].filter(ImageFilter.MaxFilter(3)).filter(ImageFilter.GaussianBlur(radius))
|
||||||
|
a = a.point(lambda v: int(v * staerke))
|
||||||
|
g = Image.new('RGBA', bild.size, farbe + (0,))
|
||||||
|
g.putalpha(a)
|
||||||
|
return g
|
||||||
|
|
||||||
|
|
||||||
|
def logo(b, mit_leuchten=True):
|
||||||
|
"""Pyramide hinter dem Schriftzug, wie im Entwurf. Rückgabe: Bild, Unterkante des Schriftzugs."""
|
||||||
|
text = schriftzug(b)
|
||||||
|
pw, ph = text.width * 0.46, text.height * 1.55
|
||||||
|
pyr = pyramide(pw, ph)
|
||||||
|
oben = int(ph - text.height * 0.62) # so weit ragt die Pyramide über den Text
|
||||||
|
rand = int(b * 1.2) if mit_leuchten else 0
|
||||||
|
W, H = text.width + 2 * rand, oben + text.height + 2 * rand
|
||||||
|
bild = Image.new('RGBA', (W, H), (0, 0, 0, 0))
|
||||||
|
px = rand + (text.width - pyr.width) // 2 + int(text.width * 0.03)
|
||||||
|
schicht = Image.new('RGBA', (W, H), (0, 0, 0, 0))
|
||||||
|
schicht.alpha_composite(pyr, (px, rand))
|
||||||
|
schicht.alpha_composite(text, (rand, rand + oben))
|
||||||
|
if mit_leuchten:
|
||||||
|
bild.alpha_composite(leuchten(schicht, b * 0.9))
|
||||||
|
bild.alpha_composite(schicht)
|
||||||
|
return bild, rand + oben + text.height
|
||||||
|
|
||||||
|
|
||||||
|
def einpassen(bild, w, h):
|
||||||
|
f = min(w / bild.width, h / bild.height, 1.0)
|
||||||
|
return bild.resize((max(1, int(bild.width * f)), max(1, int(bild.height * f))), Image.LANCZOS) if f < 1 else bild
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Titel-Texturen ----------
|
||||||
|
|
||||||
|
def titel_art():
|
||||||
|
"""Pyramide, Schriftzug und Untertitel zusammen (Titel im Hauptmenü)."""
|
||||||
|
art, unterkante = logo(15)
|
||||||
|
unter, _ = untertitel_bild(5)
|
||||||
|
bild = Image.new('RGBA', (max(art.width, unter.width), unterkante + 8 + unter.height), (0, 0, 0, 0))
|
||||||
|
bild.alpha_composite(art, ((bild.width - art.width) // 2, 0))
|
||||||
|
bild.alpha_composite(unter, ((bild.width - unter.width) // 2, unterkante + 8))
|
||||||
|
return bild
|
||||||
|
|
||||||
|
|
||||||
|
def titel(alt):
|
||||||
|
"""
|
||||||
|
Der Agent erweitert den gezeichneten Streifen auf 64 Punkte Höhe (sonst 44).
|
||||||
|
neu (ab 1.20): 256×64-Textur, ganz genutzt.
|
||||||
|
alt (bis 1.19.4): Stücke (0,0,155,64) und (0,65,155,64) im 256×256-Atlas,
|
||||||
|
sichtbare Mitte bei x=137 des 310 breiten Streifens.
|
||||||
|
"""
|
||||||
|
art = einpassen(titel_art(), (274 if alt else 256) * 4 - 8, 64 * 4)
|
||||||
|
if not alt:
|
||||||
|
t = Image.new('RGBA', (1024, 256), (0, 0, 0, 0))
|
||||||
|
t.alpha_composite(art, ((1024 - art.width) // 2, 256 - art.height))
|
||||||
|
return t
|
||||||
|
streifen = Image.new('RGBA', (310 * 4, 64 * 4), (0, 0, 0, 0))
|
||||||
|
streifen.alpha_composite(art, (137 * 4 - art.width // 2, 256 - art.height))
|
||||||
|
t = Image.new('RGBA', (1024, 1024), (0, 0, 0, 0))
|
||||||
|
t.alpha_composite(streifen.crop((0, 0, 620, 256)), (0, 0))
|
||||||
|
t.alpha_composite(streifen.crop((620, 0, 1240, 256)), (0, 65 * 4))
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
def untertitel_bild(skala, farbe=(236, 244, 255), schatten=(8, 16, 40)):
|
||||||
|
punkte, breite, hoehe = raster('DEIN MINECRAFT ERLEBNIS', KLEIN, 1, 3)
|
||||||
|
img = Image.new('RGBA', ((breite + 1) * skala, (hoehe + 1) * skala), (0, 0, 0, 0))
|
||||||
|
d = ImageDraw.Draw(img)
|
||||||
|
for dx, dy, f in ((1, 1, schatten), (0, 0, farbe)):
|
||||||
|
for x, y, _ in punkte:
|
||||||
|
d.rectangle(((x + dx) * skala, (y + dy) * skala, (x + dx + 1) * skala - 1, (y + dy + 1) * skala - 1), fill=f + (255,))
|
||||||
|
return img, breite
|
||||||
|
|
||||||
|
|
||||||
|
def edition(breite_max):
|
||||||
|
img, breite = untertitel_bild(4)
|
||||||
|
if breite + 1 > breite_max:
|
||||||
|
raise SystemExit(f'Untertitel zu breit: {breite} > {breite_max}')
|
||||||
|
t = Image.new('RGBA', (512, 64), (0, 0, 0, 0))
|
||||||
|
t.alpha_composite(img, ((breite_max * 4 - img.width) // 2, (56 - img.height) // 2))
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- stilisierte Block-Landschaft (Abendstimmung am Bergsee) ----------
|
||||||
|
# Eine Szene, zwei Ansichten: als Rundum-Panorama (Menü) und als flacher
|
||||||
|
# Ausschnitt nach vorne (Ladebildschirm). Gleicher Zufallswert -> gleiche Berge.
|
||||||
|
|
||||||
|
def _mix(a, b, t):
|
||||||
|
return np.array(a, np.float32) * (1 - t) + np.array(b, np.float32) * t
|
||||||
|
|
||||||
|
|
||||||
|
def _profil(phi, seed, schritt_rad):
|
||||||
|
"""periodisches Höhenprofil 0..1, blockig gestuft"""
|
||||||
|
r = random.Random(seed)
|
||||||
|
stufe = np.floor(phi / schritt_rad) * schritt_rad + schritt_rad / 2
|
||||||
|
werte = np.zeros_like(phi)
|
||||||
|
for k in range(1, 16):
|
||||||
|
werte += r.uniform(0.25, 1.0) / k ** 1.05 * np.sin(k * stufe + r.uniform(0, 6.3))
|
||||||
|
return (werte + 2.2) / 4.4
|
||||||
|
|
||||||
|
|
||||||
|
def szene(W, H, horizont, start, spanne, seed=2026):
|
||||||
|
rnd = random.Random(seed)
|
||||||
|
ppd = W / math.degrees(spanne) # Pixel je Grad
|
||||||
|
blk = max(4, int(round(ppd * 0.9))) # Blockgröße der Silhouetten
|
||||||
|
schritt = math.radians(blk / ppd) # ein Block als Winkel
|
||||||
|
phi = (start + np.arange(W, dtype=np.float32) / W * spanne).astype(np.float32)
|
||||||
|
y = np.arange(H, dtype=np.float32)[:, None]
|
||||||
|
grad = (horizont - y) / ppd # Höhe über dem Horizont in Grad
|
||||||
|
|
||||||
|
sonne = math.pi + 0.66
|
||||||
|
dphi = np.angle(np.exp(1j * (phi - sonne))).astype(np.float32)[None, :]
|
||||||
|
|
||||||
|
# Himmel: Blau oben, hell am Horizont, warmer Abendschein um die Sonne
|
||||||
|
t = np.clip(grad / 75, 0, 1)[..., None]
|
||||||
|
himmel = np.where(t < 0.3, _mix((168, 202, 238), (66, 124, 210), t / 0.3), _mix((66, 124, 210), (16, 36, 96), (t - 0.3) / 0.7))
|
||||||
|
warm = (np.exp(-dphi ** 2 / 0.55) * np.exp(-(np.clip(grad, 0, None) / 14) ** 2))[..., None]
|
||||||
|
himmel = himmel * (1 - 0.8 * warm) + np.array([255, 178, 120], np.float32) * 0.8 * warm
|
||||||
|
bild = np.broadcast_to(himmel, (H, W, 3)).astype(np.float32).copy()
|
||||||
|
|
||||||
|
def maske_hoehe(h_grad):
|
||||||
|
return (grad >= 0) & (grad <= h_grad[None, :])
|
||||||
|
|
||||||
|
def stufen(h_grad):
|
||||||
|
return np.floor(h_grad * ppd / blk) * blk / ppd
|
||||||
|
|
||||||
|
# Sonne (eckig, wie im Spiel) mit Schein
|
||||||
|
s_x = (sonne - start) / spanne * W
|
||||||
|
s_y = horizont - 12 * ppd
|
||||||
|
s_r = 2.6 * ppd
|
||||||
|
rr = np.sqrt(((np.arange(W) - s_x) / ppd)[None, :] ** 2 + ((y - s_y) / ppd) ** 2)
|
||||||
|
bild += (np.exp(-(rr / 6) ** 2) * 0.55)[..., None] * np.array([255, 210, 150], np.float32)
|
||||||
|
quadrat = (np.abs(np.arange(W) - s_x)[None, :] < s_r) & (np.abs(y - s_y) < s_r)
|
||||||
|
bild[quadrat] = (255, 244, 205)
|
||||||
|
|
||||||
|
# Wolken (flach und blockig)
|
||||||
|
wolken = Image.new('RGBA', (W, H), (0, 0, 0, 0))
|
||||||
|
d = ImageDraw.Draw(wolken)
|
||||||
|
for _ in range(18):
|
||||||
|
p = rnd.uniform(0, 2 * math.pi)
|
||||||
|
for versatz in (-2 * math.pi, 0, 2 * math.pi):
|
||||||
|
cx = (p + versatz - start) / spanne * W
|
||||||
|
if -W * 0.2 < cx < W * 1.2:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
cy = horizont - rnd.uniform(20, 42) * ppd
|
||||||
|
s = blk
|
||||||
|
nah_sonne = abs(math.atan2(math.sin(p - sonne), math.cos(p - sonne))) < 0.7
|
||||||
|
farbe = (255, 222, 196) if nah_sonne else (232, 240, 252)
|
||||||
|
for _ in range(rnd.randint(2, 4)):
|
||||||
|
w, h = rnd.randint(6, 16) * s, rnd.randint(1, 2) * s
|
||||||
|
ox, oy = rnd.randint(-4, 4) * s, rnd.randint(-1, 0) * s
|
||||||
|
d.rectangle((cx + ox, cy + oy, cx + ox + w, cy + oy + h), fill=farbe + (170,))
|
||||||
|
d.rectangle((cx + ox, cy + oy + h - max(2, s // 3), cx + ox + w, cy + oy + h), fill=tuple(int(c * 0.82) for c in farbe) + (170,))
|
||||||
|
|
||||||
|
# ferne Berge mit Schnee
|
||||||
|
h_fern = stufen(6 + 22 * _profil(phi, seed + 1, schritt))
|
||||||
|
m = maske_hoehe(h_fern)
|
||||||
|
tb = np.clip(grad / 28, 0, 1)[..., None]
|
||||||
|
fels = _mix((46, 66, 112), (96, 118, 168), tb)
|
||||||
|
schnee = (grad > h_fern[None, :] - 3 * blk / ppd) & (h_fern[None, :] > 15)
|
||||||
|
fels = np.where(schnee[..., None], np.array([236, 242, 255], np.float32), fels)
|
||||||
|
sonnenseite = (np.exp(-dphi ** 2 / 0.6))[..., None] * 0.35
|
||||||
|
fels = fels * (1 - sonnenseite) + np.array([255, 190, 150], np.float32) * sonnenseite
|
||||||
|
bild[m] = np.broadcast_to(fels, bild.shape)[m]
|
||||||
|
# bewaldete Hügel
|
||||||
|
h_huegel = stufen(2 + 7 * _profil(phi, seed + 2, schritt))
|
||||||
|
m = maske_hoehe(h_huegel)
|
||||||
|
huegel = _mix((26, 70, 58), (44, 98, 74), np.clip(grad / 7, 0, 1)[..., None])
|
||||||
|
bild[m] = np.broadcast_to(huegel, bild.shape)[m]
|
||||||
|
|
||||||
|
img = Image.fromarray(np.clip(bild, 0, 255).astype(np.uint8), 'RGB').convert('RGBA')
|
||||||
|
img.alpha_composite(wolken)
|
||||||
|
# Fichten am Ufer
|
||||||
|
wald = Image.new('RGBA', (W, H), (0, 0, 0, 0))
|
||||||
|
d = ImageDraw.Draw(wald)
|
||||||
|
s = max(3, blk // 2)
|
||||||
|
for _ in range(int(W / s * 0.55)):
|
||||||
|
x = rnd.randrange(W)
|
||||||
|
hoehe = rnd.randint(6, 13)
|
||||||
|
dunkel = rnd.choice(((14, 44, 34), (18, 52, 40), (12, 38, 32)))
|
||||||
|
d.rectangle((x - s // 2, horizont - 2 * s, x + s // 2, horizont), fill=(40, 28, 20, 255))
|
||||||
|
for stufe in range(hoehe):
|
||||||
|
breite = max(0, (hoehe - stufe) // 2 - (stufe % 2))
|
||||||
|
y0 = horizont - (stufe + 2) * s
|
||||||
|
d.rectangle((x - breite * s - s // 2, y0, x + breite * s + s // 2, y0 + s), fill=dunkel + (255,))
|
||||||
|
d.rectangle((x + breite * s - s // 2, y0, x + breite * s + s // 2, y0 + s), fill=tuple(c + 14 for c in dunkel) + (255,))
|
||||||
|
img.alpha_composite(wald)
|
||||||
|
bild = np.array(img.convert('RGB'), np.float32)
|
||||||
|
|
||||||
|
# See: Spiegelung, nach unten dunkler, Wellen und Sonnenglitzern
|
||||||
|
unten = H - horizont
|
||||||
|
zeilen = np.clip(horizont - 1 - np.arange(unten), 0, H - 1)
|
||||||
|
spiegel = bild[zeilen]
|
||||||
|
tiefe = np.linspace(0, 1, unten, dtype=np.float32)[:, None, None]
|
||||||
|
wellen = ((np.sin(np.arange(unten, dtype=np.float32)[:, None] * (0.55 / max(1, ppd / 10)) + np.sin(phi[None, :] * 37) * 1.6) > 0.86) * 14)[..., None]
|
||||||
|
see = spiegel * (0.62 - 0.32 * tiefe) + np.array([10, 30, 62], np.float32) * (0.45 + 0.55 * tiefe) + wellen * np.array([0.5, 0.8, 1.0], np.float32)
|
||||||
|
glitzer = (np.exp(-(dphi[0] / 0.07) ** 2)[None, :] * (np.sin(np.arange(unten)[:, None] * 1.7 + phi[None, :] * 900) > 0.6) * np.exp(-tiefe[..., 0] * 3))[..., None]
|
||||||
|
see += glitzer * np.array([255, 200, 140], np.float32) * 0.8
|
||||||
|
bild[horizont:] = see
|
||||||
|
|
||||||
|
# leuchtende Pixel (AeroMC)
|
||||||
|
img = Image.fromarray(np.clip(bild, 0, 255).astype(np.uint8), 'RGB').convert('RGBA')
|
||||||
|
pixel = Image.new('RGBA', (W, H), (0, 0, 0, 0))
|
||||||
|
d = ImageDraw.Draw(pixel)
|
||||||
|
for _ in range(int(W / 60)):
|
||||||
|
x = rnd.randrange(W)
|
||||||
|
yy = horizont - rnd.uniform(-8, 30) * ppd
|
||||||
|
g = rnd.choice((0.6, 0.8, 1.0, 1.3)) * ppd
|
||||||
|
d.rectangle((x, yy, x + g, yy + g), fill=(90, 210, 255, rnd.randint(150, 235)))
|
||||||
|
img.alpha_composite(leuchten(pixel, max(4, ppd * 0.8), (60, 170, 255), 1.0))
|
||||||
|
img.alpha_composite(pixel)
|
||||||
|
return img.convert('RGB')
|
||||||
|
|
||||||
|
|
||||||
|
def landschaft(W=4096, H=2048):
|
||||||
|
"""Rundum-Panorama (equirektangular): Spalte W/2 = Blick nach vorne."""
|
||||||
|
return szene(W, H, H // 2, 0.0, 2 * math.pi)
|
||||||
|
|
||||||
|
|
||||||
|
def landschaft_flach(W=1920, H=1080):
|
||||||
|
"""Ausschnitt nach vorne (ca. 110°) für den Ladebildschirm."""
|
||||||
|
spanne = math.radians(110)
|
||||||
|
return szene(W, H, int(H * 0.6), math.pi - spanne / 2, spanne)
|
||||||
|
|
||||||
|
|
||||||
|
def wuerfelseiten(equi, groesse=1024):
|
||||||
|
"""Equirektangulares Panorama -> 6 Würfelseiten (0 vorne, 1 rechts, 2 hinten, 3 links, 4 oben, 5 unten)."""
|
||||||
|
q = np.array(equi.convert('RGB'), float)
|
||||||
|
H, W = q.shape[:2]
|
||||||
|
a = (np.arange(groesse) + 0.5) / groesse * 2 - 1
|
||||||
|
u, v = np.meshgrid(a, a)
|
||||||
|
eins = np.ones_like(u)
|
||||||
|
richtungen = [(u, -v, eins), (eins, -v, -u), (-u, -v, -eins), (-eins, -v, u), (u, eins, v), (u, -eins, -v)]
|
||||||
|
seiten = []
|
||||||
|
for dx, dy, dz in richtungen:
|
||||||
|
lon = np.arctan2(dx, dz)
|
||||||
|
lat = np.arctan2(dy, np.sqrt(dx * dx + dz * dz))
|
||||||
|
x = (lon / (2 * math.pi) + 0.5) * W - 0.5
|
||||||
|
y = (0.5 - lat / math.pi) * H - 0.5
|
||||||
|
x0 = np.floor(x).astype(int); y0 = np.clip(np.floor(y).astype(int), 0, H - 2)
|
||||||
|
fx, fy = (x - x0)[..., None], np.clip(y - y0, 0, 1)[..., None]
|
||||||
|
x1 = (x0 + 1) % W; x0 %= W
|
||||||
|
p = (q[y0, x0] * (1 - fx) * (1 - fy) + q[y0, x1] * fx * (1 - fy) + q[y0 + 1, x0] * (1 - fx) * fy + q[y0 + 1, x1] * fx * fy)
|
||||||
|
seiten.append(Image.fromarray(np.clip(p, 0, 255).astype(np.uint8), 'RGB'))
|
||||||
|
return seiten
|
||||||
|
|
||||||
|
|
||||||
|
def seiten_aus_bild(bild, g=1024):
|
||||||
|
"""
|
||||||
|
Aus einem flachen Bild (16:9) einen nahtlosen Würfel:
|
||||||
|
– vorne = linke Bildhälfte, rechts = rechte Hälfte, hinten/links = dieselben
|
||||||
|
gespiegelt, so stoßen alle senkrechten Kanten passend aneinander;
|
||||||
|
– oben/unten: die vier Seiten werden in ein Rundum-Panorama umgerechnet, dort
|
||||||
|
nach unten (Wasser) bzw. oben (Himmel) gespiegelt fortgesetzt und weich
|
||||||
|
auslaufen gelassen, daraus dann die obere und untere Würfelseite gerechnet.
|
||||||
|
So gehen die Kanten nahtlos über – keine einfarbigen Flächen.
|
||||||
|
"""
|
||||||
|
b = bild.convert('RGB').resize((2 * g, g), Image.LANCZOS)
|
||||||
|
vorne, rechts = b.crop((0, 0, g, g)), b.crop((g, 0, 2 * g, g))
|
||||||
|
hinten, links = rechts.transpose(Image.FLIP_LEFT_RIGHT), vorne.transpose(Image.FLIP_LEFT_RIGHT)
|
||||||
|
seiten = [np.array(s, np.float32) for s in (vorne, rechts, hinten, links)]
|
||||||
|
|
||||||
|
# 1) Seiten -> equirektangulares Panorama (oben/unten zunächst leer)
|
||||||
|
W, H = 4 * g, 2 * g
|
||||||
|
lon = ((np.arange(W) + 0.5) / W - 0.5) * 2 * math.pi
|
||||||
|
lat = (0.5 - (np.arange(H) + 0.5) / H) * math.pi
|
||||||
|
LON, LAT = np.meshgrid(lon, lat)
|
||||||
|
dx, dy, dz = np.cos(LAT) * np.sin(LON), np.sin(LAT), np.cos(LAT) * np.cos(LON)
|
||||||
|
equi = np.zeros((H, W, 3), np.float32)
|
||||||
|
gueltig = np.zeros((H, W), bool)
|
||||||
|
seitlich = np.abs(dy) < np.maximum(np.abs(dx), np.abs(dz))
|
||||||
|
# (Maske, s, u, v) passend zu wuerfelseiten(): vorne (u,-v,1), rechts (1,-v,-u), hinten (-u,-v,-1), links (-1,-v,u)
|
||||||
|
faelle = [
|
||||||
|
(seitlich & (dz >= np.abs(dx)), dz, dx, -dy),
|
||||||
|
(seitlich & (dx > np.abs(dz)), dx, -dz, -dy),
|
||||||
|
(seitlich & (-dz >= np.abs(dx)), -dz, -dx, -dy),
|
||||||
|
(seitlich & (-dx > np.abs(dz)), -dx, dz, -dy),
|
||||||
|
]
|
||||||
|
for (maske, s, u, v), seite in zip(faelle, seiten):
|
||||||
|
su, sv = u[maske] / s[maske], v[maske] / s[maske]
|
||||||
|
px = np.clip(((su + 1) / 2 * g).astype(int), 0, g - 1)
|
||||||
|
py = np.clip(((sv + 1) / 2 * g).astype(int), 0, g - 1)
|
||||||
|
equi[maske] = seite[py, px]
|
||||||
|
gueltig |= maske
|
||||||
|
|
||||||
|
# 2) Lücken spiegeln: unten das Wasser (dunkler werdend), oben der Himmel
|
||||||
|
zeilen = np.arange(H)[:, None]
|
||||||
|
unterste = np.where(gueltig.any(0), H - 1 - np.argmax(gueltig[::-1], axis=0), H // 2)
|
||||||
|
oberste = np.where(gueltig.any(0), np.argmax(gueltig, axis=0), H // 2)
|
||||||
|
spalten = np.arange(W)[None, :]
|
||||||
|
unten = zeilen > unterste[None, :]
|
||||||
|
oben = zeilen < oberste[None, :]
|
||||||
|
quelle_unten = np.clip(2 * unterste[None, :] - zeilen, oberste[None, :], H - 1)
|
||||||
|
quelle_oben = np.broadcast_to(np.clip(oberste[None, :] + 2, 0, H - 1), (H, W)) # Himmel: oberste Zeile nach oben verlängern
|
||||||
|
gespiegelt = equi.copy()
|
||||||
|
gespiegelt[unten] = equi[quelle_unten, np.broadcast_to(spalten, (H, W))][unten]
|
||||||
|
gespiegelt[oben] = equi[quelle_oben, np.broadcast_to(spalten, (H, W))][oben]
|
||||||
|
# zum Pol hin weich in eine ruhige Farbe auslaufen lassen (keine Kreuznaht im Würfel)
|
||||||
|
abstand_unten = np.clip((zeilen - unterste[None, :]) / (H * 0.34), 0, 1)[..., None] ** 1.3
|
||||||
|
abstand_oben = np.clip((oberste[None, :] - zeilen) / (H * 0.34), 0, 1)[..., None] ** 1.3
|
||||||
|
# Wasserfarbe aus der Bildmitte (am unteren Rand stehen Pflanzen), Himmel vom oberen Rand
|
||||||
|
roh = np.array(b, np.float32)
|
||||||
|
wasser = np.median(roh[int(g * 0.62):int(g * 0.82), int(g * 0.5):int(g * 1.5)].reshape(-1, 3), axis=0)
|
||||||
|
himmel = equi[np.clip(oberste + 4, 0, H - 1), np.arange(W)].mean(0)
|
||||||
|
gespiegelt = np.where(unten[..., None], gespiegelt * (1 - abstand_unten) * 0.9 + wasser * 0.8 * abstand_unten, gespiegelt)
|
||||||
|
gespiegelt = np.where(oben[..., None], gespiegelt * (1 - abstand_oben) + himmel * abstand_oben, gespiegelt)
|
||||||
|
bild_equi = Image.fromarray(np.clip(gespiegelt, 0, 255).astype(np.uint8), 'RGB')
|
||||||
|
weich = bild_equi.filter(ImageFilter.GaussianBlur(16))
|
||||||
|
maske_luecke = Image.fromarray(((unten | oben) * 255).astype(np.uint8)).filter(ImageFilter.GaussianBlur(6))
|
||||||
|
bild_equi = Image.composite(weich, bild_equi, maske_luecke)
|
||||||
|
|
||||||
|
# 3) daraus oben/unten; die vier Seiten bleiben die scharfen Originale
|
||||||
|
neu = wuerfelseiten(bild_equi, g)
|
||||||
|
return [vorne, rechts, hinten, links, neu[4], neu[5]]
|
||||||
|
|
||||||
|
|
||||||
|
def quelle(name):
|
||||||
|
for endung in ('png', 'jpg', 'jpeg', 'webp'):
|
||||||
|
p = os.path.join(QUELLE, f'{name}.{endung}')
|
||||||
|
if os.path.exists(p):
|
||||||
|
return Image.open(p).convert('RGB')
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def fuellen(bild, w, h):
|
||||||
|
f = max(w / bild.width, h / bild.height)
|
||||||
|
b = bild.resize((int(bild.width * f + 0.5), int(bild.height * f + 0.5)), Image.LANCZOS)
|
||||||
|
x, y = (b.width - w) // 2, (b.height - h) // 2
|
||||||
|
return b.crop((x, y, x + w, y + h))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Ladebildschirm (Vollbild) ----------
|
||||||
|
|
||||||
|
def ladebild(mit_text=True):
|
||||||
|
W, H = 1920, 1080
|
||||||
|
eigenes = quelle('hintergrund')
|
||||||
|
if eigenes is not None:
|
||||||
|
grund = fuellen(eigenes, W, H)
|
||||||
|
else: # gleiche Landschaft wie im Menü, als Blick nach vorne
|
||||||
|
grund = landschaft_flach(W, H)
|
||||||
|
bild = grund.convert('RGBA')
|
||||||
|
# leichte Abdunklung zur Mitte und unten, damit Logo und Balken lesbar sind
|
||||||
|
schatten = Image.new('L', (W, H), 0)
|
||||||
|
ImageDraw.Draw(schatten).ellipse((W * 0.18, H * 0.12, W * 0.82, H * 0.72), fill=95)
|
||||||
|
schatten = schatten.filter(ImageFilter.GaussianBlur(120))
|
||||||
|
unten = Image.fromarray((np.clip((np.arange(H) - H * 0.62) / (H * 0.38), 0, 1) * 120).astype(np.uint8)[:, None].repeat(W, 1))
|
||||||
|
dunkel = Image.new('RGBA', (W, H), (4, 8, 20, 0))
|
||||||
|
dunkel.putalpha(ImageChops.lighter(schatten, unten))
|
||||||
|
bild.alpha_composite(dunkel)
|
||||||
|
art, _ = logo(30)
|
||||||
|
art = einpassen(art, int(W * 0.62), int(H * 0.56))
|
||||||
|
lx, ly = (W - art.width) // 2, int(H * 0.40 - art.height / 2)
|
||||||
|
bild.alpha_composite(art, (lx, ly))
|
||||||
|
unter, _ = untertitel_bild(6)
|
||||||
|
bild.alpha_composite(unter, ((W - unter.width) // 2, ly + art.height + 6))
|
||||||
|
if not mit_text: # Neustart-Bildschirm des Launchers setzt Text und Balken selbst darüber
|
||||||
|
return bild.convert('RGB')
|
||||||
|
# "Wird geladen …" unter dem Balken (Minecraft zeichnet den Balken bei 83,25 % der Höhe)
|
||||||
|
schrift = ImageFont.truetype(SCHRIFT, 34)
|
||||||
|
text = 'Wird geladen …'
|
||||||
|
tb = ImageDraw.Draw(bild).textbbox((0, 0), text, font=schrift)
|
||||||
|
tw, th = tb[2] - tb[0], tb[3] - tb[1]
|
||||||
|
kasten = Image.new('RGBA', (W, H), (0, 0, 0, 0))
|
||||||
|
kx, ky = (W - tw) // 2, int(H * 0.885)
|
||||||
|
ImageDraw.Draw(kasten).rounded_rectangle((kx - 26, ky - 14, kx + tw + 26, ky + th + 22), radius=10, fill=(6, 12, 28, 175))
|
||||||
|
bild.alpha_composite(kasten)
|
||||||
|
ImageDraw.Draw(bild).text((kx - tb[0], ky - tb[1] + 2), text, font=schrift, fill=(236, 244, 255, 255))
|
||||||
|
return bild.convert('RGB')
|
||||||
|
|
||||||
|
|
||||||
|
def als_logotextur(bild):
|
||||||
|
"""
|
||||||
|
Minecraft zeichnet das Logo additiv auf den Hintergrund und teilt es: linke
|
||||||
|
Hälfte aus der oberen Texturhälfte, rechte aus der unteren. Darum: Nachtblau
|
||||||
|
abziehen (wird beim Zeichnen wieder addiert) und die Hälften gestreckt stapeln.
|
||||||
|
"""
|
||||||
|
a = np.clip(np.array(bild, float) - np.array(NACHTBLAU, float), 0, 255).astype(np.uint8)
|
||||||
|
b = Image.fromarray(a, 'RGB')
|
||||||
|
w, h = b.size
|
||||||
|
t = Image.new('RGB', (2048, 2048))
|
||||||
|
t.paste(b.crop((0, 0, w // 2, h)).resize((2048, 1024), Image.LANCZOS), (0, 0))
|
||||||
|
t.paste(b.crop((w // 2, 0, w, h)).resize((2048, 1024), Image.LANCZOS), (0, 1024))
|
||||||
|
return t.convert('RGBA')
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Knöpfe und Symbole (wie im Entwurf) ----------
|
||||||
|
|
||||||
|
SYMBOL_ZEICHEN = ['', '', '', '', '', ''] # Spitzhacke, Personen, Globus, Zahnrad, Ein/Aus, Puzzle (Mods)
|
||||||
|
|
||||||
|
|
||||||
|
def knopf(hell, f):
|
||||||
|
"""Knopf 200×20 Punkte in f-facher Auflösung: dunkles Schiefer bzw. leuchtendes Blau."""
|
||||||
|
w, h = 200 * f, 20 * f
|
||||||
|
if hell:
|
||||||
|
oben, unten, rand, licht = (64, 166, 248), (20, 92, 200), (130, 232, 255), (170, 225, 255)
|
||||||
|
else:
|
||||||
|
oben, unten, rand, licht = (46, 56, 74), (24, 30, 42), (124, 142, 172), (78, 92, 118)
|
||||||
|
a = verlauf(h, w, oben, unten)
|
||||||
|
bild = rgba(a, np.full((h, w), 255, np.uint8))
|
||||||
|
d = ImageDraw.Draw(bild)
|
||||||
|
s = max(1, f // 2)
|
||||||
|
d.rectangle((0, 0, w - 1, h - 1), outline=(6, 10, 20, 255), width=s) # dunkle Außenkante
|
||||||
|
d.rectangle((s, s, w - 1 - s, h - 1 - s), outline=rand + (255,), width=s) # heller Rand
|
||||||
|
d.line((2 * s, 2 * s, w - 1 - 2 * s, 2 * s), fill=licht + (255,), width=s) # Lichtkante oben
|
||||||
|
for x0, y0 in ((0, 0), (w - s, 0), (0, h - s), (w - s, h - s)): # Ecken leicht abgerundet
|
||||||
|
d.rectangle((x0, y0, x0 + s - 1, y0 + s - 1), fill=(0, 0, 0, 0))
|
||||||
|
return bild
|
||||||
|
|
||||||
|
|
||||||
|
def puzzle(farbe=(238, 246, 255, 255), groesse=64):
|
||||||
|
"""Puzzleteil (Symbol für Mods)."""
|
||||||
|
b = Image.new('RGBA', (64, 64), (0, 0, 0, 0))
|
||||||
|
d = ImageDraw.Draw(b)
|
||||||
|
d.rounded_rectangle((8, 18, 46, 56), radius=5, fill=farbe)
|
||||||
|
d.ellipse((19, 4, 35, 22), fill=farbe) # Nase oben
|
||||||
|
d.ellipse((42, 29, 60, 45), fill=farbe) # Nase rechts
|
||||||
|
d.ellipse((4, 29, 18, 43), fill=(0, 0, 0, 0)) # Kerbe links
|
||||||
|
return b if groesse == 64 else b.resize((groesse, groesse), Image.LANCZOS)
|
||||||
|
|
||||||
|
|
||||||
|
def symbole():
|
||||||
|
"""6 Symbole, je 16×16 (aus 64×64 verkleinert), weiß – als Schriftzeichen."""
|
||||||
|
groesse, f = 64, 4
|
||||||
|
leinwand = Image.new('RGBA', (16 * 6, 16), (0, 0, 0, 0))
|
||||||
|
farbe = (238, 246, 255, 255)
|
||||||
|
|
||||||
|
def neu():
|
||||||
|
return Image.new('RGBA', (groesse, groesse), (0, 0, 0, 0))
|
||||||
|
|
||||||
|
bilder = []
|
||||||
|
b = neu(); d = ImageDraw.Draw(b) # Spitzhacke: aufrecht zeichnen, dann kippen
|
||||||
|
d.line((32, 16, 32, 62), fill=farbe, width=10)
|
||||||
|
d.arc((2, 12, 62, 56), 198, 342, fill=farbe, width=12)
|
||||||
|
d.polygon([(4, 30), (0, 40), (10, 34)], fill=farbe); d.polygon([(60, 30), (64, 40), (54, 34)], fill=farbe)
|
||||||
|
bilder.append(b.rotate(-40, resample=Image.BICUBIC))
|
||||||
|
b = neu(); d = ImageDraw.Draw(b) # zwei Personen
|
||||||
|
d.ellipse((30, 8, 50, 28), fill=farbe); d.pieslice((22, 30, 58, 70), 180, 360, fill=farbe)
|
||||||
|
d.ellipse((10, 14, 28, 32), fill=farbe); d.pieslice((2, 34, 36, 68), 180, 360, fill=farbe)
|
||||||
|
bilder.append(b)
|
||||||
|
b = neu(); d = ImageDraw.Draw(b) # Globus
|
||||||
|
d.ellipse((4, 4, 60, 60), outline=farbe, width=8)
|
||||||
|
d.ellipse((20, 4, 44, 60), outline=farbe, width=7)
|
||||||
|
d.line((4, 32, 60, 32), fill=farbe, width=7)
|
||||||
|
d.arc((10, -10, 54, 26), 30, 150, fill=farbe, width=3); d.arc((10, 38, 54, 74), 210, 330, fill=farbe, width=3)
|
||||||
|
bilder.append(b)
|
||||||
|
b = neu(); d = ImageDraw.Draw(b) # Zahnrad
|
||||||
|
for k in range(8):
|
||||||
|
w = k * math.pi / 4
|
||||||
|
cx, cy = 32 + math.cos(w) * 23, 32 + math.sin(w) * 23
|
||||||
|
pts = [(cx + math.cos(w + p) * 7, cy + math.sin(w + p) * 7) for p in (0.8, 2.35, 3.9, 5.5)]
|
||||||
|
d.polygon(pts, fill=farbe)
|
||||||
|
d.ellipse((12, 12, 52, 52), fill=farbe)
|
||||||
|
d.ellipse((26, 26, 38, 38), fill=(0, 0, 0, 0))
|
||||||
|
bilder.append(b)
|
||||||
|
b = neu(); d = ImageDraw.Draw(b) # Ein/Aus
|
||||||
|
d.arc((6, 8, 58, 60), -60, 240, fill=farbe, width=10)
|
||||||
|
d.line((32, 2, 32, 30), fill=farbe, width=10)
|
||||||
|
bilder.append(b)
|
||||||
|
bilder.append(puzzle(farbe))
|
||||||
|
for i, b in enumerate(bilder):
|
||||||
|
leinwand.alpha_composite(b.resize((16, 16), Image.LANCZOS), (16 * i, 0))
|
||||||
|
return leinwand
|
||||||
|
|
||||||
|
|
||||||
|
def sprachdateien():
|
||||||
|
"""Symbole vor die Beschriftungen der Menüknöpfe setzen (de_de und en_us)."""
|
||||||
|
import json
|
||||||
|
sp, mp, rl, op, qu, md = SYMBOL_ZEICHEN
|
||||||
|
texte = {
|
||||||
|
'de_de': {'menu.singleplayer': 'Einzelspieler', 'menu.multiplayer': 'Mehrspieler', 'menu.online': 'Minecraft Realms',
|
||||||
|
'menu.options': 'Optionen …', 'menu.quit': 'Spiel beenden'},
|
||||||
|
'en_us': {'menu.singleplayer': 'Singleplayer', 'menu.multiplayer': 'Multiplayer', 'menu.online': 'Minecraft Realms',
|
||||||
|
'menu.options': 'Options...', 'menu.quit': 'Quit Game'},
|
||||||
|
}
|
||||||
|
zeichen = {'menu.singleplayer': sp, 'menu.multiplayer': mp, 'menu.online': rl, 'menu.options': op, 'menu.quit': qu}
|
||||||
|
aus = {}
|
||||||
|
for sprache, t in texte.items():
|
||||||
|
aus[sprache] = json.dumps({k: zeichen[k] + ' ' + v for k, v in t.items()}, ensure_ascii=True, indent=2)
|
||||||
|
mods = {
|
||||||
|
'de_de': {'menu.sendFeedback': md + ' Mods verwalten …', 'menu.feedback': md + ' Mods & Feedback …'},
|
||||||
|
'en_us': {'menu.sendFeedback': md + ' Manage Mods...', 'menu.feedback': md + ' Mods & Feedback...'},
|
||||||
|
}
|
||||||
|
for sprache, t in mods.items():
|
||||||
|
aus['mods_' + sprache] = json.dumps(t, ensure_ascii=True, indent=2)
|
||||||
|
schrift = json.dumps({'providers': [{'type': 'bitmap', 'file': 'aeromc:font/symbole.png', 'ascent': 7, 'height': 8,
|
||||||
|
'chars': [''.join(SYMBOL_ZEICHEN)]}]}, ensure_ascii=True, indent=2)
|
||||||
|
return aus, schrift
|
||||||
|
|
||||||
|
|
||||||
|
def knopf_dateien():
|
||||||
|
import json
|
||||||
|
knopf(False, 2).save(os.path.join(ZIEL, 'knopf.png'))
|
||||||
|
knopf(True, 2).save(os.path.join(ZIEL, 'knopf_hell.png'))
|
||||||
|
knopf(False, 4).save(os.path.join(ZIEL, 'knopf_4x.png'))
|
||||||
|
knopf(True, 4).save(os.path.join(ZIEL, 'knopf_hell_4x.png'))
|
||||||
|
meta = json.dumps({'gui': {'scaling': {'type': 'nine_slice', 'width': 200, 'height': 20, 'border': 3}}}, indent=2)
|
||||||
|
with open(os.path.join(ZIEL, 'knopf.png.mcmeta'), 'w', encoding='utf-8') as f:
|
||||||
|
f.write(meta)
|
||||||
|
symbole().save(os.path.join(ZIEL, 'symbole.png'))
|
||||||
|
puzzle(groesse=40).save(os.path.join(ZIEL, 'mods_knopf.png')) # Symbol-Knopf im Pausemenü (26.x)
|
||||||
|
sprachen, schrift = sprachdateien()
|
||||||
|
for sprache, text in sprachen.items():
|
||||||
|
with open(os.path.join(ZIEL, f'lang_{sprache}.json'), 'w', encoding='utf-8') as f:
|
||||||
|
f.write(text)
|
||||||
|
with open(os.path.join(ZIEL, 'font_default.json'), 'w', encoding='utf-8') as f:
|
||||||
|
f.write(schrift)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
os.makedirs(ZIEL, exist_ok=True)
|
||||||
|
titel(False).save(os.path.join(ZIEL, 'titel_neu.png'))
|
||||||
|
titel(True).save(os.path.join(ZIEL, 'titel_alt.png'))
|
||||||
|
# Der Untertitel steckt jetzt im Titel-Logo – edition.png bleibt leer
|
||||||
|
leer = Image.new('RGBA', (512, 64), (0, 0, 0, 0))
|
||||||
|
leer.save(os.path.join(ZIEL, 'edition_neu.png'))
|
||||||
|
leer.save(os.path.join(ZIEL, 'edition_alt.png'))
|
||||||
|
|
||||||
|
equi, flach = quelle('panorama'), quelle('hintergrund')
|
||||||
|
if equi is not None:
|
||||||
|
seiten = wuerfelseiten(equi) # echtes Rundum-Panorama
|
||||||
|
elif flach is not None:
|
||||||
|
seiten = seiten_aus_bild(flach) # flaches Bild, nahtlos gespiegelt
|
||||||
|
else:
|
||||||
|
seiten = wuerfelseiten(landschaft()) # gezeichnete Block-Landschaft
|
||||||
|
for i, s in enumerate(seiten):
|
||||||
|
s.save(os.path.join(ZIEL, f'panorama_{i}.png'), optimize=True)
|
||||||
|
|
||||||
|
lade = ladebild()
|
||||||
|
lade.save(os.path.join(QUELLE if os.path.isdir(QUELLE) else ZIEL, '_vorschau_ladebildschirm.png'))
|
||||||
|
als_logotextur(lade).save(os.path.join(ZIEL, 'ladelogo.png'), optimize=True)
|
||||||
|
# derselbe Ladebildschirm ohne Text – für den Neustart über den Mod-Schalter
|
||||||
|
ladebild(mit_text=False).save(os.path.join(ZIEL, 'neustart.jpg'), quality=88)
|
||||||
|
|
||||||
|
# 1.14/1.15: festes 256×256-Feld in der Mitte, Logo auf durchsichtigem Grund
|
||||||
|
alt = Image.new('RGBA', (1024, 1024), (0, 0, 0, 0))
|
||||||
|
art, _ = logo(26)
|
||||||
|
art = einpassen(art, 980, 600)
|
||||||
|
alt.alpha_composite(art, ((1024 - art.width) // 2, (1024 - art.height) // 2))
|
||||||
|
alt.save(os.path.join(ZIEL, 'ladelogo_alt.png'))
|
||||||
|
|
||||||
|
knopf_dateien()
|
||||||
|
|
||||||
|
symbol = Image.open(LOGO).convert('RGBA')
|
||||||
|
symbol.resize((128, 128), Image.LANCZOS).save(os.path.join(ZIEL, 'pack.png'))
|
||||||
|
for n in (16, 32, 48, 64, 128, 256):
|
||||||
|
with open(os.path.join(ZIEL, f'icon_{n}.rgba'), 'wb') as f:
|
||||||
|
f.write(symbol.resize((n, n), Image.LANCZOS).tobytes())
|
||||||
|
print('Fertig:', ZIEL, '| Hintergrund:', 'eigenes Bild' if quelle('hintergrund') else 'Panorama-Ausschnitt',
|
||||||
|
'| Panorama:', 'eigenes Panorama' if quelle('panorama') else ('aus dem Hintergrundbild' if quelle('hintergrund') else 'Block-Landschaft'))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* pruefe-version.js – läuft vor jedem Build (prebuild)
|
||||||
|
* ----------------------------------------------------
|
||||||
|
* Bricht ab, wenn die Version aus package.json schon als Release in Gitea
|
||||||
|
* veröffentlicht ist. Ein neuer Build derselben Version ergäbe andere Dateien
|
||||||
|
* mit neuer Signatur – die bereits veröffentlichten EXEs würden dann nicht mehr
|
||||||
|
* dazu passen und kein Launcher nähme das Update an.
|
||||||
|
*
|
||||||
|
* Außerdem sagt es, wie das Release in Gitea heißen muss:
|
||||||
|
*
|
||||||
|
* fertige Version 1.2.4 Tag 1.2.4
|
||||||
|
* Vorabfassung 1.2.4-beta.1 Tag 0.0.0-1.2.4-beta.1 + Haken "Pre-Release"
|
||||||
|
*
|
||||||
|
* Der Vorsatz "0.0.0-" bei Vorabfassungen ist Pflicht: Launcher bis 1.2.3 kennen
|
||||||
|
* das Pre-Release-Feld nicht und würden "1.2.4-beta.1" als normales Update an
|
||||||
|
* ALLE Nutzer verteilen (siehe src/updater.js).
|
||||||
|
*
|
||||||
|
* Ausnahme (z. B. Testbuild, der nie hochgeladen wird): AEROMC_GLEICHE_VERSION=1
|
||||||
|
*/
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const API = 'https://git.viper.ipv64.net/api/v1/repos/M_Viper/AeroMc-Launcher/releases?limit=50';
|
||||||
|
const version = require(path.join(__dirname, '..', 'package.json')).version;
|
||||||
|
const vorab = /^\d+(?:\.\d+){0,2}-/.test(version);
|
||||||
|
const tag = vorab ? `0.0.0-${version}` : version;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
if (process.env.AEROMC_GLEICHE_VERSION !== '1') {
|
||||||
|
try {
|
||||||
|
const res = await fetch(API, { headers: { 'User-Agent': 'AeroMC-Release', Accept: 'application/json' } });
|
||||||
|
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||||
|
const norm = (v) => String(v || '').trim().replace(/^v/i, '').replace(/^0\.0\.0-(?=\d)/, '');
|
||||||
|
const treffer = (await res.json() || []).find((r) => !r.draft && norm(r.tag_name) === version);
|
||||||
|
if (treffer) {
|
||||||
|
console.error(`\n✖ Version ${version} ist bereits in Gitea veröffentlicht (Tag ${treffer.tag_name}, ${treffer.published_at}).`);
|
||||||
|
console.error(' Bitte zuerst die Versionsnummer in package.json erhöhen – sonst passen die');
|
||||||
|
console.error(' veröffentlichten Dateien nicht mehr zur Signatur.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`\n! Gitea nicht erreichbar (${err.message}) – Versionsprüfung übersprungen.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`» Version ${version} ist noch nicht veröffentlicht – Build läuft.`);
|
||||||
|
if (vorab) {
|
||||||
|
console.log(` VORABFASSUNG – in Gitea so veröffentlichen: Tag ${tag} und Haken bei "Als Pre-Release kennzeichnen".`);
|
||||||
|
console.log(' Niemals mit dem Tag der reinen Versionsnummer: Ältere Launcher würden die Beta sonst an alle verteilen.');
|
||||||
|
} else {
|
||||||
|
console.log(` In Gitea veröffentlichen mit dem Tag ${tag} (ohne Pre-Release-Haken).`);
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const readline = require('readline');
|
||||||
|
const { execFileSync } = require('child_process');
|
||||||
|
const { signaturText, pruefeSignatur, holeSignatur, SIGNATUR_API } = require('../src/updater');
|
||||||
|
|
||||||
|
const ROOT = path.join(__dirname, '..');
|
||||||
|
const DIST = path.join(ROOT, 'dist');
|
||||||
|
const ORDNER = path.join(os.homedir(), '.aeromc-release');
|
||||||
|
const OEFFENTLICH = path.join(ROOT, 'src', 'update-schluessel.pem');
|
||||||
|
const PRIVAT = process.env.AEROMC_SIGN_KEY || path.join(ORDNER, 'release-key.pem');
|
||||||
|
const ZUGANG = path.join(ORDNER, 'wordpress.json');
|
||||||
|
const DATEIEN = { setup: 'AeroMC-Setup.exe', portable: 'AeroMC-Portable.exe' };
|
||||||
|
const WP_STANDARD = new URL(SIGNATUR_API).origin;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Schlüssel
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function schluesselAnlegen() {
|
||||||
|
if (fs.existsSync(PRIVAT)) {
|
||||||
|
console.log(`Es gibt bereits einen Release-Schlüssel: ${PRIVAT}`);
|
||||||
|
console.log('Er wird nicht überschrieben – sonst nähmen bestehende Launcher keine Updates mehr an.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
|
||||||
|
fs.mkdirSync(path.dirname(PRIVAT), { recursive: true });
|
||||||
|
fs.writeFileSync(PRIVAT, privateKey.export({ type: 'pkcs8', format: 'pem' }), { mode: 0o600 });
|
||||||
|
fs.writeFileSync(OEFFENTLICH, publicKey.export({ type: 'spki', format: 'pem' }));
|
||||||
|
console.log(`Privater Schlüssel: ${PRIVAT} (SICHERN, niemals weitergeben!)`);
|
||||||
|
console.log(`Öffentlicher Schlüssel: ${OEFFENTLICH} (wird in den Launcher eingebaut)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function dpapi(skript, eingabe) {
|
||||||
|
return execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', skript],
|
||||||
|
{ input: eingabe, encoding: 'utf8', windowsHide: true }).trim();
|
||||||
|
}
|
||||||
|
function verschluesseln(text) {
|
||||||
|
return dpapi('$t = [Console]::In.ReadToEnd(); ConvertTo-SecureString $t -AsPlainText -Force | ConvertFrom-SecureString', text);
|
||||||
|
}
|
||||||
|
function entschluesseln(hex) {
|
||||||
|
return dpapi('$h = [Console]::In.ReadToEnd().Trim(); $s = ConvertTo-SecureString $h; '
|
||||||
|
+ '[Runtime.InteropServices.Marshal]::PtrToStringBSTR([Runtime.InteropServices.Marshal]::SecureStringToBSTR($s))', hex);
|
||||||
|
}
|
||||||
|
|
||||||
|
function frage(text, verdeckt) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
||||||
|
if (verdeckt) {
|
||||||
|
rl._writeToOutput = (s) => { if (s.includes(text)) process.stdout.write(text); else if (s === '\r\n' || s === '\n') process.stdout.write(s); };
|
||||||
|
}
|
||||||
|
rl.question(text, (antwort) => { rl.close(); if (verdeckt) process.stdout.write('\n'); resolve(antwort.trim()); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ladeZugang() {
|
||||||
|
if (!fs.existsSync(ZUGANG)) return null;
|
||||||
|
const z = JSON.parse(fs.readFileSync(ZUGANG, 'utf8').replace(/^/, ''));
|
||||||
|
return { seite: z.seite, benutzer: z.benutzer, passwort: entschluesseln(z.passwort) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Anfrage MIT Zugangsdaten an die WordPress-Seite.
|
||||||
|
* Weiterleitungen werden nie verfolgt: Sonst gingen die Kopfzeilen – vor allem
|
||||||
|
* der eigene X-AeroMC-Auth, den fetch bei einem Wechsel der Domain nicht von
|
||||||
|
* selbst entfernt – samt Anwendungspasswort an das Ziel der Weiterleitung.
|
||||||
|
* Gesendet wird außerdem nur über HTTPS und nur an die hinterlegte Seite.
|
||||||
|
*/
|
||||||
|
async function wordpressAnfrage(zugang, pfad, optionen) {
|
||||||
|
let ziel;
|
||||||
|
try { ziel = new URL(zugang.seite + pfad); } catch { throw new Error('Die hinterlegte WordPress-Adresse ist ungültig.'); }
|
||||||
|
if (ziel.protocol !== 'https:') throw new Error('Zugangsdaten werden nur über HTTPS gesendet – WordPress-Adresse prüfen.');
|
||||||
|
const res = await fetch(ziel, Object.assign({}, optionen, { headers: kopfzeilen(zugang), redirect: 'manual' }));
|
||||||
|
if (res.status >= 300 && res.status < 400) {
|
||||||
|
const wohin = res.headers.get('location') || 'unbekannt';
|
||||||
|
throw new Error(`Die Seite leitet weiter (HTTP ${res.status} → ${wohin}). Die Zugangsdaten wurden NICHT dorthin gesendet.
|
||||||
|
`
|
||||||
|
+ ' Bitte die genaue Adresse eintragen: node scripts/sign-release.js wordpress');
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
function kopfzeilen(zugang) {
|
||||||
|
const daten = Buffer.from(`${zugang.benutzer}:${zugang.passwort}`).toString('base64');
|
||||||
|
return {
|
||||||
|
Authorization: 'Basic ' + daten,
|
||||||
|
// Ersatz für Server, die "Authorization" nicht an PHP weitergeben (siehe signaturen.php)
|
||||||
|
'X-AeroMC-Auth': daten,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'User-Agent': 'AeroMC-Release',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Übersetzt die Diagnose des Plugins (ab 1.5.4) in eine klare Meldung
|
||||||
|
function zugangsFehler(d, benutzer) {
|
||||||
|
if (!d || !d.version) {
|
||||||
|
return 'Das Plugin auf der Seite ist zu alt – bitte "AeroMC Freunde" 1.5.4 oder neuer installieren.';
|
||||||
|
}
|
||||||
|
if (!d.anwendungspasswoerter) return 'Anwendungspasswörter sind auf der Seite abgeschaltet (z. B. durch ein Sicherheits-Plugin).';
|
||||||
|
if (!d.authorization_kommt_an && !d.eigener_kopf_kommt_an) {
|
||||||
|
return 'Der Server leitet keine Anmeldedaten an WordPress weiter (beide Header fehlen) – das muss der Hoster freigeben.';
|
||||||
|
}
|
||||||
|
switch (d.grund) {
|
||||||
|
case 'invalid_username': return `Den Benutzer "${benutzer}" gibt es nicht (Benutzername, nicht E-Mail-Adresse).`;
|
||||||
|
case 'incorrect_password':
|
||||||
|
return 'Falsches Anwendungspasswort. Achtung: Gemeint ist NICHT das Login-Passwort, sondern das unter '
|
||||||
|
+ 'Benutzer → Profil → "Anwendungspasswörter" erzeugte (Form "abcd efgh ijkl …").';
|
||||||
|
case 'application_passwords_disabled':
|
||||||
|
case 'application_passwords_disabled_for_user':
|
||||||
|
return `Für "${benutzer}" sind Anwendungspasswörter abgeschaltet.`;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
if (d.benutzer && !d.darf_hochladen) {
|
||||||
|
return `Anmeldung als "${d.benutzer}" klappt, aber der Benutzer hat nicht die Rolle "AeroMC Release" (Benutzer bearbeiten → Rolle).`;
|
||||||
|
}
|
||||||
|
return 'Anmeldung abgelehnt' + (d.grund ? ` (${d.grund})` : '') + '.';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pruefeZugang(zugang) {
|
||||||
|
const res = await wordpressAnfrage(zugang, '/wp-json/aeromc/v1/update-signatur/zugang', {});
|
||||||
|
if (res.status === 404) throw new Error('Die Seite kennt die Signatur-Schnittstelle nicht – ist das Plugin "AeroMC Freunde" (ab 1.5.4) installiert und aktiviert?');
|
||||||
|
let d = null;
|
||||||
|
try { d = await res.json(); } catch { /* keine JSON-Antwort */ }
|
||||||
|
if (res.ok && d && d.ok) return d;
|
||||||
|
if (res.status === 401 || res.status === 403) throw new Error(zugangsFehler(d, zugang.benutzer));
|
||||||
|
throw new Error('WordPress antwortet mit HTTP ' + res.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wordpressEinrichten() {
|
||||||
|
console.log('WordPress-Zugang für das automatische Hochladen der Signaturen einrichten.');
|
||||||
|
console.log('Vorher in WordPress: Benutzer mit der Rolle "AeroMC Release" anlegen, dann in dessen Profil');
|
||||||
|
console.log('unter "Anwendungspasswörter" ein Passwort erzeugen (Name z. B. "AeroMC Build").\n');
|
||||||
|
const seite = (await frage(`Adresse der WordPress-Seite [${WP_STANDARD}]: `)) || WP_STANDARD;
|
||||||
|
if (!/^https:\/\//i.test(seite)) throw new Error('Bitte eine https://-Adresse angeben.');
|
||||||
|
const benutzer = await frage('WordPress-Benutzername: ');
|
||||||
|
const passwort = await frage('Anwendungspasswort (Eingabe bleibt unsichtbar): ', true);
|
||||||
|
if (!benutzer || !passwort) throw new Error('Benutzername und Passwort werden gebraucht.');
|
||||||
|
const zugang = { seite: seite.replace(/\/+$/, ''), benutzer, passwort };
|
||||||
|
const antwort = await pruefeZugang(zugang);
|
||||||
|
fs.mkdirSync(ORDNER, { recursive: true });
|
||||||
|
fs.writeFileSync(ZUGANG, JSON.stringify({ seite: zugang.seite, benutzer, passwort: verschluesseln(passwort) }, null, 2), { mode: 0o600 });
|
||||||
|
console.log(`\n✔ Zugang geprüft (angemeldet als ${antwort.benutzer}) und gespeichert: ${ZUGANG}`);
|
||||||
|
console.log(' Das Passwort ist mit Windows-DPAPI verschlüsselt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function sha256(datei) {
|
||||||
|
const h = crypto.createHash('sha256');
|
||||||
|
const fd = fs.openSync(datei, 'r');
|
||||||
|
const buf = Buffer.alloc(4 * 1024 * 1024);
|
||||||
|
let n;
|
||||||
|
while ((n = fs.readSync(fd, buf, 0, buf.length, null)) > 0) h.update(buf.subarray(0, n));
|
||||||
|
fs.closeSync(fd);
|
||||||
|
return h.digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function signieren(art) {
|
||||||
|
const name = DATEIEN[art];
|
||||||
|
if (!name) throw new Error(`Unbekannte Art "${art}" – erwartet: setup, portable, schluessel, wordpress oder hochladen.`);
|
||||||
|
const datei = path.join(DIST, name);
|
||||||
|
if (!fs.existsSync(datei)) throw new Error(`${datei} fehlt – erst bauen.`);
|
||||||
|
if (!fs.existsSync(PRIVAT)) {
|
||||||
|
throw new Error(`Kein Release-Schlüssel unter ${PRIVAT}. Einmalig "node scripts/sign-release.js schluessel" ausführen.`);
|
||||||
|
}
|
||||||
|
const privat = crypto.createPrivateKey(fs.readFileSync(PRIVAT, 'utf8'));
|
||||||
|
|
||||||
|
const eingebaut = crypto.createPublicKey(fs.readFileSync(OEFFENTLICH, 'utf8')).export({ type: 'spki', format: 'der' });
|
||||||
|
const passend = crypto.createPublicKey(privat).export({ type: 'spki', format: 'der' });
|
||||||
|
if (!eingebaut.equals(passend)) {
|
||||||
|
throw new Error('Der private Schlüssel passt nicht zu src/update-schluessel.pem – Signatur wäre für den Launcher wertlos.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = require(path.join(ROOT, 'package.json')).version;
|
||||||
|
const hash = sha256(datei);
|
||||||
|
const sig = {
|
||||||
|
format: 'AeroMC-Update/1',
|
||||||
|
datei: name,
|
||||||
|
version,
|
||||||
|
sha256: hash,
|
||||||
|
signatur: crypto.sign(null, signaturText(name, version, hash), privat).toString('base64'),
|
||||||
|
};
|
||||||
|
pruefeSignatur(sig, name, version, hash);
|
||||||
|
fs.writeFileSync(datei + '.sig', JSON.stringify(sig, null, 2) + '\n');
|
||||||
|
console.log(`\n» ${name} ${version} signiert`);
|
||||||
|
return sig;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hochladen(sig) {
|
||||||
|
const zugang = ladeZugang();
|
||||||
|
if (!zugang) {
|
||||||
|
throw new Error('Kein WordPress-Zugang eingerichtet – Signatur NICHT hochgeladen.\n'
|
||||||
|
+ ' Einmalig ausführen: node scripts/sign-release.js wordpress\n'
|
||||||
|
+ ` danach: node scripts/sign-release.js hochladen ${sig.datei === DATEIEN.setup ? 'setup' : 'portable'}`);
|
||||||
|
}
|
||||||
|
// Nie die Signatur einer schon veröffentlichten Datei überschreiben: Dann passte
|
||||||
|
// die EXE im Gitea-Release nicht mehr, und kein Launcher nähme das Update an.
|
||||||
|
const vorhanden = await holeSignatur(sig.datei, sig.version);
|
||||||
|
if (vorhanden && vorhanden.sha256 !== sig.sha256 && process.env.AEROMC_SIG_ERSETZEN !== '1') {
|
||||||
|
throw new Error(`Für ${sig.datei} ${sig.version} liegt schon eine andere Signatur auf WordPress – `
|
||||||
|
+ 'diese Version ist vermutlich bereits veröffentlicht. Bitte die Versionsnummer in package.json erhöhen.\n'
|
||||||
|
+ ' (Nur wenn die Version sicher NICHT veröffentlicht ist: AEROMC_SIG_ERSETZEN=1 setzen.)');
|
||||||
|
}
|
||||||
|
if (vorhanden && vorhanden.sha256 === sig.sha256) {
|
||||||
|
console.log('» Signatur liegt bereits unverändert auf WordPress.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await wordpressAnfrage(zugang, '/wp-json/aeromc/v1/update-signatur', {
|
||||||
|
method: 'POST', body: JSON.stringify(sig),
|
||||||
|
});
|
||||||
|
let antwort = {};
|
||||||
|
try { antwort = await res.json(); } catch { /* leer */ }
|
||||||
|
if (!res.ok || !antwort.ok) {
|
||||||
|
throw new Error(`Hochladen fehlgeschlagen (HTTP ${res.status}): ${antwort.message || 'unbekannter Fehler'}`);
|
||||||
|
}
|
||||||
|
// Gegenprobe: liefert die öffentliche Schnittstelle genau das, was der Launcher braucht?
|
||||||
|
const zurueck = await holeSignatur(sig.datei, sig.version);
|
||||||
|
if (!zurueck || zurueck.signatur !== sig.signatur || zurueck.sha256 !== sig.sha256) {
|
||||||
|
throw new Error('Signatur wurde hochgeladen, die Seite liefert aber etwas anderes zurück (Cache?).');
|
||||||
|
}
|
||||||
|
console.log(`» Signatur auf ${zugang.seite} hochgeladen und geprüft${antwort.geprueft ? ' (vom Server gegengeprüft)' : ''} – ins Gitea-Release kommt nur die EXE.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const [art, zusatz] = process.argv.slice(2);
|
||||||
|
if (art === 'schluessel') return schluesselAnlegen();
|
||||||
|
if (art === 'wordpress') return wordpressEinrichten();
|
||||||
|
if (art === 'hochladen') {
|
||||||
|
const name = DATEIEN[zusatz];
|
||||||
|
if (!name) throw new Error('Erwartet: hochladen setup|portable');
|
||||||
|
const sigDatei = path.join(DIST, name + '.sig');
|
||||||
|
if (!fs.existsSync(sigDatei)) throw new Error(`${sigDatei} fehlt – erst bauen und signieren.`);
|
||||||
|
const sig = JSON.parse(fs.readFileSync(sigDatei, 'utf8'));
|
||||||
|
pruefeSignatur(sig, name, sig.version, sha256(path.join(DIST, name)));
|
||||||
|
return hochladen(sig);
|
||||||
|
}
|
||||||
|
return hochladen(signieren(art));
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('\n✖ ' + err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
After Width: | Height: | Size: 3.1 MiB |
|
After Width: | Height: | Size: 2.8 MiB |
@@ -19,9 +19,14 @@ HOST=0.0.0.0
|
|||||||
# weiterlaufen, bis alle das Update haben – danach das alte streichen.
|
# weiterlaufen, bis alle das Update haben – danach das alte streichen.
|
||||||
# CF_PROXY_TOKEN=neues-token,altes-token
|
# CF_PROXY_TOKEN=neues-token,altes-token
|
||||||
#
|
#
|
||||||
# Leer lassen = keine Tokenprüfung.
|
# Pflicht: Ohne Token startet der Dienst nicht – sonst könnte jeder, der die
|
||||||
|
# Adresse kennt, deinen API-Key und dein Download-Kontingent benutzen.
|
||||||
|
# Ein langes Zufalls-Token erzeugen, z. B.: openssl rand -hex 32
|
||||||
CF_PROXY_TOKEN=
|
CF_PROXY_TOKEN=
|
||||||
|
|
||||||
|
# Nur für ein abgeschottetes Netz: Dienst bewusst OHNE Token betreiben.
|
||||||
|
# CF_PROXY_OFFEN=1
|
||||||
|
|
||||||
# Anfragen je Minute und Absender. 0 = keine Begrenzung.
|
# Anfragen je Minute und Absender. 0 = keine Begrenzung.
|
||||||
RATE_LIMIT_PER_MIN=60
|
RATE_LIMIT_PER_MIN=60
|
||||||
|
|
||||||
|
|||||||
@@ -101,7 +101,8 @@ Der Dienst steht offen im Netz. Eingebaut ist:
|
|||||||
die Host-Liste geprüft, höchstens vier Sprünge.
|
die Host-Liste geprüft, höchstens vier Sprünge.
|
||||||
- **Ratenbegrenzung** je Absender, einstellbar über `RATE_LIMIT_PER_MIN`.
|
- **Ratenbegrenzung** je Absender, einstellbar über `RATE_LIMIT_PER_MIN`.
|
||||||
- **Größengrenze** für durchgereichte Dateien über `MAX_DOWNLOAD_MB`.
|
- **Größengrenze** für durchgereichte Dateien über `MAX_DOWNLOAD_MB`.
|
||||||
- **Optionales Token** über `CF_PROXY_TOKEN`.
|
- **Token** über `CF_PROXY_TOKEN` – Pflicht: ohne Token startet der Dienst nicht
|
||||||
|
(bewusst offen nur mit `CF_PROXY_OFFEN=1`, z. B. im abgeschotteten Netz).
|
||||||
|
|
||||||
Zum Token eine ehrliche Einordnung: Es steckt dann auch im Launcher und ist
|
Zum Token eine ehrliche Einordnung: Es steckt dann auch im Launcher und ist
|
||||||
dort auslesbar. Es hält Gelegenheitsnutzer ab, keine entschlossenen. Die
|
dort auslesbar. Es hält Gelegenheitsnutzer ab, keine entschlossenen. Die
|
||||||
|
|||||||
@@ -1,58 +1,30 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
/*
|
|
||||||
* AeroMC – CurseForge-Weiterleitung
|
|
||||||
* ---------------------------------
|
|
||||||
* Hält den CurseForge-API-Schlüssel und stellt die Anfragen stellvertretend
|
|
||||||
* für den Launcher. So bleibt der Schlüssel auf dem Server: in der verteilten
|
|
||||||
* .exe wäre er für jeden auslesbar, und CurseForge untersagt die Weitergabe
|
|
||||||
* an Dritte ausdrücklich.
|
|
||||||
*
|
|
||||||
* Wege:
|
|
||||||
* GET /health – Lebenszeichen, ohne Schlüsselangaben
|
|
||||||
* POST /v1/mods/files – Dateiliste auflösen (Rumpf: { fileIds: [...] })
|
|
||||||
* GET /v1/download?url=... – Mod-Datei durchreichen (CDN verlangt seit
|
|
||||||
* Juli 2026 ebenfalls einen Schlüssel)
|
|
||||||
*
|
|
||||||
* Einstellungen über Umgebungsvariablen, siehe .env.example.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
|
const crypto = require('crypto');
|
||||||
const { Readable } = require('stream');
|
const { Readable } = require('stream');
|
||||||
|
|
||||||
const KEY = (process.env.CF_API_KEY || '').trim();
|
const KEY = (process.env.CF_API_KEY || '').trim();
|
||||||
const PORT = Number(process.env.PORT || 8420);
|
const PORT = Number(process.env.PORT || 8420);
|
||||||
const HOST = process.env.HOST || '0.0.0.0';
|
const HOST = process.env.HOST || '0.0.0.0';
|
||||||
/*
|
|
||||||
* Ein oder mehrere gültige Token, durch Komma getrennt.
|
|
||||||
*
|
|
||||||
* Mehrere deshalb, weil das Token in ausgelieferten Paketen steckt: Beim
|
|
||||||
* Wechsel trägst du eine Zeit lang altes und neues ein, damit ältere
|
|
||||||
* Installationen weiterlaufen, bis alle das Update haben. Danach das alte
|
|
||||||
* streichen.
|
|
||||||
*/
|
|
||||||
const TOKENS = new Set(
|
const TOKENS = new Set(
|
||||||
String(process.env.CF_PROXY_TOKEN || '')
|
String(process.env.CF_PROXY_TOKEN || '')
|
||||||
.split(',').map((t) => t.trim()).filter(Boolean),
|
.split(',').map((t) => t.trim()).filter(Boolean),
|
||||||
);
|
);
|
||||||
const LIMIT_PRO_MIN = Number(process.env.RATE_LIMIT_PER_MIN || 60);
|
const LIMIT_PRO_MIN = Number(process.env.RATE_LIMIT_PER_MIN || 60);
|
||||||
const MAX_MB = Number(process.env.MAX_DOWNLOAD_MB || 512);
|
const MAX_MB = Number(process.env.MAX_DOWNLOAD_MB || 512);
|
||||||
// Anzahl der Reverse-Proxies davor. 0 = keiner, 1 = z. B. Nginx Proxy Manager,
|
|
||||||
// 2 = z. B. Cloudflare vor dem Proxy Manager.
|
|
||||||
const PROXY_STUFEN = Math.max(0, Number(process.env.TRUST_PROXY || 0) || 0);
|
const PROXY_STUFEN = Math.max(0, Number(process.env.TRUST_PROXY || 0) || 0);
|
||||||
|
|
||||||
const CF_API = 'https://api.curseforge.com/v1';
|
const CF_API = 'https://api.curseforge.com/v1';
|
||||||
|
|
||||||
// Ohne User-Agent weisen Dienste hinter Cloudflare Anfragen gerne mit 403 ab.
|
|
||||||
const UA = 'AeroMC-Launcher-Proxy/1.0 (+https://git.viper.ipv64.net)';
|
const UA = 'AeroMC-Launcher-Proxy/1.0 (+https://git.viper.ipv64.net)';
|
||||||
|
|
||||||
function cfKopfzeilen(weitere) {
|
function cfKopfzeilen(weitere) {
|
||||||
return Object.assign({ 'x-api-key': KEY, Accept: 'application/json', 'User-Agent': UA }, weitere || {});
|
return Object.assign({ 'x-api-key': KEY, Accept: 'application/json', 'User-Agent': UA }, weitere || {});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nur diese Hosts dürfen durchgereicht werden. Ohne diese Liste wäre der
|
|
||||||
// Server ein offenes Tor in dein Netz – jemand könnte ihn Adressen im
|
|
||||||
// Heimnetz abrufen lassen und die Antwort mitlesen.
|
|
||||||
const ERLAUBTE_HOSTS = new Set([
|
const ERLAUBTE_HOSTS = new Set([
|
||||||
'edge.forgecdn.net',
|
'edge.forgecdn.net',
|
||||||
'mediafilez.forgecdn.net',
|
'mediafilez.forgecdn.net',
|
||||||
@@ -64,6 +36,17 @@ if (!KEY) {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ohne Token wäre der Dienst für jeden offen, der die Adresse kennt – und damit
|
||||||
|
// auch der API-Key und das Download-Kontingent dahinter. Deshalb startet er
|
||||||
|
// ohne CF_PROXY_TOKEN gar nicht erst. Wer ihn bewusst offen betreiben will
|
||||||
|
// (z. B. nur im eigenen Netz), muss das ausdrücklich sagen: CF_PROXY_OFFEN=1.
|
||||||
|
const OFFEN_ERLAUBT = process.env.CF_PROXY_OFFEN === '1';
|
||||||
|
if (!TOKENS.size && !OFFEN_ERLAUBT) {
|
||||||
|
console.error('CF_PROXY_TOKEN fehlt. Ohne Token könnte jeder den Dienst (und deinen API-Key) benutzen.');
|
||||||
|
console.error('Token setzen (siehe .env.example) – oder, nur für ein abgeschottetes Netz, CF_PROXY_OFFEN=1.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Ratenbegrenzung: einfaches Zeitfenster je Absender
|
// Ratenbegrenzung: einfaches Zeitfenster je Absender
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -117,9 +100,21 @@ function sendeJson(res, code, daten) {
|
|||||||
res.end(koerper);
|
res.end(koerper);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Vergleich in gleichbleibender Zeit, damit sich ein Token nicht Zeichen für
|
||||||
|
// Zeichen über die Antwortdauer erraten lässt
|
||||||
|
function gleich(a, b) {
|
||||||
|
const x = crypto.createHash('sha256').update(a).digest();
|
||||||
|
const y = crypto.createHash('sha256').update(b).digest();
|
||||||
|
return crypto.timingSafeEqual(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
function tokenOk(req) {
|
function tokenOk(req) {
|
||||||
if (!TOKENS.size) return true;
|
if (!TOKENS.size) return OFFEN_ERLAUBT; // ohne Token nur mit ausdrücklicher Freigabe
|
||||||
return TOKENS.has(String(req.headers['x-aeromc-token'] || '').trim());
|
const erhalten = String(req.headers['x-aeromc-token'] || '').trim();
|
||||||
|
if (!erhalten) return false;
|
||||||
|
let ok = false;
|
||||||
|
for (const t of TOKENS) if (gleich(t, erhalten)) ok = true;
|
||||||
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
function leseKoerper(req, maxBytes = 1_000_000) {
|
function leseKoerper(req, maxBytes = 1_000_000) {
|
||||||
@@ -337,7 +332,7 @@ server.listen(PORT, HOST, () => {
|
|||||||
console.log(`AeroMC CurseForge-Weiterleitung läuft auf ${HOST}:${PORT}`);
|
console.log(`AeroMC CurseForge-Weiterleitung läuft auf ${HOST}:${PORT}`);
|
||||||
console.log(` Ratenbegrenzung: ${LIMIT_PRO_MIN > 0 ? LIMIT_PRO_MIN + '/Minute je Absender' : 'aus'}`);
|
console.log(` Ratenbegrenzung: ${LIMIT_PRO_MIN > 0 ? LIMIT_PRO_MIN + '/Minute je Absender' : 'aus'}`);
|
||||||
// niemals die Token selbst ausgeben – Protokolle wandern schnell woanders hin
|
// niemals die Token selbst ausgeben – Protokolle wandern schnell woanders hin
|
||||||
console.log(` Token verlangt : ${TOKENS.size ? 'ja (' + TOKENS.size + ' gültige)' : 'nein'}`);
|
console.log(` Token verlangt : ${TOKENS.size ? 'ja (' + TOKENS.size + ' gültige)' : 'NEIN – offen für jeden (CF_PROXY_OFFEN=1)'}`);
|
||||||
console.log(` Proxy-Stufen : ${PROXY_STUFEN} (${PROXY_STUFEN ? 'Absender aus X-Forwarded-For' : 'Absender aus der Verbindung'})`);
|
console.log(` Proxy-Stufen : ${PROXY_STUFEN} (${PROXY_STUFEN ? 'Absender aus X-Forwarded-For' : 'Absender aus der Verbindung'})`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"skills": {
|
||||||
|
"cavecrew": {
|
||||||
|
"source": "JuliusBrussee/caveman",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": "skills/cavecrew/SKILL.md",
|
||||||
|
"computedHash": "2e122d819a812bef0e4958706f306e4346e78ecb5a4893997b99cbc19866f514"
|
||||||
|
},
|
||||||
|
"caveman": {
|
||||||
|
"source": "JuliusBrussee/caveman",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": "skills/caveman/SKILL.md",
|
||||||
|
"computedHash": "71b600c88c8d1d36f18c4875db23b53c5d6cb9ba3195e19451d6b1bb67db07c4"
|
||||||
|
},
|
||||||
|
"caveman-commit": {
|
||||||
|
"source": "JuliusBrussee/caveman",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": "skills/caveman-commit/SKILL.md",
|
||||||
|
"computedHash": "b416cf134b0f33ddadeca7977aebdbb2d3db79a166d0fce2e1aabeec98132766"
|
||||||
|
},
|
||||||
|
"caveman-compress": {
|
||||||
|
"source": "JuliusBrussee/caveman",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": "skills/caveman-compress/SKILL.md",
|
||||||
|
"computedHash": "810b9f31c05c3ef5d195eac4f76cd26674ba13f542d6ad5a0b16d5362b021614"
|
||||||
|
},
|
||||||
|
"caveman-discover": {
|
||||||
|
"source": "JuliusBrussee/caveman",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": "skills/caveman-discover/SKILL.md",
|
||||||
|
"computedHash": "6850e0ca7bd7a35a1f447cbd342f91b76ece1fb2e3473ad894e51e69746df8a2"
|
||||||
|
},
|
||||||
|
"caveman-evidence-review": {
|
||||||
|
"source": "JuliusBrussee/caveman",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": "skills/caveman-evidence-review/SKILL.md",
|
||||||
|
"computedHash": "6a2db0191082500739416c87ff896a7894f98aa867f15c70fe932e4d72edd327"
|
||||||
|
},
|
||||||
|
"caveman-explore": {
|
||||||
|
"source": "JuliusBrussee/caveman",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": "skills/caveman-explore/SKILL.md",
|
||||||
|
"computedHash": "6e89d5fef24957b921cf021d5bb392c47185ed65233db783acef64a9228dbb1b"
|
||||||
|
},
|
||||||
|
"caveman-help": {
|
||||||
|
"source": "JuliusBrussee/caveman",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": "skills/caveman-help/SKILL.md",
|
||||||
|
"computedHash": "c7a73a8c6afb6f871690666449209106aba847b4a9b0698686648960ece80012"
|
||||||
|
},
|
||||||
|
"caveman-learn": {
|
||||||
|
"source": "JuliusBrussee/caveman",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": "skills/caveman-learn/SKILL.md",
|
||||||
|
"computedHash": "7af5a39ccea7b778581938662422ffce01df4271fb5403a63953794748210e93"
|
||||||
|
},
|
||||||
|
"caveman-manage": {
|
||||||
|
"source": "JuliusBrussee/caveman",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": "skills/caveman-manage/SKILL.md",
|
||||||
|
"computedHash": "fdd3834941a8a576cae0ac96a38dfec6ac4bd26ef209ba9997468ca11aaf19f4"
|
||||||
|
},
|
||||||
|
"caveman-optimize": {
|
||||||
|
"source": "JuliusBrussee/caveman",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": "skills/caveman-optimize/SKILL.md",
|
||||||
|
"computedHash": "5e859a3a8305b5559b2f528616f634324449a426054704d43ef0b6ec8c1d9807"
|
||||||
|
},
|
||||||
|
"caveman-review": {
|
||||||
|
"source": "JuliusBrussee/caveman",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": "skills/caveman-review/SKILL.md",
|
||||||
|
"computedHash": "36a4007c5c0c6b90b5accfee1cd769a86e4963b651dece2ce5f17a223a0abb4b"
|
||||||
|
},
|
||||||
|
"caveman-setup": {
|
||||||
|
"source": "JuliusBrussee/caveman",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": "skills/caveman-setup/SKILL.md",
|
||||||
|
"computedHash": "b12b9f8f3fe6b499334a4b319ae1a16aa2da5c2d11e5bc85efd3236eac5213aa"
|
||||||
|
},
|
||||||
|
"caveman-stats": {
|
||||||
|
"source": "JuliusBrussee/caveman",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": "skills/caveman-stats/SKILL.md",
|
||||||
|
"computedHash": "cc9fcd21f2a30cff5596b3cbb197fe0f4b1b30b58084a864350853c217ad301a"
|
||||||
|
},
|
||||||
|
"investigate-first": {
|
||||||
|
"source": "JuliusBrussee/caveman",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": "skills/investigate-first/SKILL.md",
|
||||||
|
"computedHash": "59b581b5abfc0e19b7dd5a7c3bb2b16621ab5e94e085065c7b724a9c86cc3d4d"
|
||||||
|
},
|
||||||
|
"lean-build": {
|
||||||
|
"source": "JuliusBrussee/caveman",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": "skills/lean-build/SKILL.md",
|
||||||
|
"computedHash": "6074721f1653e6e9d6381595b094284807b8d5d001d7d3977ec534679b16a63c"
|
||||||
|
},
|
||||||
|
"megacave": {
|
||||||
|
"source": "JuliusBrussee/caveman",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": "skills/megacave/SKILL.md",
|
||||||
|
"computedHash": "60da384ce09cedf553e2b6dfe4669ffb4e53f34c3df8ce2c8012a32e06f8e187"
|
||||||
|
},
|
||||||
|
"migration": {
|
||||||
|
"source": "JuliusBrussee/caveman",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": "skills/migration/SKILL.md",
|
||||||
|
"computedHash": "7348b2d21ca05bdecc1e4ec2c8b63515ccb1700c8a261fdbd458b04b7383945b"
|
||||||
|
},
|
||||||
|
"safe-refactor": {
|
||||||
|
"source": "JuliusBrussee/caveman",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": "skills/safe-refactor/SKILL.md",
|
||||||
|
"computedHash": "9df34a21a7a2c18ce13dbbe4a3f66b4e91df9e4bc33c1c9da8a80261583367ec"
|
||||||
|
},
|
||||||
|
"surgical-patch": {
|
||||||
|
"source": "JuliusBrussee/caveman",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": "skills/surgical-patch/SKILL.md",
|
||||||
|
"computedHash": "028b9c3574142d19639d9f8eb11fbb01c58479f8f441e957d598a7215e79f95d"
|
||||||
|
},
|
||||||
|
"ultracave": {
|
||||||
|
"source": "JuliusBrussee/caveman",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": "skills/ultracave/SKILL.md",
|
||||||
|
"computedHash": "d2e5de7e5e664e95c0c93ea0fb84c6b78a4bb2351c71964c453a8a35c63869eb"
|
||||||
|
},
|
||||||
|
"verify-and-stop": {
|
||||||
|
"source": "JuliusBrussee/caveman",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": "skills/verify-and-stop/SKILL.md",
|
||||||
|
"computedHash": "3d67de16a2b3c48b643bc511ebc4cb86e6c7105215d8428b6140cbcb73aba4e6"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 207 B |
|
After Width: | Height: | Size: 207 B |
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"providers": [
|
||||||
|
{
|
||||||
|
"type": "bitmap",
|
||||||
|
"file": "aeromc:font/symbole.png",
|
||||||
|
"ascent": 7,
|
||||||
|
"height": 8,
|
||||||
|
"chars": [
|
||||||
|
"\ue900\ue901\ue902\ue903\ue904\ue905"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 359 B |
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"gui": {
|
||||||
|
"scaling": {
|
||||||
|
"type": "nine_slice",
|
||||||
|
"width": 200,
|
||||||
|
"height": 20,
|
||||||
|
"border": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 692 B |
|
After Width: | Height: | Size: 353 B |
|
After Width: | Height: | Size: 676 B |
|
After Width: | Height: | Size: 5.2 MiB |
|
After Width: | Height: | Size: 203 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"menu.singleplayer": "\ue900 Einzelspieler",
|
||||||
|
"menu.multiplayer": "\ue901 Mehrspieler",
|
||||||
|
"menu.online": "\ue902 Minecraft Realms",
|
||||||
|
"menu.options": "\ue903 Optionen \u2026",
|
||||||
|
"menu.quit": "\ue904 Spiel beenden"
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"menu.singleplayer": "\ue900 Singleplayer",
|
||||||
|
"menu.multiplayer": "\ue901 Multiplayer",
|
||||||
|
"menu.online": "\ue902 Minecraft Realms",
|
||||||
|
"menu.options": "\ue903 Options...",
|
||||||
|
"menu.quit": "\ue904 Quit Game"
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"menu.sendFeedback": "\ue905 Mods verwalten \u2026",
|
||||||
|
"menu.feedback": "\ue905 Mods & Feedback \u2026"
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"menu.sendFeedback": "\ue905 Manage Mods...",
|
||||||
|
"menu.feedback": "\ue905 Mods & Feedback..."
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 597 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 325 KiB |
|
After Width: | Height: | Size: 661 KiB |
@@ -0,0 +1,33 @@
|
|||||||
|
Mehr als nur Minecraft!
|
||||||
|
Mehr als nur Minecraft!
|
||||||
|
Mehr als nur Minecraft!
|
||||||
|
Mehr als nur Minecraft!
|
||||||
|
Gemeinsam mehr bauen!
|
||||||
|
Dein Minecraft-Erlebnis!
|
||||||
|
Gestartet mit AeroMC!
|
||||||
|
Jetzt mit eigenen Umhängen!
|
||||||
|
Freunde inklusive!
|
||||||
|
Blockig, aber schön!
|
||||||
|
Auch in Nachtlila!
|
||||||
|
Turbo dank Leistungs-Paket!
|
||||||
|
17 Erfolge warten!
|
||||||
|
Mit Garderobe!
|
||||||
|
Ein Klick, und los!
|
||||||
|
Mods? Ein Klick!
|
||||||
|
Diamanten nicht inklusive!
|
||||||
|
Achtung, Creeper!
|
||||||
|
Handgepixelt!
|
||||||
|
Heute schon gebaut?
|
||||||
|
Deine Welt, dein Stil!
|
||||||
|
Spielzeit läuft mit!
|
||||||
|
Läuft wie geschmiert!
|
||||||
|
Kein Lag, nur Spaß!
|
||||||
|
Lade Freunde ein!
|
||||||
|
Schon ein Backup gemacht?
|
||||||
|
Hallo Nachbar!
|
||||||
|
Nur noch ein Block!
|
||||||
|
Bitte nicht die Lava trinken!
|
||||||
|
Jetzt auch auf Deutsch!
|
||||||
|
Mit 100 % mehr Blau!
|
||||||
|
Schlaf gut, Steve!
|
||||||
|
Endlich Wochenende!
|
||||||
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 138 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<circle cx="50" cy="50" r="49.5" fill="#fff" stroke="#222" stroke-width="0.6"/>
|
||||||
|
<circle cx="50" cy="50" r="43.8" fill="#fff" stroke="#c80000" stroke-width="11.2"/>
|
||||||
|
<line x1="23.4" y1="23.4" x2="76.6" y2="76.6" stroke="#c80000" stroke-width="11.2"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 325 B |
@@ -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>
|
||||||
@@ -154,14 +154,27 @@ function scan(inputPath) {
|
|||||||
return { ok: true, instancesDir, count: list.length, instances: list };
|
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) {
|
function copyInto(sourceDir, targetDir) {
|
||||||
fs.mkdirSync(targetDir, { recursive: true });
|
fs.mkdirSync(targetDir, { recursive: true });
|
||||||
for (const name of fs.readdirSync(sourceDir)) {
|
for (const name of kopierNamen(sourceDir)) {
|
||||||
if (META_FILES.includes(name)) continue;
|
|
||||||
fs.cpSync(path.join(sourceDir, name), path.join(targetDir, name), { recursive: true });
|
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) {
|
function importOne(store, entry, copyData = true) {
|
||||||
const created = store.createOrReplaceImportedInstance({
|
const created = store.createOrReplaceImportedInstance({
|
||||||
name: entry.name,
|
name: entry.name,
|
||||||
@@ -193,4 +206,4 @@ function importOne(store, entry, copyData = true) {
|
|||||||
return { id: created.id, name: created.name, copied };
|
return { id: created.id, name: created.name, copied };
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { scan, importOne, resolveInstancesDir };
|
module.exports = { scan, importOne, kopierQuellen, resolveInstancesDir };
|
||||||
@@ -1,218 +1,227 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* crashhelp.js – Absturz-Assistent
|
* crashhelp.js – Absturz-Assistent
|
||||||
* ---------------------------------
|
* ---------------------------------
|
||||||
* Liest Absturzbericht, latest.log und die letzte Konsolenausgabe eines Starts
|
* Liest Absturzbericht, latest.log und die letzte Konsolenausgabe eines Starts
|
||||||
* und erkennt die häufigsten Ursachen. Jeder Fund bekommt eine verständliche
|
* 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
|
* Erklärung und – wo möglich – eine Aktion, die der Launcher mit einem Klick
|
||||||
* ausführen kann:
|
* ausführen kann:
|
||||||
*
|
*
|
||||||
* { typ: 'mod-installieren', projekt, name } fehlende Abhängigkeit von Modrinth holen
|
* { typ: 'mod-installieren', projekt, name } fehlende Abhängigkeit von Modrinth holen
|
||||||
* { typ: 'mod-deaktivieren', dateiname, name } Mod abschalten (umbenennen, nichts gelöscht)
|
* { typ: 'mod-deaktivieren', dateiname, name } Mod abschalten (umbenennen, nichts gelöscht)
|
||||||
* { typ: 'ram', mb } Arbeitsspeicher der Instanz ändern
|
* { typ: 'ram', mb } Arbeitsspeicher der Instanz ändern
|
||||||
* { typ: 'java-auto' } passendes Java automatisch wählen lassen
|
* { typ: 'java-auto' } passendes Java automatisch wählen lassen
|
||||||
* { typ: 'mods-ordner' } Mods-Ordner öffnen
|
* { typ: 'mods-ordner' } Mods-Ordner öffnen
|
||||||
*
|
*
|
||||||
* Bewusst nur Muster, die eindeutig sind – lieber keinen Fund als einen falschen.
|
* Bewusst nur Muster, die eindeutig sind – lieber keinen Fund als einen falschen.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Mod-IDs, die keine installierbaren Mods sind
|
// 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']);
|
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
|
// Mod-ID -> Modrinth-Projekt, wo sich beides unterscheidet
|
||||||
const PROJEKT_FUER_ID = {
|
const PROJEKT_FUER_ID = {
|
||||||
fabric: 'fabric-api',
|
fabric: 'fabric-api',
|
||||||
'fabric-api-base': 'fabric-api',
|
'fabric-api-base': 'fabric-api',
|
||||||
quilted_fabric_api: 'qsl',
|
quilted_fabric_api: 'qsl',
|
||||||
cloth_config: 'cloth-config',
|
cloth_config: 'cloth-config',
|
||||||
'cloth-config2': 'cloth-config',
|
'cloth-config2': 'cloth-config',
|
||||||
architectury: 'architectury-api',
|
architectury: 'architectury-api',
|
||||||
geckolib3: 'geckolib',
|
geckolib3: 'geckolib',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Java-Hauptversion aus der "class file version" (52 = Java 8, 61 = 17, 65 = 21 …)
|
// Java-Hauptversion aus der "class file version" (52 = Java 8, 61 = 17, 65 = 21 …)
|
||||||
const javaAusKlassenversion = (v) => Number(v) - 44;
|
const javaAusKlassenversion = (v) => Number(v) - 44;
|
||||||
|
|
||||||
function projektFuer(modId) {
|
function projektFuer(modId) {
|
||||||
const id = String(modId || '').toLowerCase();
|
const id = String(modId || '').toLowerCase();
|
||||||
return PROJEKT_FUER_ID[id] || id.replace(/_/g, '-');
|
return PROJEKT_FUER_ID[id] || id.replace(/_/g, '-');
|
||||||
}
|
}
|
||||||
|
|
||||||
// installierte Mod zu einer ID/einem Namen finden (für "abschalten")
|
// installierte Mod zu einer ID/einem Namen finden (für "abschalten")
|
||||||
function findeMod(mods, modId, name) {
|
function findeMod(mods, modId, name) {
|
||||||
const id = String(modId || '').toLowerCase();
|
const id = String(modId || '').toLowerCase();
|
||||||
const kurz = id.replace(/[-_]/g, '');
|
const kurz = id.replace(/[-_]/g, '');
|
||||||
const n = String(name || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
const n = String(name || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||||
return (mods || []).find((m) => {
|
return (mods || []).find((m) => {
|
||||||
if (m.enabled === false) return false;
|
if (m.enabled === false) return false;
|
||||||
const datei = String(m.filename || '').toLowerCase();
|
const datei = String(m.filename || '').toLowerCase();
|
||||||
const titel = String(m.title || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
const titel = String(m.title || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||||
return (id && (datei.startsWith(id) || datei.replace(/[-_]/g, '').startsWith(kurz)))
|
return (id && (datei.startsWith(id) || datei.replace(/[-_]/g, '').startsWith(kurz)))
|
||||||
|| (n && titel && (titel === n || datei.replace(/[^a-z0-9]/g, '').startsWith(n)));
|
|| (n && titel && (titel === n || datei.replace(/[^a-z0-9]/g, '').startsWith(n)));
|
||||||
}) || null;
|
}) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* text: zusammengeführter Text aus Bericht, Log und Konsole
|
* text: zusammengeführter Text aus Bericht, Log und Konsole
|
||||||
* ctx: { loader, version, maxMemMb, gesamtMb, mods: [{ filename, title, projectId, enabled }] }
|
* ctx: { loader, version, maxMemMb, gesamtMb, mods: [{ filename, title, projectId, enabled }] }
|
||||||
* -> [{ schluessel, titel, text, aktion? }]
|
* -> [{ schluessel, titel, text, aktion? }]
|
||||||
*/
|
*/
|
||||||
function analysiere(text, ctx = {}) {
|
function analysiere(text, ctx = {}) {
|
||||||
const t = String(text || '');
|
const t = String(text || '');
|
||||||
const funde = [];
|
const funde = [];
|
||||||
const schonDa = new Set();
|
const schonDa = new Set();
|
||||||
const fund = (schluessel, titel, erklaerung, aktion) => {
|
const fund = (schluessel, titel, erklaerung, aktion) => {
|
||||||
if (schonDa.has(schluessel)) return;
|
if (schonDa.has(schluessel)) return;
|
||||||
schonDa.add(schluessel);
|
schonDa.add(schluessel);
|
||||||
funde.push({ schluessel, titel, text: erklaerung, aktion: aktion || null });
|
funde.push({ schluessel, titel, text: erklaerung, aktion: aktion || null });
|
||||||
};
|
};
|
||||||
const mods = ctx.mods || [];
|
const mods = ctx.mods || [];
|
||||||
|
|
||||||
// --- Fehlende Abhängigkeiten (Fabric/Quilt) ---
|
// --- Fehlende Abhängigkeiten (Fabric/Quilt) ---
|
||||||
// "Mod 'Mod Menu' (modmenu) 7.2.2 requires any version of fabric-api, which is missing!"
|
// "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!"
|
// 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)) {
|
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 [, name, , version, fehltName, fehltIdNeu, fehltIdAlt] = m;
|
||||||
const fehlt = fehltIdNeu || fehltIdAlt;
|
const fehlt = fehltIdNeu || fehltIdAlt;
|
||||||
if (KEINE_MODS.has(fehlt.toLowerCase())) continue;
|
if (KEINE_MODS.has(fehlt.toLowerCase())) continue;
|
||||||
const anzeige = fehltName || fehlt;
|
const anzeige = fehltName || fehlt;
|
||||||
fund(`fehlt:${fehlt}`, `„${name}“ braucht ${anzeige}`,
|
fund(`fehlt:${fehlt}`, `„${name}“ braucht ${anzeige}`,
|
||||||
`Der Mod „${name}“ funktioniert nur zusammen mit „${anzeige}“${version ? ` (Version ${version.trim()})` : ''} – und der fehlt in dieser Instanz.`,
|
`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 });
|
{ typ: 'mod-installieren', projekt: projektFuer(fehlt), name: anzeige });
|
||||||
}
|
}
|
||||||
// Lösungsvorschlag von Fabric: "- Install fabric-api, any version."
|
// Lösungsvorschlag von Fabric: "- Install fabric-api, any version."
|
||||||
for (const m of t.matchAll(/^\s*-\s*Install ([\w.-]+), (?:any version|version [^\n.]+)/gim)) {
|
for (const m of t.matchAll(/^\s*-\s*Install ([\w.-]+), (?:any version|version [^\n.]+)/gim)) {
|
||||||
const fehlt = m[1];
|
const fehlt = m[1];
|
||||||
if (KEINE_MODS.has(fehlt.toLowerCase())) continue;
|
if (KEINE_MODS.has(fehlt.toLowerCase())) continue;
|
||||||
fund(`fehlt:${fehlt}`, `Es fehlt „${fehlt}“`,
|
fund(`fehlt:${fehlt}`, `Es fehlt „${fehlt}“`,
|
||||||
`Ein installierter Mod braucht „${fehlt}“, der in dieser Instanz nicht installiert ist.`,
|
`Ein installierter Mod braucht „${fehlt}“, der in dieser Instanz nicht installiert ist.`,
|
||||||
{ typ: 'mod-installieren', projekt: projektFuer(fehlt), name: fehlt });
|
{ typ: 'mod-installieren', projekt: projektFuer(fehlt), name: fehlt });
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Fehlende Abhängigkeiten (Forge/NeoForge) ---
|
// --- Fehlende Abhängigkeiten (Forge/NeoForge) ---
|
||||||
// "Mod ID: 'architectury', Requested by: 'rei', Expected range: '[9.1.12,)', Actual version: '[MISSING]'"
|
// "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)) {
|
for (const m of t.matchAll(/Mod ID: '([\w.-]+)', Requested by: '([\w.-]+)', Expected range: '([^']*)', Actual version: '([^']*)'/gi)) {
|
||||||
const [, fehlt, von, bereich, ist] = m;
|
const [, fehlt, von, bereich, ist] = m;
|
||||||
if (fehlt.toLowerCase() === 'minecraft') {
|
if (fehlt.toLowerCase() === 'minecraft') {
|
||||||
const mod = findeMod(mods, von);
|
const mod = findeMod(mods, von);
|
||||||
fund(`mcversion:${von}`, `„${von}“ passt nicht zu dieser Minecraft-Version`,
|
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}.`,
|
`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);
|
mod ? { typ: 'mod-deaktivieren', dateiname: mod.filename, name: mod.title || von } : null);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (KEINE_MODS.has(fehlt.toLowerCase())) continue;
|
if (KEINE_MODS.has(fehlt.toLowerCase())) continue;
|
||||||
if (/MISSING/i.test(ist)) {
|
if (/MISSING/i.test(ist)) {
|
||||||
fund(`fehlt:${fehlt}`, `„${von}“ braucht ${fehlt}`,
|
fund(`fehlt:${fehlt}`, `„${von}“ braucht ${fehlt}`,
|
||||||
`Der Mod „${von}“ funktioniert nur zusammen mit „${fehlt}“ – und der fehlt in dieser Instanz.`,
|
`Der Mod „${von}“ funktioniert nur zusammen mit „${fehlt}“ – und der fehlt in dieser Instanz.`,
|
||||||
{ typ: 'mod-installieren', projekt: projektFuer(fehlt), name: fehlt });
|
{ typ: 'mod-installieren', projekt: projektFuer(fehlt), name: fehlt });
|
||||||
} else {
|
} else {
|
||||||
fund(`alt:${fehlt}`, `„${fehlt}“ ist zu alt oder zu neu`,
|
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“).`);
|
`„${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 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!"
|
// "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)) {
|
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 [, name, id, fuer] = m;
|
||||||
const mod = findeMod(mods, id, name);
|
const mod = findeMod(mods, id, name);
|
||||||
fund(`mcversion:${id}`, `„${name}“ passt nicht zu dieser Minecraft-Version`,
|
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.`,
|
`„${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);
|
mod ? { typ: 'mod-deaktivieren', dateiname: mod.filename, name } : null);
|
||||||
}
|
}
|
||||||
// Lösungsvorschlag: "- Remove mod 'X' (x)."
|
// Lösungsvorschlag: "- Remove mod 'X' (x)."
|
||||||
for (const m of t.matchAll(/^\s*-\s*Remove mod '([^']+)' \(([\w.-]+)\)/gim)) {
|
for (const m of t.matchAll(/^\s*-\s*Remove mod '([^']+)' \(([\w.-]+)\)/gim)) {
|
||||||
const [, name, id] = m;
|
const [, name, id] = m;
|
||||||
const mod = findeMod(mods, id, name);
|
const mod = findeMod(mods, id, name);
|
||||||
fund(`entfernen:${id}`, `„${name}“ verursacht den Fehler`,
|
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.`,
|
`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 ? { typ: 'mod-deaktivieren', dateiname: mod.filename, name } : null);
|
||||||
}
|
}
|
||||||
// "Mod 'A' (a) is incompatible with any version of mod 'B' (b)"
|
// "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)) {
|
for (const m of t.matchAll(/Mod '([^']+)' \(([\w.-]+)\)[^\n]*? is incompatible with [^\n]*?mod '([^']+)' \(([\w.-]+)\)/gi)) {
|
||||||
const [, a, idA, b] = m;
|
const [, a, idA, b] = m;
|
||||||
const mod = findeMod(mods, idA, a);
|
const mod = findeMod(mods, idA, a);
|
||||||
fund(`inkompatibel:${idA}`, `„${a}“ und „${b}“ vertragen sich nicht`,
|
fund(`inkompatibel:${idA}`, `„${a}“ und „${b}“ vertragen sich nicht`,
|
||||||
`Diese beiden Mods können nicht zusammen installiert sein. Schalte einen der beiden ab.`,
|
`Diese beiden Mods können nicht zusammen installiert sein. Schalte einen der beiden ab.`,
|
||||||
mod ? { typ: 'mod-deaktivieren', dateiname: mod.filename, name: a } : null);
|
mod ? { typ: 'mod-deaktivieren', dateiname: mod.filename, name: a } : null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Doppelte Mods ---
|
// --- 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)) {
|
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',
|
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.',
|
'Mindestens ein Mod liegt zweimal im Mods-Ordner (z. B. zwei Versionen desselben Mods). Lösche die ältere Datei.',
|
||||||
{ typ: 'mods-ordner' });
|
{ typ: 'mods-ordner' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Falsche Java-Version ---
|
// --- Falsche Java-Version ---
|
||||||
const klasse = t.match(/UnsupportedClassVersionError[\s\S]{0,300}?class file version (\d+)\.\d+/i);
|
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);
|
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) {
|
if (klasse || javaPflicht) {
|
||||||
const noetig = klasse ? javaAusKlassenversion(klasse[1]) : Number(javaPflicht[1] || javaPflicht[2] || 0);
|
const noetig = klasse ? javaAusKlassenversion(klasse[1]) : Number(javaPflicht[1] || javaPflicht[2] || 0);
|
||||||
fund('java', noetig ? `Diese Instanz braucht Java ${noetig}` : 'Falsche Java-Version',
|
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.`,
|
`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' });
|
{ typ: 'java-auto' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Zu wenig Arbeitsspeicher ---
|
// --- Alte Forge-Version verträgt aktuelles Java 8 nicht ---
|
||||||
if (/java\.lang\.OutOfMemoryError|There is insufficient memory for the Java Runtime/i.test(t)) {
|
// Forge 1.16.5 vor 36.2.26 (und ähnlich alte Fassungen) nutzt eine interne
|
||||||
const jetzt = Number(ctx.maxMemMb) || 2048;
|
// Java-Klasse, die es seit Java 8u321 so nicht mehr gibt.
|
||||||
const grenze = Math.max(2048, Math.floor((Number(ctx.gesamtMb) || 8192) * 0.6));
|
if (/sun\.security\.util\.ManifestEntryVerifier\.<init>/i.test(t) && /forge/i.test(String(ctx.loader || ''))) {
|
||||||
const neu = Math.min(grenze, jetzt + 2048);
|
fund('forge-alt', 'Diese Forge-Version ist zu alt für das aktuelle Java',
|
||||||
fund('ram', 'Minecraft hatte zu wenig Arbeitsspeicher',
|
'Ä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.',
|
||||||
`Der Instanz waren ${Math.round(jetzt / 102.4) / 10} GB zugewiesen – zu wenig für diese Mods oder Welt.`
|
{ typ: 'forge-aktualisieren' });
|
||||||
+ (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);
|
|
||||||
}
|
// --- Zu wenig Arbeitsspeicher ---
|
||||||
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)) {
|
if (/java\.lang\.OutOfMemoryError|There is insufficient memory for the Java Runtime/i.test(t)) {
|
||||||
const jetzt = Number(ctx.maxMemMb) || 4096;
|
const jetzt = Number(ctx.maxMemMb) || 2048;
|
||||||
const neu = Math.max(2048, Math.min(jetzt - 1024, Math.floor((Number(ctx.gesamtMb) || 8192) * 0.5)));
|
const grenze = Math.max(2048, Math.floor((Number(ctx.gesamtMb) || 8192) * 0.6));
|
||||||
fund('ram-zuviel', 'Zu viel Arbeitsspeicher eingestellt',
|
const neu = Math.min(grenze, jetzt + 2048);
|
||||||
`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.`,
|
fund('ram', 'Minecraft hatte zu wenig Arbeitsspeicher',
|
||||||
neu < jetzt ? { typ: 'ram', mb: neu } : { typ: 'java-auto' });
|
`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);
|
||||||
// --- 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)) {
|
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 id = m[1] || m[2] || m[3];
|
const jetzt = Number(ctx.maxMemMb) || 4096;
|
||||||
if (!id || KEINE_MODS.has(id.toLowerCase())) continue;
|
const neu = Math.max(2048, Math.min(jetzt - 1024, Math.floor((Number(ctx.gesamtMb) || 8192) * 0.5)));
|
||||||
const mod = findeMod(mods, id);
|
fund('ram-zuviel', 'Zu viel Arbeitsspeicher eingestellt',
|
||||||
fund(`mixin:${id}`, `„${mod ? (mod.title || id) : id}“ passt nicht zu dieser Version`,
|
`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.`,
|
||||||
`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.`,
|
neu < jetzt ? { typ: 'ram', mb: neu } : { typ: 'java-auto' });
|
||||||
mod ? { typ: 'mod-deaktivieren', dateiname: mod.filename, name: mod.title || id } : null);
|
}
|
||||||
}
|
|
||||||
|
// --- Mixin-Fehler: ein bestimmter Mod passt nicht ---
|
||||||
// --- Grafiktreiber ---
|
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)) {
|
||||||
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)) {
|
const id = m[1] || m[2] || m[3];
|
||||||
fund('treiber', 'Problem mit dem Grafiktreiber',
|
if (!id || KEINE_MODS.has(id.toLowerCase())) continue;
|
||||||
'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.');
|
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.`,
|
||||||
// --- Bekannte Unverträglichkeiten anhand der installierten Mods ---
|
mod ? { typ: 'mod-deaktivieren', dateiname: mod.filename, name: mod.title || id } : null);
|
||||||
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);
|
// --- Grafiktreiber ---
|
||||||
const sodium = hat(/sodium|embeddium|rubidium/i);
|
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)) {
|
||||||
if (optifine && sodium) {
|
fund('treiber', 'Problem mit dem Grafiktreiber',
|
||||||
fund('optifine-sodium', 'OptiFine und Sodium zusammen installiert',
|
'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.');
|
||||||
'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' });
|
|
||||||
}
|
// --- Bekannte Unverträglichkeiten anhand der installierten Mods ---
|
||||||
|
const aktive = mods.filter((m) => m.enabled !== false);
|
||||||
// --- Forge nennt einen Verdächtigen, aber sonst nichts Eindeutiges ---
|
const hat = (re) => aktive.find((m) => re.test(String(m.filename || '') + ' ' + String(m.title || '')));
|
||||||
if (!funde.length) {
|
const optifine = hat(/optifine|optifabric/i);
|
||||||
const verdacht = t.match(/^Suspected Mods?:\s*([^\n]+)$/im);
|
const sodium = hat(/sodium|embeddium|rubidium/i);
|
||||||
if (verdacht && !/^\s*NONE\s*$/i.test(verdacht[1])) {
|
if (optifine && sodium) {
|
||||||
const name = verdacht[1].split(/[,(]/)[0].trim();
|
fund('optifine-sodium', 'OptiFine und Sodium zusammen installiert',
|
||||||
const mod = findeMod(mods, '', name);
|
'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.',
|
||||||
fund('verdacht', `Vermutlich verursacht von „${name}“`,
|
{ typ: 'mod-deaktivieren', dateiname: optifine.filename, name: optifine.title || 'OptiFine' });
|
||||||
`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);
|
|
||||||
}
|
// --- Forge nennt einen Verdächtigen, aber sonst nichts Eindeutiges ---
|
||||||
}
|
if (!funde.length) {
|
||||||
|
const verdacht = t.match(/^Suspected Mods?:\s*([^\n]+)$/im);
|
||||||
return funde;
|
if (verdacht && !/^\s*NONE\s*$/i.test(verdacht[1])) {
|
||||||
}
|
const name = verdacht[1].split(/[,(]/)[0].trim();
|
||||||
|
const mod = findeMod(mods, '', name);
|
||||||
module.exports = { analysiere, projektFuer };
|
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 };
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
|
|
||||||
/* ===================== Design-Auswahl in den Optionen ===================== */
|
/* ===================== Design-Auswahl in den Optionen ===================== */
|
||||||
.design-raster{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}
|
.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))}
|
.settings-card .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))}}
|
@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{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: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)}
|
.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{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.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-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"]) .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}
|
: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"]) .mods-filters select,
|
||||||
:root:is([data-design="aero"],[data-design="lila"]) .mmc-bar 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"]) .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-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"]) .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}
|
:root:is([data-design="aero"],[data-design="lila"]) .search-wrap input{border-radius:999px}
|
||||||
@@ -145,6 +149,11 @@
|
|||||||
: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"]) 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"]) .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{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"]) .ms-spalte{background:var(--surface);border-radius:14px}
|
||||||
|
:root:is([data-design="aero"],[data-design="lila"]) .ms-kopf input{background:var(--input);border-radius:10px}
|
||||||
|
: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 */
|
/* 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}
|
: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 +199,7 @@
|
|||||||
:root:is([data-design="aero"],[data-design="lila"]) .snav:hover{background:var(--surface)}
|
: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"]) .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"]) .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"]) .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"]) .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}
|
: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 +207,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}
|
: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 */
|
/* 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{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{border-radius:8px}
|
||||||
:root:is([data-design="aero"],[data-design="lila"]) .ctx-item:hover{background:rgba(var(--p-akzent),.22)}
|
:root:is([data-design="aero"],[data-design="lila"]) .ctx-item:hover{background:rgba(var(--p-akzent),.22)}
|
||||||
@@ -267,6 +283,10 @@
|
|||||||
:root[data-design="minecraft"] .toolbar{
|
: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;
|
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}
|
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{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:hover{background:rgba(0,0,0,.35);border-color:#fff}
|
||||||
:root[data-design="minecraft"] .tbtn:active{background:rgba(0,0,0,.5)}
|
:root[data-design="minecraft"] .tbtn:active{background:rgba(0,0,0,.5)}
|
||||||
@@ -288,7 +308,7 @@
|
|||||||
:root[data-design="minecraft"] .mods-filters select,
|
:root[data-design="minecraft"] .mods-filters select,
|
||||||
:root[data-design="minecraft"] .mmc-bar select,
|
:root[data-design="minecraft"] .mmc-bar select,
|
||||||
:root[data-design="minecraft"] .clip-length 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"] .login-usercode,
|
||||||
:root[data-design="minecraft"] .upd-notes,
|
:root[data-design="minecraft"] .upd-notes,
|
||||||
:root[data-design="minecraft"] .se-farbzeile input[type=text],
|
:root[data-design="minecraft"] .se-farbzeile input[type=text],
|
||||||
@@ -308,6 +328,8 @@
|
|||||||
:root[data-design="minecraft"] .list{image-rendering:auto}
|
: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-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-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 .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"] .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}
|
:root[data-design="minecraft"] .card{border-radius:0}
|
||||||
@@ -317,6 +339,11 @@
|
|||||||
:root[data-design="minecraft"] .card.sel .card-name{background:none;color:#ffff7f;border-radius:0}
|
: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-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-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 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}
|
:root[data-design="minecraft"] .empty p{color:#d0d0d0}
|
||||||
|
|
||||||
@@ -330,6 +357,8 @@
|
|||||||
:root[data-design="minecraft"] .hr{background:#000;height:2px;margin:6px 0}
|
: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{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{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 */
|
/* Statusleiste */
|
||||||
:root[data-design="minecraft"] .statusbar{background:url(assets/design/schiefer.png) 0 0/64px 64px;image-rendering:pixelated;border-top:2px solid #000}
|
: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 +418,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 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-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"] .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-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"] .settings-kv-row,
|
||||||
:root[data-design="minecraft"] .about-brand{border-bottom-color:#1a1a1b}
|
:root[data-design="minecraft"] .about-brand{border-bottom-color:#1a1a1b}
|
||||||
@@ -397,6 +430,12 @@
|
|||||||
:root[data-design="minecraft"] .spane h4{font-family:var(--pixel);font-size:16px;font-weight:400;text-shadow:var(--mc-schatten)}
|
:root[data-design="minecraft"] .spane h4{font-family:var(--pixel);font-size:16px;font-weight:400;text-shadow:var(--mc-schatten)}
|
||||||
|
|
||||||
/* Kontextmenü, Hinweis, Bildlaufleisten */
|
/* 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{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{border-radius:0;text-shadow:1px 1px 0 #000}
|
||||||
:root[data-design="minecraft"] .ctx-item:hover{background:#3c8527}
|
:root[data-design="minecraft"] .ctx-item:hover{background:#3c8527}
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
<div class="titelleiste">
|
<div class="titelleiste">
|
||||||
<img src="assets/logo.png" alt="" class="titel-logo" />
|
<img src="assets/logo.png" alt="" class="titel-logo" />
|
||||||
<span class="titel-text">AeroMC</span>
|
<span class="titel-text">AeroMC</span>
|
||||||
|
<span id="titel-beta" class="titel-beta hidden"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Obere Werkzeugleiste (MultiMC-Stil: Icon + Text) -->
|
<!-- Obere Werkzeugleiste (MultiMC-Stil: Icon + Text) -->
|
||||||
@@ -31,13 +32,15 @@
|
|||||||
<button id="btn-search" class="tbtn tb-lupe" data-ic="search" title="Instanzen suchen (Strg+F)"></button>
|
<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" />
|
<input id="search" type="text" placeholder="Suchen… (Esc schließt)" autocomplete="off" title="Instanzen nach Name, Version oder Gruppe filtern" />
|
||||||
</div>
|
</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>
|
<button id="btn-settings" class="tbtn" data-ic="settings" title="Einstellungen">Optionen</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Arbeitsbereich: Instanzliste + rechte Aktionsleiste -->
|
<!-- Arbeitsbereich: Instanzliste + rechte Aktionsleiste -->
|
||||||
<main class="workspace">
|
<main class="workspace">
|
||||||
<div class="list-area">
|
<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>
|
<div id="grid" class="list"></div>
|
||||||
<section id="empty" class="empty hidden">
|
<section id="empty" class="empty hidden">
|
||||||
<h2>Noch keine Instanzen</h2>
|
<h2>Noch keine Instanzen</h2>
|
||||||
@@ -52,8 +55,15 @@
|
|||||||
<div id="p-icon" class="side-ico">🌱</div>
|
<div id="p-icon" class="side-ico">🌱</div>
|
||||||
<div id="p-name" class="side-title">Instanz</div>
|
<div id="p-name" class="side-title">Instanz</div>
|
||||||
<div id="p-groupline" class="side-grp">Gruppe ändern</div>
|
<div id="p-groupline" class="side-grp">Gruppe ändern</div>
|
||||||
|
<div id="p-gesperrt" class="side-gesperrt hidden">
|
||||||
|
<div class="side-gesperrt-titel"><img src="assets/verbot.svg" alt="" /> Instanz gesperrt</div>
|
||||||
|
<div class="side-gesperrt-text">Diese Instanz wurde außerhalb des Launchers verändert und kann nicht mehr gestartet werden.</div>
|
||||||
|
<ul id="p-gesperrt-gruende" class="side-gesperrt-gruende"></ul>
|
||||||
|
<button id="p-wiederherstellen" class="sbtn play">Aus Sicherung wiederherstellen</button>
|
||||||
|
<div class="side-gesperrt-text dim">Die Wiederherstellung wird als neue Instanz angelegt. Die gesperrte bleibt erhalten, bis du sie selbst löschst.</div>
|
||||||
|
</div>
|
||||||
<div class="hr"></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>
|
<button id="p-offline" class="sbtn">Offline starten</button>
|
||||||
<div class="hr"></div>
|
<div class="hr"></div>
|
||||||
<button id="p-edit" class="sbtn">Instanz bearbeiten</button>
|
<button id="p-edit" class="sbtn">Instanz bearbeiten</button>
|
||||||
@@ -106,6 +116,7 @@
|
|||||||
<span>Gruppe <small class="opt">(optional)</small></span>
|
<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" />
|
<input id="f-group" type="text" list="group-list" placeholder="z. B. Modpacks, Server, Tests" autocomplete="off" />
|
||||||
<datalist id="group-list"></datalist>
|
<datalist id="group-list"></datalist>
|
||||||
|
<small class="hint">Untergruppe: mit „/“ trennen, z. B. „Modpacks/Survival“.</small>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="field-row">
|
<div class="field-row">
|
||||||
@@ -244,87 +255,66 @@
|
|||||||
<button class="snav on" data-pane="pane-general">Allgemein</button>
|
<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-appearance">Darstellung</button>
|
||||||
<button class="snav" data-pane="pane-minecraft">Spiel</button>
|
<button class="snav" data-pane="pane-minecraft">Spiel</button>
|
||||||
<button class="snav" data-pane="pane-start">Start & Java</button>
|
<button class="snav" data-pane="pane-start">Java & Erweitert</button>
|
||||||
|
<button class="snav" data-pane="pane-online">Freunde & Umhänge</button>
|
||||||
<button class="snav" data-pane="pane-network">Netzwerk</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-accounts">Konten</button>
|
||||||
<button class="snav" data-pane="pane-backup">Backup</button>
|
<button class="snav" data-pane="pane-backup">Backup</button>
|
||||||
<button class="snav" data-pane="pane-info">Info</button>
|
<button class="snav" data-pane="pane-info">Info</button>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="settings-content">
|
<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 id="pane-general" class="spane">
|
||||||
<div class="settings-hero">
|
<div class="settings-hero">
|
||||||
<h4>Allgemein</h4>
|
<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>
|
||||||
<div class="settings-grid">
|
<div class="settings-grid">
|
||||||
<section class="settings-card">
|
<section class="settings-card">
|
||||||
<h5>Instanzliste</h5>
|
<h5>Launcher</h5>
|
||||||
<label class="field">
|
<div class="s-zeile">
|
||||||
<span>Instanzen sortieren</span>
|
<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>
|
||||||
<select id="s-sortby">
|
<label class="schalter"><input id="s-autostart" type="checkbox" /><i></i></label>
|
||||||
<option value="lastPlayed">Nach letztem Start</option>
|
</div>
|
||||||
<option value="name">Nach Name</option>
|
<div class="s-zeile">
|
||||||
</select>
|
<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>
|
<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>
|
||||||
|
<div class="s-zeile">
|
||||||
|
<div class="s-text"><b>An der Beta teilnehmen</b><small>Du bekommst neue Versionen früher zum Ausprobieren. Betas können noch Fehler enthalten. Wieder ausgeschaltet, bekommst du ab der nächsten fertigen Version nur noch fertige Versionen.</small></div>
|
||||||
|
<label class="schalter"><input id="s-beta" type="checkbox" /><i></i></label>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="settings-card">
|
||||||
<section class="settings-card settings-card-wide">
|
|
||||||
<h5>Instanzen</h5>
|
<h5>Instanzen</h5>
|
||||||
<label class="field">
|
<div class="s-zeile s-voll">
|
||||||
<span>Instanz-Verzeichnis</span>
|
<div class="s-text"><b>Instanz-Verzeichnis</b><small>Jede Instanz liegt hier als Unterordner mit ihrem Namen.</small></div>
|
||||||
<div class="path-row">
|
<div class="path-row">
|
||||||
<input id="s-instances-dir" type="text" readonly />
|
<input id="s-instances-dir" type="text" readonly />
|
||||||
<button id="s-pick-dir" class="btn">Wählen…</button>
|
<button id="s-pick-dir" class="btn">Wählen…</button>
|
||||||
</div>
|
</div>
|
||||||
<small class="hint">Hier werden alle Instanzen als Unterordner mit ihrem Namen gespeichert.</small>
|
</div>
|
||||||
</label>
|
<div class="s-zeile">
|
||||||
</section>
|
<div class="s-text"><b>Sortierung</b><small>Reihenfolge der Instanzen in der Übersicht.</small></div>
|
||||||
|
<select id="s-sortby" class="s-wert">
|
||||||
<section class="settings-card">
|
<option value="lastPlayed">Nach letztem Start</option>
|
||||||
<h5>Bereiche</h5>
|
<option value="name">Nach Name</option>
|
||||||
<label class="checkbox-field">
|
</select>
|
||||||
<input id="s-bedrock" type="checkbox" /> <span>Bedrock-Bereich anzeigen</span>
|
</div>
|
||||||
</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>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="pane-appearance" class="spane hidden">
|
<div id="pane-appearance" class="spane hidden">
|
||||||
<div class="settings-hero">
|
<div class="settings-hero">
|
||||||
<h4>Darstellung</h4>
|
<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>
|
||||||
<div class="settings-grid">
|
<div class="settings-grid">
|
||||||
<section class="settings-card settings-card-wide">
|
<section class="settings-card">
|
||||||
<h5>Design</h5>
|
<h5>Design</h5>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<select id="s-theme" class="hidden">
|
<select id="s-theme" class="hidden">
|
||||||
@@ -354,119 +344,132 @@
|
|||||||
</div>
|
</div>
|
||||||
<small class="hint">Ein Klick zeigt das Design sofort an – mit „Speichern“ gilt es in allen Fenstern.</small>
|
<small class="hint">Ein Klick zeigt das Design sofort an – mit „Speichern“ gilt es in allen Fenstern.</small>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="settings-card">
|
<section class="settings-card">
|
||||||
<h5>Icons</h5>
|
<h5>Bewegung & Effekte</h5>
|
||||||
<label class="checkbox-field">
|
<div class="s-zeile">
|
||||||
<input id="s-iso3d" type="checkbox" /> <span>Block-Symbole räumlich darstellen (3D)</span>
|
<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>
|
<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>
|
||||||
|
<section class="settings-card">
|
||||||
<section class="settings-card settings-card-wide">
|
|
||||||
<h5>Skin-Hintergrund</h5>
|
<h5>Skin-Hintergrund</h5>
|
||||||
<label class="field">
|
<div class="s-zeile">
|
||||||
<span>Quelle <small class="opt">(optional)</small></span>
|
<div class="s-text"><b>Quelle</b><small>Zeigt den Skin eines Kontos groß im Instanzbereich.</small></div>
|
||||||
<select id="s-bgskin"><option value="">Aus</option></select>
|
<select id="s-bgskin" class="s-wert"><option value="">Aus</option></select>
|
||||||
<small class="hint">Zeigt den Minecraft-Skin des gewählten Kontos als Hintergrund im Instanzbereich.</small>
|
</div>
|
||||||
</label>
|
<div class="s-zeile">
|
||||||
<div class="field-row">
|
<div class="s-text"><b>Pose</b></div>
|
||||||
<label class="field">
|
<select id="s-bgskin-pose" class="s-wert">
|
||||||
<span>Pose</span>
|
<option value="walking">Laufend</option>
|
||||||
<select id="s-bgskin-pose">
|
<option value="standing">Stehend</option>
|
||||||
<option value="walking">Laufend</option>
|
<option value="legends">Heldenhaft</option>
|
||||||
<option value="standing">Stehend</option>
|
<option value="bedwars">Kampfhaltung</option>
|
||||||
<option value="legends">Heldenhaft</option>
|
<option value="hoverboard">Schwebend</option>
|
||||||
<option value="bedwars">Kampfhaltung</option>
|
<option value="sitting">Sitzend</option>
|
||||||
<option value="hoverboard">Schwebend</option>
|
<option value="spooky">Unheimlich</option>
|
||||||
<option value="sitting">Sitzend</option>
|
</select>
|
||||||
<option value="spooky">Unheimlich</option>
|
</div>
|
||||||
</select>
|
<div class="s-zeile">
|
||||||
</label>
|
<div class="s-text"><b>Armform</b><small>„Automatisch“ übernimmt die Einstellung des Kontos.</small></div>
|
||||||
<label class="field">
|
<select id="s-bgskin-model" class="s-wert">
|
||||||
<span>Armform</span>
|
<option value="auto">Automatisch (vom Konto)</option>
|
||||||
<select id="s-bgskin-model" title="Bei „Automatisch“ wird die Armform übernommen, die beim Konto eingestellt ist">
|
<option value="classic">Normal (breite Arme, Steve)</option>
|
||||||
<option value="auto">Automatisch (vom Konto)</option>
|
<option value="slim">Schmal (Alex)</option>
|
||||||
<option value="classic">Normal (breite Arme, Steve)</option>
|
</select>
|
||||||
<option value="slim">Schmal (Alex)</option>
|
</div>
|
||||||
</select>
|
<div class="s-zeile">
|
||||||
</label>
|
<div class="s-text"><b>Deckkraft</b></div>
|
||||||
<label class="field">
|
<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>
|
||||||
<span>Deckkraft: <span id="s-bgskin-val">55</span> %</span>
|
</div>
|
||||||
<input id="s-bgskin-opacity" type="range" min="10" max="100" step="5" />
|
<div class="s-zeile">
|
||||||
</label>
|
<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>
|
</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>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="pane-minecraft" class="spane hidden">
|
<div id="pane-minecraft" class="spane hidden">
|
||||||
<div class="settings-hero">
|
<div class="settings-hero">
|
||||||
<h4>Spiel</h4>
|
<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>
|
||||||
<div class="settings-grid">
|
<div class="settings-grid">
|
||||||
<section class="settings-card">
|
<section class="settings-card">
|
||||||
<h5>Speicher</h5>
|
<h5>Leistung & Fenster</h5>
|
||||||
<div class="field-row">
|
<div class="s-zeile">
|
||||||
<label class="field">
|
<div class="s-text"><b>Arbeitsspeicher</b><small>Mindestens / höchstens, in MB.</small></div>
|
||||||
<span>Standard-RAM min. (MB)</span>
|
<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>
|
||||||
<input id="s-min-mem" type="number" min="512" step="256" />
|
</div>
|
||||||
</label>
|
<div class="s-zeile">
|
||||||
<label class="field">
|
<div class="s-text"><b>Maximiert starten</b><small>Minecraft öffnet sich bildschirmfüllend.</small></div>
|
||||||
<span>Standard-RAM max. (MB)</span>
|
<label class="schalter"><input id="s-mc-max" type="checkbox" /><i></i></label>
|
||||||
<input id="s-max-mem" type="number" min="512" step="256" />
|
</div>
|
||||||
</label>
|
<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>
|
</div>
|
||||||
<small class="hint">Gilt für alle Instanzen ohne eigene RAM-Einstellung.</small>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="settings-card">
|
<section class="settings-card">
|
||||||
<h5>Fenster</h5>
|
<h5>AeroMC-Look</h5>
|
||||||
<label class="checkbox-field">
|
<div class="s-zeile">
|
||||||
<input id="s-mc-max" type="checkbox" /> <span>Minecraft maximiert starten</span>
|
<div class="s-text"><b>AeroMC-Look im Spiel</b><small>Ladebildschirm und Hauptmenü im AeroMC-Look: Landschaftsbild, großes AeroMC-Logo, Knöpfe mit Symbolen und eigene Sprüche, dazu Fenstertitel und -symbol. Ab Minecraft 1.14, gilt ab dem nächsten Start – eigene Ressourcenpakete haben Vorrang.</small></div>
|
||||||
</label>
|
<label class="schalter"><input id="s-aeromc-design" type="checkbox" /><i></i></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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="settings-card">
|
<section class="settings-card">
|
||||||
<h5>Mods</h5>
|
<h5>Mods</h5>
|
||||||
<label class="checkbox-field">
|
<div class="s-zeile">
|
||||||
<input id="s-auto-mod-updates" type="checkbox" /> <span>Vor jedem Start nach Mod-Updates suchen und installieren</span>
|
<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>
|
<label class="schalter"><input id="s-auto-mod-updates" type="checkbox" /><i></i></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>
|
||||||
|
<div class="s-zeile">
|
||||||
|
<div class="s-text"><b>Mods im Spiel verwalten</b><small>Im Pausemenü wird „Feedback geben“ zu „Mods verwalten“: Mods per Klick zwischen „Aktiv“ und „Deaktiviert“ verschieben. Das Spiel läuft weiter, die Änderung gilt ab dem nächsten Start (oder direkt mit „Übernehmen & neu starten“). Nur Instanzen mit Mod-Loader.</small></div>
|
||||||
|
<label class="schalter"><input id="s-mod-schalter" type="checkbox" /><i></i></label>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="settings-card">
|
<section class="settings-card">
|
||||||
<h5>Umhänge</h5>
|
<h5>Konsole</h5>
|
||||||
<label class="checkbox-field">
|
<div class="s-zeile">
|
||||||
<input id="s-aeromc-capes" type="checkbox" /> <span>AeroMC-Umhänge im Spiel anzeigen</span>
|
<div class="s-text"><b>Während des Spiels anzeigen</b></div>
|
||||||
</label>
|
<label class="schalter"><input id="s-console-launch" type="checkbox" /><i></i></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>
|
</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>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="pane-start" class="spane hidden">
|
<div id="pane-start" class="spane hidden">
|
||||||
<div class="settings-hero">
|
<div class="settings-hero">
|
||||||
<h4>Start & Java</h4>
|
<h4>Java & Erweitert</h4>
|
||||||
<p>Runtime-Auswahl, Startverhalten und globale Befehle für alle Instanzen.</p>
|
<p>Welches Java startet und was zusätzlich vor oder nach dem Spiel läuft.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-grid settings-grid-stack">
|
<div class="settings-grid">
|
||||||
<section class="settings-card settings-card-wide">
|
<section class="settings-card">
|
||||||
<h5>Java</h5>
|
<h5>Java</h5>
|
||||||
<label class="field">
|
<div class="s-zeile s-voll">
|
||||||
<span>Java (java.exe)</span>
|
<div class="s-text"><b>Java-Programm</b><small id="s-java-status">–</small></div>
|
||||||
<div class="path-row">
|
<div class="path-row">
|
||||||
<input id="s-java" type="text" placeholder="leer = java aus PATH" />
|
<input id="s-java" type="text" placeholder="leer = java aus PATH" />
|
||||||
<button id="s-pick-java" class="btn">Wählen…</button>
|
<button id="s-pick-java" class="btn">Wählen…</button>
|
||||||
@@ -475,102 +478,100 @@
|
|||||||
<select id="s-java-detected"><option value="">– gefundene Java-Installationen –</option></select>
|
<select id="s-java-detected"><option value="">– gefundene Java-Installationen –</option></select>
|
||||||
<button id="s-scan-java" class="btn">Erkennen</button>
|
<button id="s-scan-java" class="btn">Erkennen</button>
|
||||||
</div>
|
</div>
|
||||||
<small id="s-java-status" class="hint">–</small>
|
</div>
|
||||||
</label>
|
<div class="s-zeile">
|
||||||
<label class="checkbox-field">
|
<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>
|
||||||
<input id="s-auto-java" type="checkbox" /> <span>Fehlende Java-Version automatisch herunterladen</span>
|
<label class="schalter"><input id="s-auto-java" type="checkbox" /><i></i></label>
|
||||||
</label>
|
</div>
|
||||||
<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>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="settings-card">
|
<section class="settings-card">
|
||||||
<h5>Konsole</h5>
|
<h5>Befehle</h5>
|
||||||
<label class="checkbox-field">
|
<small class="hint s-vorab">Für Fortgeschrittene. Befehle aus dem Bearbeiten-Dialog einer Instanz haben Vorrang.</small>
|
||||||
<input id="s-console-launch" type="checkbox" /> <span>Konsole anzeigen, während das Spiel läuft</span>
|
<div class="s-zeile">
|
||||||
</label>
|
<div class="s-text"><b>Vor dem Start</b></div>
|
||||||
<label class="checkbox-field">
|
<input id="s-global-pre" class="s-wert s-breit" type="text" placeholder="läuft vor jedem Spielstart" />
|
||||||
<input id="s-console-autoclose" type="checkbox" /> <span>Konsole automatisch schließen, nachdem das Spiel beendet wurde</span>
|
</div>
|
||||||
</label>
|
<div class="s-zeile">
|
||||||
<label class="checkbox-field">
|
<div class="s-text"><b>Nach dem Beenden</b></div>
|
||||||
<input id="s-console-crash" type="checkbox" /> <span>Konsole anzeigen, wenn das Spiel abstürzt</span>
|
<input id="s-global-post" class="s-wert s-breit" type="text" placeholder="läuft nach jedem Beenden" />
|
||||||
</label>
|
</div>
|
||||||
<label class="field">
|
<div class="s-zeile">
|
||||||
<span>Historie-Limit (Zeilen)</span>
|
<div class="s-text"><b>Wrapper</b><small>Startet Java über dieses Programm.</small></div>
|
||||||
<input id="s-console-lines" type="number" min="1000" step="1000" />
|
<input id="s-wrapper" class="s-wert s-breit" type="text" placeholder="z. B. prime-run" />
|
||||||
</label>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="pane-online" class="spane hidden">
|
||||||
|
<div class="settings-hero">
|
||||||
|
<h4>Freunde & Umhänge</h4>
|
||||||
|
<p>Was andere AeroMC-Spieler von dir sehen.</p>
|
||||||
|
</div>
|
||||||
|
<div class="settings-grid">
|
||||||
<section class="settings-card">
|
<section class="settings-card">
|
||||||
<h5>Globale Befehle</h5>
|
<h5>Freundesystem</h5>
|
||||||
<label class="field">
|
<div class="s-zeile">
|
||||||
<span>Pre-Launch-Befehl</span>
|
<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>
|
||||||
<input id="s-global-pre" type="text" placeholder="läuft vor jedem Spielstart" />
|
<label class="schalter"><input id="s-friends" type="checkbox" /><i></i></label>
|
||||||
</label>
|
</div>
|
||||||
<label class="field">
|
<div class="s-zeile">
|
||||||
<span>Post-Exit-Befehl</span>
|
<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>
|
||||||
<input id="s-global-post" type="text" placeholder="läuft nach jedem Beenden" />
|
<label class="schalter"><input id="s-friends-server" type="checkbox" /><i></i></label>
|
||||||
</label>
|
</div>
|
||||||
<small class="hint">Instanzspezifische Befehle aus dem Bearbeiten-Dialog haben Vorrang.</small>
|
|
||||||
</section>
|
</section>
|
||||||
|
<section class="settings-card">
|
||||||
<section class="settings-card settings-card-wide">
|
<h5>Umhänge</h5>
|
||||||
<h5>Externe Programme</h5>
|
<div class="s-zeile">
|
||||||
<label class="field">
|
<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>
|
||||||
<span>Wrapper-Befehl</span>
|
<label class="schalter"><input id="s-aeromc-capes" type="checkbox" /><i></i></label>
|
||||||
<input id="s-wrapper" type="text" placeholder="z. B. prime-run (leer = kein Wrapper)" />
|
</div>
|
||||||
</label>
|
|
||||||
<small class="hint">Startet Java über dieses Programm. Java-Pfad und Argumente werden automatisch angehängt.</small>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="pane-network" class="spane hidden">
|
<div id="pane-network" class="spane hidden">
|
||||||
<div class="settings-hero">
|
<div class="settings-hero">
|
||||||
<h4>Netzwerk</h4>
|
<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>
|
||||||
<div class="settings-grid">
|
<div class="settings-grid">
|
||||||
<section class="settings-card settings-card-wide">
|
<section class="settings-card">
|
||||||
<h5>Proxy</h5>
|
<h5>Proxy</h5>
|
||||||
<label class="checkbox-field">
|
<div class="s-zeile">
|
||||||
<input id="s-proxy-enabled" type="checkbox" /> <span>HTTP-Proxy für Downloads verwenden</span>
|
<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>
|
<label class="schalter"><input id="s-proxy-enabled" type="checkbox" /><i></i></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>
|
</div>
|
||||||
<div class="field-row">
|
<div class="s-felder">
|
||||||
<label class="field"><span>Benutzer <small class="opt">(optional)</small></span><input id="s-proxy-user" type="text" /></label>
|
<div class="field-row">
|
||||||
<label class="field"><span>Passwort <small class="opt">(optional)</small></span><input id="s-proxy-pass" type="password" /></label>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="settings-card">
|
||||||
<section class="settings-card settings-card-wide">
|
|
||||||
<h5>CurseForge</h5>
|
<h5>CurseForge</h5>
|
||||||
<label class="field">
|
<small class="hint s-vorab">Nur für CurseForge-Modpacks und -Mods nötig. Modrinth funktioniert immer ohne.</small>
|
||||||
<span>Eigener API-Key <small class="opt">(optional)</small></span>
|
<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">
|
<div class="path-row">
|
||||||
<input id="s-cf-key" type="password" placeholder="leer lassen, um die Weiterleitung unten zu nutzen" />
|
<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>
|
<button type="button" id="s-cf-test" class="btn">Prüfen</button>
|
||||||
</div>
|
</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>
|
<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>
|
</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>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="pane-accounts" class="spane hidden">
|
<div id="pane-accounts" class="spane hidden">
|
||||||
<div class="settings-hero">
|
<div class="settings-hero">
|
||||||
<h4>Konten</h4>
|
<h4>Konten</h4>
|
||||||
@@ -591,66 +592,58 @@
|
|||||||
<div id="pane-backup" class="spane hidden">
|
<div id="pane-backup" class="spane hidden">
|
||||||
<div class="settings-hero">
|
<div class="settings-hero">
|
||||||
<h4>Backup</h4>
|
<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>
|
||||||
<div class="settings-grid">
|
<div class="settings-grid">
|
||||||
<section class="settings-card settings-card-wide">
|
<section class="settings-card">
|
||||||
<h5>Globales Backup</h5>
|
<h5>Jetzt sichern</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="s-zeile">
|
||||||
<div class="path-row" style="margin-top:14px">
|
<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>
|
<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>
|
</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 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 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 id="s-backup-progress-text" class="backup-progress-text hint">0 %</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section class="settings-card">
|
||||||
<h5>Welten</h5>
|
<h5>Automatisch sichern</h5>
|
||||||
<label class="checkbox-field">
|
<div class="s-zeile">
|
||||||
<input id="s-auto-world" type="checkbox" /> <span>Nach jeder Spielsitzung die zuletzt gespielte Welt sichern</span>
|
<div class="s-text"><b>Nach Zeitplan sichern</b><small id="s-autobackup-last">Noch keine automatische Sicherung.</small></div>
|
||||||
</label>
|
<label class="schalter"><input id="s-autobackup" type="checkbox" /><i></i></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\<Instanz>\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>
|
|
||||||
</div>
|
</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 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 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>
|
<div id="s-restore-progress-text" class="backup-progress-text hint">0 %</div>
|
||||||
@@ -658,7 +651,6 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="pane-info" class="spane hidden">
|
<div id="pane-info" class="spane hidden">
|
||||||
<div class="settings-hero">
|
<div class="settings-hero">
|
||||||
<h4>Info</h4>
|
<h4>Info</h4>
|
||||||
@@ -688,6 +680,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ===================== Mod-Schalter (über dem laufenden Spiel) ===================== -->
|
||||||
|
<div id="modal-modschalter" class="modal hidden">
|
||||||
|
<div class="modal-card modal-lg">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3>Mods verwalten <span id="ms-instanz" class="ms-instanz"></span></h3>
|
||||||
|
<button class="modal-close" id="ms-x" title="Zurück ins Spiel">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body ms-body">
|
||||||
|
<div class="ms-kopf">
|
||||||
|
<input id="ms-suche" type="search" placeholder="Mods durchsuchen …" autocomplete="off" />
|
||||||
|
<span id="ms-hinweis" class="hint"></span>
|
||||||
|
</div>
|
||||||
|
<div class="ms-spalten">
|
||||||
|
<section class="ms-spalte">
|
||||||
|
<div class="ms-titel">Aktive Mods <span id="ms-zahl-an" class="group-count"></span></div>
|
||||||
|
<div id="ms-an" class="ms-liste" role="list"></div>
|
||||||
|
</section>
|
||||||
|
<section class="ms-spalte">
|
||||||
|
<div class="ms-titel">Deaktivierte Mods <span id="ms-zahl-aus" class="group-count"></span></div>
|
||||||
|
<div id="ms-aus" class="ms-liste" role="list"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-foot">
|
||||||
|
<span id="ms-status" class="hint ms-status"></span>
|
||||||
|
<button class="btn" id="ms-fertig">Zurück ins Spiel</button>
|
||||||
|
<button class="btn btn-primary hidden" id="ms-neustart" title="Minecraft speichert, beendet sich und startet mit den neuen Mods direkt wieder – in derselben Welt bzw. auf demselben Server">Übernehmen & neu starten</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ===================== Dialog: Bestätigung ===================== -->
|
<!-- ===================== Dialog: Bestätigung ===================== -->
|
||||||
<div id="modal-confirm" class="modal hidden">
|
<div id="modal-confirm" class="modal hidden">
|
||||||
<div class="modal-card modal-sm">
|
<div class="modal-card modal-sm">
|
||||||
@@ -808,6 +831,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-foot">
|
<div class="modal-foot">
|
||||||
<button id="m-open-folder" class="btn" data-ic="folder">mods-Ordner</button>
|
<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>
|
<div class="spacer"></div>
|
||||||
<button class="btn" data-close>Schließen</button>
|
<button class="btn" data-close>Schließen</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1102,10 +1126,19 @@
|
|||||||
<button class="modal-close" data-close>✕</button>
|
<button class="modal-close" data-close>✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div id="stats-tiles" class="stats-tiles"></div>
|
<div class="mods-tabs stats-tabs">
|
||||||
<div class="friends-section-title">Spielzeit pro Instanz</div>
|
<button class="mtab on" data-stats-reiter="spielzeit">Spielzeit</button>
|
||||||
<div id="stats-list" class="stats-list" role="list"></div>
|
<button class="mtab" data-stats-reiter="erfolge">Erfolge <span id="erfolge-zahl"></span></button>
|
||||||
<p id="stats-unplayed" class="hint"></p>
|
</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>
|
||||||
<div class="modal-foot">
|
<div class="modal-foot">
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
@@ -1123,13 +1156,20 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<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>
|
<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 id="friends-content" class="freunde-raster">
|
||||||
<div class="field-row">
|
<div class="freunde-spalte">
|
||||||
<input id="friends-add-input" type="text" placeholder="Minecraft-Name…" maxlength="16" />
|
<div class="field-row">
|
||||||
<button id="friends-add-btn" class="btn btn-mini">Anfrage senden</button>
|
<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>
|
||||||
<div id="friends-requests-box"></div>
|
|
||||||
<div id="friends-list-box"></div>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- Unterhaltung mit einem Freund -->
|
<!-- Unterhaltung mit einem Freund -->
|
||||||
<div id="friends-chat" class="friends-chat hidden">
|
<div id="friends-chat" class="friends-chat hidden">
|
||||||
@@ -1229,6 +1269,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<p id="skin-konto" class="hint">–</p>
|
<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-vorschau">
|
||||||
<div class="skin-spalte">
|
<div class="skin-spalte">
|
||||||
<div class="skin-label">Aktuell</div>
|
<div class="skin-label">Aktuell</div>
|
||||||
@@ -1278,6 +1322,18 @@
|
|||||||
</div>
|
</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>
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
<div class="modal-foot">
|
<div class="modal-foot">
|
||||||
<button id="skin-reset" class="btn btn-danger">Auf Standard zurücksetzen</button>
|
<button id="skin-reset" class="btn btn-danger">Auf Standard zurücksetzen</button>
|
||||||
@@ -1548,6 +1604,16 @@
|
|||||||
<button id="import-tab-code" class="mtab" data-kind="code">Per Code</button>
|
<button id="import-tab-code" class="mtab" data-kind="code">Per Code</button>
|
||||||
</div>
|
</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">
|
<div id="import-pane-zip" class="import-pane">
|
||||||
<p class="import-copy">Importiert eine einzelne Instanz aus einem ZIP-Archiv.</p>
|
<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>
|
<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 +1894,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<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">Symbol ändern</button>
|
||||||
<button class="ctx-item" data-act="p-edit">Umbenennen</button>
|
<button class="ctx-item" data-act="p-edit">Umbenennen</button>
|
||||||
|
|||||||
@@ -0,0 +1,362 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* integritaet.js – Manipulationsschutz für Instanzen
|
||||||
|
* --------------------------------------------------
|
||||||
|
* Für jede Instanz hält der Launcher ein "Siegel" fest:
|
||||||
|
* - die Startdaten aus instance.json, mit denen sich Code ausführen ließe
|
||||||
|
* (Java-Pfad, JVM-Argumente, Befehle vor dem Start / nach dem Beenden)
|
||||||
|
* - den SHA-256 jeder Mod-Datei im mods-Ordner
|
||||||
|
* Das Siegel ist mit einem geheimen Schlüssel unterschrieben (HMAC), der per
|
||||||
|
* Windows-DPAPI verschlüsselt im Datenordner liegt.
|
||||||
|
*
|
||||||
|
* Als Manipulation gilt:
|
||||||
|
* - eine bekannte Mod-Datei hat bei gleichem Namen einen anderen Inhalt
|
||||||
|
* (so arbeitet Jar-infizierende Schadsoftware wie "Fractureiser")
|
||||||
|
* - die Startdaten wurden außerhalb des Launchers geändert
|
||||||
|
* - das Siegel selbst wurde verändert
|
||||||
|
* Keine Manipulation: neue, gelöschte, aktivierte oder deaktivierte Mods – das
|
||||||
|
* ist der normale Weg, Mods per Explorer hinzuzufügen oder zu entfernen.
|
||||||
|
*
|
||||||
|
* Eine manipulierte Instanz wird gesperrt: Sie bleibt erhalten, lässt sich aber
|
||||||
|
* nicht mehr starten, kopieren, exportieren oder teilen. Der Spieler kann sie
|
||||||
|
* aus einer sauberen Sicherung als neue Instanz wiederherstellen und muss die
|
||||||
|
* gesperrte selbst löschen.
|
||||||
|
*
|
||||||
|
* Ehrlich gesagt: Ein Schadprogramm, das gezielt AeroMC angreift und unter
|
||||||
|
* demselben Windows-Konto läuft, kann auch den DPAPI-Schlüssel benutzen. Gegen
|
||||||
|
* allgemeine Jar-Infektionen, fremde Tools und Hände am Dateisystem hilft es.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const ENC = 'enc:v1:';
|
||||||
|
const MOD_ENDUNGEN = /\.(jar|zip|litemod)(\.disabled)?$/i;
|
||||||
|
|
||||||
|
let datenOrdner = null;
|
||||||
|
let store = null;
|
||||||
|
let schluessel = null;
|
||||||
|
let zustand = { version: 1, instanzen: {} };
|
||||||
|
// Pfad -> { groesse, zeit, hash }: nur für die schnelle Prüfung beim Launcher-Start
|
||||||
|
let hashCache = new Map();
|
||||||
|
|
||||||
|
function zustandsDatei() { return path.join(datenOrdner, 'integritaet.json'); }
|
||||||
|
function schluesselDatei() { return path.join(datenOrdner, 'integritaet.key'); }
|
||||||
|
|
||||||
|
function init(userData, tresor, storeModul) {
|
||||||
|
datenOrdner = userData;
|
||||||
|
store = storeModul;
|
||||||
|
schluessel = ladeSchluessel(tresor);
|
||||||
|
if (!schluessel) return;
|
||||||
|
let roh = null;
|
||||||
|
try { roh = JSON.parse(fs.readFileSync(zustandsDatei(), 'utf8')); } catch { /* erster Start oder Datei fehlt */ }
|
||||||
|
zustand = { version: 1, instanzen: (roh && roh.instanzen && typeof roh.instanzen === 'object') ? roh.instanzen : {} };
|
||||||
|
// Siegel und Sperre sind getrennt unterschrieben: Wer nur die Sperre aus der
|
||||||
|
// Datei löscht, bekommt sie zurück – der gute Stand (für die Prüfung der
|
||||||
|
// Sicherungen) bleibt dabei gültig. Wer am Siegel selbst dreht, verliert ihn.
|
||||||
|
const jetzt = () => new Date().toISOString();
|
||||||
|
for (const [id, e] of Object.entries(zustand.instanzen)) {
|
||||||
|
if (!e || e.mac !== siegelMac(id, e)) {
|
||||||
|
zustand.instanzen[id] = versiegeltEintrag(id, {
|
||||||
|
felder: {}, dateien: {},
|
||||||
|
gesperrt: { seit: jetzt(), gruende: ['Das Siegel dieser Instanz wurde verändert.'] },
|
||||||
|
});
|
||||||
|
} else if (e.sperrMac !== sperrMac(id, e.gesperrt)) {
|
||||||
|
zustand.instanzen[id] = versiegeltEintrag(id, {
|
||||||
|
felder: e.felder, dateien: e.dateien,
|
||||||
|
gesperrt: (e.gesperrt && e.gesperrt.seit) ? e.gesperrt
|
||||||
|
: { seit: jetzt(), gruende: ['Die Sperre dieser Instanz wurde von Hand aufgehoben.'] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Liste aller versiegelten Instanzen: ein gelöschter Eintrag fällt auf
|
||||||
|
if (roh && Array.isArray(roh.ids) && roh.idsMac === listenMac(roh.ids)) {
|
||||||
|
for (const id of roh.ids) {
|
||||||
|
if (!zustand.instanzen[id]) {
|
||||||
|
zustand.instanzen[id] = versiegeltEintrag(id, {
|
||||||
|
felder: {}, dateien: {},
|
||||||
|
gesperrt: { seit: jetzt(), gruende: ['Das Siegel dieser Instanz wurde entfernt.'] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
speichere();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ladeSchluessel(tresor) {
|
||||||
|
const verfuegbar = (() => { try { return !!(tresor && tresor.available()); } catch { return false; } })();
|
||||||
|
if (fs.existsSync(schluesselDatei())) {
|
||||||
|
try {
|
||||||
|
const inhalt = fs.readFileSync(schluesselDatei(), 'utf8').trim();
|
||||||
|
const roh = inhalt.startsWith(ENC) ? tresor.decrypt(inhalt.slice(ENC.length)) : inhalt;
|
||||||
|
const s = Buffer.from(roh, 'hex');
|
||||||
|
if (s.length === 32) return s;
|
||||||
|
throw new Error('falsche Länge');
|
||||||
|
} catch (err) {
|
||||||
|
// Vorhanden, aber nicht lesbar (z. B. Datenordner von einem anderen PC
|
||||||
|
// kopiert). Nicht einfach neu anlegen – dann sähen ALLE Siegel verändert
|
||||||
|
// aus und jede Instanz wäre gesperrt. Schutz bleibt dann aus.
|
||||||
|
console.warn('Integritäts-Schlüssel nicht lesbar – Manipulationsschutz inaktiv:', err.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const neu = crypto.randomBytes(32);
|
||||||
|
const text = verfuegbar ? ENC + tresor.encrypt(neu.toString('hex')) : neu.toString('hex');
|
||||||
|
fs.mkdirSync(datenOrdner, { recursive: true });
|
||||||
|
fs.writeFileSync(schluesselDatei(), text);
|
||||||
|
return neu;
|
||||||
|
}
|
||||||
|
|
||||||
|
function speichere() {
|
||||||
|
const tmp = zustandsDatei() + '.tmp';
|
||||||
|
const ids = Object.keys(zustand.instanzen).sort();
|
||||||
|
fs.writeFileSync(tmp, JSON.stringify(Object.assign({}, zustand, { ids, idsMac: listenMac(ids) }), null, 1));
|
||||||
|
fs.renameSync(tmp, zustandsDatei());
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortiert(obj) {
|
||||||
|
return Object.keys(obj || {}).sort().map((k) => [k, obj[k]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hmac(daten) {
|
||||||
|
return crypto.createHmac('sha256', schluessel).update(JSON.stringify(daten)).digest('hex');
|
||||||
|
}
|
||||||
|
function siegelMac(id, e) { return hmac(['siegel', id, sortiert(e.felder), sortiert(e.dateien)]); }
|
||||||
|
function sperrMac(id, gesperrt) { return hmac(['sperre', id, gesperrt || null]); }
|
||||||
|
function listenMac(ids) { return hmac(['liste', ids]); }
|
||||||
|
|
||||||
|
function versiegeltEintrag(id, e) {
|
||||||
|
const eintrag = { felder: e.felder, dateien: e.dateien, gesperrt: e.gesperrt || null, stand: new Date().toISOString() };
|
||||||
|
eintrag.mac = siegelMac(id, eintrag);
|
||||||
|
eintrag.sperrMac = sperrMac(id, eintrag.gesperrt);
|
||||||
|
return eintrag;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die sicherheitsrelevanten Startdaten einer Instanz
|
||||||
|
function startDaten(inst) {
|
||||||
|
const j = (inst && inst.java) || {};
|
||||||
|
return {
|
||||||
|
javaPfad: String(j.path || ''),
|
||||||
|
jvmArgumente: String(j.extraArgs || ''),
|
||||||
|
vorStart: String((inst && inst.preLaunchCommand) || ''),
|
||||||
|
nachEnde: String((inst && inst.postExitCommand) || ''),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const FELD_NAMEN = {
|
||||||
|
javaPfad: 'Java-Pfad', jvmArgumente: 'JVM-Argumente', vorStart: 'Befehl vor dem Start', nachEnde: 'Befehl nach dem Beenden',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Alle Mod-Dateien (auch Unterordner wie mods/1.12.2/). Schlüssel ohne
|
||||||
|
// ".disabled" und in Kleinbuchstaben – Umschalten ist keine Veränderung.
|
||||||
|
function modDateien(gameDir) {
|
||||||
|
const basis = path.join(gameDir, 'mods');
|
||||||
|
const ergebnis = {};
|
||||||
|
const lauf = (ordner, tiefe) => {
|
||||||
|
let eintraege;
|
||||||
|
try { eintraege = fs.readdirSync(ordner, { withFileTypes: true }); } catch { return; }
|
||||||
|
for (const e of eintraege) {
|
||||||
|
const voll = path.join(ordner, e.name);
|
||||||
|
if (e.isDirectory()) { if (tiefe < 2) lauf(voll, tiefe + 1); continue; }
|
||||||
|
if (!e.isFile() || !MOD_ENDUNGEN.test(e.name)) continue;
|
||||||
|
const rel = path.relative(basis, voll).replace(/\\/g, '/');
|
||||||
|
ergebnis[rel.replace(/\.disabled$/i, '').toLowerCase()] = voll;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
lauf(basis, 0);
|
||||||
|
return ergebnis;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashDatei(datei) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const h = crypto.createHash('sha256');
|
||||||
|
fs.createReadStream(datei).on('error', reject).on('data', (d) => h.update(d)).on('end', () => resolve(h.digest('hex')));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// schnell=true: unveränderte Größe+Zeit -> Hash aus dem Zwischenspeicher
|
||||||
|
async function hashe(datei, schnell) {
|
||||||
|
const st = fs.statSync(datei);
|
||||||
|
const c = hashCache.get(datei);
|
||||||
|
if (schnell && c && c.groesse === st.size && c.zeit === st.mtimeMs) return c.hash;
|
||||||
|
const hash = await hashDatei(datei);
|
||||||
|
hashCache.set(datei, { groesse: st.size, zeit: st.mtimeMs, hash });
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function aktuellerStand(id, schnell) {
|
||||||
|
const inst = store.getInstance(id);
|
||||||
|
if (!inst) return null;
|
||||||
|
const dateien = {};
|
||||||
|
for (const [schl, voll] of Object.entries(modDateien(store.gameDir(id)))) {
|
||||||
|
try { dateien[schl] = await hashe(voll, schnell); } catch { /* gesperrt/weg -> beim nächsten Mal */ }
|
||||||
|
}
|
||||||
|
return { felder: startDaten(inst), dateien };
|
||||||
|
}
|
||||||
|
|
||||||
|
function status(id) {
|
||||||
|
const e = zustand.instanzen[id];
|
||||||
|
return e && e.gesperrt ? { gesperrt: true, seit: e.gesperrt.seit, gruende: e.gesperrt.gruende } : { gesperrt: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function istGesperrt(id) { return status(id).gesperrt; }
|
||||||
|
|
||||||
|
function sperre(id, gruende) {
|
||||||
|
if (!schluessel) return;
|
||||||
|
const e = zustand.instanzen[id] || { felder: {}, dateien: {} };
|
||||||
|
// das letzte gute Siegel bleibt stehen – daran wird eine Sicherung gemessen
|
||||||
|
zustand.instanzen[id] = versiegeltEintrag(id, {
|
||||||
|
felder: e.felder, dateien: e.dateien,
|
||||||
|
gesperrt: { seit: new Date().toISOString(), gruende },
|
||||||
|
});
|
||||||
|
speichere();
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Prüft eine Instanz gegen ihr Siegel.
|
||||||
|
* -> { status: 'ok' | 'neu' | 'gesperrt', gruende?, neuGesperrt? }
|
||||||
|
* Ohne Befund werden neue und entfernte Mods ins Siegel übernommen.
|
||||||
|
*/
|
||||||
|
async function pruefe(id, { schnell = false } = {}) {
|
||||||
|
if (!schluessel) return { status: 'ok' };
|
||||||
|
const vorhanden = zustand.instanzen[id];
|
||||||
|
if (vorhanden && vorhanden.gesperrt) return { status: 'gesperrt', gruende: vorhanden.gesperrt.gruende };
|
||||||
|
const jetzt = await aktuellerStand(id, schnell);
|
||||||
|
if (!jetzt) return { status: 'ok' };
|
||||||
|
if (!vorhanden) {
|
||||||
|
zustand.instanzen[id] = versiegeltEintrag(id, jetzt);
|
||||||
|
speichere();
|
||||||
|
return { status: 'neu' };
|
||||||
|
}
|
||||||
|
const gruende = [];
|
||||||
|
for (const [k, name] of Object.entries(FELD_NAMEN)) {
|
||||||
|
if ((vorhanden.felder[k] || '') !== jetzt.felder[k]) gruende.push(`${name} wurde außerhalb des Launchers geändert.`);
|
||||||
|
}
|
||||||
|
for (const [datei, hash] of Object.entries(jetzt.dateien)) {
|
||||||
|
if (vorhanden.dateien[datei] && vorhanden.dateien[datei] !== hash) gruende.push(`Mod-Datei verändert: ${datei}`);
|
||||||
|
}
|
||||||
|
if (gruende.length) {
|
||||||
|
sperre(id, gruende);
|
||||||
|
return { status: 'gesperrt', gruende, neuGesperrt: true };
|
||||||
|
}
|
||||||
|
const geaendert = JSON.stringify(sortiert(vorhanden.dateien)) !== JSON.stringify(sortiert(jetzt.dateien));
|
||||||
|
if (geaendert) {
|
||||||
|
zustand.instanzen[id] = versiegeltEintrag(id, jetzt);
|
||||||
|
speichere();
|
||||||
|
}
|
||||||
|
return { status: 'ok' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nach einer Änderung durch den Launcher selbst (Mod-Update, Bearbeiten …)
|
||||||
|
async function versiegele(id) {
|
||||||
|
if (!schluessel) return;
|
||||||
|
if (istGesperrt(id)) return;
|
||||||
|
const jetzt = await aktuellerStand(id, false);
|
||||||
|
if (!jetzt) return;
|
||||||
|
zustand.instanzen[id] = versiegeltEintrag(id, jetzt);
|
||||||
|
speichere();
|
||||||
|
}
|
||||||
|
|
||||||
|
function entferne(id) {
|
||||||
|
if (!schluessel) return;
|
||||||
|
if (!zustand.instanzen[id]) return;
|
||||||
|
delete zustand.instanzen[id];
|
||||||
|
speichere();
|
||||||
|
}
|
||||||
|
|
||||||
|
function alleGesperrten() {
|
||||||
|
const r = {};
|
||||||
|
for (const [id, e] of Object.entries(zustand.instanzen)) if (e.gesperrt) r[id] = { seit: e.gesperrt.seit, gruende: e.gesperrt.gruende };
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Das letzte gute Siegel (für die Prüfung einer Sicherung)
|
||||||
|
function gutesSiegel(id) {
|
||||||
|
const e = zustand.instanzen[id];
|
||||||
|
return e ? { felder: e.felder, dateien: e.dateien } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Wiederherstellen aus einer Sicherung (AeroMC-Backup-*.zip, siehe backup.js)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const unterPfad = (p) => String(p || '').replace(/\\/g, '/');
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Neueste Sicherung, in der die Instanz steckt UND deren Mods zum letzten
|
||||||
|
* guten Siegel passen. Eine Sicherung, die erst nach der Manipulation entstand,
|
||||||
|
* enthält die veränderte Datei und wird übersprungen.
|
||||||
|
* backupPfade: neueste zuerst
|
||||||
|
*/
|
||||||
|
async function sucheSaubereSicherung(id, backupPfade) {
|
||||||
|
const unzipper = require('unzipper');
|
||||||
|
const gut = gutesSiegel(id);
|
||||||
|
const praefix = `instances/${id}/`;
|
||||||
|
const uebersprungen = [];
|
||||||
|
for (const zip of backupPfade) {
|
||||||
|
let verzeichnis;
|
||||||
|
try { verzeichnis = await unzipper.Open.file(zip); } catch { continue; }
|
||||||
|
const eintraege = verzeichnis.files.filter((f) => f.type === 'File' && unterPfad(f.path).startsWith(praefix));
|
||||||
|
if (!eintraege.some((f) => unterPfad(f.path) === praefix + 'instance.json')) continue;
|
||||||
|
let sauber = true;
|
||||||
|
for (const f of eintraege) {
|
||||||
|
const m = unterPfad(f.path).slice(praefix.length).match(/^minecraft\/mods\/(.+)$/i);
|
||||||
|
if (!m || !MOD_ENDUNGEN.test(m[1])) continue;
|
||||||
|
const schl = m[1].replace(/\.disabled$/i, '').toLowerCase();
|
||||||
|
if (!gut || !gut.dateien[schl]) continue;
|
||||||
|
const hash = crypto.createHash('sha256').update(await f.buffer()).digest('hex');
|
||||||
|
if (hash !== gut.dateien[schl]) { sauber = false; break; }
|
||||||
|
}
|
||||||
|
if (sauber) return { zip, eintraege, praefix, uebersprungen };
|
||||||
|
uebersprungen.push(path.basename(zip));
|
||||||
|
}
|
||||||
|
return { zip: null, uebersprungen };
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Stellt die Instanz als NEUE Instanz "<Name> (wiederhergestellt)" wieder her.
|
||||||
|
* Die gesperrte bleibt unangetastet liegen.
|
||||||
|
*/
|
||||||
|
async function stelleWiederHer(id, backupPfade) {
|
||||||
|
const { pipeline } = require('stream/promises');
|
||||||
|
const fund = await sucheSaubereSicherung(id, backupPfade);
|
||||||
|
if (!fund.zip) {
|
||||||
|
throw new Error(fund.uebersprungen.length
|
||||||
|
? `Keine saubere Sicherung gefunden – ${fund.uebersprungen.length} Sicherung(en) enthalten bereits die veränderten Dateien.`
|
||||||
|
: 'Keine Sicherung mit dieser Instanz gefunden. Sicherungen lassen sich unter Optionen → Sicherung anlegen.');
|
||||||
|
}
|
||||||
|
const alt = store.getInstance(id);
|
||||||
|
const name = `${(alt && alt.name) || id} (wiederhergestellt)`;
|
||||||
|
const neuId = store.uniqueFolderId(name);
|
||||||
|
const ziel = store.instancePath(neuId);
|
||||||
|
fs.mkdirSync(ziel, { recursive: true });
|
||||||
|
for (const f of fund.eintraege) {
|
||||||
|
const rel = unterPfad(f.path).slice(fund.praefix.length);
|
||||||
|
const dest = path.join(ziel, ...rel.split('/'));
|
||||||
|
if (!dest.startsWith(ziel + path.sep)) continue; // nichts außerhalb des Ordners
|
||||||
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||||
|
await pipeline(f.stream(), fs.createWriteStream(dest));
|
||||||
|
}
|
||||||
|
const datei = path.join(ziel, 'instance.json');
|
||||||
|
const inst = JSON.parse(fs.readFileSync(datei, 'utf8'));
|
||||||
|
inst.id = neuId;
|
||||||
|
inst.name = name;
|
||||||
|
// Startdaten auf den letzten geprüften Stand setzen
|
||||||
|
const gut = gutesSiegel(id);
|
||||||
|
if (gut && gut.felder && Object.keys(gut.felder).length) {
|
||||||
|
inst.java = Object.assign({}, inst.java, { path: gut.felder.javaPfad || '', extraArgs: gut.felder.jvmArgumente || '' });
|
||||||
|
inst.preLaunchCommand = gut.felder.vorStart || '';
|
||||||
|
inst.postExitCommand = gut.felder.nachEnde || '';
|
||||||
|
}
|
||||||
|
fs.writeFileSync(datei, JSON.stringify(inst, null, 2));
|
||||||
|
await versiegele(neuId);
|
||||||
|
return { id: neuId, name, sicherung: path.basename(fund.zip), uebersprungen: fund.uebersprungen };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
init, pruefe, versiegele, sperre, status, istGesperrt, entferne, alleGesperrten, gutesSiegel,
|
||||||
|
sucheSaubereSicherung, stelleWiederHer,
|
||||||
|
startDaten, modDateien, hashDatei, FELD_NAMEN,
|
||||||
|
};
|
||||||
@@ -13,6 +13,14 @@
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const unzipper = require('unzipper');
|
const unzipper = require('unzipper');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
function sha256Datei(datei) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const h = crypto.createHash('sha256');
|
||||||
|
fs.createReadStream(datei).on('error', reject).on('data', (d) => h.update(d)).on('end', () => resolve(h.digest('hex')));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const API = 'https://api.adoptium.net/v3';
|
const API = 'https://api.adoptium.net/v3';
|
||||||
const UA = 'AeroMC-Launcher';
|
const UA = 'AeroMC-Launcher';
|
||||||
@@ -193,6 +201,11 @@ async function ensureJava(major, onProgress) {
|
|||||||
try {
|
try {
|
||||||
if (onProgress) onProgress({ phase: 'java-download', geladen: 0, gesamt: release.size, percent: 0 });
|
if (onProgress) onProgress({ phase: 'java-download', geladen: 0, gesamt: release.size, percent: 0 });
|
||||||
await ladeDatei(release.url, tmpZip, onProgress);
|
await ladeDatei(release.url, tmpZip, onProgress);
|
||||||
|
// Adoptium nennt zu jedem Paket die SHA-256 – ohne passende Prüfsumme wird
|
||||||
|
// keine Java-Laufzeit entpackt (sie würde später jedes Spiel ausführen)
|
||||||
|
const soll = String(release.sha256 || '').toLowerCase();
|
||||||
|
if (!/^[0-9a-f]{64}$/.test(soll)) throw new Error('Adoptium hat keine Prüfsumme für dieses Java-Paket geliefert – Download verworfen.');
|
||||||
|
if (await sha256Datei(tmpZip) !== soll) throw new Error('Prüfsumme des Java-Downloads stimmt nicht – Datei verworfen (beschädigt oder manipuliert).');
|
||||||
// in einen Nebenordner entpacken und erst am Ende umbenennen -> kein
|
// in einen Nebenordner entpacken und erst am Ende umbenennen -> kein
|
||||||
// halbfertiges Java, falls unterwegs etwas schiefgeht
|
// halbfertiges Java, falls unterwegs etwas schiefgeht
|
||||||
const tmpDir = ziel + '.teil';
|
const tmpDir = ziel + '.teil';
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -52,11 +52,20 @@ async function downloadIfNeeded(url, dest, sha1) {
|
|||||||
if (!sha1 || sha1File(dest) === sha1) return { skipped: true, bytes: 0 };
|
if (!sha1 || sha1File(dest) === sha1) return { skipped: true, bytes: 0 };
|
||||||
}
|
}
|
||||||
ensureDir(path.dirname(dest));
|
ensureDir(path.dirname(dest));
|
||||||
const res = await fetch(url, { headers: { 'User-Agent': UA } });
|
// Mojang (und die Loader) nennen zu jeder Datei die SHA-1 – geschrieben wird
|
||||||
if (!res.ok) throw new Error('Download HTTP ' + res.status + ': ' + url);
|
// nur, was dazu passt. Ein Fehlversuch wird einmal wiederholt (abgebrochene
|
||||||
const buf = Buffer.from(await res.arrayBuffer());
|
// Übertragung), dann gilt die Datei als beschädigt oder manipuliert.
|
||||||
fs.writeFileSync(dest, buf);
|
for (let versuch = 1; ; versuch++) {
|
||||||
return { skipped: false, bytes: buf.length };
|
const res = await fetch(url, { headers: { 'User-Agent': UA } });
|
||||||
|
if (!res.ok) throw new Error('Download HTTP ' + res.status + ': ' + url);
|
||||||
|
const buf = Buffer.from(await res.arrayBuffer());
|
||||||
|
if (sha1 && crypto.createHash('sha1').update(buf).digest('hex') !== String(sha1).toLowerCase()) {
|
||||||
|
if (versuch < 2) continue;
|
||||||
|
throw new Error('Prüfsumme stimmt nicht – Datei verworfen (beschädigt oder manipuliert): ' + path.basename(dest));
|
||||||
|
}
|
||||||
|
fs.writeFileSync(dest, buf);
|
||||||
|
return { skipped: false, bytes: buf.length };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// begrenzte Parallelität für viele kleine Downloads (Assets)
|
// begrenzte Parallelität für viele kleine Downloads (Assets)
|
||||||
@@ -254,8 +263,7 @@ async function prepareAssets(vj, sharedDir, onProgress) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function pickJava(vj, preferredPath) {
|
function pickJava(vj, preferredPath) {
|
||||||
const required = (vj.javaVersion && vj.javaVersion.majorVersion) || 8;
|
const required = (vj.javaVersion && vj.javaVersion.majorVersion) || 8; let preferred = null;
|
||||||
let preferred = null;
|
|
||||||
if (preferredPath && preferredPath.trim()) {
|
if (preferredPath && preferredPath.trim()) {
|
||||||
const info = services.probeJava(preferredPath);
|
const info = services.probeJava(preferredPath);
|
||||||
if (info) {
|
if (info) {
|
||||||
@@ -301,10 +309,18 @@ function collectArgs(argList, map) {
|
|||||||
// führt Vanilla-Version + Loader-Profil zusammen (mainClass + Argumente)
|
// führt Vanilla-Version + Loader-Profil zusammen (mainClass + Argumente)
|
||||||
function mergeLoader(vj, lp) {
|
function mergeLoader(vj, lp) {
|
||||||
const merged = Object.assign({}, vj, { mainClass: lp.mainClass || vj.mainClass });
|
const merged = Object.assign({}, vj, { mainClass: lp.mainClass || vj.mainClass });
|
||||||
merged.arguments = {
|
if (vj.arguments || lp.arguments) {
|
||||||
jvm: [...((vj.arguments && vj.arguments.jvm) || []), ...((lp.arguments && lp.arguments.jvm) || [])],
|
merged.arguments = {
|
||||||
game: [...((vj.arguments && vj.arguments.game) || []), ...((lp.arguments && lp.arguments.game) || [])],
|
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;
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -538,6 +554,10 @@ async function prepareAndLaunch(opts, onProgress) {
|
|||||||
else if (opts.mcWidth && opts.mcHeight) args.push('--width', String(opts.mcWidth), '--height', String(opts.mcHeight));
|
else if (opts.mcWidth && opts.mcHeight) args.push('--width', String(opts.mcWidth), '--height', String(opts.mcHeight));
|
||||||
// Direkt einem Server beitreten: ab 1.20 per Quick Play, davor über die
|
// Direkt einem Server beitreten: ab 1.20 per Quick Play, davor über die
|
||||||
// älteren Startparameter --server/--port (z. B. für "Beitreten" bei Freunden)
|
// älteren Startparameter --server/--port (z. B. für "Beitreten" bei Freunden)
|
||||||
|
// Direkt in eine Einzelspieler-Welt (ab 1.20, z. B. nach einem Neustart über den Mod-Schalter)
|
||||||
|
if (!opts.quickPlayServer && opts.quickPlayWelt && !vorQuickPlay(opts.versionId)) {
|
||||||
|
args.push('--quickPlaySingleplayer', String(opts.quickPlayWelt));
|
||||||
|
}
|
||||||
if (opts.quickPlayServer) {
|
if (opts.quickPlayServer) {
|
||||||
const adresse = String(opts.quickPlayServer).trim();
|
const adresse = String(opts.quickPlayServer).trim();
|
||||||
if (vorQuickPlay(opts.versionId)) {
|
if (vorQuickPlay(opts.versionId)) {
|
||||||
@@ -551,6 +571,14 @@ async function prepareAndLaunch(opts, onProgress) {
|
|||||||
|
|
||||||
ensureDir(opts.gameDir);
|
ensureDir(opts.gameDir);
|
||||||
|
|
||||||
|
// AeroMC-Look im Spiel (Ressourcenpaket, siehe spieldesign.js) – braucht das
|
||||||
|
// fertig geladene Client-JAR, aus dem die Original-Knöpfe umgefärbt werden
|
||||||
|
if (opts.vorStart) {
|
||||||
|
try {
|
||||||
|
await opts.vorStart({ gameDir: opts.gameDir, version: opts.versionId, clientJar }, (line) => emit('log', { line: line + '\n' }));
|
||||||
|
} catch { /* darf den Start nie verhindern */ }
|
||||||
|
}
|
||||||
|
|
||||||
// Pre-Launch-Befehl (bricht bei Fehler ab)
|
// Pre-Launch-Befehl (bricht bei Fehler ab)
|
||||||
if (opts.preLaunchCommand && opts.preLaunchCommand.trim()) {
|
if (opts.preLaunchCommand && opts.preLaunchCommand.trim()) {
|
||||||
emit('log', { line: '[Pre-Launch] ' + opts.preLaunchCommand + '\n' });
|
emit('log', { line: '[Pre-Launch] ' + opts.preLaunchCommand + '\n' });
|
||||||
|
|||||||
@@ -1,103 +1,117 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* loaders.js – Mod-Loader-Profile auflösen
|
* loaders.js – Mod-Loader-Profile auflösen
|
||||||
* ----------------------------------------
|
* ----------------------------------------
|
||||||
* Fabric & Quilt liefern über ihre Meta-API ein fertiges Profil-JSON
|
* Fabric & Quilt liefern über ihre Meta-API ein fertiges Profil-JSON
|
||||||
* (mainClass, zusätzliche Libraries als Maven-Koordinaten, Argumente,
|
* (mainClass, zusätzliche Libraries als Maven-Koordinaten, Argumente,
|
||||||
* inheritsFrom = Vanilla-Version). Das mergen wir beim Start mit der Vanilla-Version.
|
* inheritsFrom = Vanilla-Version). Das mergen wir beim Start mit der Vanilla-Version.
|
||||||
*
|
*
|
||||||
* Forge & NeoForge liefern KEIN einfaches Profil, sondern einen Installer,
|
* Forge & NeoForge liefern KEIN einfaches Profil, sondern einen Installer,
|
||||||
* der Prozessoren ausführt und den Client patcht -> deutlich aufwändiger,
|
* der Prozessoren ausführt und den Client patcht -> deutlich aufwändiger,
|
||||||
* daher hier noch als „in Arbeit" gekennzeichnet.
|
* daher hier noch als „in Arbeit" gekennzeichnet.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const UA = 'AeroMC-Launcher/0.5';
|
const UA = 'AeroMC-Launcher/0.5';
|
||||||
|
|
||||||
const META = {
|
const META = {
|
||||||
fabric: 'https://meta.fabricmc.net/v2',
|
fabric: 'https://meta.fabricmc.net/v2',
|
||||||
quilt: 'https://meta.quiltmc.org/v3',
|
quilt: 'https://meta.quiltmc.org/v3',
|
||||||
};
|
};
|
||||||
|
|
||||||
async function fetchJson(url) {
|
async function fetchJson(url) {
|
||||||
const res = await fetch(url, { headers: { 'User-Agent': UA, Accept: 'application/json' } });
|
const res = await fetch(url, { headers: { 'User-Agent': UA, Accept: 'application/json' } });
|
||||||
if (!res.ok) throw new Error('HTTP ' + res.status + ' bei ' + url);
|
if (!res.ok) throw new Error('HTTP ' + res.status + ' bei ' + url);
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchText(url) {
|
async function fetchText(url) {
|
||||||
const res = await fetch(url, { headers: { 'User-Agent': UA } });
|
const res = await fetch(url, { headers: { 'User-Agent': UA } });
|
||||||
if (!res.ok) throw new Error('HTTP ' + res.status + ' bei ' + url);
|
if (!res.ok) throw new Error('HTTP ' + res.status + ' bei ' + url);
|
||||||
return res.text();
|
return res.text();
|
||||||
}
|
}
|
||||||
|
|
||||||
const FORGE_META = 'https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml';
|
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';
|
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
|
// NeoForge kodiert die MC-Version im Loader-Namen: 1.21.4 -> 21.4.x
|
||||||
function neoforgePrefix(mcVersion) {
|
function neoforgePrefix(mcVersion) {
|
||||||
const p = mcVersion.split('.');
|
const p = mcVersion.split('.');
|
||||||
return `${p[1]}.${p[2] || '0'}.`;
|
return `${p[1]}.${p[2] || '0'}.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// "group:artifact:version[:classifier]" -> { relPath, url }
|
// "group:artifact:version[:classifier]" -> { relPath, url }
|
||||||
function mavenToUrl(base, name) {
|
function mavenToUrl(base, name) {
|
||||||
const parts = name.split(':');
|
const parts = name.split(':');
|
||||||
const group = parts[0].replace(/\./g, '/');
|
const group = parts[0].replace(/\./g, '/');
|
||||||
const artifact = parts[1];
|
const artifact = parts[1];
|
||||||
const version = parts[2];
|
const version = parts[2];
|
||||||
const classifier = parts[3] ? '-' + parts[3] : '';
|
const classifier = parts[3] ? '-' + parts[3] : '';
|
||||||
const file = `${artifact}-${version}${classifier}.jar`;
|
const file = `${artifact}-${version}${classifier}.jar`;
|
||||||
const relPath = `${group}/${artifact}/${version}/${file}`;
|
const relPath = `${group}/${artifact}/${version}/${file}`;
|
||||||
const root = (base || 'https://libraries.minecraft.net/').replace(/\/$/, '');
|
const root = (base || 'https://libraries.minecraft.net/').replace(/\/$/, '');
|
||||||
return { relPath, url: `${root}/${relPath}` };
|
return { relPath, url: `${root}/${relPath}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
// verfügbare Loader-Versionen (für die Anzeige/Auswahl)
|
// verfügbare Loader-Versionen (für die Anzeige/Auswahl)
|
||||||
async function listLoaderVersions(loader, mcVersion) {
|
// Versionsnummern zahlenweise vergleichen ("36.2.39" > "36.2.4"), neueste zuerst.
|
||||||
if (loader === 'fabric' || loader === 'quilt') {
|
// Die Maven-Listen von Forge/NeoForge sind nicht verlässlich sortiert – früher
|
||||||
const list = await fetchJson(`${META[loader]}/versions/loader/${mcVersion}`);
|
// landete bei Forge 1.16.5 dadurch die ÄLTESTE Version (36.0.0) vorn.
|
||||||
return list.map((l) => ({ version: l.loader.version, stable: !!l.loader.stable }));
|
function neuesteZuerst(a, b) {
|
||||||
}
|
const ta = String(a).split(/[.\-+]/), tb = String(b).split(/[.\-+]/);
|
||||||
if (loader === 'forge') {
|
for (let i = 0; i < Math.max(ta.length, tb.length); i++) {
|
||||||
const xml = await fetchText(FORGE_META);
|
const x = ta[i] || '0', y = tb[i] || '0';
|
||||||
const all = [...xml.matchAll(/<version>([^<]+)<\/version>/g)].map((m) => m[1]);
|
const nx = /^\d+$/.test(x), ny = /^\d+$/.test(y);
|
||||||
const prefix = mcVersion + '-';
|
if (nx && ny && Number(x) !== Number(y)) return Number(y) - Number(x);
|
||||||
const versions = all.filter((v) => v.startsWith(prefix)).map((v) => v.slice(prefix.length)).reverse();
|
if (!(nx && ny) && x !== y) return nx ? -1 : ny ? 1 : y.localeCompare(x); // reine Zahl vor "beta"
|
||||||
return versions.map((v, i) => ({ version: v, stable: i === 0 }));
|
}
|
||||||
}
|
return 0;
|
||||||
if (loader === 'neoforge') {
|
}
|
||||||
const xml = await fetchText(NEOFORGE_META);
|
|
||||||
const all = [...xml.matchAll(/<version>([^<]+)<\/version>/g)].map((m) => m[1]);
|
async function listLoaderVersions(loader, mcVersion) {
|
||||||
const prefix = neoforgePrefix(mcVersion);
|
if (loader === 'fabric' || loader === 'quilt') {
|
||||||
const versions = all.filter((v) => v.startsWith(prefix)).reverse();
|
const list = await fetchJson(`${META[loader]}/versions/loader/${mcVersion}`);
|
||||||
return versions.map((v, i) => ({ version: v, stable: i === 0 && !/beta/i.test(v) }));
|
return list.map((l) => ({ version: l.loader.version, stable: !!l.loader.stable }));
|
||||||
}
|
}
|
||||||
return [];
|
if (loader === 'forge') {
|
||||||
}
|
const xml = await fetchText(FORGE_META);
|
||||||
|
const all = [...xml.matchAll(/<version>([^<]+)<\/version>/g)].map((m) => m[1]);
|
||||||
// löst das Profil (mainClass, libraries, arguments) auf
|
const prefix = mcVersion + '-';
|
||||||
async function resolveLoaderProfile(loader, mcVersion, loaderVersion) {
|
const versions = all.filter((v) => v.startsWith(prefix)).map((v) => v.slice(prefix.length)).sort(neuesteZuerst);
|
||||||
if (loader === 'fabric' || loader === 'quilt') {
|
return versions.map((v, i) => ({ version: v, stable: i === 0 }));
|
||||||
let lv = loaderVersion;
|
}
|
||||||
if (!lv) {
|
if (loader === 'neoforge') {
|
||||||
const list = await fetchJson(`${META[loader]}/versions/loader/${mcVersion}`);
|
const xml = await fetchText(NEOFORGE_META);
|
||||||
if (!list.length) throw new Error(`Kein ${loader}-Loader für Minecraft ${mcVersion} verfügbar.`);
|
const all = [...xml.matchAll(/<version>([^<]+)<\/version>/g)].map((m) => m[1]);
|
||||||
const stable = list.find((l) => l.loader && l.loader.stable) || list[0];
|
const prefix = neoforgePrefix(mcVersion);
|
||||||
lv = stable.loader.version;
|
const versions = all.filter((v) => v.startsWith(prefix)).sort(neuesteZuerst);
|
||||||
}
|
return versions.map((v, i) => ({ version: v, stable: i === 0 && !/beta/i.test(v) }));
|
||||||
const profile = await fetchJson(`${META[loader]}/versions/loader/${mcVersion}/${lv}/profile/json`);
|
}
|
||||||
return { profile, loaderVersion: lv };
|
return [];
|
||||||
}
|
}
|
||||||
if (loader === 'forge' || loader === 'neoforge') {
|
|
||||||
const e = new Error(
|
// löst das Profil (mainClass, libraries, arguments) auf
|
||||||
`${loader === 'forge' ? 'Forge' : 'NeoForge'}-Installation ist noch in Arbeit ` +
|
async function resolveLoaderProfile(loader, mcVersion, loaderVersion) {
|
||||||
'(braucht Installer + Prozessoren). Fabric und Quilt funktionieren bereits vollständig.',
|
if (loader === 'fabric' || loader === 'quilt') {
|
||||||
);
|
let lv = loaderVersion;
|
||||||
e.notImplemented = true;
|
if (!lv) {
|
||||||
throw e;
|
const list = await fetchJson(`${META[loader]}/versions/loader/${mcVersion}`);
|
||||||
}
|
if (!list.length) throw new Error(`Kein ${loader}-Loader für Minecraft ${mcVersion} verfügbar.`);
|
||||||
throw new Error('Unbekannter Loader: ' + loader);
|
const stable = list.find((l) => l.loader && l.loader.stable) || list[0];
|
||||||
}
|
lv = stable.loader.version;
|
||||||
|
}
|
||||||
module.exports = { resolveLoaderProfile, listLoaderVersions, mavenToUrl };
|
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 };
|
||||||
|
|||||||
@@ -173,16 +173,31 @@ function scan(inputPath) {
|
|||||||
return { ok: true, rootDir, count: list.length, instances: list };
|
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) {
|
function copyInto(sourceDir, targetDir, usesDefaultGameDir) {
|
||||||
fs.mkdirSync(targetDir, { recursive: true });
|
fs.mkdirSync(targetDir, { recursive: true });
|
||||||
for (const name of fs.readdirSync(sourceDir)) {
|
for (const name of kopierNamen(sourceDir, usesDefaultGameDir)) {
|
||||||
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;
|
|
||||||
fs.cpSync(path.join(sourceDir, name), path.join(targetDir, name), { recursive: true });
|
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) {
|
function importOne(store, entry, copyData = true) {
|
||||||
const created = store.createOrReplaceImportedInstance({
|
const created = store.createOrReplaceImportedInstance({
|
||||||
name: entry.name,
|
name: entry.name,
|
||||||
@@ -214,4 +229,4 @@ function importOne(store, entry, copyData = true) {
|
|||||||
return { id: created.id, name: created.name, copied };
|
return { id: created.id, name: created.name, copied };
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { scan, importOne, parseLastVersionId, resolveLauncherRoot };
|
module.exports = { scan, importOne, kopierQuellen, parseLastVersionId, resolveLauncherRoot };
|
||||||
@@ -1,188 +1,199 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* mmcimport.js – Instanzen aus MultiMC/Prism übernehmen
|
* mmcimport.js – Instanzen aus MultiMC/Prism übernehmen
|
||||||
* ------------------------------------------------------
|
* ------------------------------------------------------
|
||||||
* Es genügt der Pfad zum MultiMC-Ordner (oder direkt zum instances-Ordner).
|
* Es genügt der Pfad zum MultiMC-Ordner (oder direkt zum instances-Ordner).
|
||||||
* Ausgelesen werden:
|
* Ausgelesen werden:
|
||||||
* instance.cfg – Name, Gruppe, Java-Pfad, Arbeitsspeicher, Notizen
|
* instance.cfg – Name, Gruppe, Java-Pfad, Arbeitsspeicher, Notizen
|
||||||
* mmc-pack.json – Minecraft-Version und Mod-Loader
|
* mmc-pack.json – Minecraft-Version und Mod-Loader
|
||||||
* .minecraft/ – Spielverzeichnis (Welten, Mods, Konfigurationen)
|
* .minecraft/ – Spielverzeichnis (Welten, Mods, Konfigurationen)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
// uid der Loader in mmc-pack.json -> unsere Bezeichnung
|
// uid der Loader in mmc-pack.json -> unsere Bezeichnung
|
||||||
const LOADER_UIDS = {
|
const LOADER_UIDS = {
|
||||||
'net.fabricmc.fabric-loader': 'fabric',
|
'net.fabricmc.fabric-loader': 'fabric',
|
||||||
'org.quiltmc.quilt-loader': 'quilt',
|
'org.quiltmc.quilt-loader': 'quilt',
|
||||||
'net.minecraftforge': 'forge',
|
'net.minecraftforge': 'forge',
|
||||||
'net.neoforged': 'neoforge',
|
'net.neoforged': 'neoforge',
|
||||||
};
|
};
|
||||||
|
|
||||||
function parseCfg(file) {
|
function parseCfg(file) {
|
||||||
const out = {};
|
const out = {};
|
||||||
try {
|
try {
|
||||||
for (const line of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
|
for (const line of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
|
||||||
const i = line.indexOf('=');
|
const i = line.indexOf('=');
|
||||||
if (i > 0) out[line.slice(0, i).trim()] = line.slice(i + 1).trim();
|
if (i > 0) out[line.slice(0, i).trim()] = line.slice(i + 1).trim();
|
||||||
}
|
}
|
||||||
} catch { /* Datei fehlt */ }
|
} catch { /* Datei fehlt */ }
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gruppen stehen zentral in instances/instgroups.json
|
// Gruppen stehen zentral in instances/instgroups.json
|
||||||
function readGroups(instancesDir) {
|
function readGroups(instancesDir) {
|
||||||
const map = {};
|
const map = {};
|
||||||
try {
|
try {
|
||||||
const raw = JSON.parse(fs.readFileSync(path.join(instancesDir, 'instgroups.json'), 'utf8'));
|
const raw = JSON.parse(fs.readFileSync(path.join(instancesDir, 'instgroups.json'), 'utf8'));
|
||||||
for (const [group, data] of Object.entries(raw.groups || {})) {
|
for (const [group, data] of Object.entries(raw.groups || {})) {
|
||||||
for (const inst of data.instances || []) map[inst] = group;
|
for (const inst of data.instances || []) map[inst] = group;
|
||||||
}
|
}
|
||||||
} catch { /* keine Gruppen hinterlegt */ }
|
} catch { /* keine Gruppen hinterlegt */ }
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readPack(dir) {
|
function readPack(dir) {
|
||||||
const res = { version: '', loader: 'vanilla', loaderVersion: '' };
|
const res = { version: '', loader: 'vanilla', loaderVersion: '' };
|
||||||
try {
|
try {
|
||||||
const pack = JSON.parse(fs.readFileSync(path.join(dir, 'mmc-pack.json'), 'utf8'));
|
const pack = JSON.parse(fs.readFileSync(path.join(dir, 'mmc-pack.json'), 'utf8'));
|
||||||
for (const c of pack.components || []) {
|
for (const c of pack.components || []) {
|
||||||
if (c.uid === 'net.minecraft') res.version = c.version || c.cachedVersion || '';
|
if (c.uid === 'net.minecraft') res.version = c.version || c.cachedVersion || '';
|
||||||
const loader = LOADER_UIDS[c.uid];
|
const loader = LOADER_UIDS[c.uid];
|
||||||
if (loader) { res.loader = loader; res.loaderVersion = c.version || ''; }
|
if (loader) { res.loader = loader; res.loaderVersion = c.version || ''; }
|
||||||
}
|
}
|
||||||
} catch { /* alte Instanz ohne mmc-pack.json */ }
|
} catch { /* alte Instanz ohne mmc-pack.json */ }
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
// findet den instances-Ordner, egal ob MultiMC-Wurzel oder instances direkt angegeben wurde
|
// findet den instances-Ordner, egal ob MultiMC-Wurzel oder instances direkt angegeben wurde
|
||||||
function resolveInstancesDir(inputPath) {
|
function resolveInstancesDir(inputPath) {
|
||||||
if (!inputPath) return null;
|
if (!inputPath) return null;
|
||||||
const direct = path.join(inputPath, 'instances');
|
const direct = path.join(inputPath, 'instances');
|
||||||
if (fs.existsSync(direct)) return direct;
|
if (fs.existsSync(direct)) return direct;
|
||||||
if (path.basename(inputPath).toLowerCase() === 'instances' && fs.existsSync(inputPath)) return inputPath;
|
if (path.basename(inputPath).toLowerCase() === 'instances' && fs.existsSync(inputPath)) return inputPath;
|
||||||
return fs.existsSync(inputPath) ? inputPath : null;
|
return fs.existsSync(inputPath) ? inputPath : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Liest einen einzelnen Instanzordner aus. null, wenn dort keine Instanz liegt.
|
* Liest einen einzelnen Instanzordner aus. null, wenn dort keine Instanz liegt.
|
||||||
* Wird sowohl beim Ordner-Import als auch beim ZIP-Import verwendet.
|
* Wird sowohl beim Ordner-Import als auch beim ZIP-Import verwendet.
|
||||||
*/
|
*/
|
||||||
function readInstance(dir, folderName, group) {
|
function readInstance(dir, folderName, group) {
|
||||||
const cfgFile = path.join(dir, 'instance.cfg');
|
const cfgFile = path.join(dir, 'instance.cfg');
|
||||||
if (!fs.existsSync(cfgFile)) return null; // kein Instanzordner
|
if (!fs.existsSync(cfgFile)) return null; // kein Instanzordner
|
||||||
|
|
||||||
const cfg = parseCfg(cfgFile);
|
const cfg = parseCfg(cfgFile);
|
||||||
const pack = readPack(dir);
|
const pack = readPack(dir);
|
||||||
// MultiMC nutzt .minecraft, Prism minecraft
|
// MultiMC nutzt .minecraft, Prism minecraft
|
||||||
const gameDir = ['.minecraft', 'minecraft']
|
const gameDir = ['.minecraft', 'minecraft']
|
||||||
.map((n) => path.join(dir, n))
|
.map((n) => path.join(dir, n))
|
||||||
.find((p) => fs.existsSync(p)) || null;
|
.find((p) => fs.existsSync(p)) || null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
folder: folderName,
|
folder: folderName,
|
||||||
dir,
|
dir,
|
||||||
name: cfg.name || folderName,
|
name: cfg.name || folderName,
|
||||||
group: group || '',
|
group: group || '',
|
||||||
notes: cfg.notes || '',
|
notes: cfg.notes || '',
|
||||||
version: pack.version || cfg.IntendedVersion || '',
|
version: pack.version || cfg.IntendedVersion || '',
|
||||||
loader: pack.loader,
|
loader: pack.loader,
|
||||||
loaderVersion: pack.loaderVersion,
|
loaderVersion: pack.loaderVersion,
|
||||||
javaPath: cfg.OverrideJavaLocation === 'true' ? (cfg.JavaPath || '') : '',
|
javaPath: cfg.OverrideJavaLocation === 'true' ? (cfg.JavaPath || '') : '',
|
||||||
minMemMb: cfg.OverrideMemory === 'true' ? Number(cfg.MinMemAlloc) || null : null,
|
minMemMb: cfg.OverrideMemory === 'true' ? Number(cfg.MinMemAlloc) || null : null,
|
||||||
maxMemMb: cfg.OverrideMemory === 'true' ? Number(cfg.MaxMemAlloc) || null : null,
|
maxMemMb: cfg.OverrideMemory === 'true' ? Number(cfg.MaxMemAlloc) || null : null,
|
||||||
gameDir,
|
gameDir,
|
||||||
hasGameData: !!gameDir,
|
hasGameData: !!gameDir,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Listet die gefundenen Instanzen auf (ohne etwas zu kopieren).
|
* Listet die gefundenen Instanzen auf (ohne etwas zu kopieren).
|
||||||
*/
|
*/
|
||||||
function scan(inputPath) {
|
function scan(inputPath) {
|
||||||
const instancesDir = resolveInstancesDir(inputPath);
|
const instancesDir = resolveInstancesDir(inputPath);
|
||||||
if (!instancesDir) return { ok: false, reason: 'not-found' };
|
if (!instancesDir) return { ok: false, reason: 'not-found' };
|
||||||
|
|
||||||
const groups = readGroups(instancesDir);
|
const groups = readGroups(instancesDir);
|
||||||
const list = [];
|
const list = [];
|
||||||
for (const entry of fs.readdirSync(instancesDir, { withFileTypes: true })) {
|
for (const entry of fs.readdirSync(instancesDir, { withFileTypes: true })) {
|
||||||
if (!entry.isDirectory()) continue;
|
if (!entry.isDirectory()) continue;
|
||||||
const inst = readInstance(path.join(instancesDir, entry.name), entry.name, groups[entry.name]);
|
const inst = readInstance(path.join(instancesDir, entry.name), entry.name, groups[entry.name]);
|
||||||
if (inst) list.push(inst);
|
if (inst) list.push(inst);
|
||||||
}
|
}
|
||||||
list.sort((a, b) => a.name.localeCompare(b.name));
|
list.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
return { ok: true, instancesDir, count: list.length, instances: list };
|
return { ok: true, instancesDir, count: list.length, instances: list };
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Sucht einen Instanzordner unterhalb von root. Deckt beide Zip-Varianten ab:
|
* Sucht einen Instanzordner unterhalb von root. Deckt beide Zip-Varianten ab:
|
||||||
* mit umgebendem Ordner ("26.2/instance.cfg") und ohne ("instance.cfg").
|
* mit umgebendem Ordner ("26.2/instance.cfg") und ohne ("instance.cfg").
|
||||||
* Die Tiefe ist begrenzt, damit nicht das ganze .minecraft durchsucht wird.
|
* Die Tiefe ist begrenzt, damit nicht das ganze .minecraft durchsucht wird.
|
||||||
*/
|
*/
|
||||||
function findInstanceDir(root, tiefe = 2) {
|
function findInstanceDir(root, tiefe = 2) {
|
||||||
if (!root || !fs.existsSync(root)) return null;
|
if (!root || !fs.existsSync(root)) return null;
|
||||||
if (fs.existsSync(path.join(root, 'instance.cfg'))) return root;
|
if (fs.existsSync(path.join(root, 'instance.cfg'))) return root;
|
||||||
if (tiefe <= 0) return null;
|
if (tiefe <= 0) return null;
|
||||||
let eintraege = [];
|
let eintraege = [];
|
||||||
try { eintraege = fs.readdirSync(root, { withFileTypes: true }); } catch { return null; }
|
try { eintraege = fs.readdirSync(root, { withFileTypes: true }); } catch { return null; }
|
||||||
for (const e of eintraege) {
|
for (const e of eintraege) {
|
||||||
if (!e.isDirectory()) continue;
|
if (!e.isDirectory()) continue;
|
||||||
// .minecraft enthält nie eine Instanz und ist der mit Abstand größte Ordner
|
// .minecraft enthält nie eine Instanz und ist der mit Abstand größte Ordner
|
||||||
if (e.name === '.minecraft' || e.name === 'minecraft') continue;
|
if (e.name === '.minecraft' || e.name === 'minecraft') continue;
|
||||||
const treffer = findInstanceDir(path.join(root, e.name), tiefe - 1);
|
const treffer = findInstanceDir(path.join(root, e.name), tiefe - 1);
|
||||||
if (treffer) return treffer;
|
if (treffer) return treffer;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Liest eine einzelne, ausgepackte MultiMC/Prism-Instanz.
|
* Liest eine einzelne, ausgepackte MultiMC/Prism-Instanz.
|
||||||
* -> { ok, instance } oder { ok: false, reason: 'not-found' }
|
* -> { ok, instance } oder { ok: false, reason: 'not-found' }
|
||||||
*/
|
*/
|
||||||
function scanSingle(root) {
|
function scanSingle(root) {
|
||||||
const dir = findInstanceDir(root);
|
const dir = findInstanceDir(root);
|
||||||
if (!dir) return { ok: false, reason: 'not-found' };
|
if (!dir) return { ok: false, reason: 'not-found' };
|
||||||
const inst = readInstance(dir, path.basename(dir), '');
|
const inst = readInstance(dir, path.basename(dir), '');
|
||||||
return inst ? { ok: true, instance: inst } : { ok: false, reason: 'not-found' };
|
return inst ? { ok: true, instance: inst } : { ok: false, reason: 'not-found' };
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Übernimmt eine Instanz in den Launcher.
|
* Übernimmt eine Instanz in den Launcher.
|
||||||
* store: unser Datenspeicher; copyData: Spielverzeichnis mitkopieren
|
* store: unser Datenspeicher; copyData: Spielverzeichnis mitkopieren
|
||||||
*/
|
*/
|
||||||
function importOne(store, entry, copyData = true) {
|
function importOne(store, entry, copyData = true) {
|
||||||
const created = store.createOrReplaceImportedInstance({
|
const created = store.createOrReplaceImportedInstance({
|
||||||
name: entry.name,
|
name: entry.name,
|
||||||
group: entry.group,
|
group: entry.group,
|
||||||
notes: entry.notes,
|
notes: entry.notes,
|
||||||
minecraft: {
|
minecraft: {
|
||||||
version: entry.version,
|
version: entry.version,
|
||||||
loader: entry.loader,
|
loader: entry.loader,
|
||||||
loaderVersion: entry.loaderVersion,
|
loaderVersion: entry.loaderVersion,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const patch = {};
|
const patch = {};
|
||||||
if (entry.javaPath || entry.minMemMb || entry.maxMemMb) {
|
if (entry.javaPath || entry.minMemMb || entry.maxMemMb) {
|
||||||
patch.java = {
|
patch.java = {
|
||||||
path: entry.javaPath || '',
|
path: entry.javaPath || '',
|
||||||
minMemMb: entry.minMemMb || null,
|
minMemMb: entry.minMemMb || null,
|
||||||
maxMemMb: entry.maxMemMb || null,
|
maxMemMb: entry.maxMemMb || null,
|
||||||
extraArgs: '',
|
extraArgs: '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (Object.keys(patch).length) store.updateInstance(created.id, patch);
|
if (Object.keys(patch).length) store.updateInstance(created.id, patch);
|
||||||
|
|
||||||
let copied = false;
|
let copied = false;
|
||||||
if (copyData && entry.gameDir && fs.existsSync(entry.gameDir)) {
|
if (copyData && entry.gameDir && fs.existsSync(entry.gameDir)) {
|
||||||
try {
|
try {
|
||||||
fs.cpSync(entry.gameDir, store.gameDir(created.id), { recursive: true });
|
fs.cpSync(entry.gameDir, store.gameDir(created.id), { recursive: true });
|
||||||
copied = true;
|
copied = true;
|
||||||
} catch { /* Spieldaten konnten nicht kopiert werden */ }
|
} catch { /* Spieldaten konnten nicht kopiert werden */ }
|
||||||
}
|
}
|
||||||
return { id: created.id, name: created.name, copied };
|
return { id: created.id, name: created.name, copied };
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { scan, scanSingle, importOne, resolveInstancesDir, readInstance, findInstanceDir };
|
|
||||||
|
/*
|
||||||
|
* 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 };
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* modschalter.js – Mods im laufenden Spiel an- und abschalten
|
||||||
|
* ------------------------------------------------------------
|
||||||
|
* Im Pausemenü wird „Feedback geben“ zu „Mods verwalten“ (siehe
|
||||||
|
* agent/ModsUmbau.java). Ein Klick ruft eine Adresse dieses kleinen, nur lokal
|
||||||
|
* erreichbaren Empfängers auf; der Launcher öffnet daraufhin das Mod-Fenster
|
||||||
|
* über dem Spiel.
|
||||||
|
*
|
||||||
|
* Solange Minecraft läuft, sind die Mod-Dateien gesperrt und geladene Mods
|
||||||
|
* lassen sich ohnehin nicht entladen. Änderungen werden deshalb vorgemerkt
|
||||||
|
* (instance.json -> modsAusstehend) und beim Beenden umgesetzt – das Spiel
|
||||||
|
* läuft weiter, beim nächsten Start gilt der neue Stand.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const http = require('http');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
let server = null;
|
||||||
|
let port = 0;
|
||||||
|
const sitzungen = new Map(); // token -> { instanceId, pid }
|
||||||
|
let beimKlick = null; // (sitzung) => void
|
||||||
|
|
||||||
|
function starteServer() {
|
||||||
|
if (server) return Promise.resolve(port);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
server = http.createServer((req, res) => {
|
||||||
|
const m = /^\/mods\/([a-f0-9]{32})$/.exec(req.url || '');
|
||||||
|
const s = m && sitzungen.get(m[1]);
|
||||||
|
if (!s) { res.writeHead(404); res.end(); return; }
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('ok');
|
||||||
|
if (beimKlick) { try { beimKlick(s); } catch (err) { console.error('Mod-Schalter:', err); } }
|
||||||
|
});
|
||||||
|
server.on('error', (err) => { server = null; reject(err); });
|
||||||
|
server.listen(0, '127.0.0.1', () => { port = server.address().port; resolve(port); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Neue Sitzung für einen Spielstart -> { token, adresse } (für das Agent-Argument) */
|
||||||
|
async function neueSitzung(instanceId) {
|
||||||
|
await starteServer();
|
||||||
|
const token = crypto.randomBytes(16).toString('hex');
|
||||||
|
sitzungen.set(token, { instanceId, pid: null, token });
|
||||||
|
return { token, adresse: `http://127.0.0.1:${port}/mods/${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
function setzePid(token, pid) {
|
||||||
|
const s = sitzungen.get(token);
|
||||||
|
if (s) s.pid = pid;
|
||||||
|
}
|
||||||
|
|
||||||
|
function beende(token) {
|
||||||
|
sitzungen.delete(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sitzungFuer(instanceId) {
|
||||||
|
for (const s of sitzungen.values()) if (s.instanceId === instanceId && s.pid) return s;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function beiKlick(fn) { beimKlick = fn; }
|
||||||
|
|
||||||
|
module.exports = { neueSitzung, setzePid, beende, sitzungFuer, beiKlick };
|
||||||
@@ -1,437 +1,448 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* mrimport.js – Instanzen aus der Modrinth App übernehmen
|
* mrimport.js – Instanzen aus der Modrinth App übernehmen
|
||||||
* --------------------------------------------------------
|
* --------------------------------------------------------
|
||||||
* Die Modrinth App legt ihre Daten unter %APPDATA%\ModrinthApp ab (sehr alte
|
* Die Modrinth App legt ihre Daten unter %APPDATA%\ModrinthApp ab (sehr alte
|
||||||
* Fassungen: %APPDATA%\com.modrinth.theseus). Die Spielordner liegen in
|
* Fassungen: %APPDATA%\com.modrinth.theseus). Die Spielordner liegen in
|
||||||
* "profiles\<Ordner>", die Angaben dazu (Name, Version, Loader, Gruppen,
|
* "profiles\<Ordner>", die Angaben dazu (Name, Version, Loader, Gruppen,
|
||||||
* Arbeitsspeicher …) in der SQLite-Datenbank "app.db". Wurde der Datenordner in
|
* Arbeitsspeicher …) in der SQLite-Datenbank "app.db". Wurde der Datenordner in
|
||||||
* der App verlegt, steht der neue Ort in settings.custom_dir.
|
* der App verlegt, steht der neue Ort in settings.custom_dir.
|
||||||
*
|
*
|
||||||
* Unterstützte Datenbank-Fassungen:
|
* Unterstützte Datenbank-Fassungen:
|
||||||
* - ab Mitte 2026: Tabelle "instances" + "instance_content_sets" (Version/Loader),
|
* - ab Mitte 2026: Tabelle "instances" + "instance_content_sets" (Version/Loader),
|
||||||
* Gruppen in "instance_group_memberships"/"instance_groups",
|
* Gruppen in "instance_group_memberships"/"instance_groups",
|
||||||
* Starteinstellungen als JSONB in "instance_launch_overrides"
|
* Starteinstellungen als JSONB in "instance_launch_overrides"
|
||||||
* - 2024 bis Mitte 2026: eine Tabelle "profiles"
|
* - 2024 bis Mitte 2026: eine Tabelle "profiles"
|
||||||
* - vorher (ohne app.db): je Instanz eine profile.json
|
* - vorher (ohne app.db): je Instanz eine profile.json
|
||||||
*
|
*
|
||||||
* Gelesen wird mit sql.js (SQLite als WebAssembly) auf einer Kopie im Speicher –
|
* Gelesen wird mit sql.js (SQLite als WebAssembly) auf einer Kopie im Speicher –
|
||||||
* die Datenbank der Modrinth App wird nie verändert.
|
* die Datenbank der Modrinth App wird nie verändert.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
const LOADER = new Set(['vanilla', 'fabric', 'forge', 'neoforge', 'quilt']);
|
const LOADER = new Set(['vanilla', 'fabric', 'forge', 'neoforge', 'quilt']);
|
||||||
|
|
||||||
let sqlPromise = null;
|
let sqlPromise = null;
|
||||||
function sqlJs() {
|
function sqlJs() {
|
||||||
if (!sqlPromise) {
|
if (!sqlPromise) {
|
||||||
const initSqlJs = require('sql.js');
|
const initSqlJs = require('sql.js');
|
||||||
const wasmDir = path.dirname(require.resolve('sql.js'));
|
const wasmDir = path.dirname(require.resolve('sql.js'));
|
||||||
sqlPromise = initSqlJs({ locateFile: (datei) => path.join(wasmDir, datei) });
|
sqlPromise = initSqlJs({ locateFile: (datei) => path.join(wasmDir, datei) });
|
||||||
}
|
}
|
||||||
return sqlPromise;
|
return sqlPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
function appDatenOrdner() {
|
function appDatenOrdner() {
|
||||||
const appdata = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
|
const appdata = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
|
||||||
return [path.join(appdata, 'ModrinthApp'), path.join(appdata, 'com.modrinth.theseus')];
|
return [path.join(appdata, 'ModrinthApp'), path.join(appdata, 'com.modrinth.theseus')];
|
||||||
}
|
}
|
||||||
|
|
||||||
function standardOrdner() {
|
function standardOrdner() {
|
||||||
return appDatenOrdner().find((d) => fs.existsSync(d)) || appDatenOrdner()[0];
|
return appDatenOrdner().find((d) => fs.existsSync(d)) || appDatenOrdner()[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
function readJson(file) {
|
function readJson(file) {
|
||||||
try { return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^/, '')); } catch { return null; }
|
try { return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^/, '')); } catch { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Aus der Eingabe (App-Ordner, profiles-Ordner oder leer) die Orte ermitteln
|
// Aus der Eingabe (App-Ordner, profiles-Ordner oder leer) die Orte ermitteln
|
||||||
function ermittleOrte(eingabe) {
|
function ermittleOrte(eingabe) {
|
||||||
let basis = eingabe && eingabe.trim() ? eingabe.trim() : standardOrdner();
|
let basis = eingabe && eingabe.trim() ? eingabe.trim() : standardOrdner();
|
||||||
if (!fs.existsSync(basis)) return null;
|
if (!fs.existsSync(basis)) return null;
|
||||||
// direkt den profiles-Ordner angegeben -> eine Ebene höher liegt die app.db
|
// 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'))) {
|
if (path.basename(basis).toLowerCase() === 'profiles' && !fs.existsSync(path.join(basis, 'app.db'))) {
|
||||||
const hoeher = path.dirname(basis);
|
const hoeher = path.dirname(basis);
|
||||||
if (fs.existsSync(path.join(hoeher, 'app.db')) || fs.existsSync(path.join(hoeher, 'profiles'))) basis = hoeher;
|
if (fs.existsSync(path.join(hoeher, 'app.db')) || fs.existsSync(path.join(hoeher, 'profiles'))) basis = hoeher;
|
||||||
}
|
}
|
||||||
const db = path.join(basis, 'app.db');
|
const db = path.join(basis, 'app.db');
|
||||||
return { basis, db: fs.existsSync(db) ? db : null };
|
return { basis, db: fs.existsSync(db) ? db : null };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Datenbank ----------
|
// ---------- Datenbank ----------
|
||||||
/*
|
/*
|
||||||
* Läuft die Modrinth App, stehen die neuesten Änderungen oft noch im
|
* Läuft die Modrinth App, stehen die neuesten Änderungen oft noch im
|
||||||
* Schreibprotokoll (app.db-wal) statt in app.db. Dessen abgeschlossene
|
* Schreibprotokoll (app.db-wal) statt in app.db. Dessen abgeschlossene
|
||||||
* Transaktionen spielen wir hier selbst auf die Kopie im Speicher ein – nach
|
* 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),
|
* 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.
|
* mit Prüfung von Salz und laufender Prüfsumme wie SQLite selbst.
|
||||||
* -> { daten, rahmen } oder null, wenn das Protokoll nicht passt
|
* -> { daten, rahmen } oder null, wenn das Protokoll nicht passt
|
||||||
*/
|
*/
|
||||||
function spieleWalEin(daten, walPfad) {
|
function spieleWalEin(daten, walPfad) {
|
||||||
let wal;
|
let wal;
|
||||||
try { wal = fs.readFileSync(walPfad); } catch { return null; }
|
try { wal = fs.readFileSync(walPfad); } catch { return null; }
|
||||||
if (wal.length < 32) return null;
|
if (wal.length < 32) return null;
|
||||||
const magie = wal.readUInt32BE(0);
|
const magie = wal.readUInt32BE(0);
|
||||||
if (magie !== 0x377f0682 && magie !== 0x377f0683) return null;
|
if (magie !== 0x377f0682 && magie !== 0x377f0683) return null;
|
||||||
const bigEndian = magie === 0x377f0683;
|
const bigEndian = magie === 0x377f0683;
|
||||||
const seite = wal.readUInt32BE(8);
|
const seite = wal.readUInt32BE(8);
|
||||||
const dbSeite = daten.readUInt16BE(16) === 1 ? 65536 : daten.readUInt16BE(16);
|
const dbSeite = daten.readUInt16BE(16) === 1 ? 65536 : daten.readUInt16BE(16);
|
||||||
if (!seite || seite !== dbSeite) return null;
|
if (!seite || seite !== dbSeite) return null;
|
||||||
const salz1 = wal.readUInt32BE(16), salz2 = wal.readUInt32BE(20);
|
const salz1 = wal.readUInt32BE(16), salz2 = wal.readUInt32BE(20);
|
||||||
|
|
||||||
const wort = (buf, i) => (bigEndian ? buf.readUInt32BE(i) : buf.readUInt32LE(i));
|
const wort = (buf, i) => (bigEndian ? buf.readUInt32BE(i) : buf.readUInt32LE(i));
|
||||||
let s0 = 0, s1 = 0;
|
let s0 = 0, s1 = 0;
|
||||||
const pruefe = (buf, start, ende) => {
|
const pruefe = (buf, start, ende) => {
|
||||||
for (let i = start; i < ende; i += 8) {
|
for (let i = start; i < ende; i += 8) {
|
||||||
s0 = (s0 + wort(buf, i) + s1) >>> 0;
|
s0 = (s0 + wort(buf, i) + s1) >>> 0;
|
||||||
s1 = (s1 + wort(buf, i + 4) + s0) >>> 0;
|
s1 = (s1 + wort(buf, i + 4) + s0) >>> 0;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
pruefe(wal, 0, 24);
|
pruefe(wal, 0, 24);
|
||||||
if (s0 !== wal.readUInt32BE(24) || s1 !== wal.readUInt32BE(28)) return null;
|
if (s0 !== wal.readUInt32BE(24) || s1 !== wal.readUInt32BE(28)) return null;
|
||||||
|
|
||||||
const fertig = new Map(); // Seitennummer -> Inhalt (abgeschlossene Transaktionen)
|
const fertig = new Map(); // Seitennummer -> Inhalt (abgeschlossene Transaktionen)
|
||||||
let offen = new Map();
|
let offen = new Map();
|
||||||
let seiten = Math.floor(daten.length / seite);
|
let seiten = Math.floor(daten.length / seite);
|
||||||
let rahmen = 0;
|
let rahmen = 0;
|
||||||
for (let pos = 32; pos + 24 + seite <= wal.length; pos += 24 + seite) {
|
for (let pos = 32; pos + 24 + seite <= wal.length; pos += 24 + seite) {
|
||||||
if (wal.readUInt32BE(pos + 8) !== salz1 || wal.readUInt32BE(pos + 12) !== salz2) break;
|
if (wal.readUInt32BE(pos + 8) !== salz1 || wal.readUInt32BE(pos + 12) !== salz2) break;
|
||||||
pruefe(wal, pos, pos + 8);
|
pruefe(wal, pos, pos + 8);
|
||||||
pruefe(wal, pos + 24, pos + 24 + seite);
|
pruefe(wal, pos + 24, pos + 24 + seite);
|
||||||
if (s0 !== wal.readUInt32BE(pos + 16) || s1 !== wal.readUInt32BE(pos + 20)) break;
|
if (s0 !== wal.readUInt32BE(pos + 16) || s1 !== wal.readUInt32BE(pos + 20)) break;
|
||||||
offen.set(wal.readUInt32BE(pos), wal.subarray(pos + 24, pos + 24 + seite));
|
offen.set(wal.readUInt32BE(pos), wal.subarray(pos + 24, pos + 24 + seite));
|
||||||
const nachCommit = wal.readUInt32BE(pos + 4);
|
const nachCommit = wal.readUInt32BE(pos + 4);
|
||||||
if (nachCommit) { // Transaktion abgeschlossen
|
if (nachCommit) { // Transaktion abgeschlossen
|
||||||
for (const [nr, inhalt] of offen) fertig.set(nr, inhalt);
|
for (const [nr, inhalt] of offen) fertig.set(nr, inhalt);
|
||||||
offen = new Map();
|
offen = new Map();
|
||||||
seiten = nachCommit;
|
seiten = nachCommit;
|
||||||
rahmen++;
|
rahmen++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!rahmen) return null;
|
if (!rahmen) return null;
|
||||||
const neu = Buffer.alloc(seiten * seite);
|
const neu = Buffer.alloc(seiten * seite);
|
||||||
daten.copy(neu, 0, 0, Math.min(daten.length, neu.length));
|
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);
|
for (const [nr, inhalt] of fertig) if (nr >= 1 && nr <= seiten) inhalt.copy(neu, (nr - 1) * seite);
|
||||||
return { daten: neu, rahmen };
|
return { daten: neu, rahmen };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function oeffneDb(dbPfad) {
|
async function oeffneDb(dbPfad) {
|
||||||
const SQL = await sqlJs();
|
const SQL = await sqlJs();
|
||||||
let daten = fs.readFileSync(dbPfad);
|
let daten = fs.readFileSync(dbPfad);
|
||||||
let walEingespielt = false;
|
let walEingespielt = false;
|
||||||
if (daten.length > 100 && daten[18] === 2) {
|
if (daten.length > 100 && daten[18] === 2) {
|
||||||
const mitWal = spieleWalEin(daten, dbPfad + '-wal');
|
const mitWal = spieleWalEin(daten, dbPfad + '-wal');
|
||||||
if (mitWal) { daten = mitWal.daten; walEingespielt = true; }
|
if (mitWal) { daten = mitWal.daten; walEingespielt = true; }
|
||||||
// WAL-Datenbanken lassen sich ohne geteilten Speicher nicht öffnen – die
|
// WAL-Datenbanken lassen sich ohne geteilten Speicher nicht öffnen – die
|
||||||
// Kopie im Speicher als normale Datenbank kennzeichnen (Bytes 18/19)
|
// Kopie im Speicher als normale Datenbank kennzeichnen (Bytes 18/19)
|
||||||
daten[18] = 1; daten[19] = 1;
|
daten[18] = 1; daten[19] = 1;
|
||||||
}
|
}
|
||||||
const db = new SQL.Database(daten);
|
const db = new SQL.Database(daten);
|
||||||
db.walEingespielt = walEingespielt;
|
db.walEingespielt = walEingespielt;
|
||||||
return db;
|
return db;
|
||||||
}
|
}
|
||||||
|
|
||||||
function zeilen(db, sql, params) {
|
function zeilen(db, sql, params) {
|
||||||
const st = db.prepare(sql);
|
const st = db.prepare(sql);
|
||||||
try {
|
try {
|
||||||
if (params) st.bind(params);
|
if (params) st.bind(params);
|
||||||
const out = [];
|
const out = [];
|
||||||
while (st.step()) out.push(st.getAsObject());
|
while (st.step()) out.push(st.getAsObject());
|
||||||
return out;
|
return out;
|
||||||
} finally {
|
} finally {
|
||||||
st.free();
|
st.free();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function tabellen(db) {
|
function tabellen(db) {
|
||||||
return new Set(zeilen(db, "SELECT name FROM sqlite_master WHERE type = 'table'").map((r) => r.name));
|
return new Set(zeilen(db, "SELECT name FROM sqlite_master WHERE type = 'table'").map((r) => r.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
function spalten(db, tabelle) {
|
function spalten(db, tabelle) {
|
||||||
return new Set(zeilen(db, `PRAGMA table_info(${tabelle})`).map((r) => r.name));
|
return new Set(zeilen(db, `PRAGMA table_info(${tabelle})`).map((r) => r.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
function jsonFeld(wert) {
|
function jsonFeld(wert) {
|
||||||
if (wert === null || wert === undefined || wert === '') return null;
|
if (wert === null || wert === undefined || wert === '') return null;
|
||||||
try { return JSON.parse(wert); } catch { return null; }
|
try { return JSON.parse(wert); } catch { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spalte nur abfragen, wenn es sie gibt (ältere Fassungen kennen nicht alle)
|
// Spalte nur abfragen, wenn es sie gibt (ältere Fassungen kennen nicht alle)
|
||||||
function sp(vorhanden, ausdruck, name, ersatz = 'NULL') {
|
function sp(vorhanden, ausdruck, name, ersatz = 'NULL') {
|
||||||
return vorhanden.has(name) ? `${ausdruck} AS ${name}` : `${ersatz} AS ${name}`;
|
return vorhanden.has(name) ? `${ausdruck} AS ${name}` : `${ersatz} AS ${name}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function eigenerOrdner(db, t) {
|
function eigenerOrdner(db, t) {
|
||||||
if (!t.has('settings') || !spalten(db, 'settings').has('custom_dir')) return null;
|
if (!t.has('settings') || !spalten(db, 'settings').has('custom_dir')) return null;
|
||||||
const r = zeilen(db, 'SELECT custom_dir FROM settings LIMIT 1')[0];
|
const r = zeilen(db, 'SELECT custom_dir FROM settings LIMIT 1')[0];
|
||||||
return r && r.custom_dir ? String(r.custom_dir) : null;
|
return r && r.custom_dir ? String(r.custom_dir) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function leseNeu(db, t) {
|
function leseNeu(db, t) {
|
||||||
const si = spalten(db, 'instances');
|
const si = spalten(db, 'instances');
|
||||||
const liste = zeilen(db, `
|
const liste = zeilen(db, `
|
||||||
SELECT i.id AS id, i.path AS path, i.name AS name,
|
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.icon_path', 'icon_path')}, ${sp(si, 'i.install_stage', 'install_stage')},
|
||||||
${sp(si, 'i.last_played', 'last_played')},
|
${sp(si, 'i.last_played', 'last_played')},
|
||||||
${sp(si, 'i.submitted_time_played', 'submitted_time_played', '0')},
|
${sp(si, 'i.submitted_time_played', 'submitted_time_played', '0')},
|
||||||
${sp(si, 'i.recent_time_played', 'recent_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
|
cs.game_version AS game_version, cs.loader AS loader, cs.loader_version AS loader_version
|
||||||
FROM instances i
|
FROM instances i
|
||||||
LEFT JOIN instance_content_sets cs ON cs.id = i.applied_content_set_id`);
|
LEFT JOIN instance_content_sets cs ON cs.id = i.applied_content_set_id`);
|
||||||
|
|
||||||
const overrides = new Map();
|
const overrides = new Map();
|
||||||
if (t.has('instance_launch_overrides')) {
|
if (t.has('instance_launch_overrides')) {
|
||||||
for (const r of zeilen(db, 'SELECT instance_id, json(overrides) AS o FROM 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) || {});
|
overrides.set(r.instance_id, jsonFeld(r.o) || {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const gruppen = new Map();
|
const gruppen = new Map();
|
||||||
const merke = (id, name) => { if (name && !gruppen.has(id)) gruppen.set(id, String(name)); };
|
const merke = (id, name) => { if (name && !gruppen.has(id)) gruppen.set(id, String(name)); };
|
||||||
if (t.has('instance_group_memberships') && t.has('instance_groups')) {
|
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
|
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);
|
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')) {
|
} 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);
|
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();
|
const links = new Map();
|
||||||
if (t.has('instance_links')) {
|
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')) {
|
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 });
|
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) => {
|
return liste.map((r) => {
|
||||||
const o = overrides.get(r.id) || {};
|
const o = overrides.get(r.id) || {};
|
||||||
const hooks = o.hooks || {};
|
const hooks = o.hooks || {};
|
||||||
return {
|
return {
|
||||||
path: r.path,
|
path: r.path,
|
||||||
name: r.name,
|
name: r.name,
|
||||||
iconPath: r.icon_path,
|
iconPath: r.icon_path,
|
||||||
installiert: !r.install_stage || r.install_stage === 'installed',
|
installiert: !r.install_stage || r.install_stage === 'installed',
|
||||||
lastPlayed: r.last_played,
|
lastPlayed: r.last_played,
|
||||||
sekunden: (Number(r.submitted_time_played) || 0) + (Number(r.recent_time_played) || 0),
|
sekunden: (Number(r.submitted_time_played) || 0) + (Number(r.recent_time_played) || 0),
|
||||||
gameVersion: r.game_version,
|
gameVersion: r.game_version,
|
||||||
loader: r.loader,
|
loader: r.loader,
|
||||||
loaderVersion: r.loader_version,
|
loaderVersion: r.loader_version,
|
||||||
gruppe: gruppen.get(r.id) || '',
|
gruppe: gruppen.get(r.id) || '',
|
||||||
javaPath: o.java_path || '',
|
javaPath: o.java_path || '',
|
||||||
maxMemMb: o.memory && Number(o.memory.maximum) ? Number(o.memory.maximum) : null,
|
maxMemMb: o.memory && Number(o.memory.maximum) ? Number(o.memory.maximum) : null,
|
||||||
extraArgs: Array.isArray(o.extra_launch_args) ? o.extra_launch_args.join(' ') : '',
|
extraArgs: Array.isArray(o.extra_launch_args) ? o.extra_launch_args.join(' ') : '',
|
||||||
vorStart: hooks.pre_launch || '',
|
vorStart: hooks.pre_launch || '',
|
||||||
nachEnde: hooks.post_exit || '',
|
nachEnde: hooks.post_exit || '',
|
||||||
modpack: links.get(r.id) || null,
|
modpack: links.get(r.id) || null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function leseProfile(db) {
|
function leseProfile(db) {
|
||||||
const s = spalten(db, 'profiles');
|
const s = spalten(db, 'profiles');
|
||||||
const liste = zeilen(db, `SELECT path, name,
|
const liste = zeilen(db, `SELECT path, name,
|
||||||
${sp(s, 'icon_path', 'icon_path')}, ${sp(s, 'install_stage', 'install_stage')},
|
${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, '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, 'mod_loader_version', 'mod_loader_version')}, ${sp(s, 'json(groups)', 'groups')},
|
||||||
${sp(s, 'last_played', 'last_played')},
|
${sp(s, 'last_played', 'last_played')},
|
||||||
${sp(s, 'submitted_time_played', 'submitted_time_played', '0')},
|
${sp(s, 'submitted_time_played', 'submitted_time_played', '0')},
|
||||||
${sp(s, 'recent_time_played', 'recent_time_played', '0')},
|
${sp(s, 'recent_time_played', 'recent_time_played', '0')},
|
||||||
${sp(s, 'override_java_path', 'override_java_path')},
|
${sp(s, 'override_java_path', 'override_java_path')},
|
||||||
${sp(s, 'json(override_extra_launch_args)', 'override_extra_launch_args')},
|
${sp(s, 'json(override_extra_launch_args)', 'override_extra_launch_args')},
|
||||||
${sp(s, 'override_mc_memory_max', 'override_mc_memory_max')},
|
${sp(s, 'override_mc_memory_max', 'override_mc_memory_max')},
|
||||||
${sp(s, 'override_hook_pre_launch', 'override_hook_pre_launch')},
|
${sp(s, 'override_hook_pre_launch', 'override_hook_pre_launch')},
|
||||||
${sp(s, 'override_hook_post_exit', 'override_hook_post_exit')},
|
${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')}
|
${sp(s, 'linked_project_id', 'linked_project_id')}, ${sp(s, 'linked_version_id', 'linked_version_id')}
|
||||||
FROM profiles`);
|
FROM profiles`);
|
||||||
return liste.map((r) => {
|
return liste.map((r) => {
|
||||||
const gruppen = jsonFeld(r.groups);
|
const gruppen = jsonFeld(r.groups);
|
||||||
const args = jsonFeld(r.override_extra_launch_args);
|
const args = jsonFeld(r.override_extra_launch_args);
|
||||||
return {
|
return {
|
||||||
path: r.path,
|
path: r.path,
|
||||||
name: r.name,
|
name: r.name,
|
||||||
iconPath: r.icon_path,
|
iconPath: r.icon_path,
|
||||||
installiert: !r.install_stage || r.install_stage === 'installed',
|
installiert: !r.install_stage || r.install_stage === 'installed',
|
||||||
lastPlayed: r.last_played,
|
lastPlayed: r.last_played,
|
||||||
sekunden: (Number(r.submitted_time_played) || 0) + (Number(r.recent_time_played) || 0),
|
sekunden: (Number(r.submitted_time_played) || 0) + (Number(r.recent_time_played) || 0),
|
||||||
gameVersion: r.game_version,
|
gameVersion: r.game_version,
|
||||||
loader: r.mod_loader,
|
loader: r.mod_loader,
|
||||||
loaderVersion: r.mod_loader_version,
|
loaderVersion: r.mod_loader_version,
|
||||||
gruppe: Array.isArray(gruppen) && gruppen.length ? String(gruppen[0]) : '',
|
gruppe: Array.isArray(gruppen) && gruppen.length ? String(gruppen[0]) : '',
|
||||||
javaPath: r.override_java_path || '',
|
javaPath: r.override_java_path || '',
|
||||||
maxMemMb: Number(r.override_mc_memory_max) || null,
|
maxMemMb: Number(r.override_mc_memory_max) || null,
|
||||||
extraArgs: Array.isArray(args) ? args.join(' ') : '',
|
extraArgs: Array.isArray(args) ? args.join(' ') : '',
|
||||||
vorStart: r.override_hook_pre_launch || '',
|
vorStart: r.override_hook_pre_launch || '',
|
||||||
nachEnde: r.override_hook_post_exit || '',
|
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,
|
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
|
// sehr alte Fassungen: profiles\<Ordner>\profile.json
|
||||||
function leseProfileJson(profilesDir) {
|
function leseProfileJson(profilesDir) {
|
||||||
const liste = [];
|
const liste = [];
|
||||||
for (const e of fs.readdirSync(profilesDir, { withFileTypes: true })) {
|
for (const e of fs.readdirSync(profilesDir, { withFileTypes: true })) {
|
||||||
if (!e.isDirectory()) continue;
|
if (!e.isDirectory()) continue;
|
||||||
const p = readJson(path.join(profilesDir, e.name, 'profile.json'));
|
const p = readJson(path.join(profilesDir, e.name, 'profile.json'));
|
||||||
if (!p) continue;
|
if (!p) continue;
|
||||||
const m = p.metadata || p;
|
const m = p.metadata || p;
|
||||||
const lv = m.loader_version;
|
const lv = m.loader_version;
|
||||||
liste.push({
|
liste.push({
|
||||||
path: e.name,
|
path: e.name,
|
||||||
name: m.name || e.name,
|
name: m.name || e.name,
|
||||||
iconPath: m.icon || null,
|
iconPath: m.icon || null,
|
||||||
installiert: !p.install_stage || p.install_stage === 'installed',
|
installiert: !p.install_stage || p.install_stage === 'installed',
|
||||||
lastPlayed: m.last_played || null,
|
lastPlayed: m.last_played || null,
|
||||||
sekunden: 0,
|
sekunden: 0,
|
||||||
gameVersion: m.game_version,
|
gameVersion: m.game_version,
|
||||||
loader: m.loader,
|
loader: m.loader,
|
||||||
loaderVersion: lv && typeof lv === 'object' ? lv.id : lv,
|
loaderVersion: lv && typeof lv === 'object' ? lv.id : lv,
|
||||||
gruppe: Array.isArray(m.groups) && m.groups.length ? String(m.groups[0]) : '',
|
gruppe: Array.isArray(m.groups) && m.groups.length ? String(m.groups[0]) : '',
|
||||||
javaPath: (p.java && (p.java.override_version && p.java.override_version.path)) || '',
|
javaPath: (p.java && (p.java.override_version && p.java.override_version.path)) || '',
|
||||||
maxMemMb: p.memory && Number(p.memory.maximum) ? Number(p.memory.maximum) : null,
|
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(' ') : '',
|
extraArgs: p.java && Array.isArray(p.java.extra_arguments) ? p.java.extra_arguments.join(' ') : '',
|
||||||
vorStart: (p.hooks && p.hooks.pre_launch) || '',
|
vorStart: (p.hooks && p.hooks.pre_launch) || '',
|
||||||
nachEnde: (p.hooks && p.hooks.post_exit) || '',
|
nachEnde: (p.hooks && p.hooks.post_exit) || '',
|
||||||
modpack: m.linked_data && m.linked_data.project_id && m.linked_data.version_id
|
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,
|
? { projectId: m.linked_data.project_id, versionId: m.linked_data.version_id } : null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return liste;
|
return liste;
|
||||||
}
|
}
|
||||||
|
|
||||||
// "1.20.1-47.2.0" -> "47.2.0" usw. – AeroMC erwartet die reine Loader-Version
|
// "1.20.1-47.2.0" -> "47.2.0" usw. – AeroMC erwartet die reine Loader-Version
|
||||||
function loaderVersion(loader, wert, mc) {
|
function loaderVersion(loader, wert, mc) {
|
||||||
let v = String(wert || '').trim();
|
let v = String(wert || '').trim();
|
||||||
if (!v || v === 'stable' || v === 'latest') return '';
|
if (!v || v === 'stable' || v === 'latest') return '';
|
||||||
if (loader === 'forge') {
|
if (loader === 'forge') {
|
||||||
v = v.replace(/^forge-/i, '');
|
v = v.replace(/^forge-/i, '');
|
||||||
if (mc && v.startsWith(mc + '-')) v = v.slice(mc.length + 1);
|
if (mc && v.startsWith(mc + '-')) v = v.slice(mc.length + 1);
|
||||||
} else if (loader === 'neoforge') {
|
} else if (loader === 'neoforge') {
|
||||||
v = v.replace(/^neoforge-/i, '');
|
v = v.replace(/^neoforge-/i, '');
|
||||||
} else if (loader === 'fabric' || loader === 'quilt') {
|
} else if (loader === 'fabric' || loader === 'quilt') {
|
||||||
v = v.replace(/^(fabric|quilt)(-loader)?-/i, '');
|
v = v.replace(/^(fabric|quilt)(-loader)?-/i, '');
|
||||||
if (mc && v.endsWith('-' + mc)) v = v.slice(0, -(mc.length + 1));
|
if (mc && v.endsWith('-' + mc)) v = v.slice(0, -(mc.length + 1));
|
||||||
}
|
}
|
||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
|
|
||||||
function zeitstempel(wert) {
|
function zeitstempel(wert) {
|
||||||
if (!wert) return null;
|
if (!wert) return null;
|
||||||
const n = Number(wert);
|
const n = Number(wert);
|
||||||
if (Number.isFinite(n) && n > 0) return new Date(n < 1e12 ? n * 1000 : n).toISOString();
|
if (Number.isFinite(n) && n > 0) return new Date(n < 1e12 ? n * 1000 : n).toISOString();
|
||||||
const d = new Date(wert);
|
const d = new Date(wert);
|
||||||
return Number.isNaN(d.getTime()) ? null : d.toISOString();
|
return Number.isNaN(d.getTime()) ? null : d.toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Durchsucht die Modrinth App. Leere Eingabe = Standardort.
|
* Durchsucht die Modrinth App. Leere Eingabe = Standardort.
|
||||||
* -> { ok, basis, count, instances, hinweis }
|
* -> { ok, basis, count, instances, hinweis }
|
||||||
*/
|
*/
|
||||||
async function scan(eingabe) {
|
async function scan(eingabe) {
|
||||||
const orte = ermittleOrte(eingabe);
|
const orte = ermittleOrte(eingabe);
|
||||||
if (!orte) return { ok: false, reason: 'not-found' };
|
if (!orte) return { ok: false, reason: 'not-found' };
|
||||||
|
|
||||||
let roh = [];
|
let roh = [];
|
||||||
let profilesDir = path.join(orte.basis, 'profiles');
|
let profilesDir = path.join(orte.basis, 'profiles');
|
||||||
let hinweis = '';
|
let hinweis = '';
|
||||||
if (orte.db) {
|
if (orte.db) {
|
||||||
const db = await oeffneDb(orte.db);
|
const db = await oeffneDb(orte.db);
|
||||||
try {
|
try {
|
||||||
const t = tabellen(db);
|
const t = tabellen(db);
|
||||||
const eigener = eigenerOrdner(db, t);
|
const eigener = eigenerOrdner(db, t);
|
||||||
if (eigener && fs.existsSync(path.join(eigener, 'profiles'))) profilesDir = path.join(eigener, 'profiles');
|
if (eigener && fs.existsSync(path.join(eigener, 'profiles'))) profilesDir = path.join(eigener, 'profiles');
|
||||||
if (t.has('instances')) roh = leseNeu(db, t);
|
if (t.has('instances')) roh = leseNeu(db, t);
|
||||||
else if (t.has('profiles')) roh = leseProfile(db);
|
else if (t.has('profiles')) roh = leseProfile(db);
|
||||||
} finally {
|
} finally {
|
||||||
db.close();
|
db.close();
|
||||||
}
|
}
|
||||||
// Schreibprotokoll vorhanden, ließ sich aber nicht einspielen -> Kopie
|
// Schreibprotokoll vorhanden, ließ sich aber nicht einspielen -> Kopie
|
||||||
// ist evtl. nicht ganz aktuell
|
// ist evtl. nicht ganz aktuell
|
||||||
try {
|
try {
|
||||||
const wal = orte.db + '-wal';
|
const wal = orte.db + '-wal';
|
||||||
if (!db.walEingespielt && fs.existsSync(wal) && fs.statSync(wal).size > 32) {
|
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.';
|
hinweis = 'Die Modrinth App scheint gerade zu laufen – schließe sie, damit auch die neuesten Änderungen übernommen werden.';
|
||||||
}
|
}
|
||||||
} catch { /* egal */ }
|
} catch { /* egal */ }
|
||||||
} else if (fs.existsSync(profilesDir)) {
|
} else if (fs.existsSync(profilesDir)) {
|
||||||
roh = leseProfileJson(profilesDir);
|
roh = leseProfileJson(profilesDir);
|
||||||
} else {
|
} else {
|
||||||
return { ok: false, reason: 'not-found' };
|
return { ok: false, reason: 'not-found' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const instances = [];
|
const instances = [];
|
||||||
for (const r of roh) {
|
for (const r of roh) {
|
||||||
const dir = path.join(profilesDir, String(r.path || ''));
|
const dir = path.join(profilesDir, String(r.path || ''));
|
||||||
if (!r.path || !fs.existsSync(dir)) continue;
|
if (!r.path || !fs.existsSync(dir)) continue;
|
||||||
const loader = LOADER.has(String(r.loader || '').toLowerCase()) ? String(r.loader).toLowerCase() : 'vanilla';
|
const loader = LOADER.has(String(r.loader || '').toLowerCase()) ? String(r.loader).toLowerCase() : 'vanilla';
|
||||||
const version = String(r.gameVersion || '').trim();
|
const version = String(r.gameVersion || '').trim();
|
||||||
const icon = r.iconPath && fs.existsSync(String(r.iconPath)) ? String(r.iconPath) : '';
|
const icon = r.iconPath && fs.existsSync(String(r.iconPath)) ? String(r.iconPath) : '';
|
||||||
instances.push({
|
instances.push({
|
||||||
folder: r.path,
|
folder: r.path,
|
||||||
dir,
|
dir,
|
||||||
gameDir: dir,
|
gameDir: dir,
|
||||||
name: String(r.name || r.path),
|
name: String(r.name || r.path),
|
||||||
group: r.gruppe || '',
|
group: r.gruppe || '',
|
||||||
version,
|
version,
|
||||||
loader,
|
loader,
|
||||||
loaderVersion: loader === 'vanilla' ? '' : loaderVersion(loader, r.loaderVersion, version),
|
loaderVersion: loader === 'vanilla' ? '' : loaderVersion(loader, r.loaderVersion, version),
|
||||||
installiert: r.installiert,
|
installiert: r.installiert,
|
||||||
iconPath: icon,
|
iconPath: icon,
|
||||||
lastPlayed: zeitstempel(r.lastPlayed),
|
lastPlayed: zeitstempel(r.lastPlayed),
|
||||||
playSeconds: Math.max(0, Math.round(r.sekunden || 0)),
|
playSeconds: Math.max(0, Math.round(r.sekunden || 0)),
|
||||||
javaPath: String(r.javaPath || '').trim(),
|
javaPath: String(r.javaPath || '').trim(),
|
||||||
maxMemMb: r.maxMemMb || null,
|
maxMemMb: r.maxMemMb || null,
|
||||||
extraArgs: String(r.extraArgs || '').trim(),
|
extraArgs: String(r.extraArgs || '').trim(),
|
||||||
preLaunchCommand: String(r.vorStart || '').trim(),
|
preLaunchCommand: String(r.vorStart || '').trim(),
|
||||||
postExitCommand: String(r.nachEnde || '').trim(),
|
postExitCommand: String(r.nachEnde || '').trim(),
|
||||||
modpack: r.modpack,
|
modpack: r.modpack,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
instances.sort((a, b) => a.name.localeCompare(b.name, 'de'));
|
instances.sort((a, b) => a.name.localeCompare(b.name, 'de'));
|
||||||
return { ok: true, basis: orte.basis, profilesDir, count: instances.length, instances, hinweis };
|
return { ok: true, basis: orte.basis, profilesDir, count: instances.length, instances, hinweis };
|
||||||
}
|
}
|
||||||
|
|
||||||
// gehört zur Modrinth App bzw. wird ohnehin neu erzeugt
|
// gehört zur Modrinth App bzw. wird ohnehin neu erzeugt
|
||||||
const NICHT_KOPIEREN = new Set(['profile.json', 'logs', '.fabric', '.quilt']);
|
const NICHT_KOPIEREN = new Set(['profile.json', 'logs', '.fabric', '.quilt']);
|
||||||
|
|
||||||
function copyInto(quelle, ziel) {
|
/*
|
||||||
fs.mkdirSync(ziel, { recursive: true });
|
* Was beim Übernehmen kopiert wird: [{ von, name }] – `name` ist der Zielname
|
||||||
for (const name of fs.readdirSync(quelle)) {
|
* im Spielordner der neuen Instanz. Das Kopieren selbst erledigt kopieren.js
|
||||||
if (NICHT_KOPIEREN.has(name.toLowerCase())) continue;
|
* asynchron mit Fortschritt (main.js → fuehreImportAus).
|
||||||
// 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 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 importOne(store, entry, copyData = true) {
|
}
|
||||||
const created = store.createOrReplaceImportedInstance({
|
|
||||||
name: entry.name,
|
function copyInto(quelle, ziel) {
|
||||||
group: entry.group,
|
fs.mkdirSync(ziel, { recursive: true });
|
||||||
minecraft: { version: entry.version, loader: entry.loader, loaderVersion: entry.loaderVersion },
|
for (const name of fs.readdirSync(quelle)) {
|
||||||
});
|
if (NICHT_KOPIEREN.has(name.toLowerCase())) continue;
|
||||||
|
// dereference: die App verlinkt Dateien teils aus ihrem gemeinsamen Speicher
|
||||||
const patch = {};
|
fs.cpSync(path.join(quelle, name), path.join(ziel, name), { recursive: true, dereference: true });
|
||||||
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;
|
function importOne(store, entry, copyData = true) {
|
||||||
if (entry.postExitCommand) patch.postExitCommand = entry.postExitCommand;
|
const created = store.createOrReplaceImportedInstance({
|
||||||
// Spielzeit und letzten Start mitnehmen (falls es die Instanz schon gab: das Größere)
|
name: entry.name,
|
||||||
const vorher = store.getInstance(created.id) || {};
|
group: entry.group,
|
||||||
if (entry.playSeconds > (vorher.totalPlaySeconds || 0)) patch.totalPlaySeconds = entry.playSeconds;
|
minecraft: { version: entry.version, loader: entry.loader, loaderVersion: entry.loaderVersion },
|
||||||
if (entry.lastPlayed && (!vorher.lastPlayed || entry.lastPlayed > vorher.lastPlayed)) patch.lastPlayed = entry.lastPlayed;
|
});
|
||||||
if (Object.keys(patch).length) store.updateInstance(created.id, patch);
|
|
||||||
|
const patch = {};
|
||||||
let copied = false;
|
if (entry.javaPath || entry.maxMemMb || entry.extraArgs) {
|
||||||
if (copyData && entry.gameDir && fs.existsSync(entry.gameDir)) {
|
patch.java = { path: entry.javaPath || '', minMemMb: null, maxMemMb: entry.maxMemMb || null, extraArgs: entry.extraArgs || '' };
|
||||||
copyInto(entry.gameDir, store.gameDir(created.id));
|
}
|
||||||
copied = true;
|
if (entry.preLaunchCommand) patch.preLaunchCommand = entry.preLaunchCommand;
|
||||||
}
|
if (entry.postExitCommand) patch.postExitCommand = entry.postExitCommand;
|
||||||
if (entry.iconPath) {
|
// Spielzeit und letzten Start mitnehmen (falls es die Instanz schon gab: das Größere)
|
||||||
try { store.setCustomImage(created.id, entry.iconPath); } catch { /* Bild ist kein Muss */ }
|
const vorher = store.getInstance(created.id) || {};
|
||||||
}
|
if (entry.playSeconds > (vorher.totalPlaySeconds || 0)) patch.totalPlaySeconds = entry.playSeconds;
|
||||||
return { id: created.id, name: created.name, copied };
|
if (entry.lastPlayed && (!vorher.lastPlayed || entry.lastPlayed > vorher.lastPlayed)) patch.lastPlayed = entry.lastPlayed;
|
||||||
}
|
if (Object.keys(patch).length) store.updateInstance(created.id, patch);
|
||||||
|
|
||||||
module.exports = { scan, importOne, standardOrdner };
|
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 };
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; img-src 'self'" />
|
||||||
|
<title>AeroMC</title>
|
||||||
|
<!--
|
||||||
|
Neustart-Bildschirm (Mod-Schalter): liegt über dem Minecraft-Fenster, während
|
||||||
|
das Spiel beendet, die Mods umgestellt und Minecraft wieder gestartet wird –
|
||||||
|
bis man wieder auf dem Server bzw. in der Welt ist. Gleiches Bild wie der
|
||||||
|
Ladebildschirm im Spiel; Balken und Text an derselben Stelle (83,25 % der Höhe).
|
||||||
|
Der Launcher steuert die Seite über setze({ text, prozent }) und ausblenden().
|
||||||
|
-->
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; height: 100%; overflow: hidden; background: #0a0e1a; cursor: default; user-select: none; }
|
||||||
|
#bild { position: fixed; inset: 0; background: #0a0e1a url('assets/spieldesign/neustart.jpg') center / cover no-repeat; }
|
||||||
|
#ganz { position: fixed; inset: 0; opacity: 0; transition: opacity .35s ease; }
|
||||||
|
#ganz.sichtbar { opacity: 1; }
|
||||||
|
#balken {
|
||||||
|
position: absolute; left: 22%; right: 22%; top: 83.25%; height: max(8px, 1.15vh); transform: translateY(-50%);
|
||||||
|
border: max(1px, .18vh) solid #4fd2ff; padding: max(1px, .18vh); box-sizing: content-box; overflow: hidden;
|
||||||
|
}
|
||||||
|
#fuellung { height: 100%; width: 0; background: #4fd2ff; transition: width .4s ease; }
|
||||||
|
#balken.unbestimmt #fuellung { width: 30%; animation: wandern 1.3s ease-in-out infinite; }
|
||||||
|
@keyframes wandern { 0% { transform: translateX(-110%); } 100% { transform: translateX(370%); } }
|
||||||
|
#text {
|
||||||
|
position: absolute; left: 50%; top: 88.5%; transform: translateX(-50%);
|
||||||
|
padding: .55vh 1.6vh .7vh; border-radius: .9vh; background: rgba(6, 12, 28, .7);
|
||||||
|
color: #ecf4ff; font: 600 max(13px, 3.1vh) 'Segoe UI', sans-serif; white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="ganz">
|
||||||
|
<div id="bild"></div>
|
||||||
|
<div id="balken" class="unbestimmt"><div id="fuellung"></div></div>
|
||||||
|
<div id="text">Mods werden übernommen …</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
function setze(stand) {
|
||||||
|
if (stand.text) document.getElementById('text').textContent = stand.text;
|
||||||
|
const balken = document.getElementById('balken');
|
||||||
|
const hatProzent = typeof stand.prozent === 'number';
|
||||||
|
balken.classList.toggle('unbestimmt', !hatProzent);
|
||||||
|
document.getElementById('fuellung').style.width = hatProzent ? Math.max(2, Math.min(100, stand.prozent)) + '%' : '';
|
||||||
|
}
|
||||||
|
function ausblenden() { document.getElementById('ganz').classList.remove('sichtbar'); }
|
||||||
|
requestAnimationFrame(() => document.getElementById('ganz').classList.add('sichtbar'));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,396 +1,455 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* preload.js – stellt dem Renderer eine schmale, sichere API bereit.
|
* preload.js – stellt dem Renderer eine schmale, sichere API bereit.
|
||||||
* contextIsolation ist aktiv; der Renderer hat KEINEN direkten Node-Zugriff.
|
* contextIsolation ist aktiv; der Renderer hat KEINEN direkten Node-Zugriff.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { contextBridge, ipcRenderer } = require('electron');
|
const { contextBridge, ipcRenderer, webUtils } = require('electron');
|
||||||
|
|
||||||
// Debug-Hilfe: JS-Fehler im Renderer landen im Terminal des Hauptprozesses,
|
// Debug-Hilfe: JS-Fehler im Renderer landen im Terminal des Hauptprozesses,
|
||||||
// damit man sie auch ohne offene DevTools sieht
|
// damit man sie auch ohne offene DevTools sieht
|
||||||
window.addEventListener('error', (e) => {
|
window.addEventListener('error', (e) => {
|
||||||
ipcRenderer.send('debug:rendererError', (e.error && e.error.stack) || e.message);
|
ipcRenderer.send('debug:rendererError', (e.error && e.error.stack) || e.message);
|
||||||
});
|
});
|
||||||
window.addEventListener('unhandledrejection', (e) => {
|
window.addEventListener('unhandledrejection', (e) => {
|
||||||
ipcRenderer.send('debug:rendererError', 'unhandledrejection: ' + ((e.reason && e.reason.stack) || e.reason));
|
ipcRenderer.send('debug:rendererError', 'unhandledrejection: ' + ((e.reason && e.reason.stack) || e.reason));
|
||||||
});
|
});
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('api', {
|
// Zuletzt angeklickter Knopf: Popup-Fenster wachsen daraus hervor
|
||||||
// Einstellungen
|
// (main.js → openPopout). Gilt nur kurz, sonst kämen sie aus einem alten Klick.
|
||||||
getSettings: () => ipcRenderer.invoke('settings:get'),
|
let letzterKlick = null;
|
||||||
saveSettings: (patch) => ipcRenderer.invoke('settings:save', patch),
|
window.addEventListener('pointerdown', (e) => {
|
||||||
getInstancesDir: () => ipcRenderer.invoke('settings:instancesDir'),
|
const t = e.target && e.target.closest ? (e.target.closest('button, a, [role="button"], .card, .snav') || e.target) : null;
|
||||||
detectJava: (javaPath) => ipcRenderer.invoke('java:detect', javaPath),
|
if (!t || !t.getBoundingClientRect) return;
|
||||||
scanJava: () => ipcRenderer.invoke('java:scan'),
|
const r = t.getBoundingClientRect();
|
||||||
listJava: () => ipcRenderer.invoke('java:list'),
|
letzterKlick = { x: r.left, y: r.top, width: r.width, height: r.height, zeit: Date.now() };
|
||||||
|
}, true);
|
||||||
// Minecraft-Versionen (Mojang)
|
function frischerKlick() {
|
||||||
mcVersions: () => ipcRenderer.invoke('mc:versions'),
|
if (!letzterKlick || Date.now() - letzterKlick.zeit > 2000) return null;
|
||||||
loaderVersions: (loader, mcVersion) => ipcRenderer.invoke('loaders:versions', loader, mcVersion),
|
const { x, y, width, height } = letzterKlick;
|
||||||
optifineFind: (mcVersion) => ipcRenderer.invoke('optifine:find', mcVersion),
|
return { x, y, width, height };
|
||||||
optifineSetup: (id) => ipcRenderer.invoke('optifine:setup', id),
|
}
|
||||||
optifineConfirm: (id) => ipcRenderer.invoke('optifine:confirm', id),
|
|
||||||
|
contextBridge.exposeInMainWorld('api', {
|
||||||
// Mods (Modrinth)
|
// Einstellungen
|
||||||
modSearch: (opts) => ipcRenderer.invoke('mods:search', opts),
|
getSettings: () => ipcRenderer.invoke('settings:get'),
|
||||||
curseforgeTestKey: (apiKey) => ipcRenderer.invoke('curseforge:testKey', apiKey),
|
saveSettings: (patch) => ipcRenderer.invoke('settings:save', patch),
|
||||||
// art: 'mod' | 'resourcepack' | 'shader'
|
getInstancesDir: () => ipcRenderer.invoke('settings:instancesDir'),
|
||||||
modList: (id, art) => ipcRenderer.invoke('mods:list', id, art),
|
detectJava: (javaPath) => ipcRenderer.invoke('java:detect', javaPath),
|
||||||
modInstall: (id, project, art) => ipcRenderer.invoke('mods:install', id, project, art),
|
scanJava: () => ipcRenderer.invoke('java:scan'),
|
||||||
modRemove: (id, filename, art) => ipcRenderer.invoke('mods:remove', id, filename, art),
|
listJava: () => ipcRenderer.invoke('java:list'),
|
||||||
modDependents: (id, filename, art) => ipcRenderer.invoke('mods:dependents', id, filename, art),
|
|
||||||
modAddFile: (id, art) => ipcRenderer.invoke('mods:addFile', id, art),
|
// Minecraft-Versionen (Mojang)
|
||||||
modSetEnabled: (id, filename, enabled, art) => ipcRenderer.invoke('mods:setEnabled', id, filename, enabled, art),
|
mcVersions: () => ipcRenderer.invoke('mc:versions'),
|
||||||
systemMemory: () => ipcRenderer.invoke('system:memory'),
|
loaderVersions: (loader, mcVersion) => ipcRenderer.invoke('loaders:versions', loader, mcVersion),
|
||||||
checkMemory: (minMb, maxMb) => ipcRenderer.invoke('system:checkMemory', minMb, maxMb),
|
optifineFind: (mcVersion) => ipcRenderer.invoke('optifine:find', mcVersion),
|
||||||
crashLatest: (id, seit) => ipcRenderer.invoke('crash:latest', id, seit),
|
optifineSetup: (id) => ipcRenderer.invoke('optifine:setup', id),
|
||||||
crashAnalyze: (id, seit) => ipcRenderer.invoke('crash:analyze', id, seit),
|
optifineConfirm: (id) => ipcRenderer.invoke('optifine:confirm', id),
|
||||||
crashFix: (id, aktion) => ipcRenderer.invoke('crash:fix', id, aktion),
|
|
||||||
crashRelaunch: (id) => ipcRenderer.invoke('crash:relaunch', id),
|
// Mods (Modrinth)
|
||||||
shareCreate: (id) => ipcRenderer.invoke('share:create', id),
|
modSearch: (opts) => ipcRenderer.invoke('mods:search', opts),
|
||||||
shareFetch: (code) => ipcRenderer.invoke('share:fetch', code),
|
curseforgeTestKey: (apiKey) => ipcRenderer.invoke('curseforge:testKey', apiKey),
|
||||||
shareInstall: (code) => ipcRenderer.invoke('share:install', code),
|
// art: 'mod' | 'resourcepack' | 'shader'
|
||||||
onShareProgress: (cb) => {
|
modList: (id, art) => ipcRenderer.invoke('mods:list', id, art),
|
||||||
const l = (_e, p) => cb(p);
|
modInstall: (id, project, art) => ipcRenderer.invoke('mods:install', id, project, art),
|
||||||
ipcRenderer.on('share:progress', l);
|
modRemove: (id, filename, art) => ipcRenderer.invoke('mods:remove', id, filename, art),
|
||||||
return () => ipcRenderer.removeListener('share:progress', l);
|
modDependents: (id, filename, art) => ipcRenderer.invoke('mods:dependents', id, filename, art),
|
||||||
},
|
modAddFile: (id, art) => ipcRenderer.invoke('mods:addFile', id, art),
|
||||||
onAutoBackup: (cb) => {
|
modSetEnabled: (id, filename, enabled, art) => ipcRenderer.invoke('mods:setEnabled', id, filename, enabled, art),
|
||||||
const l = (_e, d) => cb(d);
|
systemMemory: () => ipcRenderer.invoke('system:memory'),
|
||||||
ipcRenderer.on('backup:auto', l);
|
checkMemory: (minMb, maxMb) => ipcRenderer.invoke('system:checkMemory', minMb, maxMb),
|
||||||
return () => ipcRenderer.removeListener('backup:auto', l);
|
crashLatest: (id, seit) => ipcRenderer.invoke('crash:latest', id, seit),
|
||||||
},
|
crashAnalyze: (id, seit) => ipcRenderer.invoke('crash:analyze', id, seit),
|
||||||
crashOpenFolder: (id) => ipcRenderer.invoke('crash:openFolder', id),
|
crashFix: (id, aktion) => ipcRenderer.invoke('crash:fix', id, aktion),
|
||||||
serversList: (id) => ipcRenderer.invoke('servers:list', id),
|
crashRelaunch: (id) => ipcRenderer.invoke('crash:relaunch', id),
|
||||||
serversAdd: (id, eintrag) => ipcRenderer.invoke('servers:add', id, eintrag),
|
shareCreate: (id) => ipcRenderer.invoke('share:create', id),
|
||||||
serversRemove: (id, ip) => ipcRenderer.invoke('servers:remove', id, ip),
|
shareFetch: (code) => ipcRenderer.invoke('share:fetch', code),
|
||||||
serversRename: (id, ip, neuerName) => ipcRenderer.invoke('servers:rename', id, ip, neuerName),
|
shareInstall: (code) => ipcRenderer.invoke('share:install', code),
|
||||||
serversCopyTo: (zielIds, eintrag) => ipcRenderer.invoke('servers:copyTo', zielIds, eintrag),
|
onShareProgress: (cb) => {
|
||||||
serversPing: (ip) => ipcRenderer.invoke('servers:ping', ip),
|
const l = (_e, p) => cb(p);
|
||||||
logsList: (id) => ipcRenderer.invoke('logs:list', id),
|
ipcRenderer.on('share:progress', l);
|
||||||
logsRead: (id, name) => ipcRenderer.invoke('logs:read', id, name),
|
return () => ipcRenderer.removeListener('share:progress', l);
|
||||||
logsOpenFolder: (id) => ipcRenderer.invoke('logs:openFolder', id),
|
},
|
||||||
modCheckUpdates: (id, art) => ipcRenderer.invoke('mods:checkUpdates', id, art),
|
onAutoBackup: (cb) => {
|
||||||
modApplyUpdates: (id, projectIds, art) => ipcRenderer.invoke('mods:applyUpdates', id, projectIds, art),
|
const l = (_e, d) => cb(d);
|
||||||
onModUpdateProgress: (cb) => {
|
ipcRenderer.on('backup:auto', l);
|
||||||
const l = (_e, d) => cb(d);
|
return () => ipcRenderer.removeListener('backup:auto', l);
|
||||||
ipcRenderer.on('mods:updateProgress', l);
|
},
|
||||||
return () => ipcRenderer.removeListener('mods:updateProgress', l);
|
crashOpenFolder: (id) => ipcRenderer.invoke('crash:openFolder', id),
|
||||||
},
|
serversList: (id) => ipcRenderer.invoke('servers:list', id),
|
||||||
|
serversAdd: (id, eintrag) => ipcRenderer.invoke('servers:add', id, eintrag),
|
||||||
// Instanzen
|
serversRemove: (id, ip) => ipcRenderer.invoke('servers:remove', id, ip),
|
||||||
listInstances: () => ipcRenderer.invoke('instances:list'),
|
serversRename: (id, ip, neuerName) => ipcRenderer.invoke('servers:rename', id, ip, neuerName),
|
||||||
getInstance: (id) => ipcRenderer.invoke('instances:get', id),
|
serversCopyTo: (zielIds, eintrag) => ipcRenderer.invoke('servers:copyTo', zielIds, eintrag),
|
||||||
createInstance: (data) => ipcRenderer.invoke('instances:create', data),
|
serversPing: (ip) => ipcRenderer.invoke('servers:ping', ip),
|
||||||
installPerformancePack: (id) => ipcRenderer.invoke('instances:installPerformancePack', id),
|
logsList: (id) => ipcRenderer.invoke('logs:list', id),
|
||||||
updateInstance: (id, patch) => ipcRenderer.invoke('instances:update', id, patch),
|
logsRead: (id, name) => ipcRenderer.invoke('logs:read', id, name),
|
||||||
deleteInstance: (id) => ipcRenderer.invoke('instances:delete', id),
|
logsOpenFolder: (id) => ipcRenderer.invoke('logs:openFolder', id),
|
||||||
duplicateInstance: (id) => ipcRenderer.invoke('instances:duplicate', id),
|
modCheckUpdates: (id, art) => ipcRenderer.invoke('mods:checkUpdates', id, art),
|
||||||
openInstanceFolder: (id) => ipcRenderer.invoke('instances:openFolder', id),
|
modApplyUpdates: (id, projectIds, art) => ipcRenderer.invoke('mods:applyUpdates', id, projectIds, art),
|
||||||
openGameDir: (id) => ipcRenderer.invoke('instances:openGameDir', id),
|
onModUpdateProgress: (cb) => {
|
||||||
|
const l = (_e, d) => cb(d);
|
||||||
// Welten & Ressourcenpakete
|
ipcRenderer.on('mods:updateProgress', l);
|
||||||
worlds: (id) => ipcRenderer.invoke('instance:worlds', id),
|
return () => ipcRenderer.removeListener('mods:updateProgress', l);
|
||||||
resourcepacks: (id) => ipcRenderer.invoke('instance:resourcepacks', id),
|
},
|
||||||
datapacks: (id) => ipcRenderer.invoke('instance:datapacks', id),
|
|
||||||
deleteContent: (id, sub, name) => ipcRenderer.invoke('instance:deleteContent', id, sub, name),
|
// Instanzen
|
||||||
backupWorld: (id, worldName) => ipcRenderer.invoke('world:backup', id, worldName),
|
listInstances: () => ipcRenderer.invoke('instances:list'),
|
||||||
worldCopyTo: (id, worldName, zielId) => ipcRenderer.invoke('world:copyTo', id, worldName, zielId),
|
getInstance: (id) => ipcRenderer.invoke('instances:get', id),
|
||||||
friendsSendScreenshot: (otherUuid, instanceId, datei, text) => ipcRenderer.invoke('friends:sendScreenshot', otherUuid, instanceId, datei, text),
|
createInstance: (data) => ipcRenderer.invoke('instances:create', data),
|
||||||
openSubdir: (id, sub) => ipcRenderer.invoke('instance:openSubdir', id, sub),
|
installPerformancePack: (id) => ipcRenderer.invoke('instances:installPerformancePack', id),
|
||||||
screenshots: (id) => ipcRenderer.invoke('instance:screenshots', id),
|
updateInstance: (id, patch) => ipcRenderer.invoke('instances:update', id, patch),
|
||||||
screenshotData: (id, name) => ipcRenderer.invoke('instance:screenshotData', id, name),
|
deleteInstance: (id) => ipcRenderer.invoke('instances:delete', id),
|
||||||
openScreenshot: (id, name) => ipcRenderer.invoke('instance:openScreenshot', id, name),
|
duplicateInstance: (id) => ipcRenderer.invoke('instances:duplicate', id),
|
||||||
launchInstance: (id, accountId, opts) => ipcRenderer.invoke('instances:launch', id, accountId, opts),
|
openInstanceFolder: (id) => ipcRenderer.invoke('instances:openFolder', id),
|
||||||
exportInstance: (id) => ipcRenderer.invoke('instances:export', id),
|
openGameDir: (id) => ipcRenderer.invoke('instances:openGameDir', id),
|
||||||
createGlobalBackup: () => ipcRenderer.invoke('backup:global'),
|
|
||||||
listGlobalBackups: () => ipcRenderer.invoke('backup:list'),
|
// Welten & Ressourcenpakete
|
||||||
restoreGlobalBackup: (sourcePath) => ipcRenderer.invoke('backup:restore', sourcePath),
|
worlds: (id) => ipcRenderer.invoke('instance:worlds', id),
|
||||||
onBackupProgress: (cb) => {
|
resourcepacks: (id) => ipcRenderer.invoke('instance:resourcepacks', id),
|
||||||
const l = (_e, d) => cb(d);
|
datapacks: (id) => ipcRenderer.invoke('instance:datapacks', id),
|
||||||
ipcRenderer.on('backup:progress', l);
|
deleteContent: (id, sub, name) => ipcRenderer.invoke('instance:deleteContent', id, sub, name),
|
||||||
return () => ipcRenderer.removeListener('backup:progress', l);
|
backupWorld: (id, worldName) => ipcRenderer.invoke('world:backup', id, worldName),
|
||||||
},
|
worldCopyTo: (id, worldName, zielId) => ipcRenderer.invoke('world:copyTo', id, worldName, zielId),
|
||||||
importInstance: () => ipcRenderer.invoke('instances:import'),
|
friendsSendScreenshot: (otherUuid, instanceId, datei, text) => ipcRenderer.invoke('friends:sendScreenshot', otherUuid, instanceId, datei, text),
|
||||||
createShortcut: (id, customName) => ipcRenderer.invoke('instances:shortcut', id, customName),
|
openSubdir: (id, sub) => ipcRenderer.invoke('instance:openSubdir', id, sub),
|
||||||
serversShortcut: (id, ip, name, customName) => ipcRenderer.invoke('servers:shortcut', id, ip, name, customName),
|
screenshots: (id) => ipcRenderer.invoke('instance:screenshots', id),
|
||||||
// Start über eine Desktop-Verknüpfung ("--launch=<id>", optional "--server=<ip>")
|
screenshotData: (id, name) => ipcRenderer.invoke('instance:screenshotData', id, name),
|
||||||
onLaunchRequest: (cb) => {
|
openScreenshot: (id, name) => ipcRenderer.invoke('instance:openScreenshot', id, name),
|
||||||
const l = (_e, payload) => cb(payload);
|
launchInstance: (id, accountId, opts) => ipcRenderer.invoke('instances:launch', id, accountId, opts),
|
||||||
ipcRenderer.on('instance:launch-request', l);
|
exportInstance: (id) => ipcRenderer.invoke('instances:export', id),
|
||||||
return () => ipcRenderer.removeListener('instance:launch-request', l);
|
integritaetWiederherstellen: (id) => ipcRenderer.invoke('integritaet:wiederherstellen', id),
|
||||||
},
|
// Pfad einer per Drag & Drop abgelegten Datei (File.path gibt es seit Electron 32 nicht mehr)
|
||||||
// Signalisiert dem Hauptprozess, dass onLaunchRequest jetzt registriert ist –
|
pfadVonDatei: (datei) => { try { return webUtils.getPathForFile(datei) || ''; } catch { return ''; } },
|
||||||
// verhindert, dass ein Start-Wunsch aus einer Verknüpfung verloren geht,
|
createGlobalBackup: () => ipcRenderer.invoke('backup:global'),
|
||||||
// weil er ankommt, bevor die Oberfläche fertig eingerichtet ist
|
listGlobalBackups: () => ipcRenderer.invoke('backup:list'),
|
||||||
notifyReady: () => ipcRenderer.send('renderer:ready'),
|
restoreGlobalBackup: (sourcePath) => ipcRenderer.invoke('backup:restore', sourcePath),
|
||||||
importModpack: () => ipcRenderer.invoke('modpack:import'),
|
onBackupProgress: (cb) => {
|
||||||
// Datei per Drag&Drop aufs Fenster gezogen (.jar/.mrpack/.zip)
|
const l = (_e, d) => cb(d);
|
||||||
filesDrop: (filePath, targetId, targetArt) => ipcRenderer.invoke('files:drop', filePath, targetId, targetArt),
|
ipcRenderer.on('backup:progress', l);
|
||||||
modpackSearch: (opts) => ipcRenderer.invoke('modpack:search', opts),
|
return () => ipcRenderer.removeListener('backup:progress', l);
|
||||||
modpackInstallFromModrinth: (project) => ipcRenderer.invoke('modpack:installFromModrinth', project),
|
},
|
||||||
modpackInstallFromCurseForge: (project) => ipcRenderer.invoke('modpack:installFromCurseForge', project),
|
importInstance: () => ipcRenderer.invoke('instances:import'),
|
||||||
modpackCheck: (id) => ipcRenderer.invoke('modpack:check', id),
|
createShortcut: (id, customName) => ipcRenderer.invoke('instances:shortcut', id, customName),
|
||||||
modpackUpdate: (id, versionId) => ipcRenderer.invoke('modpack:update', id, versionId),
|
serversShortcut: (id, ip, name, customName) => ipcRenderer.invoke('servers:shortcut', id, ip, name, customName),
|
||||||
onLaunchProgress: (cb) => {
|
// Start über eine Desktop-Verknüpfung ("--launch=<id>", optional "--server=<ip>")
|
||||||
const listener = (_e, data) => cb(data);
|
onLaunchRequest: (cb) => {
|
||||||
ipcRenderer.on('launch:progress', listener);
|
const l = (_e, payload) => cb(payload);
|
||||||
return () => ipcRenderer.removeListener('launch:progress', listener);
|
ipcRenderer.on('instance:launch-request', l);
|
||||||
},
|
return () => ipcRenderer.removeListener('instance:launch-request', l);
|
||||||
|
},
|
||||||
// eigenes Instanz-Bild
|
// Signalisiert dem Hauptprozess, dass onLaunchRequest jetzt registriert ist –
|
||||||
pickImage: () => ipcRenderer.invoke('dialog:pickImage'),
|
// verhindert, dass ein Start-Wunsch aus einer Verknüpfung verloren geht,
|
||||||
setInstanceImage: (id, sourcePath) => ipcRenderer.invoke('instances:setImage', id, sourcePath),
|
// weil er ankommt, bevor die Oberfläche fertig eingerichtet ist
|
||||||
clearInstanceImage: (id) => ipcRenderer.invoke('instances:clearImage', id),
|
notifyReady: () => ipcRenderer.send('renderer:ready'),
|
||||||
instanceIcon: (id) => ipcRenderer.invoke('instances:icon', id),
|
importModpack: () => ipcRenderer.invoke('modpack:import'),
|
||||||
fileDataUri: (p) => ipcRenderer.invoke('file:dataUri', p),
|
// Datei per Drag&Drop aufs Fenster gezogen (.jar/.mrpack/.zip)
|
||||||
|
filesDrop: (filePath, targetId, targetArt) => ipcRenderer.invoke('files:drop', filePath, targetId, targetArt),
|
||||||
// Microsoft-Login (Phase 3) + Mehrkonten
|
modpackSearch: (opts) => ipcRenderer.invoke('modpack:search', opts),
|
||||||
authStatus: () => ipcRenderer.invoke('auth:status'),
|
modpackInstallFromModrinth: (project) => ipcRenderer.invoke('modpack:installFromModrinth', project),
|
||||||
authAccounts: () => ipcRenderer.invoke('auth:accounts'),
|
modpackInstallFromCurseForge: (project) => ipcRenderer.invoke('modpack:installFromCurseForge', project),
|
||||||
authSetActive: (id) => ipcRenderer.invoke('auth:setActive', id),
|
modpackCheck: (id) => ipcRenderer.invoke('modpack:check', id),
|
||||||
skinProfile: () => ipcRenderer.invoke('skin:profile'),
|
modpackUpdate: (id, versionId) => ipcRenderer.invoke('modpack:update', id, versionId),
|
||||||
skinChoose: () => ipcRenderer.invoke('skin:choose'),
|
onLaunchProgress: (cb) => {
|
||||||
skinApply: (pfad, variante) => ipcRenderer.invoke('skin:apply', pfad, variante),
|
const listener = (_e, data) => cb(data);
|
||||||
skinReset: () => ipcRenderer.invoke('skin:reset'),
|
ipcRenderer.on('launch:progress', listener);
|
||||||
skinCurrentData: () => ipcRenderer.invoke('skin:currentData'),
|
return () => ipcRenderer.removeListener('launch:progress', listener);
|
||||||
skinModelFor: (uuid) => ipcRenderer.invoke('skin:modelFor', uuid),
|
},
|
||||||
skinReadData: (pfad) => ipcRenderer.invoke('skin:readData', pfad),
|
|
||||||
skinSaveData: (base64) => ipcRenderer.invoke('skin:saveData', base64),
|
// eigenes Instanz-Bild
|
||||||
skinApplyData: (base64, variante) => ipcRenderer.invoke('skin:applyData', base64, variante),
|
pickImage: () => ipcRenderer.invoke('dialog:pickImage'),
|
||||||
capeInfo: () => ipcRenderer.invoke('cape:info'),
|
setInstanceImage: (id, sourcePath) => ipcRenderer.invoke('instances:setImage', id, sourcePath),
|
||||||
capeForUuid: (uuid) => ipcRenderer.invoke('cape:forUuid', uuid),
|
clearInstanceImage: (id) => ipcRenderer.invoke('instances:clearImage', id),
|
||||||
capeSetOfficial: (id) => ipcRenderer.invoke('cape:setOfficial', id),
|
instanceIcon: (id) => ipcRenderer.invoke('instances:icon', id),
|
||||||
capeUpload: (base64) => ipcRenderer.invoke('cape:upload', base64),
|
fileDataUri: (p) => ipcRenderer.invoke('file:dataUri', p),
|
||||||
capeRemove: () => ipcRenderer.invoke('cape:remove'),
|
|
||||||
capeCurrentData: () => ipcRenderer.invoke('cape:currentData'),
|
// Microsoft-Login (Phase 3) + Mehrkonten
|
||||||
capeChooseFile: (art) => ipcRenderer.invoke('cape:chooseFile', art),
|
authStatus: () => ipcRenderer.invoke('auth:status'),
|
||||||
capeSaveData: (base64) => ipcRenderer.invoke('cape:saveData', base64),
|
authAccounts: () => ipcRenderer.invoke('auth:accounts'),
|
||||||
authRemove: (id) => ipcRenderer.invoke('auth:remove', id),
|
authSetActive: (id) => ipcRenderer.invoke('auth:setActive', id),
|
||||||
authLogin: () => ipcRenderer.invoke('auth:login'),
|
skinProfile: () => ipcRenderer.invoke('skin:profile'),
|
||||||
authLogout: () => ipcRenderer.invoke('auth:logout'),
|
skinArmformPruefen: () => ipcRenderer.invoke('skin:armformPruefen'),
|
||||||
authHealth: () => ipcRenderer.invoke('auth:health'),
|
garderobeListe: () => ipcRenderer.invoke('garderobe:liste'),
|
||||||
authRecheck: () => ipcRenderer.invoke('auth:recheck'),
|
erfolgeListe: () => ipcRenderer.invoke('erfolge:liste'),
|
||||||
onAuthHealth: (cb) => {
|
laufendeInstanzen: () => ipcRenderer.invoke('game:laufend'),
|
||||||
const listener = (_e, data) => cb(data);
|
modschalterDaten: (id) => ipcRenderer.invoke('modschalter:daten', id),
|
||||||
ipcRenderer.on('auth:health', listener);
|
modschalterNeustart: (id) => ipcRenderer.invoke('modschalter:neustart', id),
|
||||||
return () => ipcRenderer.removeListener('auth:health', listener);
|
modschalterSchliessen: () => ipcRenderer.send('modschalter:schliessen'),
|
||||||
},
|
onModschalterNeustarten: (cb) => {
|
||||||
// meldet sich, wenn ein Spiel startet oder endet -> Kontenliste auffrischen
|
const l = (_e, d) => cb(d || {});
|
||||||
onAccountsChanged: (cb) => {
|
ipcRenderer.on('modschalter:neustarten', l);
|
||||||
const listener = () => cb();
|
return () => ipcRenderer.removeListener('modschalter:neustarten', l);
|
||||||
ipcRenderer.on('accounts:changed', listener);
|
},
|
||||||
return () => ipcRenderer.removeListener('accounts:changed', listener);
|
onLaufendeInstanzen: (cb) => {
|
||||||
},
|
const l = (_e, d) => cb(d || []);
|
||||||
onAuthRefreshed: (cb) => {
|
ipcRenderer.on('game:laufend', l);
|
||||||
const listener = () => cb();
|
return () => ipcRenderer.removeListener('game:laufend', l);
|
||||||
ipcRenderer.on('auth:refreshed', listener);
|
},
|
||||||
return () => ipcRenderer.removeListener('auth:refreshed', listener);
|
onErfolgeNeu: (cb) => {
|
||||||
},
|
const l = (_e, d) => cb(d);
|
||||||
onAuthCode: (cb) => {
|
ipcRenderer.on('erfolge:neu', l);
|
||||||
const listener = (_e, data) => cb(data);
|
return () => ipcRenderer.removeListener('erfolge:neu', l);
|
||||||
ipcRenderer.on('auth:code', listener);
|
},
|
||||||
return () => ipcRenderer.removeListener('auth:code', listener);
|
modsLeistungsPaket: (id) => ipcRenderer.invoke('mods:leistungsPaket', id),
|
||||||
},
|
onPaketFortschritt: (cb) => {
|
||||||
|
const l = (_e, d) => cb(d);
|
||||||
// Dialoge / App
|
ipcRenderer.on('mods:paketFortschritt', l);
|
||||||
// MultiMC-Import
|
return () => ipcRenderer.removeListener('mods:paketFortschritt', l);
|
||||||
mmcPickFolder: () => ipcRenderer.invoke('mmc:pickFolder'),
|
},
|
||||||
mmcScan: (folder) => ipcRenderer.invoke('mmc:scan', folder),
|
garderobeSkinSpeichern: (name) => ipcRenderer.invoke('garderobe:skinSpeichern', name),
|
||||||
mmcImport: (entries, copyData) => ipcRenderer.invoke('mmc:import', entries, copyData),
|
garderobeUmhangSpeichern: (name) => ipcRenderer.invoke('garderobe:umhangSpeichern', name),
|
||||||
onMmcProgress: (cb) => {
|
garderobeAnziehen: (id) => ipcRenderer.invoke('garderobe:anziehen', id),
|
||||||
const l = (_e, d) => cb(d);
|
garderobeUmbenennen: (id, name) => ipcRenderer.invoke('garderobe:umbenennen', id, name),
|
||||||
ipcRenderer.on('mmc:progress', l);
|
garderobeLoeschen: (id) => ipcRenderer.invoke('garderobe:loeschen', id),
|
||||||
return () => ipcRenderer.removeListener('mmc:progress', l);
|
skinArmformKorrigieren: () => ipcRenderer.invoke('skin:armformKorrigieren'),
|
||||||
},
|
skinChoose: () => ipcRenderer.invoke('skin:choose'),
|
||||||
cfPickFolder: () => ipcRenderer.invoke('cf:pickFolder'),
|
skinApply: (pfad, variante) => ipcRenderer.invoke('skin:apply', pfad, variante),
|
||||||
mrPickFolder: () => ipcRenderer.invoke('mr:pickFolder'),
|
skinReset: () => ipcRenderer.invoke('skin:reset'),
|
||||||
mrScan: (folder) => ipcRenderer.invoke('mr:scan', folder),
|
skinCurrentData: () => ipcRenderer.invoke('skin:currentData'),
|
||||||
mrImport: (entries, copyData) => ipcRenderer.invoke('mr:import', entries, copyData),
|
skinModelFor: (uuid) => ipcRenderer.invoke('skin:modelFor', uuid),
|
||||||
onMrProgress: (cb) => {
|
skinReadData: (pfad) => ipcRenderer.invoke('skin:readData', pfad),
|
||||||
const l = (_e, p) => cb(p);
|
skinSaveData: (base64) => ipcRenderer.invoke('skin:saveData', base64),
|
||||||
ipcRenderer.on('mr:progress', l);
|
skinApplyData: (base64, variante) => ipcRenderer.invoke('skin:applyData', base64, variante),
|
||||||
return () => ipcRenderer.removeListener('mr:progress', l);
|
capeInfo: () => ipcRenderer.invoke('cape:info'),
|
||||||
},
|
capeForUuid: (uuid) => ipcRenderer.invoke('cape:forUuid', uuid),
|
||||||
cfScan: (folder) => ipcRenderer.invoke('cf:scan', folder),
|
capeSetOfficial: (id) => ipcRenderer.invoke('cape:setOfficial', id),
|
||||||
cfImport: (entries, copyData) => ipcRenderer.invoke('cf:import', entries, copyData),
|
capeUpload: (base64) => ipcRenderer.invoke('cape:upload', base64),
|
||||||
onCfProgress: (cb) => {
|
capeRemove: () => ipcRenderer.invoke('cape:remove'),
|
||||||
const l = (_e, d) => cb(d);
|
capeCurrentData: () => ipcRenderer.invoke('cape:currentData'),
|
||||||
ipcRenderer.on('cf:progress', l);
|
capeChooseFile: (art) => ipcRenderer.invoke('cape:chooseFile', art),
|
||||||
return () => ipcRenderer.removeListener('cf:progress', l);
|
capeSaveData: (base64) => ipcRenderer.invoke('cape:saveData', base64),
|
||||||
},
|
authRemove: (id) => ipcRenderer.invoke('auth:remove', id),
|
||||||
mcPickFolder: () => ipcRenderer.invoke('mc:pickFolder'),
|
authLogin: () => ipcRenderer.invoke('auth:login'),
|
||||||
mcScan: (folder) => ipcRenderer.invoke('mc:scan', folder),
|
authLogout: () => ipcRenderer.invoke('auth:logout'),
|
||||||
mcImport: (entries, copyData) => ipcRenderer.invoke('mc:import', entries, copyData),
|
authHealth: () => ipcRenderer.invoke('auth:health'),
|
||||||
onMcProgress: (cb) => {
|
authRecheck: () => ipcRenderer.invoke('auth:recheck'),
|
||||||
const l = (_e, d) => cb(d);
|
onAuthHealth: (cb) => {
|
||||||
ipcRenderer.on('mc:progress', l);
|
const listener = (_e, data) => cb(data);
|
||||||
return () => ipcRenderer.removeListener('mc:progress', l);
|
ipcRenderer.on('auth:health', listener);
|
||||||
},
|
return () => ipcRenderer.removeListener('auth:health', listener);
|
||||||
technicPickFolder: () => ipcRenderer.invoke('technic:pickFolder'),
|
},
|
||||||
technicScan: (folder) => ipcRenderer.invoke('technic:scan', folder),
|
// meldet sich, wenn ein Spiel startet oder endet -> Kontenliste auffrischen
|
||||||
technicImport: (entries, copyData) => ipcRenderer.invoke('technic:import', entries, copyData),
|
onAccountsChanged: (cb) => {
|
||||||
onTechnicProgress: (cb) => {
|
const listener = () => cb();
|
||||||
const l = (_e, d) => cb(d);
|
ipcRenderer.on('accounts:changed', listener);
|
||||||
ipcRenderer.on('technic:progress', l);
|
return () => ipcRenderer.removeListener('accounts:changed', listener);
|
||||||
return () => ipcRenderer.removeListener('technic:progress', l);
|
},
|
||||||
},
|
onAuthRefreshed: (cb) => {
|
||||||
technicPlatformSearch: (query) => ipcRenderer.invoke('technicPlatform:search', query),
|
const listener = () => cb();
|
||||||
technicPlatformInstall: (slug) => ipcRenderer.invoke('technicPlatform:install', slug),
|
ipcRenderer.on('auth:refreshed', listener);
|
||||||
onTechnicPlatformProgress: (cb) => {
|
return () => ipcRenderer.removeListener('auth:refreshed', listener);
|
||||||
const l = (_e, d) => cb(d);
|
},
|
||||||
ipcRenderer.on('technicPlatform:progress', l);
|
onAuthCode: (cb) => {
|
||||||
return () => ipcRenderer.removeListener('technicPlatform:progress', l);
|
const listener = (_e, data) => cb(data);
|
||||||
},
|
ipcRenderer.on('auth:code', listener);
|
||||||
|
return () => ipcRenderer.removeListener('auth:code', listener);
|
||||||
// Bedrock (Windows-Store-Ausgabe)
|
},
|
||||||
bedrockDetect: () => ipcRenderer.invoke('bedrock:detect'),
|
|
||||||
bedrockLaunch: () => ipcRenderer.invoke('bedrock:launch'),
|
// Dialoge / App
|
||||||
bedrockWorlds: () => ipcRenderer.invoke('bedrock:worlds'),
|
// MultiMC-Import
|
||||||
bedrockList: (kind) => ipcRenderer.invoke('bedrock:list', kind),
|
// gemeinsamer Fortschritt aller Launcher-Importe (main.js → fuehreImportAus)
|
||||||
bedrockRemove: (kind, name) => ipcRenderer.invoke('bedrock:remove', kind, name),
|
onImportFortschritt: (cb) => {
|
||||||
bedrockOpenFolder: (kind) => ipcRenderer.invoke('bedrock:openFolder', kind),
|
const l = (_e, d) => cb(d);
|
||||||
bedrockInstallAddon: () => ipcRenderer.invoke('bedrock:installAddon'),
|
ipcRenderer.on('import:fortschritt', l);
|
||||||
bedrockOpenWorld: (dir) => ipcRenderer.invoke('bedrock:openWorld', dir),
|
return () => ipcRenderer.removeListener('import:fortschritt', l);
|
||||||
|
},
|
||||||
// echte Minecraft-Icons
|
importAbbrechen: () => ipcRenderer.invoke('import:abbrechen'),
|
||||||
iconsList: () => ipcRenderer.invoke('icons:list'),
|
mmcPickFolder: () => ipcRenderer.invoke('mmc:pickFolder'),
|
||||||
iconsExtract: () => ipcRenderer.invoke('icons:extract'),
|
mmcScan: (folder) => ipcRenderer.invoke('mmc:scan', folder),
|
||||||
|
mmcImport: (entries, copyData) => ipcRenderer.invoke('mmc:import', entries, copyData),
|
||||||
// Updates
|
onMmcProgress: (cb) => {
|
||||||
updateCheck: () => ipcRenderer.invoke('update:check'),
|
const l = (_e, d) => cb(d);
|
||||||
updateLastCheck: () => ipcRenderer.invoke('update:lastCheck'),
|
ipcRenderer.on('mmc:progress', l);
|
||||||
updateInstall: (asset) => ipcRenderer.invoke('update:install', asset),
|
return () => ipcRenderer.removeListener('mmc:progress', l);
|
||||||
onUpdateAvailable: (cb) => {
|
},
|
||||||
const l = (_e, d) => cb(d);
|
cfPickFolder: () => ipcRenderer.invoke('cf:pickFolder'),
|
||||||
ipcRenderer.on('update:available', l);
|
mrPickFolder: () => ipcRenderer.invoke('mr:pickFolder'),
|
||||||
return () => ipcRenderer.removeListener('update:available', l);
|
mrScan: (folder) => ipcRenderer.invoke('mr:scan', folder),
|
||||||
},
|
mrImport: (entries, copyData) => ipcRenderer.invoke('mr:import', entries, copyData),
|
||||||
// Ergebnis des automatischen Checks beim Start – auch wenn kein Update
|
onMrProgress: (cb) => {
|
||||||
// verfügbar ist (im Unterschied zu onUpdateAvailable, das nur bei
|
const l = (_e, p) => cb(p);
|
||||||
// verfügbarem Update feuert)
|
ipcRenderer.on('mr:progress', l);
|
||||||
onUpdateChecked: (cb) => {
|
return () => ipcRenderer.removeListener('mr:progress', l);
|
||||||
const l = (_e, d) => cb(d);
|
},
|
||||||
ipcRenderer.on('update:checked', l);
|
cfScan: (folder) => ipcRenderer.invoke('cf:scan', folder),
|
||||||
return () => ipcRenderer.removeListener('update:checked', l);
|
cfImport: (entries, copyData) => ipcRenderer.invoke('cf:import', entries, copyData),
|
||||||
},
|
onCfProgress: (cb) => {
|
||||||
onUpdateProgress: (cb) => {
|
const l = (_e, d) => cb(d);
|
||||||
const l = (_e, d) => cb(d);
|
ipcRenderer.on('cf:progress', l);
|
||||||
ipcRenderer.on('update:progress', l);
|
return () => ipcRenderer.removeListener('cf:progress', l);
|
||||||
return () => ipcRenderer.removeListener('update:progress', l);
|
},
|
||||||
},
|
mcPickFolder: () => ipcRenderer.invoke('mc:pickFolder'),
|
||||||
|
mcScan: (folder) => ipcRenderer.invoke('mc:scan', folder),
|
||||||
openExternal: (url) => ipcRenderer.invoke('app:openExternal', url),
|
mcImport: (entries, copyData) => ipcRenderer.invoke('mc:import', entries, copyData),
|
||||||
pickFolder: (title) => ipcRenderer.invoke('dialog:pickFolder', title),
|
onMcProgress: (cb) => {
|
||||||
pickJava: () => ipcRenderer.invoke('dialog:pickJava'),
|
const l = (_e, d) => cb(d);
|
||||||
appInfo: () => ipcRenderer.invoke('app:info'),
|
ipcRenderer.on('mc:progress', l);
|
||||||
appLicense: () => ipcRenderer.invoke('app:license'),
|
return () => ipcRenderer.removeListener('mc:progress', l);
|
||||||
|
},
|
||||||
// Einstellungen/Konsole als eigenes, frei verschiebbares Fenster
|
technicPickFolder: () => ipcRenderer.invoke('technic:pickFolder'),
|
||||||
popoutOpen: (modalId, params) => ipcRenderer.send('popout:open', modalId, params),
|
technicScan: (folder) => ipcRenderer.invoke('technic:scan', folder),
|
||||||
popoutHide: () => ipcRenderer.send('popout:hide'),
|
technicImport: (entries, copyData) => ipcRenderer.invoke('technic:import', entries, copyData),
|
||||||
setTitleBarDesign: (design) => ipcRenderer.send('window:titleBar', design),
|
onTechnicProgress: (cb) => {
|
||||||
onPopoutReopen: (cb) => {
|
const l = (_e, d) => cb(d);
|
||||||
const l = (_e, params) => cb(params);
|
ipcRenderer.on('technic:progress', l);
|
||||||
ipcRenderer.on('popout:reopen', l);
|
return () => ipcRenderer.removeListener('technic:progress', l);
|
||||||
return () => ipcRenderer.removeListener('popout:reopen', l);
|
},
|
||||||
},
|
technicPlatformSearch: (query) => ipcRenderer.invoke('technicPlatform:search', query),
|
||||||
onSettingsChanged: (cb) => {
|
technicPlatformInstall: (slug) => ipcRenderer.invoke('technicPlatform:install', slug),
|
||||||
const l = (_e, s) => cb(s);
|
onTechnicPlatformProgress: (cb) => {
|
||||||
ipcRenderer.on('settings:changed', l);
|
const l = (_e, d) => cb(d);
|
||||||
return () => ipcRenderer.removeListener('settings:changed', l);
|
ipcRenderer.on('technicPlatform:progress', l);
|
||||||
},
|
return () => ipcRenderer.removeListener('technicPlatform:progress', l);
|
||||||
onInstancesChanged: (cb) => {
|
},
|
||||||
const l = () => cb();
|
|
||||||
ipcRenderer.on('instances:changed', l);
|
// Bedrock (Windows-Store-Ausgabe)
|
||||||
return () => ipcRenderer.removeListener('instances:changed', l);
|
bedrockDetect: () => ipcRenderer.invoke('bedrock:detect'),
|
||||||
},
|
bedrockLaunch: () => ipcRenderer.invoke('bedrock:launch'),
|
||||||
onSkinChanged: (cb) => {
|
bedrockWorlds: () => ipcRenderer.invoke('bedrock:worlds'),
|
||||||
const l = (_e, info) => cb(info || {});
|
bedrockList: (kind) => ipcRenderer.invoke('bedrock:list', kind),
|
||||||
ipcRenderer.on('skin:changed', l);
|
bedrockRemove: (kind, name) => ipcRenderer.invoke('bedrock:remove', kind, name),
|
||||||
return () => ipcRenderer.removeListener('skin:changed', l);
|
bedrockOpenFolder: (kind) => ipcRenderer.invoke('bedrock:openFolder', kind),
|
||||||
},
|
bedrockInstallAddon: () => ipcRenderer.invoke('bedrock:installAddon'),
|
||||||
updateGetPending: () => ipcRenderer.invoke('update:getPending'),
|
bedrockOpenWorld: (dir) => ipcRenderer.invoke('bedrock:openWorld', dir),
|
||||||
|
|
||||||
// Spielaufzeichnung
|
// echte Minecraft-Icons
|
||||||
recordingListWindows: () => ipcRenderer.invoke('recording:listWindows'),
|
iconsList: () => ipcRenderer.invoke('icons:list'),
|
||||||
recordingGetAudioSourceId: () => ipcRenderer.invoke('recording:getAudioSourceId'),
|
iconsExtract: () => ipcRenderer.invoke('icons:extract'),
|
||||||
recordingSave: (instanceName, buffer, serverLabel, fps) => ipcRenderer.invoke('recording:save', instanceName, buffer, serverLabel, fps),
|
|
||||||
recordingOpenFolder: (instanceName) => ipcRenderer.invoke('recording:openFolder', instanceName),
|
// Updates
|
||||||
recordingList: (instanceName) => ipcRenderer.invoke('recording:list', instanceName),
|
updateCheck: () => ipcRenderer.invoke('update:check'),
|
||||||
recordingThumbnail: (instanceName, dateiname) => ipcRenderer.invoke('recording:thumbnail', instanceName, dateiname),
|
updateLastCheck: () => ipcRenderer.invoke('update:lastCheck'),
|
||||||
recordingDelete: (instanceName, dateiname) => ipcRenderer.invoke('recording:delete', instanceName, dateiname),
|
updateInstall: () => ipcRenderer.invoke('update:install'),
|
||||||
recordingOpen: (instanceName, dateiname) => ipcRenderer.invoke('recording:open', instanceName, dateiname),
|
onUpdateAvailable: (cb) => {
|
||||||
|
const l = (_e, d) => cb(d);
|
||||||
// Freundesystem
|
ipcRenderer.on('update:available', l);
|
||||||
friendsEnabled: () => ipcRenderer.invoke('friends:enabled'),
|
return () => ipcRenderer.removeListener('update:available', l);
|
||||||
friendsList: () => ipcRenderer.invoke('friends:list'),
|
},
|
||||||
friendsRequests: () => ipcRenderer.invoke('friends:requests'),
|
// Ergebnis des automatischen Checks beim Start – auch wenn kein Update
|
||||||
friendsAdd: (targetUsername) => ipcRenderer.invoke('friends:add', targetUsername),
|
// verfügbar ist (im Unterschied zu onUpdateAvailable, das nur bei
|
||||||
friendsRespond: (otherUuid, accept) => ipcRenderer.invoke('friends:respond', otherUuid, accept),
|
// verfügbarem Update feuert)
|
||||||
friendsRemove: (otherUuid) => ipcRenderer.invoke('friends:remove', otherUuid),
|
onUpdateChecked: (cb) => {
|
||||||
friendsSentRequests: () => ipcRenderer.invoke('friends:sentRequests'),
|
const l = (_e, d) => cb(d);
|
||||||
friendsCancel: (otherUuid) => ipcRenderer.invoke('friends:cancel', otherUuid),
|
ipcRenderer.on('update:checked', l);
|
||||||
friendsSetNickname: (uuid, nickname) => ipcRenderer.invoke('friends:setNickname', uuid, nickname),
|
return () => ipcRenderer.removeListener('update:checked', l);
|
||||||
friendsRequestCount: () => ipcRenderer.invoke('friends:requestCount'),
|
},
|
||||||
friendsMessages: (otherUuid) => ipcRenderer.invoke('friends:messages', otherUuid),
|
onUpdateProgress: (cb) => {
|
||||||
friendsSendMessage: (otherUuid, text) => ipcRenderer.invoke('friends:sendMessage', otherUuid, text),
|
const l = (_e, d) => cb(d);
|
||||||
friendsUnreadByFriend: () => ipcRenderer.invoke('friends:unreadByFriend'),
|
ipcRenderer.on('update:progress', l);
|
||||||
onFriendsMessage: (cb) => {
|
return () => ipcRenderer.removeListener('update:progress', l);
|
||||||
const l = (_e, n) => cb(n);
|
},
|
||||||
ipcRenderer.on('friends:message', l);
|
|
||||||
return () => ipcRenderer.removeListener('friends:message', l);
|
openExternal: (url) => ipcRenderer.invoke('app:openExternal', url),
|
||||||
},
|
pickFolder: (title) => ipcRenderer.invoke('dialog:pickFolder', title),
|
||||||
crashReport: (id, seit, wahl) => ipcRenderer.invoke('crash:report', id, seit, wahl),
|
pickJava: () => ipcRenderer.invoke('dialog:pickJava'),
|
||||||
clipSetActive: (active, instanceId) => ipcRenderer.invoke('clip:setActive', active, instanceId),
|
appInfo: () => ipcRenderer.invoke('app:info'),
|
||||||
clipSave: (data) => ipcRenderer.invoke('clip:save', data),
|
appLicense: () => ipcRenderer.invoke('app:license'),
|
||||||
onClipHotkey: (cb) => {
|
|
||||||
const l = () => cb();
|
// Einstellungen/Konsole als eigenes, frei verschiebbares Fenster
|
||||||
ipcRenderer.on('clip:hotkey', l);
|
popoutOpen: (modalId, params) => ipcRenderer.send('popout:open', modalId, params, frischerKlick()),
|
||||||
return () => ipcRenderer.removeListener('clip:hotkey', l);
|
popoutHide: () => ipcRenderer.send('popout:hide'),
|
||||||
},
|
setTitleBarDesign: (design) => ipcRenderer.send('window:titleBar', design),
|
||||||
onUiToast: (cb) => {
|
onPopoutReopen: (cb) => {
|
||||||
const l = (_e, t) => cb(t);
|
const l = (_e, params) => cb(params);
|
||||||
ipcRenderer.on('ui:toast', l);
|
ipcRenderer.on('popout:reopen', l);
|
||||||
return () => ipcRenderer.removeListener('ui:toast', l);
|
return () => ipcRenderer.removeListener('popout:reopen', l);
|
||||||
},
|
},
|
||||||
friendsJoin: (server, version) => ipcRenderer.invoke('friends:join', server, version),
|
onSettingsChanged: (cb) => {
|
||||||
friendsJoinNew: (server, version) => ipcRenderer.invoke('friends:joinNew', server, version),
|
const l = (_e, s) => cb(s);
|
||||||
friendsMyServer: () => ipcRenderer.invoke('friends:myServer'),
|
ipcRenderer.on('settings:changed', l);
|
||||||
friendsInvite: (targetUuid) => ipcRenderer.invoke('friends:invite', targetUuid),
|
return () => ipcRenderer.removeListener('settings:changed', l);
|
||||||
friendsInvites: () => ipcRenderer.invoke('friends:invites'),
|
},
|
||||||
friendsDismissInvite: (id) => ipcRenderer.invoke('friends:dismissInvite', id),
|
onInstancesChanged: (cb) => {
|
||||||
onFriendsInvite: (cb) => {
|
const l = () => cb();
|
||||||
const l = (_e, einladung) => cb(einladung);
|
ipcRenderer.on('instances:changed', l);
|
||||||
ipcRenderer.on('friends:invite', l);
|
return () => ipcRenderer.removeListener('instances:changed', l);
|
||||||
return () => ipcRenderer.removeListener('friends:invite', l);
|
},
|
||||||
},
|
onSkinChanged: (cb) => {
|
||||||
onFriendsRequestCount: (cb) => {
|
const l = (_e, info) => cb(info || {});
|
||||||
const l = (_e, zahl) => cb(zahl);
|
ipcRenderer.on('skin:changed', l);
|
||||||
ipcRenderer.on('friends:requestCount', l);
|
return () => ipcRenderer.removeListener('skin:changed', l);
|
||||||
return () => ipcRenderer.removeListener('friends:requestCount', l);
|
},
|
||||||
},
|
updateGetPending: () => ipcRenderer.invoke('update:getPending'),
|
||||||
recordingGetServer: (instanceId) => ipcRenderer.invoke('recording:getServer', instanceId),
|
|
||||||
onRecordingServerDetected: (cb) => {
|
// Spielaufzeichnung
|
||||||
const l = (_e, payload) => cb(payload);
|
recordingListWindows: () => ipcRenderer.invoke('recording:listWindows'),
|
||||||
ipcRenderer.on('recording:serverDetected', l);
|
recordingGetAudioSourceId: () => ipcRenderer.invoke('recording:getAudioSourceId'),
|
||||||
return () => ipcRenderer.removeListener('recording:serverDetected', l);
|
recordingSave: (instanceName, buffer, serverLabel, fps) => ipcRenderer.invoke('recording:save', instanceName, buffer, serverLabel, fps),
|
||||||
},
|
recordingOpenFolder: (instanceName) => ipcRenderer.invoke('recording:openFolder', instanceName),
|
||||||
recordingGetPid: (instanceId) => ipcRenderer.invoke('recording:getPid', instanceId),
|
recordingList: (instanceName) => ipcRenderer.invoke('recording:list', instanceName),
|
||||||
recordingSetCaptureTarget: (sourceId) => ipcRenderer.invoke('recording:setCaptureTarget', sourceId),
|
recordingThumbnail: (instanceName, dateiname) => ipcRenderer.invoke('recording:thumbnail', instanceName, dateiname),
|
||||||
recordingGetWindowBounds: (instanceId, fensterTitel) => ipcRenderer.invoke('recording:getWindowBounds', instanceId, fensterTitel),
|
recordingDelete: (instanceName, dateiname) => ipcRenderer.invoke('recording:delete', instanceName, dateiname),
|
||||||
recordingStartGameAudio: (pid) => ipcRenderer.invoke('recording:startGameAudio', pid),
|
recordingOpen: (instanceName, dateiname) => ipcRenderer.invoke('recording:open', instanceName, dateiname),
|
||||||
recordingStopGameAudio: () => ipcRenderer.invoke('recording:stopGameAudio'),
|
|
||||||
onGameAudioChunk: (cb) => {
|
// Freundesystem
|
||||||
const l = (_e, chunk) => cb(chunk);
|
friendsEnabled: () => ipcRenderer.invoke('friends:enabled'),
|
||||||
ipcRenderer.on('recording:gameAudioChunk', l);
|
friendsList: () => ipcRenderer.invoke('friends:list'),
|
||||||
return () => ipcRenderer.removeListener('recording:gameAudioChunk', l);
|
friendsRequests: () => ipcRenderer.invoke('friends:requests'),
|
||||||
},
|
friendsAdd: (targetUsername) => ipcRenderer.invoke('friends:add', targetUsername),
|
||||||
recordingSetGuard: (active) => ipcRenderer.send('recording:guard', active),
|
friendsRespond: (otherUuid, accept) => ipcRenderer.invoke('friends:respond', otherUuid, accept),
|
||||||
onRecordCloseAttempt: (cb) => {
|
friendsRemove: (otherUuid) => ipcRenderer.invoke('friends:remove', otherUuid),
|
||||||
const l = () => cb();
|
friendsSentRequests: () => ipcRenderer.invoke('friends:sentRequests'),
|
||||||
ipcRenderer.on('record:closeAttempt', l);
|
friendsCancel: (otherUuid) => ipcRenderer.invoke('friends:cancel', otherUuid),
|
||||||
return () => ipcRenderer.removeListener('record:closeAttempt', l);
|
friendsSetNickname: (uuid, nickname) => ipcRenderer.invoke('friends:setNickname', uuid, nickname),
|
||||||
},
|
friendsRequestCount: () => ipcRenderer.invoke('friends:requestCount'),
|
||||||
});
|
friendsMessages: (otherUuid) => ipcRenderer.invoke('friends:messages', otherUuid),
|
||||||
|
friendsSendMessage: (otherUuid, text) => ipcRenderer.invoke('friends:sendMessage', otherUuid, text),
|
||||||
|
friendsUnreadByFriend: () => ipcRenderer.invoke('friends:unreadByFriend'),
|
||||||
|
onFriendsMessage: (cb) => {
|
||||||
|
const l = (_e, n) => cb(n);
|
||||||
|
ipcRenderer.on('friends:message', l);
|
||||||
|
return () => ipcRenderer.removeListener('friends:message', l);
|
||||||
|
},
|
||||||
|
crashReport: (id, seit, wahl) => ipcRenderer.invoke('crash:report', id, seit, wahl),
|
||||||
|
clipSetActive: (active, instanceId) => ipcRenderer.invoke('clip:setActive', active, instanceId),
|
||||||
|
clipSave: (data) => ipcRenderer.invoke('clip:save', data),
|
||||||
|
onClipHotkey: (cb) => {
|
||||||
|
const l = () => cb();
|
||||||
|
ipcRenderer.on('clip:hotkey', l);
|
||||||
|
return () => ipcRenderer.removeListener('clip:hotkey', l);
|
||||||
|
},
|
||||||
|
onUiToast: (cb) => {
|
||||||
|
const l = (_e, t) => cb(t);
|
||||||
|
ipcRenderer.on('ui:toast', l);
|
||||||
|
return () => ipcRenderer.removeListener('ui:toast', l);
|
||||||
|
},
|
||||||
|
friendsJoin: (server, version) => ipcRenderer.invoke('friends:join', server, version),
|
||||||
|
friendsJoinNew: (server, version) => ipcRenderer.invoke('friends:joinNew', server, version),
|
||||||
|
friendsMyServer: () => ipcRenderer.invoke('friends:myServer'),
|
||||||
|
friendsInvite: (targetUuid) => ipcRenderer.invoke('friends:invite', targetUuid),
|
||||||
|
friendsInvites: () => ipcRenderer.invoke('friends:invites'),
|
||||||
|
friendsDismissInvite: (id) => ipcRenderer.invoke('friends:dismissInvite', id),
|
||||||
|
onFriendsInvite: (cb) => {
|
||||||
|
const l = (_e, einladung) => cb(einladung);
|
||||||
|
ipcRenderer.on('friends:invite', l);
|
||||||
|
return () => ipcRenderer.removeListener('friends:invite', l);
|
||||||
|
},
|
||||||
|
onFriendsRequestCount: (cb) => {
|
||||||
|
const l = (_e, zahl) => cb(zahl);
|
||||||
|
ipcRenderer.on('friends:requestCount', l);
|
||||||
|
return () => ipcRenderer.removeListener('friends:requestCount', l);
|
||||||
|
},
|
||||||
|
recordingGetServer: (instanceId) => ipcRenderer.invoke('recording:getServer', instanceId),
|
||||||
|
onRecordingServerDetected: (cb) => {
|
||||||
|
const l = (_e, payload) => cb(payload);
|
||||||
|
ipcRenderer.on('recording:serverDetected', l);
|
||||||
|
return () => ipcRenderer.removeListener('recording:serverDetected', l);
|
||||||
|
},
|
||||||
|
recordingGetPid: (instanceId) => ipcRenderer.invoke('recording:getPid', instanceId),
|
||||||
|
recordingSetCaptureTarget: (sourceId) => ipcRenderer.invoke('recording:setCaptureTarget', sourceId),
|
||||||
|
recordingGetWindowBounds: (instanceId, fensterTitel) => ipcRenderer.invoke('recording:getWindowBounds', instanceId, fensterTitel),
|
||||||
|
recordingStartGameAudio: (pid) => ipcRenderer.invoke('recording:startGameAudio', pid),
|
||||||
|
recordingStopGameAudio: () => ipcRenderer.invoke('recording:stopGameAudio'),
|
||||||
|
onGameAudioChunk: (cb) => {
|
||||||
|
const l = (_e, chunk) => cb(chunk);
|
||||||
|
ipcRenderer.on('recording:gameAudioChunk', l);
|
||||||
|
return () => ipcRenderer.removeListener('recording:gameAudioChunk', l);
|
||||||
|
},
|
||||||
|
recordingSetGuard: (active) => ipcRenderer.send('recording:guard', active),
|
||||||
|
onRecordCloseAttempt: (cb) => {
|
||||||
|
const l = () => cb();
|
||||||
|
ipcRenderer.on('record:closeAttempt', l);
|
||||||
|
return () => ipcRenderer.removeListener('record:closeAttempt', l);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -299,6 +299,7 @@ async function modrinthResolveFile({ projectId, gameVersion, loader, art = 'mod'
|
|||||||
versionNumber: v.version_number,
|
versionNumber: v.version_number,
|
||||||
filename: file.filename,
|
filename: file.filename,
|
||||||
url: file.url,
|
url: file.url,
|
||||||
|
hashes: mrHashes(file),
|
||||||
sha1: file.hashes && file.hashes.sha1,
|
sha1: file.hashes && file.hashes.sha1,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
dependencies: v.dependencies || [],
|
dependencies: v.dependencies || [],
|
||||||
@@ -324,6 +325,7 @@ async function modrinthResolveFile({ projectId, gameVersion, loader, art = 'mod'
|
|||||||
versionNumber: v.version_number,
|
versionNumber: v.version_number,
|
||||||
filename: file.filename,
|
filename: file.filename,
|
||||||
url: file.url,
|
url: file.url,
|
||||||
|
hashes: mrHashes(file),
|
||||||
sha1: file.hashes && file.hashes.sha1,
|
sha1: file.hashes && file.hashes.sha1,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
dependencies: v.dependencies || [],
|
dependencies: v.dependencies || [],
|
||||||
@@ -350,23 +352,58 @@ async function modrinthResolveModpack({ projectId, gameVersion = '' } = {}) {
|
|||||||
name: v.name || v.version_number,
|
name: v.name || v.version_number,
|
||||||
filename: file.filename,
|
filename: file.filename,
|
||||||
url: file.url,
|
url: file.url,
|
||||||
|
hashes: mrHashes(file),
|
||||||
size: file.size || 0,
|
size: file.size || 0,
|
||||||
gameVersions: v.game_versions || [],
|
gameVersions: v.game_versions || [],
|
||||||
loaders: v.loaders || [],
|
loaders: v.loaders || [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadBuffer(url, extraHeaders) {
|
/*
|
||||||
const res = await fetch(url, { headers: Object.assign({ 'User-Agent': UA }, extraHeaders || {}) });
|
* Prüfsumme eines Downloads kontrollieren. erwartet: { sha512?, sha1? } – die
|
||||||
if (!res.ok) throw new Error('Download fehlgeschlagen: HTTP ' + res.status);
|
* Werte stammen aus der Modrinth- bzw. CurseForge-API. Ohne Angabe wird nichts
|
||||||
return Buffer.from(await res.arrayBuffer());
|
* geprüft (z. B. Dateien, zu denen die Quelle keine Prüfsumme nennt).
|
||||||
|
*/
|
||||||
|
function pruefeDownload(buf, erwartet, name) {
|
||||||
|
if (!erwartet) return;
|
||||||
|
for (const algo of ['sha512', 'sha256', 'sha1']) {
|
||||||
|
const soll = erwartet[algo] && String(erwartet[algo]).toLowerCase();
|
||||||
|
if (!soll) continue;
|
||||||
|
const ist = require('crypto').createHash(algo).update(buf).digest('hex');
|
||||||
|
if (ist !== soll) {
|
||||||
|
throw new Error(`Prüfsumme von ${name || 'Download'} stimmt nicht – Datei verworfen (beschädigt oder manipuliert).`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// extraHeaders: z. B. der CurseForge-API-Key, den das CDN seit Juli 2026 verlangt
|
// CurseForge liefert Prüfsummen als Liste: algo 1 = SHA-1, 2 = MD5
|
||||||
async function downloadFile(url, destPath, extraHeaders) {
|
function cfHashes(datei) {
|
||||||
|
const sha1 = ((datei && datei.hashes) || []).find((h) => h.algo === 1 && h.value);
|
||||||
|
return sha1 ? { sha1: sha1.value } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modrinth: { sha512, sha1 }
|
||||||
|
function mrHashes(datei) {
|
||||||
|
const h = (datei && datei.hashes) || {};
|
||||||
|
return h.sha512 || h.sha1 ? { sha512: h.sha512, sha1: h.sha1 } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadBuffer(url, extraHeaders, erwartet) {
|
||||||
const res = await fetch(url, { headers: Object.assign({ 'User-Agent': UA }, extraHeaders || {}) });
|
const res = await fetch(url, { headers: Object.assign({ 'User-Agent': UA }, extraHeaders || {}) });
|
||||||
if (!res.ok) throw new Error('Download fehlgeschlagen: HTTP ' + res.status);
|
if (!res.ok) throw new Error('Download fehlgeschlagen: HTTP ' + res.status);
|
||||||
const buf = Buffer.from(await res.arrayBuffer());
|
const buf = Buffer.from(await res.arrayBuffer());
|
||||||
|
pruefeDownload(buf, erwartet, decodeURIComponent(String(url).split('/').pop() || ''));
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
// extraHeaders: z. B. der CurseForge-API-Key, den das CDN seit Juli 2026 verlangt
|
||||||
|
// erwartet: Prüfsummen (siehe pruefeDownload) – geschrieben wird nur, was passt
|
||||||
|
async function downloadFile(url, destPath, extraHeaders, erwartet) {
|
||||||
|
const res = await fetch(url, { headers: Object.assign({ 'User-Agent': UA }, extraHeaders || {}) });
|
||||||
|
if (!res.ok) throw new Error('Download fehlgeschlagen: HTTP ' + res.status);
|
||||||
|
const buf = Buffer.from(await res.arrayBuffer());
|
||||||
|
pruefeDownload(buf, erwartet, path.basename(destPath));
|
||||||
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
||||||
fs.writeFileSync(destPath, buf);
|
fs.writeFileSync(destPath, buf);
|
||||||
return buf.length;
|
return buf.length;
|
||||||
@@ -383,7 +420,7 @@ async function modrinthInstall({ projectId, title, gameVersion, loader, modsDir,
|
|||||||
: `Keine passende Version für ${gameVersion} gefunden.` };
|
: `Keine passende Version für ${gameVersion} gefunden.` };
|
||||||
}
|
}
|
||||||
const dest = path.join(modsDir, file.filename);
|
const dest = path.join(modsDir, file.filename);
|
||||||
const bytes = await downloadFile(file.url, dest);
|
const bytes = await downloadFile(file.url, dest, null, file.hashes);
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
dependencies: file.dependencies || [],
|
dependencies: file.dependencies || [],
|
||||||
@@ -738,6 +775,7 @@ async function curseforgePickFile(zugang, modId, gameVersion, loader, art) {
|
|||||||
fileId: match.id,
|
fileId: match.id,
|
||||||
filename: name,
|
filename: name,
|
||||||
url: dl,
|
url: dl,
|
||||||
|
hashes: cfHashes(match),
|
||||||
versionNumber: match.displayName || match.fileName || String(match.id),
|
versionNumber: match.displayName || match.fileName || String(match.id),
|
||||||
dependencies: match.dependencies || [],
|
dependencies: match.dependencies || [],
|
||||||
};
|
};
|
||||||
@@ -768,7 +806,7 @@ async function curseforgeInstall({ zugang, projectId, title, gameVersion, loader
|
|||||||
// Download: direkt mit Key oder über Weiterleitung
|
// Download: direkt mit Key oder über Weiterleitung
|
||||||
const url = zugang.downloadAdresse ? zugang.downloadAdresse(file.url) : file.url;
|
const url = zugang.downloadAdresse ? zugang.downloadAdresse(file.url) : file.url;
|
||||||
const headers = Object.assign({}, zugang.downloadKopfzeilen || {});
|
const headers = Object.assign({}, zugang.downloadKopfzeilen || {});
|
||||||
const bytes = await downloadFile(url, dest, headers);
|
const bytes = await downloadFile(url, dest, headers, file.hashes);
|
||||||
|
|
||||||
// Pflicht-Abhängigkeiten (relationType 3 = RequiredDependency)
|
// Pflicht-Abhängigkeiten (relationType 3 = RequiredDependency)
|
||||||
const depIds = (file.dependencies || [])
|
const depIds = (file.dependencies || [])
|
||||||
@@ -826,6 +864,7 @@ async function curseforgeResolveFiles(zugang, files, onProgress) {
|
|||||||
map.set(Number(d.id), {
|
map.set(Number(d.id), {
|
||||||
fileName: d.fileName,
|
fileName: d.fileName,
|
||||||
url: zugang.downloadAdresse(roh),
|
url: zugang.downloadAdresse(roh),
|
||||||
|
hashes: cfHashes(d),
|
||||||
kopfzeilen: zugang.downloadKopfzeilen,
|
kopfzeilen: zugang.downloadKopfzeilen,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,4 +176,32 @@ function waehleAnzeigeSkin(kontoProfil, spiel, kuerzlichGeaendert) {
|
|||||||
return Object.assign({}, kontoProfil, { skinUrl: spiel.skinUrl, variante: spiel.variante, skinAusSpiel: true });
|
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 };
|
||||||
|
|||||||
@@ -0,0 +1,359 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* spieldesign.js – AeroMC-Look im Spiel (Minecraft 1.14 … 26.x)
|
||||||
|
* --------------------------------------------------------------
|
||||||
|
* Zwei Teile:
|
||||||
|
*
|
||||||
|
* 1. Ressourcenpaket "AeroMC-Design" im Ordner resourcepacks der Instanz:
|
||||||
|
* Titel-Logo mit Untertitel, Menü-Hintergrund (Panorama), eigene Sprüche,
|
||||||
|
* Knöpfe im AeroMC-Stil und Symbole vor den Menü-Beschriftungen (als eigene
|
||||||
|
* Bitmap-Schrift über die Sprachdateien). Bis 1.20.1 stecken die Knöpfe im
|
||||||
|
* gemeinsamen widgets.png der Version, ab 1.20.2 sind es eigene Sprites.
|
||||||
|
* Das Paket wird in options.txt ganz unten eingereiht – eigene
|
||||||
|
* Ressourcenpakete des Spielers haben also immer Vorrang.
|
||||||
|
* 2. Grafiken für den Agenten (Ladebildschirm-Logo, Fenstersymbol): Java kann
|
||||||
|
* nicht aus app.asar lesen, darum werden sie nach userData kopiert.
|
||||||
|
*
|
||||||
|
* Für den Mod-Schalter (siehe modschalter.js) bringt das Paket außerdem die
|
||||||
|
* Beschriftung „Mods verwalten“ mit – auch wenn der AeroMC-Look aus ist.
|
||||||
|
* Ist beides aus, wird das Paket wieder entfernt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const zlib = require('zlib');
|
||||||
|
const unzipper = require('unzipper');
|
||||||
|
|
||||||
|
const PAKET = 'AeroMC-Design';
|
||||||
|
const PAKET_ID = 'file/' + PAKET;
|
||||||
|
const STAND = 8; // erhöhen, wenn sich die Grafiken ändern -> Paket wird neu gebaut
|
||||||
|
const ASSETS = path.join(__dirname, 'assets', 'spieldesign');
|
||||||
|
|
||||||
|
// ---------- Versionen ----------
|
||||||
|
|
||||||
|
// "1.21.9-rc1" -> [1, 21, 9]; Snapshots wie "25w31a" -> null
|
||||||
|
function zahlen(version) {
|
||||||
|
const m = /^(\d+)\.(\d+)(?:\.(\d+))?/.exec(String(version || ''));
|
||||||
|
return m ? [Number(m[1]), Number(m[2]), Number(m[3] || 0)] : null;
|
||||||
|
}
|
||||||
|
function ab(version, grenze) {
|
||||||
|
const a = zahlen(version);
|
||||||
|
if (!a) return true; // Snapshots u. Ä. gelten als neu
|
||||||
|
const b = zahlen(grenze);
|
||||||
|
for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ressourcenpaket-Format je Version (minecraft.wiki, "Pack format")
|
||||||
|
const FORMATE = [
|
||||||
|
['1.14', 4], ['1.15', 5], ['1.16.2', 6], ['1.17', 7], ['1.18', 8], ['1.19', 9], ['1.19.3', 12], ['1.19.4', 13],
|
||||||
|
['1.20', 15], ['1.20.2', 18], ['1.20.3', 22], ['1.20.5', 32], ['1.21', 34], ['1.21.2', 42], ['1.21.4', 46],
|
||||||
|
['1.21.5', 55], ['1.21.6', 63], ['1.21.7', 64], ['1.21.9', 69], ['1.21.11', 75], ['26.1', 84], ['26.2', 88], ['26.3', 97],
|
||||||
|
];
|
||||||
|
function paketFormat(version) {
|
||||||
|
let f = FORMATE[FORMATE.length - 1][1];
|
||||||
|
if (zahlen(version)) for (const [v, n] of FORMATE) if (ab(version, v)) f = n;
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
function mcmeta(version) {
|
||||||
|
const f = paketFormat(version);
|
||||||
|
const neuste = f === FORMATE[FORMATE.length - 1][1];
|
||||||
|
const pack = { description: 'AeroMC – Menü, Logo und Knöpfe' };
|
||||||
|
if (f >= 65) {
|
||||||
|
// ab 1.21.9: min/max statt pack_format; die neuste bekannte Version offen nach oben
|
||||||
|
pack.min_format = f;
|
||||||
|
pack.max_format = neuste ? 999 : f;
|
||||||
|
} else {
|
||||||
|
pack.pack_format = f;
|
||||||
|
}
|
||||||
|
return JSON.stringify({ pack }, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Spieldesign gibt es ab 1.14 (vorher andere Menüs und LWJGL 2). */
|
||||||
|
function unterstuetzt(version) {
|
||||||
|
return ab(version, '1.14');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- PNG (verlustfrei, ohne Zusatzbibliothek) ----------
|
||||||
|
|
||||||
|
function pngLesen(buf) {
|
||||||
|
if (buf.readUInt32BE(0) !== 0x89504e47) throw new Error('kein PNG');
|
||||||
|
let pos = 8, w = 0, h = 0, tiefe = 0, typ = 0, palette = null, transparenz = null;
|
||||||
|
const idat = [];
|
||||||
|
while (pos < buf.length) {
|
||||||
|
const laenge = buf.readUInt32BE(pos);
|
||||||
|
const art = buf.toString('ascii', pos + 4, pos + 8);
|
||||||
|
const daten = buf.subarray(pos + 8, pos + 8 + laenge);
|
||||||
|
if (art === 'IHDR') {
|
||||||
|
w = daten.readUInt32BE(0); h = daten.readUInt32BE(4); tiefe = daten[8]; typ = daten[9];
|
||||||
|
if (daten[12] !== 0) throw new Error('Interlacing nicht unterstützt');
|
||||||
|
} else if (art === 'PLTE') palette = daten;
|
||||||
|
else if (art === 'tRNS') transparenz = daten;
|
||||||
|
else if (art === 'IDAT') idat.push(daten);
|
||||||
|
else if (art === 'IEND') break;
|
||||||
|
pos += 12 + laenge;
|
||||||
|
}
|
||||||
|
const kanaele = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }[typ];
|
||||||
|
if (!kanaele || (tiefe !== 8 && !(typ === 3 && tiefe < 8) && !(typ === 0 && tiefe < 8))) throw new Error(`PNG-Format ${typ}/${tiefe} nicht unterstützt`);
|
||||||
|
const roh = zlib.inflateSync(Buffer.concat(idat));
|
||||||
|
const bpp = Math.max(1, (kanaele * tiefe) >> 3); // Bytes je Pixel für die Filter
|
||||||
|
const zeile = Math.ceil((w * kanaele * tiefe) / 8);
|
||||||
|
const pixel = Buffer.alloc(zeile * h);
|
||||||
|
for (let y = 0; y < h; y++) {
|
||||||
|
const f = roh[y * (zeile + 1)];
|
||||||
|
const ein = roh.subarray(y * (zeile + 1) + 1, (y + 1) * (zeile + 1));
|
||||||
|
const aus = pixel.subarray(y * zeile, (y + 1) * zeile);
|
||||||
|
const oben = y ? pixel.subarray((y - 1) * zeile, y * zeile) : null;
|
||||||
|
for (let i = 0; i < zeile; i++) {
|
||||||
|
const a = i >= bpp ? aus[i - bpp] : 0, b = oben ? oben[i] : 0, c = oben && i >= bpp ? oben[i - bpp] : 0;
|
||||||
|
let v = ein[i];
|
||||||
|
if (f === 1) v += a;
|
||||||
|
else if (f === 2) v += b;
|
||||||
|
else if (f === 3) v += (a + b) >> 1;
|
||||||
|
else if (f === 4) { const p = a + b - c, pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c); v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c; }
|
||||||
|
aus[i] = v & 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const rgba = Buffer.alloc(w * h * 4);
|
||||||
|
for (let y = 0; y < h; y++) {
|
||||||
|
for (let x = 0; x < w; x++) {
|
||||||
|
const o = (y * w + x) * 4;
|
||||||
|
let wert;
|
||||||
|
if (tiefe < 8) {
|
||||||
|
const bit = x * tiefe;
|
||||||
|
wert = (pixel[y * zeile + (bit >> 3)] >> (8 - tiefe - (bit & 7))) & ((1 << tiefe) - 1);
|
||||||
|
} else wert = null;
|
||||||
|
const p = y * zeile + x * kanaele;
|
||||||
|
if (typ === 6) { pixel.copy(rgba, o, p, p + 4); continue; }
|
||||||
|
if (typ === 2) { rgba[o] = pixel[p]; rgba[o + 1] = pixel[p + 1]; rgba[o + 2] = pixel[p + 2]; rgba[o + 3] = 255; continue; }
|
||||||
|
if (typ === 4) { rgba[o] = rgba[o + 1] = rgba[o + 2] = pixel[p]; rgba[o + 3] = pixel[p + 1]; continue; }
|
||||||
|
if (typ === 0) {
|
||||||
|
const g = tiefe < 8 ? Math.round((wert * 255) / ((1 << tiefe) - 1)) : pixel[p];
|
||||||
|
rgba[o] = rgba[o + 1] = rgba[o + 2] = g; rgba[o + 3] = 255; continue;
|
||||||
|
}
|
||||||
|
const index = tiefe < 8 ? wert : pixel[p]; // Palette
|
||||||
|
rgba[o] = palette[index * 3]; rgba[o + 1] = palette[index * 3 + 1]; rgba[o + 2] = palette[index * 3 + 2];
|
||||||
|
rgba[o + 3] = transparenz && index < transparenz.length ? transparenz[index] : 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { w, h, rgba };
|
||||||
|
}
|
||||||
|
|
||||||
|
const CRC = (() => {
|
||||||
|
const t = new Int32Array(256);
|
||||||
|
for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; t[n] = c; }
|
||||||
|
return t;
|
||||||
|
})();
|
||||||
|
function crc32(buf) {
|
||||||
|
let c = -1;
|
||||||
|
for (let i = 0; i < buf.length; i++) c = CRC[(c ^ buf[i]) & 255] ^ (c >>> 8);
|
||||||
|
return (c ^ -1) >>> 0;
|
||||||
|
}
|
||||||
|
function block(art, daten) {
|
||||||
|
const kopf = Buffer.alloc(8);
|
||||||
|
kopf.writeUInt32BE(daten.length, 0);
|
||||||
|
kopf.write(art, 4, 'ascii');
|
||||||
|
const pruef = Buffer.alloc(4);
|
||||||
|
pruef.writeUInt32BE(crc32(Buffer.concat([kopf.subarray(4), daten])), 0);
|
||||||
|
return Buffer.concat([kopf, daten, pruef]);
|
||||||
|
}
|
||||||
|
function pngSchreiben(w, h, rgba) {
|
||||||
|
const roh = Buffer.alloc((w * 4 + 1) * h);
|
||||||
|
for (let y = 0; y < h; y++) rgba.copy(roh, y * (w * 4 + 1) + 1, y * w * 4, (y + 1) * w * 4);
|
||||||
|
const ihdr = Buffer.alloc(13);
|
||||||
|
ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 6;
|
||||||
|
return Buffer.concat([
|
||||||
|
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||||
|
block('IHDR', ihdr), block('IDAT', zlib.deflateSync(roh, { level: 9 })), block('IEND', Buffer.alloc(0)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Paket bauen ----------
|
||||||
|
|
||||||
|
async function ausJar(jar, eintraege) {
|
||||||
|
const verz = await unzipper.Open.file(jar);
|
||||||
|
const aus = {};
|
||||||
|
for (const e of eintraege) {
|
||||||
|
const f = verz.files.find((d) => d.path === e);
|
||||||
|
if (f) aus[e] = await f.buffer();
|
||||||
|
}
|
||||||
|
return aus;
|
||||||
|
}
|
||||||
|
|
||||||
|
function schreibe(datei, inhalt) {
|
||||||
|
fs.mkdirSync(path.dirname(datei), { recursive: true });
|
||||||
|
fs.writeFileSync(datei, inhalt);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bauePaket(gameDir, version, clientJar, protokoll, teile) {
|
||||||
|
const { design, mods } = teile;
|
||||||
|
const ziel = path.join(gameDir, 'resourcepacks', PAKET);
|
||||||
|
const marke = path.join(ziel, 'aeromc.json');
|
||||||
|
const kennung = JSON.stringify({ stand: STAND, version, design: !!design, mods: !!mods });
|
||||||
|
try { if (fs.readFileSync(marke, 'utf8') === kennung) return; } catch { /* noch nicht gebaut */ }
|
||||||
|
fs.rmSync(ziel, { recursive: true, force: true });
|
||||||
|
|
||||||
|
const alt = !ab(version, '1.20'); // Titel-Logo zweigeteilt bis 1.19.4
|
||||||
|
const gui = path.join(ziel, 'assets', 'minecraft', 'textures', 'gui');
|
||||||
|
const asset = (n) => fs.readFileSync(path.join(ASSETS, n));
|
||||||
|
schreibe(path.join(ziel, 'pack.png'), asset('pack.png'));
|
||||||
|
schreibe(path.join(ziel, 'pack.mcmeta'), mcmeta(version));
|
||||||
|
|
||||||
|
// Symbole (Schriftzeichen) und Beschriftungen – für Menü-Knöpfe und/oder „Mods verwalten“
|
||||||
|
schreibe(path.join(ziel, 'assets', 'aeromc', 'textures', 'font', 'symbole.png'), asset('symbole.png'));
|
||||||
|
// Die Schriftdatei ersetzt die von Minecraft (zumindest in neueren Versionen) –
|
||||||
|
// darum die Original-Einträge dieser Version übernehmen und die Symbole davorsetzen
|
||||||
|
try {
|
||||||
|
const eintrag = 'assets/minecraft/font/default.json';
|
||||||
|
const orig = JSON.parse(String((await ausJar(clientJar, [eintrag]))[eintrag] || '{"providers":[]}'));
|
||||||
|
const eigene = JSON.parse(String(asset('font_default.json'))).providers;
|
||||||
|
schreibe(path.join(ziel, 'assets', 'minecraft', 'font', 'default.json'),
|
||||||
|
JSON.stringify({ providers: [...eigene, ...(orig.providers || [])] }, null, 2));
|
||||||
|
} catch (err) {
|
||||||
|
if (protokoll) protokoll('[AeroMC] Symbole nicht eingebunden: ' + err.message);
|
||||||
|
}
|
||||||
|
for (const sprache of ['de_de', 'en_us']) {
|
||||||
|
const texte = Object.assign({},
|
||||||
|
design ? JSON.parse(String(asset(`lang_${sprache}.json`))) : {},
|
||||||
|
mods ? JSON.parse(String(asset(`lang_mods_${sprache}.json`))) : {});
|
||||||
|
schreibe(path.join(ziel, 'assets', 'minecraft', 'lang', sprache + '.json'), JSON.stringify(texte, null, 2));
|
||||||
|
}
|
||||||
|
// 26.x: „Feedback geben“ ist ein Symbol-Knopf -> Puzzle-Symbol für „Mods verwalten“
|
||||||
|
if (mods) schreibe(path.join(gui, 'sprites', 'pause_menu', 'social_interactions.png'), asset('mods_knopf.png'));
|
||||||
|
if (!design) { schreibe(marke, kennung); return; }
|
||||||
|
|
||||||
|
const titel = fs.readFileSync(path.join(ASSETS, alt ? 'titel_alt.png' : 'titel_neu.png'));
|
||||||
|
schreibe(path.join(gui, 'title', 'minecraft.png'), titel);
|
||||||
|
schreibe(path.join(gui, 'title', 'minceraft.png'), titel);
|
||||||
|
schreibe(path.join(gui, 'title', 'edition.png'), fs.readFileSync(path.join(ASSETS, alt ? 'edition_alt.png' : 'edition_neu.png')));
|
||||||
|
// Hintergrund des Hauptmenüs (Minecraft dreht ihn als Würfel um die Kamera)
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
schreibe(path.join(gui, 'title', 'background', `panorama_${i}.png`), fs.readFileSync(path.join(ASSETS, `panorama_${i}.png`)));
|
||||||
|
}
|
||||||
|
schreibe(path.join(ziel, 'assets', 'minecraft', 'texts', 'splashes.txt'), asset('splashes.txt'));
|
||||||
|
|
||||||
|
// Knöpfe wie im Entwurf (dunkles Schiefer, hervorgehoben leuchtend blau)
|
||||||
|
try {
|
||||||
|
if (ab(version, '1.20.2')) {
|
||||||
|
// eigene Sprites (doppelte Auflösung) samt Neun-Teile-Skalierung
|
||||||
|
const w = path.join(gui, 'sprites', 'widget');
|
||||||
|
schreibe(path.join(w, 'button.png'), asset('knopf.png'));
|
||||||
|
schreibe(path.join(w, 'button_highlighted.png'), asset('knopf_hell.png'));
|
||||||
|
schreibe(path.join(w, 'button.png.mcmeta'), asset('knopf.png.mcmeta'));
|
||||||
|
schreibe(path.join(w, 'button_highlighted.png.mcmeta'), asset('knopf.png.mcmeta'));
|
||||||
|
} else {
|
||||||
|
// widgets.png enthält auch Hotbar & Co. – Original 4-fach vergrößern (verlustfrei)
|
||||||
|
// und nur die Knopf-Zeilen durch die neuen ersetzen (66–86 normal, 86–106 hervorgehoben)
|
||||||
|
const e = 'assets/minecraft/textures/gui/widgets.png';
|
||||||
|
const daten = (await ausJar(clientJar, [e]))[e];
|
||||||
|
if (daten) {
|
||||||
|
const orig = pngLesen(daten);
|
||||||
|
const f = Math.max(1, Math.round(1024 / orig.w));
|
||||||
|
const W = orig.w * f, H = orig.h * f;
|
||||||
|
const gross = Buffer.alloc(W * H * 4);
|
||||||
|
for (let y = 0; y < H; y++) {
|
||||||
|
for (let x = 0; x < W; x++) orig.rgba.copy(gross, (y * W + x) * 4, (((y / f) | 0) * orig.w + ((x / f) | 0)) * 4, (((y / f) | 0) * orig.w + ((x / f) | 0)) * 4 + 4);
|
||||||
|
}
|
||||||
|
const s = W / 256;
|
||||||
|
for (const [datei, zeile] of [['knopf_4x.png', 66], ['knopf_hell_4x.png', 86]]) {
|
||||||
|
const k = pngLesen(asset(datei));
|
||||||
|
const kb = Math.round(200 * s), kh = Math.round(20 * s), y0 = Math.round(zeile * s);
|
||||||
|
for (let y = 0; y < kh; y++) {
|
||||||
|
for (let x = 0; x < kb; x++) {
|
||||||
|
const q = (((y * k.h / kh) | 0) * k.w + ((x * k.w / kb) | 0)) * 4;
|
||||||
|
k.rgba.copy(gross, ((y0 + y) * W + x) * 4, q, q + 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
schreibe(path.join(gui, 'widgets.png'), pngSchreiben(W, H, gross));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (protokoll) protokoll('[AeroMC] Knöpfe nicht ersetzt: ' + err.message);
|
||||||
|
}
|
||||||
|
schreibe(marke, kennung);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- options.txt ----------
|
||||||
|
|
||||||
|
function liesListe(zeilen, schluessel) {
|
||||||
|
const z = zeilen.find((l) => l.startsWith(schluessel + ':'));
|
||||||
|
if (!z) return null;
|
||||||
|
try { const v = JSON.parse(z.slice(schluessel.length + 1)); return Array.isArray(v) ? v : []; } catch { return []; }
|
||||||
|
}
|
||||||
|
function setzeListe(zeilen, schluessel, liste) {
|
||||||
|
const i = zeilen.findIndex((l) => l.startsWith(schluessel + ':'));
|
||||||
|
const neu = schluessel + ':' + JSON.stringify(liste);
|
||||||
|
if (i >= 0) zeilen[i] = neu; else zeilen.push(neu);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Paket in options.txt ein- bzw. austragen. Rückgabe: ob sich etwas geändert hat. */
|
||||||
|
function trageEin(gameDir, an) {
|
||||||
|
const datei = path.join(gameDir, 'options.txt');
|
||||||
|
let text = '';
|
||||||
|
try { text = fs.readFileSync(datei, 'utf8'); } catch { if (!an) return false; }
|
||||||
|
const zeilen = text ? text.split(/\r?\n/) : [];
|
||||||
|
while (zeilen.length && zeilen[zeilen.length - 1] === '') zeilen.pop();
|
||||||
|
const aktiv = liesListe(zeilen, 'resourcePacks') || ['vanilla'];
|
||||||
|
const unvertraeglich = liesListe(zeilen, 'incompatibleResourcePacks') || [];
|
||||||
|
const vorher = JSON.stringify([aktiv, unvertraeglich]);
|
||||||
|
const ohne = (l) => l.filter((p) => p !== PAKET_ID && p !== PAKET);
|
||||||
|
let a = ohne(aktiv), u = ohne(unvertraeglich);
|
||||||
|
if (an) {
|
||||||
|
// direkt über den eingebauten Paketen, unter allen eigenen Ressourcenpaketen
|
||||||
|
let pos = a.findIndex((p) => String(p).startsWith('file/'));
|
||||||
|
if (pos < 0) pos = a.length;
|
||||||
|
if (!a.includes('vanilla')) { a.unshift('vanilla'); pos++; }
|
||||||
|
a.splice(Math.max(pos, a.indexOf('vanilla') + 1), 0, PAKET_ID);
|
||||||
|
// NICHT in incompatibleResourcePacks eintragen: Steht ein passendes Paket dort,
|
||||||
|
// streicht Minecraft es aus der Liste und lässt es dabei für diesen Start weg
|
||||||
|
}
|
||||||
|
if (JSON.stringify([a, u]) === vorher) return false;
|
||||||
|
if (liesListe(zeilen, 'resourcePacks') || an) setzeListe(zeilen, 'resourcePacks', a);
|
||||||
|
if (liesListe(zeilen, 'incompatibleResourcePacks')) setzeListe(zeilen, 'incompatibleResourcePacks', u);
|
||||||
|
fs.writeFileSync(datei, zeilen.join('\n') + '\n');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Einstieg ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vor jedem Spielstart: Paket anlegen und eintragen bzw. (ausgeschaltet)
|
||||||
|
* wieder entfernen. Fehler brechen den Start nie ab.
|
||||||
|
*/
|
||||||
|
async function vorbereiten({ gameDir, version, clientJar, an, mods, protokoll }) {
|
||||||
|
try {
|
||||||
|
if ((an || mods) && unterstuetzt(version)) {
|
||||||
|
await bauePaket(gameDir, version, clientJar, protokoll, { design: !!an, mods: !!mods });
|
||||||
|
trageEin(gameDir, true);
|
||||||
|
} else {
|
||||||
|
if (trageEin(gameDir, false) || fs.existsSync(path.join(gameDir, 'resourcepacks', PAKET))) {
|
||||||
|
fs.rmSync(path.join(gameDir, 'resourcepacks', PAKET), { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (protokoll) protokoll('[AeroMC] Spieldesign nicht vorbereitet: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Grafiken für den Agenten nach userData kopieren; Rückgabe: Ordner oder '' */
|
||||||
|
function agentOrdner(userData) {
|
||||||
|
try {
|
||||||
|
const ordner = path.join(userData, 'agent', 'spieldesign');
|
||||||
|
fs.mkdirSync(ordner, { recursive: true });
|
||||||
|
for (const n of fs.readdirSync(ASSETS)) {
|
||||||
|
if (!/^(ladelogo(_alt)?\.png|icon_\d+\.rgba)$/.test(n)) continue;
|
||||||
|
const q = path.join(ASSETS, n), z = path.join(ordner, n);
|
||||||
|
const daten = fs.readFileSync(q);
|
||||||
|
let gleich = false;
|
||||||
|
try { gleich = fs.readFileSync(z).equals(daten); } catch { /* fehlt noch */ }
|
||||||
|
if (!gleich) fs.writeFileSync(z, daten);
|
||||||
|
}
|
||||||
|
return ordner;
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { vorbereiten, agentOrdner, unterstuetzt, paketFormat, mcmeta, pngLesen, pngSchreiben, trageEin, PAKET_ID };
|
||||||
@@ -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>
|
||||||
@@ -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 };
|
||||||
@@ -1,172 +1,183 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* technicimport.js – Instanzen aus dem Technic Launcher übernehmen
|
* technicimport.js – Instanzen aus dem Technic Launcher übernehmen
|
||||||
* -------------------------------------------------------------------
|
* -------------------------------------------------------------------
|
||||||
* Der Technic Launcher legt jedes installierte Modpack als eigenen Ordner
|
* Der Technic Launcher legt jedes installierte Modpack als eigenen Ordner
|
||||||
* unter "<Technic-Wurzel>/modpacks/<name>" an (Standard unter Windows:
|
* unter "<Technic-Wurzel>/modpacks/<name>" an (Standard unter Windows:
|
||||||
* %APPDATA%\.technic\modpacks). Darin liegen "bin" (Launcher-interner
|
* %APPDATA%\.technic\modpacks). Darin liegen "bin" (Launcher-interner
|
||||||
* Jar-/Versions-Cache), "config" und "mods" als Geschwisterordner – Welten,
|
* Jar-/Versions-Cache), "config" und "mods" als Geschwisterordner – Welten,
|
||||||
* Ressourcenpakete usw. landen ebenfalls direkt im Pack-Ordner, genau wie
|
* Ressourcenpakete usw. landen ebenfalls direkt im Pack-Ordner, genau wie
|
||||||
* bei einem klassischen .minecraft.
|
* bei einem klassischen .minecraft.
|
||||||
*
|
*
|
||||||
* Es gibt keine für uns nutzbare, stabil dokumentierte JSON-Datei mit einem
|
* Es gibt keine für uns nutzbare, stabil dokumentierte JSON-Datei mit einem
|
||||||
* schönen Anzeigenamen – "installedPacks" im Wurzelordner listet Packs nur
|
* schönen Anzeigenamen – "installedPacks" im Wurzelordner listet Packs nur
|
||||||
* über ihren Ordnernamen (Slug). Wir lesen sie deshalb nur als optionale,
|
* über ihren Ordnernamen (Slug). Wir lesen sie deshalb nur als optionale,
|
||||||
* fehlertolerante Ergänzung; die eigentliche Erkennung läuft – wie bei den
|
* fehlertolerante Ergänzung; die eigentliche Erkennung läuft – wie bei den
|
||||||
* anderen Importern – über die tatsächlich vorhandenen Ordner/Dateien.
|
* anderen Importern – über die tatsächlich vorhandenen Ordner/Dateien.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const forge = require('./forge');
|
const forge = require('./forge');
|
||||||
|
|
||||||
// Ordner, die zum Technic-internen Verwaltungs-/Jar-Cache gehören und beim
|
// Ordner, die zum Technic-internen Verwaltungs-/Jar-Cache gehören und beim
|
||||||
// Übernehmen NICHT mit ins Spielverzeichnis der neuen Instanz sollen.
|
// Übernehmen NICHT mit ins Spielverzeichnis der neuen Instanz sollen.
|
||||||
const SKIP_DIRS = new Set(['bin']);
|
const SKIP_DIRS = new Set(['bin']);
|
||||||
|
|
||||||
function technicRoot() {
|
function technicRoot() {
|
||||||
return path.join(process.env.APPDATA || '', '.technic');
|
return path.join(process.env.APPDATA || '', '.technic');
|
||||||
}
|
}
|
||||||
|
|
||||||
function readJson(file) {
|
function readJson(file) {
|
||||||
try { return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^/, '')); }
|
try { return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^/, '')); }
|
||||||
catch { return null; }
|
catch { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// findet den modpacks-Ordner, egal ob Technic-Wurzel, modpacks-Ordner direkt
|
// findet den modpacks-Ordner, egal ob Technic-Wurzel, modpacks-Ordner direkt
|
||||||
// oder ein einzelner Pack-Ordner angegeben wurde
|
// oder ein einzelner Pack-Ordner angegeben wurde
|
||||||
function resolveModpacksDir(inputPath) {
|
function resolveModpacksDir(inputPath) {
|
||||||
const root = inputPath && inputPath.trim() ? inputPath.trim() : technicRoot();
|
const root = inputPath && inputPath.trim() ? inputPath.trim() : technicRoot();
|
||||||
if (!fs.existsSync(root)) return null;
|
if (!fs.existsSync(root)) return null;
|
||||||
|
|
||||||
const direct = path.join(root, 'modpacks');
|
const direct = path.join(root, 'modpacks');
|
||||||
if (fs.existsSync(direct)) return direct;
|
if (fs.existsSync(direct)) return direct;
|
||||||
if (path.basename(root).toLowerCase() === 'modpacks') return root;
|
if (path.basename(root).toLowerCase() === 'modpacks') return root;
|
||||||
return root; // z. B. ein selbst gewählter Ordner mit Pack-Unterordnern
|
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
|
// ein Pack-Ordner hat i. d. R. bin/, config/ und/oder mods/ als Geschwister
|
||||||
function looksLikePack(dir) {
|
function looksLikePack(dir) {
|
||||||
const markers = ['bin', 'mods', 'config', 'saves', 'resourcepacks', 'options.txt'];
|
const markers = ['bin', 'mods', 'config', 'saves', 'resourcepacks', 'options.txt'];
|
||||||
return markers.some((name) => fs.existsSync(path.join(dir, name)));
|
return markers.some((name) => fs.existsSync(path.join(dir, name)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// optionale Anreicherung aus der "installedPacks"-Datei der Technic-Wurzel;
|
// optionale Anreicherung aus der "installedPacks"-Datei der Technic-Wurzel;
|
||||||
// bewusst rein additiv und fehlertolerant, da das Format nicht offiziell
|
// bewusst rein additiv und fehlertolerant, da das Format nicht offiziell
|
||||||
// als stabile API dokumentiert ist
|
// als stabile API dokumentiert ist
|
||||||
function readInstalledPacksMeta(root) {
|
function readInstalledPacksMeta(root) {
|
||||||
const data = readJson(path.join(root, 'installedPacks'));
|
const data = readJson(path.join(root, 'installedPacks'));
|
||||||
const packs = data && data.installedPacks;
|
const packs = data && data.installedPacks;
|
||||||
return packs && typeof packs === 'object' ? packs : {};
|
return packs && typeof packs === 'object' ? packs : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
// versucht, aus bin/modpack.jar (version.json) oder der alten bin/version
|
// versucht, aus bin/modpack.jar (version.json) oder der alten bin/version
|
||||||
// -Textdatei Minecraft-Version und Mod-Loader zu bestimmen. Rein
|
// -Textdatei Minecraft-Version und Mod-Loader zu bestimmen. Rein
|
||||||
// bestmöglich – schlägt die Erkennung fehl, bleibt die Instanz einfach
|
// bestmöglich – schlägt die Erkennung fehl, bleibt die Instanz einfach
|
||||||
// ohne Version/Loader stehen und lässt sich manuell nachtragen.
|
// ohne Version/Loader stehen und lässt sich manuell nachtragen.
|
||||||
function detectVersion(packDir) {
|
function detectVersion(packDir) {
|
||||||
const bin = path.join(packDir, 'bin');
|
const bin = path.join(packDir, 'bin');
|
||||||
const jarFile = path.join(bin, 'modpack.jar');
|
const jarFile = path.join(bin, 'modpack.jar');
|
||||||
if (fs.existsSync(jarFile)) {
|
if (fs.existsSync(jarFile)) {
|
||||||
try {
|
try {
|
||||||
const buf = fs.readFileSync(jarFile);
|
const buf = fs.readFileSync(jarFile);
|
||||||
const idx = forge.readZipIndex(buf);
|
const idx = forge.readZipIndex(buf);
|
||||||
const entry = idx['version.json'];
|
const entry = idx['version.json'];
|
||||||
if (entry) {
|
if (entry) {
|
||||||
// manche Jars speichern JSON mit BOM – ohne Entfernen bricht JSON.parse
|
// manche Jars speichern JSON mit BOM – ohne Entfernen bricht JSON.parse
|
||||||
const text = forge.readZipEntry(buf, entry).toString('utf8').replace(/^/, '');
|
const text = forge.readZipEntry(buf, entry).toString('utf8').replace(/^/, '');
|
||||||
const profile = JSON.parse(text);
|
const profile = JSON.parse(text);
|
||||||
const libs = Array.isArray(profile.libraries) ? profile.libraries : [];
|
const libs = Array.isArray(profile.libraries) ? profile.libraries : [];
|
||||||
let loader = 'vanilla';
|
let loader = 'vanilla';
|
||||||
let loaderVersion = '';
|
let loaderVersion = '';
|
||||||
for (const lib of libs) {
|
for (const lib of libs) {
|
||||||
const name = String((lib && lib.name) || '');
|
const name = String((lib && lib.name) || '');
|
||||||
if (/net\.minecraftforge:forge:/i.test(name)) {
|
if (/net\.minecraftforge:forge:/i.test(name)) {
|
||||||
loader = 'forge';
|
loader = 'forge';
|
||||||
loaderVersion = (name.split(':')[2] || '').split('-').pop() || '';
|
loaderVersion = (name.split(':')[2] || '').split('-').pop() || '';
|
||||||
} else if (/net\.neoforged:neoforge:/i.test(name) && loader === 'vanilla') {
|
} else if (/net\.neoforged:neoforge:/i.test(name) && loader === 'vanilla') {
|
||||||
loader = 'neoforge';
|
loader = 'neoforge';
|
||||||
loaderVersion = name.split(':')[2] || '';
|
loaderVersion = name.split(':')[2] || '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const id = String(profile.id || profile.inheritsFrom || '');
|
const id = String(profile.id || profile.inheritsFrom || '');
|
||||||
const version = String(profile.inheritsFrom || id.split('-')[0] || '').trim();
|
const version = String(profile.inheritsFrom || id.split('-')[0] || '').trim();
|
||||||
if (version) return { version, loader, loaderVersion };
|
if (version) return { version, loader, loaderVersion };
|
||||||
}
|
}
|
||||||
} catch { /* modpack.jar nicht lesbar/kein gültiges ZIP – weiter unten Fallback */ }
|
} catch { /* modpack.jar nicht lesbar/kein gültiges ZIP – weiter unten Fallback */ }
|
||||||
}
|
}
|
||||||
// ältere Packs hinterlegen nur eine reine Textdatei mit der MC-Version
|
// ältere Packs hinterlegen nur eine reine Textdatei mit der MC-Version
|
||||||
const legacy = path.join(bin, 'version');
|
const legacy = path.join(bin, 'version');
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(legacy)) {
|
if (fs.existsSync(legacy)) {
|
||||||
const version = fs.readFileSync(legacy, 'utf8').trim();
|
const version = fs.readFileSync(legacy, 'utf8').trim();
|
||||||
if (version) return { version, loader: 'vanilla', loaderVersion: '' };
|
if (version) return { version, loader: 'vanilla', loaderVersion: '' };
|
||||||
}
|
}
|
||||||
} catch { /* egal, bleibt leer */ }
|
} catch { /* egal, bleibt leer */ }
|
||||||
return { version: '', loader: 'vanilla', loaderVersion: '' };
|
return { version: '', loader: 'vanilla', loaderVersion: '' };
|
||||||
}
|
}
|
||||||
|
|
||||||
function scan(inputPath) {
|
function scan(inputPath) {
|
||||||
const modpacksDir = resolveModpacksDir(inputPath);
|
const modpacksDir = resolveModpacksDir(inputPath);
|
||||||
if (!modpacksDir || !fs.existsSync(modpacksDir)) return { ok: false, reason: 'not-found' };
|
if (!modpacksDir || !fs.existsSync(modpacksDir)) return { ok: false, reason: 'not-found' };
|
||||||
|
|
||||||
const root = path.basename(modpacksDir).toLowerCase() === 'modpacks'
|
const root = path.basename(modpacksDir).toLowerCase() === 'modpacks'
|
||||||
? path.dirname(modpacksDir) : modpacksDir;
|
? path.dirname(modpacksDir) : modpacksDir;
|
||||||
const meta = readInstalledPacksMeta(root);
|
const meta = readInstalledPacksMeta(root);
|
||||||
|
|
||||||
const list = [];
|
const list = [];
|
||||||
for (const entry of fs.readdirSync(modpacksDir, { withFileTypes: true })) {
|
for (const entry of fs.readdirSync(modpacksDir, { withFileTypes: true })) {
|
||||||
if (!entry.isDirectory()) continue;
|
if (!entry.isDirectory()) continue;
|
||||||
const dir = path.join(modpacksDir, entry.name);
|
const dir = path.join(modpacksDir, entry.name);
|
||||||
if (!looksLikePack(dir)) continue;
|
if (!looksLikePack(dir)) continue;
|
||||||
|
|
||||||
const { version, loader, loaderVersion } = detectVersion(dir);
|
const { version, loader, loaderVersion } = detectVersion(dir);
|
||||||
const info = meta[entry.name] || {};
|
const info = meta[entry.name] || {};
|
||||||
list.push({
|
list.push({
|
||||||
folder: entry.name,
|
folder: entry.name,
|
||||||
dir,
|
dir,
|
||||||
name: info.displayName || entry.name,
|
name: info.displayName || entry.name,
|
||||||
group: '',
|
group: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
version,
|
version,
|
||||||
loader,
|
loader,
|
||||||
loaderVersion,
|
loaderVersion,
|
||||||
gameDir: dir,
|
gameDir: dir,
|
||||||
hasGameData: true,
|
hasGameData: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
list.sort((a, b) => a.name.localeCompare(b.name));
|
list.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
return { ok: true, instancesDir: modpacksDir, count: list.length, instances: list };
|
return { ok: true, instancesDir: modpacksDir, count: list.length, instances: list };
|
||||||
}
|
}
|
||||||
|
|
||||||
// kopiert den Pack-Ordner, lässt aber den Technic-internen bin-Cache weg
|
// kopiert den Pack-Ordner, lässt aber den Technic-internen bin-Cache weg
|
||||||
function copyInto(sourceDir, targetDir) {
|
function copyInto(sourceDir, targetDir) {
|
||||||
fs.mkdirSync(targetDir, { recursive: true });
|
fs.mkdirSync(targetDir, { recursive: true });
|
||||||
for (const name of fs.readdirSync(sourceDir)) {
|
for (const name of fs.readdirSync(sourceDir)) {
|
||||||
if (SKIP_DIRS.has(name)) continue;
|
if (SKIP_DIRS.has(name)) continue;
|
||||||
fs.cpSync(path.join(sourceDir, name), path.join(targetDir, name), { recursive: true });
|
fs.cpSync(path.join(sourceDir, name), path.join(targetDir, name), { recursive: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function importOne(store, entry, copyData = true) {
|
/*
|
||||||
const created = store.createOrReplaceImportedInstance({
|
* Was beim Übernehmen kopiert wird: [{ von, name }] – `name` ist der Zielname
|
||||||
name: entry.name,
|
* im Spielordner der neuen Instanz. Das Kopieren selbst erledigt kopieren.js
|
||||||
group: entry.group,
|
* asynchron mit Fortschritt (main.js → fuehreImportAus).
|
||||||
notes: entry.notes,
|
*/
|
||||||
minecraft: {
|
function kopierQuellen(entry) {
|
||||||
version: entry.version,
|
if (!entry.gameDir || !fs.existsSync(entry.gameDir)) return [];
|
||||||
loader: entry.loader,
|
return fs.readdirSync(entry.gameDir).filter((name) => !SKIP_DIRS.has(name))
|
||||||
loaderVersion: entry.loaderVersion,
|
.map((name) => ({ von: path.join(entry.gameDir, name), name }));
|
||||||
},
|
}
|
||||||
});
|
|
||||||
|
function importOne(store, entry, copyData = true) {
|
||||||
let copied = false;
|
const created = store.createOrReplaceImportedInstance({
|
||||||
if (copyData && entry.gameDir && fs.existsSync(entry.gameDir)) {
|
name: entry.name,
|
||||||
copyInto(entry.gameDir, store.gameDir(created.id));
|
group: entry.group,
|
||||||
copied = true;
|
notes: entry.notes,
|
||||||
}
|
minecraft: {
|
||||||
return { id: created.id, name: created.name, copied };
|
version: entry.version,
|
||||||
}
|
loader: entry.loader,
|
||||||
|
loaderVersion: entry.loaderVersion,
|
||||||
module.exports = { scan, importOne, resolveModpacksDir, technicRoot, detectVersion };
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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 };
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MCowBQYDK2VwAyEAXxt1cCntDQzvej4hjyVZt6Onj6Uv3hSlneJaOxxmsds=
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
@@ -1,370 +1,541 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* updater.js – automatische Updates über die Gitea-Releases
|
* updater.js – automatische Updates über die Gitea-Releases
|
||||||
* ---------------------------------------------------------
|
* ---------------------------------------------------------
|
||||||
* Ablauf: Releases abfragen -> neueste Version mit der laufenden vergleichen ->
|
* Ablauf: Releases abfragen -> neueste Version mit der laufenden vergleichen ->
|
||||||
* bei Bedarf den Windows-Installer herunterladen und starten.
|
* bei Bedarf den Windows-Installer herunterladen und starten.
|
||||||
* Ein Klick des Nutzers genügt; Download, Prüfung und Start laufen automatisch.
|
* Ein Klick des Nutzers genügt; Download, Prüfung und Start laufen automatisch.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { spawn } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
|
|
||||||
const API = 'https://git.viper.ipv64.net/api/v1/repos/M_Viper/AeroMc-Launcher/releases';
|
const API = 'https://git.viper.ipv64.net/api/v1/repos/M_Viper/AeroMc-Launcher/releases';
|
||||||
const UA = 'AeroMC-Launcher';
|
/*
|
||||||
|
* Beta-Kanal: Vorabfassungen (Beta, RC) liegen im selben Repository wie die
|
||||||
/*
|
* fertigen Versionen, in Gitea als "Pre-Release" markiert – und mit einem Tag,
|
||||||
* Paketart der laufenden Anwendung: 'portable', 'setup' oder 'unknown'.
|
* dem "0.0.0-" vorangestellt ist:
|
||||||
*
|
*
|
||||||
* Wichtig: es wird nie geraten. Portable-Nutzer dürfen niemals einen Installer
|
* App-Version 1.2.4-beta.1 -> Tag 0.0.0-1.2.4-beta.1
|
||||||
* bekommen und Setup-Nutzer niemals eine Portable-Datei – im Zweifel lieber
|
*
|
||||||
* 'unknown' und gar kein automatisches Update anbieten.
|
* Der Vorsatz ist nötig, weil Launcher bis 1.2.3 das Pre-Release-Feld nicht
|
||||||
*/
|
* kennen und nur die Zahlen am Anfang des Tags lesen: "1.2.4-beta.1" wäre für
|
||||||
let cachedKind = null;
|
* sie ein normales Update auf 1.2.4, das ALLE Nutzer bekämen. "0.0.0-…" lesen
|
||||||
function detectInstallKind() {
|
* sie als uralte Version 0.0.0 und ignorieren sie; fertige Versionen finden sie
|
||||||
if (cachedKind) return cachedKind;
|
* weiterhin. (Ein Tag ohne Zahl vorn, etwa "beta-…", taugt nicht: Er bringt bei
|
||||||
cachedKind = computeInstallKind();
|
* alten Launchern die Sortierung durcheinander, sie fänden dann gar kein Update
|
||||||
return cachedKind;
|
* mehr – mit dem echten 1.2.3 geprüft.)
|
||||||
}
|
*/
|
||||||
|
const BETA_VORSATZ = /^0\.0\.0-(?=\d)/;
|
||||||
function computeInstallKind() {
|
|
||||||
// 1. Portable: diese Variablen setzt ausschließlich der Portable-Build von
|
// Version, die ein Release wirklich enthält: Tag ohne "v" und ohne Beta-Vorsatz
|
||||||
// electron-builder – das ist das verlässlichste Signal überhaupt.
|
function tagVersion(tag) {
|
||||||
if (String(process.env.PORTABLE_EXECUTABLE_FILE || '').trim()) return 'portable';
|
return normVersion(tag).replace(BETA_VORSATZ, '');
|
||||||
if (String(process.env.PORTABLE_EXECUTABLE_DIR || '').trim()) return 'portable';
|
}
|
||||||
if (String(process.env.PORTABLE_EXECUTABLE_APP_FILENAME || '').trim()) return 'portable';
|
|
||||||
|
// Vorabfassung? In Gitea so markiert, mit Beta-Vorsatz oder mit Kennung im Tag
|
||||||
const exe = process.execPath || '';
|
function istVorab(release) {
|
||||||
// 2. Notnagel über den Dateinamen, falls die Variablen einmal fehlen
|
return !!release.prerelease || BETA_VORSATZ.test(normVersion(release.tag_name)) || !!parsePreRelease(tagVersion(release.tag_name));
|
||||||
if (/portable/i.test(path.basename(exe))) return 'portable';
|
}
|
||||||
|
const UA = 'AeroMC-Launcher';
|
||||||
// 3. Setup: das Setup legt neben der EXE immer "Uninstall AeroMC.exe" ab. Nur wenn der
|
// Downloads nur von hier – eine Release-Datei mit fremder Adresse wird abgelehnt
|
||||||
// da ist, ist es wirklich eine Installation.
|
const ERLAUBTER_HOST = 'git.viper.ipv64.net';
|
||||||
if (hasUninstaller(path.dirname(exe))) return 'setup';
|
// Die Signaturen liegen nicht im Gitea-Release, sondern auf der WordPress-Seite
|
||||||
|
// (Plugin aeromc-friends, includes/signaturen.php). Hochgeladen werden sie beim
|
||||||
// 4. Alles andere (Entwicklungsmodus, entpackter Ordner, unbekannte Kopie)
|
// Bauen automatisch von scripts/sign-release.js.
|
||||||
return 'unknown';
|
const SIGNATUR_API = 'https://viper-network.de/wp-json/aeromc/v1/update-signatur';
|
||||||
}
|
|
||||||
|
/*
|
||||||
function hasUninstaller(dir) {
|
* Sicherheit: Jede Update-Datei muss mit dem privaten Release-Schlüssel signiert
|
||||||
try {
|
* sein (Ed25519). Der liegt nur auf dem Rechner, auf dem gebaut wird – nie auf
|
||||||
return fs.readdirSync(dir).some((n) => /^unins(tall)?.*\.exe$/i.test(n));
|
* dem Server. Wer den Gitea-Server oder das Konto übernimmt, kann deshalb zwar
|
||||||
} catch {
|
* Dateien hochladen, aber keine gültige Signatur erzeugen; der Launcher
|
||||||
return false;
|
* installiert so etwas nicht. Signiert wird beim Bauen von
|
||||||
}
|
* scripts/sign-release.js; die Signatur liegt auf einem zweiten Server
|
||||||
}
|
* (SIGNATUR_API) – woher sie kommt, ist für die Sicherheit aber egal, fälschen
|
||||||
|
* kann sie ohne den Schlüssel niemand.
|
||||||
// Pfad der laufenden Portable-EXE (die Datei, die der Nutzer selbst gestartet hat)
|
*
|
||||||
function portableTarget() {
|
* Unterschrieben wird nicht nur der Inhalt (SHA-256), sondern auch Dateiname
|
||||||
const p = String(process.env.PORTABLE_EXECUTABLE_FILE || '').trim();
|
* und Version. So lässt sich weder eine Portable- als Setup-Datei ausgeben noch
|
||||||
if (p) return p;
|
* eine alte, signierte Fassung als "neues" Update unterschieben.
|
||||||
// Fallback: nur brauchbar, wenn die EXE nicht entpackt ausgeführt wird
|
*/
|
||||||
const exe = process.execPath || '';
|
const SIGNATUR_FORMAT = 'AeroMC-Update/1';
|
||||||
return path.basename(exe).toLowerCase().includes('portable') ? exe : '';
|
let oeffentlicherSchluessel = null;
|
||||||
}
|
function releaseSchluessel() {
|
||||||
|
if (!oeffentlicherSchluessel) {
|
||||||
function updateDir() {
|
const pem = fs.readFileSync(path.join(__dirname, 'update-schluessel.pem'), 'utf8');
|
||||||
return path.join(os.tmpdir(), 'aeromc-update');
|
oeffentlicherSchluessel = crypto.createPublicKey(pem);
|
||||||
}
|
}
|
||||||
|
return oeffentlicherSchluessel;
|
||||||
/*
|
}
|
||||||
* Räumt Reste alter Update-Vorgänge weg (Installer sind mehrere hundert MB groß).
|
|
||||||
* Dateien, die jünger als 10 Minuten sind, bleiben liegen – sie könnten gerade
|
function normVersion(v) {
|
||||||
* von einem laufenden Update benutzt werden.
|
return String(v || '').trim().replace(/^v/i, '');
|
||||||
*/
|
}
|
||||||
function cleanupUpdateCache() {
|
|
||||||
try {
|
// Der Text, der unterschrieben wird (identisch in scripts/sign-release.js)
|
||||||
const dir = updateDir();
|
function signaturText(datei, version, sha256) {
|
||||||
if (!fs.existsSync(dir)) return;
|
return Buffer.from([SIGNATUR_FORMAT, datei, normVersion(version), String(sha256).toLowerCase()].join('\n'), 'utf8');
|
||||||
for (const name of fs.readdirSync(dir)) {
|
}
|
||||||
const p = path.join(dir, name);
|
|
||||||
try {
|
// Prüft eine Signatur (Inhalt der .sig-Datei) gegen Name, Version und Hash
|
||||||
if (Date.now() - fs.statSync(p).mtimeMs < 600000) continue;
|
function pruefeSignatur(sig, datei, version, sha256) {
|
||||||
fs.rmSync(p, { force: true, recursive: true });
|
if (!sig || sig.format !== SIGNATUR_FORMAT) throw new Error('Signaturdatei hat ein unbekanntes Format.');
|
||||||
} catch { /* gesperrt – beim nächsten Start erneut versuchen */ }
|
if (sig.datei !== datei) throw new Error('Signatur gehört zu einer anderen Datei.');
|
||||||
}
|
if (normVersion(sig.version) !== normVersion(version)) throw new Error('Signatur gehört zu einer anderen Version.');
|
||||||
} catch { /* egal */ }
|
if (String(sig.sha256 || '').toLowerCase() !== String(sha256).toLowerCase()) {
|
||||||
}
|
throw new Error('Die heruntergeladene Datei passt nicht zur Signatur – Update abgebrochen.');
|
||||||
|
}
|
||||||
// "v1.2.3-beta.2" -> [1,2,3]; führendes "v" wird ignoriert
|
const ok = crypto.verify(null, signaturText(datei, version, sha256),
|
||||||
function parseVersion(v) {
|
releaseSchluessel(), Buffer.from(String(sig.signatur || ''), 'base64'));
|
||||||
const m = String(v || '').trim().replace(/^v/i, '').match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
|
if (!ok) throw new Error('Ungültige Signatur – die Datei stammt nicht vom AeroMC-Entwickler. Update abgebrochen.');
|
||||||
if (!m) return null;
|
}
|
||||||
return [Number(m[1] || 0), Number(m[2] || 0), Number(m[3] || 0)];
|
|
||||||
}
|
function erlaubteAdresse(adresse) {
|
||||||
|
try {
|
||||||
// Vorabkennung hinter dem ersten "-", z. B. "beta" oder "beta.2"
|
const u = new URL(String(adresse || ''));
|
||||||
function parsePreRelease(v) {
|
return u.protocol === 'https:' && u.hostname.toLowerCase() === ERLAUBTER_HOST;
|
||||||
const m = String(v || '').trim().replace(/^v/i, '').match(/^\d+(?:\.\d+){0,2}-([0-9A-Za-z.\-]+)/);
|
} catch { return false; }
|
||||||
return m ? m[1].split('.') : null;
|
}
|
||||||
}
|
|
||||||
|
/*
|
||||||
/*
|
* Signatur einer Release-Datei vom WordPress-Server holen.
|
||||||
* Vergleicht die Vorabkennungen zweier gleicher Versionsnummern.
|
* -> Signatur-Objekt, null wenn es (noch) keine gibt; wirft bei Netzwerkfehlern.
|
||||||
*
|
*/
|
||||||
* Bewusst nicht streng nach SemVer: trägt ein Release-Tag die Vorabkennung
|
async function holeSignatur(datei, version) {
|
||||||
* nicht, die die App selbst meldet (Tag "1.0.1" gegen App "1.0.1-beta"),
|
const adresse = `${SIGNATUR_API}?datei=${encodeURIComponent(datei)}&version=${encodeURIComponent(normVersion(version))}`;
|
||||||
* würde "1.0.1" als neuer gelten – der Launcher böte dasselbe Update nach
|
const res = await fetch(adresse, { headers: { 'User-Agent': UA, Accept: 'application/json' } });
|
||||||
* jeder Installation erneut an, eine Endlosschleife. Deshalb zählt eine
|
if (res.status === 404) return null;
|
||||||
* Vorabkennung nur, wenn sie auf beiden Seiten vorhanden ist (beta.1 -> beta.2).
|
if (!res.ok) throw new Error('Signatur-Server antwortet mit HTTP ' + res.status);
|
||||||
*/
|
const text = await res.text();
|
||||||
function comparePreRelease(a, b) {
|
if (text.length > 16384) throw new Error('Signatur ist ungültig.');
|
||||||
if (!a || !b) return 0;
|
const sig = JSON.parse(text);
|
||||||
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
if (!sig || typeof sig !== 'object' || sig.datei !== datei || normVersion(sig.version) !== normVersion(version)) {
|
||||||
const x = a[i]; const y = b[i];
|
throw new Error('Signatur passt nicht zu diesem Update.');
|
||||||
if (x === undefined) return -1;
|
}
|
||||||
if (y === undefined) return 1;
|
return { format: sig.format, datei: sig.datei, version: sig.version, sha256: sig.sha256, signatur: sig.signatur };
|
||||||
const nx = /^\d+$/.test(x) ? Number(x) : null;
|
}
|
||||||
const ny = /^\d+$/.test(y) ? Number(y) : null;
|
|
||||||
if (nx !== null && ny !== null) { if (nx !== ny) return nx - ny; continue; }
|
function sha256Datei(datei) {
|
||||||
if (nx !== null) return -1; // Zahlen zählen weniger als Text
|
return new Promise((resolve, reject) => {
|
||||||
if (ny !== null) return 1;
|
const h = crypto.createHash('sha256');
|
||||||
if (x !== y) return x < y ? -1 : 1;
|
fs.createReadStream(datei).on('error', reject).on('data', (d) => h.update(d)).on('end', () => resolve(h.digest('hex')));
|
||||||
}
|
});
|
||||||
return 0;
|
}
|
||||||
}
|
|
||||||
|
/*
|
||||||
// >0 wenn a neuer als b
|
* Paketart der laufenden Anwendung: 'portable', 'setup' oder 'unknown'.
|
||||||
function compareVersions(a, b) {
|
*
|
||||||
const x = parseVersion(a); const y = parseVersion(b);
|
* Wichtig: es wird nie geraten. Portable-Nutzer dürfen niemals einen Installer
|
||||||
if (!x || !y) return 0;
|
* bekommen und Setup-Nutzer niemals eine Portable-Datei – im Zweifel lieber
|
||||||
for (let i = 0; i < 3; i++) {
|
* 'unknown' und gar kein automatisches Update anbieten.
|
||||||
if (x[i] !== y[i]) return x[i] - y[i];
|
*/
|
||||||
}
|
let cachedKind = null;
|
||||||
return comparePreRelease(parsePreRelease(a), parsePreRelease(b));
|
function detectInstallKind() {
|
||||||
}
|
if (cachedKind) return cachedKind;
|
||||||
|
cachedKind = computeInstallKind();
|
||||||
/*
|
return cachedKind;
|
||||||
* Paketart einer Release-Datei am Namen erkennen: 'portable', 'setup' oder null.
|
}
|
||||||
* Namen, die auf beides passen (z. B. "AeroMC-Portable-Setup.exe"), gelten als
|
|
||||||
* mehrdeutig und werden bewusst niemandem angeboten – lieber kein Update als
|
function computeInstallKind() {
|
||||||
* das falsche.
|
// 1. Portable: diese Variablen setzt ausschließlich der Portable-Build von
|
||||||
*/
|
// electron-builder – das ist das verlässlichste Signal überhaupt.
|
||||||
function assetKind(name) {
|
if (String(process.env.PORTABLE_EXECUTABLE_FILE || '').trim()) return 'portable';
|
||||||
const n = String(name || '');
|
if (String(process.env.PORTABLE_EXECUTABLE_DIR || '').trim()) return 'portable';
|
||||||
if (!/\.exe$/i.test(n)) return null;
|
if (String(process.env.PORTABLE_EXECUTABLE_APP_FILENAME || '').trim()) return 'portable';
|
||||||
const portable = /portable/i.test(n);
|
|
||||||
const setup = /setup|install(er)?/i.test(n);
|
const exe = process.execPath || '';
|
||||||
if (portable && setup) return null;
|
// 2. Notnagel über den Dateinamen, falls die Variablen einmal fehlen
|
||||||
if (portable) return 'portable';
|
if (/portable/i.test(path.basename(exe))) return 'portable';
|
||||||
if (setup) return 'setup';
|
|
||||||
return null; // ohne Kennzeichnung im Namen nicht zuzuordnen
|
// 3. Setup: das Setup legt neben der EXE immer "Uninstall AeroMC.exe" ab. Nur wenn der
|
||||||
}
|
// da ist, ist es wirklich eine Installation.
|
||||||
|
if (hasUninstaller(path.dirname(exe))) return 'setup';
|
||||||
// passendes Windows-Artefakt im Release finden
|
|
||||||
function pickAsset(release, installKind) {
|
// 4. Alles andere (Entwicklungsmodus, entpackter Ordner, unbekannte Kopie)
|
||||||
if (installKind !== 'portable' && installKind !== 'setup') return null;
|
return 'unknown';
|
||||||
const assets = release.assets || [];
|
}
|
||||||
return assets.find((a) => assetKind(a.name) === installKind) || null;
|
|
||||||
}
|
function hasUninstaller(dir) {
|
||||||
|
try {
|
||||||
/*
|
return fs.readdirSync(dir).some((n) => /^unins(tall)?.*\.exe$/i.test(n));
|
||||||
* Prüft auf Updates.
|
} catch {
|
||||||
* -> { available, currentVersion, latestVersion, notes, asset, url, noReleases }
|
return false;
|
||||||
*/
|
}
|
||||||
async function checkForUpdate(currentVersion) {
|
}
|
||||||
const installKind = detectInstallKind();
|
|
||||||
const res = await fetch(API + '?limit=10', { headers: { 'User-Agent': UA, Accept: 'application/json' } });
|
// Pfad der laufenden Portable-EXE (die Datei, die der Nutzer selbst gestartet hat)
|
||||||
if (!res.ok) throw new Error('Update-Server antwortet mit HTTP ' + res.status);
|
function portableTarget() {
|
||||||
const list = await res.json();
|
const p = String(process.env.PORTABLE_EXECUTABLE_FILE || '').trim();
|
||||||
const releases = (Array.isArray(list) ? list : []).filter((r) => !r.draft);
|
if (p) return p;
|
||||||
if (!releases.length) {
|
// Fallback: nur brauchbar, wenn die EXE nicht entpackt ausgeführt wird
|
||||||
return { available: false, noReleases: true, currentVersion };
|
const exe = process.execPath || '';
|
||||||
}
|
return path.basename(exe).toLowerCase().includes('portable') ? exe : '';
|
||||||
// neueste anhand der Versionsnummer (nicht nach Datum, damit Nachträge nicht stören)
|
}
|
||||||
releases.sort((a, b) => compareVersions(b.tag_name, a.tag_name));
|
|
||||||
const latest = releases[0];
|
function updateDir() {
|
||||||
const available = compareVersions(latest.tag_name, currentVersion) > 0;
|
return path.join(os.tmpdir(), 'aeromc-update');
|
||||||
const asset = pickAsset(latest, installKind);
|
}
|
||||||
return {
|
|
||||||
available,
|
/*
|
||||||
currentVersion,
|
* Räumt Reste alter Update-Vorgänge weg (Installer sind mehrere hundert MB groß).
|
||||||
latestVersion: String(latest.tag_name || '').replace(/^v/i, ''),
|
* Dateien, die jünger als 10 Minuten sind, bleiben liegen – sie könnten gerade
|
||||||
installKind,
|
* von einem laufenden Update benutzt werden.
|
||||||
notes: latest.body || '',
|
*/
|
||||||
published: latest.published_at || latest.created_at || null,
|
function cleanupUpdateCache() {
|
||||||
asset: asset ? { name: asset.name, url: asset.browser_download_url, size: asset.size, kind: installKind } : null,
|
try {
|
||||||
url: latest.html_url,
|
const dir = updateDir();
|
||||||
};
|
if (!fs.existsSync(dir)) return;
|
||||||
}
|
for (const name of fs.readdirSync(dir)) {
|
||||||
|
const p = path.join(dir, name);
|
||||||
/*
|
try {
|
||||||
* Wirft, sobald eine Datei nicht zweifelsfrei zur laufenden Paketart gehört.
|
if (Date.now() - fs.statSync(p).mtimeMs < 600000) continue;
|
||||||
* Wird vor dem Download und noch einmal direkt vor dem Ausführen aufgerufen.
|
fs.rmSync(p, { force: true, recursive: true });
|
||||||
*/
|
} catch { /* gesperrt – beim nächsten Start erneut versuchen */ }
|
||||||
function assertMatchingKind(fileName, installKind) {
|
}
|
||||||
if (installKind !== 'portable' && installKind !== 'setup') {
|
} catch { /* egal */ }
|
||||||
throw new Error('Die Paketart des Launchers lässt sich nicht sicher bestimmen – bitte manuell aktualisieren.');
|
}
|
||||||
}
|
|
||||||
const kind = assetKind(fileName);
|
// "v1.2.3-beta.2" -> [1,2,3]; führendes "v" wird ignoriert
|
||||||
if (!kind) {
|
function parseVersion(v) {
|
||||||
throw new Error(`"${fileName}" lässt sich keiner Paketart zuordnen – bitte manuell aktualisieren.`);
|
const m = String(v || '').trim().replace(/^v/i, '').match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
|
||||||
}
|
if (!m) return null;
|
||||||
if (kind !== installKind) {
|
return [Number(m[1] || 0), Number(m[2] || 0), Number(m[3] || 0)];
|
||||||
const soll = installKind === 'portable' ? 'eine Portable-Datei' : 'einen Installer';
|
}
|
||||||
const ist = kind === 'portable' ? 'eine Portable-Datei' : 'ein Installer';
|
|
||||||
throw new Error(`Falsches Paket: Dieser Launcher braucht ${soll}, angeboten wurde ${ist}.`);
|
// Vorabkennung hinter dem ersten "-", z. B. "beta" oder "beta.2"
|
||||||
}
|
function parsePreRelease(v) {
|
||||||
}
|
const m = String(v || '').trim().replace(/^v/i, '').match(/^\d+(?:\.\d+){0,2}-([0-9A-Za-z.\-]+)/);
|
||||||
|
return m ? m[1].split('.') : null;
|
||||||
/*
|
}
|
||||||
* Lädt den Installer herunter (mit Fortschritt) und startet ihn.
|
|
||||||
* onProgress: ({ phase, loaded, total, percent })
|
/*
|
||||||
*/
|
* Vergleicht die Vorabkennungen zweier gleicher Versionsnummern – wie SemVer:
|
||||||
async function downloadAndInstall(asset, onProgress) {
|
* Die fertige Fassung ist neuer als jede Vorabfassung derselben Nummer
|
||||||
if (!asset || !asset.url) throw new Error('Kein Installer im Release gefunden.');
|
* ("1.3.0" > "1.3.0-beta.2"), sonst könnten Beta-Nutzer nie auf die fertige
|
||||||
const installKind = detectInstallKind();
|
* Version wechseln.
|
||||||
// Paketart am echten Dateinamen prüfen, nicht am mitgelieferten Feld –
|
*
|
||||||
// sonst kontrolliert sich die Angabe nur selbst.
|
* Früher zählte eine Vorabkennung nur, wenn beide Seiten eine hatten – als
|
||||||
assertMatchingKind(asset.name, installKind);
|
* Schutz vor einer Endlosschleife, falls ein Release unter dem Tag "1.0.1"
|
||||||
|
* steht, die App darin sich aber weiter als "1.0.1-beta" meldet. Das kann nicht
|
||||||
// Portable-Updates ersetzen später die laufende EXE – das geht nur, wenn wir sie kennen
|
* mehr passieren: Die Signatur bindet die Version (siehe pruefeSignatur), und
|
||||||
const target = installKind === 'portable' ? portableTarget() : '';
|
* sign-release.js nimmt sie aus derselben package.json wie die App. Ein Release,
|
||||||
if (installKind === 'portable' && !target) {
|
* bei dem Tag und App-Version nicht übereinstimmen, lässt sich gar nicht
|
||||||
throw new Error('Der Pfad der laufenden Portable-Datei ist unbekannt – bitte manuell aktualisieren.');
|
* installieren.
|
||||||
}
|
*/
|
||||||
|
function comparePreRelease(a, b) {
|
||||||
const dir = updateDir();
|
if (!a && !b) return 0;
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
if (!a) return 1; // a ist die fertige Fassung, b eine Vorabfassung
|
||||||
let dest = path.join(dir, asset.name.replace(/[^\w.\-]/g, '_'));
|
if (!b) return -1;
|
||||||
try {
|
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
||||||
fs.rmSync(dest, { force: true });
|
const x = a[i]; const y = b[i];
|
||||||
} catch {
|
if (x === undefined) return -1;
|
||||||
// Alte Datei ist gesperrt (Virenscanner prüft sie gerade, oder ein vorheriger
|
if (y === undefined) return 1;
|
||||||
// Installer-Versuch läuft noch) - dann eben unter neuem Namen laden, statt mit
|
const nx = /^\d+$/.test(x) ? Number(x) : null;
|
||||||
// EPERM abzubrechen. Der Rest wird bei nächster Gelegenheit von
|
const ny = /^\d+$/.test(y) ? Number(y) : null;
|
||||||
// cleanupUpdateCache() aufgeräumt (ab 10 Minuten Alter).
|
if (nx !== null && ny !== null) { if (nx !== ny) return nx - ny; continue; }
|
||||||
const parsed = path.parse(dest);
|
if (nx !== null) return -1; // Zahlen zählen weniger als Text
|
||||||
dest = path.join(parsed.dir, `${parsed.name}-${Date.now()}${parsed.ext}`);
|
if (ny !== null) return 1;
|
||||||
}
|
if (x !== y) return x < y ? -1 : 1;
|
||||||
|
}
|
||||||
const res = await fetch(asset.url, { headers: { 'User-Agent': UA } });
|
return 0;
|
||||||
if (!res.ok) throw new Error('Download fehlgeschlagen: HTTP ' + res.status);
|
}
|
||||||
|
|
||||||
const total = Number(res.headers.get('content-length')) || asset.size || 0;
|
// >0 wenn a neuer als b
|
||||||
|
function compareVersions(a, b) {
|
||||||
// direkt auf die Platte schreiben statt alles im Speicher zu sammeln
|
const x = parseVersion(a); const y = parseVersion(b);
|
||||||
const out = fs.createWriteStream(dest);
|
if (!x || !y) return 0;
|
||||||
const hash = crypto.createHash('sha256');
|
for (let i = 0; i < 3; i++) {
|
||||||
let loaded = 0;
|
if (x[i] !== y[i]) return x[i] - y[i];
|
||||||
let lastReport = 0;
|
}
|
||||||
try {
|
return comparePreRelease(parsePreRelease(a), parsePreRelease(b));
|
||||||
for await (const chunk of res.body) {
|
}
|
||||||
const buf = Buffer.from(chunk);
|
|
||||||
hash.update(buf);
|
/*
|
||||||
loaded += buf.length;
|
* Paketart einer Release-Datei am Namen erkennen: 'portable', 'setup' oder null.
|
||||||
// auf den Puffer warten; der jeweils andere Listener wird mit abgeräumt,
|
* Namen, die auf beides passen (z. B. "AeroMC-Portable-Setup.exe"), gelten als
|
||||||
// sonst sammeln sich über den ganzen Download hunderte davon an
|
* mehrdeutig und werden bewusst niemandem angeboten – lieber kein Update als
|
||||||
if (!out.write(buf)) await new Promise((resolve, reject) => {
|
* das falsche.
|
||||||
const onDrain = () => { out.off('error', onError); resolve(); };
|
*/
|
||||||
const onError = (e) => { out.off('drain', onDrain); reject(e); };
|
function assetKind(name) {
|
||||||
out.once('drain', onDrain);
|
const n = String(name || '');
|
||||||
out.once('error', onError);
|
if (!/\.exe$/i.test(n)) return null;
|
||||||
});
|
const portable = /portable/i.test(n);
|
||||||
// höchstens viermal pro Sekunde melden, sonst flutet es die Oberfläche
|
const setup = /setup|install(er)?/i.test(n);
|
||||||
if (onProgress && Date.now() - lastReport >= 250) {
|
if (portable && setup) return null;
|
||||||
lastReport = Date.now();
|
if (portable) return 'portable';
|
||||||
onProgress({ phase: 'download', loaded, total, percent: total ? Math.round((loaded / total) * 100) : 0 });
|
if (setup) return 'setup';
|
||||||
}
|
return null; // ohne Kennzeichnung im Namen nicht zuzuordnen
|
||||||
}
|
}
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
out.once('error', reject);
|
// passendes Windows-Artefakt im Release finden
|
||||||
out.end((err) => (err ? reject(err) : resolve()));
|
function pickAsset(release, installKind) {
|
||||||
});
|
if (installKind !== 'portable' && installKind !== 'setup') return null;
|
||||||
} catch (err) {
|
const assets = release.assets || [];
|
||||||
out.destroy();
|
return assets.find((a) => assetKind(a.name) === installKind) || null;
|
||||||
fs.rmSync(dest, { force: true });
|
}
|
||||||
throw err;
|
|
||||||
}
|
/*
|
||||||
|
* Prüft auf Updates.
|
||||||
if (total && loaded !== total) {
|
* -> { available, currentVersion, latestVersion, notes, asset, url, noReleases }
|
||||||
fs.rmSync(dest, { force: true });
|
*/
|
||||||
throw new Error('Download unvollständig.');
|
async function checkForUpdate(currentVersion, opts) {
|
||||||
}
|
const installKind = detectInstallKind();
|
||||||
|
const kopf = { headers: { 'User-Agent': UA, Accept: 'application/json' } };
|
||||||
if (onProgress) onProgress({ phase: 'ready', loaded, total: loaded, percent: 100 });
|
const res = await fetch(API + '?limit=30', kopf);
|
||||||
return { path: dest, bytes: loaded, sha256: hash.digest('hex'), kind: installKind, target };
|
if (!res.ok) throw new Error('Update-Server antwortet mit HTTP ' + res.status);
|
||||||
}
|
const list = await res.json();
|
||||||
|
const alle = (Array.isArray(list) ? list : []).filter((r) => !r.draft);
|
||||||
/*
|
|
||||||
* Hilfsskript für Portable-Updates: wartet, bis der Launcher die eigene EXE
|
if (!alle.length) {
|
||||||
* freigibt, tauscht sie gegen die neue aus und startet sie wieder.
|
return { available: false, noReleases: true, currentVersion };
|
||||||
* "ping" statt "timeout", weil timeout ohne Konsole abbricht.
|
}
|
||||||
*/
|
// Vorabfassungen (Beta, RC …) bekommt nur, wer selbst eine Vorabfassung nutzt.
|
||||||
const SWAP_SCRIPT = `@echo off
|
// Wer die fertige Fassung installiert hat, bleibt auf fertigen Versionen.
|
||||||
setlocal
|
// Ob jemand teilnimmt, entscheidet der Schalter "An der Beta teilnehmen"
|
||||||
set "SRC=%~1"
|
// (opts.beta). Ohne Angabe zählt die installierte Version.
|
||||||
set "DST=%~2"
|
const nutztVorab = opts && typeof opts.beta === 'boolean' ? opts.beta : !!parsePreRelease(currentVersion);
|
||||||
set "LOG=%~dp0update.log"
|
const releases = nutztVorab ? alle : alle.filter((r) => !istVorab(r));
|
||||||
set TRIES=0
|
if (!releases.length) {
|
||||||
:loop
|
return { available: false, currentVersion, installKind };
|
||||||
ping -n 2 127.0.0.1 >nul
|
}
|
||||||
copy /y "%SRC%" "%DST%" >nul 2>&1
|
// neueste anhand der Versionsnummer (nicht nach Datum, damit Nachträge nicht stören)
|
||||||
if not errorlevel 1 goto done
|
releases.sort((a, b) => compareVersions(tagVersion(b.tag_name), tagVersion(a.tag_name)));
|
||||||
set /a TRIES+=1
|
const latest = releases[0];
|
||||||
if %TRIES% GEQ 60 goto fail
|
const latestVersion = tagVersion(latest.tag_name);
|
||||||
goto loop
|
const available = compareVersions(latestVersion, currentVersion) > 0;
|
||||||
:done
|
let asset = pickAsset(latest, installKind);
|
||||||
del /f /q "%SRC%" >nul 2>&1
|
// ohne Signatur oder von fremder Adresse wird nichts automatisch installiert
|
||||||
start "" "%DST%"
|
let unsigniert = false;
|
||||||
exit /b 0
|
let signaturHinweis = '';
|
||||||
:fail
|
let sig = null;
|
||||||
echo [%DATE% %TIME%] Konnte "%DST%" nicht ersetzen - Datei gesperrt oder schreibgeschuetzt.>>"%LOG%"
|
if (asset && !erlaubteAdresse(asset.browser_download_url)) {
|
||||||
exit /b 1
|
asset = null;
|
||||||
`;
|
unsigniert = true;
|
||||||
|
} else if (asset && available) {
|
||||||
/*
|
try {
|
||||||
* Wendet das geladene Update an.
|
sig = await holeSignatur(asset.name, latestVersion);
|
||||||
* setup -> AeroMC-Setup mit --update starten, es überschreibt die Installation selbst
|
} catch (err) {
|
||||||
* portable -> Skript starten, das die laufende EXE nach dem Beenden ersetzt
|
signaturHinweis = 'Die Signatur des Updates ließ sich gerade nicht abrufen (' + (err.message || err) + '). Bitte später erneut versuchen.';
|
||||||
* Der Launcher muss sich danach beenden.
|
}
|
||||||
*/
|
if (!sig) {
|
||||||
function applyUpdate(file) {
|
asset = null;
|
||||||
const installKind = detectInstallKind();
|
unsigniert = !signaturHinweis;
|
||||||
// letzte Kontrolle unmittelbar vor dem Start: passt die Datei auf der Platte
|
}
|
||||||
// wirklich zu dieser Paketart?
|
}
|
||||||
assertMatchingKind(path.basename(file.path || ''), installKind);
|
return {
|
||||||
const kind = installKind;
|
available,
|
||||||
|
currentVersion,
|
||||||
if (kind !== 'portable') {
|
latestVersion,
|
||||||
// --update: das AeroMC-Setup aktualisiert ohne Rückfragen und startet AeroMC danach neu
|
installKind,
|
||||||
const child = spawn(file.path, ['--update'], { detached: true, stdio: 'ignore' });
|
unsigniert,
|
||||||
child.unref();
|
signaturHinweis,
|
||||||
return { started: 'installer' };
|
notes: latest.body || '',
|
||||||
}
|
published: latest.published_at || latest.created_at || null,
|
||||||
|
asset: asset ? {
|
||||||
const target = file.target || portableTarget();
|
name: asset.name, url: asset.browser_download_url, size: asset.size, kind: installKind,
|
||||||
if (!target) throw new Error('Der Pfad der laufenden Portable-Datei ist unbekannt.');
|
version: latestVersion, signatur: sig,
|
||||||
|
} : null,
|
||||||
const script = path.join(updateDir(), 'aeromc-swap.cmd');
|
url: latest.html_url,
|
||||||
fs.writeFileSync(script, SWAP_SCRIPT, 'ascii');
|
};
|
||||||
|
}
|
||||||
// über "start /min", damit kein Konsolenfenster im Vordergrund stehen bleibt
|
|
||||||
const child = spawn(process.env.COMSPEC || 'cmd.exe',
|
/*
|
||||||
['/c', 'start', '', '/min', script, file.path, target],
|
* Wirft, sobald eine Datei nicht zweifelsfrei zur laufenden Paketart gehört.
|
||||||
{ detached: true, stdio: 'ignore', windowsHide: true });
|
* Wird vor dem Download und noch einmal direkt vor dem Ausführen aufgerufen.
|
||||||
child.unref();
|
*/
|
||||||
return { started: 'swap', target };
|
function assertMatchingKind(fileName, installKind) {
|
||||||
}
|
if (installKind !== 'portable' && installKind !== 'setup') {
|
||||||
|
throw new Error('Die Paketart des Launchers lässt sich nicht sicher bestimmen – bitte manuell aktualisieren.');
|
||||||
// Rückwärtskompatibel: nur für die Setup-Variante
|
}
|
||||||
function runInstaller(installerPath) {
|
const kind = assetKind(fileName);
|
||||||
return applyUpdate({ path: installerPath, kind: 'setup' });
|
if (!kind) {
|
||||||
}
|
throw new Error(`"${fileName}" lässt sich keiner Paketart zuordnen – bitte manuell aktualisieren.`);
|
||||||
|
}
|
||||||
module.exports = {
|
if (kind !== installKind) {
|
||||||
checkForUpdate,
|
const soll = installKind === 'portable' ? 'eine Portable-Datei' : 'einen Installer';
|
||||||
downloadAndInstall,
|
const ist = kind === 'portable' ? 'eine Portable-Datei' : 'ein Installer';
|
||||||
applyUpdate,
|
throw new Error(`Falsches Paket: Dieser Launcher braucht ${soll}, angeboten wurde ${ist}.`);
|
||||||
runInstaller,
|
}
|
||||||
cleanupUpdateCache,
|
}
|
||||||
compareVersions,
|
|
||||||
parseVersion,
|
/*
|
||||||
detectInstallKind,
|
* Lädt den Installer herunter (mit Fortschritt) und startet ihn.
|
||||||
assetKind,
|
* onProgress: ({ phase, loaded, total, percent })
|
||||||
portableTarget,
|
*/
|
||||||
};
|
async function downloadAndInstall(asset, onProgress) {
|
||||||
|
if (!asset || !asset.url) throw new Error('Kein Installer im Release gefunden.');
|
||||||
|
const installKind = detectInstallKind();
|
||||||
|
// Paketart am echten Dateinamen prüfen, nicht am mitgelieferten Feld –
|
||||||
|
// sonst kontrolliert sich die Angabe nur selbst.
|
||||||
|
assertMatchingKind(asset.name, installKind);
|
||||||
|
|
||||||
|
if (!erlaubteAdresse(asset.url)) {
|
||||||
|
throw new Error('Das Update kommt nicht vom AeroMC-Server – abgebrochen.');
|
||||||
|
}
|
||||||
|
// ohne Signatur wird gar nicht erst heruntergeladen
|
||||||
|
const signatur = asset.signatur;
|
||||||
|
if (!signatur || signatur.datei !== asset.name || normVersion(signatur.version) !== normVersion(asset.version)) {
|
||||||
|
throw new Error('Signatur passt nicht zu diesem Update – abgebrochen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Portable-Updates ersetzen später die laufende EXE – das geht nur, wenn wir sie kennen
|
||||||
|
const target = installKind === 'portable' ? portableTarget() : '';
|
||||||
|
if (installKind === 'portable' && !target) {
|
||||||
|
throw new Error('Der Pfad der laufenden Portable-Datei ist unbekannt – bitte manuell aktualisieren.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const dir = updateDir();
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
let dest = path.join(dir, asset.name.replace(/[^\w.\-]/g, '_'));
|
||||||
|
try {
|
||||||
|
fs.rmSync(dest, { force: true });
|
||||||
|
} catch {
|
||||||
|
// Alte Datei ist gesperrt (Virenscanner prüft sie gerade, oder ein vorheriger
|
||||||
|
// Installer-Versuch läuft noch) - dann eben unter neuem Namen laden, statt mit
|
||||||
|
// EPERM abzubrechen. Der Rest wird bei nächster Gelegenheit von
|
||||||
|
// cleanupUpdateCache() aufgeräumt (ab 10 Minuten Alter).
|
||||||
|
const parsed = path.parse(dest);
|
||||||
|
dest = path.join(parsed.dir, `${parsed.name}-${Date.now()}${parsed.ext}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(asset.url, { headers: { 'User-Agent': UA } });
|
||||||
|
if (!res.ok) throw new Error('Download fehlgeschlagen: HTTP ' + res.status);
|
||||||
|
|
||||||
|
const total = Number(res.headers.get('content-length')) || asset.size || 0;
|
||||||
|
|
||||||
|
// direkt auf die Platte schreiben statt alles im Speicher zu sammeln
|
||||||
|
const out = fs.createWriteStream(dest);
|
||||||
|
const hash = crypto.createHash('sha256');
|
||||||
|
let loaded = 0;
|
||||||
|
let lastReport = 0;
|
||||||
|
try {
|
||||||
|
for await (const chunk of res.body) {
|
||||||
|
const buf = Buffer.from(chunk);
|
||||||
|
hash.update(buf);
|
||||||
|
loaded += buf.length;
|
||||||
|
// auf den Puffer warten; der jeweils andere Listener wird mit abgeräumt,
|
||||||
|
// sonst sammeln sich über den ganzen Download hunderte davon an
|
||||||
|
if (!out.write(buf)) await new Promise((resolve, reject) => {
|
||||||
|
const onDrain = () => { out.off('error', onError); resolve(); };
|
||||||
|
const onError = (e) => { out.off('drain', onDrain); reject(e); };
|
||||||
|
out.once('drain', onDrain);
|
||||||
|
out.once('error', onError);
|
||||||
|
});
|
||||||
|
// höchstens viermal pro Sekunde melden, sonst flutet es die Oberfläche
|
||||||
|
if (onProgress && Date.now() - lastReport >= 250) {
|
||||||
|
lastReport = Date.now();
|
||||||
|
onProgress({ phase: 'download', loaded, total, percent: total ? Math.round((loaded / total) * 100) : 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
out.once('error', reject);
|
||||||
|
out.end((err) => (err ? reject(err) : resolve()));
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
out.destroy();
|
||||||
|
fs.rmSync(dest, { force: true });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (total && loaded !== total) {
|
||||||
|
fs.rmSync(dest, { force: true });
|
||||||
|
throw new Error('Download unvollständig.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sha256 = hash.digest('hex');
|
||||||
|
try {
|
||||||
|
pruefeSignatur(signatur, asset.name, asset.version, sha256);
|
||||||
|
} catch (err) {
|
||||||
|
fs.rmSync(dest, { force: true });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onProgress) onProgress({ phase: 'ready', loaded, total: loaded, percent: 100 });
|
||||||
|
return { path: dest, bytes: loaded, sha256, signatur, name: asset.name, version: asset.version, kind: installKind, target };
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Hilfsskript für Portable-Updates: wartet, bis der Launcher die eigene EXE
|
||||||
|
* freigibt, tauscht sie gegen die neue aus und startet sie wieder.
|
||||||
|
* "ping" statt "timeout", weil timeout ohne Konsole abbricht.
|
||||||
|
*/
|
||||||
|
const SWAP_SCRIPT = `@echo off
|
||||||
|
setlocal
|
||||||
|
set "SRC=%~1"
|
||||||
|
set "DST=%~2"
|
||||||
|
set "LOG=%~dp0update.log"
|
||||||
|
set TRIES=0
|
||||||
|
:loop
|
||||||
|
ping -n 2 127.0.0.1 >nul
|
||||||
|
copy /y "%SRC%" "%DST%" >nul 2>&1
|
||||||
|
if not errorlevel 1 goto done
|
||||||
|
set /a TRIES+=1
|
||||||
|
if %TRIES% GEQ 60 goto fail
|
||||||
|
goto loop
|
||||||
|
:done
|
||||||
|
del /f /q "%SRC%" >nul 2>&1
|
||||||
|
start "" "%DST%"
|
||||||
|
exit /b 0
|
||||||
|
:fail
|
||||||
|
echo [%DATE% %TIME%] Konnte "%DST%" nicht ersetzen - Datei gesperrt oder schreibgeschuetzt.>>"%LOG%"
|
||||||
|
exit /b 1
|
||||||
|
`;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Wendet das geladene Update an.
|
||||||
|
* setup -> AeroMC-Setup mit --update starten, es überschreibt die Installation selbst
|
||||||
|
* portable -> Skript starten, das die laufende EXE nach dem Beenden ersetzt
|
||||||
|
* Der Launcher muss sich danach beenden.
|
||||||
|
*/
|
||||||
|
async function applyUpdate(file) {
|
||||||
|
const installKind = detectInstallKind();
|
||||||
|
// letzte Kontrolle unmittelbar vor dem Start: passt die Datei auf der Platte
|
||||||
|
// wirklich zu dieser Paketart – und ist sie noch genau die signierte? (Zwischen
|
||||||
|
// Download und Start könnte sie im Temp-Ordner ausgetauscht worden sein.)
|
||||||
|
assertMatchingKind(path.basename(file.path || ''), installKind);
|
||||||
|
pruefeSignatur(file.signatur, file.name, file.version, await sha256Datei(file.path));
|
||||||
|
const kind = installKind;
|
||||||
|
|
||||||
|
if (kind !== 'portable') {
|
||||||
|
// --update: das AeroMC-Setup aktualisiert ohne Rückfragen und startet AeroMC danach neu
|
||||||
|
const child = spawn(file.path, ['--update'], { detached: true, stdio: 'ignore' });
|
||||||
|
child.unref();
|
||||||
|
return { started: 'installer' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = file.target || portableTarget();
|
||||||
|
if (!target) throw new Error('Der Pfad der laufenden Portable-Datei ist unbekannt.');
|
||||||
|
|
||||||
|
const script = path.join(updateDir(), 'aeromc-swap.cmd');
|
||||||
|
fs.writeFileSync(script, SWAP_SCRIPT, 'ascii');
|
||||||
|
|
||||||
|
// über "start /min", damit kein Konsolenfenster im Vordergrund stehen bleibt
|
||||||
|
const child = spawn(process.env.COMSPEC || 'cmd.exe',
|
||||||
|
['/c', 'start', '', '/min', script, file.path, target],
|
||||||
|
{ detached: true, stdio: 'ignore', windowsHide: true });
|
||||||
|
child.unref();
|
||||||
|
return { started: 'swap', target };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
checkForUpdate,
|
||||||
|
downloadAndInstall,
|
||||||
|
applyUpdate,
|
||||||
|
pruefeSignatur,
|
||||||
|
signaturText,
|
||||||
|
holeSignatur,
|
||||||
|
tagVersion,
|
||||||
|
istVorabVersion: (v) => !!parsePreRelease(v),
|
||||||
|
SIGNATUR_API,
|
||||||
|
cleanupUpdateCache,
|
||||||
|
compareVersions,
|
||||||
|
parseVersion,
|
||||||
|
detectInstallKind,
|
||||||
|
assetKind,
|
||||||
|
portableTarget,
|
||||||
|
};
|
||||||
|
|||||||