#!/usr/bin/env node "use strict"; /** * Testserver für das Add-on "Swyx Tab Bridge" – simuliert SwyxTray. * Ohne externe Abhängigkeiten (RFC 6455, nur Text-Frames). * * node test-server/server.js [port] [--log |--no-log] # Default 17655 * * Verbindungen werden mit Zeitstempel protokolliert – auf der Konsole und in * test-server/swyx-tray.log. Das ist zugleich die Vorlage dafuer, was SwyxTray * mitschreiben sollte: Aufbau, Anmeldung (hello), Abbau samt Dauer. * * Im Terminal können Tab-Nachrichten an das Add-on geschickt werden: * * list [current] -> {"type":"tab","action":"list"} * open [background] -> {"type":"tab","action":"open","url":"…"} * close -> {"type":"tab","action":"close","tabId":42} * activate -> {"type":"tab","action":"activate","tabId":42} * focus [titel…] -> {"type":"tab","action":"focus","url":"…"} * raw {"type":"tab","action":"list"} -> beliebiges JSON (Nachrichten ohne * type:"tab" ignoriert das Add-on) * quit */ const http = require("http"); const crypto = require("crypto"); const readline = require("readline"); const fs = require("fs"); const path = require("path"); const GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; /** Wie lange nach dem Verbindungsaufbau auf die hello-Nachricht gewartet wird. */ const HELLO_GRACE_MS = 3000; const { port: PORT, logFile: LOG_FILE } = parseArgs(process.argv.slice(2)); let client = null; // aktuell verbundener Socket let clientInfo = null; // { since, remote, identity } let nextId = 1; function parseArgs(argv) { let port = 17655; let logFile = path.join(__dirname, "swyx-tray.log"); for (let i = 0; i < argv.length; i++) { if (argv[i] === "--log") logFile = argv[++i]; else if (argv[i] === "--no-log") logFile = null; else if (/^\d+$/.test(argv[i])) port = Number(argv[i]); } return { port, logFile }; } /* --------------------------------------------------- HTTP + Upgrade */ const server = http.createServer((req, res) => { res.writeHead(426, { "Content-Type": "text/plain" }); res.end("WebSocket only\n"); }); server.on("upgrade", (req, socket) => { const key = req.headers["sec-websocket-key"]; if (req.headers.upgrade?.toLowerCase() !== "websocket" || !key) { socket.destroy(); return; } const accept = crypto.createHash("sha1").update(key + GUID).digest("base64"); socket.write( "HTTP/1.1 101 Switching Protocols\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + `Sec-WebSocket-Accept: ${accept}\r\n\r\n` ); socket.setNoDelay(true); if (client) { log("Neue Verbindung – alte wird ersetzt."); try { client.end(); } catch (_) {} } const remote = req.socket.remoteAddress; client = socket; clientInfo = { since: Date.now(), remote, identity: null }; log(`Verbindung geöffnet von ${remote}.`); // Ohne hello laesst sich nicht sagen, wer da verbunden ist. Das ist keine // Fehlersituation – nur bemerkenswert, wenn das Add-on erwartet wurde. const helloTimer = setTimeout(() => { if (client === socket && clientInfo && !clientInfo.identity) { log(`Client ${remote} hat sich nicht angemeldet (kein hello) – unbekannte Gegenstelle.`); rl.prompt(); } }, HELLO_GRACE_MS); attachFrameReader(socket, onMessage); // Der Socket aus dem HTTP-Upgrade hat allowHalfOpen = true: schickt die // Gegenstelle ein FIN, feuert ausschliesslich 'end' und niemals 'close'. // Ohne die eigene Gegenschliessung bliebe die Verbindung hier ewig als // "verbunden" stehen und Nachrichten gingen ins Leere. let finished = false; const finish = (reason) => { if (finished) return; finished = true; clearTimeout(helloTimer); try { socket.end(); } catch (_) {} if (client !== socket) return; // bereits durch eine neue Verbindung ersetzt const who = clientInfo && clientInfo.identity ? clientInfo.identity : "Unbekannte Gegenstelle"; const held = clientInfo ? duration(Date.now() - clientInfo.since) : "?"; log(`Verbindung getrennt: ${who} (Dauer ${held})${reason ? " – " + reason : ""}.`); client = null; clientInfo = null; rl.prompt(); }; socket.on("end", () => finish(null)); socket.on("close", () => finish(null)); socket.on("error", (err) => finish(err && err.message ? err.message : "Verbindungsfehler")); }); server.listen(PORT, "127.0.0.1", () => { log(`WebSocket-Server läuft auf ws://127.0.0.1:${PORT}`); log(LOG_FILE ? `Protokoll: ${LOG_FILE}` : "Protokoll: nur Konsole (--no-log)"); log("Kommandos: list [current] | open [background] | close | activate | focus | raw | quit"); rl.prompt(); }); /* ------------------------------------------------------ Frame-Handling */ function attachFrameReader(socket, onText) { let buffer = Buffer.alloc(0); socket.on("data", (chunk) => { buffer = Buffer.concat([buffer, chunk]); for (;;) { if (buffer.length < 2) return; const fin = (buffer[0] & 0x80) !== 0; const opcode = buffer[0] & 0x0f; const masked = (buffer[1] & 0x80) !== 0; let len = buffer[1] & 0x7f; let offset = 2; if (len === 126) { if (buffer.length < offset + 2) return; len = buffer.readUInt16BE(offset); offset += 2; } else if (len === 127) { if (buffer.length < offset + 8) return; const big = buffer.readBigUInt64BE(offset); if (big > 64n * 1024n * 1024n) { socket.destroy(); return; } len = Number(big); offset += 8; } let mask = null; if (masked) { if (buffer.length < offset + 4) return; mask = buffer.subarray(offset, offset + 4); offset += 4; } if (buffer.length < offset + len) return; const payload = Buffer.from(buffer.subarray(offset, offset + len)); buffer = buffer.subarray(offset + len); if (mask) { for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i & 3]; } switch (opcode) { case 0x1: // Text if (!fin) { log("Fragmentierte Frames werden nicht unterstützt."); break; } onText(payload.toString("utf8")); break; case 0x8: // Close socket.end(encodeFrame(Buffer.alloc(0), 0x8)); return; case 0x9: // Ping socket.write(encodeFrame(payload, 0xa)); break; case 0xa: // Pong break; default: break; } } }); } function encodeFrame(payload, opcode = 0x1) { const len = payload.length; let header; if (len < 126) { header = Buffer.alloc(2); header[1] = len; } else if (len < 65536) { header = Buffer.alloc(4); header[1] = 126; header.writeUInt16BE(len, 2); } else { header = Buffer.alloc(10); header[1] = 127; header.writeBigUInt64BE(BigInt(len), 2); } header[0] = 0x80 | opcode; // FIN + Opcode, Serverframes bleiben unmaskiert return Buffer.concat([header, payload]); } function sendJson(obj) { if (!client) { log("Kein Client verbunden."); return null; } client.write(encodeFrame(Buffer.from(JSON.stringify(obj), "utf8"), 0x1)); return obj.id; } /* -------------------------------------------------- Eingehende Nachrichten */ function onMessage(text) { let msg; try { msg = JSON.parse(text); } catch (_) { log("Ungültiges JSON empfangen: " + text); return; } // Antwort des Add-ons auf eine Tab-Nachricht if (msg.cmd === "tabresult") { printResult(msg); rl.prompt(); return; } // Anmeldung des Add-ons. Erst hierdurch ist die Gegenstelle identifiziert. if (msg.cmd === "hello") { const identity = `${msg.client || "unbekannt"} v${msg.version || "?"}`; if (clientInfo) clientInfo.identity = identity; const details = []; if (Array.isArray(msg.actions)) details.push(`Aktionen: ${msg.actions.join(", ")}`); details.push(msg.token ? "mit Token" : "ohne Token"); log(`Angemeldet: ${identity} (${details.join("; ")})`); rl.prompt(); return; } log("Unbekannte Nachricht: " + text); rl.prompt(); } function printResult(msg) { const { cmd, id, ok, error, tabs, ...rest } = msg; if (ok === false) { log(`Fehler (#${id}): ${error || "(ohne Text)"}`); return; } if (Array.isArray(tabs)) { log(`tabresult #${id}: ${tabs.length} Tab(s)`); for (const t of tabs) { console.log(` [${t.id}] ${t.active ? "*" : " "} ${t.title}\n ${t.url}`); } return; } log(`tabresult #${id}: ok${Object.keys(rest).length ? " " + JSON.stringify(rest) : ""}`); } /* ------------------------------------------------------------- Konsole */ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: "> " }); rl.on("line", (line) => { const input = line.trim(); if (!input) return rl.prompt(); const [cmd, ...rest] = input.split(/\s+/); switch (cmd) { case "list": sendJson({ type: "tab", id: nextId++, action: "list", currentWindowOnly: rest[0] === "current" }); break; case "open": if (!rest[0]) { log("Verwendung: open [background]"); break; } sendJson({ type: "tab", id: nextId++, action: "open", url: rest[0], active: rest[1] !== "background" }); break; case "close": { const tabId = Number(rest[0]); if (!Number.isInteger(tabId)) { log("Verwendung: close "); break; } sendJson({ type: "tab", id: nextId++, action: "close", tabId }); break; } case "activate": { const tabId = Number(rest[0]); if (!Number.isInteger(tabId)) { log("Verwendung: activate "); break; } sendJson({ type: "tab", id: nextId++, action: "activate", tabId }); break; } case "focus": { if (!rest[0]) { log("Verwendung: focus [titel…]"); break; } const request = { type: "tab", id: nextId++, action: "focus", url: rest[0] }; if (rest.length > 1) request.title = rest.slice(1).join(" "); sendJson(request); break; } case "raw": try { const obj = JSON.parse(input.slice(4)); if (obj.id === undefined) obj.id = nextId++; sendJson(obj); } catch (err) { log("Ungültiges JSON: " + err.message); } break; case "quit": case "exit": rl.close(); return; default: log(`Unbekanntes Kommando: ${cmd}`); } rl.prompt(); }); rl.on("close", () => { try { client?.end(); } catch (_) {} server.close(); process.exit(0); }); /** Zeitspanne in Worten, fuer die Verbindungsdauer im Log. */ function duration(ms) { const seconds = Math.round(ms / 1000); if (seconds < 60) return `${seconds} s`; const minutes = Math.floor(seconds / 60); if (minutes < 60) return `${minutes} min ${seconds % 60} s`; return `${Math.floor(minutes / 60)} h ${minutes % 60} min`; } function timestamp() { const d = new Date(); const pad = (n, width = 2) => String(n).padStart(width, "0"); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` + `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`; } function log(msg) { const line = `[${timestamp()}] ${msg}`; console.log(line); if (!LOG_FILE) return; try { fs.appendFileSync(LOG_FILE, line + "\n"); } catch (err) { // Ein nicht schreibbares Log darf den Dienst nicht anhalten. console.log(`(Log nicht schreibbar: ${err.message})`); } }