Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7f0939279 | ||
|
|
cd08d72b2c | ||
|
|
2760253b81 | ||
|
|
b7fad0d874 | ||
|
|
f6fcc5096a | ||
|
|
32fc09f152 | ||
|
|
53aefae162 | ||
|
|
94cb1b9b62 | ||
|
|
b242e64678 | ||
|
|
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 |
@@ -2,6 +2,69 @@
|
||||
|
||||
Alle nennenswerten Änderungen an AeroMC werden hier festgehalten.
|
||||
|
||||
## [1.3.2] - 2026-11-09
|
||||
|
||||
### Neu
|
||||
- CurseForge: Die Suche im Katalog funktioniert jetzt ohne eigenen API-Key. Mods und Modpacks lassen sich direkt aus CurseForge suchen, installieren und importieren, so wie bei Modrinth
|
||||
|
||||
### Geändert
|
||||
- Die Hinweise in den Netzwerk-Einstellungen und im Importfenster zu CurseForge sind angepasst: Ein eigener Key ist nur noch optional
|
||||
|
||||
## [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
|
||||
- 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.2).
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
@@ -5,7 +5,7 @@
|
||||
},
|
||||
"projects": {
|
||||
"C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj": {
|
||||
"version": "1.2.3",
|
||||
"version": "1.3.2",
|
||||
"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.3.0")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.2.3")]
|
||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.3.2.0")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.3.2")]
|
||||
[assembly: System.Reflection.AssemblyProductAttribute("AeroMC Setup")]
|
||||
[assembly: System.Reflection.AssemblyTitleAttribute("AeroMC Setup")]
|
||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.2.3.0")]
|
||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.3.2.0")]
|
||||
[assembly: System.Resources.NeutralResourcesLanguageAttribute("de-DE")]
|
||||
|
||||
// Von der MSBuild WriteCodeFragment-Klasse generiert.
|
||||
|
||||
@@ -1 +1 @@
|
||||
111db8ee3dc7193961107c73afd8913fff02b92fd4e49b8b7e4bf051181600bd
|
||||
2821045dd08c55a3267e014e5026ac3588e68340b7d53441249b44302c5f3e33
|
||||
|
||||
@@ -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.3",
|
||||
"version": "1.3.2",
|
||||
"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": "5nf4o8NXPS0=",
|
||||
"dgSpecHash": "dC0MHaIYOYg=",
|
||||
"success": true,
|
||||
"projectFilePath": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj",
|
||||
"expectedPackageFiles": [
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aeromc",
|
||||
"version": "1.2.3",
|
||||
"version": "1.3.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aeromc",
|
||||
"version": "1.2.3",
|
||||
"version": "1.3.2",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"dependencies": {
|
||||
"@xhayper/discord-rpc": "^1.3.4",
|
||||
@@ -16,7 +16,7 @@
|
||||
"node-window-manager": "^2.2.4",
|
||||
"skinview3d": "^3.4.2",
|
||||
"sql.js": "^1.14.2",
|
||||
"undici": "^6.28.0",
|
||||
"undici": "^6.29.1",
|
||||
"unzipper": "^0.12.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -4380,9 +4380,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "6.29.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.29.0.tgz",
|
||||
"integrity": "sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==",
|
||||
"version": "6.29.1",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.29.1.tgz",
|
||||
"integrity": "sha512-EvRlUt/QqAEvXdQeRMYUfwOQsfzpDztykrG2owGEE/rBOBoYv9uF39ZzsNDh4lTFHhTr2XOZjKkYgueD12ZmGA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "aeromc",
|
||||
"productName": "AeroMC",
|
||||
"version": "1.2.3",
|
||||
"version": "1.3.2",
|
||||
"description": "AeroMC – Instanz-basierter Minecraft-Launcher (à la MultiMC/Prism)",
|
||||
"homepage": "https://m-viper.de",
|
||||
"launcherWebsite": "https://aeromc.viper.ipv64.net",
|
||||
@@ -13,7 +13,8 @@
|
||||
"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",
|
||||
"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": {
|
||||
@@ -21,6 +22,16 @@
|
||||
"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",
|
||||
@@ -55,7 +66,7 @@
|
||||
"node-window-manager": "^2.2.4",
|
||||
"skinview3d": "^3.4.2",
|
||||
"sql.js": "^1.14.2",
|
||||
"undici": "^6.28.0",
|
||||
"undici": "^6.29.1",
|
||||
"unzipper": "^0.12.5"
|
||||
},
|
||||
"overrides": {
|
||||
|
||||
@@ -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); });
|
||||
@@ -8,6 +8,15 @@
|
||||
* 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
|
||||
*/
|
||||
|
||||
@@ -15,25 +24,31 @@ 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') return;
|
||||
let releases;
|
||||
try {
|
||||
const res = await fetch(API, { headers: { 'User-Agent': 'AeroMC-Release', Accept: 'application/json' } });
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
releases = await res.json();
|
||||
} catch (err) {
|
||||
console.warn(`\n! Gitea nicht erreichbar (${err.message}) – Versionsprüfung übersprungen.`);
|
||||
return;
|
||||
}
|
||||
const norm = (v) => String(v || '').trim().replace(/^v/i, '');
|
||||
const treffer = (releases || []).find((r) => !r.draft && norm(r.tag_name) === version);
|
||||
if (treffer) {
|
||||
console.error(`\n✖ Version ${version} ist bereits in Gitea veröffentlicht (${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);
|
||||
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).`);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -64,6 +64,27 @@ function ladeZugang() {
|
||||
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 {
|
||||
@@ -101,7 +122,7 @@ function zugangsFehler(d, benutzer) {
|
||||
}
|
||||
|
||||
async function pruefeZugang(zugang) {
|
||||
const res = await fetch(`${zugang.seite}/wp-json/aeromc/v1/update-signatur/zugang`, { headers: kopfzeilen(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 */ }
|
||||
@@ -189,8 +210,8 @@ async function hochladen(sig) {
|
||||
console.log('» Signatur liegt bereits unverändert auf WordPress.');
|
||||
return;
|
||||
}
|
||||
const res = await fetch(`${zugang.seite}/wp-json/aeromc/v1/update-signatur`, {
|
||||
method: 'POST', headers: kopfzeilen(zugang), body: JSON.stringify(sig),
|
||||
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 */ }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,6 +17,13 @@ auf dem Server, und der Launcher kennt nur die Adresse dieses Dienstes.
|
||||
| `GET /health` | Lebenszeichen, verrät nichts über den Schlüssel |
|
||||
| `POST /v1/mods/files` | Dateiliste auflösen, Rumpf `{ "fileIds": [...] }`, höchstens 100 je Anfrage |
|
||||
| `GET /v1/download?url=…` | Mod-Datei durchreichen; das CurseForge-CDN verlangt seit Juli 2026 ebenfalls einen Schlüssel |
|
||||
| `GET /v1/mods/search` | Katalogsuche. Erlaubt sind nur `classId`, `categoryId`, `searchFilter`, `gameVersion`, `modLoaderType`, `sortField`, `sortOrder`, `pageSize` (1–50) und `index`; `gameId` ist immer Minecraft (432) |
|
||||
| `GET /v1/categories` | Kategorien (nur `classId`, `gameId` ist immer 432) |
|
||||
| `GET /v1/mods/<ID>/files` | Dateien einer Mod (`gameVersion`, `modLoaderType`, `pageSize`, `index`) |
|
||||
|
||||
Die Katalog-Wege geben ungültige oder unbekannte Parameter nicht weiter, sondern
|
||||
lassen sie weg. Antworten von CurseForge werden nicht zwischengespeichert. Alles
|
||||
andere (z. B. `/v1/games`) gibt es nicht und liefert `404`.
|
||||
|
||||
## Einrichten
|
||||
|
||||
@@ -52,14 +59,19 @@ pm2 save
|
||||
pm2 startup # einmalig, für den Start nach einem Neustart
|
||||
```
|
||||
|
||||
Nach jeder Änderung an der `.env`:
|
||||
Nach jeder Änderung an der `.env` den Eintrag entfernen und neu aus der
|
||||
`ecosystem.config.js` starten:
|
||||
|
||||
```bash
|
||||
pm2 restart aeromc-cf-proxy --update-env
|
||||
pm2 delete aeromc-cf-proxy
|
||||
pm2 start ecosystem.config.js
|
||||
pm2 save
|
||||
```
|
||||
|
||||
Ohne `--update-env` behält pm2 die alten Werte, und du suchst lange nach dem
|
||||
Grund, warum der neue Schlüssel nicht greift.
|
||||
Ein bloßes `pm2 restart aeromc-cf-proxy --update-env` reicht **nicht**: Die
|
||||
`.env` wird nur gelesen, wenn pm2 die `ecosystem.config.js` lädt. Beim Restart
|
||||
behält pm2 die Werte vom ersten Start, und du suchst lange nach dem Grund, warum
|
||||
der neue Schlüssel nicht greift.
|
||||
|
||||
Nachsehen, ob er läuft:
|
||||
|
||||
@@ -100,8 +112,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,7 +1,8 @@
|
||||
'use strict';
|
||||
|
||||
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);
|
||||
@@ -13,6 +14,15 @@ const TOKENS = new Set(
|
||||
);
|
||||
const LIMIT_PRO_MIN = Number(process.env.RATE_LIMIT_PER_MIN || 60);
|
||||
const MAX_MB = Number(process.env.MAX_DOWNLOAD_MB || 512);
|
||||
// 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);
|
||||
|
||||
@@ -35,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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -88,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) {
|
||||
@@ -133,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
|
||||
@@ -142,38 +176,120 @@ async function dateilisteAufloesen(req, res) {
|
||||
sendeJson(res, 200, await antwort.json());
|
||||
}
|
||||
|
||||
/*
|
||||
* Katalog-Wege: Suche, Kategorien und Dateiliste einer Mod. Es wird nur durchgereicht,
|
||||
* was der Launcher dafür braucht – jeder Weg hat eine feste Liste erlaubter Parameter,
|
||||
* andere werden verworfen. gameId ist immer Minecraft (432), damit sich der Dienst nicht
|
||||
* für andere Spiele missbrauchen lässt. Antworten werden NICHT zwischengespeichert
|
||||
* (die CurseForge-Bedingungen verbieten es, Daten aus der API zu speichern).
|
||||
*/
|
||||
const MC_SPIEL = '432';
|
||||
const ganz = (min, max) => (v) => /^\d{1,9}$/.test(v) && Number(v) >= min && Number(v) <= max;
|
||||
const text = (maxLaenge) => (v) => v.length > 0 && v.length <= maxLaenge && !/[\u0000-\u001f]/.test(v);
|
||||
const KATALOG_PARAMETER = {
|
||||
suche: {
|
||||
classId: ganz(0, 100000), categoryId: ganz(0, 100000),
|
||||
searchFilter: text(100), gameVersion: text(32),
|
||||
modLoaderType: ganz(0, 6), sortField: ganz(1, 8),
|
||||
sortOrder: (v) => v === 'asc' || v === 'desc',
|
||||
pageSize: ganz(1, 50), index: ganz(0, 10000),
|
||||
},
|
||||
kategorien: { classId: ganz(0, 100000) },
|
||||
dateien: { gameVersion: text(32), modLoaderType: ganz(0, 6), pageSize: ganz(1, 50), index: ganz(0, 10000) },
|
||||
};
|
||||
const MAX_KATALOG_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
async function katalogAnfrage(res, url, cfPfad, erlaubt) {
|
||||
const ziel = new URL(CF_API + cfPfad);
|
||||
if (erlaubt === KATALOG_PARAMETER.suche || erlaubt === KATALOG_PARAMETER.kategorien) ziel.searchParams.set('gameId', MC_SPIEL);
|
||||
for (const [name, wert] of url.searchParams) {
|
||||
const pruefer = erlaubt[name];
|
||||
if (!pruefer || !pruefer(wert)) continue; // unbekannte oder ungültige Parameter einfach weglassen
|
||||
ziel.searchParams.set(name, wert);
|
||||
}
|
||||
const antwort = await fetch(ziel, { headers: cfKopfzeilen(), signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS) });
|
||||
if (!antwort.ok) {
|
||||
// Schlüsselprobleme nicht im Wortlaut weiterreichen
|
||||
const code = antwort.status === 401 || antwort.status === 403 ? 502 : antwort.status;
|
||||
return sendeJson(res, code, { error: 'CurseForge antwortet mit HTTP ' + antwort.status });
|
||||
}
|
||||
const roh = await antwort.text();
|
||||
if (roh.length > MAX_KATALOG_BYTES) return sendeJson(res, 502, { error: 'Antwort von CurseForge zu groß.' });
|
||||
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(roh) });
|
||||
res.end(roh);
|
||||
}
|
||||
|
||||
async function dateiDurchreichen(req, res, url) {
|
||||
const ziel = pruefeDownloadZiel(url.searchParams.get('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;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -187,7 +303,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
|
||||
@@ -225,6 +345,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: {
|
||||
@@ -256,6 +386,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 */ }
|
||||
@@ -288,6 +419,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);
|
||||
}
|
||||
|
||||
@@ -296,6 +429,10 @@ const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
if (req.method === 'POST' && url.pathname === '/v1/mods/files') return await dateilisteAufloesen(req, res);
|
||||
if (req.method === 'GET' && url.pathname === '/v1/download') return await dateiDurchreichen(req, res, url);
|
||||
if (req.method === 'GET' && url.pathname === '/v1/mods/search') return await katalogAnfrage(res, url, '/mods/search', KATALOG_PARAMETER.suche);
|
||||
if (req.method === 'GET' && url.pathname === '/v1/categories') return await katalogAnfrage(res, url, '/categories', KATALOG_PARAMETER.kategorien);
|
||||
const modDateien = req.method === 'GET' && /^\/v1\/mods\/(\d{1,9})\/files$/.exec(url.pathname);
|
||||
if (modDateien) return await katalogAnfrage(res, url, '/mods/' + modDateien[1] + '/files', KATALOG_PARAMETER.dateien);
|
||||
sendeJson(res, 404, { error: 'Unbekannter Weg.' });
|
||||
} catch (err) {
|
||||
console.error('[Fehler]', err.message);
|
||||
@@ -308,7 +445,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 |
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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) -->
|
||||
@@ -279,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>
|
||||
@@ -548,7 +561,7 @@
|
||||
</section>
|
||||
<section class="settings-card">
|
||||
<h5>CurseForge</h5>
|
||||
<small class="hint s-vorab">Nur für CurseForge-Modpacks und -Mods nötig. Modrinth funktioniert immer ohne.</small>
|
||||
<small class="hint s-vorab">Für CurseForge ist normalerweise nichts nötig: AeroMC nutzt die Weiterleitung unten. Ein eigener Key ist nur für den Fall gedacht, dass du sie nicht nutzen willst. Modrinth funktioniert immer ohne.</small>
|
||||
<div class="s-zeile s-voll">
|
||||
<div class="s-text"><b>Eigener API-Key</b><small>Von CurseForge for Studios (Profil → Schlüssel erstellen, beginnt mit cfc_pat_…) oder ein Organisationsschlüssel. Nach dem Eintragen speichern.</small></div>
|
||||
<div class="path-row">
|
||||
@@ -1616,7 +1629,7 @@
|
||||
|
||||
<div id="import-pane-modpack" class="import-pane hidden">
|
||||
<p class="import-copy">Modpack aus dem Katalog installieren oder als Datei importieren.</p>
|
||||
<p class="hint">Katalog-Suche läuft über Modrinth oder CurseForge. Für CurseForge wird ein eigener API-Key in den Netzwerk-Einstellungen gebraucht (die Weiterleitung reicht nur für den Datei-Import). Datei-Import unterstützt zusätzlich CurseForge-ZIP (manifest.json).</p>
|
||||
<p class="hint">Katalog-Suche läuft über Modrinth oder CurseForge. Datei-Import unterstützt zusätzlich CurseForge-ZIP (manifest.json).</p>
|
||||
|
||||
<div class="field-row" style="margin-top:12px;align-items:flex-end">
|
||||
<label class="field" style="width:150px">
|
||||
|
||||
@@ -127,6 +127,8 @@ const skins = require('./skins');
|
||||
const servers = require('./servers');
|
||||
const mcping = require('./mcping');
|
||||
const discordpresence = require('./discordpresence');
|
||||
const streamdeck = require('./streamdeck');
|
||||
const streamdeckplugin = require('./streamdeckplugin');
|
||||
const friends = require('./friends');
|
||||
const crashhelp = require('./crashhelp');
|
||||
|
||||
@@ -212,6 +214,7 @@ const POPOUT_SPEC = {
|
||||
stats: { title: 'AeroMC – Spielzeit-Statistik', width: 640, height: 560, minWidth: 520, minHeight: 420 },
|
||||
};
|
||||
const POPOUT_DEFAULT = { title: 'AeroMC', width: 860, height: 680, minWidth: 640, minHeight: 480 };
|
||||
let betaStand = false; // zuletzt bekannter Stand von "An der Beta teilnehmen"
|
||||
let pendingUpdateInfo = null; // letztes Ergebnis von update:check/checkForUpdate, für Update-Popup
|
||||
// Ergebnis der letzten Update-Suche (auch "läuft gerade" oder Fehler) – Fenster, die
|
||||
// erst später aufgehen (z. B. Einstellungen → Info), holen es sich hierüber ab
|
||||
@@ -223,7 +226,7 @@ async function pruefeAufUpdates() {
|
||||
letzterUpdateCheck = { checking: true };
|
||||
broadcast('update:checked', letzterUpdateCheck);
|
||||
try {
|
||||
const info = await updater.checkForUpdate(app.getVersion());
|
||||
const info = await updater.checkForUpdate(app.getVersion(), { beta: store.getSettings().betaTeilnahme === true });
|
||||
pendingUpdateInfo = info;
|
||||
letzterUpdateCheck = info;
|
||||
} catch (err) {
|
||||
@@ -244,7 +247,8 @@ function broadcast(channel, payload) {
|
||||
|
||||
// von: Rechteck des angeklickten Knopfs (Bildschirmkoordinaten) – dann wächst
|
||||
// das Fenster daraus hervor (Optionen → Darstellung → Animationen)
|
||||
function openPopout(modalId, params, von) {
|
||||
// still: Fenster erscheint, ohne dem Spiel den Fokus zu nehmen (Stream Deck: Aufnahme/Clip-Modus)
|
||||
function openPopout(modalId, params, von, still) {
|
||||
const wachsen = !!von && store.getSettings().uiAnimationen !== false;
|
||||
let win = popoutWindows.get(modalId);
|
||||
if (win && !win.isDestroyed()) {
|
||||
@@ -281,7 +285,7 @@ function openPopout(modalId, params, von) {
|
||||
});
|
||||
popoutWindows.set(modalId, win);
|
||||
win.loadFile(path.join(__dirname, 'index.html'), { query });
|
||||
win.once('ready-to-show', () => { if (wachsen) fensteraufbau.wachsen(win, von); else win.show(); });
|
||||
win.once('ready-to-show', () => { if (still) { win.showInactive(); return; } if (wachsen) fensteraufbau.wachsen(win, von); else win.show(); });
|
||||
win.webContents.on('did-fail-load', (_e, code, desc) => {
|
||||
console.error('Popup-Fenster "' + modalId + '" konnte nicht laden:', code, desc);
|
||||
});
|
||||
@@ -301,7 +305,11 @@ function openPopout(modalId, params, von) {
|
||||
}
|
||||
|
||||
let recordingGuardActive = false;
|
||||
ipcMain.on('recording:guard', (_e, active) => { recordingGuardActive = !!active; });
|
||||
ipcMain.on('recording:guard', (_e, active, instanceId) => {
|
||||
recordingGuardActive = !!active;
|
||||
if (active) zeigeAufnahmeHinweis(instanceId).catch(() => {});
|
||||
else stoppeAufnahmeHinweis();
|
||||
});
|
||||
|
||||
ipcMain.on('debug:rendererError', (_e, msg) => console.error('[Renderer-Fehler]', msg));
|
||||
ipcMain.on('popout:open', (e, modalId, params, knopf) => {
|
||||
@@ -555,6 +563,7 @@ let clipInstanz = null;
|
||||
ipcMain.handle('clip:setActive', (_e, aktiv, instanceId) => {
|
||||
if (clipKuerzel) { globalShortcut.unregister(clipKuerzel); clipKuerzel = null; }
|
||||
clipInstanz = aktiv ? instanceId : null;
|
||||
if (aktiv) sdClipStartetBis = 0;
|
||||
if (!aktiv) return null;
|
||||
for (const kuerzel of CLIP_TASTENKUERZEL) {
|
||||
const ok = globalShortcut.register(kuerzel, () => {
|
||||
@@ -638,8 +647,11 @@ ipcMain.handle('recording:list', (_e, instanceName) => {
|
||||
} catch { return []; }
|
||||
});
|
||||
|
||||
// nur ein schlichter Dateiname - nie ein Pfad, der aus dem Aufnahmen-Ordner hinausführt
|
||||
const istDateiname = (n) => typeof n === 'string' && !!n && n === path.basename(n) && n !== '.' && n !== '..';
|
||||
|
||||
ipcMain.handle('recording:thumbnail', async (_e, instanceName, dateiname) => {
|
||||
if (!ffmpegPfad) return null;
|
||||
if (!ffmpegPfad || !istDateiname(dateiname)) return null;
|
||||
const ordner = aufzeichnungsOrdner(instanceName);
|
||||
const quelle = path.join(ordner, dateiname);
|
||||
if (!fs.existsSync(quelle)) return null;
|
||||
@@ -660,6 +672,7 @@ ipcMain.handle('recording:thumbnail', async (_e, instanceName, dateiname) => {
|
||||
});
|
||||
|
||||
ipcMain.handle('recording:delete', (_e, instanceName, dateiname) => {
|
||||
if (!istDateiname(dateiname)) return false;
|
||||
const ordner = aufzeichnungsOrdner(instanceName);
|
||||
try {
|
||||
fs.rmSync(path.join(ordner, dateiname), { force: true });
|
||||
@@ -669,8 +682,10 @@ ipcMain.handle('recording:delete', (_e, instanceName, dateiname) => {
|
||||
});
|
||||
|
||||
ipcMain.handle('recording:open', (_e, instanceName, dateiname) => {
|
||||
if (!istDateiname(dateiname)) return false;
|
||||
const ziel = path.join(aufzeichnungsOrdner(instanceName), dateiname);
|
||||
if (fs.existsSync(ziel)) shell.openPath(ziel);
|
||||
// nur Videos öffnen, keine beliebigen Dateien aus dem Aufnahmen-Ordner
|
||||
if (/\.(mp4|webm|mkv)$/i.test(ziel) && fs.existsSync(ziel)) shell.openPath(ziel);
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -1073,12 +1088,25 @@ app.whenReady().then(() => {
|
||||
decrypt: (b) => safeStorage.decryptString(Buffer.from(b, 'base64')),
|
||||
};
|
||||
auth.init(app.getPath('userData'), tresor);
|
||||
// Beta-Teilnahme: Wer eine Vorabfassung von Hand installiert, nimmt ab da teil
|
||||
// (einmalig beim Wechsel von einer fertigen auf eine Vorabfassung – danach
|
||||
// gilt allein der Schalter in den Optionen, auch wenn er wieder aus ist).
|
||||
{
|
||||
const vorab = updater.istVorabVersion(app.getVersion());
|
||||
const e = store.getSettings();
|
||||
const neu = {};
|
||||
if (vorab && !e.letzteVersionVorab && e.betaTeilnahme !== true) neu.betaTeilnahme = true;
|
||||
if (!!e.letzteVersionVorab !== vorab) neu.letzteVersionVorab = vorab;
|
||||
if (Object.keys(neu).length) store.saveSettings(neu);
|
||||
betaStand = store.getSettings().betaTeilnahme === true;
|
||||
}
|
||||
try { integritaet.init(app.getPath('userData'), tresor, store); } catch (err) { console.warn('Manipulationsschutz:', err.message); }
|
||||
setTimeout(() => { pruefeAlleInstanzen().catch(() => {}); }, 8000);
|
||||
// Proxy-Passwort und API-Schlüssel ebenso (siehe store.setzeTresor)
|
||||
store.setzeTresor(tresor);
|
||||
applyProxy(store.getSettings());
|
||||
discordpresence.configure(store.getSettings());
|
||||
initStreamdeck();
|
||||
const autostartAn = !!store.getSettings().autostart;
|
||||
// Autostart-Eintrag/Tray mit der Einstellung synchron halten (falls z. B.
|
||||
// von Hand an der Registry gedreht wurde oder nach einem Update)
|
||||
@@ -1165,7 +1193,7 @@ app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') app.quit();
|
||||
});
|
||||
|
||||
app.on('before-quit', () => { appIsQuitting = true; discordpresence.shutdown(); });
|
||||
app.on('before-quit', () => { appIsQuitting = true; discordpresence.shutdown(); streamdeck.shutdown(); });
|
||||
app.on('will-quit', () => { globalShortcut.unregisterAll(); });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1195,6 +1223,15 @@ ipcMain.handle('settings:save', (_e, patch) => {
|
||||
applyProxy(s);
|
||||
if (patch && Object.prototype.hasOwnProperty.call(patch, 'autostart')) applyAutostart(s.autostart);
|
||||
if (patch && Object.prototype.hasOwnProperty.call(patch, 'discordClientId')) discordpresence.configure(s);
|
||||
if (patch && Object.prototype.hasOwnProperty.call(patch, 'streamdeckEnabled')) {
|
||||
streamdeck.configure(s);
|
||||
if (s.streamdeckEnabled === true) installiereStreamdeckPlugin('schalter');
|
||||
}
|
||||
// Beta-Teilnahme umgeschaltet -> gleich neu nach Updates suchen
|
||||
if (patch && Object.prototype.hasOwnProperty.call(patch, 'betaTeilnahme') && !!patch.betaTeilnahme !== betaStand) {
|
||||
betaStand = !!patch.betaTeilnahme;
|
||||
pruefeAufUpdates().then((info) => { if (info.available) broadcast('update:available', info); }).catch(() => {});
|
||||
}
|
||||
if (patch && Object.prototype.hasOwnProperty.call(patch, 'friendsEnabled')) {
|
||||
aktualisiereFreundeTastenkuerzel();
|
||||
aktualisiereAnfragenZahl().catch(() => {});
|
||||
@@ -2026,8 +2063,9 @@ ipcMain.handle('auth:recheck', () => checkAuthServices());
|
||||
// "playing" kommt aus den tatsächlich laufenden Spielen, nicht aus dem Aktiv-Merkmal
|
||||
ipcMain.handle('auth:accounts', () => auth.listAccounts()
|
||||
.map((a) => Object.assign({}, a, { playing: spielendeKonten.has(a.id) })));
|
||||
ipcMain.handle('auth:setActive', (_e, id) => {
|
||||
function setzeAktivesKonto(id) {
|
||||
const res = auth.setActive(id);
|
||||
sdFreunde = null; // Freundesliste gehört zum Konto -> neu holen
|
||||
// Ohne das hier bleibt z. B. ein separates Konten- oder Aufzeichnungs-
|
||||
// Fenster (eigener Renderer-Prozess, siehe cross-window state) auf dem
|
||||
// alten Konto stehen, bis irgendein anderes Ereignis zufällig ein Neuladen
|
||||
@@ -2039,7 +2077,8 @@ ipcMain.handle('auth:setActive', (_e, id) => {
|
||||
aktualisiereAnfragenZahl().catch(() => {});
|
||||
pruefeNachrichten().catch(() => {});
|
||||
return res;
|
||||
});
|
||||
}
|
||||
ipcMain.handle('auth:setActive', (_e, id) => setzeAktivesKonto(id));
|
||||
|
||||
// ---- Skin des aktiven Kontos ----
|
||||
|
||||
@@ -2868,6 +2907,8 @@ async function zeigeIngameOverlay(titel, text, pid, dauerMs = 6000) {
|
||||
});
|
||||
overlayFenster.setIgnoreMouseEvents(true);
|
||||
overlayFenster.setAlwaysOnTop(true, 'screen-saver');
|
||||
// Hinweise gehören nicht ins aufgenommene Video (siehe zeigeAufnahmeHinweis)
|
||||
if (ausblendenAusAufnahmeMoeglich()) overlayFenster.setContentProtection(true);
|
||||
|
||||
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
// charset=utf-8 nicht vergessen: ohne das interpretiert Chromium die
|
||||
@@ -2892,6 +2933,67 @@ async function zeigeIngameOverlay(titel, text, pid, dauerMs = 6000) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------- Aufnahme-Hinweis im Spiel ----------
|
||||
// Kleine "● REC 00:25"-Anzeige oben rechts im Minecraft-Fenster, solange eine Aufnahme läuft.
|
||||
// setContentProtection nimmt das Fenster aus Bildschirmaufnahmen heraus (Windows 10 ab 2004:
|
||||
// WDA_EXCLUDEFROMCAPTURE) - die Anzeige ist also nie im Video. Auf älteren Windows-Fassungen
|
||||
// würde daraus ein schwarzes Rechteck im Video, dort gibt es die Anzeige deshalb nicht.
|
||||
let aufnahmeAnzeige = null; // { win, timer }
|
||||
|
||||
function ausblendenAusAufnahmeMoeglich() {
|
||||
const build = Number(String(os.release()).split('.')[2]);
|
||||
return process.platform === 'win32' && build >= 19041;
|
||||
}
|
||||
|
||||
function stoppeAufnahmeHinweis() {
|
||||
if (!aufnahmeAnzeige) return;
|
||||
clearInterval(aufnahmeAnzeige.timer);
|
||||
if (!aufnahmeAnzeige.win.isDestroyed()) aufnahmeAnzeige.win.destroy();
|
||||
aufnahmeAnzeige = null;
|
||||
}
|
||||
|
||||
async function zeigeAufnahmeHinweis(instanceId) {
|
||||
stoppeAufnahmeHinweis();
|
||||
if (!ausblendenAusAufnahmeMoeglich()) return;
|
||||
const pid = laufendePid.get(instanceId) || [...laufendePid.values()].pop();
|
||||
const fenster = await findeMinecraftFenster(pid);
|
||||
if (!fenster || !recordingGuardActive || aufnahmeAnzeige) return; // inzwischen beendet bzw. schon eine Anzeige
|
||||
const B = 118, H = 28;
|
||||
const ort = (b) => ({ x: Math.round(b.x + b.width - B - 20), y: Math.round(b.y + 40), width: B, height: H });
|
||||
const win = new BrowserWindow(Object.assign(ort(fenster.getBounds()), {
|
||||
frame: false, transparent: true, alwaysOnTop: true, skipTaskbar: true, focusable: false,
|
||||
resizable: false, hasShadow: false, show: false,
|
||||
webPreferences: { contextIsolation: true, sandbox: true },
|
||||
}));
|
||||
win.setContentProtection(true);
|
||||
win.setIgnoreMouseEvents(true);
|
||||
win.setAlwaysOnTop(true, 'screen-saver');
|
||||
const html = `data:text/html;charset=utf-8,${encodeURIComponent(`<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body style="margin:0;overflow:hidden;background:transparent;">
|
||||
<div style="font-family:Segoe UI,sans-serif;font-size:13px;font-weight:600;color:#fff;background:rgba(20,20,25,0.82);border-radius:14px;
|
||||
height:${H}px;box-sizing:border-box;padding:0 12px;display:flex;align-items:center;gap:7px;white-space:nowrap;">
|
||||
<span id="p" style="width:9px;height:9px;border-radius:50%;background:#ef4444;box-shadow:0 0 6px #ef4444;"></span>
|
||||
<span>REC</span><span id="t" style="opacity:.85;font-variant-numeric:tabular-nums;">00:00</span>
|
||||
</div>
|
||||
<script>
|
||||
const start = ${Date.now()};
|
||||
const t = document.getElementById('t'), p = document.getElementById('p');
|
||||
setInterval(() => {
|
||||
const s = Math.floor((Date.now() - start) / 1000);
|
||||
t.textContent = (s >= 3600 ? Math.floor(s / 3600) + ':' : '') + String(Math.floor(s % 3600 / 60)).padStart(2, '0') + ':' + String(s % 60).padStart(2, '0');
|
||||
p.style.opacity = s % 2 ? '0.35' : '1';
|
||||
}, 500);
|
||||
</script></body></html>`)}`;
|
||||
win.loadURL(html);
|
||||
win.once('ready-to-show', () => { if (!win.isDestroyed()) win.showInactive(); });
|
||||
// mit dem Spielfenster mitwandern; ist das Fenster weg (Spiel beendet), verschwindet die Anzeige
|
||||
const timer = setInterval(async () => {
|
||||
const f = await findeMinecraftFenster(pid);
|
||||
if (!f || win.isDestroyed()) { stoppeAufnahmeHinweis(); return; }
|
||||
win.setBounds(ort(f.getBounds()));
|
||||
}, 1000);
|
||||
aufnahmeAnzeige = { win, timer };
|
||||
}
|
||||
|
||||
// ---------- Tastenkürzel: Online-Freunde ingame anzeigen ----------
|
||||
// Nur registriert, solange über diesen Launcher ein Spiel läuft - ein globales
|
||||
// Tastenkürzel fängt die Kombination systemweit ab, das soll andere Programme
|
||||
@@ -3525,7 +3627,11 @@ ipcMain.handle('instance:worlds', (_e, id) => listDir(path.join(store.gameDir(id
|
||||
ipcMain.handle('instance:resourcepacks', (_e, id) => listDir(path.join(store.gameDir(id), 'resourcepacks'), false));
|
||||
ipcMain.handle('instance:datapacks', (_e, id) => listDir(path.join(store.gameDir(id), 'datapacks'), false));
|
||||
|
||||
// Unterordner (saves, mods, screenshots …) ist immer genau ein Ordnername - nie ein Pfad mit ".."
|
||||
const istOrdnerName = (s) => typeof s === 'string' && /^[\w.-]+$/.test(s) && s !== '.' && s !== '..';
|
||||
|
||||
function safeSub(id, sub, name) {
|
||||
if (!istOrdnerName(sub) || typeof name !== 'string' || !name) return null;
|
||||
const base = path.join(store.gameDir(id), sub);
|
||||
const target = path.join(base, name);
|
||||
// Pfad-Ausbruch verhindern
|
||||
@@ -3540,6 +3646,7 @@ ipcMain.handle('instance:deleteContent', (_e, id, sub, name) => {
|
||||
});
|
||||
|
||||
ipcMain.handle('instance:openSubdir', (_e, id, sub) => {
|
||||
if (!istOrdnerName(sub) || !store.getInstance(id)) return false;
|
||||
const p = path.join(store.gameDir(id), sub);
|
||||
if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true });
|
||||
shell.openPath(p);
|
||||
@@ -3566,7 +3673,8 @@ ipcMain.handle('instance:screenshotData', (_e, id, name) => {
|
||||
|
||||
ipcMain.handle('instance:openScreenshot', (_e, id, name) => {
|
||||
const target = safeSub(id, 'screenshots', name);
|
||||
if (target && fs.existsSync(target)) shell.openPath(target);
|
||||
// nur Bilder öffnen – im Ordner abgelegte Programme (.exe, .bat …) würden sonst ausgeführt
|
||||
if (target && /\.(png|jpe?g|gif|webp)$/i.test(target) && fs.existsSync(target)) shell.openPath(target);
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -3980,11 +4088,13 @@ async function importCurseForge(buf, zip, zugang) {
|
||||
const modsDir = path.join(gameDir, 'mods');
|
||||
let geladen = 0;
|
||||
const fehlend = [];
|
||||
// Dateien, die der Autor nicht über Launcher freigibt: nicht laden, nur melden
|
||||
const gesperrtIds = new Set((aufgeloest.gesperrt || []).map((g) => g.fileID));
|
||||
let i = 0;
|
||||
for (const f of dateien) {
|
||||
i++;
|
||||
const info = aufgeloest.get(Number(f.fileID));
|
||||
if (!info) { fehlend.push(f.fileID); continue; }
|
||||
if (!info) { if (!gesperrtIds.has(Number(f.fileID))) fehlend.push(f.fileID); continue; }
|
||||
const dest = path.join(modsDir, path.basename(info.fileName));
|
||||
if (!inside(dest)) continue;
|
||||
melde('Mods laden', i, dateien.length);
|
||||
@@ -4008,12 +4118,28 @@ async function importCurseForge(buf, zip, zugang) {
|
||||
fs.writeFileSync(dest, forge.readZipEntry(buf, zip[name]));
|
||||
}
|
||||
|
||||
// Liste mit Links zum Selbstladen in den Spielordner legen
|
||||
const gesperrt = aufgeloest.gesperrt || [];
|
||||
if (gesperrt.length) {
|
||||
try {
|
||||
const zeilen = [
|
||||
'Diese Mods dürfen laut ihren Autoren nicht über Launcher wie AeroMC geladen werden.',
|
||||
'Bitte von der jeweiligen CurseForge-Seite laden und in den Ordner "mods" legen:',
|
||||
'',
|
||||
...gesperrt.map((g) => `${g.fileName}\n ${g.seite}`),
|
||||
'',
|
||||
];
|
||||
fs.writeFileSync(path.join(gameDir, 'GESPERRTE-MODS.txt'), zeilen.join('\r\n'), 'utf8');
|
||||
} catch { /* Hinweisdatei ist nur eine Hilfe */ }
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
instance: store.getInstance(created.id),
|
||||
fileCount: dateien.length,
|
||||
geladen,
|
||||
fehlend: fehlend.length,
|
||||
gesperrt: gesperrt.length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4088,6 +4214,13 @@ ipcMain.handle('modpack:installFromCurseForge', async (_e, project) => {
|
||||
const gameVersion = (project && project.gameVersion) || '';
|
||||
broadcast('launch:progress', { phase: 'modpack', detail: 'Modpack-Version wird ermittelt …' });
|
||||
const file = await services.curseforgeResolveModpackFile(zugang, modId, gameVersion);
|
||||
if (file && file.gesperrt) {
|
||||
return {
|
||||
ok: false,
|
||||
message: 'Der Autor erlaubt kein Laden dieses Modpacks über Launcher. Bitte von Hand laden und über „Importieren“ einlesen: '
|
||||
+ services.cfProjektSeite(modId),
|
||||
};
|
||||
}
|
||||
if (!file || !file.url) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -4244,9 +4377,25 @@ async function verschiebeInhalt(quelle, ziel, nur) {
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Vor dem Entpacken alle Pfade im Archiv prüfen: ".."-Teile, absolute Pfade und Laufwerksangaben
|
||||
* könnten sonst Dateien außerhalb des Zielordners anlegen ("Zip Slip"). Gelesen wird nur das
|
||||
* Inhaltsverzeichnis, nicht das ganze Archiv.
|
||||
*/
|
||||
async function pruefeZipPfade(zipPfad) {
|
||||
const verzeichnis = await require('unzipper').Open.file(zipPfad);
|
||||
for (const eintrag of verzeichnis.files || []) {
|
||||
const teile = String(eintrag.path || '').replace(/\\/g, '/').split('/');
|
||||
if (teile.includes('..') || String(eintrag.path || '').startsWith('/') || /^[a-z]:/i.test(String(eintrag.path || '')) || /[\0]/.test(String(eintrag.path || ''))) {
|
||||
throw new Error('Das Archiv enthält ungültige Pfade und wurde nicht entpackt.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function importInstanceZipFromPath(filePath) {
|
||||
const tmp = path.join(app.getPath('temp'), 'vn-import-' + Date.now());
|
||||
try {
|
||||
await pruefeZipPfade(filePath);
|
||||
fs.mkdirSync(tmp, { recursive: true });
|
||||
await runPwsh('Expand-Archive -Path $env:VN_SRC -DestinationPath $env:VN_DST -Force',
|
||||
{ VN_SRC: filePath, VN_DST: tmp });
|
||||
@@ -4263,6 +4412,9 @@ async function importInstanceZipFromPath(filePath) {
|
||||
if (!fs.existsSync(path.join(base, 'instance.json'))) {
|
||||
const mmc = mmcimport.scanSingle(tmp);
|
||||
if (mmc.ok) {
|
||||
// Wie beim AeroMC-Export oben: kein Java-Pfad aus einem fremden Archiv (instance.cfg
|
||||
// könnte auf ein beliebiges Programm oder einen Netzwerkpfad zeigen, das beim Start läuft)
|
||||
mmc.instance.javaPath = '';
|
||||
const done = mmcimport.importOne(store, mmc.instance, false);
|
||||
const quellen = mmcimport.kopierQuellen(mmc.instance);
|
||||
if (quellen.length) await verschiebeInhalt(mmc.instance.gameDir, store.gameDir(done.id), quellen.map((q) => q.name));
|
||||
@@ -4763,6 +4915,297 @@ ipcMain.handle('instances:launch', async (_e, id, accountId, launchOpts) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stream Deck (lokale Schnittstelle, siehe streamdeck.js – nur auf Wunsch aktiv)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function aufnahmeFenster() {
|
||||
const win = popoutWindows.get('record');
|
||||
return win && !win.isDestroyed() ? win : null;
|
||||
}
|
||||
|
||||
/*
|
||||
* Aufnahme bzw. Clip-Modus per Stream Deck umschalten. Gibt es noch kein
|
||||
* Aufnahme-Fenster, wird es für die laufende Instanz geöffnet; der
|
||||
* Renderer sucht das Spielfenster, wählt es und führt die Aktion dann selbst aus
|
||||
* (Parameter "sd"). Das Fenster erscheint ohne Fokus und verschwindet nach dem Start wieder
|
||||
* (sonst läge es über dem Spiel und würde mit aufgenommen). Rückmeldung zusätzlich im Spiel.
|
||||
*/
|
||||
let sdBackup = { laeuft: false, zeit: 0, ok: null }; // Ergebnis der letzten Sicherung per Stream Deck
|
||||
|
||||
// Freunde online: höchstens alle 45 s vom Server holen, nicht bei jeder Stream-Deck-Abfrage
|
||||
let sdFreunde = null;
|
||||
let sdFreundeLaedt = false;
|
||||
function sdFreundeStand() {
|
||||
const konto = aktivesFreundeKonto();
|
||||
if (!konto || store.getSettings().friendsEnabled === false) return null;
|
||||
const veraltet = !sdFreunde || sdFreunde.uuid !== konto.uuid || Date.now() - sdFreunde.zeit > 45000;
|
||||
if (veraltet && !sdFreundeLaedt) {
|
||||
sdFreundeLaedt = true;
|
||||
friends.getFriends(konto.uuid)
|
||||
.then(async (res) => {
|
||||
if (!res.ok || !Array.isArray(res.data)) return;
|
||||
const liste = await mitNamen(res.data);
|
||||
const online = liste.filter((f) => f.online);
|
||||
sdFreunde = { uuid: konto.uuid, zeit: Date.now(), gesamt: liste.length, online: online.length, namen: online.slice(0, 6).map((f) => f.username) };
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => { sdFreundeLaedt = false; });
|
||||
}
|
||||
return sdFreunde && sdFreunde.uuid === konto.uuid ? { gesamt: sdFreunde.gesamt, online: sdFreunde.online, namen: sdFreunde.namen } : null;
|
||||
}
|
||||
|
||||
let sdClipStartetBis = 0; // Clip-Modus wird gerade per Stream Deck gestartet (dauert einige Sekunden)
|
||||
|
||||
async function aufnahmeAktion(art) {
|
||||
const win = aufnahmeFenster();
|
||||
const id = [...laufendePid.keys()].pop(); // zuletzt gestartete laufende Instanz
|
||||
if (!id && !recordingGuardActive && !clipInstanz) return { ok: false, message: 'Minecraft läuft nicht.' };
|
||||
if (art === 'clipmodus' && !clipInstanz) sdClipStartetBis = Date.now() + 60000;
|
||||
if (!win) {
|
||||
openPopout('record', { instanceId: id, sd: art }, null, true);
|
||||
return { ok: true };
|
||||
}
|
||||
win.webContents.send('streamdeck:aktion', { art, instanceId: id || null });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
ipcMain.on('streamdeck:fensterZeigen', (e) => {
|
||||
const win = BrowserWindow.fromWebContents(e.sender);
|
||||
if (win && !win.isDestroyed()) { win.show(); win.focus(); }
|
||||
});
|
||||
|
||||
ipcMain.on('streamdeck:meldung', (_e, titel, text, fehler) => {
|
||||
if (fehler) { sdClipStartetBis = 0; broadcast('ui:toast', { text: String(titel || '') + ': ' + String(text || ''), error: true }); }
|
||||
const pid = [...laufendePid.values()].pop();
|
||||
if (pid) zeigeIngameOverlay(String(titel || ''), String(text || ''), pid, 2500).catch(() => {});
|
||||
});
|
||||
|
||||
/*
|
||||
* Screenshot vom Spielfenster: Bildschirm erfassen und auf das Minecraft-Fenster zuschneiden
|
||||
* (derselbe Weg wie bei der Aufnahme, siehe findeFensterUndBildschirm). Landet im
|
||||
* Screenshot-Ordner der Instanz wie ein Screenshot aus dem Spiel (F2).
|
||||
*/
|
||||
async function macheScreenshot() {
|
||||
const id = [...laufendePid.keys()].pop();
|
||||
if (!id) return { ok: false, message: 'Minecraft läuft nicht.' };
|
||||
const pid = laufendePid.get(id);
|
||||
const info = await findeFensterUndBildschirm(pid, null);
|
||||
if (!info) return { ok: false, message: 'Spielfenster nicht gefunden.' };
|
||||
const anzeige = screen.getDisplayMatching(info.fensterBounds);
|
||||
const px = { width: Math.round(anzeige.bounds.width * anzeige.scaleFactor), height: Math.round(anzeige.bounds.height * anzeige.scaleFactor) };
|
||||
const quellen = await desktopCapturer.getSources({ types: ['screen'], thumbnailSize: px });
|
||||
const quelle = quellen.find((q) => q.id === info.screenSourceId) || quellen[0];
|
||||
if (!quelle || quelle.thumbnail.isEmpty()) return { ok: false, message: 'Bildschirm nicht erfassbar.' };
|
||||
const g = quelle.thumbnail.getSize();
|
||||
const sx = g.width / info.bildschirmBounds.width;
|
||||
const sy = g.height / info.bildschirmBounds.height;
|
||||
const b = info.fensterBounds;
|
||||
// unsichtbarer Fensterrahmen (siehe Aufnahme) - nur bei Fenstern, die nicht den ganzen Bildschirm füllen
|
||||
const rand = (b.width >= info.bildschirmBounds.width && b.height >= info.bildschirmBounds.height) ? 0 : 8;
|
||||
const x = Math.max(0, Math.round((b.x - info.bildschirmBounds.x + rand) * sx));
|
||||
const y = Math.max(0, Math.round((b.y - info.bildschirmBounds.y + rand) * sy));
|
||||
const w = Math.min(g.width - x, Math.round((b.width - rand * 2) * sx));
|
||||
const h = Math.min(g.height - y, Math.round((b.height - rand * 2) * sy));
|
||||
if (w < 16 || h < 16) return { ok: false, message: 'Spielfenster nicht sichtbar.' };
|
||||
const png = quelle.thumbnail.crop({ x, y, width: w, height: h }).toPNG();
|
||||
const ordner = path.join(store.gameDir(id), 'screenshots');
|
||||
fs.mkdirSync(ordner, { recursive: true });
|
||||
const d = new Date();
|
||||
const p2 = (n) => String(n).padStart(2, '0');
|
||||
const basis = `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())}_${p2(d.getHours())}.${p2(d.getMinutes())}.${p2(d.getSeconds())}`;
|
||||
let datei = path.join(ordner, basis + '.png');
|
||||
for (let n = 1; fs.existsSync(datei); n++) datei = path.join(ordner, `${basis}_${n}.png`);
|
||||
fs.writeFileSync(datei, png);
|
||||
zeigeIngameOverlay('Screenshot', 'Gespeichert', pid, 2500).catch(() => {});
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/*
|
||||
* Das mitgelieferte Plugin in die Stream-Deck-Software kopieren (siehe streamdeckplugin.js).
|
||||
* modus: 'start' = beim Programmstart (Fehler still), 'schalter' = beim Einschalten der Option,
|
||||
* 'knopf' = per Schaltfläche (zusätzlich "schon aktuell" melden)
|
||||
*/
|
||||
function installiereStreamdeckPlugin(modus) {
|
||||
const res = streamdeckplugin.installiere({ quelle: streamdeckplugin.quelle(app.isPackaged, process.resourcesPath) });
|
||||
if (res.ok && res.neustart) {
|
||||
broadcast('ui:toast', { text: `Stream-Deck-Plugin ${res.status === 'installiert' ? 'installiert' : 'aktualisiert'} – bitte die Stream-Deck-Software einmal neu starten.` });
|
||||
} else if (res.ok && modus === 'knopf') {
|
||||
broadcast('ui:toast', { text: 'Das Stream-Deck-Plugin ist schon auf dem neuesten Stand.' });
|
||||
} else if (!res.ok && modus !== 'start') {
|
||||
broadcast('ui:toast', { text: res.message, error: true });
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
ipcMain.handle('streamdeck:installPlugin', () => installiereStreamdeckPlugin('knopf'));
|
||||
|
||||
function initStreamdeck() {
|
||||
streamdeck.init({
|
||||
userDataDir: app.getPath('userData'),
|
||||
zustand: (opt) => {
|
||||
// Instanzen einmal lesen, ohne die Bilddateien selbst (der Stream Deck fragt alle 2 Sekunden)
|
||||
const alle = store.listInstances({ ohneBild: true });
|
||||
const bildId = (i) => {
|
||||
if (!i.customImage) return 'k:' + (i.icon || 'grass');
|
||||
try { return 'c:' + i.customImage + ':' + fs.statSync(path.join(store.instancePath(i.id), i.customImage)).mtimeMs; } catch { return 'c:' + i.customImage; }
|
||||
};
|
||||
const zuletzt = alle.filter((i) => i.lastPlayed).sort((x, y) => String(y.lastPlayed).localeCompare(String(x.lastPlayed)))[0];
|
||||
return {
|
||||
instanzen: alle.map((i) => ({
|
||||
id: i.id,
|
||||
name: i.name,
|
||||
laeuft: laufendeInstanzen.has(i.id),
|
||||
gesamt: i.totalPlaySeconds || 0,
|
||||
bildId: bildId(i),
|
||||
zuletzt: i.lastPlayed || null,
|
||||
seit: laufendeInstanzen.has(i.id) && sitzungsInfo.get(i.id) ? sitzungsInfo.get(i.id).start : null,
|
||||
})),
|
||||
aufnahme: recordingGuardActive,
|
||||
clip: !!clipInstanz,
|
||||
clipStartet: !clipInstanz && Date.now() < sdClipStartetBis,
|
||||
schnellstart: store.getSettings().schnellstart || {},
|
||||
konten: auth.listAccounts().map((a) => ({ id: a.id, name: a.name, aktiv: !!a.active, spielt: spielendeKonten.has(a.id) })),
|
||||
// Freunde nur holen, wenn eine Taste sie anzeigt (kostet eine Anfrage an den Server)
|
||||
freunde: opt && opt.freunde ? sdFreundeStand() : null,
|
||||
backup: { laeuft: backupLaeuft, zeit: sdBackup.zeit, ok: sdBackup.ok },
|
||||
zuletzt: zuletzt ? zuletzt.id : null,
|
||||
};
|
||||
},
|
||||
starte: async (id, server) => {
|
||||
if (!store.getInstance(id)) return { ok: false, message: 'Instanz nicht gefunden.' };
|
||||
if (laufendeInstanzen.has(id)) return { ok: false, message: 'Läuft bereits.' };
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return { ok: false, message: 'Launcher-Fenster fehlt.' };
|
||||
starteInstanzImFenster(id, server || null); // gleicher Weg wie die Desktop-Verknüpfung
|
||||
return { ok: true };
|
||||
},
|
||||
neustart: async (id) => {
|
||||
const pid = laufendePid.get(id);
|
||||
if (!pid) return { ok: false, message: 'Das Spiel läuft nicht.' };
|
||||
require('child_process').spawn('taskkill', ['/PID', String(pid)], { windowsHide: true }).on('error', () => {});
|
||||
// warten, bis Minecraft wirklich beendet ist (Speichern kann dauern), dann neu starten
|
||||
const server = letzteQuickPlayServer.get(id) || null;
|
||||
const bis = Date.now() + 90000;
|
||||
const t = setInterval(() => {
|
||||
if (laufendeInstanzen.has(id) && Date.now() < bis) return;
|
||||
clearInterval(t);
|
||||
if (!laufendeInstanzen.has(id)) setTimeout(() => starteInstanzImFenster(id, server), 1500);
|
||||
}, 500);
|
||||
return { ok: true };
|
||||
},
|
||||
fenster: async () => {
|
||||
if (!mainWindow || mainWindow.isDestroyed() || !mainWindow.isVisible() || mainWindow.isMinimized() || !mainWindow.isFocused()) showMainWindow();
|
||||
else mainWindow.minimize();
|
||||
return { ok: true };
|
||||
},
|
||||
clipModus: () => aufnahmeAktion('clipmodus'),
|
||||
ordner: async (id) => {
|
||||
const inst = id ? store.getInstance(id) : null;
|
||||
const dir = inst ? aufzeichnungsOrdner(inst.name) : path.dirname(aufzeichnungsOrdner('x'));
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
shell.openPath(dir);
|
||||
return { ok: true };
|
||||
},
|
||||
screenshot: () => macheScreenshot(),
|
||||
kopf: async (uuid) => {
|
||||
if (!/^[0-9a-f-]{32,36}$/i.test(String(uuid || ''))) return { ok: false };
|
||||
const uri = await spielerKopf(String(uuid || ''));
|
||||
return uri ? { ok: true, pixel: false, bild: uri } : { ok: false };
|
||||
},
|
||||
absturz: async (id) => {
|
||||
const inst = store.getInstance(id);
|
||||
if (!inst) return { ok: false };
|
||||
const r = crashreports.lies(store.gameDir(id), 0);
|
||||
if (!r.ok) return { ok: true, bericht: false };
|
||||
const zeit = Date.parse(r.zeitpunkt);
|
||||
// "frisch" = der Bericht stammt aus dem letzten Spielstart (nicht von einem früheren Absturz)
|
||||
const frisch = !inst.lastPlayed || zeit >= Date.parse(inst.lastPlayed) - 5000;
|
||||
return { ok: true, bericht: true, zeit, frisch };
|
||||
},
|
||||
konto: async (id) => {
|
||||
if (!auth.listAccounts().some((a) => a.id === id)) return { ok: false, message: 'Konto nicht gefunden.' };
|
||||
setzeAktivesKonto(id);
|
||||
return { ok: true };
|
||||
},
|
||||
backup: async () => {
|
||||
if (backupLaeuft) return { ok: false, message: 'Läuft bereits.' };
|
||||
sdBackup = { laeuft: true, zeit: 0, ok: null };
|
||||
erstelleBackup({ still: true })
|
||||
.then((r) => { sdBackup = { laeuft: false, zeit: Date.now(), ok: !!(r && r.ok) }; })
|
||||
.catch(() => { sdBackup = { laeuft: false, zeit: Date.now(), ok: false }; });
|
||||
return { ok: true };
|
||||
},
|
||||
oeffne: async (art, id) => {
|
||||
const instanz = (id && store.getInstance(id) ? id : null) || [...laufendePid.keys()].pop()
|
||||
|| (store.listInstances().filter((i) => i.lastPlayed).sort((a, b) => String(b.lastPlayed).localeCompare(String(a.lastPlayed)))[0] || {}).id;
|
||||
if (art === 'friends') { openPopout('friends', {}); return { ok: true }; }
|
||||
if (art === 'console') { openPopout('console', {}); return { ok: true }; }
|
||||
if (!instanz) return { ok: false, message: 'Keine Instanz.' };
|
||||
if (art === 'logs') { openPopout('logs', { instanceId: instanz }); return { ok: true }; }
|
||||
if (art === 'crash') {
|
||||
if (!crashreports.lies(store.gameDir(instanz), 0).ok) return { ok: false, message: 'Kein Absturzbericht.' };
|
||||
openPopout('crash', { instanceId: instanz, seit: 0 });
|
||||
return { ok: true };
|
||||
}
|
||||
return { ok: false, message: 'Unbekannt.' };
|
||||
},
|
||||
symbol: async (id) => {
|
||||
const inst = store.getInstance(id);
|
||||
if (!inst) return { ok: false };
|
||||
// eigenes Bild verkleinert (die Taste ist nur 144 px groß), sonst das Minecraft-Icon des Schlüssels
|
||||
if (inst.customImage) {
|
||||
try {
|
||||
const d = store.iconDataUri(id);
|
||||
const bild = d ? nativeImage.createFromDataURL(d) : null;
|
||||
if (bild && !bild.isEmpty()) {
|
||||
const g = bild.getSize();
|
||||
const k = Math.min(1, 96 / Math.max(g.width, g.height));
|
||||
return { ok: true, pixel: false, bild: bild.resize({ width: Math.max(1, Math.round(g.width * k)), height: Math.max(1, Math.round(g.height * k)) }).toDataURL() };
|
||||
}
|
||||
} catch { /* dann Minecraft-Icon */ }
|
||||
}
|
||||
try {
|
||||
const schluessel = String(inst.icon || 'grass');
|
||||
if (!/^[\w-]+$/.test(schluessel)) return { ok: false };
|
||||
const datei = path.join(iconDirs().out, schluessel + '.png');
|
||||
return { ok: true, pixel: true, bild: 'data:image/png;base64,' + fs.readFileSync(datei).toString('base64') };
|
||||
} catch { return { ok: false }; }
|
||||
},
|
||||
serverListe: async (id) => {
|
||||
if (!store.getInstance(id)) return { ok: false, server: [] };
|
||||
const r = servers.lies(store.gameDir(id));
|
||||
return { ok: !!r.ok, server: (r.server || []).map((x) => ({ name: x.name, ip: x.ip })) };
|
||||
},
|
||||
ping: async (ip) => {
|
||||
const m = String(ip || '').trim().match(/^([^:]+)(?::(d+))?$/);
|
||||
if (!m) return { ok: false };
|
||||
try {
|
||||
const r = await mcping.pingServer(m[1], m[2] ? Number(m[2]) : 25565);
|
||||
return { ok: !!r.ok, online: r.online, max: r.max, latencyMs: r.latencyMs };
|
||||
} catch { return { ok: false }; }
|
||||
},
|
||||
beende: async (id) => {
|
||||
const pid = laufendePid.get(id);
|
||||
if (!pid) return { ok: false, message: 'Das Spiel läuft nicht.' };
|
||||
// ohne /F: Minecraft bekommt die Bitte zum Schließen und speichert sauber
|
||||
require('child_process').spawn('taskkill', ['/PID', String(pid)], { windowsHide: true }).on('error', () => {});
|
||||
return { ok: true };
|
||||
},
|
||||
clipSpeichern: async () => {
|
||||
const win = aufnahmeFenster();
|
||||
if (!clipInstanz || !win) return { ok: false, message: 'Clip-Modus ist nicht aktiv.' };
|
||||
const pid = laufendePid.get(clipInstanz);
|
||||
if (pid) zeigeIngameOverlay('Clip', 'Wird gespeichert …', pid, 2500).catch(() => {});
|
||||
win.webContents.send('clip:hotkey');
|
||||
return { ok: true };
|
||||
},
|
||||
aufnahmeUmschalten: () => aufnahmeAktion('aufnahme'),
|
||||
});
|
||||
streamdeck.configure(store.getSettings());
|
||||
// nach einem Launcher-Update bekommt auch das Plugin die neue Fassung
|
||||
if (store.getSettings().streamdeckEnabled === true) setTimeout(() => installiereStreamdeckPlugin('start'), 6000);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IPC – Dialoge
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -4795,6 +5238,8 @@ ipcMain.handle('instances:icon', (_e, id) => store.iconDataUri(id));
|
||||
// beliebige Bilddatei als data:-URI (für Vorschau vor dem Speichern)
|
||||
ipcMain.handle('file:dataUri', (_e, filePath) => {
|
||||
try {
|
||||
// nur Bilddateien - sonst ließe sich damit jede beliebige Datei auslesen
|
||||
if (typeof filePath !== 'string' || !/\.(png|jpe?g|gif|webp|bmp)$/i.test(filePath)) return null;
|
||||
const stat = fs.statSync(filePath);
|
||||
if (stat.size > 8 * 1024 * 1024) return null; // >8 MB nicht als Vorschau
|
||||
let ext = path.extname(filePath).slice(1).toLowerCase();
|
||||
@@ -5084,7 +5529,8 @@ ipcMain.handle('bedrock:installAddon', async (e) => {
|
||||
// Weltordner im Explorer zeigen
|
||||
ipcMain.handle('bedrock:openWorld', (_e, dir) => {
|
||||
const base = bedrock.folderPath('worlds');
|
||||
if (base && dir && dir.startsWith(base) && fs.existsSync(dir)) shell.openPath(dir);
|
||||
const voll = typeof dir === 'string' ? path.resolve(dir) : '';
|
||||
if (base && voll.startsWith(path.resolve(base) + path.sep) && fs.existsSync(voll)) shell.openPath(voll);
|
||||
return true;
|
||||
});
|
||||
|
||||
|
||||
@@ -409,6 +409,14 @@ contextBridge.exposeInMainWorld('api', {
|
||||
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);
|
||||
@@ -446,7 +454,7 @@ contextBridge.exposeInMainWorld('api', {
|
||||
ipcRenderer.on('recording:gameAudioChunk', l);
|
||||
return () => ipcRenderer.removeListener('recording:gameAudioChunk', l);
|
||||
},
|
||||
recordingSetGuard: (active) => ipcRenderer.send('recording:guard', active),
|
||||
recordingSetGuard: (active, instanceId) => ipcRenderer.send('recording:guard', active, instanceId),
|
||||
onRecordCloseAttempt: (cb) => {
|
||||
const l = () => cb();
|
||||
ipcRenderer.on('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);
|
||||
@@ -4100,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); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4137,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4553,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';
|
||||
@@ -6134,7 +6174,9 @@ 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;
|
||||
@@ -6272,7 +6314,9 @@ 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,
|
||||
@@ -7492,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);
|
||||
@@ -7691,6 +7738,7 @@ const POPOUT_OPENERS = {
|
||||
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);
|
||||
}
|
||||
@@ -7716,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) {
|
||||
@@ -7879,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 */ }
|
||||
})();
|
||||
|
||||
@@ -75,7 +75,10 @@ const DEFAULT_SETTINGS = {
|
||||
sortBy: 'lastPlayed', // lastPlayed | name
|
||||
iso3dIcons: true, // Block-Symbole räumlich darstellen
|
||||
bedrockEnabled: true, // Bedrock-Bereich anzeigen (nur wenn auch installiert)
|
||||
betaTeilnahme: false, // Vorabfassungen (Beta, RC) als Update bekommen
|
||||
letzteVersionVorab: false, // lief zuletzt eine Vorabfassung? (siehe main.js: Beta-Teilnahme)
|
||||
desktopNotifications: true, // Windows-Meldung bei fertigem Hintergrund-Download/-Backup
|
||||
streamdeckEnabled: false, // lokale Schnittstelle für das Stream-Deck-Plugin (siehe streamdeck.js)
|
||||
friendsEnabled: true,
|
||||
friendsShareServer: true, // Freunde sehen, auf welchem Server man spielt (für "Beitreten")
|
||||
autoModUpdates: false,
|
||||
@@ -187,10 +190,22 @@ function getInstancesDir() {
|
||||
: path.join(USER_DATA, 'instances');
|
||||
}
|
||||
|
||||
// Kennungen kommen teils aus der Oberfläche: ".." oder Pfadtrenner dürfen nie aus dem Instanzordner hinausführen
|
||||
function istGueltigeId(id) {
|
||||
const s = typeof id === 'string' ? id : '';
|
||||
return !!s && s !== '.' && s !== '..' && !/[\\/:*?"<>|\0]/.test(s) && s === s.trim();
|
||||
}
|
||||
|
||||
function instancePath(id) {
|
||||
if (!istGueltigeId(id)) throw new Error('Ungültige Instanz-Kennung.');
|
||||
return path.join(getInstancesDir(), id);
|
||||
}
|
||||
|
||||
// Dateiname eines eigenen Instanz-Bildes: nur ein schlichter Bildname direkt im Instanzordner
|
||||
function istBildName(name) {
|
||||
return typeof name === 'string' && name === path.basename(name) && /^[\w.\- ]+\.(png|jpe?g|gif|webp)$/i.test(name);
|
||||
}
|
||||
|
||||
function instanceFile(id) {
|
||||
return path.join(instancePath(id), 'instance.json');
|
||||
}
|
||||
@@ -330,6 +345,8 @@ function findDependents(id, filename, art = 'mod') {
|
||||
}
|
||||
|
||||
function removeMod(id, filename, art = 'mod') {
|
||||
// nur ein schlichter Dateiname im Inhaltsordner - nie ein Pfad, der daraus hinausführt
|
||||
if (typeof filename !== 'string' || !filename || filename !== path.basename(filename)) return listMods(id, art);
|
||||
const list = listMods(id, art).filter((m) => m.filename !== filename);
|
||||
writeJson(modsIndexFile(id, art), list);
|
||||
// zugehörige Datei löschen – auch, wenn sie gerade abgeschaltet ist
|
||||
@@ -372,7 +389,7 @@ function defaultInstance(name) {
|
||||
// liest ein eigenes Instanz-Bild als data:-URI (für die Anzeige im Renderer)
|
||||
function iconDataUri(id) {
|
||||
const meta = readJson(instanceFile(id), null);
|
||||
if (!meta || !meta.customImage) return null;
|
||||
if (!meta || !meta.customImage || !istBildName(meta.customImage)) return null;
|
||||
try {
|
||||
const buf = fs.readFileSync(path.join(instancePath(id), meta.customImage));
|
||||
let ext = path.extname(meta.customImage).slice(1).toLowerCase();
|
||||
@@ -397,7 +414,8 @@ function clearCustomImage(id) {
|
||||
return updateInstance(id, { customImage: null });
|
||||
}
|
||||
|
||||
function listInstances() {
|
||||
// opts.ohneBild: eigene Bilder nicht mitlesen (z. B. für häufige Abfragen vom Stream Deck)
|
||||
function listInstances(opts) {
|
||||
const dir = getInstancesDir();
|
||||
ensureDir(dir);
|
||||
const result = [];
|
||||
@@ -406,7 +424,7 @@ function listInstances() {
|
||||
const meta = readJson(path.join(dir, entry.name, 'instance.json'), null);
|
||||
if (meta && meta.id) {
|
||||
// eigenes Bild direkt für die Anzeige mitliefern (nicht persistiert)
|
||||
if (meta.customImage) meta.iconData = iconDataUri(meta.id);
|
||||
if (meta.customImage && !(opts && opts.ohneBild)) meta.iconData = iconDataUri(meta.id);
|
||||
result.push(meta);
|
||||
}
|
||||
}
|
||||
@@ -423,6 +441,7 @@ function listInstances() {
|
||||
}
|
||||
|
||||
function getInstance(id) {
|
||||
if (!istGueltigeId(id)) return null;
|
||||
return readJson(instanceFile(id), null);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -941,20 +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}
|
||||
|
||||
/* 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}
|
||||
|
||||
@@ -15,6 +15,32 @@ 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';
|
||||
@@ -188,16 +214,23 @@ function parsePreRelease(v) {
|
||||
}
|
||||
|
||||
/*
|
||||
* Vergleicht die Vorabkennungen zweier gleicher Versionsnummern.
|
||||
* 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.
|
||||
*
|
||||
* 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).
|
||||
* 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 && !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;
|
||||
@@ -250,20 +283,31 @@ function pickAsset(release, installKind) {
|
||||
* Prüft auf Updates.
|
||||
* -> { available, currentVersion, latestVersion, notes, asset, url, noReleases }
|
||||
*/
|
||||
async function checkForUpdate(currentVersion) {
|
||||
async function checkForUpdate(currentVersion, opts) {
|
||||
const installKind = detectInstallKind();
|
||||
const res = await fetch(API + '?limit=10', { headers: { 'User-Agent': UA, Accept: 'application/json' } });
|
||||
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 releases = (Array.isArray(list) ? list : []).filter((r) => !r.draft);
|
||||
if (!releases.length) {
|
||||
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(b.tag_name, a.tag_name));
|
||||
releases.sort((a, b) => compareVersions(tagVersion(b.tag_name), tagVersion(a.tag_name)));
|
||||
const latest = releases[0];
|
||||
const available = compareVersions(latest.tag_name, currentVersion) > 0;
|
||||
const latestVersion = normVersion(latest.tag_name);
|
||||
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;
|
||||
@@ -485,6 +529,8 @@ module.exports = {
|
||||
pruefeSignatur,
|
||||
signaturText,
|
||||
holeSignatur,
|
||||
tagVersion,
|
||||
istVorabVersion: (v) => !!parsePreRelease(v),
|
||||
SIGNATUR_API,
|
||||
cleanupUpdateCache,
|
||||
compareVersions,
|
||||
|
||||
@@ -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 |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 57 KiB |
@@ -0,0 +1,365 @@
|
||||
{
|
||||
"Name": "AeroMC",
|
||||
"Version": "1.1.1.0",
|
||||
"Author": "M_Viper",
|
||||
"Description": "Steuert den AeroMC-Launcher: Instanzen starten und beenden, Clips speichern und die Aufnahme umschalten - mit Live-Status auf den Tasten.",
|
||||
"Category": "AeroMC",
|
||||
"CategoryIcon": "imgs/plugin/category-icon",
|
||||
"Icon": "imgs/plugin/marketplace",
|
||||
"URL": "https://aeromc.viper.ipv64.net",
|
||||
"SDKVersion": 2,
|
||||
"UUID": "com.aeromc.launcher",
|
||||
"Nodejs": {
|
||||
"Version": "20"
|
||||
},
|
||||
"Software": {
|
||||
"MinimumVersion": "6.5"
|
||||
},
|
||||
"OS": [
|
||||
{
|
||||
"Platform": "windows",
|
||||
"MinimumVersion": "10"
|
||||
}
|
||||
],
|
||||
"CodePath": "bin/plugin.js",
|
||||
"Actions": [
|
||||
{
|
||||
"Name": "Instanz starten/beenden",
|
||||
"UUID": "com.aeromc.launcher.instanz",
|
||||
"Icon": "imgs/actions/icon",
|
||||
"Tooltip": "Startet die gewählte Instanz; läuft sie schon, beendet die Taste sie wieder. Zeigt den Zustand live an.",
|
||||
"PropertyInspectorPath": "ui/instanz.html",
|
||||
"Controllers": [
|
||||
"Keypad"
|
||||
],
|
||||
"States": [
|
||||
{
|
||||
"Image": "imgs/actions/key",
|
||||
"TitleAlignment": "bottom",
|
||||
"ShowTitle": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Clip speichern",
|
||||
"UUID": "com.aeromc.launcher.clip",
|
||||
"Icon": "imgs/actions/icon",
|
||||
"Tooltip": "Speichert die letzten Sekunden als Clip. Der Clip-Modus muss laufen (Taste „Clip-Modus ein/aus“).",
|
||||
"Controllers": [
|
||||
"Keypad"
|
||||
],
|
||||
"States": [
|
||||
{
|
||||
"Image": "imgs/actions/key",
|
||||
"TitleAlignment": "bottom",
|
||||
"ShowTitle": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Aufnahme starten/stoppen",
|
||||
"UUID": "com.aeromc.launcher.aufnahme",
|
||||
"Icon": "imgs/actions/icon",
|
||||
"Tooltip": "Startet bzw. stoppt die Aufnahme des laufenden Spiels. Das Aufnahme-Fenster öffnet sich dabei unsichtbar von selbst.",
|
||||
"Controllers": [
|
||||
"Keypad"
|
||||
],
|
||||
"States": [
|
||||
{
|
||||
"Image": "imgs/actions/key",
|
||||
"TitleAlignment": "bottom",
|
||||
"ShowTitle": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Server beitreten",
|
||||
"UUID": "com.aeromc.launcher.server",
|
||||
"Icon": "imgs/actions/icon",
|
||||
"Tooltip": "Startet die gewählte Instanz und verbindet direkt mit dem gewählten Server.",
|
||||
"PropertyInspectorPath": "ui/server.html",
|
||||
"Controllers": [
|
||||
"Keypad"
|
||||
],
|
||||
"States": [
|
||||
{
|
||||
"Image": "imgs/actions/key",
|
||||
"TitleAlignment": "bottom",
|
||||
"ShowTitle": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Zuletzt gespielte Instanz",
|
||||
"UUID": "com.aeromc.launcher.zuletzt",
|
||||
"Icon": "imgs/actions/icon",
|
||||
"Tooltip": "Startet (bzw. beendet) die Instanz, die du zuletzt gespielt hast – ohne Auswahl.",
|
||||
"Controllers": [
|
||||
"Keypad"
|
||||
],
|
||||
"States": [
|
||||
{
|
||||
"Image": "imgs/actions/key",
|
||||
"TitleAlignment": "bottom",
|
||||
"ShowTitle": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Launcher-Fenster",
|
||||
"UUID": "com.aeromc.launcher.fenster",
|
||||
"Icon": "imgs/actions/icon",
|
||||
"Tooltip": "Holt das AeroMC-Fenster nach vorn; ist es schon vorn, wird es minimiert.",
|
||||
"Controllers": [
|
||||
"Keypad"
|
||||
],
|
||||
"States": [
|
||||
{
|
||||
"Image": "imgs/actions/key",
|
||||
"TitleAlignment": "bottom",
|
||||
"ShowTitle": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Clip-Modus ein/aus",
|
||||
"UUID": "com.aeromc.launcher.clipmodus",
|
||||
"Icon": "imgs/actions/icon",
|
||||
"Tooltip": "Schaltet den Clip-Puffer des laufenden Spiels ein oder aus. Das Aufnahme-Fenster öffnet sich dabei unsichtbar von selbst.",
|
||||
"Controllers": [
|
||||
"Keypad"
|
||||
],
|
||||
"States": [
|
||||
{
|
||||
"Image": "imgs/actions/key",
|
||||
"TitleAlignment": "bottom",
|
||||
"ShowTitle": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Instanz neu starten",
|
||||
"UUID": "com.aeromc.launcher.neustart",
|
||||
"Icon": "imgs/actions/icon",
|
||||
"Tooltip": "Beendet die laufende Instanz sauber und startet sie wieder (mit demselben Server).",
|
||||
"PropertyInspectorPath": "ui/instanz.html",
|
||||
"Controllers": [
|
||||
"Keypad"
|
||||
],
|
||||
"States": [
|
||||
{
|
||||
"Image": "imgs/actions/key",
|
||||
"TitleAlignment": "bottom",
|
||||
"ShowTitle": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Aufnahmen-Ordner",
|
||||
"UUID": "com.aeromc.launcher.ordner",
|
||||
"Icon": "imgs/actions/icon",
|
||||
"Tooltip": "Öffnet den Ordner mit Aufnahmen und Clips im Explorer.",
|
||||
"PropertyInspectorPath": "ui/instanz.html",
|
||||
"Controllers": [
|
||||
"Keypad"
|
||||
],
|
||||
"States": [
|
||||
{
|
||||
"Image": "imgs/actions/key",
|
||||
"TitleAlignment": "bottom",
|
||||
"ShowTitle": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Spielzeit",
|
||||
"UUID": "com.aeromc.launcher.spielzeit",
|
||||
"Icon": "imgs/actions/icon",
|
||||
"Tooltip": "Zeigt die Gesamtspielzeit (einer Instanz oder aller) und während des Spielens die aktuelle Sitzung.",
|
||||
"PropertyInspectorPath": "ui/instanz.html",
|
||||
"Controllers": [
|
||||
"Keypad"
|
||||
],
|
||||
"States": [
|
||||
{
|
||||
"Image": "imgs/actions/key",
|
||||
"TitleAlignment": "bottom",
|
||||
"ShowTitle": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Server-Status",
|
||||
"UUID": "com.aeromc.launcher.serverstatus",
|
||||
"Icon": "imgs/actions/icon",
|
||||
"Tooltip": "Zeigt, ob ein Server online ist, wie viele Spieler drauf sind und den Ping. Aktualisiert alle 30 Sekunden.",
|
||||
"PropertyInspectorPath": "ui/server.html",
|
||||
"Controllers": [
|
||||
"Keypad"
|
||||
],
|
||||
"States": [
|
||||
{
|
||||
"Image": "imgs/actions/key",
|
||||
"TitleAlignment": "bottom",
|
||||
"ShowTitle": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Screenshot",
|
||||
"UUID": "com.aeromc.launcher.screenshot",
|
||||
"Icon": "imgs/actions/icon",
|
||||
"Tooltip": "Speichert ein Bild des Spielfensters im Screenshot-Ordner der laufenden Instanz. Überlagernde Fenster (z. B. das Stream Deck) erscheinen mit auf dem Bild.",
|
||||
"Controllers": [
|
||||
"Keypad"
|
||||
],
|
||||
"States": [
|
||||
{
|
||||
"Image": "imgs/actions/key",
|
||||
"TitleAlignment": "bottom",
|
||||
"ShowTitle": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Instanz-Drehregler",
|
||||
"UUID": "com.aeromc.launcher.dreh",
|
||||
"Icon": "imgs/actions/icon",
|
||||
"Tooltip": "Für das Stream Deck+: Drehen wählt die Instanz, Drücken startet bzw. beendet sie, Tippen auf den Streifen zeigt oder minimiert den Launcher.",
|
||||
"Controllers": [
|
||||
"Encoder"
|
||||
],
|
||||
"Encoder": {
|
||||
"layout": "$B1",
|
||||
"TriggerDescription": {
|
||||
"Rotate": "Instanz wählen",
|
||||
"Push": "Starten / Beenden",
|
||||
"Touch": "Launcher-Fenster"
|
||||
}
|
||||
},
|
||||
"States": [
|
||||
{
|
||||
"Image": "imgs/actions/key",
|
||||
"TitleAlignment": "bottom",
|
||||
"ShowTitle": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Freunde online",
|
||||
"UUID": "com.aeromc.launcher.freunde",
|
||||
"Icon": "imgs/actions/icon",
|
||||
"Tooltip": "Zeigt, wie viele deiner AeroMC-Freunde gerade online sind. Ein Druck öffnet das Freunde-Fenster.",
|
||||
"Controllers": [
|
||||
"Keypad"
|
||||
],
|
||||
"States": [
|
||||
{
|
||||
"Image": "imgs/actions/key",
|
||||
"TitleAlignment": "bottom",
|
||||
"ShowTitle": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Konto wechseln",
|
||||
"UUID": "com.aeromc.launcher.konto",
|
||||
"Icon": "imgs/actions/icon",
|
||||
"Tooltip": "Setzt das gewählte Konto als aktives Konto in AeroMC. Zeigt den Spielerkopf; grün = gerade aktiv.",
|
||||
"PropertyInspectorPath": "ui/konto.html",
|
||||
"Controllers": [
|
||||
"Keypad"
|
||||
],
|
||||
"States": [
|
||||
{
|
||||
"Image": "imgs/actions/key",
|
||||
"TitleAlignment": "bottom",
|
||||
"ShowTitle": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Schnellstart 1–5",
|
||||
"UUID": "com.aeromc.launcher.schnellstart",
|
||||
"Icon": "imgs/actions/icon",
|
||||
"Tooltip": "Startet die Instanz, die du in AeroMC auf Strg+1 bis Strg+5 gelegt hast (bzw. beendet sie, wenn sie läuft).",
|
||||
"PropertyInspectorPath": "ui/schnellstart.html",
|
||||
"Controllers": [
|
||||
"Keypad"
|
||||
],
|
||||
"States": [
|
||||
{
|
||||
"Image": "imgs/actions/key",
|
||||
"TitleAlignment": "bottom",
|
||||
"ShowTitle": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Backup jetzt",
|
||||
"UUID": "com.aeromc.launcher.backup",
|
||||
"Icon": "imgs/actions/icon",
|
||||
"Tooltip": "Sichert alle Instanzen. Zeigt „läuft“ und nach dem Ende „fertig“ bzw. „Fehler“.",
|
||||
"Controllers": [
|
||||
"Keypad"
|
||||
],
|
||||
"States": [
|
||||
{
|
||||
"Image": "imgs/actions/key",
|
||||
"TitleAlignment": "bottom",
|
||||
"ShowTitle": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Logdateien",
|
||||
"UUID": "com.aeromc.launcher.logs",
|
||||
"Icon": "imgs/actions/icon",
|
||||
"Tooltip": "Öffnet die Logdateien der gewählten (sonst der laufenden oder zuletzt gespielten) Instanz.",
|
||||
"PropertyInspectorPath": "ui/instanz.html",
|
||||
"Controllers": [
|
||||
"Keypad"
|
||||
],
|
||||
"States": [
|
||||
{
|
||||
"Image": "imgs/actions/key",
|
||||
"TitleAlignment": "bottom",
|
||||
"ShowTitle": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Konsole",
|
||||
"UUID": "com.aeromc.launcher.konsole",
|
||||
"Icon": "imgs/actions/icon",
|
||||
"Tooltip": "Öffnet die Konsole von AeroMC.",
|
||||
"Controllers": [
|
||||
"Keypad"
|
||||
],
|
||||
"States": [
|
||||
{
|
||||
"Image": "imgs/actions/key",
|
||||
"TitleAlignment": "bottom",
|
||||
"ShowTitle": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Letzter Absturz",
|
||||
"UUID": "com.aeromc.launcher.absturz",
|
||||
"Icon": "imgs/actions/icon",
|
||||
"Tooltip": "Öffnet den Absturzbericht der gewählten (sonst laufenden/zuletzt gespielten) Instanz. Leuchtet rot, wenn der letzte Start abgestürzt ist.",
|
||||
"PropertyInspectorPath": "ui/instanz.html",
|
||||
"Controllers": [
|
||||
"Keypad"
|
||||
],
|
||||
"States": [
|
||||
{
|
||||
"Image": "imgs/actions/key",
|
||||
"TitleAlignment": "bottom",
|
||||
"ShowTitle": false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>AeroMC-Instanz</title>
|
||||
<style>
|
||||
body { font-family: "Segoe UI", sans-serif; font-size: 13px; color: #d8d8d8; background: #2d2d2d; margin: 0; padding: 12px; }
|
||||
label { display: block; margin-bottom: 4px; color: #a8a8a8; }
|
||||
select { width: 100%; padding: 6px; background: #3d3d3d; color: #fff; border: 1px solid #555; border-radius: 4px; }
|
||||
.hint { margin-top: 10px; color: #8a8a8a; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<label for="instanz">Instanz</label>
|
||||
<select id="instanz"><option value="">Lade …</option></select>
|
||||
<p class="hint" id="hinweis"></p>
|
||||
<script>
|
||||
// bei diesen Tasten darf die Instanz leer bleiben (= alle Instanzen)
|
||||
const OPTIONAL = ['com.aeromc.launcher.spielzeit', 'com.aeromc.launcher.ordner', 'com.aeromc.launcher.logs', 'com.aeromc.launcher.absturz'];
|
||||
const OPTIONAL_TEXT = { 'com.aeromc.launcher.logs': 'Laufende / zuletzt gespielte', 'com.aeromc.launcher.absturz': 'Laufende / zuletzt gespielte' };
|
||||
let ws, uuid, aktion, einstellungen = {}, wiederholung = null;
|
||||
|
||||
function frageInstanzen() {
|
||||
if (ws.readyState === 1) ws.send(JSON.stringify({ event: 'sendToPlugin', action: aktion, context: uuid, payload: { frage: 'instanzen' } }));
|
||||
}
|
||||
|
||||
function fuelle(p) {
|
||||
// solange AeroMC nicht erreichbar ist, alle 2 s neu fragen - ein Neuauswählen der Taste ist dann nicht nötig
|
||||
if (p.erreichbar) { clearInterval(wiederholung); wiederholung = null; }
|
||||
else if (!wiederholung) wiederholung = setInterval(frageInstanzen, 2000);
|
||||
const sel = document.getElementById('instanz');
|
||||
const hinweis = document.getElementById('hinweis');
|
||||
sel.innerHTML = '';
|
||||
if (!p.erreichbar) {
|
||||
sel.innerHTML = '<option value="">AeroMC nicht erreichbar</option>';
|
||||
hinweis.textContent = 'AeroMC starten und unter Optionen → Allgemein „Stream Deck erlauben“ einschalten. Dieses Fenster aktualisiert sich dann selbst.';
|
||||
return;
|
||||
}
|
||||
hinweis.textContent = '';
|
||||
const leer = document.createElement('option');
|
||||
leer.value = ''; leer.textContent = OPTIONAL.includes(aktion) ? (OPTIONAL_TEXT[aktion] || 'Alle Instanzen') : '– bitte wählen –';
|
||||
sel.appendChild(leer);
|
||||
for (const i of p.instanzen) {
|
||||
const o = document.createElement('option');
|
||||
o.value = i.id; o.textContent = i.name;
|
||||
sel.appendChild(o);
|
||||
}
|
||||
sel.value = einstellungen.instanceId || '';
|
||||
}
|
||||
|
||||
function connectElgatoStreamDeckSocket(port, inUUID, registerEvent, info, actionInfo) {
|
||||
uuid = inUUID;
|
||||
const a = JSON.parse(actionInfo);
|
||||
aktion = a.action;
|
||||
einstellungen = (a.payload && a.payload.settings) || {};
|
||||
ws = new WebSocket('ws://127.0.0.1:' + port);
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify({ event: registerEvent, uuid }));
|
||||
frageInstanzen();
|
||||
};
|
||||
ws.onmessage = (e) => {
|
||||
const m = JSON.parse(e.data);
|
||||
if (m.event === 'sendToPropertyInspector') fuelle(m.payload || {});
|
||||
};
|
||||
document.getElementById('instanz').addEventListener('change', (ev) => {
|
||||
einstellungen.instanceId = ev.target.value;
|
||||
ws.send(JSON.stringify({ event: 'setSettings', context: uuid, payload: einstellungen }));
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,65 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>AeroMC-Konto</title>
|
||||
<style>
|
||||
body { font-family: "Segoe UI", sans-serif; font-size: 13px; color: #d8d8d8; background: #2d2d2d; margin: 0; padding: 12px; }
|
||||
label { display: block; margin-bottom: 4px; color: #a8a8a8; }
|
||||
select { width: 100%; padding: 6px; background: #3d3d3d; color: #fff; border: 1px solid #555; border-radius: 4px; }
|
||||
.hint { margin-top: 10px; color: #8a8a8a; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<label for="wahl">Konto</label>
|
||||
<select id="wahl"><option value="">Lade …</option></select>
|
||||
<p class="hint" id="hinweis"></p>
|
||||
<script>
|
||||
let ws, uuid, aktion, einstellungen = {}, wiederholung = null;
|
||||
|
||||
function frage() {
|
||||
if (ws.readyState === 1) ws.send(JSON.stringify({ event: 'sendToPlugin', action: aktion, context: uuid, payload: { frage: 'liste' } }));
|
||||
}
|
||||
|
||||
function fuelle(p) {
|
||||
if (p.erreichbar) { clearInterval(wiederholung); wiederholung = null; }
|
||||
else if (!wiederholung) wiederholung = setInterval(frage, 2000);
|
||||
const sel = document.getElementById('wahl');
|
||||
const hinweis = document.getElementById('hinweis');
|
||||
sel.innerHTML = '';
|
||||
if (!p.erreichbar) {
|
||||
sel.innerHTML = '<option value="">AeroMC nicht erreichbar</option>';
|
||||
hinweis.textContent = 'AeroMC starten und unter Optionen → Allgemein „Stream Deck erlauben“ einschalten. Dieses Fenster aktualisiert sich dann selbst.';
|
||||
return;
|
||||
}
|
||||
hinweis.textContent = '';
|
||||
const leer = document.createElement('option');
|
||||
leer.value = ''; leer.textContent = '– bitte wählen –';
|
||||
sel.appendChild(leer);
|
||||
for (const k of p.konten) {
|
||||
const o = document.createElement('option');
|
||||
o.value = k.id; o.textContent = k.name + (k.aktiv ? ' (aktiv)' : '');
|
||||
sel.appendChild(o);
|
||||
}
|
||||
sel.value = einstellungen.kontoId || '';
|
||||
}
|
||||
|
||||
function connectElgatoStreamDeckSocket(port, inUUID, registerEvent, info, actionInfo) {
|
||||
uuid = inUUID;
|
||||
const a = JSON.parse(actionInfo);
|
||||
aktion = a.action;
|
||||
einstellungen = (a.payload && a.payload.settings) || {};
|
||||
ws = new WebSocket('ws://127.0.0.1:' + port);
|
||||
ws.onopen = () => { ws.send(JSON.stringify({ event: registerEvent, uuid })); frage(); };
|
||||
ws.onmessage = (e) => {
|
||||
const m = JSON.parse(e.data);
|
||||
if (m.event === 'sendToPropertyInspector') fuelle(m.payload || {});
|
||||
};
|
||||
document.getElementById('wahl').addEventListener('change', (ev) => {
|
||||
einstellungen.kontoId = ev.target.value;
|
||||
ws.send(JSON.stringify({ event: 'setSettings', context: uuid, payload: einstellungen }));
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,65 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>AeroMC-Schnellstart</title>
|
||||
<style>
|
||||
body { font-family: "Segoe UI", sans-serif; font-size: 13px; color: #d8d8d8; background: #2d2d2d; margin: 0; padding: 12px; }
|
||||
label { display: block; margin-bottom: 4px; color: #a8a8a8; }
|
||||
select { width: 100%; padding: 6px; background: #3d3d3d; color: #fff; border: 1px solid #555; border-radius: 4px; }
|
||||
.hint { margin-top: 10px; color: #8a8a8a; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<label for="wahl">Schnellstart-Platz (Strg+Zahl in AeroMC)</label>
|
||||
<select id="wahl"><option value="">Lade …</option></select>
|
||||
<p class="hint" id="hinweis"></p>
|
||||
<script>
|
||||
let ws, uuid, aktion, einstellungen = {}, wiederholung = null;
|
||||
|
||||
function frage() {
|
||||
if (ws.readyState === 1) ws.send(JSON.stringify({ event: 'sendToPlugin', action: aktion, context: uuid, payload: { frage: 'liste' } }));
|
||||
}
|
||||
|
||||
function fuelle(p) {
|
||||
if (p.erreichbar) { clearInterval(wiederholung); wiederholung = null; }
|
||||
else if (!wiederholung) wiederholung = setInterval(frage, 2000);
|
||||
const sel = document.getElementById('wahl');
|
||||
const hinweis = document.getElementById('hinweis');
|
||||
sel.innerHTML = '';
|
||||
if (!p.erreichbar) {
|
||||
sel.innerHTML = '<option value="">AeroMC nicht erreichbar</option>';
|
||||
hinweis.textContent = 'AeroMC starten und unter Optionen → Allgemein „Stream Deck erlauben“ einschalten. Dieses Fenster aktualisiert sich dann selbst.';
|
||||
return;
|
||||
}
|
||||
hinweis.textContent = '';
|
||||
const namen = Object.fromEntries(p.instanzen.map((i) => [i.id, i.name]));
|
||||
for (const n of ['1', '2', '3', '4', '5']) {
|
||||
const o = document.createElement('option');
|
||||
o.value = n;
|
||||
o.textContent = 'Strg+' + n + ' – ' + (namen[p.schnellstart[n]] || 'nicht belegt');
|
||||
sel.appendChild(o);
|
||||
}
|
||||
if (!einstellungen.slot) einstellungen.slot = '1';
|
||||
sel.value = einstellungen.slot || '';
|
||||
}
|
||||
|
||||
function connectElgatoStreamDeckSocket(port, inUUID, registerEvent, info, actionInfo) {
|
||||
uuid = inUUID;
|
||||
const a = JSON.parse(actionInfo);
|
||||
aktion = a.action;
|
||||
einstellungen = (a.payload && a.payload.settings) || {};
|
||||
ws = new WebSocket('ws://127.0.0.1:' + port);
|
||||
ws.onopen = () => { ws.send(JSON.stringify({ event: registerEvent, uuid })); frage(); };
|
||||
ws.onmessage = (e) => {
|
||||
const m = JSON.parse(e.data);
|
||||
if (m.event === 'sendToPropertyInspector') fuelle(m.payload || {});
|
||||
};
|
||||
document.getElementById('wahl').addEventListener('change', (ev) => {
|
||||
einstellungen.slot = ev.target.value;
|
||||
ws.send(JSON.stringify({ event: 'setSettings', context: uuid, payload: einstellungen }));
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,110 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>AeroMC-Server</title>
|
||||
<style>
|
||||
body { font-family: "Segoe UI", sans-serif; font-size: 13px; color: #d8d8d8; background: #2d2d2d; margin: 0; padding: 12px; }
|
||||
label { display: block; margin-bottom: 4px; color: #a8a8a8; }
|
||||
select { width: 100%; padding: 6px; background: #3d3d3d; color: #fff; border: 1px solid #555; border-radius: 4px; }
|
||||
input { width: 100%; box-sizing: border-box; padding: 6px; background: #3d3d3d; color: #fff; border: 1px solid #555; border-radius: 4px; }
|
||||
.hint { margin-top: 10px; color: #8a8a8a; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<label for="instanz">Instanz</label>
|
||||
<select id="instanz"><option value="">Lade …</option></select>
|
||||
<label for="server" style="margin-top:12px">Server dieser Instanz</label>
|
||||
<select id="server"><option value="">– Instanz wählen –</option></select>
|
||||
<label for="adresse" style="margin-top:12px">oder Adresse eingeben</label>
|
||||
<input id="adresse" type="text" placeholder="z. B. mc.example.net:25565" />
|
||||
<p class="hint" id="hinweis"></p>
|
||||
<script>
|
||||
// bei diesen Tasten darf die Instanz leer bleiben (= alle Instanzen)
|
||||
const OPTIONAL = [];
|
||||
let ws, uuid, aktion, einstellungen = {}, wiederholung = null;
|
||||
|
||||
function frageInstanzen() {
|
||||
if (ws.readyState !== 1) return;
|
||||
const id = einstellungen.instanceId;
|
||||
ws.send(JSON.stringify({ event: 'sendToPlugin', action: aktion, context: uuid, payload: id ? { frage: 'server', id } : { frage: 'instanzen' } }));
|
||||
}
|
||||
|
||||
function speichere() {
|
||||
ws.send(JSON.stringify({ event: 'setSettings', context: uuid, payload: einstellungen }));
|
||||
}
|
||||
|
||||
function fuelleServer(liste) {
|
||||
const sel = document.getElementById('server');
|
||||
sel.innerHTML = '';
|
||||
const leer = document.createElement('option');
|
||||
leer.value = ''; leer.textContent = liste.length ? '– bitte wählen –' : 'Keine Server in dieser Instanz';
|
||||
sel.appendChild(leer);
|
||||
for (const x of liste) {
|
||||
const o = document.createElement('option');
|
||||
o.value = x.ip; o.textContent = (x.name ? x.name + ' – ' : '') + x.ip; o.dataset.name = x.name || '';
|
||||
sel.appendChild(o);
|
||||
}
|
||||
sel.value = liste.some((x) => x.ip === einstellungen.server) ? einstellungen.server : '';
|
||||
}
|
||||
|
||||
function fuelle(p) {
|
||||
// solange AeroMC nicht erreichbar ist, alle 2 s neu fragen - ein Neuauswählen der Taste ist dann nicht nötig
|
||||
if (p.erreichbar) { clearInterval(wiederholung); wiederholung = null; }
|
||||
else if (!wiederholung) wiederholung = setInterval(frageInstanzen, 2000);
|
||||
const sel = document.getElementById('instanz');
|
||||
const hinweis = document.getElementById('hinweis');
|
||||
sel.innerHTML = '';
|
||||
if (!p.erreichbar) {
|
||||
sel.innerHTML = '<option value="">AeroMC nicht erreichbar</option>';
|
||||
hinweis.textContent = 'AeroMC starten und unter Optionen → Allgemein „Stream Deck erlauben“ einschalten. Dieses Fenster aktualisiert sich dann selbst.';
|
||||
return;
|
||||
}
|
||||
hinweis.textContent = '';
|
||||
const leer = document.createElement('option');
|
||||
leer.value = ''; leer.textContent = OPTIONAL.includes(aktion) ? 'Alle Instanzen' : '– bitte wählen –';
|
||||
sel.appendChild(leer);
|
||||
for (const i of p.instanzen) {
|
||||
const o = document.createElement('option');
|
||||
o.value = i.id; o.textContent = i.name;
|
||||
sel.appendChild(o);
|
||||
}
|
||||
sel.value = einstellungen.instanceId || '';
|
||||
if (p.server) fuelleServer(p.server);
|
||||
}
|
||||
|
||||
function connectElgatoStreamDeckSocket(port, inUUID, registerEvent, info, actionInfo) {
|
||||
uuid = inUUID;
|
||||
const a = JSON.parse(actionInfo);
|
||||
aktion = a.action;
|
||||
einstellungen = (a.payload && a.payload.settings) || {};
|
||||
ws = new WebSocket('ws://127.0.0.1:' + port);
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify({ event: registerEvent, uuid }));
|
||||
frageInstanzen();
|
||||
};
|
||||
ws.onmessage = (e) => {
|
||||
const m = JSON.parse(e.data);
|
||||
if (m.event === 'sendToPropertyInspector') fuelle(m.payload || {});
|
||||
};
|
||||
document.getElementById('adresse').value = einstellungen.adresse || '';
|
||||
document.getElementById('instanz').addEventListener('change', (ev) => {
|
||||
einstellungen.instanceId = ev.target.value;
|
||||
einstellungen.server = ''; einstellungen.serverName = '';
|
||||
speichere();
|
||||
fuelleServer([]);
|
||||
frageInstanzen();
|
||||
});
|
||||
document.getElementById('server').addEventListener('change', (ev) => {
|
||||
einstellungen.server = ev.target.value;
|
||||
einstellungen.serverName = ev.target.selectedOptions[0] ? ev.target.selectedOptions[0].dataset.name || '' : '';
|
||||
speichere();
|
||||
});
|
||||
document.getElementById('adresse').addEventListener('input', (ev) => {
|
||||
einstellungen.adresse = ev.target.value.trim();
|
||||
speichere();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||