diff --git a/src/streamdeck.js b/src/streamdeck.js
new file mode 100644
index 0000000..35da599
--- /dev/null
+++ b/src/streamdeck.js
@@ -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 };
diff --git a/src/streamdeckplugin.js b/src/streamdeckplugin.js
new file mode 100644
index 0000000..19202c1
--- /dev/null
+++ b/src/streamdeckplugin.js
@@ -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 };
diff --git a/streamdeck-plugin/com.aeromc.launcher.sdPlugin/bin/package-lock.json b/streamdeck-plugin/com.aeromc.launcher.sdPlugin/bin/package-lock.json
new file mode 100644
index 0000000..689ecd8
--- /dev/null
+++ b/streamdeck-plugin/com.aeromc.launcher.sdPlugin/bin/package-lock.json
@@ -0,0 +1,33 @@
+{
+ "name": "bin",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "dependencies": {
+ "ws": "8.18.0"
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
+ "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
+ "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
+ }
+ }
+ }
+ }
+}
diff --git a/streamdeck-plugin/com.aeromc.launcher.sdPlugin/bin/package.json b/streamdeck-plugin/com.aeromc.launcher.sdPlugin/bin/package.json
new file mode 100644
index 0000000..c3aff41
--- /dev/null
+++ b/streamdeck-plugin/com.aeromc.launcher.sdPlugin/bin/package.json
@@ -0,0 +1 @@
+{ "type": "module", "dependencies": { "ws": "8.18.0" } }
diff --git a/streamdeck-plugin/com.aeromc.launcher.sdPlugin/bin/plugin.js b/streamdeck-plugin/com.aeromc.launcher.sdPlugin/bin/plugin.js
new file mode 100644
index 0000000..d73f5c8
--- /dev/null
+++ b/streamdeck-plugin/com.aeromc.launcher.sdPlugin/bin/plugin.js
@@ -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) => `${esc(z)}`).join('');
+ const 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);
diff --git a/streamdeck-plugin/com.aeromc.launcher.sdPlugin/imgs/actions/icon.png b/streamdeck-plugin/com.aeromc.launcher.sdPlugin/imgs/actions/icon.png
new file mode 100644
index 0000000..a440993
Binary files /dev/null and b/streamdeck-plugin/com.aeromc.launcher.sdPlugin/imgs/actions/icon.png differ
diff --git a/streamdeck-plugin/com.aeromc.launcher.sdPlugin/imgs/actions/icon@2x.png b/streamdeck-plugin/com.aeromc.launcher.sdPlugin/imgs/actions/icon@2x.png
new file mode 100644
index 0000000..73cc625
Binary files /dev/null and b/streamdeck-plugin/com.aeromc.launcher.sdPlugin/imgs/actions/icon@2x.png differ
diff --git a/streamdeck-plugin/com.aeromc.launcher.sdPlugin/imgs/actions/key.png b/streamdeck-plugin/com.aeromc.launcher.sdPlugin/imgs/actions/key.png
new file mode 100644
index 0000000..f0a50f9
Binary files /dev/null and b/streamdeck-plugin/com.aeromc.launcher.sdPlugin/imgs/actions/key.png differ
diff --git a/streamdeck-plugin/com.aeromc.launcher.sdPlugin/imgs/actions/key@2x.png b/streamdeck-plugin/com.aeromc.launcher.sdPlugin/imgs/actions/key@2x.png
new file mode 100644
index 0000000..672aa89
Binary files /dev/null and b/streamdeck-plugin/com.aeromc.launcher.sdPlugin/imgs/actions/key@2x.png differ
diff --git a/streamdeck-plugin/com.aeromc.launcher.sdPlugin/imgs/plugin/category-icon.png b/streamdeck-plugin/com.aeromc.launcher.sdPlugin/imgs/plugin/category-icon.png
new file mode 100644
index 0000000..3db7df7
Binary files /dev/null and b/streamdeck-plugin/com.aeromc.launcher.sdPlugin/imgs/plugin/category-icon.png differ
diff --git a/streamdeck-plugin/com.aeromc.launcher.sdPlugin/imgs/plugin/category-icon@2x.png b/streamdeck-plugin/com.aeromc.launcher.sdPlugin/imgs/plugin/category-icon@2x.png
new file mode 100644
index 0000000..1407009
Binary files /dev/null and b/streamdeck-plugin/com.aeromc.launcher.sdPlugin/imgs/plugin/category-icon@2x.png differ
diff --git a/streamdeck-plugin/com.aeromc.launcher.sdPlugin/imgs/plugin/marketplace.png b/streamdeck-plugin/com.aeromc.launcher.sdPlugin/imgs/plugin/marketplace.png
new file mode 100644
index 0000000..3d0d5d6
Binary files /dev/null and b/streamdeck-plugin/com.aeromc.launcher.sdPlugin/imgs/plugin/marketplace.png differ
diff --git a/streamdeck-plugin/com.aeromc.launcher.sdPlugin/imgs/plugin/marketplace@2x.png b/streamdeck-plugin/com.aeromc.launcher.sdPlugin/imgs/plugin/marketplace@2x.png
new file mode 100644
index 0000000..d455db1
Binary files /dev/null and b/streamdeck-plugin/com.aeromc.launcher.sdPlugin/imgs/plugin/marketplace@2x.png differ
diff --git a/streamdeck-plugin/com.aeromc.launcher.sdPlugin/manifest.json b/streamdeck-plugin/com.aeromc.launcher.sdPlugin/manifest.json
new file mode 100644
index 0000000..e8fd17b
--- /dev/null
+++ b/streamdeck-plugin/com.aeromc.launcher.sdPlugin/manifest.json
@@ -0,0 +1,365 @@
+{
+ "Name": "AeroMC",
+ "Version": "1.1.0.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
+ }
+ ]
+ }
+ ]
+}
diff --git a/streamdeck-plugin/com.aeromc.launcher.sdPlugin/ui/instanz.html b/streamdeck-plugin/com.aeromc.launcher.sdPlugin/ui/instanz.html
new file mode 100644
index 0000000..bfbdf1c
--- /dev/null
+++ b/streamdeck-plugin/com.aeromc.launcher.sdPlugin/ui/instanz.html
@@ -0,0 +1,72 @@
+
+
+
+
+AeroMC-Instanz
+
+
+
+
+
+
+
+
+
diff --git a/streamdeck-plugin/com.aeromc.launcher.sdPlugin/ui/konto.html b/streamdeck-plugin/com.aeromc.launcher.sdPlugin/ui/konto.html
new file mode 100644
index 0000000..c379cb4
--- /dev/null
+++ b/streamdeck-plugin/com.aeromc.launcher.sdPlugin/ui/konto.html
@@ -0,0 +1,65 @@
+
+
+
+
+AeroMC-Konto
+
+
+
+
+
+
+
+
+
diff --git a/streamdeck-plugin/com.aeromc.launcher.sdPlugin/ui/schnellstart.html b/streamdeck-plugin/com.aeromc.launcher.sdPlugin/ui/schnellstart.html
new file mode 100644
index 0000000..4ac04dd
--- /dev/null
+++ b/streamdeck-plugin/com.aeromc.launcher.sdPlugin/ui/schnellstart.html
@@ -0,0 +1,65 @@
+
+
+
+
+AeroMC-Schnellstart
+
+
+
+
+
+
+
+
+
diff --git a/streamdeck-plugin/com.aeromc.launcher.sdPlugin/ui/server.html b/streamdeck-plugin/com.aeromc.launcher.sdPlugin/ui/server.html
new file mode 100644
index 0000000..b13b51a
--- /dev/null
+++ b/streamdeck-plugin/com.aeromc.launcher.sdPlugin/ui/server.html
@@ -0,0 +1,110 @@
+
+
+
+
+AeroMC-Server
+
+
+
+
+
+
+
+
+
+
+
+
+