Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28d234eaf8 | ||
|
|
b711a3a7a1 | ||
|
|
b134e19bd0 | ||
|
|
d3838f9d30 | ||
|
|
1a52097201 | ||
|
|
c249c1d7f5 | ||
|
|
df3eae4ee8 | ||
|
|
cf5fc48fba | ||
|
|
8080fc1f62 | ||
|
|
30459acb6f | ||
|
|
1c8dc48490 | ||
|
|
8c67204de2 | ||
|
|
c90ac36c74 | ||
|
|
b1bc1f2ba5 | ||
|
|
e5a486fe62 | ||
|
|
a563ba6837 | ||
|
|
4210d5780b | ||
|
|
8338d8f0e8 | ||
|
|
e41f8faa18 | ||
|
|
988292ae24 | ||
|
|
2a9bf9b610 | ||
|
|
3b366d83ec | ||
|
|
c4ea9bae27 | ||
|
|
7681e17bb6 | ||
|
|
d3c5f50710 | ||
|
|
3d656f49c6 | ||
|
|
9d55faa3eb | ||
|
|
e8b5d6b6e0 | ||
|
|
f97ad783fb | ||
|
|
50345fb8e3 | ||
|
|
103771e9c2 | ||
|
|
9cebeeeaa4 | ||
|
|
3b7408a2fc | ||
|
|
a70916fc7d | ||
|
|
4ef589ebbc | ||
|
|
6f53307925 | ||
|
|
2b68dc8768 | ||
|
|
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 |
@@ -2,6 +2,63 @@
|
||||
|
||||
Alle nennenswerten Änderungen an AeroMC werden hier festgehalten.
|
||||
|
||||
## [1.3.1] - 2026-11-02
|
||||
|
||||
### Geändert
|
||||
- CurseForge: Mods, die ihre Autoren nicht über Launcher freigeben, werden nicht mehr geladen. Beim Modpack-Import legt AeroMC stattdessen die Liste „GESPERRTE-MODS.txt“ mit Links zum Selbstladen in den Spielordner, bei einzelnen Mods erscheint eine Meldung mit dem Link
|
||||
- Stream Deck: Das Plugin ist jetzt in Fassung 1.1.1.0 dabei. Bei eingeschaltetem „Stream Deck erlauben“ aktualisiert AeroMC es von selbst, danach die Stream-Deck-Software einmal neu starten
|
||||
|
||||
### Verbessert
|
||||
- Kleinere Verbesserungen und Aktualisierungen im Hintergrund
|
||||
|
||||
## [1.3.0] - 2026-10-30
|
||||
|
||||
### Neu
|
||||
- Stream Deck: AeroMC lässt sich jetzt per Taste steuern. Die Tasten zeigen den Zustand live an und tragen das Bild der jeweiligen Instanz.
|
||||
- Spielen: Instanz starten und beenden, zuletzt gespielte Instanz, Schnellstart 1–5, Instanz neu starten, Server beitreten
|
||||
- Aufnehmen: Aufnahme starten und stoppen, Clip-Modus ein/aus, Clip speichern, Screenshot, Aufnahmen-Ordner
|
||||
- Anzeigen: Spielzeit, Server-Status (online, Spieler, Ping), Freunde online
|
||||
- Verwalten: Konto wechseln, Backup jetzt, Logdateien, Konsole, letzter Absturz, Launcher-Fenster
|
||||
- Für das Stream Deck+ gibt es einen Drehregler zum Wählen und Starten von Instanzen.
|
||||
- Einschalten unter Optionen → Allgemein → „Stream Deck erlauben“. Das Plugin liegt AeroMC bei und wird dabei selbst in die Stream-Deck-Software kopiert; danach die Stream-Deck-Software einmal neu starten. Die Tasten stehen dann unter „AeroMC“.
|
||||
- Zusätzlich gibt es das Plugin als Datei „AeroMC.streamDeckPlugin“, die sich per Doppelklick installieren lässt.
|
||||
- Während einer Aufnahme zeigt AeroMC oben rechts im Spiel „● REC“ mit der Laufzeit an. Sie und die übrigen Einblendungen von AeroMC im Spiel (z. B. Freunde-Hinweise) sind nicht im aufgenommenen Video zu sehen (ab Windows 10, Version 2004).
|
||||
|
||||
## [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
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,98 @@
|
||||
# AeroMC – Sicherheit
|
||||
|
||||
Überblick über die Sicherheitsmaßnahmen im AeroMC Launcher (Stand: Version 1.3.1).
|
||||
|
||||
---
|
||||
|
||||
## 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:** Die eingesetzten Programmbibliotheken werden vor jeder
|
||||
Veröffentlichung auf bekannte Sicherheitslücken geprüft und bei Bedarf aktualisiert.
|
||||
|
||||
## 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.
|
||||
|
||||
## 8. Stream Deck
|
||||
|
||||
- **Nur auf Wunsch:** Die Schnittstelle zum Stream Deck ist standardmäßig aus und läuft nur,
|
||||
wenn „Stream Deck erlauben“ eingeschaltet ist.
|
||||
- **Nur der eigene Rechner:** Sie lauscht ausschließlich auf 127.0.0.1, aus dem Netzwerk
|
||||
ist sie nicht erreichbar.
|
||||
- **Zufallsschlüssel:** Jede Anfrage braucht einen Schlüssel, der bei jedem Start neu
|
||||
erzeugt und nur in eine Datei im eigenen Benutzerordner geschrieben wird.
|
||||
- **Kein Zugriff aus Webseiten:** Anfragen aus dem Browser (mit Herkunftskopf) und mit
|
||||
fremdem Host-Namen werden abgewiesen. Damit lässt sich AeroMC nicht über eine Webseite
|
||||
fernsteuern.
|
||||
@@ -3,15 +3,22 @@ package de.aeromc.capes;
|
||||
import java.lang.instrument.Instrumentation;
|
||||
|
||||
/**
|
||||
* AeroMC-Umhang-Agent.
|
||||
* AeroMC-Agent.
|
||||
*
|
||||
* 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
|
||||
* 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.
|
||||
* Loader) und kann zwei Dinge, einzeln zuschaltbar:
|
||||
*
|
||||
* 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 {
|
||||
/** 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) {
|
||||
try {
|
||||
String basis = argument != null && argument.startsWith("http") ? argument : Nachschlagen.STANDARD_BASIS;
|
||||
// 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);
|
||||
String basis = Nachschlagen.STANDARD_BASIS;
|
||||
boolean umhaenge = true;
|
||||
String design = null;
|
||||
String mods = null;
|
||||
if (argument != null) {
|
||||
for (String teil : argument.split("\\|")) {
|
||||
if (teil.startsWith("http")) basis = teil;
|
||||
else if (teil.equals("capes=0")) umhaenge = false;
|
||||
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.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.util.Enumeration;
|
||||
import java.util.jar.Attributes;
|
||||
import java.util.jar.JarEntry;
|
||||
import java.util.jar.JarFile;
|
||||
import java.util.jar.JarOutputStream;
|
||||
import java.util.jar.Manifest;
|
||||
|
||||
import org.objectweb.asm.ClassReader;
|
||||
import org.objectweb.asm.ClassWriter;
|
||||
import org.objectweb.asm.commons.ClassRemapper;
|
||||
import org.objectweb.asm.commons.Remapper;
|
||||
|
||||
/**
|
||||
* 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
|
||||
* demselben Klassenpfad liegt) nie mit unserem verwechselt werden.
|
||||
*
|
||||
* Aufruf: java Shade <ziel.jar> <klassenordner> <asm.jar>
|
||||
*/
|
||||
public class Shade {
|
||||
static final Remapper UMBENENNEN = new Remapper() {
|
||||
@Override
|
||||
public String map(String name) {
|
||||
return name.startsWith("org/objectweb/asm/") ? "de/aeromc/capes/asm/" + name.substring(18) : name;
|
||||
}
|
||||
};
|
||||
|
||||
static byte[] umbenennen(byte[] klasse) {
|
||||
ClassReader r = new ClassReader(klasse);
|
||||
ClassWriter w = new ClassWriter(0);
|
||||
r.accept(new ClassRemapper(w, UMBENENNEN), 0);
|
||||
return w.toByteArray();
|
||||
}
|
||||
|
||||
static byte[] lies(InputStream in) throws Exception {
|
||||
ByteArrayOutputStream b = new ByteArrayOutputStream();
|
||||
byte[] p = new byte[8192];
|
||||
int n;
|
||||
while ((n = in.read(p)) > 0) b.write(p, 0, n);
|
||||
return b.toByteArray();
|
||||
}
|
||||
|
||||
public static void main(String[] a) throws Exception {
|
||||
Manifest mf = new Manifest();
|
||||
Attributes at = mf.getMainAttributes();
|
||||
at.put(Attributes.Name.MANIFEST_VERSION, "1.0");
|
||||
at.putValue("Premain-Class", "de.aeromc.capes.Agent");
|
||||
at.putValue("Agent-Class", "de.aeromc.capes.Agent");
|
||||
at.putValue("Can-Retransform-Classes", "false");
|
||||
at.putValue("Implementation-Title", "AeroMC Umhang-Agent");
|
||||
try (JarOutputStream out = new JarOutputStream(new FileOutputStream(a[0]), mf)) {
|
||||
// eigene Klassen
|
||||
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)) {
|
||||
String name = basis.toPath().relativize(f.toPath()).toString().replace('\\', '/');
|
||||
out.putNextEntry(new JarEntry(name));
|
||||
out.write(umbenennen(Files.readAllBytes(f.toPath())));
|
||||
out.closeEntry();
|
||||
}
|
||||
// ASM (umbenannt), ohne Modul-Infos und Signaturen
|
||||
try (JarFile asm = new JarFile(a[2])) {
|
||||
Enumeration<JarEntry> e = asm.entries();
|
||||
while (e.hasMoreElements()) {
|
||||
JarEntry je = e.nextElement();
|
||||
String n = je.getName();
|
||||
if (!n.startsWith("org/objectweb/asm/") || !n.endsWith(".class")) continue;
|
||||
byte[] neu = umbenennen(lies(asm.getInputStream(je)));
|
||||
out.putNextEntry(new JarEntry("de/aeromc/capes/asm/" + n.substring(18)));
|
||||
out.write(neu);
|
||||
out.closeEntry();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.util.Enumeration;
|
||||
import java.util.jar.Attributes;
|
||||
import java.util.jar.JarEntry;
|
||||
import java.util.jar.JarFile;
|
||||
import java.util.jar.JarOutputStream;
|
||||
import java.util.jar.Manifest;
|
||||
|
||||
import org.objectweb.asm.ClassReader;
|
||||
import org.objectweb.asm.ClassWriter;
|
||||
import org.objectweb.asm.commons.ClassRemapper;
|
||||
import org.objectweb.asm.commons.Remapper;
|
||||
|
||||
/**
|
||||
* 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
|
||||
* demselben Klassenpfad liegt) nie mit unserem verwechselt werden.
|
||||
*
|
||||
* Aufruf: java Shade <ziel.jar> <klassenordner> <asm.jar> [weitere ASM-Jars, z. B. asm-tree]
|
||||
*/
|
||||
public class Shade {
|
||||
static final Remapper UMBENENNEN = new Remapper() {
|
||||
@Override
|
||||
public String map(String name) {
|
||||
return name.startsWith("org/objectweb/asm/") ? "de/aeromc/capes/asm/" + name.substring(18) : name;
|
||||
}
|
||||
};
|
||||
|
||||
static byte[] umbenennen(byte[] klasse) {
|
||||
ClassReader r = new ClassReader(klasse);
|
||||
ClassWriter w = new ClassWriter(0);
|
||||
r.accept(new ClassRemapper(w, UMBENENNEN), 0);
|
||||
return w.toByteArray();
|
||||
}
|
||||
|
||||
static byte[] lies(InputStream in) throws Exception {
|
||||
ByteArrayOutputStream b = new ByteArrayOutputStream();
|
||||
byte[] p = new byte[8192];
|
||||
int n;
|
||||
while ((n = in.read(p)) > 0) b.write(p, 0, n);
|
||||
return b.toByteArray();
|
||||
}
|
||||
|
||||
public static void main(String[] a) throws Exception {
|
||||
Manifest mf = new Manifest();
|
||||
Attributes at = mf.getMainAttributes();
|
||||
at.put(Attributes.Name.MANIFEST_VERSION, "1.0");
|
||||
at.putValue("Premain-Class", "de.aeromc.capes.Agent");
|
||||
at.putValue("Agent-Class", "de.aeromc.capes.Agent");
|
||||
at.putValue("Can-Retransform-Classes", "false");
|
||||
at.putValue("Implementation-Title", "AeroMC Agent (Umhaenge, Spieldesign)");
|
||||
try (JarOutputStream out = new JarOutputStream(new FileOutputStream(a[0]), mf)) {
|
||||
// eigene Klassen
|
||||
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)) {
|
||||
String name = basis.toPath().relativize(f.toPath()).toString().replace('\\', '/');
|
||||
out.putNextEntry(new JarEntry(name));
|
||||
out.write(umbenennen(Files.readAllBytes(f.toPath())));
|
||||
out.closeEntry();
|
||||
}
|
||||
// ASM (umbenannt), ohne Modul-Infos und Signaturen
|
||||
for (int j = 2; j < a.length; j++) try (JarFile asm = new JarFile(a[j])) {
|
||||
Enumeration<JarEntry> e = asm.entries();
|
||||
while (e.hasMoreElements()) {
|
||||
JarEntry je = e.nextElement();
|
||||
String n = je.getName();
|
||||
if (!n.startsWith("org/objectweb/asm/") || !n.endsWith(".class")) continue;
|
||||
byte[] neu = umbenennen(lies(asm.getInputStream(je)));
|
||||
out.putNextEntry(new JarEntry("de/aeromc/capes/asm/" + n.substring(18)));
|
||||
out.write(neu);
|
||||
out.closeEntry();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,9 +181,35 @@ public static class Installer
|
||||
k.SetValue("NoRepair", 1, RegistryValueKind.DWord);
|
||||
}
|
||||
|
||||
progress.Report((0.98, "Installation wird abgeschlossen …"));
|
||||
Vorwaermen(exe);
|
||||
|
||||
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) =>
|
||||
zip.Entries
|
||||
.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);
|
||||
break;
|
||||
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();
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
},
|
||||
"projects": {
|
||||
"C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj": {
|
||||
"version": "1.2.1",
|
||||
"version": "1.3.1",
|
||||
"restore": {
|
||||
"projectUniqueName": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj",
|
||||
"projectName": "AeroMC-Setup",
|
||||
@@ -51,7 +51,7 @@
|
||||
"autoReferenced": true
|
||||
}
|
||||
},
|
||||
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\10.0.112\\RuntimeIdentifierGraph.json"
|
||||
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\10.0.114\\RuntimeIdentifierGraph.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ namespace AeroMCSetup {
|
||||
/// InitializeComponent
|
||||
/// </summary>
|
||||
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.12.0")]
|
||||
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.14.0")]
|
||||
public void InitializeComponent() {
|
||||
if (_contentLoaded) {
|
||||
return;
|
||||
@@ -66,7 +66,7 @@ namespace AeroMCSetup {
|
||||
/// </summary>
|
||||
[System.STAThreadAttribute()]
|
||||
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.12.0")]
|
||||
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.14.0")]
|
||||
public static void Main() {
|
||||
AeroMCSetup.App app = new AeroMCSetup.App();
|
||||
app.InitializeComponent();
|
||||
|
||||
@@ -542,7 +542,7 @@ namespace AeroMCSetup {
|
||||
/// InitializeComponent
|
||||
/// </summary>
|
||||
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.12.0")]
|
||||
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.14.0")]
|
||||
public void InitializeComponent() {
|
||||
if (_contentLoaded) {
|
||||
return;
|
||||
@@ -558,7 +558,7 @@ namespace AeroMCSetup {
|
||||
}
|
||||
|
||||
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.12.0")]
|
||||
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.14.0")]
|
||||
[System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Never)]
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Design", "CA1033:InterfaceMethodsShouldBeCallableByChildTypes")]
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Maintainability", "CA1502:AvoidExcessiveComplexity")]
|
||||
|
||||
@@ -14,11 +14,11 @@ using System.Reflection;
|
||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Release")]
|
||||
[assembly: System.Reflection.AssemblyCopyrightAttribute("Copyright (c) M_Viper. Alle Rechte vorbehalten.")]
|
||||
[assembly: System.Reflection.AssemblyDescriptionAttribute("Installiert AeroMC")]
|
||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.2.1.0")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.2.1")]
|
||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.3.1.0")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.3.1")]
|
||||
[assembly: System.Reflection.AssemblyProductAttribute("AeroMC Setup")]
|
||||
[assembly: System.Reflection.AssemblyTitleAttribute("AeroMC Setup")]
|
||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.2.1.0")]
|
||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.3.1.0")]
|
||||
[assembly: System.Resources.NeutralResourcesLanguageAttribute("de-DE")]
|
||||
|
||||
// Von der MSBuild WriteCodeFragment-Klasse generiert.
|
||||
|
||||
@@ -1 +1 @@
|
||||
a17a2bc3b05635d3c29adc5c6884bde0a77826489d54b55a9b7744bfaa1ef206
|
||||
580c54ad965e1465e6447eef31337065bb9284a8dba224aec55fdd4c0fb93d67
|
||||
|
||||
@@ -46,7 +46,7 @@ namespace AeroMCSetup {
|
||||
/// InitializeComponent
|
||||
/// </summary>
|
||||
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.12.0")]
|
||||
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.14.0")]
|
||||
public void InitializeComponent() {
|
||||
if (_contentLoaded) {
|
||||
return;
|
||||
@@ -66,7 +66,7 @@ namespace AeroMCSetup {
|
||||
/// </summary>
|
||||
[System.STAThreadAttribute()]
|
||||
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.12.0")]
|
||||
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.14.0")]
|
||||
public static void Main() {
|
||||
AeroMCSetup.App app = new AeroMCSetup.App();
|
||||
app.InitializeComponent();
|
||||
|
||||
@@ -542,7 +542,7 @@ namespace AeroMCSetup {
|
||||
/// InitializeComponent
|
||||
/// </summary>
|
||||
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.12.0")]
|
||||
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.14.0")]
|
||||
public void InitializeComponent() {
|
||||
if (_contentLoaded) {
|
||||
return;
|
||||
@@ -558,7 +558,7 @@ namespace AeroMCSetup {
|
||||
}
|
||||
|
||||
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.12.0")]
|
||||
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.14.0")]
|
||||
[System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Never)]
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Design", "CA1033:InterfaceMethodsShouldBeCallableByChildTypes")]
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Maintainability", "CA1502:AvoidExcessiveComplexity")]
|
||||
|
||||
@@ -418,7 +418,7 @@
|
||||
"C:\\Users\\Viper\\.nuget\\packages\\": {}
|
||||
},
|
||||
"project": {
|
||||
"version": "1.2.1",
|
||||
"version": "1.3.1",
|
||||
"restore": {
|
||||
"projectUniqueName": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj",
|
||||
"projectName": "AeroMC-Setup",
|
||||
@@ -464,7 +464,7 @@
|
||||
"autoReferenced": true
|
||||
}
|
||||
},
|
||||
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\10.0.112\\RuntimeIdentifierGraph.json"
|
||||
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\10.0.114\\RuntimeIdentifierGraph.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": 2,
|
||||
"dgSpecHash": "zBMXx+S6DFU=",
|
||||
"dgSpecHash": "SW1JzBEcPG8=",
|
||||
"success": true,
|
||||
"projectFilePath": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj",
|
||||
"expectedPackageFiles": [
|
||||
|
||||
@@ -1,54 +1,75 @@
|
||||
{
|
||||
"name": "aeromc",
|
||||
"productName": "AeroMC",
|
||||
"version": "1.2.1",
|
||||
"description": "AeroMC – Instanz-basierter Minecraft-Launcher (à la MultiMC/Prism)",
|
||||
"homepage": "https://m-viper.de",
|
||||
"launcherWebsite": "https://aeromc.viper.ipv64.net",
|
||||
"main": "src/main.js",
|
||||
"author": "M_Viper",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"prebuild": "node scripts/write-build-config.js",
|
||||
"prebuild:portable": "node scripts/write-build-config.js",
|
||||
"build": "electron-builder --win dir && node scripts/build-setup.js",
|
||||
"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": {
|
||||
"appId": "com.aeromc.launcher",
|
||||
"productName": "AeroMC",
|
||||
"artifactName": "AeroMC.${ext}",
|
||||
"compression": "store",
|
||||
"files": [
|
||||
"src/**/*",
|
||||
"package.json",
|
||||
"LICENSE"
|
||||
],
|
||||
"asarUnpack": [
|
||||
"node_modules/loopback-capture/**/*",
|
||||
"node_modules/node-window-manager/**/*",
|
||||
"node_modules/ffmpeg-static/**/*"
|
||||
],
|
||||
"win": {
|
||||
"target": "dir",
|
||||
"icon": "src/assets/icon.ico"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^28.0.0",
|
||||
"electron-builder": "^24.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xhayper/discord-rpc": "^1.3.4",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
{
|
||||
"name": "aeromc",
|
||||
"productName": "AeroMC",
|
||||
"version": "1.3.1",
|
||||
"description": "AeroMC – Instanz-basierter Minecraft-Launcher (à la MultiMC/Prism)",
|
||||
"homepage": "https://m-viper.de",
|
||||
"launcherWebsite": "https://aeromc.viper.ipv64.net",
|
||||
"main": "src/main.js",
|
||||
"author": "M_Viper",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"prebuild": "node scripts/pruefe-version.js && 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 && node scripts/sign-release.js setup && node scripts/build-streamdeck-plugin.js",
|
||||
"build:streamdeck": "node scripts/build-streamdeck-plugin.js",
|
||||
"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": {
|
||||
"appId": "com.aeromc.launcher",
|
||||
"productName": "AeroMC",
|
||||
"artifactName": "AeroMC.${ext}",
|
||||
"compression": "store",
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "streamdeck-plugin/com.aeromc.launcher.sdPlugin",
|
||||
"to": "streamdeck-plugin/com.aeromc.launcher.sdPlugin",
|
||||
"filter": [
|
||||
"**/*",
|
||||
"!logs/**"
|
||||
]
|
||||
}
|
||||
],
|
||||
"files": [
|
||||
"src/**/*",
|
||||
"package.json",
|
||||
"LICENSE"
|
||||
],
|
||||
"asarUnpack": [
|
||||
"node_modules/loopback-capture/**/*",
|
||||
"node_modules/node-window-manager/**/*",
|
||||
"node_modules/ffmpeg-static/**/*"
|
||||
],
|
||||
"win": {
|
||||
"target": "dir",
|
||||
"icon": "src/assets/icon.ico"
|
||||
},
|
||||
"electronFuses": {
|
||||
"runAsNode": false,
|
||||
"enableNodeOptionsEnvironmentVariable": false,
|
||||
"enableNodeCliInspectArguments": false,
|
||||
"enableEmbeddedAsarIntegrityValidation": true,
|
||||
"onlyLoadAppFromAsar": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^44.6.0",
|
||||
"electron-builder": "^26.15.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xhayper/discord-rpc": "^1.3.4",
|
||||
"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.29.1",
|
||||
"unzipper": "^0.12.5"
|
||||
},
|
||||
"overrides": {
|
||||
"brace-expansion@^2.0.0": "^2.1.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,72 +1,73 @@
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* 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
|
||||
* gebraucht, die fertige Datei liegt im Projekt. Gesucht wird das JDK über
|
||||
* JAVA_HOME bzw. unter "C:\Program Files\Eclipse Adoptium", ASM im
|
||||
* 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.
|
||||
*
|
||||
* Aufruf: node scripts/build-agent.js
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const WURZEL = path.join(__dirname, '..');
|
||||
const ZIEL = path.join(WURZEL, 'src', 'assets', 'agent', 'aeromc-capes.jar');
|
||||
|
||||
function findeJdk() {
|
||||
const kandidaten = [];
|
||||
if (process.env.JAVA_HOME) kandidaten.push(process.env.JAVA_HOME);
|
||||
const adoptium = 'C:\\Program Files\\Eclipse 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));
|
||||
}
|
||||
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).');
|
||||
return jdk;
|
||||
}
|
||||
|
||||
function findeAsm(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}`);
|
||||
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));
|
||||
const datei = path.join(basis, versionen[0], `${name}-${versionen[0]}.jar`);
|
||||
if (!fs.existsSync(datei)) throw new Error('ASM-Datei fehlt: ' + datei);
|
||||
return datei;
|
||||
}
|
||||
|
||||
function quellen(ordner) {
|
||||
return fs.readdirSync(ordner, { withFileTypes: true }).flatMap((e) => {
|
||||
const p = path.join(ordner, e.name);
|
||||
return e.isDirectory() ? quellen(p) : (e.name.endsWith('.java') ? [p] : []);
|
||||
});
|
||||
}
|
||||
|
||||
const jdk = findeJdk();
|
||||
const javac = path.join(jdk, 'bin', 'javac');
|
||||
const java = path.join(jdk, 'bin', 'java');
|
||||
const asm = findeAsm('asm');
|
||||
const asmCommons = findeAsm('asm-commons');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'aeromc-agent-'));
|
||||
const klassen = path.join(tmp, 'klassen');
|
||||
const werkzeug = path.join(tmp, 'werkzeug');
|
||||
console.log('JDK:', jdk);
|
||||
console.log('ASM:', asm);
|
||||
try {
|
||||
execFileSync(javac, ['--release', '8', '-nowarn', '-encoding', 'UTF-8', '-cp', asm, '-d', klassen,
|
||||
...quellen(path.join(WURZEL, 'agent', 'src'))], { stdio: 'inherit' });
|
||||
execFileSync(javac, ['-encoding', 'UTF-8', '-cp', [asm, asmCommons].join(path.delimiter), '-d', werkzeug,
|
||||
path.join(WURZEL, 'agent', 'tools', 'Shade.java')], { stdio: 'inherit' });
|
||||
fs.mkdirSync(path.dirname(ZIEL), { recursive: true });
|
||||
execFileSync(java, ['-cp', [werkzeug, asm, asmCommons].join(path.delimiter), 'Shade', ZIEL, klassen, asm], { stdio: 'inherit' });
|
||||
console.log(`Fertig: ${ZIEL} (${fs.statSync(ZIEL).size} Bytes)`);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* 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
|
||||
* gebraucht, die fertige Datei liegt im Projekt. Gesucht wird das JDK über
|
||||
* JAVA_HOME bzw. unter "C:\Program Files\Eclipse Adoptium", ASM im
|
||||
* 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.
|
||||
*
|
||||
* Aufruf: node scripts/build-agent.js
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const WURZEL = path.join(__dirname, '..');
|
||||
const ZIEL = path.join(WURZEL, 'src', 'assets', 'agent', 'aeromc-capes.jar');
|
||||
|
||||
function findeJdk() {
|
||||
const kandidaten = [];
|
||||
if (process.env.JAVA_HOME) kandidaten.push(process.env.JAVA_HOME);
|
||||
const adoptium = 'C:\\Program Files\\Eclipse 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));
|
||||
}
|
||||
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).');
|
||||
return jdk;
|
||||
}
|
||||
|
||||
function findeAsm(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}`);
|
||||
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));
|
||||
const datei = path.join(basis, versionen[0], `${name}-${versionen[0]}.jar`);
|
||||
if (!fs.existsSync(datei)) throw new Error('ASM-Datei fehlt: ' + datei);
|
||||
return datei;
|
||||
}
|
||||
|
||||
function quellen(ordner) {
|
||||
return fs.readdirSync(ordner, { withFileTypes: true }).flatMap((e) => {
|
||||
const p = path.join(ordner, e.name);
|
||||
return e.isDirectory() ? quellen(p) : (e.name.endsWith('.java') ? [p] : []);
|
||||
});
|
||||
}
|
||||
|
||||
const jdk = findeJdk();
|
||||
const javac = path.join(jdk, 'bin', 'javac');
|
||||
const java = path.join(jdk, 'bin', 'java');
|
||||
const asm = findeAsm('asm');
|
||||
const asmCommons = findeAsm('asm-commons');
|
||||
const asmTree = findeAsm('asm-tree');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'aeromc-agent-'));
|
||||
const klassen = path.join(tmp, 'klassen');
|
||||
const werkzeug = path.join(tmp, 'werkzeug');
|
||||
console.log('JDK:', jdk);
|
||||
console.log('ASM:', asm);
|
||||
try {
|
||||
execFileSync(javac, ['--release', '8', '-nowarn', '-encoding', 'UTF-8', '-cp', [asm, asmTree].join(path.delimiter), '-d', klassen,
|
||||
...quellen(path.join(WURZEL, 'agent', 'src'))], { stdio: 'inherit' });
|
||||
execFileSync(javac, ['-encoding', 'UTF-8', '-cp', [asm, asmCommons].join(path.delimiter), '-d', werkzeug,
|
||||
path.join(WURZEL, 'agent', 'tools', 'Shade.java')], { stdio: 'inherit' });
|
||||
fs.mkdirSync(path.dirname(ZIEL), { recursive: true });
|
||||
execFileSync(java, ['-cp', [werkzeug, asm, asmCommons].join(path.delimiter), 'Shade', ZIEL, klassen, asm, asmTree], { stdio: 'inherit' });
|
||||
console.log(`Fertig: ${ZIEL} (${fs.statSync(ZIEL).size} Bytes)`);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* build-streamdeck-plugin.js – das AeroMC-Plugin als Datei zum Weitergeben packen
|
||||
* -------------------------------------------------------------------------------
|
||||
* Erzeugt dist/AeroMC.streamDeckPlugin. Das ist ein ZIP mit dem Plugin-Ordner; ein
|
||||
* Doppelklick darauf installiert es in der Stream-Deck-Software. Die Datei ist für
|
||||
* alle gedacht, die den Launcher nicht selbst aktualisieren lassen (oder die
|
||||
* Stream-Deck-Software erst später installieren).
|
||||
*
|
||||
* Die Launcher-Installation enthält das Plugin ohnehin und kopiert es von selbst
|
||||
* (src/streamdeckplugin.js) - diese Datei ist der zweite Weg dafür.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const archiver = require('archiver');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const NAME = 'com.aeromc.launcher.sdPlugin';
|
||||
const QUELLE = path.join(ROOT, 'streamdeck-plugin', NAME);
|
||||
const AUSGABE = path.join(ROOT, 'dist', 'AeroMC.streamDeckPlugin');
|
||||
|
||||
function main() {
|
||||
if (!fs.existsSync(path.join(QUELLE, 'manifest.json'))) throw new Error(`${QUELLE}\\manifest.json fehlt.`);
|
||||
fs.mkdirSync(path.dirname(AUSGABE), { recursive: true });
|
||||
fs.rmSync(AUSGABE, { force: true });
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const aus = fs.createWriteStream(AUSGABE);
|
||||
const zip = archiver('zip', { zlib: { level: 9 } });
|
||||
aus.on('close', () => {
|
||||
const version = JSON.parse(fs.readFileSync(path.join(QUELLE, 'manifest.json'), 'utf8')).Version;
|
||||
console.log(`\n» Stream-Deck-Plugin ${version}: ${AUSGABE} (${Math.round(zip.pointer() / 1024)} KB)`);
|
||||
resolve();
|
||||
});
|
||||
zip.on('error', reject);
|
||||
zip.pipe(aus);
|
||||
// Der Plugin-Ordner steht als einziger Eintrag oben im ZIP; Protokolle der Stream-Deck-Software gehören nicht hinein
|
||||
zip.directory(QUELLE, NAME, (eintrag) => (/^(logs[\\/]|logs$)/.test(eintrag.name.replace(NAME + '/', '')) ? false : eintrag));
|
||||
zip.finalize();
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((err) => { console.error('Stream-Deck-Plugin konnte nicht gepackt werden:', err.message); process.exit(1); });
|
||||
@@ -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,15 +19,24 @@ HOST=0.0.0.0
|
||||
# weiterlaufen, bis alle das Update haben – danach das alte streichen.
|
||||
# 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=
|
||||
|
||||
# Nur für ein abgeschottetes Netz: Dienst bewusst OHNE Token betreiben.
|
||||
# CF_PROXY_OFFEN=1
|
||||
|
||||
# Anfragen je Minute und Absender. 0 = keine Begrenzung.
|
||||
RATE_LIMIT_PER_MIN=60
|
||||
|
||||
# Obergrenze für durchgereichte Dateien in MB. 0 = keine Grenze.
|
||||
MAX_DOWNLOAD_MB=512
|
||||
|
||||
# Gleichzeitige Downloads insgesamt (weitere Anfragen bekommen 503 mit
|
||||
# Retry-After). Schützt die Bandbreite. 0 = keine Grenze.
|
||||
MAX_PARALLEL_DOWNLOADS=8
|
||||
|
||||
# Anzahl der Reverse-Proxy-Stufen davor:
|
||||
# 0 = keine, der Dienst ist direkt erreichbar
|
||||
# 1 = ein Proxy, z. B. Nginx Proxy Manager oder Caddy
|
||||
|
||||
@@ -100,8 +100,22 @@ Der Dienst steht offen im Netz. Eingebaut ist:
|
||||
- **Umleitungen** werden selbst verfolgt und bei jedem Sprung erneut gegen
|
||||
die Host-Liste geprüft, höchstens vier Sprünge.
|
||||
- **Ratenbegrenzung** je Absender, einstellbar über `RATE_LIMIT_PER_MIN`.
|
||||
- **Größengrenze** für durchgereichte Dateien über `MAX_DOWNLOAD_MB`.
|
||||
- **Optionales Token** über `CF_PROXY_TOKEN`.
|
||||
- **Größengrenze** für durchgereichte Dateien über `MAX_DOWNLOAD_MB`. Sie gilt
|
||||
auch dann, wenn CurseForge keine Dateigröße angibt: Es wird mitgezählt und
|
||||
bei Überschreitung abgebrochen.
|
||||
- **Parallelgrenze** über `MAX_PARALLEL_DOWNLOADS` (Standard 8). Weitere
|
||||
Downloads bekommen `503` mit `Retry-After`.
|
||||
- **Zeitlimit** von 30 Sekunden für Antworten von CurseForge. Bricht der
|
||||
Launcher mitten im Download ab, wird auch die Verbindung zu CurseForge
|
||||
geschlossen.
|
||||
- **Fehlversuche** beim Token zählen zur Ratenbegrenzung, ein Raten ist also
|
||||
nicht unbegrenzt schnell möglich.
|
||||
- **`/selftest`** läuft höchstens alle 30 Sekunden wirklich (fünf Anfragen an
|
||||
CurseForge), dazwischen kommt das letzte Ergebnis mit
|
||||
`"zwischengespeichert": true`. Das Token steckt im Launcher; ohne diese
|
||||
Bremse ließe sich damit dein CurseForge-Kontingent aufbrauchen.
|
||||
- **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
|
||||
dort auslesbar. Es hält Gelegenheitsnutzer ab, keine entschlossenen. Die
|
||||
|
||||
@@ -12,3 +12,6 @@ services:
|
||||
read_only: true
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
# keine Linux-Fähigkeiten nötig (Port 8420 liegt über 1024)
|
||||
cap_drop:
|
||||
- ALL
|
||||
|
||||
@@ -1,58 +1,39 @@
|
||||
'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 { Readable } = require('stream');
|
||||
const crypto = require('crypto');
|
||||
const { Readable, Transform, pipeline } = require('stream');
|
||||
|
||||
const KEY = (process.env.CF_API_KEY || '').trim();
|
||||
const PORT = Number(process.env.PORT || 8420);
|
||||
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(
|
||||
String(process.env.CF_PROXY_TOKEN || '')
|
||||
.split(',').map((t) => t.trim()).filter(Boolean),
|
||||
);
|
||||
const LIMIT_PRO_MIN = Number(process.env.RATE_LIMIT_PER_MIN || 60);
|
||||
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.
|
||||
// gleichzeitige Downloads insgesamt – schützt die Bandbreite, falls jemand mit dem
|
||||
// (im Launcher steckenden) Token viele große Dateien parallel zieht. 0 = keine Grenze.
|
||||
const MAX_PARALLEL = Number(process.env.MAX_PARALLEL_DOWNLOADS ?? 8);
|
||||
// wie lange CurseForge für eine Antwort brauchen darf, bevor die Anfrage abgebrochen wird
|
||||
const UPSTREAM_TIMEOUT_MS = 30_000;
|
||||
let laufendeDownloads = 0;
|
||||
const SELFTEST_ABSTAND_MS = 30_000;
|
||||
let selftestErgebnis = null;
|
||||
let selftestZeit = 0;
|
||||
|
||||
const PROXY_STUFEN = Math.max(0, Number(process.env.TRUST_PROXY || 0) || 0);
|
||||
|
||||
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)';
|
||||
|
||||
function cfKopfzeilen(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([
|
||||
'edge.forgecdn.net',
|
||||
'mediafilez.forgecdn.net',
|
||||
@@ -64,6 +45,17 @@ if (!KEY) {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -117,9 +109,21 @@ function sendeJson(res, code, daten) {
|
||||
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) {
|
||||
if (!TOKENS.size) return true;
|
||||
return TOKENS.has(String(req.headers['x-aeromc-token'] || '').trim());
|
||||
if (!TOKENS.size) return OFFEN_ERLAUBT; // ohne Token nur mit ausdrücklicher Freigabe
|
||||
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) {
|
||||
@@ -162,6 +166,7 @@ async function dateilisteAufloesen(req, res) {
|
||||
method: 'POST',
|
||||
headers: cfKopfzeilen({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ fileIds: ids }),
|
||||
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
|
||||
});
|
||||
if (!antwort.ok) {
|
||||
// Schlüsselprobleme nicht im Wortlaut weiterreichen
|
||||
@@ -176,33 +181,72 @@ async function dateiDurchreichen(req, res, url) {
|
||||
if (!ziel) return sendeJson(res, 400, { error: 'Adresse fehlt oder zeigt nicht auf das CurseForge-CDN.' });
|
||||
|
||||
// Weiterleitungen selbst verfolgen und jedes Mal den Host neu prüfen
|
||||
let aktuell = ziel;
|
||||
let antwort = null;
|
||||
for (let sprung = 0; sprung < 4; sprung++) {
|
||||
antwort = await fetch(aktuell, { headers: cfKopfzeilen(), redirect: 'manual' });
|
||||
const weiter = antwort.headers.get('location');
|
||||
if (antwort.status >= 300 && antwort.status < 400 && weiter) {
|
||||
const naechste = pruefeDownloadZiel(new URL(weiter, aktuell).toString());
|
||||
if (!naechste) return sendeJson(res, 502, { error: 'Weiterleitung führt aus dem CurseForge-CDN heraus.' });
|
||||
aktuell = naechste;
|
||||
continue;
|
||||
if (MAX_PARALLEL > 0 && laufendeDownloads >= MAX_PARALLEL) {
|
||||
res.setHeader('Retry-After', '5');
|
||||
return sendeJson(res, 503, { error: 'Gerade zu viele Downloads, bitte gleich erneut versuchen.' });
|
||||
}
|
||||
laufendeDownloads++;
|
||||
let frei = false;
|
||||
const freigeben = () => { if (!frei) { frei = true; laufendeDownloads--; } };
|
||||
|
||||
try {
|
||||
// Zeitlimit nur bis die Antwort-Köpfe da sind – der eigentliche Download darf länger dauern
|
||||
const abbruch = new AbortController();
|
||||
const frist = setTimeout(() => abbruch.abort(), UPSTREAM_TIMEOUT_MS);
|
||||
res.on('close', () => abbruch.abort()); // Aufrufer weg -> CurseForge-Verbindung schließen
|
||||
|
||||
let aktuell = ziel;
|
||||
let antwort = null;
|
||||
let umgeleitet = true;
|
||||
try {
|
||||
for (let sprung = 0; sprung < 4 && umgeleitet; sprung++) {
|
||||
antwort = await fetch(aktuell, { headers: cfKopfzeilen(), redirect: 'manual', signal: abbruch.signal });
|
||||
const weiter = antwort.headers.get('location');
|
||||
if (antwort.status >= 300 && antwort.status < 400 && weiter) {
|
||||
const naechste = pruefeDownloadZiel(new URL(weiter, aktuell).toString());
|
||||
if (!naechste) { freigeben(); return sendeJson(res, 502, { error: 'Weiterleitung führt aus dem CurseForge-CDN heraus.' }); }
|
||||
aktuell = naechste;
|
||||
} else {
|
||||
umgeleitet = false;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(frist);
|
||||
}
|
||||
if (umgeleitet) { freigeben(); return sendeJson(res, 502, { error: 'Zu viele Weiterleitungen.' }); }
|
||||
if (!antwort || !antwort.ok) {
|
||||
freigeben();
|
||||
return sendeJson(res, antwort && antwort.status >= 400 ? antwort.status : 502, { error: 'Download fehlgeschlagen.' });
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (!antwort || !antwort.ok) {
|
||||
return sendeJson(res, antwort ? antwort.status : 502, { error: 'Download fehlgeschlagen.' });
|
||||
}
|
||||
|
||||
const laenge = Number(antwort.headers.get('content-length')) || 0;
|
||||
if (MAX_MB > 0 && laenge > MAX_MB * 1024 * 1024) {
|
||||
return sendeJson(res, 413, { error: `Datei größer als erlaubt (${MAX_MB} MB).` });
|
||||
}
|
||||
const laenge = Number(antwort.headers.get('content-length')) || 0;
|
||||
const grenze = MAX_MB > 0 ? MAX_MB * 1024 * 1024 : 0;
|
||||
if (grenze && laenge > grenze) {
|
||||
freigeben();
|
||||
return sendeJson(res, 413, { error: `Datei größer als erlaubt (${MAX_MB} MB).` });
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': antwort.headers.get('content-type') || 'application/octet-stream',
|
||||
...(laenge ? { 'Content-Length': String(laenge) } : {}),
|
||||
});
|
||||
Readable.fromWeb(antwort.body).pipe(res);
|
||||
res.writeHead(200, {
|
||||
'Content-Type': antwort.headers.get('content-type') || 'application/octet-stream',
|
||||
...(laenge ? { 'Content-Length': String(laenge) } : {}),
|
||||
});
|
||||
|
||||
// Auch ohne Content-Length (chunked) mitzählen und bei Überschreitung abbrechen,
|
||||
// sonst ließe sich die Größengrenze umgehen
|
||||
let gesamt = 0;
|
||||
const zaehlen = new Transform({
|
||||
transform(stueck, _enc, fertig) {
|
||||
gesamt += stueck.length;
|
||||
if (grenze && gesamt > grenze) return fertig(new Error('Datei größer als erlaubt'));
|
||||
fertig(null, stueck);
|
||||
},
|
||||
});
|
||||
// pipeline räumt auf, wenn eine Seite abbricht (statt wie pipe() offen zu bleiben)
|
||||
pipeline(Readable.fromWeb(antwort.body), zaehlen, res, () => freigeben());
|
||||
} catch (err) {
|
||||
freigeben();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -216,7 +260,11 @@ const server = http.createServer(async (req, res) => {
|
||||
return sendeJson(res, 200, { ok: true, dienst: 'aeromc-cf-proxy' });
|
||||
}
|
||||
|
||||
if (!tokenOk(req)) return sendeJson(res, 401, { error: 'Nicht berechtigt.' });
|
||||
if (!tokenOk(req)) {
|
||||
// Fehlversuche je Absender begrenzen, damit sich das Token nicht in Ruhe erraten lässt
|
||||
if (!darfNoch('falsch:' + absender(req))) return sendeJson(res, 429, { error: 'Zu viele Anfragen, bitte kurz warten.' });
|
||||
return sendeJson(res, 401, { error: 'Nicht berechtigt.' });
|
||||
}
|
||||
|
||||
/*
|
||||
* Hilfe beim Einstellen von TRUST_PROXY: zeigt, was der Dienst von DIESER
|
||||
@@ -254,6 +302,16 @@ const server = http.createServer(async (req, res) => {
|
||||
* niemals den Schlüssel selbst.
|
||||
*/
|
||||
if (url.pathname === '/selftest') {
|
||||
/*
|
||||
* Das Token steckt im Launcher und ist auslesbar. Jeder Selbsttest kostet fünf
|
||||
* CurseForge-Anfragen und läuft vor der Ratenbegrenzung – ohne Bremse ließe sich
|
||||
* damit das Kontingent des Schlüssels aufbrauchen. Deshalb höchstens alle
|
||||
* 30 Sekunden wirklich ausführen, dazwischen das letzte Ergebnis zeigen.
|
||||
*/
|
||||
const jetzt = Date.now();
|
||||
if (selftestErgebnis && jetzt - selftestZeit < SELFTEST_ABSTAND_MS) {
|
||||
return sendeJson(res, 200, Object.assign({}, selftestErgebnis, { zwischengespeichert: true }));
|
||||
}
|
||||
const roh = process.env.CF_API_KEY || '';
|
||||
const befund = {
|
||||
schluessel: {
|
||||
@@ -285,6 +343,7 @@ const server = http.createServer(async (req, res) => {
|
||||
method: p.method,
|
||||
headers: cfKopfzeilen(p.body ? { 'Content-Type': 'application/json' } : undefined),
|
||||
body: p.body ? JSON.stringify(p.body) : undefined,
|
||||
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
|
||||
});
|
||||
let text = '';
|
||||
try { text = (await a.text()).replace(/\s+/g, ' ').trim().slice(0, 160); } catch { /* egal */ }
|
||||
@@ -317,6 +376,8 @@ const server = http.createServer(async (req, res) => {
|
||||
|
||||
// Kurzfassung für den schnellen Blick
|
||||
befund.kurz = befund.proben.map((p) => `${p.name}: ${p.status ?? p.fehler}`);
|
||||
selftestErgebnis = befund;
|
||||
selftestZeit = Date.now();
|
||||
return sendeJson(res, 200, befund);
|
||||
}
|
||||
|
||||
@@ -337,7 +398,7 @@ server.listen(PORT, HOST, () => {
|
||||
console.log(`AeroMC CurseForge-Weiterleitung läuft auf ${HOST}:${PORT}`);
|
||||
console.log(` Ratenbegrenzung: ${LIMIT_PRO_MIN > 0 ? LIMIT_PRO_MIN + '/Minute je Absender' : 'aus'}`);
|
||||
// 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'})`);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 |
@@ -231,8 +231,8 @@ function installAddon(filePath) {
|
||||
if (!/\.(mcpack|mcaddon|mcworld|mctemplate)$/i.test(filePath)) {
|
||||
return { ok: false, message: 'Nur .mcpack, .mcaddon, .mcworld oder .mctemplate möglich.' };
|
||||
}
|
||||
const child = spawn('cmd', ['/c', 'start', '', filePath], { detached: true, stdio: 'ignore' });
|
||||
child.unref();
|
||||
// über die Shell öffnen, nicht über "cmd /c start": Zeichen wie & im Dateinamen wären dort Befehlstrenner
|
||||
require('electron').shell.openPath(filePath).catch(() => {});
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
|
||||
@@ -150,6 +150,8 @@
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .login-usercode{background:var(--input);color:var(--text);border-color:var(--border);border-radius:12px}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .upd-notes{background:var(--input);border-radius:10px}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .upd-notes h5{color:rgb(var(--p-hell))}
|
||||
:root:is([data-design="aero"],[data-design="lila"]) .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))}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<div class="titelleiste">
|
||||
<img src="assets/logo.png" alt="" class="titel-logo" />
|
||||
<span class="titel-text">AeroMC</span>
|
||||
<span id="titel-beta" class="titel-beta hidden"></span>
|
||||
</div>
|
||||
|
||||
<!-- Obere Werkzeugleiste (MultiMC-Stil: Icon + Text) -->
|
||||
@@ -54,6 +55,13 @@
|
||||
<div id="p-icon" class="side-ico">🌱</div>
|
||||
<div id="p-name" class="side-title">Instanz</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>
|
||||
<button id="p-play" class="sbtn play"><span class="play-text">Starten</span></button>
|
||||
<button id="p-offline" class="sbtn">Offline starten</button>
|
||||
@@ -272,10 +280,22 @@
|
||||
<div class="s-text"><b>Windows-Benachrichtigungen</b><small>Meldet, wenn ein großer Download oder ein Backup im Hintergrund fertig ist – nur, solange der Launcher nicht im Vordergrund ist.</small></div>
|
||||
<label class="schalter"><input id="s-notify" type="checkbox" /><i></i></label>
|
||||
</div>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Stream Deck erlauben</b><small>Öffnet eine Schnittstelle, die nur dieser PC erreicht, damit das AeroMC-Plugin Instanzen starten, Clips speichern und die Aufnahme steuern kann.</small></div>
|
||||
<label class="schalter"><input id="s-streamdeck" type="checkbox" /><i></i></label>
|
||||
</div>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Stream-Deck-Plugin</b><small>Das Plugin liegt in AeroMC bei und wird beim Einschalten selbst in die Stream-Deck-Software kopiert. Danach die Stream-Deck-Software einmal neu starten – die Tasten stehen dann unter „AeroMC“.</small></div>
|
||||
<button id="s-streamdeck-plugin" class="btn btn-mini" type="button">Installieren</button>
|
||||
</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 class="settings-card">
|
||||
<h5>Instanzen</h5>
|
||||
@@ -409,12 +429,23 @@
|
||||
<div class="s-wert s-paar"><input id="s-mc-width" type="number" min="320" step="10" aria-label="Fensterbreite" /><span>×</span><input id="s-mc-height" type="number" min="240" step="10" aria-label="Fensterhöhe" /></div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="settings-card">
|
||||
<h5>AeroMC-Look</h5>
|
||||
<div class="s-zeile">
|
||||
<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 class="schalter"><input id="s-aeromc-design" type="checkbox" /><i></i></label>
|
||||
</div>
|
||||
</section>
|
||||
<section class="settings-card">
|
||||
<h5>Mods</h5>
|
||||
<div class="s-zeile">
|
||||
<div class="s-text"><b>Mod-Updates vor dem Start</b><small>Sucht vor jedem Start nach Updates für Modrinth-Mods und installiert sie. Nach 20 Sekunden startet Minecraft ohne Update; abgeschaltete Mods bleiben aus.</small></div>
|
||||
<label class="schalter"><input id="s-auto-mod-updates" type="checkbox" /><i></i></label>
|
||||
</div>
|
||||
<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 class="settings-card">
|
||||
<h5>Konsole</h5>
|
||||
@@ -657,6 +688,37 @@
|
||||
</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 ===================== -->
|
||||
<div id="modal-confirm" class="modal hidden">
|
||||
<div class="modal-card modal-sm">
|
||||
|
||||
@@ -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 path = require('path');
|
||||
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 UA = 'AeroMC-Launcher';
|
||||
@@ -193,6 +201,11 @@ async function ensureJava(major, onProgress) {
|
||||
try {
|
||||
if (onProgress) onProgress({ phase: 'java-download', geladen: 0, gesamt: release.size, percent: 0 });
|
||||
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
|
||||
// halbfertiges Java, falls unterwegs etwas schiefgeht
|
||||
const tmpDir = ziel + '.teil';
|
||||
|
||||
@@ -52,11 +52,20 @@ async function downloadIfNeeded(url, dest, sha1) {
|
||||
if (!sha1 || sha1File(dest) === sha1) return { skipped: true, bytes: 0 };
|
||||
}
|
||||
ensureDir(path.dirname(dest));
|
||||
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());
|
||||
fs.writeFileSync(dest, buf);
|
||||
return { skipped: false, bytes: buf.length };
|
||||
// Mojang (und die Loader) nennen zu jeder Datei die SHA-1 – geschrieben wird
|
||||
// nur, was dazu passt. Ein Fehlversuch wird einmal wiederholt (abgebrochene
|
||||
// Übertragung), dann gilt die Datei als beschädigt oder manipuliert.
|
||||
for (let versuch = 1; ; versuch++) {
|
||||
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)
|
||||
@@ -254,8 +263,7 @@ async function prepareAssets(vj, sharedDir, onProgress) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function pickJava(vj, preferredPath) {
|
||||
const required = (vj.javaVersion && vj.javaVersion.majorVersion) || 8;
|
||||
let preferred = null;
|
||||
const required = (vj.javaVersion && vj.javaVersion.majorVersion) || 8; let preferred = null;
|
||||
if (preferredPath && preferredPath.trim()) {
|
||||
const info = services.probeJava(preferredPath);
|
||||
if (info) {
|
||||
@@ -546,6 +554,10 @@ async function prepareAndLaunch(opts, onProgress) {
|
||||
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
|
||||
// ä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) {
|
||||
const adresse = String(opts.quickPlayServer).trim();
|
||||
if (vorQuickPlay(opts.versionId)) {
|
||||
@@ -559,6 +571,14 @@ async function prepareAndLaunch(opts, onProgress) {
|
||||
|
||||
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)
|
||||
if (opts.preLaunchCommand && opts.preLaunchCommand.trim()) {
|
||||
emit('log', { line: '[Pre-Launch] ' + opts.preLaunchCommand + '\n' });
|
||||
|
||||
@@ -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 };
|
||||
@@ -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,444 +1,463 @@
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* preload.js – stellt dem Renderer eine schmale, sichere API bereit.
|
||||
* contextIsolation ist aktiv; der Renderer hat KEINEN direkten Node-Zugriff.
|
||||
*/
|
||||
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
// Debug-Hilfe: JS-Fehler im Renderer landen im Terminal des Hauptprozesses,
|
||||
// damit man sie auch ohne offene DevTools sieht
|
||||
window.addEventListener('error', (e) => {
|
||||
ipcRenderer.send('debug:rendererError', (e.error && e.error.stack) || e.message);
|
||||
});
|
||||
window.addEventListener('unhandledrejection', (e) => {
|
||||
ipcRenderer.send('debug:rendererError', 'unhandledrejection: ' + ((e.reason && e.reason.stack) || e.reason));
|
||||
});
|
||||
|
||||
// Zuletzt angeklickter Knopf: Popup-Fenster wachsen daraus hervor
|
||||
// (main.js → openPopout). Gilt nur kurz, sonst kämen sie aus einem alten Klick.
|
||||
let letzterKlick = null;
|
||||
window.addEventListener('pointerdown', (e) => {
|
||||
const t = e.target && e.target.closest ? (e.target.closest('button, a, [role="button"], .card, .snav') || e.target) : null;
|
||||
if (!t || !t.getBoundingClientRect) return;
|
||||
const r = t.getBoundingClientRect();
|
||||
letzterKlick = { x: r.left, y: r.top, width: r.width, height: r.height, zeit: Date.now() };
|
||||
}, true);
|
||||
function frischerKlick() {
|
||||
if (!letzterKlick || Date.now() - letzterKlick.zeit > 2000) return null;
|
||||
const { x, y, width, height } = letzterKlick;
|
||||
return { x, y, width, height };
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld('api', {
|
||||
// Einstellungen
|
||||
getSettings: () => ipcRenderer.invoke('settings:get'),
|
||||
saveSettings: (patch) => ipcRenderer.invoke('settings:save', patch),
|
||||
getInstancesDir: () => ipcRenderer.invoke('settings:instancesDir'),
|
||||
detectJava: (javaPath) => ipcRenderer.invoke('java:detect', javaPath),
|
||||
scanJava: () => ipcRenderer.invoke('java:scan'),
|
||||
listJava: () => ipcRenderer.invoke('java:list'),
|
||||
|
||||
// Minecraft-Versionen (Mojang)
|
||||
mcVersions: () => ipcRenderer.invoke('mc:versions'),
|
||||
loaderVersions: (loader, mcVersion) => ipcRenderer.invoke('loaders:versions', loader, mcVersion),
|
||||
optifineFind: (mcVersion) => ipcRenderer.invoke('optifine:find', mcVersion),
|
||||
optifineSetup: (id) => ipcRenderer.invoke('optifine:setup', id),
|
||||
optifineConfirm: (id) => ipcRenderer.invoke('optifine:confirm', id),
|
||||
|
||||
// Mods (Modrinth)
|
||||
modSearch: (opts) => ipcRenderer.invoke('mods:search', opts),
|
||||
curseforgeTestKey: (apiKey) => ipcRenderer.invoke('curseforge:testKey', apiKey),
|
||||
// art: 'mod' | 'resourcepack' | 'shader'
|
||||
modList: (id, art) => ipcRenderer.invoke('mods:list', id, art),
|
||||
modInstall: (id, project, art) => ipcRenderer.invoke('mods:install', id, project, art),
|
||||
modRemove: (id, filename, art) => ipcRenderer.invoke('mods:remove', id, filename, art),
|
||||
modDependents: (id, filename, art) => ipcRenderer.invoke('mods:dependents', id, filename, art),
|
||||
modAddFile: (id, art) => ipcRenderer.invoke('mods:addFile', id, art),
|
||||
modSetEnabled: (id, filename, enabled, art) => ipcRenderer.invoke('mods:setEnabled', id, filename, enabled, art),
|
||||
systemMemory: () => ipcRenderer.invoke('system:memory'),
|
||||
checkMemory: (minMb, maxMb) => ipcRenderer.invoke('system:checkMemory', minMb, maxMb),
|
||||
crashLatest: (id, seit) => ipcRenderer.invoke('crash:latest', id, seit),
|
||||
crashAnalyze: (id, seit) => ipcRenderer.invoke('crash:analyze', id, seit),
|
||||
crashFix: (id, aktion) => ipcRenderer.invoke('crash:fix', id, aktion),
|
||||
crashRelaunch: (id) => ipcRenderer.invoke('crash:relaunch', id),
|
||||
shareCreate: (id) => ipcRenderer.invoke('share:create', id),
|
||||
shareFetch: (code) => ipcRenderer.invoke('share:fetch', code),
|
||||
shareInstall: (code) => ipcRenderer.invoke('share:install', code),
|
||||
onShareProgress: (cb) => {
|
||||
const l = (_e, p) => cb(p);
|
||||
ipcRenderer.on('share:progress', l);
|
||||
return () => ipcRenderer.removeListener('share:progress', l);
|
||||
},
|
||||
onAutoBackup: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('backup:auto', l);
|
||||
return () => ipcRenderer.removeListener('backup:auto', l);
|
||||
},
|
||||
crashOpenFolder: (id) => ipcRenderer.invoke('crash:openFolder', id),
|
||||
serversList: (id) => ipcRenderer.invoke('servers:list', id),
|
||||
serversAdd: (id, eintrag) => ipcRenderer.invoke('servers:add', id, eintrag),
|
||||
serversRemove: (id, ip) => ipcRenderer.invoke('servers:remove', id, ip),
|
||||
serversRename: (id, ip, neuerName) => ipcRenderer.invoke('servers:rename', id, ip, neuerName),
|
||||
serversCopyTo: (zielIds, eintrag) => ipcRenderer.invoke('servers:copyTo', zielIds, eintrag),
|
||||
serversPing: (ip) => ipcRenderer.invoke('servers:ping', ip),
|
||||
logsList: (id) => ipcRenderer.invoke('logs:list', id),
|
||||
logsRead: (id, name) => ipcRenderer.invoke('logs:read', id, name),
|
||||
logsOpenFolder: (id) => ipcRenderer.invoke('logs:openFolder', id),
|
||||
modCheckUpdates: (id, art) => ipcRenderer.invoke('mods:checkUpdates', id, art),
|
||||
modApplyUpdates: (id, projectIds, art) => ipcRenderer.invoke('mods:applyUpdates', id, projectIds, art),
|
||||
onModUpdateProgress: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('mods:updateProgress', l);
|
||||
return () => ipcRenderer.removeListener('mods:updateProgress', l);
|
||||
},
|
||||
|
||||
// Instanzen
|
||||
listInstances: () => ipcRenderer.invoke('instances:list'),
|
||||
getInstance: (id) => ipcRenderer.invoke('instances:get', id),
|
||||
createInstance: (data) => ipcRenderer.invoke('instances:create', data),
|
||||
installPerformancePack: (id) => ipcRenderer.invoke('instances:installPerformancePack', id),
|
||||
updateInstance: (id, patch) => ipcRenderer.invoke('instances:update', id, patch),
|
||||
deleteInstance: (id) => ipcRenderer.invoke('instances:delete', id),
|
||||
duplicateInstance: (id) => ipcRenderer.invoke('instances:duplicate', id),
|
||||
openInstanceFolder: (id) => ipcRenderer.invoke('instances:openFolder', id),
|
||||
openGameDir: (id) => ipcRenderer.invoke('instances:openGameDir', id),
|
||||
|
||||
// Welten & Ressourcenpakete
|
||||
worlds: (id) => ipcRenderer.invoke('instance:worlds', id),
|
||||
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),
|
||||
backupWorld: (id, worldName) => ipcRenderer.invoke('world:backup', id, worldName),
|
||||
worldCopyTo: (id, worldName, zielId) => ipcRenderer.invoke('world:copyTo', id, worldName, zielId),
|
||||
friendsSendScreenshot: (otherUuid, instanceId, datei, text) => ipcRenderer.invoke('friends:sendScreenshot', otherUuid, instanceId, datei, text),
|
||||
openSubdir: (id, sub) => ipcRenderer.invoke('instance:openSubdir', id, sub),
|
||||
screenshots: (id) => ipcRenderer.invoke('instance:screenshots', id),
|
||||
screenshotData: (id, name) => ipcRenderer.invoke('instance:screenshotData', id, name),
|
||||
openScreenshot: (id, name) => ipcRenderer.invoke('instance:openScreenshot', id, name),
|
||||
launchInstance: (id, accountId, opts) => ipcRenderer.invoke('instances:launch', id, accountId, opts),
|
||||
exportInstance: (id) => ipcRenderer.invoke('instances:export', id),
|
||||
createGlobalBackup: () => ipcRenderer.invoke('backup:global'),
|
||||
listGlobalBackups: () => ipcRenderer.invoke('backup:list'),
|
||||
restoreGlobalBackup: (sourcePath) => ipcRenderer.invoke('backup:restore', sourcePath),
|
||||
onBackupProgress: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('backup:progress', l);
|
||||
return () => ipcRenderer.removeListener('backup:progress', l);
|
||||
},
|
||||
importInstance: () => ipcRenderer.invoke('instances:import'),
|
||||
createShortcut: (id, customName) => ipcRenderer.invoke('instances:shortcut', id, customName),
|
||||
serversShortcut: (id, ip, name, customName) => ipcRenderer.invoke('servers:shortcut', id, ip, name, customName),
|
||||
// Start über eine Desktop-Verknüpfung ("--launch=<id>", optional "--server=<ip>")
|
||||
onLaunchRequest: (cb) => {
|
||||
const l = (_e, payload) => cb(payload);
|
||||
ipcRenderer.on('instance:launch-request', l);
|
||||
return () => ipcRenderer.removeListener('instance:launch-request', l);
|
||||
},
|
||||
// Signalisiert dem Hauptprozess, dass onLaunchRequest jetzt registriert ist –
|
||||
// verhindert, dass ein Start-Wunsch aus einer Verknüpfung verloren geht,
|
||||
// weil er ankommt, bevor die Oberfläche fertig eingerichtet ist
|
||||
notifyReady: () => ipcRenderer.send('renderer:ready'),
|
||||
importModpack: () => ipcRenderer.invoke('modpack:import'),
|
||||
// Datei per Drag&Drop aufs Fenster gezogen (.jar/.mrpack/.zip)
|
||||
filesDrop: (filePath, targetId, targetArt) => ipcRenderer.invoke('files:drop', filePath, targetId, targetArt),
|
||||
modpackSearch: (opts) => ipcRenderer.invoke('modpack:search', opts),
|
||||
modpackInstallFromModrinth: (project) => ipcRenderer.invoke('modpack:installFromModrinth', project),
|
||||
modpackInstallFromCurseForge: (project) => ipcRenderer.invoke('modpack:installFromCurseForge', project),
|
||||
modpackCheck: (id) => ipcRenderer.invoke('modpack:check', id),
|
||||
modpackUpdate: (id, versionId) => ipcRenderer.invoke('modpack:update', id, versionId),
|
||||
onLaunchProgress: (cb) => {
|
||||
const listener = (_e, data) => cb(data);
|
||||
ipcRenderer.on('launch:progress', listener);
|
||||
return () => ipcRenderer.removeListener('launch:progress', listener);
|
||||
},
|
||||
|
||||
// eigenes Instanz-Bild
|
||||
pickImage: () => ipcRenderer.invoke('dialog:pickImage'),
|
||||
setInstanceImage: (id, sourcePath) => ipcRenderer.invoke('instances:setImage', id, sourcePath),
|
||||
clearInstanceImage: (id) => ipcRenderer.invoke('instances:clearImage', id),
|
||||
instanceIcon: (id) => ipcRenderer.invoke('instances:icon', id),
|
||||
fileDataUri: (p) => ipcRenderer.invoke('file:dataUri', p),
|
||||
|
||||
// Microsoft-Login (Phase 3) + Mehrkonten
|
||||
authStatus: () => ipcRenderer.invoke('auth:status'),
|
||||
authAccounts: () => ipcRenderer.invoke('auth:accounts'),
|
||||
authSetActive: (id) => ipcRenderer.invoke('auth:setActive', id),
|
||||
skinProfile: () => ipcRenderer.invoke('skin:profile'),
|
||||
skinArmformPruefen: () => ipcRenderer.invoke('skin:armformPruefen'),
|
||||
garderobeListe: () => ipcRenderer.invoke('garderobe:liste'),
|
||||
erfolgeListe: () => ipcRenderer.invoke('erfolge:liste'),
|
||||
laufendeInstanzen: () => ipcRenderer.invoke('game:laufend'),
|
||||
onLaufendeInstanzen: (cb) => {
|
||||
const l = (_e, d) => cb(d || []);
|
||||
ipcRenderer.on('game:laufend', l);
|
||||
return () => ipcRenderer.removeListener('game:laufend', l);
|
||||
},
|
||||
onErfolgeNeu: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('erfolge:neu', l);
|
||||
return () => ipcRenderer.removeListener('erfolge:neu', l);
|
||||
},
|
||||
modsLeistungsPaket: (id) => ipcRenderer.invoke('mods:leistungsPaket', id),
|
||||
onPaketFortschritt: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('mods:paketFortschritt', l);
|
||||
return () => ipcRenderer.removeListener('mods:paketFortschritt', l);
|
||||
},
|
||||
garderobeSkinSpeichern: (name) => ipcRenderer.invoke('garderobe:skinSpeichern', name),
|
||||
garderobeUmhangSpeichern: (name) => ipcRenderer.invoke('garderobe:umhangSpeichern', name),
|
||||
garderobeAnziehen: (id) => ipcRenderer.invoke('garderobe:anziehen', id),
|
||||
garderobeUmbenennen: (id, name) => ipcRenderer.invoke('garderobe:umbenennen', id, name),
|
||||
garderobeLoeschen: (id) => ipcRenderer.invoke('garderobe:loeschen', id),
|
||||
skinArmformKorrigieren: () => ipcRenderer.invoke('skin:armformKorrigieren'),
|
||||
skinChoose: () => ipcRenderer.invoke('skin:choose'),
|
||||
skinApply: (pfad, variante) => ipcRenderer.invoke('skin:apply', pfad, variante),
|
||||
skinReset: () => ipcRenderer.invoke('skin:reset'),
|
||||
skinCurrentData: () => ipcRenderer.invoke('skin:currentData'),
|
||||
skinModelFor: (uuid) => ipcRenderer.invoke('skin:modelFor', uuid),
|
||||
skinReadData: (pfad) => ipcRenderer.invoke('skin:readData', pfad),
|
||||
skinSaveData: (base64) => ipcRenderer.invoke('skin:saveData', base64),
|
||||
skinApplyData: (base64, variante) => ipcRenderer.invoke('skin:applyData', base64, variante),
|
||||
capeInfo: () => ipcRenderer.invoke('cape:info'),
|
||||
capeForUuid: (uuid) => ipcRenderer.invoke('cape:forUuid', uuid),
|
||||
capeSetOfficial: (id) => ipcRenderer.invoke('cape:setOfficial', id),
|
||||
capeUpload: (base64) => ipcRenderer.invoke('cape:upload', base64),
|
||||
capeRemove: () => ipcRenderer.invoke('cape:remove'),
|
||||
capeCurrentData: () => ipcRenderer.invoke('cape:currentData'),
|
||||
capeChooseFile: (art) => ipcRenderer.invoke('cape:chooseFile', art),
|
||||
capeSaveData: (base64) => ipcRenderer.invoke('cape:saveData', base64),
|
||||
authRemove: (id) => ipcRenderer.invoke('auth:remove', id),
|
||||
authLogin: () => ipcRenderer.invoke('auth:login'),
|
||||
authLogout: () => ipcRenderer.invoke('auth:logout'),
|
||||
authHealth: () => ipcRenderer.invoke('auth:health'),
|
||||
authRecheck: () => ipcRenderer.invoke('auth:recheck'),
|
||||
onAuthHealth: (cb) => {
|
||||
const listener = (_e, data) => cb(data);
|
||||
ipcRenderer.on('auth:health', listener);
|
||||
return () => ipcRenderer.removeListener('auth:health', listener);
|
||||
},
|
||||
// meldet sich, wenn ein Spiel startet oder endet -> Kontenliste auffrischen
|
||||
onAccountsChanged: (cb) => {
|
||||
const listener = () => cb();
|
||||
ipcRenderer.on('accounts:changed', listener);
|
||||
return () => ipcRenderer.removeListener('accounts:changed', listener);
|
||||
},
|
||||
onAuthRefreshed: (cb) => {
|
||||
const listener = () => cb();
|
||||
ipcRenderer.on('auth:refreshed', listener);
|
||||
return () => ipcRenderer.removeListener('auth:refreshed', listener);
|
||||
},
|
||||
onAuthCode: (cb) => {
|
||||
const listener = (_e, data) => cb(data);
|
||||
ipcRenderer.on('auth:code', listener);
|
||||
return () => ipcRenderer.removeListener('auth:code', listener);
|
||||
},
|
||||
|
||||
// Dialoge / App
|
||||
// MultiMC-Import
|
||||
// gemeinsamer Fortschritt aller Launcher-Importe (main.js → fuehreImportAus)
|
||||
onImportFortschritt: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('import:fortschritt', l);
|
||||
return () => ipcRenderer.removeListener('import:fortschritt', l);
|
||||
},
|
||||
importAbbrechen: () => ipcRenderer.invoke('import:abbrechen'),
|
||||
mmcPickFolder: () => ipcRenderer.invoke('mmc:pickFolder'),
|
||||
mmcScan: (folder) => ipcRenderer.invoke('mmc:scan', folder),
|
||||
mmcImport: (entries, copyData) => ipcRenderer.invoke('mmc:import', entries, copyData),
|
||||
onMmcProgress: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('mmc:progress', l);
|
||||
return () => ipcRenderer.removeListener('mmc:progress', l);
|
||||
},
|
||||
cfPickFolder: () => ipcRenderer.invoke('cf:pickFolder'),
|
||||
mrPickFolder: () => ipcRenderer.invoke('mr:pickFolder'),
|
||||
mrScan: (folder) => ipcRenderer.invoke('mr:scan', folder),
|
||||
mrImport: (entries, copyData) => ipcRenderer.invoke('mr:import', entries, copyData),
|
||||
onMrProgress: (cb) => {
|
||||
const l = (_e, p) => cb(p);
|
||||
ipcRenderer.on('mr:progress', l);
|
||||
return () => ipcRenderer.removeListener('mr:progress', l);
|
||||
},
|
||||
cfScan: (folder) => ipcRenderer.invoke('cf:scan', folder),
|
||||
cfImport: (entries, copyData) => ipcRenderer.invoke('cf:import', entries, copyData),
|
||||
onCfProgress: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('cf:progress', l);
|
||||
return () => ipcRenderer.removeListener('cf:progress', l);
|
||||
},
|
||||
mcPickFolder: () => ipcRenderer.invoke('mc:pickFolder'),
|
||||
mcScan: (folder) => ipcRenderer.invoke('mc:scan', folder),
|
||||
mcImport: (entries, copyData) => ipcRenderer.invoke('mc:import', entries, copyData),
|
||||
onMcProgress: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('mc:progress', l);
|
||||
return () => ipcRenderer.removeListener('mc:progress', l);
|
||||
},
|
||||
technicPickFolder: () => ipcRenderer.invoke('technic:pickFolder'),
|
||||
technicScan: (folder) => ipcRenderer.invoke('technic:scan', folder),
|
||||
technicImport: (entries, copyData) => ipcRenderer.invoke('technic:import', entries, copyData),
|
||||
onTechnicProgress: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('technic:progress', l);
|
||||
return () => ipcRenderer.removeListener('technic:progress', l);
|
||||
},
|
||||
technicPlatformSearch: (query) => ipcRenderer.invoke('technicPlatform:search', query),
|
||||
technicPlatformInstall: (slug) => ipcRenderer.invoke('technicPlatform:install', slug),
|
||||
onTechnicPlatformProgress: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('technicPlatform:progress', l);
|
||||
return () => ipcRenderer.removeListener('technicPlatform:progress', l);
|
||||
},
|
||||
|
||||
// Bedrock (Windows-Store-Ausgabe)
|
||||
bedrockDetect: () => ipcRenderer.invoke('bedrock:detect'),
|
||||
bedrockLaunch: () => ipcRenderer.invoke('bedrock:launch'),
|
||||
bedrockWorlds: () => ipcRenderer.invoke('bedrock:worlds'),
|
||||
bedrockList: (kind) => ipcRenderer.invoke('bedrock:list', kind),
|
||||
bedrockRemove: (kind, name) => ipcRenderer.invoke('bedrock:remove', kind, name),
|
||||
bedrockOpenFolder: (kind) => ipcRenderer.invoke('bedrock:openFolder', kind),
|
||||
bedrockInstallAddon: () => ipcRenderer.invoke('bedrock:installAddon'),
|
||||
bedrockOpenWorld: (dir) => ipcRenderer.invoke('bedrock:openWorld', dir),
|
||||
|
||||
// echte Minecraft-Icons
|
||||
iconsList: () => ipcRenderer.invoke('icons:list'),
|
||||
iconsExtract: () => ipcRenderer.invoke('icons:extract'),
|
||||
|
||||
// Updates
|
||||
updateCheck: () => ipcRenderer.invoke('update:check'),
|
||||
updateLastCheck: () => ipcRenderer.invoke('update:lastCheck'),
|
||||
updateInstall: (asset) => ipcRenderer.invoke('update:install', asset),
|
||||
onUpdateAvailable: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('update:available', l);
|
||||
return () => ipcRenderer.removeListener('update:available', l);
|
||||
},
|
||||
// Ergebnis des automatischen Checks beim Start – auch wenn kein Update
|
||||
// verfügbar ist (im Unterschied zu onUpdateAvailable, das nur bei
|
||||
// verfügbarem Update feuert)
|
||||
onUpdateChecked: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('update:checked', l);
|
||||
return () => ipcRenderer.removeListener('update:checked', l);
|
||||
},
|
||||
onUpdateProgress: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('update:progress', l);
|
||||
return () => ipcRenderer.removeListener('update:progress', l);
|
||||
},
|
||||
|
||||
openExternal: (url) => ipcRenderer.invoke('app:openExternal', url),
|
||||
pickFolder: (title) => ipcRenderer.invoke('dialog:pickFolder', title),
|
||||
pickJava: () => ipcRenderer.invoke('dialog:pickJava'),
|
||||
appInfo: () => ipcRenderer.invoke('app:info'),
|
||||
appLicense: () => ipcRenderer.invoke('app:license'),
|
||||
|
||||
// Einstellungen/Konsole als eigenes, frei verschiebbares Fenster
|
||||
popoutOpen: (modalId, params) => ipcRenderer.send('popout:open', modalId, params, frischerKlick()),
|
||||
popoutHide: () => ipcRenderer.send('popout:hide'),
|
||||
setTitleBarDesign: (design) => ipcRenderer.send('window:titleBar', design),
|
||||
onPopoutReopen: (cb) => {
|
||||
const l = (_e, params) => cb(params);
|
||||
ipcRenderer.on('popout:reopen', l);
|
||||
return () => ipcRenderer.removeListener('popout:reopen', l);
|
||||
},
|
||||
onSettingsChanged: (cb) => {
|
||||
const l = (_e, s) => cb(s);
|
||||
ipcRenderer.on('settings:changed', l);
|
||||
return () => ipcRenderer.removeListener('settings:changed', l);
|
||||
},
|
||||
onInstancesChanged: (cb) => {
|
||||
const l = () => cb();
|
||||
ipcRenderer.on('instances:changed', l);
|
||||
return () => ipcRenderer.removeListener('instances:changed', l);
|
||||
},
|
||||
onSkinChanged: (cb) => {
|
||||
const l = (_e, info) => cb(info || {});
|
||||
ipcRenderer.on('skin:changed', l);
|
||||
return () => ipcRenderer.removeListener('skin:changed', l);
|
||||
},
|
||||
updateGetPending: () => ipcRenderer.invoke('update:getPending'),
|
||||
|
||||
// Spielaufzeichnung
|
||||
recordingListWindows: () => ipcRenderer.invoke('recording:listWindows'),
|
||||
recordingGetAudioSourceId: () => ipcRenderer.invoke('recording:getAudioSourceId'),
|
||||
recordingSave: (instanceName, buffer, serverLabel, fps) => ipcRenderer.invoke('recording:save', instanceName, buffer, serverLabel, fps),
|
||||
recordingOpenFolder: (instanceName) => ipcRenderer.invoke('recording:openFolder', instanceName),
|
||||
recordingList: (instanceName) => ipcRenderer.invoke('recording:list', instanceName),
|
||||
recordingThumbnail: (instanceName, dateiname) => ipcRenderer.invoke('recording:thumbnail', instanceName, dateiname),
|
||||
recordingDelete: (instanceName, dateiname) => ipcRenderer.invoke('recording:delete', instanceName, dateiname),
|
||||
recordingOpen: (instanceName, dateiname) => ipcRenderer.invoke('recording:open', instanceName, dateiname),
|
||||
|
||||
// Freundesystem
|
||||
friendsEnabled: () => ipcRenderer.invoke('friends:enabled'),
|
||||
friendsList: () => ipcRenderer.invoke('friends:list'),
|
||||
friendsRequests: () => ipcRenderer.invoke('friends:requests'),
|
||||
friendsAdd: (targetUsername) => ipcRenderer.invoke('friends:add', targetUsername),
|
||||
friendsRespond: (otherUuid, accept) => ipcRenderer.invoke('friends:respond', otherUuid, accept),
|
||||
friendsRemove: (otherUuid) => ipcRenderer.invoke('friends:remove', otherUuid),
|
||||
friendsSentRequests: () => ipcRenderer.invoke('friends:sentRequests'),
|
||||
friendsCancel: (otherUuid) => ipcRenderer.invoke('friends:cancel', otherUuid),
|
||||
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);
|
||||
},
|
||||
});
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* preload.js – stellt dem Renderer eine schmale, sichere API bereit.
|
||||
* contextIsolation ist aktiv; der Renderer hat KEINEN direkten Node-Zugriff.
|
||||
*/
|
||||
|
||||
const { contextBridge, ipcRenderer, webUtils } = require('electron');
|
||||
|
||||
// Debug-Hilfe: JS-Fehler im Renderer landen im Terminal des Hauptprozesses,
|
||||
// damit man sie auch ohne offene DevTools sieht
|
||||
window.addEventListener('error', (e) => {
|
||||
ipcRenderer.send('debug:rendererError', (e.error && e.error.stack) || e.message);
|
||||
});
|
||||
window.addEventListener('unhandledrejection', (e) => {
|
||||
ipcRenderer.send('debug:rendererError', 'unhandledrejection: ' + ((e.reason && e.reason.stack) || e.reason));
|
||||
});
|
||||
|
||||
// Zuletzt angeklickter Knopf: Popup-Fenster wachsen daraus hervor
|
||||
// (main.js → openPopout). Gilt nur kurz, sonst kämen sie aus einem alten Klick.
|
||||
let letzterKlick = null;
|
||||
window.addEventListener('pointerdown', (e) => {
|
||||
const t = e.target && e.target.closest ? (e.target.closest('button, a, [role="button"], .card, .snav') || e.target) : null;
|
||||
if (!t || !t.getBoundingClientRect) return;
|
||||
const r = t.getBoundingClientRect();
|
||||
letzterKlick = { x: r.left, y: r.top, width: r.width, height: r.height, zeit: Date.now() };
|
||||
}, true);
|
||||
function frischerKlick() {
|
||||
if (!letzterKlick || Date.now() - letzterKlick.zeit > 2000) return null;
|
||||
const { x, y, width, height } = letzterKlick;
|
||||
return { x, y, width, height };
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld('api', {
|
||||
// Einstellungen
|
||||
getSettings: () => ipcRenderer.invoke('settings:get'),
|
||||
saveSettings: (patch) => ipcRenderer.invoke('settings:save', patch),
|
||||
getInstancesDir: () => ipcRenderer.invoke('settings:instancesDir'),
|
||||
detectJava: (javaPath) => ipcRenderer.invoke('java:detect', javaPath),
|
||||
scanJava: () => ipcRenderer.invoke('java:scan'),
|
||||
listJava: () => ipcRenderer.invoke('java:list'),
|
||||
|
||||
// Minecraft-Versionen (Mojang)
|
||||
mcVersions: () => ipcRenderer.invoke('mc:versions'),
|
||||
loaderVersions: (loader, mcVersion) => ipcRenderer.invoke('loaders:versions', loader, mcVersion),
|
||||
optifineFind: (mcVersion) => ipcRenderer.invoke('optifine:find', mcVersion),
|
||||
optifineSetup: (id) => ipcRenderer.invoke('optifine:setup', id),
|
||||
optifineConfirm: (id) => ipcRenderer.invoke('optifine:confirm', id),
|
||||
|
||||
// Mods (Modrinth)
|
||||
modSearch: (opts) => ipcRenderer.invoke('mods:search', opts),
|
||||
curseforgeTestKey: (apiKey) => ipcRenderer.invoke('curseforge:testKey', apiKey),
|
||||
// art: 'mod' | 'resourcepack' | 'shader'
|
||||
modList: (id, art) => ipcRenderer.invoke('mods:list', id, art),
|
||||
modInstall: (id, project, art) => ipcRenderer.invoke('mods:install', id, project, art),
|
||||
modRemove: (id, filename, art) => ipcRenderer.invoke('mods:remove', id, filename, art),
|
||||
modDependents: (id, filename, art) => ipcRenderer.invoke('mods:dependents', id, filename, art),
|
||||
modAddFile: (id, art) => ipcRenderer.invoke('mods:addFile', id, art),
|
||||
modSetEnabled: (id, filename, enabled, art) => ipcRenderer.invoke('mods:setEnabled', id, filename, enabled, art),
|
||||
systemMemory: () => ipcRenderer.invoke('system:memory'),
|
||||
checkMemory: (minMb, maxMb) => ipcRenderer.invoke('system:checkMemory', minMb, maxMb),
|
||||
crashLatest: (id, seit) => ipcRenderer.invoke('crash:latest', id, seit),
|
||||
crashAnalyze: (id, seit) => ipcRenderer.invoke('crash:analyze', id, seit),
|
||||
crashFix: (id, aktion) => ipcRenderer.invoke('crash:fix', id, aktion),
|
||||
crashRelaunch: (id) => ipcRenderer.invoke('crash:relaunch', id),
|
||||
shareCreate: (id) => ipcRenderer.invoke('share:create', id),
|
||||
shareFetch: (code) => ipcRenderer.invoke('share:fetch', code),
|
||||
shareInstall: (code) => ipcRenderer.invoke('share:install', code),
|
||||
onShareProgress: (cb) => {
|
||||
const l = (_e, p) => cb(p);
|
||||
ipcRenderer.on('share:progress', l);
|
||||
return () => ipcRenderer.removeListener('share:progress', l);
|
||||
},
|
||||
onAutoBackup: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('backup:auto', l);
|
||||
return () => ipcRenderer.removeListener('backup:auto', l);
|
||||
},
|
||||
crashOpenFolder: (id) => ipcRenderer.invoke('crash:openFolder', id),
|
||||
serversList: (id) => ipcRenderer.invoke('servers:list', id),
|
||||
serversAdd: (id, eintrag) => ipcRenderer.invoke('servers:add', id, eintrag),
|
||||
serversRemove: (id, ip) => ipcRenderer.invoke('servers:remove', id, ip),
|
||||
serversRename: (id, ip, neuerName) => ipcRenderer.invoke('servers:rename', id, ip, neuerName),
|
||||
serversCopyTo: (zielIds, eintrag) => ipcRenderer.invoke('servers:copyTo', zielIds, eintrag),
|
||||
serversPing: (ip) => ipcRenderer.invoke('servers:ping', ip),
|
||||
logsList: (id) => ipcRenderer.invoke('logs:list', id),
|
||||
logsRead: (id, name) => ipcRenderer.invoke('logs:read', id, name),
|
||||
logsOpenFolder: (id) => ipcRenderer.invoke('logs:openFolder', id),
|
||||
modCheckUpdates: (id, art) => ipcRenderer.invoke('mods:checkUpdates', id, art),
|
||||
modApplyUpdates: (id, projectIds, art) => ipcRenderer.invoke('mods:applyUpdates', id, projectIds, art),
|
||||
onModUpdateProgress: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('mods:updateProgress', l);
|
||||
return () => ipcRenderer.removeListener('mods:updateProgress', l);
|
||||
},
|
||||
|
||||
// Instanzen
|
||||
listInstances: () => ipcRenderer.invoke('instances:list'),
|
||||
getInstance: (id) => ipcRenderer.invoke('instances:get', id),
|
||||
createInstance: (data) => ipcRenderer.invoke('instances:create', data),
|
||||
installPerformancePack: (id) => ipcRenderer.invoke('instances:installPerformancePack', id),
|
||||
updateInstance: (id, patch) => ipcRenderer.invoke('instances:update', id, patch),
|
||||
deleteInstance: (id) => ipcRenderer.invoke('instances:delete', id),
|
||||
duplicateInstance: (id) => ipcRenderer.invoke('instances:duplicate', id),
|
||||
openInstanceFolder: (id) => ipcRenderer.invoke('instances:openFolder', id),
|
||||
openGameDir: (id) => ipcRenderer.invoke('instances:openGameDir', id),
|
||||
|
||||
// Welten & Ressourcenpakete
|
||||
worlds: (id) => ipcRenderer.invoke('instance:worlds', id),
|
||||
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),
|
||||
backupWorld: (id, worldName) => ipcRenderer.invoke('world:backup', id, worldName),
|
||||
worldCopyTo: (id, worldName, zielId) => ipcRenderer.invoke('world:copyTo', id, worldName, zielId),
|
||||
friendsSendScreenshot: (otherUuid, instanceId, datei, text) => ipcRenderer.invoke('friends:sendScreenshot', otherUuid, instanceId, datei, text),
|
||||
openSubdir: (id, sub) => ipcRenderer.invoke('instance:openSubdir', id, sub),
|
||||
screenshots: (id) => ipcRenderer.invoke('instance:screenshots', id),
|
||||
screenshotData: (id, name) => ipcRenderer.invoke('instance:screenshotData', id, name),
|
||||
openScreenshot: (id, name) => ipcRenderer.invoke('instance:openScreenshot', id, name),
|
||||
launchInstance: (id, accountId, opts) => ipcRenderer.invoke('instances:launch', id, accountId, opts),
|
||||
exportInstance: (id) => ipcRenderer.invoke('instances:export', id),
|
||||
integritaetWiederherstellen: (id) => ipcRenderer.invoke('integritaet:wiederherstellen', id),
|
||||
// Pfad einer per Drag & Drop abgelegten Datei (File.path gibt es seit Electron 32 nicht mehr)
|
||||
pfadVonDatei: (datei) => { try { return webUtils.getPathForFile(datei) || ''; } catch { return ''; } },
|
||||
createGlobalBackup: () => ipcRenderer.invoke('backup:global'),
|
||||
listGlobalBackups: () => ipcRenderer.invoke('backup:list'),
|
||||
restoreGlobalBackup: (sourcePath) => ipcRenderer.invoke('backup:restore', sourcePath),
|
||||
onBackupProgress: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('backup:progress', l);
|
||||
return () => ipcRenderer.removeListener('backup:progress', l);
|
||||
},
|
||||
importInstance: () => ipcRenderer.invoke('instances:import'),
|
||||
createShortcut: (id, customName) => ipcRenderer.invoke('instances:shortcut', id, customName),
|
||||
serversShortcut: (id, ip, name, customName) => ipcRenderer.invoke('servers:shortcut', id, ip, name, customName),
|
||||
// Start über eine Desktop-Verknüpfung ("--launch=<id>", optional "--server=<ip>")
|
||||
onLaunchRequest: (cb) => {
|
||||
const l = (_e, payload) => cb(payload);
|
||||
ipcRenderer.on('instance:launch-request', l);
|
||||
return () => ipcRenderer.removeListener('instance:launch-request', l);
|
||||
},
|
||||
// Signalisiert dem Hauptprozess, dass onLaunchRequest jetzt registriert ist –
|
||||
// verhindert, dass ein Start-Wunsch aus einer Verknüpfung verloren geht,
|
||||
// weil er ankommt, bevor die Oberfläche fertig eingerichtet ist
|
||||
notifyReady: () => ipcRenderer.send('renderer:ready'),
|
||||
importModpack: () => ipcRenderer.invoke('modpack:import'),
|
||||
// Datei per Drag&Drop aufs Fenster gezogen (.jar/.mrpack/.zip)
|
||||
filesDrop: (filePath, targetId, targetArt) => ipcRenderer.invoke('files:drop', filePath, targetId, targetArt),
|
||||
modpackSearch: (opts) => ipcRenderer.invoke('modpack:search', opts),
|
||||
modpackInstallFromModrinth: (project) => ipcRenderer.invoke('modpack:installFromModrinth', project),
|
||||
modpackInstallFromCurseForge: (project) => ipcRenderer.invoke('modpack:installFromCurseForge', project),
|
||||
modpackCheck: (id) => ipcRenderer.invoke('modpack:check', id),
|
||||
modpackUpdate: (id, versionId) => ipcRenderer.invoke('modpack:update', id, versionId),
|
||||
onLaunchProgress: (cb) => {
|
||||
const listener = (_e, data) => cb(data);
|
||||
ipcRenderer.on('launch:progress', listener);
|
||||
return () => ipcRenderer.removeListener('launch:progress', listener);
|
||||
},
|
||||
|
||||
// eigenes Instanz-Bild
|
||||
pickImage: () => ipcRenderer.invoke('dialog:pickImage'),
|
||||
setInstanceImage: (id, sourcePath) => ipcRenderer.invoke('instances:setImage', id, sourcePath),
|
||||
clearInstanceImage: (id) => ipcRenderer.invoke('instances:clearImage', id),
|
||||
instanceIcon: (id) => ipcRenderer.invoke('instances:icon', id),
|
||||
fileDataUri: (p) => ipcRenderer.invoke('file:dataUri', p),
|
||||
|
||||
// Microsoft-Login (Phase 3) + Mehrkonten
|
||||
authStatus: () => ipcRenderer.invoke('auth:status'),
|
||||
authAccounts: () => ipcRenderer.invoke('auth:accounts'),
|
||||
authSetActive: (id) => ipcRenderer.invoke('auth:setActive', id),
|
||||
skinProfile: () => ipcRenderer.invoke('skin:profile'),
|
||||
skinArmformPruefen: () => ipcRenderer.invoke('skin:armformPruefen'),
|
||||
garderobeListe: () => ipcRenderer.invoke('garderobe:liste'),
|
||||
erfolgeListe: () => ipcRenderer.invoke('erfolge:liste'),
|
||||
laufendeInstanzen: () => ipcRenderer.invoke('game:laufend'),
|
||||
modschalterDaten: (id) => ipcRenderer.invoke('modschalter:daten', id),
|
||||
modschalterNeustart: (id) => ipcRenderer.invoke('modschalter:neustart', id),
|
||||
modschalterSchliessen: () => ipcRenderer.send('modschalter:schliessen'),
|
||||
onModschalterNeustarten: (cb) => {
|
||||
const l = (_e, d) => cb(d || {});
|
||||
ipcRenderer.on('modschalter:neustarten', l);
|
||||
return () => ipcRenderer.removeListener('modschalter:neustarten', l);
|
||||
},
|
||||
onLaufendeInstanzen: (cb) => {
|
||||
const l = (_e, d) => cb(d || []);
|
||||
ipcRenderer.on('game:laufend', l);
|
||||
return () => ipcRenderer.removeListener('game:laufend', l);
|
||||
},
|
||||
onErfolgeNeu: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('erfolge:neu', l);
|
||||
return () => ipcRenderer.removeListener('erfolge:neu', l);
|
||||
},
|
||||
modsLeistungsPaket: (id) => ipcRenderer.invoke('mods:leistungsPaket', id),
|
||||
onPaketFortschritt: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('mods:paketFortschritt', l);
|
||||
return () => ipcRenderer.removeListener('mods:paketFortschritt', l);
|
||||
},
|
||||
garderobeSkinSpeichern: (name) => ipcRenderer.invoke('garderobe:skinSpeichern', name),
|
||||
garderobeUmhangSpeichern: (name) => ipcRenderer.invoke('garderobe:umhangSpeichern', name),
|
||||
garderobeAnziehen: (id) => ipcRenderer.invoke('garderobe:anziehen', id),
|
||||
garderobeUmbenennen: (id, name) => ipcRenderer.invoke('garderobe:umbenennen', id, name),
|
||||
garderobeLoeschen: (id) => ipcRenderer.invoke('garderobe:loeschen', id),
|
||||
skinArmformKorrigieren: () => ipcRenderer.invoke('skin:armformKorrigieren'),
|
||||
skinChoose: () => ipcRenderer.invoke('skin:choose'),
|
||||
skinApply: (pfad, variante) => ipcRenderer.invoke('skin:apply', pfad, variante),
|
||||
skinReset: () => ipcRenderer.invoke('skin:reset'),
|
||||
skinCurrentData: () => ipcRenderer.invoke('skin:currentData'),
|
||||
skinModelFor: (uuid) => ipcRenderer.invoke('skin:modelFor', uuid),
|
||||
skinReadData: (pfad) => ipcRenderer.invoke('skin:readData', pfad),
|
||||
skinSaveData: (base64) => ipcRenderer.invoke('skin:saveData', base64),
|
||||
skinApplyData: (base64, variante) => ipcRenderer.invoke('skin:applyData', base64, variante),
|
||||
capeInfo: () => ipcRenderer.invoke('cape:info'),
|
||||
capeForUuid: (uuid) => ipcRenderer.invoke('cape:forUuid', uuid),
|
||||
capeSetOfficial: (id) => ipcRenderer.invoke('cape:setOfficial', id),
|
||||
capeUpload: (base64) => ipcRenderer.invoke('cape:upload', base64),
|
||||
capeRemove: () => ipcRenderer.invoke('cape:remove'),
|
||||
capeCurrentData: () => ipcRenderer.invoke('cape:currentData'),
|
||||
capeChooseFile: (art) => ipcRenderer.invoke('cape:chooseFile', art),
|
||||
capeSaveData: (base64) => ipcRenderer.invoke('cape:saveData', base64),
|
||||
authRemove: (id) => ipcRenderer.invoke('auth:remove', id),
|
||||
authLogin: () => ipcRenderer.invoke('auth:login'),
|
||||
authLogout: () => ipcRenderer.invoke('auth:logout'),
|
||||
authHealth: () => ipcRenderer.invoke('auth:health'),
|
||||
authRecheck: () => ipcRenderer.invoke('auth:recheck'),
|
||||
onAuthHealth: (cb) => {
|
||||
const listener = (_e, data) => cb(data);
|
||||
ipcRenderer.on('auth:health', listener);
|
||||
return () => ipcRenderer.removeListener('auth:health', listener);
|
||||
},
|
||||
// meldet sich, wenn ein Spiel startet oder endet -> Kontenliste auffrischen
|
||||
onAccountsChanged: (cb) => {
|
||||
const listener = () => cb();
|
||||
ipcRenderer.on('accounts:changed', listener);
|
||||
return () => ipcRenderer.removeListener('accounts:changed', listener);
|
||||
},
|
||||
onAuthRefreshed: (cb) => {
|
||||
const listener = () => cb();
|
||||
ipcRenderer.on('auth:refreshed', listener);
|
||||
return () => ipcRenderer.removeListener('auth:refreshed', listener);
|
||||
},
|
||||
onAuthCode: (cb) => {
|
||||
const listener = (_e, data) => cb(data);
|
||||
ipcRenderer.on('auth:code', listener);
|
||||
return () => ipcRenderer.removeListener('auth:code', listener);
|
||||
},
|
||||
|
||||
// Dialoge / App
|
||||
// MultiMC-Import
|
||||
// gemeinsamer Fortschritt aller Launcher-Importe (main.js → fuehreImportAus)
|
||||
onImportFortschritt: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('import:fortschritt', l);
|
||||
return () => ipcRenderer.removeListener('import:fortschritt', l);
|
||||
},
|
||||
importAbbrechen: () => ipcRenderer.invoke('import:abbrechen'),
|
||||
mmcPickFolder: () => ipcRenderer.invoke('mmc:pickFolder'),
|
||||
mmcScan: (folder) => ipcRenderer.invoke('mmc:scan', folder),
|
||||
mmcImport: (entries, copyData) => ipcRenderer.invoke('mmc:import', entries, copyData),
|
||||
onMmcProgress: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('mmc:progress', l);
|
||||
return () => ipcRenderer.removeListener('mmc:progress', l);
|
||||
},
|
||||
cfPickFolder: () => ipcRenderer.invoke('cf:pickFolder'),
|
||||
mrPickFolder: () => ipcRenderer.invoke('mr:pickFolder'),
|
||||
mrScan: (folder) => ipcRenderer.invoke('mr:scan', folder),
|
||||
mrImport: (entries, copyData) => ipcRenderer.invoke('mr:import', entries, copyData),
|
||||
onMrProgress: (cb) => {
|
||||
const l = (_e, p) => cb(p);
|
||||
ipcRenderer.on('mr:progress', l);
|
||||
return () => ipcRenderer.removeListener('mr:progress', l);
|
||||
},
|
||||
cfScan: (folder) => ipcRenderer.invoke('cf:scan', folder),
|
||||
cfImport: (entries, copyData) => ipcRenderer.invoke('cf:import', entries, copyData),
|
||||
onCfProgress: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('cf:progress', l);
|
||||
return () => ipcRenderer.removeListener('cf:progress', l);
|
||||
},
|
||||
mcPickFolder: () => ipcRenderer.invoke('mc:pickFolder'),
|
||||
mcScan: (folder) => ipcRenderer.invoke('mc:scan', folder),
|
||||
mcImport: (entries, copyData) => ipcRenderer.invoke('mc:import', entries, copyData),
|
||||
onMcProgress: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('mc:progress', l);
|
||||
return () => ipcRenderer.removeListener('mc:progress', l);
|
||||
},
|
||||
technicPickFolder: () => ipcRenderer.invoke('technic:pickFolder'),
|
||||
technicScan: (folder) => ipcRenderer.invoke('technic:scan', folder),
|
||||
technicImport: (entries, copyData) => ipcRenderer.invoke('technic:import', entries, copyData),
|
||||
onTechnicProgress: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('technic:progress', l);
|
||||
return () => ipcRenderer.removeListener('technic:progress', l);
|
||||
},
|
||||
technicPlatformSearch: (query) => ipcRenderer.invoke('technicPlatform:search', query),
|
||||
technicPlatformInstall: (slug) => ipcRenderer.invoke('technicPlatform:install', slug),
|
||||
onTechnicPlatformProgress: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('technicPlatform:progress', l);
|
||||
return () => ipcRenderer.removeListener('technicPlatform:progress', l);
|
||||
},
|
||||
|
||||
// Bedrock (Windows-Store-Ausgabe)
|
||||
bedrockDetect: () => ipcRenderer.invoke('bedrock:detect'),
|
||||
bedrockLaunch: () => ipcRenderer.invoke('bedrock:launch'),
|
||||
bedrockWorlds: () => ipcRenderer.invoke('bedrock:worlds'),
|
||||
bedrockList: (kind) => ipcRenderer.invoke('bedrock:list', kind),
|
||||
bedrockRemove: (kind, name) => ipcRenderer.invoke('bedrock:remove', kind, name),
|
||||
bedrockOpenFolder: (kind) => ipcRenderer.invoke('bedrock:openFolder', kind),
|
||||
bedrockInstallAddon: () => ipcRenderer.invoke('bedrock:installAddon'),
|
||||
bedrockOpenWorld: (dir) => ipcRenderer.invoke('bedrock:openWorld', dir),
|
||||
|
||||
// echte Minecraft-Icons
|
||||
iconsList: () => ipcRenderer.invoke('icons:list'),
|
||||
iconsExtract: () => ipcRenderer.invoke('icons:extract'),
|
||||
|
||||
// Updates
|
||||
updateCheck: () => ipcRenderer.invoke('update:check'),
|
||||
updateLastCheck: () => ipcRenderer.invoke('update:lastCheck'),
|
||||
updateInstall: () => ipcRenderer.invoke('update:install'),
|
||||
onUpdateAvailable: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('update:available', l);
|
||||
return () => ipcRenderer.removeListener('update:available', l);
|
||||
},
|
||||
// Ergebnis des automatischen Checks beim Start – auch wenn kein Update
|
||||
// verfügbar ist (im Unterschied zu onUpdateAvailable, das nur bei
|
||||
// verfügbarem Update feuert)
|
||||
onUpdateChecked: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('update:checked', l);
|
||||
return () => ipcRenderer.removeListener('update:checked', l);
|
||||
},
|
||||
onUpdateProgress: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('update:progress', l);
|
||||
return () => ipcRenderer.removeListener('update:progress', l);
|
||||
},
|
||||
|
||||
openExternal: (url) => ipcRenderer.invoke('app:openExternal', url),
|
||||
pickFolder: (title) => ipcRenderer.invoke('dialog:pickFolder', title),
|
||||
pickJava: () => ipcRenderer.invoke('dialog:pickJava'),
|
||||
appInfo: () => ipcRenderer.invoke('app:info'),
|
||||
appLicense: () => ipcRenderer.invoke('app:license'),
|
||||
|
||||
// Einstellungen/Konsole als eigenes, frei verschiebbares Fenster
|
||||
popoutOpen: (modalId, params) => ipcRenderer.send('popout:open', modalId, params, frischerKlick()),
|
||||
popoutHide: () => ipcRenderer.send('popout:hide'),
|
||||
setTitleBarDesign: (design) => ipcRenderer.send('window:titleBar', design),
|
||||
onPopoutReopen: (cb) => {
|
||||
const l = (_e, params) => cb(params);
|
||||
ipcRenderer.on('popout:reopen', l);
|
||||
return () => ipcRenderer.removeListener('popout:reopen', l);
|
||||
},
|
||||
onSettingsChanged: (cb) => {
|
||||
const l = (_e, s) => cb(s);
|
||||
ipcRenderer.on('settings:changed', l);
|
||||
return () => ipcRenderer.removeListener('settings:changed', l);
|
||||
},
|
||||
onInstancesChanged: (cb) => {
|
||||
const l = () => cb();
|
||||
ipcRenderer.on('instances:changed', l);
|
||||
return () => ipcRenderer.removeListener('instances:changed', l);
|
||||
},
|
||||
onSkinChanged: (cb) => {
|
||||
const l = (_e, info) => cb(info || {});
|
||||
ipcRenderer.on('skin:changed', l);
|
||||
return () => ipcRenderer.removeListener('skin:changed', l);
|
||||
},
|
||||
updateGetPending: () => ipcRenderer.invoke('update:getPending'),
|
||||
|
||||
// Spielaufzeichnung
|
||||
recordingListWindows: () => ipcRenderer.invoke('recording:listWindows'),
|
||||
recordingGetAudioSourceId: () => ipcRenderer.invoke('recording:getAudioSourceId'),
|
||||
recordingSave: (instanceName, buffer, serverLabel, fps) => ipcRenderer.invoke('recording:save', instanceName, buffer, serverLabel, fps),
|
||||
recordingOpenFolder: (instanceName) => ipcRenderer.invoke('recording:openFolder', instanceName),
|
||||
recordingList: (instanceName) => ipcRenderer.invoke('recording:list', instanceName),
|
||||
recordingThumbnail: (instanceName, dateiname) => ipcRenderer.invoke('recording:thumbnail', instanceName, dateiname),
|
||||
recordingDelete: (instanceName, dateiname) => ipcRenderer.invoke('recording:delete', instanceName, dateiname),
|
||||
recordingOpen: (instanceName, dateiname) => ipcRenderer.invoke('recording:open', instanceName, dateiname),
|
||||
|
||||
// Freundesystem
|
||||
friendsEnabled: () => ipcRenderer.invoke('friends:enabled'),
|
||||
friendsList: () => ipcRenderer.invoke('friends:list'),
|
||||
friendsRequests: () => ipcRenderer.invoke('friends:requests'),
|
||||
friendsAdd: (targetUsername) => ipcRenderer.invoke('friends:add', targetUsername),
|
||||
friendsRespond: (otherUuid, accept) => ipcRenderer.invoke('friends:respond', otherUuid, accept),
|
||||
friendsRemove: (otherUuid) => ipcRenderer.invoke('friends:remove', otherUuid),
|
||||
friendsSentRequests: () => ipcRenderer.invoke('friends:sentRequests'),
|
||||
friendsCancel: (otherUuid) => ipcRenderer.invoke('friends:cancel', otherUuid),
|
||||
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);
|
||||
},
|
||||
onStreamdeckAktion: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('streamdeck:aktion', l);
|
||||
return () => ipcRenderer.removeListener('streamdeck:aktion', l);
|
||||
},
|
||||
streamdeckInstalliere: () => ipcRenderer.invoke('streamdeck:installPlugin'),
|
||||
streamdeckMeldung: (titel, text, fehler) => ipcRenderer.send('streamdeck:meldung', titel, text, !!fehler),
|
||||
streamdeckFensterZeigen: () => ipcRenderer.send('streamdeck:fensterZeigen'),
|
||||
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, instanceId) => ipcRenderer.send('recording:guard', active, instanceId),
|
||||
onRecordCloseAttempt: (cb) => {
|
||||
const l = () => cb();
|
||||
ipcRenderer.on('record:closeAttempt', l);
|
||||
return () => ipcRenderer.removeListener('record:closeAttempt', l);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -508,13 +508,13 @@ const el = (id) => document.getElementById(id);
|
||||
function show(id) { el(id).classList.remove('hidden'); }
|
||||
function hide(id) { el(id).classList.add('hidden'); }
|
||||
|
||||
function toast(msg, isError = false) {
|
||||
function toast(msg, isError = false, dauerMs = 2800) {
|
||||
const t = el('toast');
|
||||
t.textContent = msg;
|
||||
t.classList.toggle('err', isError);
|
||||
t.classList.remove('hidden');
|
||||
clearTimeout(toast._t);
|
||||
toast._t = setTimeout(() => t.classList.add('hidden'), 2800);
|
||||
toast._t = setTimeout(() => t.classList.add('hidden'), dauerMs);
|
||||
}
|
||||
|
||||
function fmtDate(iso) {
|
||||
@@ -778,6 +778,7 @@ async function runModpackImport() {
|
||||
toast(`Modpack importiert: ${res.instance.name} (${res.fileCount} Dateien)`);
|
||||
// CurseForge gibt nicht jede Datei heraus – ehrlich benennen statt verschweigen
|
||||
if (res.fehlend) toast(`${res.fehlend} Datei(en) konnten nicht geladen werden und fehlen in der Instanz.`, true);
|
||||
if (res.gesperrt) toast(`${res.gesperrt} Mod(s) dürfen laut Autor nicht über Launcher geladen werden. Liste mit Links: GESPERRTE-MODS.txt im Spielordner der Instanz.`, true, 10000);
|
||||
return;
|
||||
}
|
||||
toast(res.message || 'Modpack-Import fehlgeschlagen.', true);
|
||||
@@ -1702,8 +1703,12 @@ function showUpdateDialog(info) {
|
||||
el('upd-install').textContent = 'Installieren';
|
||||
el('upd-open').classList.toggle('hidden', hatPaket || !info.url);
|
||||
el('upd-hint').textContent = hatPaket
|
||||
? 'Der Launcher lädt das Update herunter und startet die Installation automatisch.'
|
||||
: (channel
|
||||
? 'Der Launcher lädt das Update herunter, prüft die Signatur und startet die Installation automatisch.'
|
||||
: info.signaturHinweis
|
||||
? info.signaturHinweis
|
||||
: info.unsigniert
|
||||
? 'Dieses Release ist nicht signiert. Aus Sicherheitsgründen wird es nicht automatisch installiert – bitte nur von der offiziellen Seite laden.'
|
||||
: (channel
|
||||
? `Für den Kanal ${channel} liegt in diesem Release keine passende Datei bereit. Bitte im Release nachsehen und von Hand aktualisieren.`
|
||||
: 'Die Paketart dieses Launchers lässt sich nicht sicher bestimmen. Bitte das Update von Hand aus dem Release laden.');
|
||||
show('modal-update');
|
||||
@@ -1729,7 +1734,7 @@ async function startUpdate() {
|
||||
el('upd-progress').classList.remove('hidden');
|
||||
el('upd-status').textContent = 'Wird heruntergeladen …';
|
||||
|
||||
const res = await window.api.updateInstall(pendingUpdate.asset);
|
||||
const res = await window.api.updateInstall();
|
||||
if (res.ok) {
|
||||
el('upd-status').textContent = res.kind === 'portable'
|
||||
? 'Launcher schließt sich, wird ersetzt und startet automatisch neu.'
|
||||
@@ -2233,6 +2238,7 @@ async function refreshSelectionPanel() {
|
||||
|
||||
if (count > 1) {
|
||||
currentDetailId = null;
|
||||
zeigeSperre(null);
|
||||
setSingleSelectionActionsDisabled(true);
|
||||
el('p-delete').disabled = false;
|
||||
el('p-delete').textContent = `${count} Instanzen löschen`;
|
||||
@@ -2283,6 +2289,49 @@ async function refreshSelectionPanel() {
|
||||
el('status-left').textContent = inst.name + ' (' + (inst.minecraft.version || 'ohne Version') + loaderTxt + ')';
|
||||
el('status-play').textContent = 'Zuletzt gespielt: ' + (inst.lastPlayed ? fmtDate(inst.lastPlayed) : 'nie');
|
||||
el('status-total').textContent = 'Spielzeit: ' + fmtDuration(inst.totalPlaySeconds);
|
||||
zeigeSperre(inst);
|
||||
}
|
||||
|
||||
// Manipulierte Instanz (siehe integritaet.js im Hauptprozess): nicht startbar,
|
||||
// nicht kopier-/export-/teilbar – nur ansehen, wiederherstellen oder löschen
|
||||
const GESPERRT_AUS = ['p-play', 'p-offline', 'p-edit', 'p-mods', 'p-modpack', 'p-record', 'p-servers', 'p-shortcut', 'p-export', 'p-share', 'p-copy'];
|
||||
|
||||
function zeigeSperre(inst) {
|
||||
const sperre = inst && inst.gesperrt && inst.gesperrt.gesperrt ? inst.gesperrt : null;
|
||||
el('p-gesperrt').classList.toggle('hidden', !sperre);
|
||||
el('p-icon').classList.toggle('gesperrt', !!sperre);
|
||||
['p-play', 'p-offline'].forEach((id) => el(id).classList.toggle('hidden', !!sperre));
|
||||
el('p-share').disabled = false;
|
||||
el('p-record').disabled = false;
|
||||
if (!sperre) return;
|
||||
GESPERRT_AUS.forEach((id) => { el(id).disabled = true; });
|
||||
if (!el('p-icon').querySelector('.side-verbot')) {
|
||||
el('p-icon').insertAdjacentHTML('beforeend', '<img class="side-verbot" src="assets/verbot.svg" alt="" />');
|
||||
}
|
||||
const gruende = sperre.gruende || [];
|
||||
const liste = el('p-gesperrt-gruende');
|
||||
liste.innerHTML = gruende.slice(0, 5).map((g) => `<li>${escapeHtml(g)}</li>`).join('')
|
||||
+ (gruende.length > 5 ? `<li>… und ${gruende.length - 5} weitere</li>` : '')
|
||||
+ (sperre.seit ? `<li class="dim">Erkannt: ${escapeHtml(fmtDate(sperre.seit))}</li>` : '');
|
||||
el('status-play').textContent = 'Gesperrt – bitte aus einer Sicherung wiederherstellen';
|
||||
}
|
||||
|
||||
async function wiederherstellenAusSicherung() {
|
||||
const id = currentDetailId;
|
||||
if (!id) return;
|
||||
const knopf = el('p-wiederherstellen');
|
||||
knopf.disabled = true;
|
||||
knopf.textContent = 'Sicherungen werden geprüft …';
|
||||
try {
|
||||
const res = await window.api.integritaetWiederherstellen(id);
|
||||
if (!res.ok) { toast(res.message || 'Wiederherstellen fehlgeschlagen.', true); return; }
|
||||
toast(`Wiederhergestellt aus ${res.sicherung} als „${res.name}“. Die gesperrte Instanz kannst du jetzt löschen.`);
|
||||
await reload();
|
||||
await selectInstance(res.id);
|
||||
} finally {
|
||||
knopf.disabled = false;
|
||||
knopf.textContent = 'Aus Sicherung wiederherstellen';
|
||||
}
|
||||
}
|
||||
|
||||
async function applySelection(ids) {
|
||||
@@ -2313,13 +2362,17 @@ function buildCard(inst) {
|
||||
const taste = schnellstartTasteVon(inst.id);
|
||||
if (taste) tip += `\nSchnellstart: Strg+${taste}`;
|
||||
if (inst.notes && inst.notes.trim()) tip += '\n\n' + inst.notes.trim();
|
||||
const gesperrt = !!(inst.gesperrt && inst.gesperrt.gesperrt);
|
||||
if (gesperrt) tip = 'GESPERRT – außerhalb des Launchers verändert\n' + tip;
|
||||
card.title = tip;
|
||||
|
||||
card.innerHTML = `
|
||||
${iconHtml}
|
||||
<i class="card-riss" aria-hidden="true"></i>
|
||||
${gesperrt ? '<img class="card-verbot" src="assets/verbot.svg" alt="Gesperrt" />' : ''}
|
||||
<div class="card-name">${escapeHtml(inst.name)}</div>
|
||||
${taste ? `<span class="card-taste">Strg+${taste}</span>` : ''}`;
|
||||
if (gesperrt) card.classList.add('gesperrt');
|
||||
if (laufendeIds.has(inst.id)) card.classList.add('laeuft');
|
||||
if (startStand.has(inst.id)) zeigeAbbau(card);
|
||||
|
||||
@@ -2516,9 +2569,13 @@ function baueGruppenSektion(knoten, tiefe, showHeaders) {
|
||||
|
||||
// Drop-Ziel: Instanz per Drag&Drop in diese (Unter-)Gruppe verschieben –
|
||||
// stopPropagation, damit die innerste Gruppe gewinnt, nicht die äußere
|
||||
section.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); section.classList.add('drop-hover'); });
|
||||
// Dateien aus dem Explorer gehören nicht hierher, sondern zum Datei-Import
|
||||
// (wireFileDrop) – sonst würde die Gruppe sie verschlucken
|
||||
const istDateiZug = (e) => e.dataTransfer && [...e.dataTransfer.types].includes('Files');
|
||||
section.addEventListener('dragover', (e) => { if (istDateiZug(e)) return; e.preventDefault(); e.stopPropagation(); section.classList.add('drop-hover'); });
|
||||
section.addEventListener('dragleave', (e) => { if (!section.contains(e.relatedTarget)) section.classList.remove('drop-hover'); });
|
||||
section.addEventListener('drop', async (e) => {
|
||||
if (istDateiZug(e)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
section.classList.remove('drop-hover');
|
||||
@@ -2860,6 +2917,11 @@ let launching = false;
|
||||
|
||||
async function launch(id, accountId, launchOpts) {
|
||||
if (launching) return;
|
||||
const lokal = instances.find((i) => i.id === id);
|
||||
if (lokal && lokal.gesperrt && lokal.gesperrt.gesperrt) {
|
||||
toast('Diese Instanz ist gesperrt, weil sie verändert wurde – bitte aus einer Sicherung wiederherstellen.', true);
|
||||
return;
|
||||
}
|
||||
launching = true;
|
||||
status('Vorbereiten …');
|
||||
el('status-right').textContent = '';
|
||||
@@ -4039,12 +4101,15 @@ async function pollForMinecraftWindow() {
|
||||
} else if (sources.length > 1) {
|
||||
clearInterval(mcSearchTimer);
|
||||
showRecordingPicker(sources);
|
||||
// per Stream Deck gestartet: das Fenster ist unsichtbar, die Auswahl braucht aber dich
|
||||
if (sdAusstehend) { window.api.streamdeckFensterZeigen(); sdMeldung('Aufnahme', 'Bitte Spielfenster wählen'); }
|
||||
} else {
|
||||
mcSearchElapsed += 1000;
|
||||
if (mcSearchElapsed >= 30000) {
|
||||
clearInterval(mcSearchTimer);
|
||||
el('record-search-text').textContent = 'Kein Spielfenster gefunden. Läuft Minecraft und ist es nicht minimiert?';
|
||||
el('record-search-retry').classList.remove('hidden');
|
||||
if (sdAusstehend) { sdAusstehend = null; sdMeldung('Aufnahme', 'Kein Spielfenster gefunden', true); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4076,11 +4141,47 @@ async function selectRecordingSource(source) {
|
||||
await Promise.all([populateMicList(), checkGameAudioAvailability()]);
|
||||
updateAudioSummary();
|
||||
updateVideoSummary();
|
||||
if (sdAusstehend) { const art = sdAusstehend; sdAusstehend = null; fuehreSdAktionAus(art); }
|
||||
} catch (err) {
|
||||
hide('record-stage');
|
||||
show('record-search');
|
||||
el('record-search-text').textContent = 'Vorschau fehlgeschlagen: ' + err.message;
|
||||
el('record-search-retry').classList.remove('hidden');
|
||||
if (sdAusstehend) { sdAusstehend = null; sdMeldung('Aufnahme', 'Spielfenster nicht erfasst', true); }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Stream-Deck-Aktionen ----------
|
||||
// Nach erfolgreichem Start verschwindet das Fenster wieder (Clip-Modus und Aufnahme laufen im Hintergrund weiter);
|
||||
// bei einem Fehler bleibt es offen, damit man die Meldung sieht
|
||||
let sdAusstehend = null; // 'aufnahme' | 'clipmodus': nach der Fenstersuche noch auszuführen
|
||||
|
||||
function sdMeldung(titel, text, fehler) {
|
||||
window.api.streamdeckMeldung(titel, text, fehler);
|
||||
// Fehler zeigt der Hauptprozess im Hauptfenster - das Aufnahme-Fenster selbst hätte ohne offenes Modal nur eine leere Fläche
|
||||
|
||||
}
|
||||
|
||||
function sdFensterWegNachErfolg() { setTimeout(() => window.api.popoutHide(), 2000); }
|
||||
|
||||
async function fuehreSdAktionAus(art) {
|
||||
try {
|
||||
if (art === 'clipmodus') {
|
||||
if (clipAktiv) { stoppeClipModus(); sdMeldung('Clip-Modus', 'Aus'); }
|
||||
else if (recordingActive()) sdMeldung('Clip-Modus', 'Aufnahme läuft', true);
|
||||
else { await starteClipModus(); if (clipAktiv) { sdMeldung('Clip-Modus', 'An'); sdFensterWegNachErfolg(); } }
|
||||
} else if (recordingActive()) {
|
||||
sdMeldung('Aufnahme', 'Wird gespeichert …');
|
||||
await stopMcRecording();
|
||||
} else {
|
||||
// Aufnahme und Clip-Modus teilen sich Bildquelle und Ton -> läuft der Clip-Modus, wird er für die Aufnahme beendet
|
||||
const clipWarAn = clipAktiv;
|
||||
if (clipAktiv) stoppeClipModus();
|
||||
await startMcRecording();
|
||||
if (recordingActive()) { sdMeldung('Aufnahme', clipWarAn ? 'Läuft (Clip-Modus aus)' : 'Läuft'); sdFensterWegNachErfolg(); }
|
||||
}
|
||||
} catch (err) {
|
||||
sdMeldung(art === 'clipmodus' ? 'Clip-Modus' : 'Aufnahme', 'Fehler: ' + err.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4492,7 +4593,7 @@ async function startMcRecording() {
|
||||
mcRecorder.onstop = onMcRecordingStopped;
|
||||
mcRecorder.start(1000);
|
||||
|
||||
window.api.recordingSetGuard(true);
|
||||
window.api.recordingSetGuard(true, currentDetailId);
|
||||
mcStartedAt = Date.now();
|
||||
el('record-live-badge').classList.remove('hidden');
|
||||
el('record-toggle').textContent = 'Aufnahme stoppen';
|
||||
@@ -4987,6 +5088,101 @@ async function installiereTeilCode() {
|
||||
el('code-preview').classList.add('hidden');
|
||||
}
|
||||
|
||||
// ---------- Mod-Schalter (Fenster über dem laufenden Spiel) ----------
|
||||
// Zwei Listen wie beim Ressourcenpaket-Bildschirm: ein Klick schiebt eine Mod in
|
||||
// die andere Liste. Läuft das Spiel, merkt der Launcher die Änderung vor und
|
||||
// setzt sie beim Beenden um (geladene Mods lassen sich nicht entladen).
|
||||
let msInstanz = null;
|
||||
let msDaten = null;
|
||||
let msStart = null; // Stand beim Öffnen: filename -> an
|
||||
async function openModschalter(instanceId) {
|
||||
if (POPOUT !== 'modschalter') return;
|
||||
msInstanz = instanceId || new URLSearchParams(location.search).get('instanceId');
|
||||
msStart = null;
|
||||
el('ms-suche').value = '';
|
||||
show('modal-modschalter');
|
||||
await ladeModschalter();
|
||||
el('ms-suche').focus();
|
||||
}
|
||||
|
||||
async function ladeModschalter(bewegt) {
|
||||
const d = await window.api.modschalterDaten(msInstanz);
|
||||
if (!d.ok) { el('ms-status').textContent = d.message || 'Instanz nicht gefunden.'; return; }
|
||||
msDaten = d;
|
||||
if (!msStart) msStart = new Map(d.mods.map((m) => [m.filename, m.enabled !== false]));
|
||||
el('ms-instanz').textContent = `${d.name} · ${d.version}`;
|
||||
zeichneModschalter(bewegt);
|
||||
}
|
||||
|
||||
function zeichneModschalter(bewegt) {
|
||||
const q = el('ms-suche').value.trim().toLowerCase();
|
||||
const passt = (m) => !q || `${m.title || ''} ${m.filename}`.toLowerCase().includes(q);
|
||||
const mods = msDaten.mods.filter((m) => !m.fehlt).sort((a, b) => (a.title || a.filename).localeCompare(b.title || b.filename, 'de'));
|
||||
const an = mods.filter((m) => m.enabled !== false), aus = mods.filter((m) => m.enabled === false);
|
||||
el('ms-zahl-an').textContent = `(${an.length})`;
|
||||
el('ms-zahl-aus').textContent = `(${aus.length})`;
|
||||
for (const [box, liste, istAn] of [[el('ms-an'), an, true], [el('ms-aus'), aus, false]]) {
|
||||
box.innerHTML = '';
|
||||
const sichtbar = liste.filter(passt);
|
||||
if (!sichtbar.length) {
|
||||
box.innerHTML = `<div class="ms-leer">${q ? 'Keine Treffer.' : istAn ? 'Keine aktiven Mods.' : 'Hierher verschobene Mods werden deaktiviert.'}</div>`;
|
||||
continue;
|
||||
}
|
||||
for (const m of sichtbar) {
|
||||
const zeile = document.createElement('div');
|
||||
zeile.className = 'ms-mod' + (bewegt === m.filename ? ' kommt' : '');
|
||||
zeile.style.setProperty('--ms-von', istAn ? '14px' : '-14px');
|
||||
zeile.setAttribute('role', 'listitem');
|
||||
zeile.title = istAn ? 'Klicken zum Deaktivieren' : 'Klicken zum Aktivieren';
|
||||
const geaendert = msStart && msStart.get(m.filename) !== (m.enabled !== false);
|
||||
zeile.innerHTML = `<div class="ms-icon">${m.iconUrl ? `<img src="${escapeHtml(m.iconUrl)}" alt="" />` : '🧩'}</div>
|
||||
<div class="ms-info"><div class="ms-name">${escapeHtml(m.title || m.filename)}${geaendert ? '<span class="ms-neu">ab nächstem Start</span>' : ''}</div>
|
||||
<div class="ms-meta">${escapeHtml([m.versionNumber && m.versionNumber !== 'Importiert' ? m.versionNumber : '', m.filename].filter(Boolean).join(' · '))}</div>
|
||||
<div class="ms-warn"></div></div>
|
||||
<button class="ms-pfeil" tabindex="-1">${istAn ? '›' : '‹'}</button>`;
|
||||
const img = zeile.querySelector('img');
|
||||
if (img) img.addEventListener('error', () => { img.parentElement.textContent = '🧩'; });
|
||||
zeile.addEventListener('click', () => schalteMod(m, !istAn, zeile));
|
||||
box.appendChild(zeile);
|
||||
}
|
||||
}
|
||||
const geaendert = msStart ? msDaten.mods.filter((m) => msStart.get(m.filename) !== (m.enabled !== false)).length : 0;
|
||||
el('ms-neustart').classList.toggle('hidden', !(msDaten.laeuft && geaendert));
|
||||
const z = msDaten.ziel;
|
||||
el('ms-neustart').title = z && z.quickPlayServer
|
||||
? `Minecraft speichert, startet mit den neuen Mods neu und verbindet wieder mit ${z.quickPlayServer}`
|
||||
: z && z.quickPlayWelt && msDaten.quickPlayWelt
|
||||
? `Minecraft speichert, startet mit den neuen Mods neu und öffnet wieder die Welt „${z.quickPlayWelt}“`
|
||||
: 'Minecraft speichert und startet mit den neuen Mods neu (ins Hauptmenü)';
|
||||
el('ms-status').textContent = geaendert
|
||||
? (msDaten.laeuft
|
||||
? `${geaendert} Änderung${geaendert === 1 ? '' : 'en'} – gilt ab dem nächsten Start (das Spiel läuft weiter)`
|
||||
+ (z && z.quickPlayServer ? ` · Neustart verbindet wieder mit ${z.quickPlayServer}` : z && z.quickPlayWelt && msDaten.quickPlayWelt ? ` · Neustart öffnet wieder „${z.quickPlayWelt}“` : '')
|
||||
: `${geaendert} Änderung${geaendert === 1 ? '' : 'en'} übernommen`)
|
||||
: 'Klicke eine Mod an, um sie in die andere Liste zu verschieben.';
|
||||
}
|
||||
|
||||
async function schalteMod(m, an, zeile) {
|
||||
if (!an) {
|
||||
// Warnen, wenn andere Mods diese brauchen – verschoben wird trotzdem
|
||||
const brauchen = await window.api.modDependents(msInstanz, m.filename, 'mod').catch(() => []);
|
||||
if (brauchen && brauchen.length) toast(`${m.title || m.filename} wird gebraucht von: ${brauchen.slice(0, 3).join(', ')}${brauchen.length > 3 ? ' …' : ''}`, true);
|
||||
}
|
||||
if (zeile) zeile.style.pointerEvents = 'none';
|
||||
const res = await window.api.modSetEnabled(msInstanz, m.filename, an, 'mod');
|
||||
if (!res.ok) toast(res.message || 'Umschalten fehlgeschlagen.', true);
|
||||
await ladeModschalter(m.filename);
|
||||
}
|
||||
|
||||
function schliesseModschalter() { window.api.modschalterSchliessen(); }
|
||||
|
||||
async function modschalterNeustart() {
|
||||
el('ms-neustart').disabled = true;
|
||||
el('ms-status').textContent = 'Minecraft speichert und startet neu …';
|
||||
const res = await window.api.modschalterNeustart(msInstanz);
|
||||
if (!res.ok) { el('ms-neustart').disabled = false; toast(res.message || 'Neustart nicht möglich.', true); }
|
||||
}
|
||||
|
||||
// ---------- Spielzeit-Statistik ----------
|
||||
// Gesamtwerte als Kennzahlen, darunter eine nach Spielzeit sortierte Balkenliste
|
||||
// (eine Reihe = eine Instanz, Balkenlänge relativ zur meistgespielten).
|
||||
@@ -5704,6 +5900,7 @@ async function refreshInstalled() {
|
||||
const kennzeichen = [];
|
||||
if (up) kennzeichen.push('<span class="mod-new">Update</span>');
|
||||
if (aus) kennzeichen.push('<span class="mod-off">aus</span>');
|
||||
if (m.ausstehend) kennzeichen.push('<span class="mod-new" title="Das Spiel läuft – die Änderung wird beim Beenden übernommen">ab nächstem Start</span>');
|
||||
if (m.fehlt) kennzeichen.push('<span class="mod-off">Datei fehlt</span>');
|
||||
// OptiFine lässt sich nicht über Modrinth/CurseForge installieren (kein API-Zugriff auf
|
||||
// deren Downloads) – von Hand hinzugefügt taucht es sonst wie ein beliebiger Mod auf.
|
||||
@@ -5733,7 +5930,7 @@ async function refreshInstalled() {
|
||||
schalter.addEventListener('click', async () => {
|
||||
schalter.disabled = true;
|
||||
const res = await window.api.modSetEnabled(currentDetailId, m.filename, aus, modArt);
|
||||
if (res.ok) toast((aus ? 'Eingeschaltet: ' : 'Ausgeschaltet: ') + m.title);
|
||||
if (res.ok) toast((aus ? 'Eingeschaltet: ' : 'Ausgeschaltet: ') + m.title + (res.ausstehend ? ' – gilt ab dem nächsten Start' : ''));
|
||||
else toast(res.message || 'Umschalten fehlgeschlagen.', true);
|
||||
await refreshInstalled();
|
||||
});
|
||||
@@ -5977,11 +6174,15 @@ async function openSettings(pane) {
|
||||
el('s-start-animation').checked = s.startAnimation !== false;
|
||||
el('s-ui-animationen').checked = s.uiAnimationen !== false;
|
||||
el('s-bedrock').checked = s.bedrockEnabled !== false;
|
||||
el('s-beta').checked = s.betaTeilnahme === true;
|
||||
el('s-notify').checked = s.desktopNotifications !== false;
|
||||
el('s-streamdeck').checked = s.streamdeckEnabled === true;
|
||||
el('s-friends').checked = s.friendsEnabled !== false;
|
||||
el('s-friends-server').checked = s.friendsShareServer !== false;
|
||||
el('s-auto-mod-updates').checked = !!s.autoModUpdates;
|
||||
el('s-mod-schalter').checked = s.modSchalter !== false;
|
||||
el('s-aeromc-capes').checked = s.aeromcCapes !== false;
|
||||
el('s-aeromc-design').checked = s.aeromcDesign !== false;
|
||||
el('s-autostart').checked = !!s.autostart;
|
||||
el('s-auto-java').checked = s.autoJava !== false;
|
||||
el('s-autobackup').checked = !!s.autoBackupEnabled;
|
||||
@@ -6113,11 +6314,15 @@ async function saveSettings() {
|
||||
startAnimation: el('s-start-animation').checked,
|
||||
uiAnimationen: el('s-ui-animationen').checked,
|
||||
bedrockEnabled: el('s-bedrock').checked,
|
||||
betaTeilnahme: el('s-beta').checked,
|
||||
desktopNotifications: el('s-notify').checked,
|
||||
streamdeckEnabled: el('s-streamdeck').checked,
|
||||
friendsEnabled: el('s-friends').checked,
|
||||
friendsShareServer: el('s-friends-server').checked,
|
||||
autoModUpdates: el('s-auto-mod-updates').checked,
|
||||
modSchalter: el('s-mod-schalter').checked,
|
||||
aeromcCapes: el('s-aeromc-capes').checked,
|
||||
aeromcDesign: el('s-aeromc-design').checked,
|
||||
autostart: el('s-autostart').checked,
|
||||
autoJava: el('s-auto-java').checked,
|
||||
autoBackupEnabled: el('s-autobackup').checked,
|
||||
@@ -6950,6 +7155,7 @@ function wire() {
|
||||
|
||||
// Rechte Aktionsleiste (Instanz-Aktionen)
|
||||
el('p-play').addEventListener('click', () => currentDetailId && launch(currentDetailId));
|
||||
el('p-wiederherstellen').addEventListener('click', wiederherstellenAusSicherung);
|
||||
el('p-offline').addEventListener('click', () => currentDetailId && launch(currentDetailId));
|
||||
el('p-shortcut').addEventListener('click', async () => {
|
||||
if (!currentDetailId) return;
|
||||
@@ -7330,6 +7536,9 @@ function wire() {
|
||||
});
|
||||
}
|
||||
|
||||
// Stream-Deck-Plugin von Hand (neu) installieren; die Rückmeldung kommt als Hinweis aus dem Hauptprozess
|
||||
if (el('s-streamdeck-plugin')) el('s-streamdeck-plugin').addEventListener('click', () => { window.api.streamdeckInstalliere(); });
|
||||
|
||||
// Microsoft-Login / Kontoverwaltung
|
||||
el('btn-account').addEventListener('click', (e) => { e.stopPropagation(); oeffneKontoMenue(); });
|
||||
el('login-add').addEventListener('click', addAccount);
|
||||
@@ -7457,8 +7666,8 @@ function wireFileDrop() {
|
||||
e.preventDefault();
|
||||
dragDepth = 0;
|
||||
document.body.classList.remove('file-drop-active');
|
||||
const files = [...(e.dataTransfer.files || [])].filter((f) => f.path);
|
||||
for (const f of files) await handleDroppedFile(f.path);
|
||||
const pfade = [...(e.dataTransfer.files || [])].map((f) => window.api.pfadVonDatei(f)).filter(Boolean);
|
||||
for (const pfad of pfade) await handleDroppedFile(pfad);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7523,11 +7732,13 @@ const POPOUT_OPENERS = {
|
||||
record: () => openRecording(),
|
||||
friends: () => openFriends(),
|
||||
stats: (p) => openStats(p.reiter),
|
||||
modschalter: (p) => openModschalter(p.instanceId),
|
||||
};
|
||||
|
||||
function popoutBoot(params) {
|
||||
const p = params || {};
|
||||
if (p.instanceId) currentDetailId = p.instanceId;
|
||||
if (POPOUT === 'record' && (p.sd === 'aufnahme' || p.sd === 'clipmodus')) sdAusstehend = p.sd;
|
||||
const opener = POPOUT_OPENERS[POPOUT];
|
||||
if (opener) opener(p);
|
||||
}
|
||||
@@ -7553,6 +7764,13 @@ window.addEventListener('DOMContentLoaded', async () => {
|
||||
if (POPOUT === 'record') window.api.onRecordCloseAttempt(() => confirmStopAndCloseRecording());
|
||||
if (POPOUT === 'record') {
|
||||
window.api.onClipHotkey(() => speichereClip());
|
||||
// Stream-Deck-Tasten "Aufnahme" und "Clip-Modus": ist das Spielfenster schon erfasst, gleich
|
||||
// ausführen, sonst erst die Suche starten (siehe sdAusstehend in selectRecordingSource)
|
||||
window.api.onStreamdeckAktion((d) => {
|
||||
if (d && d.instanceId && !clipAktiv && !recordingActive()) currentDetailId = d.instanceId;
|
||||
if (mcVideoStream || clipAktiv) fuehreSdAktionAus(d.art);
|
||||
else { sdAusstehend = d.art; openRecording(); }
|
||||
});
|
||||
// Spiel beendet -> Clip-Modus ist ohne Bildquelle sinnlos
|
||||
window.api.onLaunchProgress((evt) => {
|
||||
if (evt.phase === 'exit' && evt.id === currentDetailId && clipAktiv) {
|
||||
@@ -7612,6 +7830,23 @@ window.addEventListener('DOMContentLoaded', async () => {
|
||||
await refreshSkinBackground();
|
||||
window.api.onLaunchProgress(onLaunchEvt);
|
||||
window.api.onErfolgeNeu(zeigeErfolgMeldung);
|
||||
// Mod-Schalter
|
||||
el('ms-suche').addEventListener('input', () => msDaten && zeichneModschalter());
|
||||
el('ms-x').addEventListener('click', schliesseModschalter);
|
||||
el('ms-fertig').addEventListener('click', schliesseModschalter);
|
||||
el('ms-neustart').addEventListener('click', modschalterNeustart);
|
||||
document.addEventListener('keydown', (e) => { if (POPOUT === 'modschalter' && e.key === 'Escape') schliesseModschalter(); });
|
||||
// Neustart nach dem Mod-Schalter: wieder in dieselbe Welt bzw. auf denselben Server
|
||||
if (!POPOUT) {
|
||||
window.api.onModschalterNeustarten(({ id, ziel }) => {
|
||||
if (!instances.some((i) => i.id === id)) return;
|
||||
const wohin = ziel && ziel.quickPlayServer ? ` und verbindet wieder mit ${ziel.quickPlayServer}` : ziel && ziel.quickPlayWelt ? ` in die Welt „${ziel.quickPlayWelt}“` : '';
|
||||
toast(`Mods übernommen – Minecraft startet neu${wohin} …`);
|
||||
const opts = ziel && (ziel.quickPlayServer || ziel.quickPlayWelt)
|
||||
? { quickPlayServer: ziel.quickPlayServer || '', quickPlayWelt: ziel.quickPlayWelt || '' } : null;
|
||||
launch(id, (ziel && ziel.kontoId) || null, opts);
|
||||
});
|
||||
}
|
||||
el('upd-notes').addEventListener('click', updateLinkKlick);
|
||||
window.api.onLaufendeInstanzen(setzeLaufende);
|
||||
window.api.laufendeInstanzen().then(setzeLaufende).catch(() => {});
|
||||
@@ -7699,3 +7934,18 @@ window.addEventListener('DOMContentLoaded', async () => {
|
||||
popoutBoot(Object.fromEntries(new URLSearchParams(location.search)));
|
||||
}
|
||||
});
|
||||
|
||||
// Vorabfassung in der Titelleiste kenntlich machen: aus "1.2.4-beta.1" wird
|
||||
// "Beta 1", aus "1.3.0-rc.2" wird "RC 2". Fertige Versionen zeigen nichts.
|
||||
(async () => {
|
||||
try {
|
||||
const info = await window.api.appInfo();
|
||||
const m = String((info && info.version) || '').match(/^\d+(?:\.\d+){0,2}-([a-z]+)[.-]?(\d+)?/i);
|
||||
const marke = document.getElementById('titel-beta');
|
||||
if (!m || !marke) return;
|
||||
const art = /^rc$/i.test(m[1]) ? 'RC' : m[1].charAt(0).toUpperCase() + m[1].slice(1).toLowerCase();
|
||||
marke.textContent = art + (m[2] ? ' ' + m[2] : '');
|
||||
marke.title = `Vorabfassung ${info.version} – zum Testen, kann noch Fehler enthalten`;
|
||||
marke.classList.remove('hidden');
|
||||
} catch { /* nur Anzeige */ }
|
||||
})();
|
||||
|
||||
@@ -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,189 @@
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* streamdeck.js – lokale Schnittstelle für das AeroMC-Plugin des Elgato Stream Deck
|
||||
* -----------------------------------------------------------------------------
|
||||
* Ein kleiner HTTP-Server, der AUSSCHLIESSLICH auf 127.0.0.1 lauscht und nur auf
|
||||
* Wunsch läuft (Einstellung "streamdeckEnabled", standardmäßig aus).
|
||||
*
|
||||
* Absicherung gegen fremde Zugriffe:
|
||||
* - nur Loopback, kein Zugriff aus dem Netzwerk
|
||||
* - jede Anfrage braucht den Zufalls-Schlüssel im Kopf "Authorization: Bearer …";
|
||||
* der wird bei jedem Start neu erzeugt und nur in eine Datei im
|
||||
* Benutzerordner geschrieben, die das Plugin liest (streamdeck.json)
|
||||
* - Anfragen mit "Origin"-Kopf (also aus einer Webseite im Browser) und mit
|
||||
* fremdem "Host"-Kopf (DNS-Rebinding) werden abgewiesen
|
||||
*
|
||||
* Die eigentlichen Aktionen kommen von main.js (siehe init) – dieses Modul kennt
|
||||
* weder Instanzen noch Aufnahmen und reicht nur weiter.
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const STANDARD_PORT = 48615;
|
||||
const PORT_VERSUCHE = 10;
|
||||
const MAX_BODY = 4096;
|
||||
|
||||
let deps = null;
|
||||
let server = null;
|
||||
let schluessel = null;
|
||||
let verbindungsDatei = null;
|
||||
|
||||
/*
|
||||
* deps: {
|
||||
* userDataDir,
|
||||
* zustand() -> { instanzen: [{ id, name, laeuft, seit }], aufnahme, clip }
|
||||
* starte(id, server) -> { ok, message? } (server optional: direkt verbinden)
|
||||
* neustart(id) -> { ok, message? }
|
||||
* fenster() -> { ok } Launcher-Fenster zeigen bzw. wegminimieren
|
||||
* clipModus() -> { ok, message? }
|
||||
* ordner(id) -> { ok } Aufnahmen-Ordner öffnen
|
||||
* serverListe(id) -> { ok, server: [{ name, ip }] }
|
||||
* ping(ip) -> { ok, online, max, latencyMs }
|
||||
* screenshot() -> { ok, message? } Bild des Spielfensters in den Screenshot-Ordner
|
||||
* symbol(id) -> { ok, bild, pixel } Instanz-Bild als data:-URI
|
||||
* kopf(uuid) -> { ok, bild } Spielerkopf eines Kontos als data:-URI
|
||||
* absturz(id) -> { ok, bericht, zeit, frisch } neuester Absturzbericht der Instanz
|
||||
* konto(id) -> { ok } aktives Konto wechseln
|
||||
* backup() -> { ok, message? } Sicherung aller Instanzen starten
|
||||
* oeffne(art, id) -> { ok, message? } Fenster öffnen: friends | logs | console | crash
|
||||
* beende(id) -> { ok, message? }
|
||||
* clipSpeichern() -> { ok, message? }
|
||||
* aufnahmeUmschalten() -> { ok, message? }
|
||||
* }
|
||||
*/
|
||||
function init(d) {
|
||||
deps = d;
|
||||
verbindungsDatei = path.join(d.userDataDir, 'streamdeck.json');
|
||||
}
|
||||
|
||||
function istSchluesselGleich(angabe) {
|
||||
if (!schluessel || typeof angabe !== 'string') return false;
|
||||
const a = Buffer.from(angabe);
|
||||
const b = Buffer.from(schluessel);
|
||||
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
function antworte(res, status, daten) {
|
||||
const text = JSON.stringify(daten);
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Content-Length': Buffer.byteLength(text),
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(text);
|
||||
}
|
||||
|
||||
function leseBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let groesse = 0;
|
||||
const teile = [];
|
||||
req.on('data', (c) => {
|
||||
groesse += c.length;
|
||||
if (groesse > MAX_BODY) { reject(new Error('zu groß')); req.destroy(); return; }
|
||||
teile.push(c);
|
||||
});
|
||||
req.on('end', () => {
|
||||
if (!teile.length) { resolve({}); return; }
|
||||
try { resolve(JSON.parse(Buffer.concat(teile).toString('utf8')) || {}); } catch { reject(new Error('kein JSON')); }
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function behandle(req, res, port) {
|
||||
// Anfragen aus Webseiten (Origin) und DNS-Rebinding (fremder Host) gar nicht erst bedienen
|
||||
const host = String(req.headers.host || '').toLowerCase();
|
||||
if (req.headers.origin || (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`)) {
|
||||
return antworte(res, 403, { ok: false, message: 'Nicht erlaubt.' });
|
||||
}
|
||||
const auth = String(req.headers.authorization || '');
|
||||
if (!auth.startsWith('Bearer ') || !istSchluesselGleich(auth.slice(7))) {
|
||||
return antworte(res, 401, { ok: false, message: 'Schlüssel fehlt oder ist falsch.' });
|
||||
}
|
||||
|
||||
const url = new URL(req.url || '/', 'http://127.0.0.1');
|
||||
const pfad = url.pathname;
|
||||
if (req.method === 'GET' && pfad === '/state') {
|
||||
return antworte(res, 200, Object.assign({ ok: true }, deps.zustand({ freunde: url.searchParams.get('freunde') === '1' })));
|
||||
}
|
||||
if (req.method === 'GET' && pfad === '/servers') {
|
||||
return antworte(res, 200, await deps.serverListe(url.searchParams.get('id') || ''));
|
||||
}
|
||||
if (req.method === 'GET' && pfad === '/icon') {
|
||||
const konto = url.searchParams.get('konto');
|
||||
return antworte(res, 200, konto ? await deps.kopf(konto) : await deps.symbol(url.searchParams.get('id') || ''));
|
||||
}
|
||||
if (req.method === 'GET' && pfad === '/crash') {
|
||||
return antworte(res, 200, await deps.absturz(url.searchParams.get('id') || ''));
|
||||
}
|
||||
if (req.method === 'GET' && pfad === '/ping') {
|
||||
return antworte(res, 200, await deps.ping(url.searchParams.get('ip') || ''));
|
||||
}
|
||||
if (req.method !== 'POST') return antworte(res, 405, { ok: false, message: 'Nur GET/POST.' });
|
||||
|
||||
let body;
|
||||
try { body = await leseBody(req); } catch (err) { return antworte(res, 400, { ok: false, message: err.message }); }
|
||||
const id = typeof body.id === 'string' ? body.id : '';
|
||||
|
||||
let erg;
|
||||
if (pfad === '/launch' && id) erg = await deps.starte(id, typeof body.server === 'string' ? body.server.trim().slice(0, 253) : '');
|
||||
else if (pfad === '/restart' && id) erg = await deps.neustart(id);
|
||||
else if (pfad === '/window') erg = await deps.fenster();
|
||||
else if (pfad === '/clipmode') erg = await deps.clipModus();
|
||||
else if (pfad === '/folder') erg = await deps.ordner(id);
|
||||
else if (pfad === '/screenshot') erg = await deps.screenshot();
|
||||
else if (pfad === '/account' && id) erg = await deps.konto(id);
|
||||
else if (pfad === '/backup') erg = await deps.backup();
|
||||
else if (pfad === '/open' && typeof body.art === 'string') erg = await deps.oeffne(body.art, id);
|
||||
else if (pfad === '/stop' && id) erg = await deps.beende(id);
|
||||
else if (pfad === '/clip') erg = await deps.clipSpeichern();
|
||||
else if (pfad === '/record') erg = await deps.aufnahmeUmschalten();
|
||||
else return antworte(res, 404, { ok: false, message: 'Unbekannte Aktion.' });
|
||||
return antworte(res, erg && erg.ok ? 200 : 409, erg || { ok: false });
|
||||
}
|
||||
|
||||
function schreibeVerbindung(port) {
|
||||
try {
|
||||
fs.writeFileSync(verbindungsDatei, JSON.stringify({ port, token: schluessel }), 'utf8');
|
||||
} catch { /* ohne Datei findet das Plugin den Launcher nicht – mehr geht nicht */ }
|
||||
}
|
||||
|
||||
function loescheVerbindung() {
|
||||
try { fs.rmSync(verbindungsDatei, { force: true }); } catch { /* egal */ }
|
||||
}
|
||||
|
||||
function lausche(port, versuche) {
|
||||
const s = http.createServer((req, res) => {
|
||||
behandle(req, res, port).catch(() => { try { antworte(res, 500, { ok: false }); } catch { /* egal */ } });
|
||||
});
|
||||
s.on('error', (err) => {
|
||||
if (err.code === 'EADDRINUSE' && versuche > 1 && server === s) { lausche(port + 1, versuche - 1); return; }
|
||||
if (server === s) server = null;
|
||||
});
|
||||
server = s;
|
||||
s.listen(port, '127.0.0.1', () => {
|
||||
if (server === s) schreibeVerbindung(port);
|
||||
});
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (server || !deps) return;
|
||||
schluessel = crypto.randomBytes(32).toString('hex');
|
||||
lausche(STANDARD_PORT, PORT_VERSUCHE);
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (server) { try { server.close(); } catch { /* egal */ } server = null; }
|
||||
schluessel = null;
|
||||
if (verbindungsDatei) loescheVerbindung();
|
||||
}
|
||||
|
||||
function configure(settings) {
|
||||
if (settings && settings.streamdeckEnabled === true) start(); else stop();
|
||||
}
|
||||
|
||||
module.exports = { init, configure, shutdown: stop };
|
||||
@@ -0,0 +1,82 @@
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* streamdeckplugin.js – das AeroMC-Plugin in die Stream-Deck-Software kopieren
|
||||
* -----------------------------------------------------------------------------
|
||||
* Das Plugin (Ordner streamdeck-plugin/com.aeromc.launcher.sdPlugin) liegt im
|
||||
* Launcher mit dabei. Schaltet jemand "Stream Deck erlauben" ein – und bei jedem
|
||||
* Start, solange es an ist – legt AeroMC es in den Plugin-Ordner der Stream-Deck-
|
||||
* Software, falls es dort fehlt oder von einer anderen Fassung stammt. Nach dem
|
||||
* Kopieren muss die Stream-Deck-Software einmal neu gestartet werden, damit sie
|
||||
* das Plugin lädt.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const PLUGIN_NAME = 'com.aeromc.launcher.sdPlugin';
|
||||
|
||||
// Ordner mit dem mitgelieferten Plugin; null, wenn er in dieser Fassung fehlt
|
||||
function quelle(istPaketiert, ressourcenOrdner) {
|
||||
const kandidat = istPaketiert
|
||||
? path.join(ressourcenOrdner, 'streamdeck-plugin', PLUGIN_NAME)
|
||||
: path.join(__dirname, '..', 'streamdeck-plugin', PLUGIN_NAME);
|
||||
return fs.existsSync(path.join(kandidat, 'manifest.json')) ? kandidat : null;
|
||||
}
|
||||
|
||||
// Plugin-Ordner der Stream-Deck-Software; null, wenn sie nie installiert war
|
||||
function pluginOrdner() {
|
||||
const appdata = process.env.APPDATA;
|
||||
if (!appdata) return null;
|
||||
const ordner = path.join(appdata, 'Elgato', 'StreamDeck', 'Plugins');
|
||||
return fs.existsSync(ordner) ? ordner : null;
|
||||
}
|
||||
|
||||
// Was die Stream-Deck-Software selbst im Plugin-Ordner anlegt (Protokolle), zählt nicht mit
|
||||
const IGNORIERT = new Set(['logs']);
|
||||
|
||||
function dateien(basis, rel = '') {
|
||||
const ergebnis = [];
|
||||
for (const e of fs.readdirSync(path.join(basis, rel), { withFileTypes: true })) {
|
||||
if (!rel && IGNORIERT.has(e.name)) continue;
|
||||
const r = rel ? rel + '/' + e.name : e.name;
|
||||
if (e.isDirectory()) ergebnis.push(...dateien(basis, r));
|
||||
else ergebnis.push(r);
|
||||
}
|
||||
return ergebnis.sort();
|
||||
}
|
||||
|
||||
// Prüfsumme über Dateinamen und -inhalt - gleiche Prüfsumme = gleicher Stand
|
||||
function fingerabdruck(ordner) {
|
||||
const h = crypto.createHash('sha1');
|
||||
for (const rel of dateien(ordner)) {
|
||||
h.update(rel + '\0');
|
||||
h.update(fs.readFileSync(path.join(ordner, ...rel.split('/'))));
|
||||
}
|
||||
return h.digest('hex');
|
||||
}
|
||||
|
||||
/*
|
||||
* -> { ok: true, status: 'installiert' | 'aktualisiert' | 'aktuell', neustart }
|
||||
* { ok: false, grund: 'quelle-fehlt' | 'kein-streamdeck' | 'kopieren', message }
|
||||
*/
|
||||
function installiere(opt) {
|
||||
const o = opt || {};
|
||||
if (!o.quelle) return { ok: false, grund: 'quelle-fehlt', message: 'Das Plugin ist in dieser Fassung nicht enthalten.' };
|
||||
const plugins = o.pluginOrdner !== undefined ? o.pluginOrdner : pluginOrdner();
|
||||
if (!plugins || !fs.existsSync(plugins)) {
|
||||
return { ok: false, grund: 'kein-streamdeck', message: 'Die Stream-Deck-Software wurde nicht gefunden.' };
|
||||
}
|
||||
const ziel = path.join(plugins, PLUGIN_NAME);
|
||||
try {
|
||||
const vorhanden = fs.existsSync(ziel);
|
||||
if (vorhanden && fingerabdruck(ziel) === fingerabdruck(o.quelle)) return { ok: true, status: 'aktuell', neustart: false };
|
||||
fs.cpSync(o.quelle, ziel, { recursive: true, force: true, filter: (src) => !IGNORIERT.has(path.basename(src)) || src === o.quelle });
|
||||
return { ok: true, status: vorhanden ? 'aktualisiert' : 'installiert', neustart: true };
|
||||
} catch (err) {
|
||||
return { ok: false, grund: 'kopieren', message: 'Das Plugin konnte nicht kopiert werden (' + err.message + '). Bitte die Stream-Deck-Software beenden und es erneut versuchen.' };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { PLUGIN_NAME, quelle, pluginOrdner, installiere, fingerabdruck };
|
||||
@@ -82,6 +82,8 @@ body{display:grid;grid-template-rows:auto auto 1fr auto;grid-template-columns:mi
|
||||
.titelleiste{display:flex;align-items:center;gap:8px;height:32px;padding:0 150px 0 10px;background:var(--titel-bg);color:var(--titel-fg);-webkit-app-region:drag;user-select:none;font-size:12px}
|
||||
.titel-logo{width:16px;height:16px;flex:none}
|
||||
.titel-text{white-space:nowrap}
|
||||
/* Kennzeichnung einer Vorabfassung ("Beta 1", "RC 2") neben dem Namen */
|
||||
.titel-beta{padding:1px 8px;border-radius:99px;background:#e9a800;color:#1b1400;font-size:10.5px;font-weight:700;letter-spacing:.6px;text-transform:uppercase;line-height:16px;white-space:nowrap}
|
||||
body.fenster-inaktiv .titelleiste>*{opacity:.55}
|
||||
body.is-popout>.titelleiste{display:none}
|
||||
|
||||
@@ -839,6 +841,32 @@ select.s-wert,input.s-wert{padding:6px 8px;border:1px solid var(--border);border
|
||||
.erfolg-karte small{display:block;font-size:11px;color:var(--dim);line-height:1.3}
|
||||
.erfolg-karte.offen{opacity:.45;filter:grayscale(1)}
|
||||
|
||||
/* Mod-Schalter: zwei Listen wie beim Ressourcenpaket-Bildschirm */
|
||||
#modal-modschalter.popout-host .modal-body{display:flex;flex-direction:column}
|
||||
.ms-instanz{font-weight:400;color:var(--dim);font-size:12px;margin-left:6px}
|
||||
.ms-kopf{display:flex;align-items:center;gap:12px;margin-bottom:10px}
|
||||
.ms-kopf input{flex:1;max-width:320px;padding:6px 10px;border:1px solid var(--border);border-radius:6px;background:var(--input,#fff);color:var(--text);font-family:inherit;font-size:13px}
|
||||
.ms-kopf input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 2px rgba(64,126,232,.25)}
|
||||
.ms-spalten{flex:1 1 auto;min-height:0;display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
||||
.ms-spalte{display:flex;flex-direction:column;min-height:0;border:1px solid var(--border);border-radius:8px;background:rgba(127,127,127,.06)}
|
||||
.ms-titel{padding:8px 12px;font-weight:600;font-size:12.5px;border-bottom:1px solid var(--border)}
|
||||
.ms-liste{flex:1 1 auto;min-height:0;overflow:auto;padding:6px}
|
||||
.ms-mod{display:flex;align-items:center;gap:10px;padding:7px 8px;border-radius:6px;cursor:pointer;border:1px solid transparent}
|
||||
.ms-mod:hover{background:rgba(127,127,127,.12);border-color:var(--border)}
|
||||
.ms-icon{width:32px;height:32px;flex:none;border-radius:6px;display:grid;place-items:center;background:rgba(127,127,127,.15);font-size:17px;overflow:hidden}
|
||||
.ms-icon img{width:100%;height:100%;object-fit:cover}
|
||||
.ms-info{flex:1;min-width:0}
|
||||
.ms-name{font-size:13px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.ms-meta{font-size:11px;color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.ms-warn{font-size:11px;color:#d9822b}
|
||||
.ms-pfeil{flex:none;width:28px;height:28px;border-radius:6px;border:1px solid var(--border);background:var(--win);color:var(--text);cursor:pointer;font-size:14px;opacity:.55}
|
||||
.ms-mod:hover .ms-pfeil{opacity:1;border-color:var(--accent);color:var(--accent)}
|
||||
.ms-neu{font-size:10.5px;font-weight:700;color:var(--accent);margin-left:6px}
|
||||
.ms-leer{color:var(--dim);font-size:12px;padding:14px;text-align:center}
|
||||
.ms-status{margin-right:auto}
|
||||
.ui-animiert .ms-mod.kommt{animation:ms-rein .28s cubic-bezier(.2,.8,.2,1)}
|
||||
@keyframes ms-rein{from{opacity:0;transform:translateX(var(--ms-von,12px))}}
|
||||
|
||||
/* Konto-Menü oben rechts (wie in MultiMC) */
|
||||
.acct-pfeil{font-size:9px;opacity:.7;margin-left:-2px}
|
||||
.konto-menu{min-width:220px}
|
||||
@@ -915,3 +943,20 @@ select.s-wert,input.s-wert{padding:6px 8px;border:1px solid var(--border);border
|
||||
.se-u2d[data-werkzeug="pipette"] canvas{cursor:copy}
|
||||
.se-fuss{align-items:center}
|
||||
.se-status{flex:1;min-width:0;margin-left:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
|
||||
/* Manipulationsschutz: gesperrte Instanz (Verbotszeichen, siehe integritaet.js) */
|
||||
.card.gesperrt .card-icon,.card.gesperrt .card-icon-img{filter:grayscale(1) brightness(.55)}
|
||||
.card-verbot{position:absolute;left:50%;top:6px;width:60px;height:60px;margin-left:-30px;opacity:.95;pointer-events:none;z-index:2;filter:drop-shadow(0 1px 2px rgba(0,0,0,.45))}
|
||||
.card.gesperrt .card-name{color:#e05555}
|
||||
.card.gesperrt.sel .card-name{color:#fff;background:#b02020}
|
||||
.side-ico{position:relative}
|
||||
.side-ico.gesperrt > :not(.side-verbot){filter:grayscale(1) brightness(.55)}
|
||||
.side-ico img.side-verbot{position:absolute;left:-4px;top:-4px;width:60px;height:60px;object-fit:contain;border-radius:0;pointer-events:none}
|
||||
.side-gesperrt{margin:8px 6px 2px;padding:9px 10px;border:1px solid rgba(200,0,0,.55);background:rgba(200,0,0,.10);border-radius:6px;font-size:12px}
|
||||
.side-gesperrt-titel{display:flex;align-items:center;gap:6px;font-weight:700;color:#e04848;font-size:13px;margin-bottom:4px}
|
||||
.side-gesperrt-titel img{width:18px;height:18px}
|
||||
.side-gesperrt-text{line-height:1.35;margin:4px 0}
|
||||
.side-gesperrt-text.dim{opacity:.7;font-size:11px}
|
||||
.side-gesperrt-gruende{margin:4px 0 8px;padding-left:16px;max-height:110px;overflow:auto;word-break:break-word;line-height:1.35}
|
||||
.side-gesperrt-gruende .dim{opacity:.7;list-style:none;margin-left:-16px}
|
||||
#p-wiederherstellen{width:100%;margin:4px 0}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MCowBQYDK2VwAyEAXxt1cCntDQzvej4hjyVZt6Onj6Uv3hSlneJaOxxmsds=
|
||||
-----END PUBLIC KEY-----
|
||||
@@ -1,370 +1,541 @@
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* updater.js – automatische Updates über die Gitea-Releases
|
||||
* ---------------------------------------------------------
|
||||
* Ablauf: Releases abfragen -> neueste Version mit der laufenden vergleichen ->
|
||||
* bei Bedarf den Windows-Installer herunterladen und starten.
|
||||
* Ein Klick des Nutzers genügt; Download, Prüfung und Start laufen automatisch.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const crypto = require('crypto');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
const API = 'https://git.viper.ipv64.net/api/v1/repos/M_Viper/AeroMc-Launcher/releases';
|
||||
const UA = 'AeroMC-Launcher';
|
||||
|
||||
/*
|
||||
* Paketart der laufenden Anwendung: 'portable', 'setup' oder 'unknown'.
|
||||
*
|
||||
* Wichtig: es wird nie geraten. Portable-Nutzer dürfen niemals einen Installer
|
||||
* bekommen und Setup-Nutzer niemals eine Portable-Datei – im Zweifel lieber
|
||||
* 'unknown' und gar kein automatisches Update anbieten.
|
||||
*/
|
||||
let cachedKind = null;
|
||||
function detectInstallKind() {
|
||||
if (cachedKind) return cachedKind;
|
||||
cachedKind = computeInstallKind();
|
||||
return cachedKind;
|
||||
}
|
||||
|
||||
function computeInstallKind() {
|
||||
// 1. Portable: diese Variablen setzt ausschließlich der Portable-Build von
|
||||
// electron-builder – das ist das verlässlichste Signal überhaupt.
|
||||
if (String(process.env.PORTABLE_EXECUTABLE_FILE || '').trim()) return 'portable';
|
||||
if (String(process.env.PORTABLE_EXECUTABLE_DIR || '').trim()) return 'portable';
|
||||
if (String(process.env.PORTABLE_EXECUTABLE_APP_FILENAME || '').trim()) return 'portable';
|
||||
|
||||
const exe = process.execPath || '';
|
||||
// 2. Notnagel über den Dateinamen, falls die Variablen einmal fehlen
|
||||
if (/portable/i.test(path.basename(exe))) return 'portable';
|
||||
|
||||
// 3. Setup: das Setup legt neben der EXE immer "Uninstall AeroMC.exe" ab. Nur wenn der
|
||||
// da ist, ist es wirklich eine Installation.
|
||||
if (hasUninstaller(path.dirname(exe))) return 'setup';
|
||||
|
||||
// 4. Alles andere (Entwicklungsmodus, entpackter Ordner, unbekannte Kopie)
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function hasUninstaller(dir) {
|
||||
try {
|
||||
return fs.readdirSync(dir).some((n) => /^unins(tall)?.*\.exe$/i.test(n));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Pfad der laufenden Portable-EXE (die Datei, die der Nutzer selbst gestartet hat)
|
||||
function portableTarget() {
|
||||
const p = String(process.env.PORTABLE_EXECUTABLE_FILE || '').trim();
|
||||
if (p) return p;
|
||||
// Fallback: nur brauchbar, wenn die EXE nicht entpackt ausgeführt wird
|
||||
const exe = process.execPath || '';
|
||||
return path.basename(exe).toLowerCase().includes('portable') ? exe : '';
|
||||
}
|
||||
|
||||
function updateDir() {
|
||||
return path.join(os.tmpdir(), 'aeromc-update');
|
||||
}
|
||||
|
||||
/*
|
||||
* Räumt Reste alter Update-Vorgänge weg (Installer sind mehrere hundert MB groß).
|
||||
* Dateien, die jünger als 10 Minuten sind, bleiben liegen – sie könnten gerade
|
||||
* von einem laufenden Update benutzt werden.
|
||||
*/
|
||||
function cleanupUpdateCache() {
|
||||
try {
|
||||
const dir = updateDir();
|
||||
if (!fs.existsSync(dir)) return;
|
||||
for (const name of fs.readdirSync(dir)) {
|
||||
const p = path.join(dir, name);
|
||||
try {
|
||||
if (Date.now() - fs.statSync(p).mtimeMs < 600000) continue;
|
||||
fs.rmSync(p, { force: true, recursive: true });
|
||||
} catch { /* gesperrt – beim nächsten Start erneut versuchen */ }
|
||||
}
|
||||
} catch { /* egal */ }
|
||||
}
|
||||
|
||||
// "v1.2.3-beta.2" -> [1,2,3]; führendes "v" wird ignoriert
|
||||
function parseVersion(v) {
|
||||
const m = String(v || '').trim().replace(/^v/i, '').match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
|
||||
if (!m) return null;
|
||||
return [Number(m[1] || 0), Number(m[2] || 0), Number(m[3] || 0)];
|
||||
}
|
||||
|
||||
// Vorabkennung hinter dem ersten "-", z. B. "beta" oder "beta.2"
|
||||
function parsePreRelease(v) {
|
||||
const m = String(v || '').trim().replace(/^v/i, '').match(/^\d+(?:\.\d+){0,2}-([0-9A-Za-z.\-]+)/);
|
||||
return m ? m[1].split('.') : null;
|
||||
}
|
||||
|
||||
/*
|
||||
* Vergleicht die Vorabkennungen zweier gleicher Versionsnummern.
|
||||
*
|
||||
* Bewusst nicht streng nach SemVer: trägt ein Release-Tag die Vorabkennung
|
||||
* nicht, die die App selbst meldet (Tag "1.0.1" gegen App "1.0.1-beta"),
|
||||
* würde "1.0.1" als neuer gelten – der Launcher böte dasselbe Update nach
|
||||
* jeder Installation erneut an, eine Endlosschleife. Deshalb zählt eine
|
||||
* Vorabkennung nur, wenn sie auf beiden Seiten vorhanden ist (beta.1 -> beta.2).
|
||||
*/
|
||||
function comparePreRelease(a, b) {
|
||||
if (!a || !b) return 0;
|
||||
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
||||
const x = a[i]; const y = b[i];
|
||||
if (x === undefined) return -1;
|
||||
if (y === undefined) return 1;
|
||||
const nx = /^\d+$/.test(x) ? Number(x) : null;
|
||||
const ny = /^\d+$/.test(y) ? Number(y) : null;
|
||||
if (nx !== null && ny !== null) { if (nx !== ny) return nx - ny; continue; }
|
||||
if (nx !== null) return -1; // Zahlen zählen weniger als Text
|
||||
if (ny !== null) return 1;
|
||||
if (x !== y) return x < y ? -1 : 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// >0 wenn a neuer als b
|
||||
function compareVersions(a, b) {
|
||||
const x = parseVersion(a); const y = parseVersion(b);
|
||||
if (!x || !y) return 0;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (x[i] !== y[i]) return x[i] - y[i];
|
||||
}
|
||||
return comparePreRelease(parsePreRelease(a), parsePreRelease(b));
|
||||
}
|
||||
|
||||
/*
|
||||
* Paketart einer Release-Datei am Namen erkennen: 'portable', 'setup' oder null.
|
||||
* Namen, die auf beides passen (z. B. "AeroMC-Portable-Setup.exe"), gelten als
|
||||
* mehrdeutig und werden bewusst niemandem angeboten – lieber kein Update als
|
||||
* das falsche.
|
||||
*/
|
||||
function assetKind(name) {
|
||||
const n = String(name || '');
|
||||
if (!/\.exe$/i.test(n)) return null;
|
||||
const portable = /portable/i.test(n);
|
||||
const setup = /setup|install(er)?/i.test(n);
|
||||
if (portable && setup) return null;
|
||||
if (portable) return 'portable';
|
||||
if (setup) return 'setup';
|
||||
return null; // ohne Kennzeichnung im Namen nicht zuzuordnen
|
||||
}
|
||||
|
||||
// passendes Windows-Artefakt im Release finden
|
||||
function pickAsset(release, installKind) {
|
||||
if (installKind !== 'portable' && installKind !== 'setup') return null;
|
||||
const assets = release.assets || [];
|
||||
return assets.find((a) => assetKind(a.name) === installKind) || null;
|
||||
}
|
||||
|
||||
/*
|
||||
* Prüft auf Updates.
|
||||
* -> { available, currentVersion, latestVersion, notes, asset, url, noReleases }
|
||||
*/
|
||||
async function checkForUpdate(currentVersion) {
|
||||
const installKind = detectInstallKind();
|
||||
const res = await fetch(API + '?limit=10', { headers: { 'User-Agent': UA, Accept: 'application/json' } });
|
||||
if (!res.ok) throw new Error('Update-Server antwortet mit HTTP ' + res.status);
|
||||
const list = await res.json();
|
||||
const releases = (Array.isArray(list) ? list : []).filter((r) => !r.draft);
|
||||
if (!releases.length) {
|
||||
return { available: false, noReleases: true, currentVersion };
|
||||
}
|
||||
// neueste anhand der Versionsnummer (nicht nach Datum, damit Nachträge nicht stören)
|
||||
releases.sort((a, b) => compareVersions(b.tag_name, a.tag_name));
|
||||
const latest = releases[0];
|
||||
const available = compareVersions(latest.tag_name, currentVersion) > 0;
|
||||
const asset = pickAsset(latest, installKind);
|
||||
return {
|
||||
available,
|
||||
currentVersion,
|
||||
latestVersion: String(latest.tag_name || '').replace(/^v/i, ''),
|
||||
installKind,
|
||||
notes: latest.body || '',
|
||||
published: latest.published_at || latest.created_at || null,
|
||||
asset: asset ? { name: asset.name, url: asset.browser_download_url, size: asset.size, kind: installKind } : null,
|
||||
url: latest.html_url,
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
* Wirft, sobald eine Datei nicht zweifelsfrei zur laufenden Paketart gehört.
|
||||
* Wird vor dem Download und noch einmal direkt vor dem Ausführen aufgerufen.
|
||||
*/
|
||||
function assertMatchingKind(fileName, installKind) {
|
||||
if (installKind !== 'portable' && installKind !== 'setup') {
|
||||
throw new Error('Die Paketart des Launchers lässt sich nicht sicher bestimmen – bitte manuell aktualisieren.');
|
||||
}
|
||||
const kind = assetKind(fileName);
|
||||
if (!kind) {
|
||||
throw new Error(`"${fileName}" lässt sich keiner Paketart zuordnen – bitte manuell aktualisieren.`);
|
||||
}
|
||||
if (kind !== installKind) {
|
||||
const soll = installKind === 'portable' ? 'eine Portable-Datei' : 'einen Installer';
|
||||
const ist = kind === 'portable' ? 'eine Portable-Datei' : 'ein Installer';
|
||||
throw new Error(`Falsches Paket: Dieser Launcher braucht ${soll}, angeboten wurde ${ist}.`);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Lädt den Installer herunter (mit Fortschritt) und startet ihn.
|
||||
* onProgress: ({ phase, loaded, total, percent })
|
||||
*/
|
||||
async function downloadAndInstall(asset, onProgress) {
|
||||
if (!asset || !asset.url) throw new Error('Kein Installer im Release gefunden.');
|
||||
const installKind = detectInstallKind();
|
||||
// Paketart am echten Dateinamen prüfen, nicht am mitgelieferten Feld –
|
||||
// sonst kontrolliert sich die Angabe nur selbst.
|
||||
assertMatchingKind(asset.name, installKind);
|
||||
|
||||
// Portable-Updates ersetzen später die laufende EXE – das geht nur, wenn wir sie kennen
|
||||
const target = installKind === 'portable' ? portableTarget() : '';
|
||||
if (installKind === 'portable' && !target) {
|
||||
throw new Error('Der Pfad der laufenden Portable-Datei ist unbekannt – bitte manuell aktualisieren.');
|
||||
}
|
||||
|
||||
const dir = updateDir();
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
let dest = path.join(dir, asset.name.replace(/[^\w.\-]/g, '_'));
|
||||
try {
|
||||
fs.rmSync(dest, { force: true });
|
||||
} catch {
|
||||
// Alte Datei ist gesperrt (Virenscanner prüft sie gerade, oder ein vorheriger
|
||||
// Installer-Versuch läuft noch) - dann eben unter neuem Namen laden, statt mit
|
||||
// EPERM abzubrechen. Der Rest wird bei nächster Gelegenheit von
|
||||
// cleanupUpdateCache() aufgeräumt (ab 10 Minuten Alter).
|
||||
const parsed = path.parse(dest);
|
||||
dest = path.join(parsed.dir, `${parsed.name}-${Date.now()}${parsed.ext}`);
|
||||
}
|
||||
|
||||
const res = await fetch(asset.url, { headers: { 'User-Agent': UA } });
|
||||
if (!res.ok) throw new Error('Download fehlgeschlagen: HTTP ' + res.status);
|
||||
|
||||
const total = Number(res.headers.get('content-length')) || asset.size || 0;
|
||||
|
||||
// direkt auf die Platte schreiben statt alles im Speicher zu sammeln
|
||||
const out = fs.createWriteStream(dest);
|
||||
const hash = crypto.createHash('sha256');
|
||||
let loaded = 0;
|
||||
let lastReport = 0;
|
||||
try {
|
||||
for await (const chunk of res.body) {
|
||||
const buf = Buffer.from(chunk);
|
||||
hash.update(buf);
|
||||
loaded += buf.length;
|
||||
// auf den Puffer warten; der jeweils andere Listener wird mit abgeräumt,
|
||||
// sonst sammeln sich über den ganzen Download hunderte davon an
|
||||
if (!out.write(buf)) await new Promise((resolve, reject) => {
|
||||
const onDrain = () => { out.off('error', onError); resolve(); };
|
||||
const onError = (e) => { out.off('drain', onDrain); reject(e); };
|
||||
out.once('drain', onDrain);
|
||||
out.once('error', onError);
|
||||
});
|
||||
// höchstens viermal pro Sekunde melden, sonst flutet es die Oberfläche
|
||||
if (onProgress && Date.now() - lastReport >= 250) {
|
||||
lastReport = Date.now();
|
||||
onProgress({ phase: 'download', loaded, total, percent: total ? Math.round((loaded / total) * 100) : 0 });
|
||||
}
|
||||
}
|
||||
await new Promise((resolve, reject) => {
|
||||
out.once('error', reject);
|
||||
out.end((err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
} catch (err) {
|
||||
out.destroy();
|
||||
fs.rmSync(dest, { force: true });
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (total && loaded !== total) {
|
||||
fs.rmSync(dest, { force: true });
|
||||
throw new Error('Download unvollständig.');
|
||||
}
|
||||
|
||||
if (onProgress) onProgress({ phase: 'ready', loaded, total: loaded, percent: 100 });
|
||||
return { path: dest, bytes: loaded, sha256: hash.digest('hex'), kind: installKind, target };
|
||||
}
|
||||
|
||||
/*
|
||||
* Hilfsskript für Portable-Updates: wartet, bis der Launcher die eigene EXE
|
||||
* freigibt, tauscht sie gegen die neue aus und startet sie wieder.
|
||||
* "ping" statt "timeout", weil timeout ohne Konsole abbricht.
|
||||
*/
|
||||
const SWAP_SCRIPT = `@echo off
|
||||
setlocal
|
||||
set "SRC=%~1"
|
||||
set "DST=%~2"
|
||||
set "LOG=%~dp0update.log"
|
||||
set TRIES=0
|
||||
:loop
|
||||
ping -n 2 127.0.0.1 >nul
|
||||
copy /y "%SRC%" "%DST%" >nul 2>&1
|
||||
if not errorlevel 1 goto done
|
||||
set /a TRIES+=1
|
||||
if %TRIES% GEQ 60 goto fail
|
||||
goto loop
|
||||
:done
|
||||
del /f /q "%SRC%" >nul 2>&1
|
||||
start "" "%DST%"
|
||||
exit /b 0
|
||||
:fail
|
||||
echo [%DATE% %TIME%] Konnte "%DST%" nicht ersetzen - Datei gesperrt oder schreibgeschuetzt.>>"%LOG%"
|
||||
exit /b 1
|
||||
`;
|
||||
|
||||
/*
|
||||
* Wendet das geladene Update an.
|
||||
* setup -> 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.
|
||||
*/
|
||||
function applyUpdate(file) {
|
||||
const installKind = detectInstallKind();
|
||||
// letzte Kontrolle unmittelbar vor dem Start: passt die Datei auf der Platte
|
||||
// wirklich zu dieser Paketart?
|
||||
assertMatchingKind(path.basename(file.path || ''), installKind);
|
||||
const kind = installKind;
|
||||
|
||||
if (kind !== 'portable') {
|
||||
// --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 };
|
||||
}
|
||||
|
||||
// Rückwärtskompatibel: nur für die Setup-Variante
|
||||
function runInstaller(installerPath) {
|
||||
return applyUpdate({ path: installerPath, kind: 'setup' });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
checkForUpdate,
|
||||
downloadAndInstall,
|
||||
applyUpdate,
|
||||
runInstaller,
|
||||
cleanupUpdateCache,
|
||||
compareVersions,
|
||||
parseVersion,
|
||||
detectInstallKind,
|
||||
assetKind,
|
||||
portableTarget,
|
||||
};
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* updater.js – automatische Updates über die Gitea-Releases
|
||||
* ---------------------------------------------------------
|
||||
* Ablauf: Releases abfragen -> neueste Version mit der laufenden vergleichen ->
|
||||
* bei Bedarf den Windows-Installer herunterladen und starten.
|
||||
* Ein Klick des Nutzers genügt; Download, Prüfung und Start laufen automatisch.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const crypto = require('crypto');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
const API = 'https://git.viper.ipv64.net/api/v1/repos/M_Viper/AeroMc-Launcher/releases';
|
||||
/*
|
||||
* Beta-Kanal: Vorabfassungen (Beta, RC) liegen im selben Repository wie die
|
||||
* fertigen Versionen, in Gitea als "Pre-Release" markiert – und mit einem Tag,
|
||||
* dem "0.0.0-" vorangestellt ist:
|
||||
*
|
||||
* App-Version 1.2.4-beta.1 -> Tag 0.0.0-1.2.4-beta.1
|
||||
*
|
||||
* 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
|
||||
* sie ein normales Update auf 1.2.4, das ALLE Nutzer bekämen. "0.0.0-…" lesen
|
||||
* sie als uralte Version 0.0.0 und ignorieren sie; fertige Versionen finden sie
|
||||
* weiterhin. (Ein Tag ohne Zahl vorn, etwa "beta-…", taugt nicht: Er bringt bei
|
||||
* alten Launchern die Sortierung durcheinander, sie fänden dann gar kein Update
|
||||
* mehr – mit dem echten 1.2.3 geprüft.)
|
||||
*/
|
||||
const BETA_VORSATZ = /^0\.0\.0-(?=\d)/;
|
||||
|
||||
// Version, die ein Release wirklich enthält: Tag ohne "v" und ohne Beta-Vorsatz
|
||||
function tagVersion(tag) {
|
||||
return normVersion(tag).replace(BETA_VORSATZ, '');
|
||||
}
|
||||
|
||||
// Vorabfassung? In Gitea so markiert, mit Beta-Vorsatz oder mit Kennung im Tag
|
||||
function istVorab(release) {
|
||||
return !!release.prerelease || BETA_VORSATZ.test(normVersion(release.tag_name)) || !!parsePreRelease(tagVersion(release.tag_name));
|
||||
}
|
||||
const UA = 'AeroMC-Launcher';
|
||||
// Downloads nur von hier – eine Release-Datei mit fremder Adresse wird abgelehnt
|
||||
const ERLAUBTER_HOST = 'git.viper.ipv64.net';
|
||||
// Die Signaturen liegen nicht im Gitea-Release, sondern auf der WordPress-Seite
|
||||
// (Plugin aeromc-friends, includes/signaturen.php). Hochgeladen werden sie beim
|
||||
// Bauen automatisch von scripts/sign-release.js.
|
||||
const SIGNATUR_API = 'https://viper-network.de/wp-json/aeromc/v1/update-signatur';
|
||||
|
||||
/*
|
||||
* Sicherheit: Jede Update-Datei muss mit dem privaten Release-Schlüssel signiert
|
||||
* sein (Ed25519). Der liegt nur auf dem Rechner, auf dem gebaut wird – nie auf
|
||||
* dem Server. Wer den Gitea-Server oder das Konto übernimmt, kann deshalb zwar
|
||||
* Dateien hochladen, aber keine gültige Signatur erzeugen; der Launcher
|
||||
* installiert so etwas nicht. Signiert wird beim Bauen von
|
||||
* scripts/sign-release.js; die Signatur liegt auf einem zweiten Server
|
||||
* (SIGNATUR_API) – woher sie kommt, ist für die Sicherheit aber egal, fälschen
|
||||
* kann sie ohne den Schlüssel niemand.
|
||||
*
|
||||
* Unterschrieben wird nicht nur der Inhalt (SHA-256), sondern auch Dateiname
|
||||
* und Version. So lässt sich weder eine Portable- als Setup-Datei ausgeben noch
|
||||
* eine alte, signierte Fassung als "neues" Update unterschieben.
|
||||
*/
|
||||
const SIGNATUR_FORMAT = 'AeroMC-Update/1';
|
||||
let oeffentlicherSchluessel = null;
|
||||
function releaseSchluessel() {
|
||||
if (!oeffentlicherSchluessel) {
|
||||
const pem = fs.readFileSync(path.join(__dirname, 'update-schluessel.pem'), 'utf8');
|
||||
oeffentlicherSchluessel = crypto.createPublicKey(pem);
|
||||
}
|
||||
return oeffentlicherSchluessel;
|
||||
}
|
||||
|
||||
function normVersion(v) {
|
||||
return String(v || '').trim().replace(/^v/i, '');
|
||||
}
|
||||
|
||||
// Der Text, der unterschrieben wird (identisch in scripts/sign-release.js)
|
||||
function signaturText(datei, version, sha256) {
|
||||
return Buffer.from([SIGNATUR_FORMAT, datei, normVersion(version), String(sha256).toLowerCase()].join('\n'), 'utf8');
|
||||
}
|
||||
|
||||
// Prüft eine Signatur (Inhalt der .sig-Datei) gegen Name, Version und Hash
|
||||
function pruefeSignatur(sig, datei, version, sha256) {
|
||||
if (!sig || sig.format !== SIGNATUR_FORMAT) throw new Error('Signaturdatei hat ein unbekanntes Format.');
|
||||
if (sig.datei !== datei) throw new Error('Signatur gehört zu einer anderen Datei.');
|
||||
if (normVersion(sig.version) !== normVersion(version)) throw new Error('Signatur gehört zu einer anderen Version.');
|
||||
if (String(sig.sha256 || '').toLowerCase() !== String(sha256).toLowerCase()) {
|
||||
throw new Error('Die heruntergeladene Datei passt nicht zur Signatur – Update abgebrochen.');
|
||||
}
|
||||
const ok = crypto.verify(null, signaturText(datei, version, sha256),
|
||||
releaseSchluessel(), Buffer.from(String(sig.signatur || ''), 'base64'));
|
||||
if (!ok) throw new Error('Ungültige Signatur – die Datei stammt nicht vom AeroMC-Entwickler. Update abgebrochen.');
|
||||
}
|
||||
|
||||
function erlaubteAdresse(adresse) {
|
||||
try {
|
||||
const u = new URL(String(adresse || ''));
|
||||
return u.protocol === 'https:' && u.hostname.toLowerCase() === ERLAUBTER_HOST;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
/*
|
||||
* Signatur einer Release-Datei vom WordPress-Server holen.
|
||||
* -> Signatur-Objekt, null wenn es (noch) keine gibt; wirft bei Netzwerkfehlern.
|
||||
*/
|
||||
async function holeSignatur(datei, version) {
|
||||
const adresse = `${SIGNATUR_API}?datei=${encodeURIComponent(datei)}&version=${encodeURIComponent(normVersion(version))}`;
|
||||
const res = await fetch(adresse, { headers: { 'User-Agent': UA, Accept: 'application/json' } });
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) throw new Error('Signatur-Server antwortet mit HTTP ' + res.status);
|
||||
const text = await res.text();
|
||||
if (text.length > 16384) throw new Error('Signatur ist ungültig.');
|
||||
const sig = JSON.parse(text);
|
||||
if (!sig || typeof sig !== 'object' || sig.datei !== datei || normVersion(sig.version) !== normVersion(version)) {
|
||||
throw new Error('Signatur passt nicht zu diesem Update.');
|
||||
}
|
||||
return { format: sig.format, datei: sig.datei, version: sig.version, sha256: sig.sha256, signatur: sig.signatur };
|
||||
}
|
||||
|
||||
function sha256Datei(datei) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const h = crypto.createHash('sha256');
|
||||
fs.createReadStream(datei).on('error', reject).on('data', (d) => h.update(d)).on('end', () => resolve(h.digest('hex')));
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* Paketart der laufenden Anwendung: 'portable', 'setup' oder 'unknown'.
|
||||
*
|
||||
* Wichtig: es wird nie geraten. Portable-Nutzer dürfen niemals einen Installer
|
||||
* bekommen und Setup-Nutzer niemals eine Portable-Datei – im Zweifel lieber
|
||||
* 'unknown' und gar kein automatisches Update anbieten.
|
||||
*/
|
||||
let cachedKind = null;
|
||||
function detectInstallKind() {
|
||||
if (cachedKind) return cachedKind;
|
||||
cachedKind = computeInstallKind();
|
||||
return cachedKind;
|
||||
}
|
||||
|
||||
function computeInstallKind() {
|
||||
// 1. Portable: diese Variablen setzt ausschließlich der Portable-Build von
|
||||
// electron-builder – das ist das verlässlichste Signal überhaupt.
|
||||
if (String(process.env.PORTABLE_EXECUTABLE_FILE || '').trim()) return 'portable';
|
||||
if (String(process.env.PORTABLE_EXECUTABLE_DIR || '').trim()) return 'portable';
|
||||
if (String(process.env.PORTABLE_EXECUTABLE_APP_FILENAME || '').trim()) return 'portable';
|
||||
|
||||
const exe = process.execPath || '';
|
||||
// 2. Notnagel über den Dateinamen, falls die Variablen einmal fehlen
|
||||
if (/portable/i.test(path.basename(exe))) return 'portable';
|
||||
|
||||
// 3. Setup: das Setup legt neben der EXE immer "Uninstall AeroMC.exe" ab. Nur wenn der
|
||||
// da ist, ist es wirklich eine Installation.
|
||||
if (hasUninstaller(path.dirname(exe))) return 'setup';
|
||||
|
||||
// 4. Alles andere (Entwicklungsmodus, entpackter Ordner, unbekannte Kopie)
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function hasUninstaller(dir) {
|
||||
try {
|
||||
return fs.readdirSync(dir).some((n) => /^unins(tall)?.*\.exe$/i.test(n));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Pfad der laufenden Portable-EXE (die Datei, die der Nutzer selbst gestartet hat)
|
||||
function portableTarget() {
|
||||
const p = String(process.env.PORTABLE_EXECUTABLE_FILE || '').trim();
|
||||
if (p) return p;
|
||||
// Fallback: nur brauchbar, wenn die EXE nicht entpackt ausgeführt wird
|
||||
const exe = process.execPath || '';
|
||||
return path.basename(exe).toLowerCase().includes('portable') ? exe : '';
|
||||
}
|
||||
|
||||
function updateDir() {
|
||||
return path.join(os.tmpdir(), 'aeromc-update');
|
||||
}
|
||||
|
||||
/*
|
||||
* Räumt Reste alter Update-Vorgänge weg (Installer sind mehrere hundert MB groß).
|
||||
* Dateien, die jünger als 10 Minuten sind, bleiben liegen – sie könnten gerade
|
||||
* von einem laufenden Update benutzt werden.
|
||||
*/
|
||||
function cleanupUpdateCache() {
|
||||
try {
|
||||
const dir = updateDir();
|
||||
if (!fs.existsSync(dir)) return;
|
||||
for (const name of fs.readdirSync(dir)) {
|
||||
const p = path.join(dir, name);
|
||||
try {
|
||||
if (Date.now() - fs.statSync(p).mtimeMs < 600000) continue;
|
||||
fs.rmSync(p, { force: true, recursive: true });
|
||||
} catch { /* gesperrt – beim nächsten Start erneut versuchen */ }
|
||||
}
|
||||
} catch { /* egal */ }
|
||||
}
|
||||
|
||||
// "v1.2.3-beta.2" -> [1,2,3]; führendes "v" wird ignoriert
|
||||
function parseVersion(v) {
|
||||
const m = String(v || '').trim().replace(/^v/i, '').match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
|
||||
if (!m) return null;
|
||||
return [Number(m[1] || 0), Number(m[2] || 0), Number(m[3] || 0)];
|
||||
}
|
||||
|
||||
// Vorabkennung hinter dem ersten "-", z. B. "beta" oder "beta.2"
|
||||
function parsePreRelease(v) {
|
||||
const m = String(v || '').trim().replace(/^v/i, '').match(/^\d+(?:\.\d+){0,2}-([0-9A-Za-z.\-]+)/);
|
||||
return m ? m[1].split('.') : null;
|
||||
}
|
||||
|
||||
/*
|
||||
* Vergleicht die Vorabkennungen zweier gleicher Versionsnummern – wie SemVer:
|
||||
* Die fertige Fassung ist neuer als jede Vorabfassung derselben Nummer
|
||||
* ("1.3.0" > "1.3.0-beta.2"), sonst könnten Beta-Nutzer nie auf die fertige
|
||||
* Version wechseln.
|
||||
*
|
||||
* Früher zählte eine Vorabkennung nur, wenn beide Seiten eine hatten – als
|
||||
* 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
|
||||
* mehr passieren: Die Signatur bindet die Version (siehe pruefeSignatur), und
|
||||
* sign-release.js nimmt sie aus derselben package.json wie die App. Ein Release,
|
||||
* bei dem Tag und App-Version nicht übereinstimmen, lässt sich gar nicht
|
||||
* installieren.
|
||||
*/
|
||||
function comparePreRelease(a, b) {
|
||||
if (!a && !b) return 0;
|
||||
if (!a) return 1; // a ist die fertige Fassung, b eine Vorabfassung
|
||||
if (!b) return -1;
|
||||
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
||||
const x = a[i]; const y = b[i];
|
||||
if (x === undefined) return -1;
|
||||
if (y === undefined) return 1;
|
||||
const nx = /^\d+$/.test(x) ? Number(x) : null;
|
||||
const ny = /^\d+$/.test(y) ? Number(y) : null;
|
||||
if (nx !== null && ny !== null) { if (nx !== ny) return nx - ny; continue; }
|
||||
if (nx !== null) return -1; // Zahlen zählen weniger als Text
|
||||
if (ny !== null) return 1;
|
||||
if (x !== y) return x < y ? -1 : 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// >0 wenn a neuer als b
|
||||
function compareVersions(a, b) {
|
||||
const x = parseVersion(a); const y = parseVersion(b);
|
||||
if (!x || !y) return 0;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (x[i] !== y[i]) return x[i] - y[i];
|
||||
}
|
||||
return comparePreRelease(parsePreRelease(a), parsePreRelease(b));
|
||||
}
|
||||
|
||||
/*
|
||||
* Paketart einer Release-Datei am Namen erkennen: 'portable', 'setup' oder null.
|
||||
* Namen, die auf beides passen (z. B. "AeroMC-Portable-Setup.exe"), gelten als
|
||||
* mehrdeutig und werden bewusst niemandem angeboten – lieber kein Update als
|
||||
* das falsche.
|
||||
*/
|
||||
function assetKind(name) {
|
||||
const n = String(name || '');
|
||||
if (!/\.exe$/i.test(n)) return null;
|
||||
const portable = /portable/i.test(n);
|
||||
const setup = /setup|install(er)?/i.test(n);
|
||||
if (portable && setup) return null;
|
||||
if (portable) return 'portable';
|
||||
if (setup) return 'setup';
|
||||
return null; // ohne Kennzeichnung im Namen nicht zuzuordnen
|
||||
}
|
||||
|
||||
// passendes Windows-Artefakt im Release finden
|
||||
function pickAsset(release, installKind) {
|
||||
if (installKind !== 'portable' && installKind !== 'setup') return null;
|
||||
const assets = release.assets || [];
|
||||
return assets.find((a) => assetKind(a.name) === installKind) || null;
|
||||
}
|
||||
|
||||
/*
|
||||
* Prüft auf Updates.
|
||||
* -> { available, currentVersion, latestVersion, notes, asset, url, noReleases }
|
||||
*/
|
||||
async function checkForUpdate(currentVersion, opts) {
|
||||
const installKind = detectInstallKind();
|
||||
const kopf = { headers: { 'User-Agent': UA, Accept: 'application/json' } };
|
||||
const res = await fetch(API + '?limit=30', kopf);
|
||||
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);
|
||||
|
||||
if (!alle.length) {
|
||||
return { available: false, noReleases: true, currentVersion };
|
||||
}
|
||||
// Vorabfassungen (Beta, RC …) bekommt nur, wer selbst eine Vorabfassung nutzt.
|
||||
// Wer die fertige Fassung installiert hat, bleibt auf fertigen Versionen.
|
||||
// Ob jemand teilnimmt, entscheidet der Schalter "An der Beta teilnehmen"
|
||||
// (opts.beta). Ohne Angabe zählt die installierte Version.
|
||||
const nutztVorab = opts && typeof opts.beta === 'boolean' ? opts.beta : !!parsePreRelease(currentVersion);
|
||||
const releases = nutztVorab ? alle : alle.filter((r) => !istVorab(r));
|
||||
if (!releases.length) {
|
||||
return { available: false, currentVersion, installKind };
|
||||
}
|
||||
// neueste anhand der Versionsnummer (nicht nach Datum, damit Nachträge nicht stören)
|
||||
releases.sort((a, b) => compareVersions(tagVersion(b.tag_name), tagVersion(a.tag_name)));
|
||||
const latest = releases[0];
|
||||
const latestVersion = tagVersion(latest.tag_name);
|
||||
const available = compareVersions(latestVersion, currentVersion) > 0;
|
||||
let asset = pickAsset(latest, installKind);
|
||||
// ohne Signatur oder von fremder Adresse wird nichts automatisch installiert
|
||||
let unsigniert = false;
|
||||
let signaturHinweis = '';
|
||||
let sig = null;
|
||||
if (asset && !erlaubteAdresse(asset.browser_download_url)) {
|
||||
asset = null;
|
||||
unsigniert = true;
|
||||
} else if (asset && available) {
|
||||
try {
|
||||
sig = await holeSignatur(asset.name, latestVersion);
|
||||
} catch (err) {
|
||||
signaturHinweis = 'Die Signatur des Updates ließ sich gerade nicht abrufen (' + (err.message || err) + '). Bitte später erneut versuchen.';
|
||||
}
|
||||
if (!sig) {
|
||||
asset = null;
|
||||
unsigniert = !signaturHinweis;
|
||||
}
|
||||
}
|
||||
return {
|
||||
available,
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
installKind,
|
||||
unsigniert,
|
||||
signaturHinweis,
|
||||
notes: latest.body || '',
|
||||
published: latest.published_at || latest.created_at || null,
|
||||
asset: asset ? {
|
||||
name: asset.name, url: asset.browser_download_url, size: asset.size, kind: installKind,
|
||||
version: latestVersion, signatur: sig,
|
||||
} : null,
|
||||
url: latest.html_url,
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
* Wirft, sobald eine Datei nicht zweifelsfrei zur laufenden Paketart gehört.
|
||||
* Wird vor dem Download und noch einmal direkt vor dem Ausführen aufgerufen.
|
||||
*/
|
||||
function assertMatchingKind(fileName, installKind) {
|
||||
if (installKind !== 'portable' && installKind !== 'setup') {
|
||||
throw new Error('Die Paketart des Launchers lässt sich nicht sicher bestimmen – bitte manuell aktualisieren.');
|
||||
}
|
||||
const kind = assetKind(fileName);
|
||||
if (!kind) {
|
||||
throw new Error(`"${fileName}" lässt sich keiner Paketart zuordnen – bitte manuell aktualisieren.`);
|
||||
}
|
||||
if (kind !== installKind) {
|
||||
const soll = installKind === 'portable' ? 'eine Portable-Datei' : 'einen Installer';
|
||||
const ist = kind === 'portable' ? 'eine Portable-Datei' : 'ein Installer';
|
||||
throw new Error(`Falsches Paket: Dieser Launcher braucht ${soll}, angeboten wurde ${ist}.`);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Lädt den Installer herunter (mit Fortschritt) und startet ihn.
|
||||
* onProgress: ({ phase, loaded, total, percent })
|
||||
*/
|
||||
async function downloadAndInstall(asset, onProgress) {
|
||||
if (!asset || !asset.url) throw new Error('Kein Installer im Release gefunden.');
|
||||
const installKind = detectInstallKind();
|
||||
// Paketart am echten Dateinamen prüfen, nicht am mitgelieferten Feld –
|
||||
// sonst kontrolliert sich die Angabe nur selbst.
|
||||
assertMatchingKind(asset.name, installKind);
|
||||
|
||||
if (!erlaubteAdresse(asset.url)) {
|
||||
throw new Error('Das Update kommt nicht vom AeroMC-Server – abgebrochen.');
|
||||
}
|
||||
// ohne Signatur wird gar nicht erst heruntergeladen
|
||||
const signatur = asset.signatur;
|
||||
if (!signatur || signatur.datei !== asset.name || normVersion(signatur.version) !== normVersion(asset.version)) {
|
||||
throw new Error('Signatur passt nicht zu diesem Update – abgebrochen.');
|
||||
}
|
||||
|
||||
// Portable-Updates ersetzen später die laufende EXE – das geht nur, wenn wir sie kennen
|
||||
const target = installKind === 'portable' ? portableTarget() : '';
|
||||
if (installKind === 'portable' && !target) {
|
||||
throw new Error('Der Pfad der laufenden Portable-Datei ist unbekannt – bitte manuell aktualisieren.');
|
||||
}
|
||||
|
||||
const dir = updateDir();
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
let dest = path.join(dir, asset.name.replace(/[^\w.\-]/g, '_'));
|
||||
try {
|
||||
fs.rmSync(dest, { force: true });
|
||||
} catch {
|
||||
// Alte Datei ist gesperrt (Virenscanner prüft sie gerade, oder ein vorheriger
|
||||
// Installer-Versuch läuft noch) - dann eben unter neuem Namen laden, statt mit
|
||||
// EPERM abzubrechen. Der Rest wird bei nächster Gelegenheit von
|
||||
// cleanupUpdateCache() aufgeräumt (ab 10 Minuten Alter).
|
||||
const parsed = path.parse(dest);
|
||||
dest = path.join(parsed.dir, `${parsed.name}-${Date.now()}${parsed.ext}`);
|
||||
}
|
||||
|
||||
const res = await fetch(asset.url, { headers: { 'User-Agent': UA } });
|
||||
if (!res.ok) throw new Error('Download fehlgeschlagen: HTTP ' + res.status);
|
||||
|
||||
const total = Number(res.headers.get('content-length')) || asset.size || 0;
|
||||
|
||||
// direkt auf die Platte schreiben statt alles im Speicher zu sammeln
|
||||
const out = fs.createWriteStream(dest);
|
||||
const hash = crypto.createHash('sha256');
|
||||
let loaded = 0;
|
||||
let lastReport = 0;
|
||||
try {
|
||||
for await (const chunk of res.body) {
|
||||
const buf = Buffer.from(chunk);
|
||||
hash.update(buf);
|
||||
loaded += buf.length;
|
||||
// auf den Puffer warten; der jeweils andere Listener wird mit abgeräumt,
|
||||
// sonst sammeln sich über den ganzen Download hunderte davon an
|
||||
if (!out.write(buf)) await new Promise((resolve, reject) => {
|
||||
const onDrain = () => { out.off('error', onError); resolve(); };
|
||||
const onError = (e) => { out.off('drain', onDrain); reject(e); };
|
||||
out.once('drain', onDrain);
|
||||
out.once('error', onError);
|
||||
});
|
||||
// höchstens viermal pro Sekunde melden, sonst flutet es die Oberfläche
|
||||
if (onProgress && Date.now() - lastReport >= 250) {
|
||||
lastReport = Date.now();
|
||||
onProgress({ phase: 'download', loaded, total, percent: total ? Math.round((loaded / total) * 100) : 0 });
|
||||
}
|
||||
}
|
||||
await new Promise((resolve, reject) => {
|
||||
out.once('error', reject);
|
||||
out.end((err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
} catch (err) {
|
||||
out.destroy();
|
||||
fs.rmSync(dest, { force: true });
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (total && loaded !== total) {
|
||||
fs.rmSync(dest, { force: true });
|
||||
throw new Error('Download unvollständig.');
|
||||
}
|
||||
|
||||
const sha256 = hash.digest('hex');
|
||||
try {
|
||||
pruefeSignatur(signatur, asset.name, asset.version, sha256);
|
||||
} catch (err) {
|
||||
fs.rmSync(dest, { force: true });
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (onProgress) onProgress({ phase: 'ready', loaded, total: loaded, percent: 100 });
|
||||
return { path: dest, bytes: loaded, sha256, signatur, name: asset.name, version: asset.version, kind: installKind, target };
|
||||
}
|
||||
|
||||
/*
|
||||
* Hilfsskript für Portable-Updates: wartet, bis der Launcher die eigene EXE
|
||||
* freigibt, tauscht sie gegen die neue aus und startet sie wieder.
|
||||
* "ping" statt "timeout", weil timeout ohne Konsole abbricht.
|
||||
*/
|
||||
const SWAP_SCRIPT = `@echo off
|
||||
setlocal
|
||||
set "SRC=%~1"
|
||||
set "DST=%~2"
|
||||
set "LOG=%~dp0update.log"
|
||||
set TRIES=0
|
||||
:loop
|
||||
ping -n 2 127.0.0.1 >nul
|
||||
copy /y "%SRC%" "%DST%" >nul 2>&1
|
||||
if not errorlevel 1 goto done
|
||||
set /a TRIES+=1
|
||||
if %TRIES% GEQ 60 goto fail
|
||||
goto loop
|
||||
:done
|
||||
del /f /q "%SRC%" >nul 2>&1
|
||||
start "" "%DST%"
|
||||
exit /b 0
|
||||
:fail
|
||||
echo [%DATE% %TIME%] Konnte "%DST%" nicht ersetzen - Datei gesperrt oder schreibgeschuetzt.>>"%LOG%"
|
||||
exit /b 1
|
||||
`;
|
||||
|
||||
/*
|
||||
* Wendet das geladene Update an.
|
||||
* setup -> AeroMC-Setup mit --update starten, es überschreibt die Installation selbst
|
||||
* portable -> Skript starten, das die laufende EXE nach dem Beenden ersetzt
|
||||
* Der Launcher muss sich danach beenden.
|
||||
*/
|
||||
async function applyUpdate(file) {
|
||||
const installKind = detectInstallKind();
|
||||
// letzte Kontrolle unmittelbar vor dem Start: passt die Datei auf der Platte
|
||||
// wirklich zu dieser Paketart – und ist sie noch genau die signierte? (Zwischen
|
||||
// Download und Start könnte sie im Temp-Ordner ausgetauscht worden sein.)
|
||||
assertMatchingKind(path.basename(file.path || ''), installKind);
|
||||
pruefeSignatur(file.signatur, file.name, file.version, await sha256Datei(file.path));
|
||||
const kind = installKind;
|
||||
|
||||
if (kind !== 'portable') {
|
||||
// --update: das AeroMC-Setup aktualisiert ohne Rückfragen und startet AeroMC danach neu
|
||||
const child = spawn(file.path, ['--update'], { detached: true, stdio: 'ignore' });
|
||||
child.unref();
|
||||
return { started: 'installer' };
|
||||
}
|
||||
|
||||
const target = file.target || portableTarget();
|
||||
if (!target) throw new Error('Der Pfad der laufenden Portable-Datei ist unbekannt.');
|
||||
|
||||
const script = path.join(updateDir(), 'aeromc-swap.cmd');
|
||||
fs.writeFileSync(script, SWAP_SCRIPT, 'ascii');
|
||||
|
||||
// über "start /min", damit kein Konsolenfenster im Vordergrund stehen bleibt
|
||||
const child = spawn(process.env.COMSPEC || 'cmd.exe',
|
||||
['/c', 'start', '', '/min', script, file.path, target],
|
||||
{ detached: true, stdio: 'ignore', windowsHide: true });
|
||||
child.unref();
|
||||
return { started: 'swap', target };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
checkForUpdate,
|
||||
downloadAndInstall,
|
||||
applyUpdate,
|
||||
pruefeSignatur,
|
||||
signaturText,
|
||||
holeSignatur,
|
||||
tagVersion,
|
||||
istVorabVersion: (v) => !!parsePreRelease(v),
|
||||
SIGNATUR_API,
|
||||
cleanupUpdateCache,
|
||||
compareVersions,
|
||||
parseVersion,
|
||||
detectInstallKind,
|
||||
assetKind,
|
||||
portableTarget,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "bin",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"ws": "8.22.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.22.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.1.tgz",
|
||||
"integrity": "sha512-oyL6OPYme/CmiFvNuGzeoxCJ4nOrZE2O1FwM9MWM1AsGri6nLXB2pg+lNA8aLeXMSav0J6eIVS1j6vUPzUtK9g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"type":"module","dependencies":{"ws":"8.22.1"}}
|
||||
@@ -0,0 +1,536 @@
|
||||
// AeroMC-Plugin für das Stream Deck.
|
||||
// Spricht direkt das Stream-Deck-Protokoll (WebSocket) und mit dem Launcher über
|
||||
// dessen lokale Schnittstelle (Einstellung "Stream Deck erlauben" in AeroMC).
|
||||
// Port und Schlüssel stehen in %APPDATA%\AeroMC\streamdeck.json.
|
||||
|
||||
import WebSocket from 'ws';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const A = (n) => 'com.aeromc.launcher.' + n;
|
||||
const AKTION_INSTANZ = A('instanz');
|
||||
const AKTION_CLIP = A('clip');
|
||||
const AKTION_AUFNAHME = A('aufnahme');
|
||||
const AKTION_SERVER = A('server');
|
||||
const AKTION_ZULETZT = A('zuletzt');
|
||||
const AKTION_FENSTER = A('fenster');
|
||||
const AKTION_CLIPMODUS = A('clipmodus');
|
||||
const AKTION_NEUSTART = A('neustart');
|
||||
const AKTION_ORDNER = A('ordner');
|
||||
const AKTION_SPIELZEIT = A('spielzeit');
|
||||
const AKTION_SERVERSTATUS = A('serverstatus');
|
||||
const AKTION_SCREENSHOT = A('screenshot');
|
||||
const AKTION_DREH = A('dreh');
|
||||
const AKTION_FREUNDE = A('freunde');
|
||||
const AKTION_KONTO = A('konto');
|
||||
const AKTION_SCHNELL = A('schnellstart');
|
||||
const AKTION_BACKUP = A('backup');
|
||||
const AKTION_LOGS = A('logs');
|
||||
const AKTION_KONSOLE = A('konsole');
|
||||
const AKTION_ABSTURZ = A('absturz');
|
||||
const CRASH_MS = 30000;
|
||||
const ABFRAGE_MS = 2000;
|
||||
const PING_MS = 30000;
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const arg = (name) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : null; };
|
||||
const port = arg('-port');
|
||||
const pluginUUID = arg('-pluginUUID');
|
||||
const registerEvent = arg('-registerEvent');
|
||||
|
||||
const tasten = new Map(); // context -> { action, settings }
|
||||
let stand = null; // letzte Antwort von /state, null = Launcher nicht erreichbar
|
||||
let meldung = null; // kurze Rückmeldung nach einem Tastendruck { context, text, bis }
|
||||
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
const sende = (obj) => { if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj)); };
|
||||
|
||||
// ---------- Launcher ----------
|
||||
|
||||
function verbindung() {
|
||||
const datei = path.join(process.env.APPDATA || '', 'AeroMC', 'streamdeck.json');
|
||||
try { return JSON.parse(fs.readFileSync(datei, 'utf8')); } catch { return null; }
|
||||
}
|
||||
|
||||
async function anfrage(methode, pfad, body) {
|
||||
const v = verbindung();
|
||||
if (!v) return null;
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${v.port}${pfad}`, {
|
||||
method: methode,
|
||||
headers: { Authorization: `Bearer ${v.token}`, 'Content-Type': 'application/json' },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
return await res.json();
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
async function holeStand() {
|
||||
// Freunde nur anfordern, wenn eine Freunde-Taste existiert (der Launcher fragt dafür den Server)
|
||||
const mitFreunden = [...tasten.values()].some((t) => t.action === AKTION_FREUNDE);
|
||||
const s = await anfrage('GET', '/state' + (mitFreunden ? '?freunde=1' : ''));
|
||||
stand = s && s.ok ? s : null;
|
||||
}
|
||||
|
||||
// ---------- Tastenbild ----------
|
||||
|
||||
const esc = (t) => String(t).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||
|
||||
function zeilen(text, breite = 9) {
|
||||
const woerter = String(text || '').split(/\s+/).filter(Boolean);
|
||||
const out = [];
|
||||
let zeile = '';
|
||||
for (const w of woerter) {
|
||||
if ((zeile + ' ' + w).trim().length <= breite) zeile = (zeile + ' ' + w).trim();
|
||||
else { if (zeile) out.push(zeile); zeile = w; }
|
||||
}
|
||||
if (zeile) out.push(zeile);
|
||||
return out.slice(0, 2).map((z) => (z.length > breite ? z.slice(0, breite - 1) + '…' : z));
|
||||
}
|
||||
|
||||
function dauer(seit) {
|
||||
const s = Math.max(0, Math.floor((Date.now() - seit) / 1000));
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
return h ? `${h}:${String(m).padStart(2, '0')} h` : `${m} min`;
|
||||
}
|
||||
|
||||
// AeroMC-Logo einmal einlesen und in jedes Tastenbild einbetten
|
||||
let logo = '';
|
||||
try {
|
||||
const datei = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'imgs', 'actions', 'key@2x.png');
|
||||
logo = 'data:image/png;base64,' + fs.readFileSync(datei).toString('base64');
|
||||
} catch { /* ohne Logo geht es auch */ }
|
||||
|
||||
function bild({ farbe, kopf, titel, fuss, symbol }) {
|
||||
const sym = symbol && symbol.bild ? symbol : (logo ? { bild: logo, pixel: false } : null);
|
||||
const t = zeilen(titel);
|
||||
const texte = t.map((z, i) => `<text x="72" y="${92 + i * 20}" font-size="18" font-weight="700" fill="#fff" text-anchor="middle">${esc(z)}</text>`).join('');
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="144" height="144" viewBox="0 0 144 144" font-family="Segoe UI, Arial, sans-serif">`
|
||||
+ `<rect width="144" height="144" rx="14" fill="#14161f"/>`
|
||||
+ (sym ? `<image href="${sym.bild}" x="48" y="4" width="48" height="48" preserveAspectRatio="xMidYMid meet"${sym.pixel ? ' style="image-rendering:pixelated"' : ''}/>` : '')
|
||||
+ `<text x="72" y="68" font-size="14" font-weight="700" fill="${farbe}" text-anchor="middle">${esc(kopf)}</text>`
|
||||
+ texte
|
||||
+ `<text x="72" y="134" font-size="14" fill="#aab" text-anchor="middle">${esc(fuss)}</text>`
|
||||
+ `<rect x="0" y="138" width="144" height="6" fill="${farbe}"/></svg>`;
|
||||
return 'data:image/svg+xml;charset=utf8,' + encodeURIComponent(svg);
|
||||
}
|
||||
|
||||
const GRAU = '#6b7280', GRUEN = '#34d399', ROT = '#f87171', GELB = '#fbbf24', BLAU = '#60a5fa';
|
||||
|
||||
const symbole = new Map(); // Instanz-ID -> { bildId, bild, pixel }
|
||||
|
||||
const koepfe = new Map(); // Konto-UUID -> { bild }
|
||||
const abstuerze = new Map(); // Instanz-ID -> { zeit(Abfrage), bericht, frisch, berichtZeit }
|
||||
|
||||
function symbolKonto(uuid) {
|
||||
const x = uuid && koepfe.get(uuid);
|
||||
return x && x.bild ? x : null;
|
||||
}
|
||||
|
||||
// Instanz, die gerade läuft, sonst die zuletzt gespielte - für Tasten ohne feste Auswahl
|
||||
function standardInstanz() {
|
||||
const l = (stand && stand.instanzen) || [];
|
||||
const lauf = l.find((x) => x.laeuft);
|
||||
return lauf ? lauf.id : (stand && stand.zuletzt) || null;
|
||||
}
|
||||
|
||||
function vor(ms) {
|
||||
const min = Math.max(0, Math.floor((Date.now() - ms) / 60000));
|
||||
if (min < 1) return 'gerade eben';
|
||||
if (min < 60) return 'vor ' + min + ' min';
|
||||
if (min < 1440) return 'vor ' + Math.floor(min / 60) + ' h';
|
||||
return 'vor ' + Math.floor(min / 1440) + ' Tg.';
|
||||
}
|
||||
|
||||
async function pollAbstuerze(erzwingen) {
|
||||
const ids = new Set();
|
||||
for (const t of tasten.values()) if (t.action === AKTION_ABSTURZ) { const id = instanzId(t); if (id) ids.add(id); }
|
||||
for (const id of ids) {
|
||||
const alt = abstuerze.get(id);
|
||||
if (!erzwingen && alt && Date.now() - alt.zeit < CRASH_MS) continue;
|
||||
const r = await anfrage('GET', '/crash?id=' + encodeURIComponent(id));
|
||||
abstuerze.set(id, { zeit: Date.now(), bericht: !!(r && r.bericht), frisch: !!(r && r.frisch), berichtZeit: r && r.zeit });
|
||||
}
|
||||
}
|
||||
|
||||
function symbolFuer(id) {
|
||||
const x = id && symbole.get(id);
|
||||
return x && x.bild ? x : null;
|
||||
}
|
||||
|
||||
// Instanz-Bilder (eigenes Bild oder Minecraft-Icon) nachladen, sobald sie gebraucht werden oder sich ändern
|
||||
async function holeSymbole() {
|
||||
if (!stand) return;
|
||||
const ids = new Set();
|
||||
for (const t of tasten.values()) { const id = instanzId(t); if (id) ids.add(id); }
|
||||
for (const t of tasten.values()) {
|
||||
const u = t.action === AKTION_KONTO && t.settings.kontoId;
|
||||
if (u && !koepfe.has(u)) {
|
||||
const r = await anfrage('GET', '/icon?konto=' + encodeURIComponent(u));
|
||||
koepfe.set(u, { bild: r && r.ok ? r.bild : null });
|
||||
}
|
||||
}
|
||||
for (const id of ids) {
|
||||
const inst = (stand.instanzen || []).find((x) => x.id === id);
|
||||
if (!inst) continue;
|
||||
const alt = symbole.get(id);
|
||||
if (alt && alt.bildId === inst.bildId) continue;
|
||||
const r = await anfrage('GET', '/icon?id=' + encodeURIComponent(id));
|
||||
symbole.set(id, { bildId: inst.bildId, bild: r && r.ok ? r.bild : null, pixel: !!(r && r.pixel) });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Drehregler (Stream Deck+) ----------
|
||||
|
||||
function dialListe() { return (stand && stand.instanzen) || []; }
|
||||
|
||||
function dialIndex(t) {
|
||||
const l = dialListe();
|
||||
let i = l.findIndex((x) => x.id === t.settings.instanceId);
|
||||
if (i < 0) i = l.findIndex((x) => x.id === (stand && stand.zuletzt));
|
||||
return i < 0 ? 0 : i;
|
||||
}
|
||||
|
||||
function zeichneDial(context) {
|
||||
const t = tasten.get(context);
|
||||
if (!t) return;
|
||||
const l = dialListe();
|
||||
let fb;
|
||||
if (meldung && meldung.context === context && meldung.bis > Date.now()) {
|
||||
fb = { title: 'HINWEIS', value: meldung.text, icon: logo || '', indicator: { value: 0 } };
|
||||
} else if (!stand) {
|
||||
fb = { title: 'AeroMC', value: 'Launcher aus', icon: logo || '', indicator: { value: 0 } };
|
||||
} else if (!l.length) {
|
||||
fb = { title: 'AeroMC', value: 'Keine Instanz', icon: logo || '', indicator: { value: 0 } };
|
||||
} else {
|
||||
const i = dialIndex(t);
|
||||
const x = l[i];
|
||||
const sym = symbolFuer(x.id);
|
||||
fb = {
|
||||
title: x.name,
|
||||
value: x.laeuft ? '● ' + dauer(x.seit || Date.now()) : '▶ bereit',
|
||||
icon: (sym && sym.bild) || logo || '',
|
||||
indicator: { value: Math.round(((i + 1) / l.length) * 100) },
|
||||
};
|
||||
}
|
||||
sende({ event: 'setFeedback', context, payload: fb });
|
||||
}
|
||||
|
||||
const pings = new Map(); // Adresse -> { ok, online, max, latencyMs, zeit }
|
||||
|
||||
function stunden(sek) {
|
||||
if (sek < 3600) return Math.floor(sek / 60) + ' min';
|
||||
const h = sek / 3600;
|
||||
return (h < 100 ? h.toFixed(1).replace('.', ',') : Math.round(h)) + ' h';
|
||||
}
|
||||
|
||||
// Welche Instanz gehört zu dieser Taste?
|
||||
function instanzId(t) {
|
||||
if (t.action === AKTION_ZULETZT) return stand && stand.zuletzt;
|
||||
if (t.action === AKTION_SCHNELL) return (stand && stand.schnellstart && stand.schnellstart[t.settings.slot || '1']) || null;
|
||||
if (t.action === AKTION_LOGS || t.action === AKTION_ABSTURZ) return t.settings.instanceId || standardInstanz();
|
||||
if (t.action === AKTION_DREH) return stand && dialListe().length ? dialListe()[dialIndex(t)].id : null;
|
||||
return t.settings.instanceId || null;
|
||||
}
|
||||
|
||||
function adresse(t) {
|
||||
return String(t.settings.adresse || t.settings.server || '').trim();
|
||||
}
|
||||
|
||||
function zeichne(context) {
|
||||
const t = tasten.get(context);
|
||||
if (!t) return;
|
||||
if (t.action === AKTION_DREH) { zeichneDial(context); return; }
|
||||
const id = instanzId(t);
|
||||
const sym = t.action === AKTION_KONTO ? symbolKonto(t.settings.kontoId) : symbolFuer(id);
|
||||
const i = stand && id ? (stand.instanzen || []).find((x) => x.id === id) : null;
|
||||
if (i) t.name = i.name; // merken, damit die Taste auch bei ausgeschaltetem Launcher ihren Namen zeigt
|
||||
const aus = !stand;
|
||||
const name = (i && i.name) || t.name || 'Instanz';
|
||||
let b;
|
||||
if (meldung && meldung.context === context && meldung.bis > Date.now()) {
|
||||
b = bild({ symbol: sym, farbe: GELB, kopf: 'HINWEIS', titel: meldung.text, fuss: '' });
|
||||
} else switch (t.action) {
|
||||
case AKTION_INSTANZ:
|
||||
case AKTION_ZULETZT: {
|
||||
const kopfLeer = t.action === AKTION_ZULETZT ? 'ZULETZT' : 'INSTANZ';
|
||||
if (t.action === AKTION_INSTANZ && !id) b = bild({ symbol: sym, farbe: GRAU, kopf: kopfLeer, titel: 'Bitte wählen', fuss: '' });
|
||||
else if (aus) b = bild({ symbol: sym, farbe: GRAU, kopf: 'AUS', titel: name, fuss: 'Launcher aus' });
|
||||
else if (!i) b = bild({ symbol: sym, farbe: GRAU, kopf: kopfLeer, titel: t.action === AKTION_ZULETZT ? 'noch nichts gespielt' : 'nicht gefunden', fuss: '' });
|
||||
else if (i.laeuft) b = bild({ symbol: sym, farbe: GRUEN, kopf: '● LÄUFT', titel: i.name, fuss: i.seit ? dauer(i.seit) : '■ beenden' });
|
||||
else b = bild({ symbol: sym, farbe: BLAU, kopf: t.action === AKTION_ZULETZT ? '▶ ZULETZT' : '▶ START', titel: i.name, fuss: '' });
|
||||
break;
|
||||
}
|
||||
case AKTION_SERVER: {
|
||||
const adr = adresse(t);
|
||||
if (!id || !adr) b = bild({ symbol: sym, farbe: GRAU, kopf: 'SERVER', titel: 'Bitte wählen', fuss: '' });
|
||||
else if (aus) b = bild({ symbol: sym, farbe: GRAU, kopf: 'AUS', titel: adr, fuss: 'Launcher aus' });
|
||||
else if (i && i.laeuft) b = bild({ symbol: sym, farbe: GRUEN, kopf: '● LÄUFT', titel: adr, fuss: name });
|
||||
else b = bild({ symbol: sym, farbe: BLAU, kopf: '▶ BEITRETEN', titel: t.settings.serverName || adr, fuss: name });
|
||||
break;
|
||||
}
|
||||
case AKTION_NEUSTART:
|
||||
if (!id) b = bild({ symbol: sym, farbe: GRAU, kopf: 'NEUSTART', titel: 'Bitte wählen', fuss: '' });
|
||||
else if (aus) b = bild({ symbol: sym, farbe: GRAU, kopf: 'NEUSTART', titel: name, fuss: 'Launcher aus' });
|
||||
else if (i && i.laeuft) b = bild({ symbol: sym, farbe: GELB, kopf: '↻ NEUSTART', titel: name, fuss: dauer(i.seit || Date.now()) });
|
||||
else b = bild({ symbol: sym, farbe: GRAU, kopf: 'NEUSTART', titel: name, fuss: 'läuft nicht' });
|
||||
break;
|
||||
case AKTION_FENSTER:
|
||||
b = bild({ symbol: sym, farbe: aus ? GRAU : BLAU, kopf: 'LAUNCHER', titel: 'Fenster', fuss: aus ? 'Launcher aus' : 'zeigen / weg' });
|
||||
break;
|
||||
case AKTION_CLIPMODUS:
|
||||
if (aus) b = bild({ symbol: sym, farbe: GRAU, kopf: 'CLIP-MODUS', titel: 'Puffer', fuss: 'Launcher aus' });
|
||||
else if (stand.clipStartet) b = bild({ symbol: sym, farbe: GELB, kopf: 'CLIP-MODUS', titel: 'Puffer', fuss: 'startet …' });
|
||||
else b = stand.clip ? bild({ symbol: sym, farbe: GRUEN, kopf: 'CLIP-MODUS', titel: 'Puffer', fuss: '● an' }) : bild({ symbol: sym, farbe: GRAU, kopf: 'CLIP-MODUS', titel: 'Puffer', fuss: 'aus' });
|
||||
break;
|
||||
case AKTION_ORDNER:
|
||||
b = bild({ symbol: sym, farbe: aus ? GRAU : BLAU, kopf: 'ORDNER', titel: 'Aufnahmen', fuss: aus ? 'Launcher aus' : (id ? name : '') });
|
||||
break;
|
||||
case AKTION_SPIELZEIT: {
|
||||
if (aus) { b = bild({ symbol: sym, farbe: GRAU, kopf: 'SPIELZEIT', titel: '–', fuss: 'Launcher aus' }); break; }
|
||||
const liste = id ? (i ? [i] : []) : stand.instanzen || [];
|
||||
const summe = liste.reduce((n, x) => n + (x.gesamt || 0), 0);
|
||||
const lauf = liste.find((x) => x.laeuft && x.seit);
|
||||
b = lauf
|
||||
? bild({ symbol: sym, farbe: GRUEN, kopf: '● SITZUNG', titel: dauer(lauf.seit), fuss: 'gesamt ' + stunden(summe + Math.floor((Date.now() - lauf.seit) / 1000)) })
|
||||
: bild({ symbol: sym, farbe: BLAU, kopf: 'SPIELZEIT', titel: stunden(summe), fuss: id ? name : 'alle Instanzen' });
|
||||
break;
|
||||
}
|
||||
case AKTION_SERVERSTATUS: {
|
||||
const adr = adresse(t);
|
||||
const p = adr && pings.get(adr);
|
||||
if (!adr) b = bild({ symbol: sym, farbe: GRAU, kopf: 'SERVER', titel: 'Bitte wählen', fuss: '' });
|
||||
else if (aus) b = bild({ symbol: sym, farbe: GRAU, kopf: 'AUS', titel: adr, fuss: 'Launcher aus' });
|
||||
else if (!p) b = bild({ symbol: sym, farbe: GRAU, kopf: 'SERVER', titel: adr, fuss: 'prüfe …' });
|
||||
else if (!p.ok) b = bild({ symbol: sym, farbe: ROT, kopf: '● OFFLINE', titel: t.settings.serverName || adr, fuss: '' });
|
||||
else b = bild({ symbol: sym, farbe: GRUEN, kopf: '● ONLINE', titel: p.online != null ? p.online + ' Spieler' : 'online', fuss: (p.max != null ? 'max ' + p.max + ' · ' : '') + p.latencyMs + ' ms' });
|
||||
break;
|
||||
}
|
||||
case AKTION_SCHNELL: {
|
||||
const slot = t.settings.slot || '1';
|
||||
if (aus) b = bild({ symbol: sym, farbe: GRAU, kopf: 'AUS', titel: name, fuss: 'Launcher aus' });
|
||||
else if (!id || !i) b = bild({ symbol: sym, farbe: GRAU, kopf: 'STRG+' + slot, titel: 'nicht belegt', fuss: '' });
|
||||
else if (i.laeuft) b = bild({ symbol: sym, farbe: GRUEN, kopf: '● LÄUFT', titel: i.name, fuss: i.seit ? dauer(i.seit) : '■ beenden' });
|
||||
else b = bild({ symbol: sym, farbe: BLAU, kopf: '▶ STRG+' + slot, titel: i.name, fuss: '' });
|
||||
break;
|
||||
}
|
||||
case AKTION_KONTO: {
|
||||
const k = stand && (stand.konten || []).find((x) => x.id === t.settings.kontoId);
|
||||
if (!t.settings.kontoId) b = bild({ symbol: sym, farbe: GRAU, kopf: 'KONTO', titel: 'Bitte wählen', fuss: '' });
|
||||
else if (aus) b = bild({ symbol: sym, farbe: GRAU, kopf: 'AUS', titel: t.name || 'Konto', fuss: 'Launcher aus' });
|
||||
else if (!k) b = bild({ symbol: sym, farbe: ROT, kopf: 'KONTO', titel: 'nicht gefunden', fuss: '' });
|
||||
else { t.name = k.name; b = bild({ symbol: sym, farbe: k.aktiv ? GRUEN : BLAU, kopf: k.aktiv ? '● AKTIV' : 'KONTO', titel: k.name, fuss: k.spielt ? 'spielt gerade' : (k.aktiv ? '' : 'wechseln') }); }
|
||||
break;
|
||||
}
|
||||
case AKTION_FREUNDE: {
|
||||
const f = stand && stand.freunde;
|
||||
if (aus) b = bild({ symbol: sym, farbe: GRAU, kopf: 'FREUNDE', titel: '–', fuss: 'Launcher aus' });
|
||||
else if (!f) b = bild({ symbol: sym, farbe: GRAU, kopf: 'FREUNDE', titel: 'Keine Daten', fuss: 'aus / lädt' });
|
||||
else b = bild({ symbol: sym, farbe: f.online ? GRUEN : GRAU, kopf: f.online ? '● ONLINE' : 'FREUNDE', titel: f.online + ' online', fuss: 'von ' + f.gesamt });
|
||||
break;
|
||||
}
|
||||
case AKTION_BACKUP: {
|
||||
const bk = stand && stand.backup;
|
||||
const frisch = bk && bk.zeit && Date.now() - bk.zeit < 600000;
|
||||
if (aus) b = bild({ symbol: sym, farbe: GRAU, kopf: 'BACKUP', titel: 'Jetzt sichern', fuss: 'Launcher aus' });
|
||||
else if (bk && bk.laeuft) b = bild({ symbol: sym, farbe: GELB, kopf: '● LÄUFT', titel: 'Backup', fuss: 'bitte warten' });
|
||||
else if (frisch && bk.ok) b = bild({ symbol: sym, farbe: GRUEN, kopf: '✔ FERTIG', titel: 'Backup', fuss: vor(bk.zeit) });
|
||||
else if (frisch) b = bild({ symbol: sym, farbe: ROT, kopf: 'FEHLER', titel: 'Backup', fuss: vor(bk.zeit) });
|
||||
else b = bild({ symbol: sym, farbe: BLAU, kopf: 'BACKUP', titel: 'Jetzt sichern', fuss: '' });
|
||||
break;
|
||||
}
|
||||
case AKTION_LOGS:
|
||||
b = bild({ symbol: sym, farbe: aus ? GRAU : BLAU, kopf: 'LOGS', titel: 'Logs öffnen', fuss: aus ? 'Launcher aus' : (i ? i.name : '') });
|
||||
break;
|
||||
case AKTION_KONSOLE:
|
||||
b = bild({ symbol: sym, farbe: aus ? GRAU : BLAU, kopf: 'KONSOLE', titel: 'Konsole', fuss: aus ? 'Launcher aus' : '' });
|
||||
break;
|
||||
case AKTION_ABSTURZ: {
|
||||
const a = id && abstuerze.get(id);
|
||||
if (aus) b = bild({ symbol: sym, farbe: GRAU, kopf: 'ABSTURZ', titel: 'Bericht', fuss: 'Launcher aus' });
|
||||
else if (!a) b = bild({ symbol: sym, farbe: GRAU, kopf: 'ABSTURZ', titel: 'Bericht', fuss: id ? 'prüfe …' : 'keine Instanz' });
|
||||
else if (!a.bericht) b = bild({ symbol: sym, farbe: GRAU, kopf: 'ABSTURZ', titel: 'keiner', fuss: i ? i.name : '' });
|
||||
else if (a.frisch) b = bild({ symbol: sym, farbe: ROT, kopf: '⚠ ABSTURZ', titel: 'Bericht zeigen', fuss: a.berichtZeit ? vor(a.berichtZeit) : '' });
|
||||
else b = bild({ symbol: sym, farbe: GRAU, kopf: 'ABSTURZ', titel: 'kein neuer', fuss: a.berichtZeit ? 'letzter ' + vor(a.berichtZeit) : '' });
|
||||
break;
|
||||
}
|
||||
case AKTION_SCREENSHOT:
|
||||
b = bild({ symbol: sym, farbe: aus ? GRAU : BLAU, kopf: 'SCREENSHOT', titel: 'Spielfenster', fuss: aus ? 'Launcher aus' : '' });
|
||||
break;
|
||||
case AKTION_CLIP:
|
||||
if (aus) b = bild({ symbol: sym, farbe: GRAU, kopf: 'CLIP', titel: 'Clip speichern', fuss: 'Launcher aus' });
|
||||
else if (stand.clipStartet) b = bild({ symbol: sym, farbe: GELB, kopf: 'CLIP', titel: 'Clip speichern', fuss: 'startet …' });
|
||||
else b = stand.clip
|
||||
? bild({ symbol: sym, farbe: GRUEN, kopf: 'CLIP', titel: 'Clip speichern', fuss: 'bereit' })
|
||||
: bild({ symbol: sym, farbe: GRAU, kopf: 'CLIP', titel: 'Clip speichern', fuss: 'Modus aus' });
|
||||
break;
|
||||
default:
|
||||
if (aus) b = bild({ symbol: sym, farbe: GRAU, kopf: 'AUFNAHME', titel: 'Aufnahme starten', fuss: 'Launcher aus' });
|
||||
else b = stand.aufnahme
|
||||
? bild({ symbol: sym, farbe: ROT, kopf: '● REC', titel: 'Aufnahme stoppen', fuss: '' })
|
||||
: bild({ symbol: sym, farbe: BLAU, kopf: 'AUFNAHME', titel: 'Aufnahme starten', fuss: '' });
|
||||
}
|
||||
sende({ event: 'setImage', context, payload: { image: b, target: 0 } });
|
||||
}
|
||||
|
||||
const zeichneAlle = () => { for (const c of tasten.keys()) zeichne(c); };
|
||||
|
||||
async function pingeFaellige(erzwingen) {
|
||||
const adressen = new Set();
|
||||
for (const t of tasten.values()) if (t.action === AKTION_SERVERSTATUS && adresse(t)) adressen.add(adresse(t));
|
||||
for (const adr of adressen) {
|
||||
const alt = pings.get(adr);
|
||||
if (!erzwingen && alt && Date.now() - alt.zeit < PING_MS) continue;
|
||||
const r = await anfrage('GET', '/ping?ip=' + encodeURIComponent(adr));
|
||||
pings.set(adr, Object.assign({ ok: false }, r || {}, { zeit: Date.now() }));
|
||||
}
|
||||
}
|
||||
|
||||
async function abfrage() {
|
||||
if (!tasten.size) return;
|
||||
await holeStand();
|
||||
if (stand) { await holeSymbole(); await pingeFaellige(false); await pollAbstuerze(false); }
|
||||
zeichneAlle();
|
||||
}
|
||||
|
||||
// ---------- Stream-Deck-Ereignisse ----------
|
||||
|
||||
function zeigeMeldung(context, text) {
|
||||
meldung = { context, text, bis: Date.now() + 2500 };
|
||||
zeichne(context);
|
||||
setTimeout(() => zeichne(context), 2600);
|
||||
}
|
||||
|
||||
const FEHLERTEXT = {
|
||||
[AKTION_CLIP]: 'Clip-Modus aus',
|
||||
[AKTION_AUFNAHME]: 'Spiel läuft nicht',
|
||||
[AKTION_CLIPMODUS]: 'Spiel läuft nicht',
|
||||
[AKTION_NEUSTART]: 'Läuft nicht',
|
||||
[AKTION_SERVER]: 'Läuft schon',
|
||||
[AKTION_SCREENSHOT]: 'Kein Spiel',
|
||||
[AKTION_ABSTURZ]: 'Kein Bericht',
|
||||
[AKTION_BACKUP]: 'Läuft schon',
|
||||
[AKTION_SCHNELL]: 'Nicht belegt',
|
||||
};
|
||||
|
||||
async function tastendruck(context) {
|
||||
const t = tasten.get(context);
|
||||
if (!t) return;
|
||||
const id = instanzId(t);
|
||||
const i = stand && id ? (stand.instanzen || []).find((x) => x.id === id) : null;
|
||||
let erg;
|
||||
switch (t.action) {
|
||||
case AKTION_INSTANZ:
|
||||
case AKTION_ZULETZT:
|
||||
case AKTION_SCHNELL:
|
||||
case AKTION_DREH:
|
||||
if (!id) { sende({ event: 'showAlert', context }); return; }
|
||||
erg = await anfrage('POST', i && i.laeuft ? '/stop' : '/launch', { id });
|
||||
break;
|
||||
case AKTION_SCREENSHOT: erg = await anfrage('POST', '/screenshot'); break;
|
||||
case AKTION_FREUNDE: erg = await anfrage('POST', '/open', { art: 'friends' }); break;
|
||||
case AKTION_KONSOLE: erg = await anfrage('POST', '/open', { art: 'console' }); break;
|
||||
case AKTION_LOGS: erg = await anfrage('POST', '/open', { art: 'logs', id: id || '' }); break;
|
||||
case AKTION_ABSTURZ: erg = await anfrage('POST', '/open', { art: 'crash', id: id || '' }); break;
|
||||
case AKTION_BACKUP: erg = await anfrage('POST', '/backup'); break;
|
||||
case AKTION_KONTO:
|
||||
if (!t.settings.kontoId) { sende({ event: 'showAlert', context }); return; }
|
||||
erg = await anfrage('POST', '/account', { id: t.settings.kontoId });
|
||||
break;
|
||||
case AKTION_SERVER:
|
||||
if (!id || !adresse(t)) { sende({ event: 'showAlert', context }); return; }
|
||||
erg = await anfrage('POST', '/launch', { id, server: adresse(t) });
|
||||
break;
|
||||
case AKTION_NEUSTART:
|
||||
if (!id) { sende({ event: 'showAlert', context }); return; }
|
||||
erg = await anfrage('POST', '/restart', { id });
|
||||
break;
|
||||
case AKTION_FENSTER: erg = await anfrage('POST', '/window'); break;
|
||||
case AKTION_CLIPMODUS: erg = await anfrage('POST', '/clipmode'); break;
|
||||
case AKTION_ORDNER: erg = await anfrage('POST', '/folder', id ? { id } : {}); break;
|
||||
case AKTION_SPIELZEIT: zeichne(context); return;
|
||||
case AKTION_SERVERSTATUS:
|
||||
pings.delete(adresse(t));
|
||||
await pingeFaellige(true);
|
||||
zeichne(context);
|
||||
return;
|
||||
case AKTION_CLIP:
|
||||
if (stand && stand.clipStartet) { zeigeMeldung(context, 'Startet noch …'); return; }
|
||||
erg = await anfrage('POST', '/clip');
|
||||
break;
|
||||
default: erg = await anfrage('POST', '/record');
|
||||
}
|
||||
if (erg && erg.ok) sende({ event: 'showOk', context });
|
||||
else {
|
||||
sende({ event: 'showAlert', context });
|
||||
zeigeMeldung(context, !erg ? 'Launcher aus' : FEHLERTEXT[t.action] || 'Nicht möglich');
|
||||
}
|
||||
setTimeout(abfrage, 600);
|
||||
}
|
||||
|
||||
ws.on('open', () => sende({ event: registerEvent, uuid: pluginUUID }));
|
||||
|
||||
ws.on('message', async (roh) => {
|
||||
let m;
|
||||
try { m = JSON.parse(roh.toString()); } catch { return; }
|
||||
switch (m.event) {
|
||||
case 'willAppear':
|
||||
tasten.set(m.context, { action: m.action, settings: (m.payload && m.payload.settings) || {} });
|
||||
await holeStand();
|
||||
await holeSymbole();
|
||||
zeichneAlle();
|
||||
break;
|
||||
case 'willDisappear':
|
||||
tasten.delete(m.context);
|
||||
break;
|
||||
case 'didReceiveSettings': {
|
||||
const t = tasten.get(m.context);
|
||||
if (t) { t.settings = (m.payload && m.payload.settings) || {}; zeichne(m.context); holeSymbole().then(() => pollAbstuerze(true)).then(() => zeichne(m.context)); }
|
||||
break;
|
||||
}
|
||||
case 'keyDown':
|
||||
case 'dialDown':
|
||||
tastendruck(m.context);
|
||||
break;
|
||||
case 'dialRotate': {
|
||||
const t = tasten.get(m.context);
|
||||
const l = dialListe();
|
||||
if (!t || !l.length) break;
|
||||
const n = l.length;
|
||||
const neu = (((dialIndex(t) + (m.payload.ticks || 0)) % n) + n) % n;
|
||||
t.settings.instanceId = l[neu].id;
|
||||
sende({ event: 'setSettings', context: m.context, payload: t.settings });
|
||||
zeichneDial(m.context);
|
||||
holeSymbole().then(() => zeichneDial(m.context));
|
||||
break;
|
||||
}
|
||||
case 'touchTap':
|
||||
// Tippen auf den Touch-Streifen: Launcher-Fenster zeigen bzw. wegminimieren
|
||||
anfrage('POST', '/window');
|
||||
break;
|
||||
case 'sendToPlugin': {
|
||||
// Eigenschaften-Fenster fragt nach der Instanzliste bzw. den Servern einer Instanz
|
||||
await holeStand();
|
||||
const payload = {
|
||||
erreichbar: !!stand,
|
||||
instanzen: stand ? stand.instanzen.map((i) => ({ id: i.id, name: i.name })) : [],
|
||||
konten: stand ? (stand.konten || []) : [],
|
||||
schnellstart: stand ? (stand.schnellstart || {}) : {},
|
||||
};
|
||||
const frage = m.payload || {};
|
||||
if (frage.frage === 'server' && frage.id && stand) {
|
||||
const r = await anfrage('GET', '/servers?id=' + encodeURIComponent(frage.id));
|
||||
payload.server = (r && r.server) || [];
|
||||
}
|
||||
sende({ event: 'sendToPropertyInspector', action: m.action, context: m.context, payload });
|
||||
break;
|
||||
}
|
||||
default:
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => process.exit(0));
|
||||
setInterval(abfrage, ABFRAGE_MS);
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 14 KiB |