Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28d234eaf8 | ||
|
|
b711a3a7a1 | ||
|
|
b134e19bd0 | ||
|
|
d3838f9d30 | ||
|
|
1a52097201 | ||
|
|
c249c1d7f5 | ||
|
|
df3eae4ee8 | ||
|
|
cf5fc48fba | ||
|
|
8080fc1f62 | ||
|
|
30459acb6f | ||
|
|
1c8dc48490 | ||
|
|
8c67204de2 | ||
|
|
c90ac36c74 | ||
|
|
b1bc1f2ba5 | ||
|
|
e5a486fe62 | ||
|
|
a563ba6837 | ||
|
|
4210d5780b | ||
|
|
8338d8f0e8 | ||
|
|
e41f8faa18 | ||
|
|
988292ae24 | ||
|
|
2a9bf9b610 | ||
|
|
3b366d83ec | ||
|
|
c4ea9bae27 | ||
|
|
7681e17bb6 | ||
|
|
d3c5f50710 | ||
|
|
3d656f49c6 | ||
|
|
9d55faa3eb | ||
|
|
e8b5d6b6e0 | ||
|
|
f97ad783fb | ||
|
|
50345fb8e3 | ||
|
|
103771e9c2 | ||
|
|
9cebeeeaa4 | ||
|
|
3b7408a2fc | ||
|
|
a70916fc7d | ||
|
|
4ef589ebbc | ||
|
|
6f53307925 | ||
|
|
2b68dc8768 |
@@ -2,6 +2,28 @@
|
||||
|
||||
Alle nennenswerten Änderungen an AeroMC werden hier festgehalten.
|
||||
|
||||
## [1.3.1] - 2026-11-02
|
||||
|
||||
### Geändert
|
||||
- CurseForge: Mods, die ihre Autoren nicht über Launcher freigeben, werden nicht mehr geladen. Beim Modpack-Import legt AeroMC stattdessen die Liste „GESPERRTE-MODS.txt“ mit Links zum Selbstladen in den Spielordner, bei einzelnen Mods erscheint eine Meldung mit dem Link
|
||||
- Stream Deck: Das Plugin ist jetzt in Fassung 1.1.1.0 dabei. Bei eingeschaltetem „Stream Deck erlauben“ aktualisiert AeroMC es von selbst, danach die Stream-Deck-Software einmal neu starten
|
||||
|
||||
### Verbessert
|
||||
- Kleinere Verbesserungen und Aktualisierungen im Hintergrund
|
||||
|
||||
## [1.3.0] - 2026-10-30
|
||||
|
||||
### Neu
|
||||
- Stream Deck: AeroMC lässt sich jetzt per Taste steuern. Die Tasten zeigen den Zustand live an und tragen das Bild der jeweiligen Instanz.
|
||||
- Spielen: Instanz starten und beenden, zuletzt gespielte Instanz, Schnellstart 1–5, Instanz neu starten, Server beitreten
|
||||
- Aufnehmen: Aufnahme starten und stoppen, Clip-Modus ein/aus, Clip speichern, Screenshot, Aufnahmen-Ordner
|
||||
- Anzeigen: Spielzeit, Server-Status (online, Spieler, Ping), Freunde online
|
||||
- Verwalten: Konto wechseln, Backup jetzt, Logdateien, Konsole, letzter Absturz, Launcher-Fenster
|
||||
- Für das Stream Deck+ gibt es einen Drehregler zum Wählen und Starten von Instanzen.
|
||||
- Einschalten unter Optionen → Allgemein → „Stream Deck erlauben“. Das Plugin liegt AeroMC bei und wird dabei selbst in die Stream-Deck-Software kopiert; danach die Stream-Deck-Software einmal neu starten. Die Tasten stehen dann unter „AeroMC“.
|
||||
- Zusätzlich gibt es das Plugin als Datei „AeroMC.streamDeckPlugin“, die sich per Doppelklick installieren lässt.
|
||||
- Während einer Aufnahme zeigt AeroMC oben rechts im Spiel „● REC“ mit der Laufzeit an. Sie und die übrigen Einblendungen von AeroMC im Spiel (z. B. Freunde-Hinweise) sind nicht im aufgenommenen Video zu sehen (ab Windows 10, Version 2004).
|
||||
|
||||
## [1.2.4] - 2026-10-11
|
||||
|
||||
### Neu
|
||||
|
||||
@@ -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.1).
|
||||
|
||||
---
|
||||
|
||||
@@ -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.1",
|
||||
"restore": {
|
||||
"projectUniqueName": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj",
|
||||
"projectName": "AeroMC-Setup",
|
||||
@@ -51,7 +51,7 @@
|
||||
"autoReferenced": true
|
||||
}
|
||||
},
|
||||
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\10.0.112\\RuntimeIdentifierGraph.json"
|
||||
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\10.0.114\\RuntimeIdentifierGraph.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ namespace AeroMCSetup {
|
||||
/// InitializeComponent
|
||||
/// </summary>
|
||||
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.12.0")]
|
||||
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.14.0")]
|
||||
public void InitializeComponent() {
|
||||
if (_contentLoaded) {
|
||||
return;
|
||||
@@ -66,7 +66,7 @@ namespace AeroMCSetup {
|
||||
/// </summary>
|
||||
[System.STAThreadAttribute()]
|
||||
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.12.0")]
|
||||
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.14.0")]
|
||||
public static void Main() {
|
||||
AeroMCSetup.App app = new AeroMCSetup.App();
|
||||
app.InitializeComponent();
|
||||
|
||||
@@ -542,7 +542,7 @@ namespace AeroMCSetup {
|
||||
/// InitializeComponent
|
||||
/// </summary>
|
||||
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.12.0")]
|
||||
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.14.0")]
|
||||
public void InitializeComponent() {
|
||||
if (_contentLoaded) {
|
||||
return;
|
||||
@@ -558,7 +558,7 @@ namespace AeroMCSetup {
|
||||
}
|
||||
|
||||
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.12.0")]
|
||||
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.14.0")]
|
||||
[System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Never)]
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Design", "CA1033:InterfaceMethodsShouldBeCallableByChildTypes")]
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Maintainability", "CA1502:AvoidExcessiveComplexity")]
|
||||
|
||||
@@ -14,11 +14,11 @@ using System.Reflection;
|
||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Release")]
|
||||
[assembly: System.Reflection.AssemblyCopyrightAttribute("Copyright (c) M_Viper. Alle Rechte vorbehalten.")]
|
||||
[assembly: System.Reflection.AssemblyDescriptionAttribute("Installiert AeroMC")]
|
||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.2.4.0")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.2.4")]
|
||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.3.1.0")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.3.1")]
|
||||
[assembly: System.Reflection.AssemblyProductAttribute("AeroMC Setup")]
|
||||
[assembly: System.Reflection.AssemblyTitleAttribute("AeroMC Setup")]
|
||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.2.4.0")]
|
||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.3.1.0")]
|
||||
[assembly: System.Resources.NeutralResourcesLanguageAttribute("de-DE")]
|
||||
|
||||
// Von der MSBuild WriteCodeFragment-Klasse generiert.
|
||||
|
||||
@@ -1 +1 @@
|
||||
c285113a921613345252c7c4e9c903f219339ded7e116f59ea5c382b3fb91383
|
||||
580c54ad965e1465e6447eef31337065bb9284a8dba224aec55fdd4c0fb93d67
|
||||
|
||||
@@ -46,7 +46,7 @@ namespace AeroMCSetup {
|
||||
/// InitializeComponent
|
||||
/// </summary>
|
||||
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.12.0")]
|
||||
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.14.0")]
|
||||
public void InitializeComponent() {
|
||||
if (_contentLoaded) {
|
||||
return;
|
||||
@@ -66,7 +66,7 @@ namespace AeroMCSetup {
|
||||
/// </summary>
|
||||
[System.STAThreadAttribute()]
|
||||
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.12.0")]
|
||||
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.14.0")]
|
||||
public static void Main() {
|
||||
AeroMCSetup.App app = new AeroMCSetup.App();
|
||||
app.InitializeComponent();
|
||||
|
||||
@@ -542,7 +542,7 @@ namespace AeroMCSetup {
|
||||
/// InitializeComponent
|
||||
/// </summary>
|
||||
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.12.0")]
|
||||
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.14.0")]
|
||||
public void InitializeComponent() {
|
||||
if (_contentLoaded) {
|
||||
return;
|
||||
@@ -558,7 +558,7 @@ namespace AeroMCSetup {
|
||||
}
|
||||
|
||||
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.12.0")]
|
||||
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.14.0")]
|
||||
[System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Never)]
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Design", "CA1033:InterfaceMethodsShouldBeCallableByChildTypes")]
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Maintainability", "CA1502:AvoidExcessiveComplexity")]
|
||||
|
||||
@@ -418,7 +418,7 @@
|
||||
"C:\\Users\\Viper\\.nuget\\packages\\": {}
|
||||
},
|
||||
"project": {
|
||||
"version": "0.0.0",
|
||||
"version": "1.3.1",
|
||||
"restore": {
|
||||
"projectUniqueName": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj",
|
||||
"projectName": "AeroMC-Setup",
|
||||
@@ -464,7 +464,7 @@
|
||||
"autoReferenced": true
|
||||
}
|
||||
},
|
||||
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\10.0.112\\RuntimeIdentifierGraph.json"
|
||||
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\10.0.114\\RuntimeIdentifierGraph.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": 2,
|
||||
"dgSpecHash": "FnRoda2hVew=",
|
||||
"dgSpecHash": "SW1JzBEcPG8=",
|
||||
"success": true,
|
||||
"projectFilePath": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj",
|
||||
"expectedPackageFiles": [
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aeromc",
|
||||
"version": "1.2.4",
|
||||
"version": "1.3.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aeromc",
|
||||
"version": "1.2.4",
|
||||
"version": "1.3.1",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"dependencies": {
|
||||
"@xhayper/discord-rpc": "^1.3.4",
|
||||
@@ -16,7 +16,7 @@
|
||||
"node-window-manager": "^2.2.4",
|
||||
"skinview3d": "^3.4.2",
|
||||
"sql.js": "^1.14.2",
|
||||
"undici": "^6.28.0",
|
||||
"undici": "^6.29.1",
|
||||
"unzipper": "^0.12.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -4380,9 +4380,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "6.29.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.29.0.tgz",
|
||||
"integrity": "sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==",
|
||||
"version": "6.29.1",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.29.1.tgz",
|
||||
"integrity": "sha512-EvRlUt/QqAEvXdQeRMYUfwOQsfzpDztykrG2owGEE/rBOBoYv9uF39ZzsNDh4lTFHhTr2XOZjKkYgueD12ZmGA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "aeromc",
|
||||
"productName": "AeroMC",
|
||||
"version": "1.2.4",
|
||||
"version": "1.3.1",
|
||||
"description": "AeroMC – Instanz-basierter Minecraft-Launcher (à la MultiMC/Prism)",
|
||||
"homepage": "https://m-viper.de",
|
||||
"launcherWebsite": "https://aeromc.viper.ipv64.net",
|
||||
@@ -13,7 +13,8 @@
|
||||
"start": "electron .",
|
||||
"prebuild": "node scripts/pruefe-version.js && node scripts/write-build-config.js",
|
||||
"prebuild:portable": "node scripts/pruefe-version.js && node scripts/write-build-config.js",
|
||||
"build": "electron-builder --win dir && node scripts/build-setup.js && node scripts/sign-release.js setup",
|
||||
"build": "electron-builder --win dir && node scripts/build-setup.js && node scripts/sign-release.js setup && node scripts/build-streamdeck-plugin.js",
|
||||
"build:streamdeck": "node scripts/build-streamdeck-plugin.js",
|
||||
"build:portable": "electron-builder --win portable --x64 && node -e \"const fs=require('fs');const path=require('path');['dist/builder-debug.yml','dist/builder-effective-config.yaml','dist/win-unpacked'].forEach((target)=>fs.rmSync(target,{recursive:true,force:true}));if(fs.existsSync('dist')){const source=path.join('dist','AeroMC.exe');const target=path.join('dist','AeroMC-Portable.exe');if(fs.existsSync(target))fs.rmSync(target,{force:true});if(fs.existsSync(source))fs.renameSync(source,target);for(const name of fs.readdirSync('dist')){if((name.endsWith('.exe')&&name!=='AeroMC-Setup.exe'&&name!=='AeroMC-Portable.exe')||name.endsWith('.blockmap')){fs.rmSync(path.join('dist',name),{force:true})}}}\" && node scripts/sign-release.js portable"
|
||||
},
|
||||
"build": {
|
||||
@@ -21,6 +22,16 @@
|
||||
"productName": "AeroMC",
|
||||
"artifactName": "AeroMC.${ext}",
|
||||
"compression": "store",
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "streamdeck-plugin/com.aeromc.launcher.sdPlugin",
|
||||
"to": "streamdeck-plugin/com.aeromc.launcher.sdPlugin",
|
||||
"filter": [
|
||||
"**/*",
|
||||
"!logs/**"
|
||||
]
|
||||
}
|
||||
],
|
||||
"files": [
|
||||
"src/**/*",
|
||||
"package.json",
|
||||
@@ -55,7 +66,7 @@
|
||||
"node-window-manager": "^2.2.4",
|
||||
"skinview3d": "^3.4.2",
|
||||
"sql.js": "^1.14.2",
|
||||
"undici": "^6.28.0",
|
||||
"undici": "^6.29.1",
|
||||
"unzipper": "^0.12.5"
|
||||
},
|
||||
"overrides": {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* build-streamdeck-plugin.js – das AeroMC-Plugin als Datei zum Weitergeben packen
|
||||
* -------------------------------------------------------------------------------
|
||||
* Erzeugt dist/AeroMC.streamDeckPlugin. Das ist ein ZIP mit dem Plugin-Ordner; ein
|
||||
* Doppelklick darauf installiert es in der Stream-Deck-Software. Die Datei ist für
|
||||
* alle gedacht, die den Launcher nicht selbst aktualisieren lassen (oder die
|
||||
* Stream-Deck-Software erst später installieren).
|
||||
*
|
||||
* Die Launcher-Installation enthält das Plugin ohnehin und kopiert es von selbst
|
||||
* (src/streamdeckplugin.js) - diese Datei ist der zweite Weg dafür.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const archiver = require('archiver');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const NAME = 'com.aeromc.launcher.sdPlugin';
|
||||
const QUELLE = path.join(ROOT, 'streamdeck-plugin', NAME);
|
||||
const AUSGABE = path.join(ROOT, 'dist', 'AeroMC.streamDeckPlugin');
|
||||
|
||||
function main() {
|
||||
if (!fs.existsSync(path.join(QUELLE, 'manifest.json'))) throw new Error(`${QUELLE}\\manifest.json fehlt.`);
|
||||
fs.mkdirSync(path.dirname(AUSGABE), { recursive: true });
|
||||
fs.rmSync(AUSGABE, { force: true });
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const aus = fs.createWriteStream(AUSGABE);
|
||||
const zip = archiver('zip', { zlib: { level: 9 } });
|
||||
aus.on('close', () => {
|
||||
const version = JSON.parse(fs.readFileSync(path.join(QUELLE, 'manifest.json'), 'utf8')).Version;
|
||||
console.log(`\n» Stream-Deck-Plugin ${version}: ${AUSGABE} (${Math.round(zip.pointer() / 1024)} KB)`);
|
||||
resolve();
|
||||
});
|
||||
zip.on('error', reject);
|
||||
zip.pipe(aus);
|
||||
// Der Plugin-Ordner steht als einziger Eintrag oben im ZIP; Protokolle der Stream-Deck-Software gehören nicht hinein
|
||||
zip.directory(QUELLE, NAME, (eintrag) => (/^(logs[\\/]|logs$)/.test(eintrag.name.replace(NAME + '/', '')) ? false : eintrag));
|
||||
zip.finalize();
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((err) => { console.error('Stream-Deck-Plugin konnte nicht gepackt werden:', err.message); process.exit(1); });
|
||||
@@ -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
|
||||
|
||||
@@ -100,7 +100,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
|
||||
|
||||
@@ -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
|
||||
@@ -171,33 +181,72 @@ async function dateiDurchreichen(req, res, url) {
|
||||
if (!ziel) return sendeJson(res, 400, { error: 'Adresse fehlt oder zeigt nicht auf das CurseForge-CDN.' });
|
||||
|
||||
// Weiterleitungen selbst verfolgen und jedes Mal den Host neu prüfen
|
||||
let aktuell = ziel;
|
||||
let antwort = null;
|
||||
for (let sprung = 0; sprung < 4; sprung++) {
|
||||
antwort = await fetch(aktuell, { headers: cfKopfzeilen(), redirect: 'manual' });
|
||||
const weiter = antwort.headers.get('location');
|
||||
if (antwort.status >= 300 && antwort.status < 400 && weiter) {
|
||||
const naechste = pruefeDownloadZiel(new URL(weiter, aktuell).toString());
|
||||
if (!naechste) return sendeJson(res, 502, { error: 'Weiterleitung führt aus dem CurseForge-CDN heraus.' });
|
||||
aktuell = naechste;
|
||||
continue;
|
||||
if (MAX_PARALLEL > 0 && laufendeDownloads >= MAX_PARALLEL) {
|
||||
res.setHeader('Retry-After', '5');
|
||||
return sendeJson(res, 503, { error: 'Gerade zu viele Downloads, bitte gleich erneut versuchen.' });
|
||||
}
|
||||
laufendeDownloads++;
|
||||
let frei = false;
|
||||
const freigeben = () => { if (!frei) { frei = true; laufendeDownloads--; } };
|
||||
|
||||
try {
|
||||
// Zeitlimit nur bis die Antwort-Köpfe da sind – der eigentliche Download darf länger dauern
|
||||
const abbruch = new AbortController();
|
||||
const frist = setTimeout(() => abbruch.abort(), UPSTREAM_TIMEOUT_MS);
|
||||
res.on('close', () => abbruch.abort()); // Aufrufer weg -> CurseForge-Verbindung schließen
|
||||
|
||||
let aktuell = ziel;
|
||||
let antwort = null;
|
||||
let umgeleitet = true;
|
||||
try {
|
||||
for (let sprung = 0; sprung < 4 && umgeleitet; sprung++) {
|
||||
antwort = await fetch(aktuell, { headers: cfKopfzeilen(), redirect: 'manual', signal: abbruch.signal });
|
||||
const weiter = antwort.headers.get('location');
|
||||
if (antwort.status >= 300 && antwort.status < 400 && weiter) {
|
||||
const naechste = pruefeDownloadZiel(new URL(weiter, aktuell).toString());
|
||||
if (!naechste) { freigeben(); return sendeJson(res, 502, { error: 'Weiterleitung führt aus dem CurseForge-CDN heraus.' }); }
|
||||
aktuell = naechste;
|
||||
} else {
|
||||
umgeleitet = false;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(frist);
|
||||
}
|
||||
if (umgeleitet) { freigeben(); return sendeJson(res, 502, { error: 'Zu viele Weiterleitungen.' }); }
|
||||
if (!antwort || !antwort.ok) {
|
||||
freigeben();
|
||||
return sendeJson(res, antwort && antwort.status >= 400 ? antwort.status : 502, { error: 'Download fehlgeschlagen.' });
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (!antwort || !antwort.ok) {
|
||||
return sendeJson(res, antwort ? antwort.status : 502, { error: 'Download fehlgeschlagen.' });
|
||||
}
|
||||
|
||||
const laenge = Number(antwort.headers.get('content-length')) || 0;
|
||||
if (MAX_MB > 0 && laenge > MAX_MB * 1024 * 1024) {
|
||||
return sendeJson(res, 413, { error: `Datei größer als erlaubt (${MAX_MB} MB).` });
|
||||
}
|
||||
const laenge = Number(antwort.headers.get('content-length')) || 0;
|
||||
const grenze = MAX_MB > 0 ? MAX_MB * 1024 * 1024 : 0;
|
||||
if (grenze && laenge > grenze) {
|
||||
freigeben();
|
||||
return sendeJson(res, 413, { error: `Datei größer als erlaubt (${MAX_MB} MB).` });
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': antwort.headers.get('content-type') || 'application/octet-stream',
|
||||
...(laenge ? { 'Content-Length': String(laenge) } : {}),
|
||||
});
|
||||
Readable.fromWeb(antwort.body).pipe(res);
|
||||
res.writeHead(200, {
|
||||
'Content-Type': antwort.headers.get('content-type') || 'application/octet-stream',
|
||||
...(laenge ? { 'Content-Length': String(laenge) } : {}),
|
||||
});
|
||||
|
||||
// Auch ohne Content-Length (chunked) mitzählen und bei Überschreitung abbrechen,
|
||||
// sonst ließe sich die Größengrenze umgehen
|
||||
let gesamt = 0;
|
||||
const zaehlen = new Transform({
|
||||
transform(stueck, _enc, fertig) {
|
||||
gesamt += stueck.length;
|
||||
if (grenze && gesamt > grenze) return fertig(new Error('Datei größer als erlaubt'));
|
||||
fertig(null, stueck);
|
||||
},
|
||||
});
|
||||
// pipeline räumt auf, wenn eine Seite abbricht (statt wie pipe() offen zu bleiben)
|
||||
pipeline(Readable.fromWeb(antwort.body), zaehlen, res, () => freigeben());
|
||||
} catch (err) {
|
||||
freigeben();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -211,7 +260,11 @@ const server = http.createServer(async (req, res) => {
|
||||
return sendeJson(res, 200, { ok: true, dienst: 'aeromc-cf-proxy' });
|
||||
}
|
||||
|
||||
if (!tokenOk(req)) return sendeJson(res, 401, { error: 'Nicht berechtigt.' });
|
||||
if (!tokenOk(req)) {
|
||||
// Fehlversuche je Absender begrenzen, damit sich das Token nicht in Ruhe erraten lässt
|
||||
if (!darfNoch('falsch:' + absender(req))) return sendeJson(res, 429, { error: 'Zu viele Anfragen, bitte kurz warten.' });
|
||||
return sendeJson(res, 401, { error: 'Nicht berechtigt.' });
|
||||
}
|
||||
|
||||
/*
|
||||
* Hilfe beim Einstellen von TRUST_PROXY: zeigt, was der Dienst von DIESER
|
||||
@@ -249,6 +302,16 @@ const server = http.createServer(async (req, res) => {
|
||||
* niemals den Schlüssel selbst.
|
||||
*/
|
||||
if (url.pathname === '/selftest') {
|
||||
/*
|
||||
* Das Token steckt im Launcher und ist auslesbar. Jeder Selbsttest kostet fünf
|
||||
* CurseForge-Anfragen und läuft vor der Ratenbegrenzung – ohne Bremse ließe sich
|
||||
* damit das Kontingent des Schlüssels aufbrauchen. Deshalb höchstens alle
|
||||
* 30 Sekunden wirklich ausführen, dazwischen das letzte Ergebnis zeigen.
|
||||
*/
|
||||
const jetzt = Date.now();
|
||||
if (selftestErgebnis && jetzt - selftestZeit < SELFTEST_ABSTAND_MS) {
|
||||
return sendeJson(res, 200, Object.assign({}, selftestErgebnis, { zwischengespeichert: true }));
|
||||
}
|
||||
const roh = process.env.CF_API_KEY || '';
|
||||
const befund = {
|
||||
schluessel: {
|
||||
@@ -280,6 +343,7 @@ const server = http.createServer(async (req, res) => {
|
||||
method: p.method,
|
||||
headers: cfKopfzeilen(p.body ? { 'Content-Type': 'application/json' } : undefined),
|
||||
body: p.body ? JSON.stringify(p.body) : undefined,
|
||||
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
|
||||
});
|
||||
let text = '';
|
||||
try { text = (await a.text()).replace(/\s+/g, ' ').trim().slice(0, 160); } catch { /* egal */ }
|
||||
@@ -312,6 +376,8 @@ const server = http.createServer(async (req, res) => {
|
||||
|
||||
// Kurzfassung für den schnellen Blick
|
||||
befund.kurz = befund.proben.map((p) => `${p.name}: ${p.status ?? p.fehler}`);
|
||||
selftestErgebnis = befund;
|
||||
selftestZeit = Date.now();
|
||||
return sendeJson(res, 200, befund);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4262,9 +4370,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 +4405,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 +4908,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 +5231,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 +5522,8 @@ ipcMain.handle('bedrock:installAddon', async (e) => {
|
||||
// Weltordner im Explorer zeigen
|
||||
ipcMain.handle('bedrock:openWorld', (_e, dir) => {
|
||||
const base = bedrock.folderPath('worlds');
|
||||
if (base && dir && dir.startsWith(base) && fs.existsSync(dir)) shell.openPath(dir);
|
||||
const voll = typeof dir === 'string' ? path.resolve(dir) : '';
|
||||
if (base && voll.startsWith(path.resolve(base) + path.sep) && fs.existsSync(voll)) shell.openPath(voll);
|
||||
return true;
|
||||
});
|
||||
|
||||
|
||||
@@ -409,6 +409,14 @@ contextBridge.exposeInMainWorld('api', {
|
||||
ipcRenderer.on('clip:hotkey', l);
|
||||
return () => ipcRenderer.removeListener('clip:hotkey', l);
|
||||
},
|
||||
onStreamdeckAktion: (cb) => {
|
||||
const l = (_e, d) => cb(d);
|
||||
ipcRenderer.on('streamdeck:aktion', l);
|
||||
return () => ipcRenderer.removeListener('streamdeck:aktion', l);
|
||||
},
|
||||
streamdeckInstalliere: () => ipcRenderer.invoke('streamdeck:installPlugin'),
|
||||
streamdeckMeldung: (titel, text, fehler) => ipcRenderer.send('streamdeck:meldung', titel, text, !!fehler),
|
||||
streamdeckFensterZeigen: () => ipcRenderer.send('streamdeck:fensterZeigen'),
|
||||
onUiToast: (cb) => {
|
||||
const l = (_e, t) => cb(t);
|
||||
ipcRenderer.on('ui:toast', l);
|
||||
@@ -446,7 +454,7 @@ contextBridge.exposeInMainWorld('api', {
|
||||
ipcRenderer.on('recording:gameAudioChunk', l);
|
||||
return () => ipcRenderer.removeListener('recording:gameAudioChunk', l);
|
||||
},
|
||||
recordingSetGuard: (active) => ipcRenderer.send('recording:guard', active),
|
||||
recordingSetGuard: (active, instanceId) => ipcRenderer.send('recording:guard', active, instanceId),
|
||||
onRecordCloseAttempt: (cb) => {
|
||||
const l = () => cb();
|
||||
ipcRenderer.on('record:closeAttempt', l);
|
||||
|
||||
@@ -508,13 +508,13 @@ const el = (id) => document.getElementById(id);
|
||||
function show(id) { el(id).classList.remove('hidden'); }
|
||||
function hide(id) { el(id).classList.add('hidden'); }
|
||||
|
||||
function toast(msg, isError = false) {
|
||||
function toast(msg, isError = false, dauerMs = 2800) {
|
||||
const t = el('toast');
|
||||
t.textContent = msg;
|
||||
t.classList.toggle('err', isError);
|
||||
t.classList.remove('hidden');
|
||||
clearTimeout(toast._t);
|
||||
toast._t = setTimeout(() => t.classList.add('hidden'), 2800);
|
||||
toast._t = setTimeout(() => t.classList.add('hidden'), dauerMs);
|
||||
}
|
||||
|
||||
function fmtDate(iso) {
|
||||
@@ -778,6 +778,7 @@ async function runModpackImport() {
|
||||
toast(`Modpack importiert: ${res.instance.name} (${res.fileCount} Dateien)`);
|
||||
// CurseForge gibt nicht jede Datei heraus – ehrlich benennen statt verschweigen
|
||||
if (res.fehlend) toast(`${res.fehlend} Datei(en) konnten nicht geladen werden und fehlen in der Instanz.`, true);
|
||||
if (res.gesperrt) toast(`${res.gesperrt} Mod(s) dürfen laut Autor nicht über Launcher geladen werden. Liste mit Links: GESPERRTE-MODS.txt im Spielordner der Instanz.`, true, 10000);
|
||||
return;
|
||||
}
|
||||
toast(res.message || 'Modpack-Import fehlgeschlagen.', true);
|
||||
@@ -4100,12 +4101,15 @@ async function pollForMinecraftWindow() {
|
||||
} else if (sources.length > 1) {
|
||||
clearInterval(mcSearchTimer);
|
||||
showRecordingPicker(sources);
|
||||
// per Stream Deck gestartet: das Fenster ist unsichtbar, die Auswahl braucht aber dich
|
||||
if (sdAusstehend) { window.api.streamdeckFensterZeigen(); sdMeldung('Aufnahme', 'Bitte Spielfenster wählen'); }
|
||||
} else {
|
||||
mcSearchElapsed += 1000;
|
||||
if (mcSearchElapsed >= 30000) {
|
||||
clearInterval(mcSearchTimer);
|
||||
el('record-search-text').textContent = 'Kein Spielfenster gefunden. Läuft Minecraft und ist es nicht minimiert?';
|
||||
el('record-search-retry').classList.remove('hidden');
|
||||
if (sdAusstehend) { sdAusstehend = null; sdMeldung('Aufnahme', 'Kein Spielfenster gefunden', true); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4137,11 +4141,47 @@ async function selectRecordingSource(source) {
|
||||
await Promise.all([populateMicList(), checkGameAudioAvailability()]);
|
||||
updateAudioSummary();
|
||||
updateVideoSummary();
|
||||
if (sdAusstehend) { const art = sdAusstehend; sdAusstehend = null; fuehreSdAktionAus(art); }
|
||||
} catch (err) {
|
||||
hide('record-stage');
|
||||
show('record-search');
|
||||
el('record-search-text').textContent = 'Vorschau fehlgeschlagen: ' + err.message;
|
||||
el('record-search-retry').classList.remove('hidden');
|
||||
if (sdAusstehend) { sdAusstehend = null; sdMeldung('Aufnahme', 'Spielfenster nicht erfasst', true); }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Stream-Deck-Aktionen ----------
|
||||
// Nach erfolgreichem Start verschwindet das Fenster wieder (Clip-Modus und Aufnahme laufen im Hintergrund weiter);
|
||||
// bei einem Fehler bleibt es offen, damit man die Meldung sieht
|
||||
let sdAusstehend = null; // 'aufnahme' | 'clipmodus': nach der Fenstersuche noch auszuführen
|
||||
|
||||
function sdMeldung(titel, text, fehler) {
|
||||
window.api.streamdeckMeldung(titel, text, fehler);
|
||||
// Fehler zeigt der Hauptprozess im Hauptfenster - das Aufnahme-Fenster selbst hätte ohne offenes Modal nur eine leere Fläche
|
||||
|
||||
}
|
||||
|
||||
function sdFensterWegNachErfolg() { setTimeout(() => window.api.popoutHide(), 2000); }
|
||||
|
||||
async function fuehreSdAktionAus(art) {
|
||||
try {
|
||||
if (art === 'clipmodus') {
|
||||
if (clipAktiv) { stoppeClipModus(); sdMeldung('Clip-Modus', 'Aus'); }
|
||||
else if (recordingActive()) sdMeldung('Clip-Modus', 'Aufnahme läuft', true);
|
||||
else { await starteClipModus(); if (clipAktiv) { sdMeldung('Clip-Modus', 'An'); sdFensterWegNachErfolg(); } }
|
||||
} else if (recordingActive()) {
|
||||
sdMeldung('Aufnahme', 'Wird gespeichert …');
|
||||
await stopMcRecording();
|
||||
} else {
|
||||
// Aufnahme und Clip-Modus teilen sich Bildquelle und Ton -> läuft der Clip-Modus, wird er für die Aufnahme beendet
|
||||
const clipWarAn = clipAktiv;
|
||||
if (clipAktiv) stoppeClipModus();
|
||||
await startMcRecording();
|
||||
if (recordingActive()) { sdMeldung('Aufnahme', clipWarAn ? 'Läuft (Clip-Modus aus)' : 'Läuft'); sdFensterWegNachErfolg(); }
|
||||
}
|
||||
} catch (err) {
|
||||
sdMeldung(art === 'clipmodus' ? 'Clip-Modus' : 'Aufnahme', 'Fehler: ' + err.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4553,7 +4593,7 @@ async function startMcRecording() {
|
||||
mcRecorder.onstop = onMcRecordingStopped;
|
||||
mcRecorder.start(1000);
|
||||
|
||||
window.api.recordingSetGuard(true);
|
||||
window.api.recordingSetGuard(true, currentDetailId);
|
||||
mcStartedAt = Date.now();
|
||||
el('record-live-badge').classList.remove('hidden');
|
||||
el('record-toggle').textContent = 'Aufnahme stoppen';
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* streamdeck.js – lokale Schnittstelle für das AeroMC-Plugin des Elgato Stream Deck
|
||||
* -----------------------------------------------------------------------------
|
||||
* Ein kleiner HTTP-Server, der AUSSCHLIESSLICH auf 127.0.0.1 lauscht und nur auf
|
||||
* Wunsch läuft (Einstellung "streamdeckEnabled", standardmäßig aus).
|
||||
*
|
||||
* Absicherung gegen fremde Zugriffe:
|
||||
* - nur Loopback, kein Zugriff aus dem Netzwerk
|
||||
* - jede Anfrage braucht den Zufalls-Schlüssel im Kopf "Authorization: Bearer …";
|
||||
* der wird bei jedem Start neu erzeugt und nur in eine Datei im
|
||||
* Benutzerordner geschrieben, die das Plugin liest (streamdeck.json)
|
||||
* - Anfragen mit "Origin"-Kopf (also aus einer Webseite im Browser) und mit
|
||||
* fremdem "Host"-Kopf (DNS-Rebinding) werden abgewiesen
|
||||
*
|
||||
* Die eigentlichen Aktionen kommen von main.js (siehe init) – dieses Modul kennt
|
||||
* weder Instanzen noch Aufnahmen und reicht nur weiter.
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const STANDARD_PORT = 48615;
|
||||
const PORT_VERSUCHE = 10;
|
||||
const MAX_BODY = 4096;
|
||||
|
||||
let deps = null;
|
||||
let server = null;
|
||||
let schluessel = null;
|
||||
let verbindungsDatei = null;
|
||||
|
||||
/*
|
||||
* deps: {
|
||||
* userDataDir,
|
||||
* zustand() -> { instanzen: [{ id, name, laeuft, seit }], aufnahme, clip }
|
||||
* starte(id, server) -> { ok, message? } (server optional: direkt verbinden)
|
||||
* neustart(id) -> { ok, message? }
|
||||
* fenster() -> { ok } Launcher-Fenster zeigen bzw. wegminimieren
|
||||
* clipModus() -> { ok, message? }
|
||||
* ordner(id) -> { ok } Aufnahmen-Ordner öffnen
|
||||
* serverListe(id) -> { ok, server: [{ name, ip }] }
|
||||
* ping(ip) -> { ok, online, max, latencyMs }
|
||||
* screenshot() -> { ok, message? } Bild des Spielfensters in den Screenshot-Ordner
|
||||
* symbol(id) -> { ok, bild, pixel } Instanz-Bild als data:-URI
|
||||
* kopf(uuid) -> { ok, bild } Spielerkopf eines Kontos als data:-URI
|
||||
* absturz(id) -> { ok, bericht, zeit, frisch } neuester Absturzbericht der Instanz
|
||||
* konto(id) -> { ok } aktives Konto wechseln
|
||||
* backup() -> { ok, message? } Sicherung aller Instanzen starten
|
||||
* oeffne(art, id) -> { ok, message? } Fenster öffnen: friends | logs | console | crash
|
||||
* beende(id) -> { ok, message? }
|
||||
* clipSpeichern() -> { ok, message? }
|
||||
* aufnahmeUmschalten() -> { ok, message? }
|
||||
* }
|
||||
*/
|
||||
function init(d) {
|
||||
deps = d;
|
||||
verbindungsDatei = path.join(d.userDataDir, 'streamdeck.json');
|
||||
}
|
||||
|
||||
function istSchluesselGleich(angabe) {
|
||||
if (!schluessel || typeof angabe !== 'string') return false;
|
||||
const a = Buffer.from(angabe);
|
||||
const b = Buffer.from(schluessel);
|
||||
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
function antworte(res, status, daten) {
|
||||
const text = JSON.stringify(daten);
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Content-Length': Buffer.byteLength(text),
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(text);
|
||||
}
|
||||
|
||||
function leseBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let groesse = 0;
|
||||
const teile = [];
|
||||
req.on('data', (c) => {
|
||||
groesse += c.length;
|
||||
if (groesse > MAX_BODY) { reject(new Error('zu groß')); req.destroy(); return; }
|
||||
teile.push(c);
|
||||
});
|
||||
req.on('end', () => {
|
||||
if (!teile.length) { resolve({}); return; }
|
||||
try { resolve(JSON.parse(Buffer.concat(teile).toString('utf8')) || {}); } catch { reject(new Error('kein JSON')); }
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function behandle(req, res, port) {
|
||||
// Anfragen aus Webseiten (Origin) und DNS-Rebinding (fremder Host) gar nicht erst bedienen
|
||||
const host = String(req.headers.host || '').toLowerCase();
|
||||
if (req.headers.origin || (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`)) {
|
||||
return antworte(res, 403, { ok: false, message: 'Nicht erlaubt.' });
|
||||
}
|
||||
const auth = String(req.headers.authorization || '');
|
||||
if (!auth.startsWith('Bearer ') || !istSchluesselGleich(auth.slice(7))) {
|
||||
return antworte(res, 401, { ok: false, message: 'Schlüssel fehlt oder ist falsch.' });
|
||||
}
|
||||
|
||||
const url = new URL(req.url || '/', 'http://127.0.0.1');
|
||||
const pfad = url.pathname;
|
||||
if (req.method === 'GET' && pfad === '/state') {
|
||||
return antworte(res, 200, Object.assign({ ok: true }, deps.zustand({ freunde: url.searchParams.get('freunde') === '1' })));
|
||||
}
|
||||
if (req.method === 'GET' && pfad === '/servers') {
|
||||
return antworte(res, 200, await deps.serverListe(url.searchParams.get('id') || ''));
|
||||
}
|
||||
if (req.method === 'GET' && pfad === '/icon') {
|
||||
const konto = url.searchParams.get('konto');
|
||||
return antworte(res, 200, konto ? await deps.kopf(konto) : await deps.symbol(url.searchParams.get('id') || ''));
|
||||
}
|
||||
if (req.method === 'GET' && pfad === '/crash') {
|
||||
return antworte(res, 200, await deps.absturz(url.searchParams.get('id') || ''));
|
||||
}
|
||||
if (req.method === 'GET' && pfad === '/ping') {
|
||||
return antworte(res, 200, await deps.ping(url.searchParams.get('ip') || ''));
|
||||
}
|
||||
if (req.method !== 'POST') return antworte(res, 405, { ok: false, message: 'Nur GET/POST.' });
|
||||
|
||||
let body;
|
||||
try { body = await leseBody(req); } catch (err) { return antworte(res, 400, { ok: false, message: err.message }); }
|
||||
const id = typeof body.id === 'string' ? body.id : '';
|
||||
|
||||
let erg;
|
||||
if (pfad === '/launch' && id) erg = await deps.starte(id, typeof body.server === 'string' ? body.server.trim().slice(0, 253) : '');
|
||||
else if (pfad === '/restart' && id) erg = await deps.neustart(id);
|
||||
else if (pfad === '/window') erg = await deps.fenster();
|
||||
else if (pfad === '/clipmode') erg = await deps.clipModus();
|
||||
else if (pfad === '/folder') erg = await deps.ordner(id);
|
||||
else if (pfad === '/screenshot') erg = await deps.screenshot();
|
||||
else if (pfad === '/account' && id) erg = await deps.konto(id);
|
||||
else if (pfad === '/backup') erg = await deps.backup();
|
||||
else if (pfad === '/open' && typeof body.art === 'string') erg = await deps.oeffne(body.art, id);
|
||||
else if (pfad === '/stop' && id) erg = await deps.beende(id);
|
||||
else if (pfad === '/clip') erg = await deps.clipSpeichern();
|
||||
else if (pfad === '/record') erg = await deps.aufnahmeUmschalten();
|
||||
else return antworte(res, 404, { ok: false, message: 'Unbekannte Aktion.' });
|
||||
return antworte(res, erg && erg.ok ? 200 : 409, erg || { ok: false });
|
||||
}
|
||||
|
||||
function schreibeVerbindung(port) {
|
||||
try {
|
||||
fs.writeFileSync(verbindungsDatei, JSON.stringify({ port, token: schluessel }), 'utf8');
|
||||
} catch { /* ohne Datei findet das Plugin den Launcher nicht – mehr geht nicht */ }
|
||||
}
|
||||
|
||||
function loescheVerbindung() {
|
||||
try { fs.rmSync(verbindungsDatei, { force: true }); } catch { /* egal */ }
|
||||
}
|
||||
|
||||
function lausche(port, versuche) {
|
||||
const s = http.createServer((req, res) => {
|
||||
behandle(req, res, port).catch(() => { try { antworte(res, 500, { ok: false }); } catch { /* egal */ } });
|
||||
});
|
||||
s.on('error', (err) => {
|
||||
if (err.code === 'EADDRINUSE' && versuche > 1 && server === s) { lausche(port + 1, versuche - 1); return; }
|
||||
if (server === s) server = null;
|
||||
});
|
||||
server = s;
|
||||
s.listen(port, '127.0.0.1', () => {
|
||||
if (server === s) schreibeVerbindung(port);
|
||||
});
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (server || !deps) return;
|
||||
schluessel = crypto.randomBytes(32).toString('hex');
|
||||
lausche(STANDARD_PORT, PORT_VERSUCHE);
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (server) { try { server.close(); } catch { /* egal */ } server = null; }
|
||||
schluessel = null;
|
||||
if (verbindungsDatei) loescheVerbindung();
|
||||
}
|
||||
|
||||
function configure(settings) {
|
||||
if (settings && settings.streamdeckEnabled === true) start(); else stop();
|
||||
}
|
||||
|
||||
module.exports = { init, configure, shutdown: stop };
|
||||
@@ -0,0 +1,82 @@
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* streamdeckplugin.js – das AeroMC-Plugin in die Stream-Deck-Software kopieren
|
||||
* -----------------------------------------------------------------------------
|
||||
* Das Plugin (Ordner streamdeck-plugin/com.aeromc.launcher.sdPlugin) liegt im
|
||||
* Launcher mit dabei. Schaltet jemand "Stream Deck erlauben" ein – und bei jedem
|
||||
* Start, solange es an ist – legt AeroMC es in den Plugin-Ordner der Stream-Deck-
|
||||
* Software, falls es dort fehlt oder von einer anderen Fassung stammt. Nach dem
|
||||
* Kopieren muss die Stream-Deck-Software einmal neu gestartet werden, damit sie
|
||||
* das Plugin lädt.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const PLUGIN_NAME = 'com.aeromc.launcher.sdPlugin';
|
||||
|
||||
// Ordner mit dem mitgelieferten Plugin; null, wenn er in dieser Fassung fehlt
|
||||
function quelle(istPaketiert, ressourcenOrdner) {
|
||||
const kandidat = istPaketiert
|
||||
? path.join(ressourcenOrdner, 'streamdeck-plugin', PLUGIN_NAME)
|
||||
: path.join(__dirname, '..', 'streamdeck-plugin', PLUGIN_NAME);
|
||||
return fs.existsSync(path.join(kandidat, 'manifest.json')) ? kandidat : null;
|
||||
}
|
||||
|
||||
// Plugin-Ordner der Stream-Deck-Software; null, wenn sie nie installiert war
|
||||
function pluginOrdner() {
|
||||
const appdata = process.env.APPDATA;
|
||||
if (!appdata) return null;
|
||||
const ordner = path.join(appdata, 'Elgato', 'StreamDeck', 'Plugins');
|
||||
return fs.existsSync(ordner) ? ordner : null;
|
||||
}
|
||||
|
||||
// Was die Stream-Deck-Software selbst im Plugin-Ordner anlegt (Protokolle), zählt nicht mit
|
||||
const IGNORIERT = new Set(['logs']);
|
||||
|
||||
function dateien(basis, rel = '') {
|
||||
const ergebnis = [];
|
||||
for (const e of fs.readdirSync(path.join(basis, rel), { withFileTypes: true })) {
|
||||
if (!rel && IGNORIERT.has(e.name)) continue;
|
||||
const r = rel ? rel + '/' + e.name : e.name;
|
||||
if (e.isDirectory()) ergebnis.push(...dateien(basis, r));
|
||||
else ergebnis.push(r);
|
||||
}
|
||||
return ergebnis.sort();
|
||||
}
|
||||
|
||||
// Prüfsumme über Dateinamen und -inhalt - gleiche Prüfsumme = gleicher Stand
|
||||
function fingerabdruck(ordner) {
|
||||
const h = crypto.createHash('sha1');
|
||||
for (const rel of dateien(ordner)) {
|
||||
h.update(rel + '\0');
|
||||
h.update(fs.readFileSync(path.join(ordner, ...rel.split('/'))));
|
||||
}
|
||||
return h.digest('hex');
|
||||
}
|
||||
|
||||
/*
|
||||
* -> { ok: true, status: 'installiert' | 'aktualisiert' | 'aktuell', neustart }
|
||||
* { ok: false, grund: 'quelle-fehlt' | 'kein-streamdeck' | 'kopieren', message }
|
||||
*/
|
||||
function installiere(opt) {
|
||||
const o = opt || {};
|
||||
if (!o.quelle) return { ok: false, grund: 'quelle-fehlt', message: 'Das Plugin ist in dieser Fassung nicht enthalten.' };
|
||||
const plugins = o.pluginOrdner !== undefined ? o.pluginOrdner : pluginOrdner();
|
||||
if (!plugins || !fs.existsSync(plugins)) {
|
||||
return { ok: false, grund: 'kein-streamdeck', message: 'Die Stream-Deck-Software wurde nicht gefunden.' };
|
||||
}
|
||||
const ziel = path.join(plugins, PLUGIN_NAME);
|
||||
try {
|
||||
const vorhanden = fs.existsSync(ziel);
|
||||
if (vorhanden && fingerabdruck(ziel) === fingerabdruck(o.quelle)) return { ok: true, status: 'aktuell', neustart: false };
|
||||
fs.cpSync(o.quelle, ziel, { recursive: true, force: true, filter: (src) => !IGNORIERT.has(path.basename(src)) || src === o.quelle });
|
||||
return { ok: true, status: vorhanden ? 'aktualisiert' : 'installiert', neustart: true };
|
||||
} catch (err) {
|
||||
return { ok: false, grund: 'kopieren', message: 'Das Plugin konnte nicht kopiert werden (' + err.message + '). Bitte die Stream-Deck-Software beenden und es erneut versuchen.' };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { PLUGIN_NAME, quelle, pluginOrdner, installiere, fingerabdruck };
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "bin",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"ws": "8.22.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.22.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.1.tgz",
|
||||
"integrity": "sha512-oyL6OPYme/CmiFvNuGzeoxCJ4nOrZE2O1FwM9MWM1AsGri6nLXB2pg+lNA8aLeXMSav0J6eIVS1j6vUPzUtK9g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"type":"module","dependencies":{"ws":"8.22.1"}}
|
||||
@@ -0,0 +1,536 @@
|
||||
// AeroMC-Plugin für das Stream Deck.
|
||||
// Spricht direkt das Stream-Deck-Protokoll (WebSocket) und mit dem Launcher über
|
||||
// dessen lokale Schnittstelle (Einstellung "Stream Deck erlauben" in AeroMC).
|
||||
// Port und Schlüssel stehen in %APPDATA%\AeroMC\streamdeck.json.
|
||||
|
||||
import WebSocket from 'ws';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const A = (n) => 'com.aeromc.launcher.' + n;
|
||||
const AKTION_INSTANZ = A('instanz');
|
||||
const AKTION_CLIP = A('clip');
|
||||
const AKTION_AUFNAHME = A('aufnahme');
|
||||
const AKTION_SERVER = A('server');
|
||||
const AKTION_ZULETZT = A('zuletzt');
|
||||
const AKTION_FENSTER = A('fenster');
|
||||
const AKTION_CLIPMODUS = A('clipmodus');
|
||||
const AKTION_NEUSTART = A('neustart');
|
||||
const AKTION_ORDNER = A('ordner');
|
||||
const AKTION_SPIELZEIT = A('spielzeit');
|
||||
const AKTION_SERVERSTATUS = A('serverstatus');
|
||||
const AKTION_SCREENSHOT = A('screenshot');
|
||||
const AKTION_DREH = A('dreh');
|
||||
const AKTION_FREUNDE = A('freunde');
|
||||
const AKTION_KONTO = A('konto');
|
||||
const AKTION_SCHNELL = A('schnellstart');
|
||||
const AKTION_BACKUP = A('backup');
|
||||
const AKTION_LOGS = A('logs');
|
||||
const AKTION_KONSOLE = A('konsole');
|
||||
const AKTION_ABSTURZ = A('absturz');
|
||||
const CRASH_MS = 30000;
|
||||
const ABFRAGE_MS = 2000;
|
||||
const PING_MS = 30000;
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const arg = (name) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : null; };
|
||||
const port = arg('-port');
|
||||
const pluginUUID = arg('-pluginUUID');
|
||||
const registerEvent = arg('-registerEvent');
|
||||
|
||||
const tasten = new Map(); // context -> { action, settings }
|
||||
let stand = null; // letzte Antwort von /state, null = Launcher nicht erreichbar
|
||||
let meldung = null; // kurze Rückmeldung nach einem Tastendruck { context, text, bis }
|
||||
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
const sende = (obj) => { if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj)); };
|
||||
|
||||
// ---------- Launcher ----------
|
||||
|
||||
function verbindung() {
|
||||
const datei = path.join(process.env.APPDATA || '', 'AeroMC', 'streamdeck.json');
|
||||
try { return JSON.parse(fs.readFileSync(datei, 'utf8')); } catch { return null; }
|
||||
}
|
||||
|
||||
async function anfrage(methode, pfad, body) {
|
||||
const v = verbindung();
|
||||
if (!v) return null;
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${v.port}${pfad}`, {
|
||||
method: methode,
|
||||
headers: { Authorization: `Bearer ${v.token}`, 'Content-Type': 'application/json' },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
return await res.json();
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
async function holeStand() {
|
||||
// Freunde nur anfordern, wenn eine Freunde-Taste existiert (der Launcher fragt dafür den Server)
|
||||
const mitFreunden = [...tasten.values()].some((t) => t.action === AKTION_FREUNDE);
|
||||
const s = await anfrage('GET', '/state' + (mitFreunden ? '?freunde=1' : ''));
|
||||
stand = s && s.ok ? s : null;
|
||||
}
|
||||
|
||||
// ---------- Tastenbild ----------
|
||||
|
||||
const esc = (t) => String(t).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||
|
||||
function zeilen(text, breite = 9) {
|
||||
const woerter = String(text || '').split(/\s+/).filter(Boolean);
|
||||
const out = [];
|
||||
let zeile = '';
|
||||
for (const w of woerter) {
|
||||
if ((zeile + ' ' + w).trim().length <= breite) zeile = (zeile + ' ' + w).trim();
|
||||
else { if (zeile) out.push(zeile); zeile = w; }
|
||||
}
|
||||
if (zeile) out.push(zeile);
|
||||
return out.slice(0, 2).map((z) => (z.length > breite ? z.slice(0, breite - 1) + '…' : z));
|
||||
}
|
||||
|
||||
function dauer(seit) {
|
||||
const s = Math.max(0, Math.floor((Date.now() - seit) / 1000));
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
return h ? `${h}:${String(m).padStart(2, '0')} h` : `${m} min`;
|
||||
}
|
||||
|
||||
// AeroMC-Logo einmal einlesen und in jedes Tastenbild einbetten
|
||||
let logo = '';
|
||||
try {
|
||||
const datei = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'imgs', 'actions', 'key@2x.png');
|
||||
logo = 'data:image/png;base64,' + fs.readFileSync(datei).toString('base64');
|
||||
} catch { /* ohne Logo geht es auch */ }
|
||||
|
||||
function bild({ farbe, kopf, titel, fuss, symbol }) {
|
||||
const sym = symbol && symbol.bild ? symbol : (logo ? { bild: logo, pixel: false } : null);
|
||||
const t = zeilen(titel);
|
||||
const texte = t.map((z, i) => `<text x="72" y="${92 + i * 20}" font-size="18" font-weight="700" fill="#fff" text-anchor="middle">${esc(z)}</text>`).join('');
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="144" height="144" viewBox="0 0 144 144" font-family="Segoe UI, Arial, sans-serif">`
|
||||
+ `<rect width="144" height="144" rx="14" fill="#14161f"/>`
|
||||
+ (sym ? `<image href="${sym.bild}" x="48" y="4" width="48" height="48" preserveAspectRatio="xMidYMid meet"${sym.pixel ? ' style="image-rendering:pixelated"' : ''}/>` : '')
|
||||
+ `<text x="72" y="68" font-size="14" font-weight="700" fill="${farbe}" text-anchor="middle">${esc(kopf)}</text>`
|
||||
+ texte
|
||||
+ `<text x="72" y="134" font-size="14" fill="#aab" text-anchor="middle">${esc(fuss)}</text>`
|
||||
+ `<rect x="0" y="138" width="144" height="6" fill="${farbe}"/></svg>`;
|
||||
return 'data:image/svg+xml;charset=utf8,' + encodeURIComponent(svg);
|
||||
}
|
||||
|
||||
const GRAU = '#6b7280', GRUEN = '#34d399', ROT = '#f87171', GELB = '#fbbf24', BLAU = '#60a5fa';
|
||||
|
||||
const symbole = new Map(); // Instanz-ID -> { bildId, bild, pixel }
|
||||
|
||||
const koepfe = new Map(); // Konto-UUID -> { bild }
|
||||
const abstuerze = new Map(); // Instanz-ID -> { zeit(Abfrage), bericht, frisch, berichtZeit }
|
||||
|
||||
function symbolKonto(uuid) {
|
||||
const x = uuid && koepfe.get(uuid);
|
||||
return x && x.bild ? x : null;
|
||||
}
|
||||
|
||||
// Instanz, die gerade läuft, sonst die zuletzt gespielte - für Tasten ohne feste Auswahl
|
||||
function standardInstanz() {
|
||||
const l = (stand && stand.instanzen) || [];
|
||||
const lauf = l.find((x) => x.laeuft);
|
||||
return lauf ? lauf.id : (stand && stand.zuletzt) || null;
|
||||
}
|
||||
|
||||
function vor(ms) {
|
||||
const min = Math.max(0, Math.floor((Date.now() - ms) / 60000));
|
||||
if (min < 1) return 'gerade eben';
|
||||
if (min < 60) return 'vor ' + min + ' min';
|
||||
if (min < 1440) return 'vor ' + Math.floor(min / 60) + ' h';
|
||||
return 'vor ' + Math.floor(min / 1440) + ' Tg.';
|
||||
}
|
||||
|
||||
async function pollAbstuerze(erzwingen) {
|
||||
const ids = new Set();
|
||||
for (const t of tasten.values()) if (t.action === AKTION_ABSTURZ) { const id = instanzId(t); if (id) ids.add(id); }
|
||||
for (const id of ids) {
|
||||
const alt = abstuerze.get(id);
|
||||
if (!erzwingen && alt && Date.now() - alt.zeit < CRASH_MS) continue;
|
||||
const r = await anfrage('GET', '/crash?id=' + encodeURIComponent(id));
|
||||
abstuerze.set(id, { zeit: Date.now(), bericht: !!(r && r.bericht), frisch: !!(r && r.frisch), berichtZeit: r && r.zeit });
|
||||
}
|
||||
}
|
||||
|
||||
function symbolFuer(id) {
|
||||
const x = id && symbole.get(id);
|
||||
return x && x.bild ? x : null;
|
||||
}
|
||||
|
||||
// Instanz-Bilder (eigenes Bild oder Minecraft-Icon) nachladen, sobald sie gebraucht werden oder sich ändern
|
||||
async function holeSymbole() {
|
||||
if (!stand) return;
|
||||
const ids = new Set();
|
||||
for (const t of tasten.values()) { const id = instanzId(t); if (id) ids.add(id); }
|
||||
for (const t of tasten.values()) {
|
||||
const u = t.action === AKTION_KONTO && t.settings.kontoId;
|
||||
if (u && !koepfe.has(u)) {
|
||||
const r = await anfrage('GET', '/icon?konto=' + encodeURIComponent(u));
|
||||
koepfe.set(u, { bild: r && r.ok ? r.bild : null });
|
||||
}
|
||||
}
|
||||
for (const id of ids) {
|
||||
const inst = (stand.instanzen || []).find((x) => x.id === id);
|
||||
if (!inst) continue;
|
||||
const alt = symbole.get(id);
|
||||
if (alt && alt.bildId === inst.bildId) continue;
|
||||
const r = await anfrage('GET', '/icon?id=' + encodeURIComponent(id));
|
||||
symbole.set(id, { bildId: inst.bildId, bild: r && r.ok ? r.bild : null, pixel: !!(r && r.pixel) });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Drehregler (Stream Deck+) ----------
|
||||
|
||||
function dialListe() { return (stand && stand.instanzen) || []; }
|
||||
|
||||
function dialIndex(t) {
|
||||
const l = dialListe();
|
||||
let i = l.findIndex((x) => x.id === t.settings.instanceId);
|
||||
if (i < 0) i = l.findIndex((x) => x.id === (stand && stand.zuletzt));
|
||||
return i < 0 ? 0 : i;
|
||||
}
|
||||
|
||||
function zeichneDial(context) {
|
||||
const t = tasten.get(context);
|
||||
if (!t) return;
|
||||
const l = dialListe();
|
||||
let fb;
|
||||
if (meldung && meldung.context === context && meldung.bis > Date.now()) {
|
||||
fb = { title: 'HINWEIS', value: meldung.text, icon: logo || '', indicator: { value: 0 } };
|
||||
} else if (!stand) {
|
||||
fb = { title: 'AeroMC', value: 'Launcher aus', icon: logo || '', indicator: { value: 0 } };
|
||||
} else if (!l.length) {
|
||||
fb = { title: 'AeroMC', value: 'Keine Instanz', icon: logo || '', indicator: { value: 0 } };
|
||||
} else {
|
||||
const i = dialIndex(t);
|
||||
const x = l[i];
|
||||
const sym = symbolFuer(x.id);
|
||||
fb = {
|
||||
title: x.name,
|
||||
value: x.laeuft ? '● ' + dauer(x.seit || Date.now()) : '▶ bereit',
|
||||
icon: (sym && sym.bild) || logo || '',
|
||||
indicator: { value: Math.round(((i + 1) / l.length) * 100) },
|
||||
};
|
||||
}
|
||||
sende({ event: 'setFeedback', context, payload: fb });
|
||||
}
|
||||
|
||||
const pings = new Map(); // Adresse -> { ok, online, max, latencyMs, zeit }
|
||||
|
||||
function stunden(sek) {
|
||||
if (sek < 3600) return Math.floor(sek / 60) + ' min';
|
||||
const h = sek / 3600;
|
||||
return (h < 100 ? h.toFixed(1).replace('.', ',') : Math.round(h)) + ' h';
|
||||
}
|
||||
|
||||
// Welche Instanz gehört zu dieser Taste?
|
||||
function instanzId(t) {
|
||||
if (t.action === AKTION_ZULETZT) return stand && stand.zuletzt;
|
||||
if (t.action === AKTION_SCHNELL) return (stand && stand.schnellstart && stand.schnellstart[t.settings.slot || '1']) || null;
|
||||
if (t.action === AKTION_LOGS || t.action === AKTION_ABSTURZ) return t.settings.instanceId || standardInstanz();
|
||||
if (t.action === AKTION_DREH) return stand && dialListe().length ? dialListe()[dialIndex(t)].id : null;
|
||||
return t.settings.instanceId || null;
|
||||
}
|
||||
|
||||
function adresse(t) {
|
||||
return String(t.settings.adresse || t.settings.server || '').trim();
|
||||
}
|
||||
|
||||
function zeichne(context) {
|
||||
const t = tasten.get(context);
|
||||
if (!t) return;
|
||||
if (t.action === AKTION_DREH) { zeichneDial(context); return; }
|
||||
const id = instanzId(t);
|
||||
const sym = t.action === AKTION_KONTO ? symbolKonto(t.settings.kontoId) : symbolFuer(id);
|
||||
const i = stand && id ? (stand.instanzen || []).find((x) => x.id === id) : null;
|
||||
if (i) t.name = i.name; // merken, damit die Taste auch bei ausgeschaltetem Launcher ihren Namen zeigt
|
||||
const aus = !stand;
|
||||
const name = (i && i.name) || t.name || 'Instanz';
|
||||
let b;
|
||||
if (meldung && meldung.context === context && meldung.bis > Date.now()) {
|
||||
b = bild({ symbol: sym, farbe: GELB, kopf: 'HINWEIS', titel: meldung.text, fuss: '' });
|
||||
} else switch (t.action) {
|
||||
case AKTION_INSTANZ:
|
||||
case AKTION_ZULETZT: {
|
||||
const kopfLeer = t.action === AKTION_ZULETZT ? 'ZULETZT' : 'INSTANZ';
|
||||
if (t.action === AKTION_INSTANZ && !id) b = bild({ symbol: sym, farbe: GRAU, kopf: kopfLeer, titel: 'Bitte wählen', fuss: '' });
|
||||
else if (aus) b = bild({ symbol: sym, farbe: GRAU, kopf: 'AUS', titel: name, fuss: 'Launcher aus' });
|
||||
else if (!i) b = bild({ symbol: sym, farbe: GRAU, kopf: kopfLeer, titel: t.action === AKTION_ZULETZT ? 'noch nichts gespielt' : 'nicht gefunden', fuss: '' });
|
||||
else if (i.laeuft) b = bild({ symbol: sym, farbe: GRUEN, kopf: '● LÄUFT', titel: i.name, fuss: i.seit ? dauer(i.seit) : '■ beenden' });
|
||||
else b = bild({ symbol: sym, farbe: BLAU, kopf: t.action === AKTION_ZULETZT ? '▶ ZULETZT' : '▶ START', titel: i.name, fuss: '' });
|
||||
break;
|
||||
}
|
||||
case AKTION_SERVER: {
|
||||
const adr = adresse(t);
|
||||
if (!id || !adr) b = bild({ symbol: sym, farbe: GRAU, kopf: 'SERVER', titel: 'Bitte wählen', fuss: '' });
|
||||
else if (aus) b = bild({ symbol: sym, farbe: GRAU, kopf: 'AUS', titel: adr, fuss: 'Launcher aus' });
|
||||
else if (i && i.laeuft) b = bild({ symbol: sym, farbe: GRUEN, kopf: '● LÄUFT', titel: adr, fuss: name });
|
||||
else b = bild({ symbol: sym, farbe: BLAU, kopf: '▶ BEITRETEN', titel: t.settings.serverName || adr, fuss: name });
|
||||
break;
|
||||
}
|
||||
case AKTION_NEUSTART:
|
||||
if (!id) b = bild({ symbol: sym, farbe: GRAU, kopf: 'NEUSTART', titel: 'Bitte wählen', fuss: '' });
|
||||
else if (aus) b = bild({ symbol: sym, farbe: GRAU, kopf: 'NEUSTART', titel: name, fuss: 'Launcher aus' });
|
||||
else if (i && i.laeuft) b = bild({ symbol: sym, farbe: GELB, kopf: '↻ NEUSTART', titel: name, fuss: dauer(i.seit || Date.now()) });
|
||||
else b = bild({ symbol: sym, farbe: GRAU, kopf: 'NEUSTART', titel: name, fuss: 'läuft nicht' });
|
||||
break;
|
||||
case AKTION_FENSTER:
|
||||
b = bild({ symbol: sym, farbe: aus ? GRAU : BLAU, kopf: 'LAUNCHER', titel: 'Fenster', fuss: aus ? 'Launcher aus' : 'zeigen / weg' });
|
||||
break;
|
||||
case AKTION_CLIPMODUS:
|
||||
if (aus) b = bild({ symbol: sym, farbe: GRAU, kopf: 'CLIP-MODUS', titel: 'Puffer', fuss: 'Launcher aus' });
|
||||
else if (stand.clipStartet) b = bild({ symbol: sym, farbe: GELB, kopf: 'CLIP-MODUS', titel: 'Puffer', fuss: 'startet …' });
|
||||
else b = stand.clip ? bild({ symbol: sym, farbe: GRUEN, kopf: 'CLIP-MODUS', titel: 'Puffer', fuss: '● an' }) : bild({ symbol: sym, farbe: GRAU, kopf: 'CLIP-MODUS', titel: 'Puffer', fuss: 'aus' });
|
||||
break;
|
||||
case AKTION_ORDNER:
|
||||
b = bild({ symbol: sym, farbe: aus ? GRAU : BLAU, kopf: 'ORDNER', titel: 'Aufnahmen', fuss: aus ? 'Launcher aus' : (id ? name : '') });
|
||||
break;
|
||||
case AKTION_SPIELZEIT: {
|
||||
if (aus) { b = bild({ symbol: sym, farbe: GRAU, kopf: 'SPIELZEIT', titel: '–', fuss: 'Launcher aus' }); break; }
|
||||
const liste = id ? (i ? [i] : []) : stand.instanzen || [];
|
||||
const summe = liste.reduce((n, x) => n + (x.gesamt || 0), 0);
|
||||
const lauf = liste.find((x) => x.laeuft && x.seit);
|
||||
b = lauf
|
||||
? bild({ symbol: sym, farbe: GRUEN, kopf: '● SITZUNG', titel: dauer(lauf.seit), fuss: 'gesamt ' + stunden(summe + Math.floor((Date.now() - lauf.seit) / 1000)) })
|
||||
: bild({ symbol: sym, farbe: BLAU, kopf: 'SPIELZEIT', titel: stunden(summe), fuss: id ? name : 'alle Instanzen' });
|
||||
break;
|
||||
}
|
||||
case AKTION_SERVERSTATUS: {
|
||||
const adr = adresse(t);
|
||||
const p = adr && pings.get(adr);
|
||||
if (!adr) b = bild({ symbol: sym, farbe: GRAU, kopf: 'SERVER', titel: 'Bitte wählen', fuss: '' });
|
||||
else if (aus) b = bild({ symbol: sym, farbe: GRAU, kopf: 'AUS', titel: adr, fuss: 'Launcher aus' });
|
||||
else if (!p) b = bild({ symbol: sym, farbe: GRAU, kopf: 'SERVER', titel: adr, fuss: 'prüfe …' });
|
||||
else if (!p.ok) b = bild({ symbol: sym, farbe: ROT, kopf: '● OFFLINE', titel: t.settings.serverName || adr, fuss: '' });
|
||||
else b = bild({ symbol: sym, farbe: GRUEN, kopf: '● ONLINE', titel: p.online != null ? p.online + ' Spieler' : 'online', fuss: (p.max != null ? 'max ' + p.max + ' · ' : '') + p.latencyMs + ' ms' });
|
||||
break;
|
||||
}
|
||||
case AKTION_SCHNELL: {
|
||||
const slot = t.settings.slot || '1';
|
||||
if (aus) b = bild({ symbol: sym, farbe: GRAU, kopf: 'AUS', titel: name, fuss: 'Launcher aus' });
|
||||
else if (!id || !i) b = bild({ symbol: sym, farbe: GRAU, kopf: 'STRG+' + slot, titel: 'nicht belegt', fuss: '' });
|
||||
else if (i.laeuft) b = bild({ symbol: sym, farbe: GRUEN, kopf: '● LÄUFT', titel: i.name, fuss: i.seit ? dauer(i.seit) : '■ beenden' });
|
||||
else b = bild({ symbol: sym, farbe: BLAU, kopf: '▶ STRG+' + slot, titel: i.name, fuss: '' });
|
||||
break;
|
||||
}
|
||||
case AKTION_KONTO: {
|
||||
const k = stand && (stand.konten || []).find((x) => x.id === t.settings.kontoId);
|
||||
if (!t.settings.kontoId) b = bild({ symbol: sym, farbe: GRAU, kopf: 'KONTO', titel: 'Bitte wählen', fuss: '' });
|
||||
else if (aus) b = bild({ symbol: sym, farbe: GRAU, kopf: 'AUS', titel: t.name || 'Konto', fuss: 'Launcher aus' });
|
||||
else if (!k) b = bild({ symbol: sym, farbe: ROT, kopf: 'KONTO', titel: 'nicht gefunden', fuss: '' });
|
||||
else { t.name = k.name; b = bild({ symbol: sym, farbe: k.aktiv ? GRUEN : BLAU, kopf: k.aktiv ? '● AKTIV' : 'KONTO', titel: k.name, fuss: k.spielt ? 'spielt gerade' : (k.aktiv ? '' : 'wechseln') }); }
|
||||
break;
|
||||
}
|
||||
case AKTION_FREUNDE: {
|
||||
const f = stand && stand.freunde;
|
||||
if (aus) b = bild({ symbol: sym, farbe: GRAU, kopf: 'FREUNDE', titel: '–', fuss: 'Launcher aus' });
|
||||
else if (!f) b = bild({ symbol: sym, farbe: GRAU, kopf: 'FREUNDE', titel: 'Keine Daten', fuss: 'aus / lädt' });
|
||||
else b = bild({ symbol: sym, farbe: f.online ? GRUEN : GRAU, kopf: f.online ? '● ONLINE' : 'FREUNDE', titel: f.online + ' online', fuss: 'von ' + f.gesamt });
|
||||
break;
|
||||
}
|
||||
case AKTION_BACKUP: {
|
||||
const bk = stand && stand.backup;
|
||||
const frisch = bk && bk.zeit && Date.now() - bk.zeit < 600000;
|
||||
if (aus) b = bild({ symbol: sym, farbe: GRAU, kopf: 'BACKUP', titel: 'Jetzt sichern', fuss: 'Launcher aus' });
|
||||
else if (bk && bk.laeuft) b = bild({ symbol: sym, farbe: GELB, kopf: '● LÄUFT', titel: 'Backup', fuss: 'bitte warten' });
|
||||
else if (frisch && bk.ok) b = bild({ symbol: sym, farbe: GRUEN, kopf: '✔ FERTIG', titel: 'Backup', fuss: vor(bk.zeit) });
|
||||
else if (frisch) b = bild({ symbol: sym, farbe: ROT, kopf: 'FEHLER', titel: 'Backup', fuss: vor(bk.zeit) });
|
||||
else b = bild({ symbol: sym, farbe: BLAU, kopf: 'BACKUP', titel: 'Jetzt sichern', fuss: '' });
|
||||
break;
|
||||
}
|
||||
case AKTION_LOGS:
|
||||
b = bild({ symbol: sym, farbe: aus ? GRAU : BLAU, kopf: 'LOGS', titel: 'Logs öffnen', fuss: aus ? 'Launcher aus' : (i ? i.name : '') });
|
||||
break;
|
||||
case AKTION_KONSOLE:
|
||||
b = bild({ symbol: sym, farbe: aus ? GRAU : BLAU, kopf: 'KONSOLE', titel: 'Konsole', fuss: aus ? 'Launcher aus' : '' });
|
||||
break;
|
||||
case AKTION_ABSTURZ: {
|
||||
const a = id && abstuerze.get(id);
|
||||
if (aus) b = bild({ symbol: sym, farbe: GRAU, kopf: 'ABSTURZ', titel: 'Bericht', fuss: 'Launcher aus' });
|
||||
else if (!a) b = bild({ symbol: sym, farbe: GRAU, kopf: 'ABSTURZ', titel: 'Bericht', fuss: id ? 'prüfe …' : 'keine Instanz' });
|
||||
else if (!a.bericht) b = bild({ symbol: sym, farbe: GRAU, kopf: 'ABSTURZ', titel: 'keiner', fuss: i ? i.name : '' });
|
||||
else if (a.frisch) b = bild({ symbol: sym, farbe: ROT, kopf: '⚠ ABSTURZ', titel: 'Bericht zeigen', fuss: a.berichtZeit ? vor(a.berichtZeit) : '' });
|
||||
else b = bild({ symbol: sym, farbe: GRAU, kopf: 'ABSTURZ', titel: 'kein neuer', fuss: a.berichtZeit ? 'letzter ' + vor(a.berichtZeit) : '' });
|
||||
break;
|
||||
}
|
||||
case AKTION_SCREENSHOT:
|
||||
b = bild({ symbol: sym, farbe: aus ? GRAU : BLAU, kopf: 'SCREENSHOT', titel: 'Spielfenster', fuss: aus ? 'Launcher aus' : '' });
|
||||
break;
|
||||
case AKTION_CLIP:
|
||||
if (aus) b = bild({ symbol: sym, farbe: GRAU, kopf: 'CLIP', titel: 'Clip speichern', fuss: 'Launcher aus' });
|
||||
else if (stand.clipStartet) b = bild({ symbol: sym, farbe: GELB, kopf: 'CLIP', titel: 'Clip speichern', fuss: 'startet …' });
|
||||
else b = stand.clip
|
||||
? bild({ symbol: sym, farbe: GRUEN, kopf: 'CLIP', titel: 'Clip speichern', fuss: 'bereit' })
|
||||
: bild({ symbol: sym, farbe: GRAU, kopf: 'CLIP', titel: 'Clip speichern', fuss: 'Modus aus' });
|
||||
break;
|
||||
default:
|
||||
if (aus) b = bild({ symbol: sym, farbe: GRAU, kopf: 'AUFNAHME', titel: 'Aufnahme starten', fuss: 'Launcher aus' });
|
||||
else b = stand.aufnahme
|
||||
? bild({ symbol: sym, farbe: ROT, kopf: '● REC', titel: 'Aufnahme stoppen', fuss: '' })
|
||||
: bild({ symbol: sym, farbe: BLAU, kopf: 'AUFNAHME', titel: 'Aufnahme starten', fuss: '' });
|
||||
}
|
||||
sende({ event: 'setImage', context, payload: { image: b, target: 0 } });
|
||||
}
|
||||
|
||||
const zeichneAlle = () => { for (const c of tasten.keys()) zeichne(c); };
|
||||
|
||||
async function pingeFaellige(erzwingen) {
|
||||
const adressen = new Set();
|
||||
for (const t of tasten.values()) if (t.action === AKTION_SERVERSTATUS && adresse(t)) adressen.add(adresse(t));
|
||||
for (const adr of adressen) {
|
||||
const alt = pings.get(adr);
|
||||
if (!erzwingen && alt && Date.now() - alt.zeit < PING_MS) continue;
|
||||
const r = await anfrage('GET', '/ping?ip=' + encodeURIComponent(adr));
|
||||
pings.set(adr, Object.assign({ ok: false }, r || {}, { zeit: Date.now() }));
|
||||
}
|
||||
}
|
||||
|
||||
async function abfrage() {
|
||||
if (!tasten.size) return;
|
||||
await holeStand();
|
||||
if (stand) { await holeSymbole(); await pingeFaellige(false); await pollAbstuerze(false); }
|
||||
zeichneAlle();
|
||||
}
|
||||
|
||||
// ---------- Stream-Deck-Ereignisse ----------
|
||||
|
||||
function zeigeMeldung(context, text) {
|
||||
meldung = { context, text, bis: Date.now() + 2500 };
|
||||
zeichne(context);
|
||||
setTimeout(() => zeichne(context), 2600);
|
||||
}
|
||||
|
||||
const FEHLERTEXT = {
|
||||
[AKTION_CLIP]: 'Clip-Modus aus',
|
||||
[AKTION_AUFNAHME]: 'Spiel läuft nicht',
|
||||
[AKTION_CLIPMODUS]: 'Spiel läuft nicht',
|
||||
[AKTION_NEUSTART]: 'Läuft nicht',
|
||||
[AKTION_SERVER]: 'Läuft schon',
|
||||
[AKTION_SCREENSHOT]: 'Kein Spiel',
|
||||
[AKTION_ABSTURZ]: 'Kein Bericht',
|
||||
[AKTION_BACKUP]: 'Läuft schon',
|
||||
[AKTION_SCHNELL]: 'Nicht belegt',
|
||||
};
|
||||
|
||||
async function tastendruck(context) {
|
||||
const t = tasten.get(context);
|
||||
if (!t) return;
|
||||
const id = instanzId(t);
|
||||
const i = stand && id ? (stand.instanzen || []).find((x) => x.id === id) : null;
|
||||
let erg;
|
||||
switch (t.action) {
|
||||
case AKTION_INSTANZ:
|
||||
case AKTION_ZULETZT:
|
||||
case AKTION_SCHNELL:
|
||||
case AKTION_DREH:
|
||||
if (!id) { sende({ event: 'showAlert', context }); return; }
|
||||
erg = await anfrage('POST', i && i.laeuft ? '/stop' : '/launch', { id });
|
||||
break;
|
||||
case AKTION_SCREENSHOT: erg = await anfrage('POST', '/screenshot'); break;
|
||||
case AKTION_FREUNDE: erg = await anfrage('POST', '/open', { art: 'friends' }); break;
|
||||
case AKTION_KONSOLE: erg = await anfrage('POST', '/open', { art: 'console' }); break;
|
||||
case AKTION_LOGS: erg = await anfrage('POST', '/open', { art: 'logs', id: id || '' }); break;
|
||||
case AKTION_ABSTURZ: erg = await anfrage('POST', '/open', { art: 'crash', id: id || '' }); break;
|
||||
case AKTION_BACKUP: erg = await anfrage('POST', '/backup'); break;
|
||||
case AKTION_KONTO:
|
||||
if (!t.settings.kontoId) { sende({ event: 'showAlert', context }); return; }
|
||||
erg = await anfrage('POST', '/account', { id: t.settings.kontoId });
|
||||
break;
|
||||
case AKTION_SERVER:
|
||||
if (!id || !adresse(t)) { sende({ event: 'showAlert', context }); return; }
|
||||
erg = await anfrage('POST', '/launch', { id, server: adresse(t) });
|
||||
break;
|
||||
case AKTION_NEUSTART:
|
||||
if (!id) { sende({ event: 'showAlert', context }); return; }
|
||||
erg = await anfrage('POST', '/restart', { id });
|
||||
break;
|
||||
case AKTION_FENSTER: erg = await anfrage('POST', '/window'); break;
|
||||
case AKTION_CLIPMODUS: erg = await anfrage('POST', '/clipmode'); break;
|
||||
case AKTION_ORDNER: erg = await anfrage('POST', '/folder', id ? { id } : {}); break;
|
||||
case AKTION_SPIELZEIT: zeichne(context); return;
|
||||
case AKTION_SERVERSTATUS:
|
||||
pings.delete(adresse(t));
|
||||
await pingeFaellige(true);
|
||||
zeichne(context);
|
||||
return;
|
||||
case AKTION_CLIP:
|
||||
if (stand && stand.clipStartet) { zeigeMeldung(context, 'Startet noch …'); return; }
|
||||
erg = await anfrage('POST', '/clip');
|
||||
break;
|
||||
default: erg = await anfrage('POST', '/record');
|
||||
}
|
||||
if (erg && erg.ok) sende({ event: 'showOk', context });
|
||||
else {
|
||||
sende({ event: 'showAlert', context });
|
||||
zeigeMeldung(context, !erg ? 'Launcher aus' : FEHLERTEXT[t.action] || 'Nicht möglich');
|
||||
}
|
||||
setTimeout(abfrage, 600);
|
||||
}
|
||||
|
||||
ws.on('open', () => sende({ event: registerEvent, uuid: pluginUUID }));
|
||||
|
||||
ws.on('message', async (roh) => {
|
||||
let m;
|
||||
try { m = JSON.parse(roh.toString()); } catch { return; }
|
||||
switch (m.event) {
|
||||
case 'willAppear':
|
||||
tasten.set(m.context, { action: m.action, settings: (m.payload && m.payload.settings) || {} });
|
||||
await holeStand();
|
||||
await holeSymbole();
|
||||
zeichneAlle();
|
||||
break;
|
||||
case 'willDisappear':
|
||||
tasten.delete(m.context);
|
||||
break;
|
||||
case 'didReceiveSettings': {
|
||||
const t = tasten.get(m.context);
|
||||
if (t) { t.settings = (m.payload && m.payload.settings) || {}; zeichne(m.context); holeSymbole().then(() => pollAbstuerze(true)).then(() => zeichne(m.context)); }
|
||||
break;
|
||||
}
|
||||
case 'keyDown':
|
||||
case 'dialDown':
|
||||
tastendruck(m.context);
|
||||
break;
|
||||
case 'dialRotate': {
|
||||
const t = tasten.get(m.context);
|
||||
const l = dialListe();
|
||||
if (!t || !l.length) break;
|
||||
const n = l.length;
|
||||
const neu = (((dialIndex(t) + (m.payload.ticks || 0)) % n) + n) % n;
|
||||
t.settings.instanceId = l[neu].id;
|
||||
sende({ event: 'setSettings', context: m.context, payload: t.settings });
|
||||
zeichneDial(m.context);
|
||||
holeSymbole().then(() => zeichneDial(m.context));
|
||||
break;
|
||||
}
|
||||
case 'touchTap':
|
||||
// Tippen auf den Touch-Streifen: Launcher-Fenster zeigen bzw. wegminimieren
|
||||
anfrage('POST', '/window');
|
||||
break;
|
||||
case 'sendToPlugin': {
|
||||
// Eigenschaften-Fenster fragt nach der Instanzliste bzw. den Servern einer Instanz
|
||||
await holeStand();
|
||||
const payload = {
|
||||
erreichbar: !!stand,
|
||||
instanzen: stand ? stand.instanzen.map((i) => ({ id: i.id, name: i.name })) : [],
|
||||
konten: stand ? (stand.konten || []) : [],
|
||||
schnellstart: stand ? (stand.schnellstart || {}) : {},
|
||||
};
|
||||
const frage = m.payload || {};
|
||||
if (frage.frage === 'server' && frage.id && stand) {
|
||||
const r = await anfrage('GET', '/servers?id=' + encodeURIComponent(frage.id));
|
||||
payload.server = (r && r.server) || [];
|
||||
}
|
||||
sende({ event: 'sendToPropertyInspector', action: m.action, context: m.context, payload });
|
||||
break;
|
||||
}
|
||||
default:
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => process.exit(0));
|
||||
setInterval(abfrage, ABFRAGE_MS);
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 57 KiB |
@@ -0,0 +1,365 @@
|
||||
{
|
||||
"Name": "AeroMC",
|
||||
"Version": "1.1.1.0",
|
||||
"Author": "M_Viper",
|
||||
"Description": "Steuert den AeroMC-Launcher: Instanzen starten und beenden, Clips speichern und die Aufnahme umschalten - mit Live-Status auf den Tasten.",
|
||||
"Category": "AeroMC",
|
||||
"CategoryIcon": "imgs/plugin/category-icon",
|
||||
"Icon": "imgs/plugin/marketplace",
|
||||
"URL": "https://aeromc.viper.ipv64.net",
|
||||
"SDKVersion": 2,
|
||||
"UUID": "com.aeromc.launcher",
|
||||
"Nodejs": {
|
||||
"Version": "20"
|
||||
},
|
||||
"Software": {
|
||||
"MinimumVersion": "6.5"
|
||||
},
|
||||
"OS": [
|
||||
{
|
||||
"Platform": "windows",
|
||||
"MinimumVersion": "10"
|
||||
}
|
||||
],
|
||||
"CodePath": "bin/plugin.js",
|
||||
"Actions": [
|
||||
{
|
||||
"Name": "Instanz starten/beenden",
|
||||
"UUID": "com.aeromc.launcher.instanz",
|
||||
"Icon": "imgs/actions/icon",
|
||||
"Tooltip": "Startet die gewählte Instanz; läuft sie schon, beendet die Taste sie wieder. Zeigt den Zustand live an.",
|
||||
"PropertyInspectorPath": "ui/instanz.html",
|
||||
"Controllers": [
|
||||
"Keypad"
|
||||
],
|
||||
"States": [
|
||||
{
|
||||
"Image": "imgs/actions/key",
|
||||
"TitleAlignment": "bottom",
|
||||
"ShowTitle": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Clip speichern",
|
||||
"UUID": "com.aeromc.launcher.clip",
|
||||
"Icon": "imgs/actions/icon",
|
||||
"Tooltip": "Speichert die letzten Sekunden als Clip. Der Clip-Modus muss laufen (Taste „Clip-Modus ein/aus“).",
|
||||
"Controllers": [
|
||||
"Keypad"
|
||||
],
|
||||
"States": [
|
||||
{
|
||||
"Image": "imgs/actions/key",
|
||||
"TitleAlignment": "bottom",
|
||||
"ShowTitle": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Aufnahme starten/stoppen",
|
||||
"UUID": "com.aeromc.launcher.aufnahme",
|
||||
"Icon": "imgs/actions/icon",
|
||||
"Tooltip": "Startet bzw. stoppt die Aufnahme des laufenden Spiels. Das Aufnahme-Fenster öffnet sich dabei unsichtbar von selbst.",
|
||||
"Controllers": [
|
||||
"Keypad"
|
||||
],
|
||||
"States": [
|
||||
{
|
||||
"Image": "imgs/actions/key",
|
||||
"TitleAlignment": "bottom",
|
||||
"ShowTitle": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Server beitreten",
|
||||
"UUID": "com.aeromc.launcher.server",
|
||||
"Icon": "imgs/actions/icon",
|
||||
"Tooltip": "Startet die gewählte Instanz und verbindet direkt mit dem gewählten Server.",
|
||||
"PropertyInspectorPath": "ui/server.html",
|
||||
"Controllers": [
|
||||
"Keypad"
|
||||
],
|
||||
"States": [
|
||||
{
|
||||
"Image": "imgs/actions/key",
|
||||
"TitleAlignment": "bottom",
|
||||
"ShowTitle": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Zuletzt gespielte Instanz",
|
||||
"UUID": "com.aeromc.launcher.zuletzt",
|
||||
"Icon": "imgs/actions/icon",
|
||||
"Tooltip": "Startet (bzw. beendet) die Instanz, die du zuletzt gespielt hast – ohne Auswahl.",
|
||||
"Controllers": [
|
||||
"Keypad"
|
||||
],
|
||||
"States": [
|
||||
{
|
||||
"Image": "imgs/actions/key",
|
||||
"TitleAlignment": "bottom",
|
||||
"ShowTitle": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Launcher-Fenster",
|
||||
"UUID": "com.aeromc.launcher.fenster",
|
||||
"Icon": "imgs/actions/icon",
|
||||
"Tooltip": "Holt das AeroMC-Fenster nach vorn; ist es schon vorn, wird es minimiert.",
|
||||
"Controllers": [
|
||||
"Keypad"
|
||||
],
|
||||
"States": [
|
||||
{
|
||||
"Image": "imgs/actions/key",
|
||||
"TitleAlignment": "bottom",
|
||||
"ShowTitle": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Clip-Modus ein/aus",
|
||||
"UUID": "com.aeromc.launcher.clipmodus",
|
||||
"Icon": "imgs/actions/icon",
|
||||
"Tooltip": "Schaltet den Clip-Puffer des laufenden Spiels ein oder aus. Das Aufnahme-Fenster öffnet sich dabei unsichtbar von selbst.",
|
||||
"Controllers": [
|
||||
"Keypad"
|
||||
],
|
||||
"States": [
|
||||
{
|
||||
"Image": "imgs/actions/key",
|
||||
"TitleAlignment": "bottom",
|
||||
"ShowTitle": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Instanz neu starten",
|
||||
"UUID": "com.aeromc.launcher.neustart",
|
||||
"Icon": "imgs/actions/icon",
|
||||
"Tooltip": "Beendet die laufende Instanz sauber und startet sie wieder (mit demselben Server).",
|
||||
"PropertyInspectorPath": "ui/instanz.html",
|
||||
"Controllers": [
|
||||
"Keypad"
|
||||
],
|
||||
"States": [
|
||||
{
|
||||
"Image": "imgs/actions/key",
|
||||
"TitleAlignment": "bottom",
|
||||
"ShowTitle": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Aufnahmen-Ordner",
|
||||
"UUID": "com.aeromc.launcher.ordner",
|
||||
"Icon": "imgs/actions/icon",
|
||||
"Tooltip": "Öffnet den Ordner mit Aufnahmen und Clips im Explorer.",
|
||||
"PropertyInspectorPath": "ui/instanz.html",
|
||||
"Controllers": [
|
||||
"Keypad"
|
||||
],
|
||||
"States": [
|
||||
{
|
||||
"Image": "imgs/actions/key",
|
||||
"TitleAlignment": "bottom",
|
||||
"ShowTitle": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Spielzeit",
|
||||
"UUID": "com.aeromc.launcher.spielzeit",
|
||||
"Icon": "imgs/actions/icon",
|
||||
"Tooltip": "Zeigt die Gesamtspielzeit (einer Instanz oder aller) und während des Spielens die aktuelle Sitzung.",
|
||||
"PropertyInspectorPath": "ui/instanz.html",
|
||||
"Controllers": [
|
||||
"Keypad"
|
||||
],
|
||||
"States": [
|
||||
{
|
||||
"Image": "imgs/actions/key",
|
||||
"TitleAlignment": "bottom",
|
||||
"ShowTitle": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Server-Status",
|
||||
"UUID": "com.aeromc.launcher.serverstatus",
|
||||
"Icon": "imgs/actions/icon",
|
||||
"Tooltip": "Zeigt, ob ein Server online ist, wie viele Spieler drauf sind und den Ping. Aktualisiert alle 30 Sekunden.",
|
||||
"PropertyInspectorPath": "ui/server.html",
|
||||
"Controllers": [
|
||||
"Keypad"
|
||||
],
|
||||
"States": [
|
||||
{
|
||||
"Image": "imgs/actions/key",
|
||||
"TitleAlignment": "bottom",
|
||||
"ShowTitle": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Screenshot",
|
||||
"UUID": "com.aeromc.launcher.screenshot",
|
||||
"Icon": "imgs/actions/icon",
|
||||
"Tooltip": "Speichert ein Bild des Spielfensters im Screenshot-Ordner der laufenden Instanz. Überlagernde Fenster (z. B. das Stream Deck) erscheinen mit auf dem Bild.",
|
||||
"Controllers": [
|
||||
"Keypad"
|
||||
],
|
||||
"States": [
|
||||
{
|
||||
"Image": "imgs/actions/key",
|
||||
"TitleAlignment": "bottom",
|
||||
"ShowTitle": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Instanz-Drehregler",
|
||||
"UUID": "com.aeromc.launcher.dreh",
|
||||
"Icon": "imgs/actions/icon",
|
||||
"Tooltip": "Für das Stream Deck+: Drehen wählt die Instanz, Drücken startet bzw. beendet sie, Tippen auf den Streifen zeigt oder minimiert den Launcher.",
|
||||
"Controllers": [
|
||||
"Encoder"
|
||||
],
|
||||
"Encoder": {
|
||||
"layout": "$B1",
|
||||
"TriggerDescription": {
|
||||
"Rotate": "Instanz wählen",
|
||||
"Push": "Starten / Beenden",
|
||||
"Touch": "Launcher-Fenster"
|
||||
}
|
||||
},
|
||||
"States": [
|
||||
{
|
||||
"Image": "imgs/actions/key",
|
||||
"TitleAlignment": "bottom",
|
||||
"ShowTitle": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Freunde online",
|
||||
"UUID": "com.aeromc.launcher.freunde",
|
||||
"Icon": "imgs/actions/icon",
|
||||
"Tooltip": "Zeigt, wie viele deiner AeroMC-Freunde gerade online sind. Ein Druck öffnet das Freunde-Fenster.",
|
||||
"Controllers": [
|
||||
"Keypad"
|
||||
],
|
||||
"States": [
|
||||
{
|
||||
"Image": "imgs/actions/key",
|
||||
"TitleAlignment": "bottom",
|
||||
"ShowTitle": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Konto wechseln",
|
||||
"UUID": "com.aeromc.launcher.konto",
|
||||
"Icon": "imgs/actions/icon",
|
||||
"Tooltip": "Setzt das gewählte Konto als aktives Konto in AeroMC. Zeigt den Spielerkopf; grün = gerade aktiv.",
|
||||
"PropertyInspectorPath": "ui/konto.html",
|
||||
"Controllers": [
|
||||
"Keypad"
|
||||
],
|
||||
"States": [
|
||||
{
|
||||
"Image": "imgs/actions/key",
|
||||
"TitleAlignment": "bottom",
|
||||
"ShowTitle": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Schnellstart 1–5",
|
||||
"UUID": "com.aeromc.launcher.schnellstart",
|
||||
"Icon": "imgs/actions/icon",
|
||||
"Tooltip": "Startet die Instanz, die du in AeroMC auf Strg+1 bis Strg+5 gelegt hast (bzw. beendet sie, wenn sie läuft).",
|
||||
"PropertyInspectorPath": "ui/schnellstart.html",
|
||||
"Controllers": [
|
||||
"Keypad"
|
||||
],
|
||||
"States": [
|
||||
{
|
||||
"Image": "imgs/actions/key",
|
||||
"TitleAlignment": "bottom",
|
||||
"ShowTitle": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Backup jetzt",
|
||||
"UUID": "com.aeromc.launcher.backup",
|
||||
"Icon": "imgs/actions/icon",
|
||||
"Tooltip": "Sichert alle Instanzen. Zeigt „läuft“ und nach dem Ende „fertig“ bzw. „Fehler“.",
|
||||
"Controllers": [
|
||||
"Keypad"
|
||||
],
|
||||
"States": [
|
||||
{
|
||||
"Image": "imgs/actions/key",
|
||||
"TitleAlignment": "bottom",
|
||||
"ShowTitle": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Logdateien",
|
||||
"UUID": "com.aeromc.launcher.logs",
|
||||
"Icon": "imgs/actions/icon",
|
||||
"Tooltip": "Öffnet die Logdateien der gewählten (sonst der laufenden oder zuletzt gespielten) Instanz.",
|
||||
"PropertyInspectorPath": "ui/instanz.html",
|
||||
"Controllers": [
|
||||
"Keypad"
|
||||
],
|
||||
"States": [
|
||||
{
|
||||
"Image": "imgs/actions/key",
|
||||
"TitleAlignment": "bottom",
|
||||
"ShowTitle": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Konsole",
|
||||
"UUID": "com.aeromc.launcher.konsole",
|
||||
"Icon": "imgs/actions/icon",
|
||||
"Tooltip": "Öffnet die Konsole von AeroMC.",
|
||||
"Controllers": [
|
||||
"Keypad"
|
||||
],
|
||||
"States": [
|
||||
{
|
||||
"Image": "imgs/actions/key",
|
||||
"TitleAlignment": "bottom",
|
||||
"ShowTitle": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Letzter Absturz",
|
||||
"UUID": "com.aeromc.launcher.absturz",
|
||||
"Icon": "imgs/actions/icon",
|
||||
"Tooltip": "Öffnet den Absturzbericht der gewählten (sonst laufenden/zuletzt gespielten) Instanz. Leuchtet rot, wenn der letzte Start abgestürzt ist.",
|
||||
"PropertyInspectorPath": "ui/instanz.html",
|
||||
"Controllers": [
|
||||
"Keypad"
|
||||
],
|
||||
"States": [
|
||||
{
|
||||
"Image": "imgs/actions/key",
|
||||
"TitleAlignment": "bottom",
|
||||
"ShowTitle": false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>AeroMC-Instanz</title>
|
||||
<style>
|
||||
body { font-family: "Segoe UI", sans-serif; font-size: 13px; color: #d8d8d8; background: #2d2d2d; margin: 0; padding: 12px; }
|
||||
label { display: block; margin-bottom: 4px; color: #a8a8a8; }
|
||||
select { width: 100%; padding: 6px; background: #3d3d3d; color: #fff; border: 1px solid #555; border-radius: 4px; }
|
||||
.hint { margin-top: 10px; color: #8a8a8a; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<label for="instanz">Instanz</label>
|
||||
<select id="instanz"><option value="">Lade …</option></select>
|
||||
<p class="hint" id="hinweis"></p>
|
||||
<script>
|
||||
// bei diesen Tasten darf die Instanz leer bleiben (= alle Instanzen)
|
||||
const OPTIONAL = ['com.aeromc.launcher.spielzeit', 'com.aeromc.launcher.ordner', 'com.aeromc.launcher.logs', 'com.aeromc.launcher.absturz'];
|
||||
const OPTIONAL_TEXT = { 'com.aeromc.launcher.logs': 'Laufende / zuletzt gespielte', 'com.aeromc.launcher.absturz': 'Laufende / zuletzt gespielte' };
|
||||
let ws, uuid, aktion, einstellungen = {}, wiederholung = null;
|
||||
|
||||
function frageInstanzen() {
|
||||
if (ws.readyState === 1) ws.send(JSON.stringify({ event: 'sendToPlugin', action: aktion, context: uuid, payload: { frage: 'instanzen' } }));
|
||||
}
|
||||
|
||||
function fuelle(p) {
|
||||
// solange AeroMC nicht erreichbar ist, alle 2 s neu fragen - ein Neuauswählen der Taste ist dann nicht nötig
|
||||
if (p.erreichbar) { clearInterval(wiederholung); wiederholung = null; }
|
||||
else if (!wiederholung) wiederholung = setInterval(frageInstanzen, 2000);
|
||||
const sel = document.getElementById('instanz');
|
||||
const hinweis = document.getElementById('hinweis');
|
||||
sel.innerHTML = '';
|
||||
if (!p.erreichbar) {
|
||||
sel.innerHTML = '<option value="">AeroMC nicht erreichbar</option>';
|
||||
hinweis.textContent = 'AeroMC starten und unter Optionen → Allgemein „Stream Deck erlauben“ einschalten. Dieses Fenster aktualisiert sich dann selbst.';
|
||||
return;
|
||||
}
|
||||
hinweis.textContent = '';
|
||||
const leer = document.createElement('option');
|
||||
leer.value = ''; leer.textContent = OPTIONAL.includes(aktion) ? (OPTIONAL_TEXT[aktion] || 'Alle Instanzen') : '– bitte wählen –';
|
||||
sel.appendChild(leer);
|
||||
for (const i of p.instanzen) {
|
||||
const o = document.createElement('option');
|
||||
o.value = i.id; o.textContent = i.name;
|
||||
sel.appendChild(o);
|
||||
}
|
||||
sel.value = einstellungen.instanceId || '';
|
||||
}
|
||||
|
||||
function connectElgatoStreamDeckSocket(port, inUUID, registerEvent, info, actionInfo) {
|
||||
uuid = inUUID;
|
||||
const a = JSON.parse(actionInfo);
|
||||
aktion = a.action;
|
||||
einstellungen = (a.payload && a.payload.settings) || {};
|
||||
ws = new WebSocket('ws://127.0.0.1:' + port);
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify({ event: registerEvent, uuid }));
|
||||
frageInstanzen();
|
||||
};
|
||||
ws.onmessage = (e) => {
|
||||
const m = JSON.parse(e.data);
|
||||
if (m.event === 'sendToPropertyInspector') fuelle(m.payload || {});
|
||||
};
|
||||
document.getElementById('instanz').addEventListener('change', (ev) => {
|
||||
einstellungen.instanceId = ev.target.value;
|
||||
ws.send(JSON.stringify({ event: 'setSettings', context: uuid, payload: einstellungen }));
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,65 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>AeroMC-Konto</title>
|
||||
<style>
|
||||
body { font-family: "Segoe UI", sans-serif; font-size: 13px; color: #d8d8d8; background: #2d2d2d; margin: 0; padding: 12px; }
|
||||
label { display: block; margin-bottom: 4px; color: #a8a8a8; }
|
||||
select { width: 100%; padding: 6px; background: #3d3d3d; color: #fff; border: 1px solid #555; border-radius: 4px; }
|
||||
.hint { margin-top: 10px; color: #8a8a8a; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<label for="wahl">Konto</label>
|
||||
<select id="wahl"><option value="">Lade …</option></select>
|
||||
<p class="hint" id="hinweis"></p>
|
||||
<script>
|
||||
let ws, uuid, aktion, einstellungen = {}, wiederholung = null;
|
||||
|
||||
function frage() {
|
||||
if (ws.readyState === 1) ws.send(JSON.stringify({ event: 'sendToPlugin', action: aktion, context: uuid, payload: { frage: 'liste' } }));
|
||||
}
|
||||
|
||||
function fuelle(p) {
|
||||
if (p.erreichbar) { clearInterval(wiederholung); wiederholung = null; }
|
||||
else if (!wiederholung) wiederholung = setInterval(frage, 2000);
|
||||
const sel = document.getElementById('wahl');
|
||||
const hinweis = document.getElementById('hinweis');
|
||||
sel.innerHTML = '';
|
||||
if (!p.erreichbar) {
|
||||
sel.innerHTML = '<option value="">AeroMC nicht erreichbar</option>';
|
||||
hinweis.textContent = 'AeroMC starten und unter Optionen → Allgemein „Stream Deck erlauben“ einschalten. Dieses Fenster aktualisiert sich dann selbst.';
|
||||
return;
|
||||
}
|
||||
hinweis.textContent = '';
|
||||
const leer = document.createElement('option');
|
||||
leer.value = ''; leer.textContent = '– bitte wählen –';
|
||||
sel.appendChild(leer);
|
||||
for (const k of p.konten) {
|
||||
const o = document.createElement('option');
|
||||
o.value = k.id; o.textContent = k.name + (k.aktiv ? ' (aktiv)' : '');
|
||||
sel.appendChild(o);
|
||||
}
|
||||
sel.value = einstellungen.kontoId || '';
|
||||
}
|
||||
|
||||
function connectElgatoStreamDeckSocket(port, inUUID, registerEvent, info, actionInfo) {
|
||||
uuid = inUUID;
|
||||
const a = JSON.parse(actionInfo);
|
||||
aktion = a.action;
|
||||
einstellungen = (a.payload && a.payload.settings) || {};
|
||||
ws = new WebSocket('ws://127.0.0.1:' + port);
|
||||
ws.onopen = () => { ws.send(JSON.stringify({ event: registerEvent, uuid })); frage(); };
|
||||
ws.onmessage = (e) => {
|
||||
const m = JSON.parse(e.data);
|
||||
if (m.event === 'sendToPropertyInspector') fuelle(m.payload || {});
|
||||
};
|
||||
document.getElementById('wahl').addEventListener('change', (ev) => {
|
||||
einstellungen.kontoId = ev.target.value;
|
||||
ws.send(JSON.stringify({ event: 'setSettings', context: uuid, payload: einstellungen }));
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,65 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>AeroMC-Schnellstart</title>
|
||||
<style>
|
||||
body { font-family: "Segoe UI", sans-serif; font-size: 13px; color: #d8d8d8; background: #2d2d2d; margin: 0; padding: 12px; }
|
||||
label { display: block; margin-bottom: 4px; color: #a8a8a8; }
|
||||
select { width: 100%; padding: 6px; background: #3d3d3d; color: #fff; border: 1px solid #555; border-radius: 4px; }
|
||||
.hint { margin-top: 10px; color: #8a8a8a; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<label for="wahl">Schnellstart-Platz (Strg+Zahl in AeroMC)</label>
|
||||
<select id="wahl"><option value="">Lade …</option></select>
|
||||
<p class="hint" id="hinweis"></p>
|
||||
<script>
|
||||
let ws, uuid, aktion, einstellungen = {}, wiederholung = null;
|
||||
|
||||
function frage() {
|
||||
if (ws.readyState === 1) ws.send(JSON.stringify({ event: 'sendToPlugin', action: aktion, context: uuid, payload: { frage: 'liste' } }));
|
||||
}
|
||||
|
||||
function fuelle(p) {
|
||||
if (p.erreichbar) { clearInterval(wiederholung); wiederholung = null; }
|
||||
else if (!wiederholung) wiederholung = setInterval(frage, 2000);
|
||||
const sel = document.getElementById('wahl');
|
||||
const hinweis = document.getElementById('hinweis');
|
||||
sel.innerHTML = '';
|
||||
if (!p.erreichbar) {
|
||||
sel.innerHTML = '<option value="">AeroMC nicht erreichbar</option>';
|
||||
hinweis.textContent = 'AeroMC starten und unter Optionen → Allgemein „Stream Deck erlauben“ einschalten. Dieses Fenster aktualisiert sich dann selbst.';
|
||||
return;
|
||||
}
|
||||
hinweis.textContent = '';
|
||||
const namen = Object.fromEntries(p.instanzen.map((i) => [i.id, i.name]));
|
||||
for (const n of ['1', '2', '3', '4', '5']) {
|
||||
const o = document.createElement('option');
|
||||
o.value = n;
|
||||
o.textContent = 'Strg+' + n + ' – ' + (namen[p.schnellstart[n]] || 'nicht belegt');
|
||||
sel.appendChild(o);
|
||||
}
|
||||
if (!einstellungen.slot) einstellungen.slot = '1';
|
||||
sel.value = einstellungen.slot || '';
|
||||
}
|
||||
|
||||
function connectElgatoStreamDeckSocket(port, inUUID, registerEvent, info, actionInfo) {
|
||||
uuid = inUUID;
|
||||
const a = JSON.parse(actionInfo);
|
||||
aktion = a.action;
|
||||
einstellungen = (a.payload && a.payload.settings) || {};
|
||||
ws = new WebSocket('ws://127.0.0.1:' + port);
|
||||
ws.onopen = () => { ws.send(JSON.stringify({ event: registerEvent, uuid })); frage(); };
|
||||
ws.onmessage = (e) => {
|
||||
const m = JSON.parse(e.data);
|
||||
if (m.event === 'sendToPropertyInspector') fuelle(m.payload || {});
|
||||
};
|
||||
document.getElementById('wahl').addEventListener('change', (ev) => {
|
||||
einstellungen.slot = ev.target.value;
|
||||
ws.send(JSON.stringify({ event: 'setSettings', context: uuid, payload: einstellungen }));
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,110 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>AeroMC-Server</title>
|
||||
<style>
|
||||
body { font-family: "Segoe UI", sans-serif; font-size: 13px; color: #d8d8d8; background: #2d2d2d; margin: 0; padding: 12px; }
|
||||
label { display: block; margin-bottom: 4px; color: #a8a8a8; }
|
||||
select { width: 100%; padding: 6px; background: #3d3d3d; color: #fff; border: 1px solid #555; border-radius: 4px; }
|
||||
input { width: 100%; box-sizing: border-box; padding: 6px; background: #3d3d3d; color: #fff; border: 1px solid #555; border-radius: 4px; }
|
||||
.hint { margin-top: 10px; color: #8a8a8a; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<label for="instanz">Instanz</label>
|
||||
<select id="instanz"><option value="">Lade …</option></select>
|
||||
<label for="server" style="margin-top:12px">Server dieser Instanz</label>
|
||||
<select id="server"><option value="">– Instanz wählen –</option></select>
|
||||
<label for="adresse" style="margin-top:12px">oder Adresse eingeben</label>
|
||||
<input id="adresse" type="text" placeholder="z. B. mc.example.net:25565" />
|
||||
<p class="hint" id="hinweis"></p>
|
||||
<script>
|
||||
// bei diesen Tasten darf die Instanz leer bleiben (= alle Instanzen)
|
||||
const OPTIONAL = [];
|
||||
let ws, uuid, aktion, einstellungen = {}, wiederholung = null;
|
||||
|
||||
function frageInstanzen() {
|
||||
if (ws.readyState !== 1) return;
|
||||
const id = einstellungen.instanceId;
|
||||
ws.send(JSON.stringify({ event: 'sendToPlugin', action: aktion, context: uuid, payload: id ? { frage: 'server', id } : { frage: 'instanzen' } }));
|
||||
}
|
||||
|
||||
function speichere() {
|
||||
ws.send(JSON.stringify({ event: 'setSettings', context: uuid, payload: einstellungen }));
|
||||
}
|
||||
|
||||
function fuelleServer(liste) {
|
||||
const sel = document.getElementById('server');
|
||||
sel.innerHTML = '';
|
||||
const leer = document.createElement('option');
|
||||
leer.value = ''; leer.textContent = liste.length ? '– bitte wählen –' : 'Keine Server in dieser Instanz';
|
||||
sel.appendChild(leer);
|
||||
for (const x of liste) {
|
||||
const o = document.createElement('option');
|
||||
o.value = x.ip; o.textContent = (x.name ? x.name + ' – ' : '') + x.ip; o.dataset.name = x.name || '';
|
||||
sel.appendChild(o);
|
||||
}
|
||||
sel.value = liste.some((x) => x.ip === einstellungen.server) ? einstellungen.server : '';
|
||||
}
|
||||
|
||||
function fuelle(p) {
|
||||
// solange AeroMC nicht erreichbar ist, alle 2 s neu fragen - ein Neuauswählen der Taste ist dann nicht nötig
|
||||
if (p.erreichbar) { clearInterval(wiederholung); wiederholung = null; }
|
||||
else if (!wiederholung) wiederholung = setInterval(frageInstanzen, 2000);
|
||||
const sel = document.getElementById('instanz');
|
||||
const hinweis = document.getElementById('hinweis');
|
||||
sel.innerHTML = '';
|
||||
if (!p.erreichbar) {
|
||||
sel.innerHTML = '<option value="">AeroMC nicht erreichbar</option>';
|
||||
hinweis.textContent = 'AeroMC starten und unter Optionen → Allgemein „Stream Deck erlauben“ einschalten. Dieses Fenster aktualisiert sich dann selbst.';
|
||||
return;
|
||||
}
|
||||
hinweis.textContent = '';
|
||||
const leer = document.createElement('option');
|
||||
leer.value = ''; leer.textContent = OPTIONAL.includes(aktion) ? 'Alle Instanzen' : '– bitte wählen –';
|
||||
sel.appendChild(leer);
|
||||
for (const i of p.instanzen) {
|
||||
const o = document.createElement('option');
|
||||
o.value = i.id; o.textContent = i.name;
|
||||
sel.appendChild(o);
|
||||
}
|
||||
sel.value = einstellungen.instanceId || '';
|
||||
if (p.server) fuelleServer(p.server);
|
||||
}
|
||||
|
||||
function connectElgatoStreamDeckSocket(port, inUUID, registerEvent, info, actionInfo) {
|
||||
uuid = inUUID;
|
||||
const a = JSON.parse(actionInfo);
|
||||
aktion = a.action;
|
||||
einstellungen = (a.payload && a.payload.settings) || {};
|
||||
ws = new WebSocket('ws://127.0.0.1:' + port);
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify({ event: registerEvent, uuid }));
|
||||
frageInstanzen();
|
||||
};
|
||||
ws.onmessage = (e) => {
|
||||
const m = JSON.parse(e.data);
|
||||
if (m.event === 'sendToPropertyInspector') fuelle(m.payload || {});
|
||||
};
|
||||
document.getElementById('adresse').value = einstellungen.adresse || '';
|
||||
document.getElementById('instanz').addEventListener('change', (ev) => {
|
||||
einstellungen.instanceId = ev.target.value;
|
||||
einstellungen.server = ''; einstellungen.serverName = '';
|
||||
speichere();
|
||||
fuelleServer([]);
|
||||
frageInstanzen();
|
||||
});
|
||||
document.getElementById('server').addEventListener('change', (ev) => {
|
||||
einstellungen.server = ev.target.value;
|
||||
einstellungen.serverName = ev.target.selectedOptions[0] ? ev.target.selectedOptions[0].dataset.name || '' : '';
|
||||
speichere();
|
||||
});
|
||||
document.getElementById('adresse').addEventListener('input', (ev) => {
|
||||
einstellungen.adresse = ev.target.value.trim();
|
||||
speichere();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||