Files
AeroMc-Launcher/src/streamdeck.js
T
2026-10-30 05:54:28 +00:00

190 lines
7.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 };