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 = `` + + `` + + (sym ? `` : '') + + `${esc(kopf)}` + + texte + + `${esc(fuss)}` + + ``; + 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 + + + + + + + + + +

+ + +