first commit

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-20 11:05:58 +02:00
co-authored by Claude Opus 5
commit 99757361ef
15 changed files with 3020 additions and 0 deletions
+374
View File
@@ -0,0 +1,374 @@
#!/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 <datei>|--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 <url> [background] -> {"type":"tab","action":"open","url":"…"}
* close <tabId> -> {"type":"tab","action":"close","tabId":42}
* activate <tabId> -> {"type":"tab","action":"activate","tabId":42}
* focus <url> [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 <url> [background] | close <id> | activate <id> | focus <url> | raw <json> | 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 <url> [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 <tabId>"); break; }
sendJson({ type: "tab", id: nextId++, action: "close", tabId });
break;
}
case "activate": {
const tabId = Number(rest[0]);
if (!Number.isInteger(tabId)) { log("Verwendung: activate <tabId>"); break; }
sendJson({ type: "tab", id: nextId++, action: "activate", tabId });
break;
}
case "focus": {
if (!rest[0]) { log("Verwendung: focus <url> [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})`);
}
}