Compare commits

..
46 Commits
Author SHA1 Message Date
M_Viper e7f0939279 Upload via GUI (219 Dateien) 2026-11-09 13:01:12 +00:00
M_Viper cd08d72b2c Upload via GUI (219 Dateien) 2026-11-09 13:01:07 +00:00
M_Viper 2760253b81 Upload via GUI (219 Dateien) 2026-11-09 13:01:05 +00:00
M_Viper b7fad0d874 Upload via GUI (219 Dateien) 2026-11-09 13:01:02 +00:00
M_Viper f6fcc5096a Upload via GUI (219 Dateien) 2026-11-09 13:00:58 +00:00
M_Viper 32fc09f152 Upload via GUI (219 Dateien) 2026-11-09 13:00:55 +00:00
M_Viper 53aefae162 Upload via GUI (219 Dateien) 2026-11-09 13:00:48 +00:00
M_Viper 94cb1b9b62 Upload via GUI (219 Dateien) 2026-11-09 13:00:46 +00:00
M_Viper b242e64678 Upload via GUI (219 Dateien) 2026-11-09 13:00:43 +00:00
M_Viper 28d234eaf8 Delete 53 files via Git Manager GUI 2026-11-02 21:55:05 +00:00
M_Viper b711a3a7a1 Delete 52 files via Git Manager GUI 2026-11-02 21:55:00 +00:00
M_Viper b134e19bd0 Upload via GUI (324 Dateien) 2026-11-02 21:48:51 +00:00
M_Viper d3838f9d30 Upload via GUI (324 Dateien) 2026-11-02 21:48:49 +00:00
M_Viper 1a52097201 Upload via GUI (324 Dateien) 2026-11-02 21:48:48 +00:00
M_Viper c249c1d7f5 Upload via GUI (324 Dateien) 2026-11-02 21:48:47 +00:00
M_Viper df3eae4ee8 Upload via GUI (324 Dateien) 2026-11-02 21:48:45 +00:00
M_Viper cf5fc48fba Upload via GUI (324 Dateien) 2026-11-02 21:48:43 +00:00
M_Viper 8080fc1f62 Upload via GUI (324 Dateien) 2026-11-02 21:48:40 +00:00
M_Viper 30459acb6f Upload via GUI (324 Dateien) 2026-11-02 21:48:39 +00:00
M_Viper 1c8dc48490 Upload via GUI (324 Dateien) 2026-11-02 21:48:37 +00:00
M_Viper 8c67204de2 Upload via GUI (324 Dateien) 2026-11-02 21:48:36 +00:00
M_Viper c90ac36c74 Upload via GUI (324 Dateien) 2026-11-02 21:48:35 +00:00
M_Viper b1bc1f2ba5 Delete 53 files via Git Manager GUI 2026-10-30 21:48:49 +00:00
M_Viper e5a486fe62 Delete 52 files via Git Manager GUI 2026-10-30 21:48:46 +00:00
M_Viper a563ba6837 Upload via GUI (324 Dateien) 2026-10-30 21:48:35 +00:00
M_Viper 4210d5780b Upload via GUI (324 Dateien) 2026-10-30 21:48:33 +00:00
M_Viper 8338d8f0e8 Upload via GUI (324 Dateien) 2026-10-30 21:48:32 +00:00
M_Viper e41f8faa18 Upload via GUI (324 Dateien) 2026-10-30 21:48:31 +00:00
M_Viper 988292ae24 Upload via GUI (324 Dateien) 2026-10-30 21:48:29 +00:00
M_Viper 2a9bf9b610 Upload via GUI (324 Dateien) 2026-10-30 21:48:28 +00:00
M_Viper 3b366d83ec Upload via GUI (324 Dateien) 2026-10-30 21:48:27 +00:00
M_Viper c4ea9bae27 Upload via GUI (324 Dateien) 2026-10-30 21:48:25 +00:00
M_Viper 7681e17bb6 Upload via GUI (324 Dateien) 2026-10-30 21:48:25 +00:00
M_Viper d3c5f50710 Upload via GUI (324 Dateien) 2026-10-30 21:48:23 +00:00
M_Viper 3d656f49c6 Upload via GUI (324 Dateien) 2026-10-30 21:48:22 +00:00
M_Viper 9d55faa3eb Upload via GUI (324 Dateien) 2026-10-30 05:54:28 +00:00
M_Viper e8b5d6b6e0 Upload via GUI (324 Dateien) 2026-10-30 05:54:21 +00:00
M_Viper f97ad783fb Upload via GUI (324 Dateien) 2026-10-30 05:54:19 +00:00
M_Viper 50345fb8e3 Upload via GUI (324 Dateien) 2026-10-30 05:54:18 +00:00
M_Viper 103771e9c2 Upload via GUI (324 Dateien) 2026-10-30 05:54:16 +00:00
M_Viper 9cebeeeaa4 Upload via GUI (324 Dateien) 2026-10-30 05:54:14 +00:00
M_Viper 3b7408a2fc Upload via GUI (324 Dateien) 2026-10-30 05:54:12 +00:00
M_Viper a70916fc7d Upload via GUI (324 Dateien) 2026-10-30 05:54:11 +00:00
M_Viper 4ef589ebbc Upload via GUI (324 Dateien) 2026-10-30 05:54:10 +00:00
M_Viper 6f53307925 Upload via GUI (324 Dateien) 2026-10-30 05:54:09 +00:00
M_Viper 2b68dc8768 Upload via GUI (324 Dateien) 2026-10-30 05:54:07 +00:00
45 changed files with 3265 additions and 975 deletions
+28
View File
@@ -2,6 +2,34 @@
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
+15 -3
View File
@@ -1,6 +1,6 @@
# AeroMC – Sicherheit
Überblick über die Sicherheitsmaßnahmen im AeroMC Launcher (Stand: Version 1.2.3).
Überblick über die Sicherheitsmaßnahmen im AeroMC Launcher (Stand: Version 1.3.2).
---
@@ -28,8 +28,8 @@
Ist das der Fall, startet es nicht.
- **Keine fremden Webseiten:** Launcher-Fenster laden nie fremde Seiten, Links öffnen sich
immer im Browser.
- **Geprüfte Abhängigkeiten:** Alle eingesetzten Programmbibliotheken sind ohne bekannte
Sicherheitslücken.
- **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
@@ -84,3 +84,15 @@ gespeichert wird. Eine veränderte oder beschädigte Datei wird verworfen.
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": "0.0.0",
"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"
}
}
}
Binary file not shown.
+2 -2
View File
@@ -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();
+2 -2
View File
@@ -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")]
Binary file not shown.
@@ -14,11 +14,11 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyConfigurationAttribute("Release")]
[assembly: System.Reflection.AssemblyCopyrightAttribute("Copyright (c) M_Viper. Alle Rechte vorbehalten.")]
[assembly: System.Reflection.AssemblyDescriptionAttribute("Installiert AeroMC")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.2.4.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.2.4")]
[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.4.0")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.3.2.0")]
[assembly: System.Resources.NeutralResourcesLanguageAttribute("de-DE")]
// Von der MSBuild WriteCodeFragment-Klasse generiert.
@@ -1 +1 @@
c285113a921613345252c7c4e9c903f219339ded7e116f59ea5c382b3fb91383
2821045dd08c55a3267e014e5026ac3588e68340b7d53441249b44302c5f3e33
+2 -2
View File
@@ -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();
+2 -2
View File
@@ -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")]
+2 -2
View File
@@ -418,7 +418,7 @@
"C:\\Users\\Viper\\.nuget\\packages\\": {}
},
"project": {
"version": "0.0.0",
"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 -1
View File
@@ -1,6 +1,6 @@
{
"version": 2,
"dgSpecHash": "FnRoda2hVew=",
"dgSpecHash": "dC0MHaIYOYg=",
"success": true,
"projectFilePath": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj",
"expectedPackageFiles": [
+6 -6
View File
@@ -1,12 +1,12 @@
{
"name": "aeromc",
"version": "1.2.4",
"version": "1.3.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aeromc",
"version": "1.2.4",
"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"
+14 -3
View File
@@ -1,7 +1,7 @@
{
"name": "aeromc",
"productName": "AeroMC",
"version": "1.2.4",
"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": {
+45
View File
@@ -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); });
+4
View File
@@ -33,6 +33,10 @@ 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
+30 -5
View File
@@ -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,7 +112,20 @@ 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`.
- **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).
@@ -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
+139 -26
View File
@@ -2,7 +2,7 @@
const http = require('http');
const crypto = require('crypto');
const { Readable } = require('stream');
const { Readable, Transform, pipeline } = require('stream');
const KEY = (process.env.CF_API_KEY || '').trim();
const PORT = Number(process.env.PORT || 8420);
@@ -14,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);
@@ -157,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
@@ -166,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;
}
}
// ---------------------------------------------------------------------------
@@ -211,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
@@ -249,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: {
@@ -280,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 */ }
@@ -312,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);
}
@@ -320,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);
+2 -2
View File
@@ -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 };
}
+10 -2
View File
@@ -280,6 +280,14 @@
<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>
@@ -553,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">
@@ -1621,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">
+439 -11
View File
@@ -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');
@@ -245,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()) {
@@ -282,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);
});
@@ -302,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) => {
@@ -556,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, () => {
@@ -639,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;
@@ -661,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 });
@@ -670,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;
});
@@ -1092,6 +1106,7 @@ app.whenReady().then(() => {
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)
@@ -1178,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(); });
// ---------------------------------------------------------------------------
@@ -1208,6 +1223,10 @@ 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;
@@ -2044,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
@@ -2057,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 ----
@@ -2886,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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
// charset=utf-8 nicht vergessen: ohne das interpretiert Chromium die
@@ -2910,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
@@ -3543,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
@@ -3558,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);
@@ -3584,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;
});
@@ -3998,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);
@@ -4026,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,
};
}
@@ -4106,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,
@@ -4262,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 });
@@ -4281,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));
@@ -4781,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
// ---------------------------------------------------------------------------
@@ -4813,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();
@@ -5102,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;
});
+9 -1
View File
@@ -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);
+56 -3
View File
@@ -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';
@@ -6136,6 +6176,7 @@ async function openSettings(pane) {
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;
@@ -6275,6 +6316,7 @@ async function saveSettings() {
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,
@@ -7494,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);
@@ -7693,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);
}
@@ -7718,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) {
+910 -893
View File
File diff suppressed because it is too large Load Diff
+20 -3
View File
@@ -78,6 +78,7 @@ const DEFAULT_SETTINGS = {
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,
@@ -189,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');
}
@@ -332,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
@@ -374,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();
@@ -399,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 = [];
@@ -408,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);
}
}
@@ -425,6 +441,7 @@ function listInstances() {
}
function getInstance(id) {
if (!istGueltigeId(id)) return null;
return readJson(instanceFile(id), null);
}
+189
View File
@@ -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 };
+82
View File
@@ -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 };
@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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);
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

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>