Upload via GUI (324 Dateien)
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* streamdeck.js – lokale Schnittstelle für das AeroMC-Plugin des Elgato Stream Deck
|
||||
* -----------------------------------------------------------------------------
|
||||
* Ein kleiner HTTP-Server, der AUSSCHLIESSLICH auf 127.0.0.1 lauscht und nur auf
|
||||
* Wunsch läuft (Einstellung "streamdeckEnabled", standardmäßig aus).
|
||||
*
|
||||
* Absicherung gegen fremde Zugriffe:
|
||||
* - nur Loopback, kein Zugriff aus dem Netzwerk
|
||||
* - jede Anfrage braucht den Zufalls-Schlüssel im Kopf "Authorization: Bearer …";
|
||||
* der wird bei jedem Start neu erzeugt und nur in eine Datei im
|
||||
* Benutzerordner geschrieben, die das Plugin liest (streamdeck.json)
|
||||
* - Anfragen mit "Origin"-Kopf (also aus einer Webseite im Browser) und mit
|
||||
* fremdem "Host"-Kopf (DNS-Rebinding) werden abgewiesen
|
||||
*
|
||||
* Die eigentlichen Aktionen kommen von main.js (siehe init) – dieses Modul kennt
|
||||
* weder Instanzen noch Aufnahmen und reicht nur weiter.
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const STANDARD_PORT = 48615;
|
||||
const PORT_VERSUCHE = 10;
|
||||
const MAX_BODY = 4096;
|
||||
|
||||
let deps = null;
|
||||
let server = null;
|
||||
let schluessel = null;
|
||||
let verbindungsDatei = null;
|
||||
|
||||
/*
|
||||
* deps: {
|
||||
* userDataDir,
|
||||
* zustand() -> { instanzen: [{ id, name, laeuft, seit }], aufnahme, clip }
|
||||
* starte(id, server) -> { ok, message? } (server optional: direkt verbinden)
|
||||
* neustart(id) -> { ok, message? }
|
||||
* fenster() -> { ok } Launcher-Fenster zeigen bzw. wegminimieren
|
||||
* clipModus() -> { ok, message? }
|
||||
* ordner(id) -> { ok } Aufnahmen-Ordner öffnen
|
||||
* serverListe(id) -> { ok, server: [{ name, ip }] }
|
||||
* ping(ip) -> { ok, online, max, latencyMs }
|
||||
* screenshot() -> { ok, message? } Bild des Spielfensters in den Screenshot-Ordner
|
||||
* symbol(id) -> { ok, bild, pixel } Instanz-Bild als data:-URI
|
||||
* kopf(uuid) -> { ok, bild } Spielerkopf eines Kontos als data:-URI
|
||||
* absturz(id) -> { ok, bericht, zeit, frisch } neuester Absturzbericht der Instanz
|
||||
* konto(id) -> { ok } aktives Konto wechseln
|
||||
* backup() -> { ok, message? } Sicherung aller Instanzen starten
|
||||
* oeffne(art, id) -> { ok, message? } Fenster öffnen: friends | logs | console | crash
|
||||
* beende(id) -> { ok, message? }
|
||||
* clipSpeichern() -> { ok, message? }
|
||||
* aufnahmeUmschalten() -> { ok, message? }
|
||||
* }
|
||||
*/
|
||||
function init(d) {
|
||||
deps = d;
|
||||
verbindungsDatei = path.join(d.userDataDir, 'streamdeck.json');
|
||||
}
|
||||
|
||||
function istSchluesselGleich(angabe) {
|
||||
if (!schluessel || typeof angabe !== 'string') return false;
|
||||
const a = Buffer.from(angabe);
|
||||
const b = Buffer.from(schluessel);
|
||||
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
function antworte(res, status, daten) {
|
||||
const text = JSON.stringify(daten);
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Content-Length': Buffer.byteLength(text),
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(text);
|
||||
}
|
||||
|
||||
function leseBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let groesse = 0;
|
||||
const teile = [];
|
||||
req.on('data', (c) => {
|
||||
groesse += c.length;
|
||||
if (groesse > MAX_BODY) { reject(new Error('zu groß')); req.destroy(); return; }
|
||||
teile.push(c);
|
||||
});
|
||||
req.on('end', () => {
|
||||
if (!teile.length) { resolve({}); return; }
|
||||
try { resolve(JSON.parse(Buffer.concat(teile).toString('utf8')) || {}); } catch { reject(new Error('kein JSON')); }
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function behandle(req, res, port) {
|
||||
// Anfragen aus Webseiten (Origin) und DNS-Rebinding (fremder Host) gar nicht erst bedienen
|
||||
const host = String(req.headers.host || '').toLowerCase();
|
||||
if (req.headers.origin || (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`)) {
|
||||
return antworte(res, 403, { ok: false, message: 'Nicht erlaubt.' });
|
||||
}
|
||||
const auth = String(req.headers.authorization || '');
|
||||
if (!auth.startsWith('Bearer ') || !istSchluesselGleich(auth.slice(7))) {
|
||||
return antworte(res, 401, { ok: false, message: 'Schlüssel fehlt oder ist falsch.' });
|
||||
}
|
||||
|
||||
const url = new URL(req.url || '/', 'http://127.0.0.1');
|
||||
const pfad = url.pathname;
|
||||
if (req.method === 'GET' && pfad === '/state') {
|
||||
return antworte(res, 200, Object.assign({ ok: true }, deps.zustand({ freunde: url.searchParams.get('freunde') === '1' })));
|
||||
}
|
||||
if (req.method === 'GET' && pfad === '/servers') {
|
||||
return antworte(res, 200, await deps.serverListe(url.searchParams.get('id') || ''));
|
||||
}
|
||||
if (req.method === 'GET' && pfad === '/icon') {
|
||||
const konto = url.searchParams.get('konto');
|
||||
return antworte(res, 200, konto ? await deps.kopf(konto) : await deps.symbol(url.searchParams.get('id') || ''));
|
||||
}
|
||||
if (req.method === 'GET' && pfad === '/crash') {
|
||||
return antworte(res, 200, await deps.absturz(url.searchParams.get('id') || ''));
|
||||
}
|
||||
if (req.method === 'GET' && pfad === '/ping') {
|
||||
return antworte(res, 200, await deps.ping(url.searchParams.get('ip') || ''));
|
||||
}
|
||||
if (req.method !== 'POST') return antworte(res, 405, { ok: false, message: 'Nur GET/POST.' });
|
||||
|
||||
let body;
|
||||
try { body = await leseBody(req); } catch (err) { return antworte(res, 400, { ok: false, message: err.message }); }
|
||||
const id = typeof body.id === 'string' ? body.id : '';
|
||||
|
||||
let erg;
|
||||
if (pfad === '/launch' && id) erg = await deps.starte(id, typeof body.server === 'string' ? body.server.trim().slice(0, 253) : '');
|
||||
else if (pfad === '/restart' && id) erg = await deps.neustart(id);
|
||||
else if (pfad === '/window') erg = await deps.fenster();
|
||||
else if (pfad === '/clipmode') erg = await deps.clipModus();
|
||||
else if (pfad === '/folder') erg = await deps.ordner(id);
|
||||
else if (pfad === '/screenshot') erg = await deps.screenshot();
|
||||
else if (pfad === '/account' && id) erg = await deps.konto(id);
|
||||
else if (pfad === '/backup') erg = await deps.backup();
|
||||
else if (pfad === '/open' && typeof body.art === 'string') erg = await deps.oeffne(body.art, id);
|
||||
else if (pfad === '/stop' && id) erg = await deps.beende(id);
|
||||
else if (pfad === '/clip') erg = await deps.clipSpeichern();
|
||||
else if (pfad === '/record') erg = await deps.aufnahmeUmschalten();
|
||||
else return antworte(res, 404, { ok: false, message: 'Unbekannte Aktion.' });
|
||||
return antworte(res, erg && erg.ok ? 200 : 409, erg || { ok: false });
|
||||
}
|
||||
|
||||
function schreibeVerbindung(port) {
|
||||
try {
|
||||
fs.writeFileSync(verbindungsDatei, JSON.stringify({ port, token: schluessel }), 'utf8');
|
||||
} catch { /* ohne Datei findet das Plugin den Launcher nicht – mehr geht nicht */ }
|
||||
}
|
||||
|
||||
function loescheVerbindung() {
|
||||
try { fs.rmSync(verbindungsDatei, { force: true }); } catch { /* egal */ }
|
||||
}
|
||||
|
||||
function lausche(port, versuche) {
|
||||
const s = http.createServer((req, res) => {
|
||||
behandle(req, res, port).catch(() => { try { antworte(res, 500, { ok: false }); } catch { /* egal */ } });
|
||||
});
|
||||
s.on('error', (err) => {
|
||||
if (err.code === 'EADDRINUSE' && versuche > 1 && server === s) { lausche(port + 1, versuche - 1); return; }
|
||||
if (server === s) server = null;
|
||||
});
|
||||
server = s;
|
||||
s.listen(port, '127.0.0.1', () => {
|
||||
if (server === s) schreibeVerbindung(port);
|
||||
});
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (server || !deps) return;
|
||||
schluessel = crypto.randomBytes(32).toString('hex');
|
||||
lausche(STANDARD_PORT, PORT_VERSUCHE);
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (server) { try { server.close(); } catch { /* egal */ } server = null; }
|
||||
schluessel = null;
|
||||
if (verbindungsDatei) loescheVerbindung();
|
||||
}
|
||||
|
||||
function configure(settings) {
|
||||
if (settings && settings.streamdeckEnabled === true) start(); else stop();
|
||||
}
|
||||
|
||||
module.exports = { init, configure, shutdown: stop };
|
||||
@@ -0,0 +1,82 @@
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* streamdeckplugin.js – das AeroMC-Plugin in die Stream-Deck-Software kopieren
|
||||
* -----------------------------------------------------------------------------
|
||||
* Das Plugin (Ordner streamdeck-plugin/com.aeromc.launcher.sdPlugin) liegt im
|
||||
* Launcher mit dabei. Schaltet jemand "Stream Deck erlauben" ein – und bei jedem
|
||||
* Start, solange es an ist – legt AeroMC es in den Plugin-Ordner der Stream-Deck-
|
||||
* Software, falls es dort fehlt oder von einer anderen Fassung stammt. Nach dem
|
||||
* Kopieren muss die Stream-Deck-Software einmal neu gestartet werden, damit sie
|
||||
* das Plugin lädt.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const PLUGIN_NAME = 'com.aeromc.launcher.sdPlugin';
|
||||
|
||||
// Ordner mit dem mitgelieferten Plugin; null, wenn er in dieser Fassung fehlt
|
||||
function quelle(istPaketiert, ressourcenOrdner) {
|
||||
const kandidat = istPaketiert
|
||||
? path.join(ressourcenOrdner, 'streamdeck-plugin', PLUGIN_NAME)
|
||||
: path.join(__dirname, '..', 'streamdeck-plugin', PLUGIN_NAME);
|
||||
return fs.existsSync(path.join(kandidat, 'manifest.json')) ? kandidat : null;
|
||||
}
|
||||
|
||||
// Plugin-Ordner der Stream-Deck-Software; null, wenn sie nie installiert war
|
||||
function pluginOrdner() {
|
||||
const appdata = process.env.APPDATA;
|
||||
if (!appdata) return null;
|
||||
const ordner = path.join(appdata, 'Elgato', 'StreamDeck', 'Plugins');
|
||||
return fs.existsSync(ordner) ? ordner : null;
|
||||
}
|
||||
|
||||
// Was die Stream-Deck-Software selbst im Plugin-Ordner anlegt (Protokolle), zählt nicht mit
|
||||
const IGNORIERT = new Set(['logs']);
|
||||
|
||||
function dateien(basis, rel = '') {
|
||||
const ergebnis = [];
|
||||
for (const e of fs.readdirSync(path.join(basis, rel), { withFileTypes: true })) {
|
||||
if (!rel && IGNORIERT.has(e.name)) continue;
|
||||
const r = rel ? rel + '/' + e.name : e.name;
|
||||
if (e.isDirectory()) ergebnis.push(...dateien(basis, r));
|
||||
else ergebnis.push(r);
|
||||
}
|
||||
return ergebnis.sort();
|
||||
}
|
||||
|
||||
// Prüfsumme über Dateinamen und -inhalt - gleiche Prüfsumme = gleicher Stand
|
||||
function fingerabdruck(ordner) {
|
||||
const h = crypto.createHash('sha1');
|
||||
for (const rel of dateien(ordner)) {
|
||||
h.update(rel + '\0');
|
||||
h.update(fs.readFileSync(path.join(ordner, ...rel.split('/'))));
|
||||
}
|
||||
return h.digest('hex');
|
||||
}
|
||||
|
||||
/*
|
||||
* -> { ok: true, status: 'installiert' | 'aktualisiert' | 'aktuell', neustart }
|
||||
* { ok: false, grund: 'quelle-fehlt' | 'kein-streamdeck' | 'kopieren', message }
|
||||
*/
|
||||
function installiere(opt) {
|
||||
const o = opt || {};
|
||||
if (!o.quelle) return { ok: false, grund: 'quelle-fehlt', message: 'Das Plugin ist in dieser Fassung nicht enthalten.' };
|
||||
const plugins = o.pluginOrdner !== undefined ? o.pluginOrdner : pluginOrdner();
|
||||
if (!plugins || !fs.existsSync(plugins)) {
|
||||
return { ok: false, grund: 'kein-streamdeck', message: 'Die Stream-Deck-Software wurde nicht gefunden.' };
|
||||
}
|
||||
const ziel = path.join(plugins, PLUGIN_NAME);
|
||||
try {
|
||||
const vorhanden = fs.existsSync(ziel);
|
||||
if (vorhanden && fingerabdruck(ziel) === fingerabdruck(o.quelle)) return { ok: true, status: 'aktuell', neustart: false };
|
||||
fs.cpSync(o.quelle, ziel, { recursive: true, force: true, filter: (src) => !IGNORIERT.has(path.basename(src)) || src === o.quelle });
|
||||
return { ok: true, status: vorhanden ? 'aktualisiert' : 'installiert', neustart: true };
|
||||
} catch (err) {
|
||||
return { ok: false, grund: 'kopieren', message: 'Das Plugin konnte nicht kopiert werden (' + err.message + '). Bitte die Stream-Deck-Software beenden und es erneut versuchen.' };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { PLUGIN_NAME, quelle, pluginOrdner, installiere, fingerabdruck };
|
||||
Reference in New Issue
Block a user