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
+450
View File
@@ -0,0 +1,450 @@
"use strict";
/**
* Swyx Tab Bridge Hintergrundskript.
*
* Das Add-on ist WebSocket-*Client*: Es verbindet sich zu SwyxTray
* (Default ws://127.0.0.1:17655). Ein Add-on kann selbst keinen Server-Socket öffnen.
*
* Protokoll (Tab-Nachrichten, alles andere wird ignoriert):
*
* -> von SwyxTray : {"type":"tab","id":1,"action":"list"}
* <- vom Plugin : {"cmd":"tabresult","id":1,"ok":true,"tabs":[{"id":42,"title":"…","url":"…","active":false}]}
*
* -> {"type":"tab","id":2,"action":"open","url":"https://crm.example.local/kunden/4711"}
* <- {"cmd":"tabresult","id":2,"ok":true,"tabId":44}
*
* -> {"type":"tab","id":3,"action":"close","tabId":42}
* <- {"cmd":"tabresult","id":3,"ok":true}
*
* Direkt nach dem Verbindungsaufbau meldet sich das Add-on einmal an:
*
* <- {"cmd":"hello","client":"swyx-tab-bridge","version":"1.0.0","actions":[…]}
*
* Damit kann die Gegenstelle den Verbindungsaufbau protokollieren; eine Antwort
* darauf wird nicht erwartet.
*
* Fehler: {"cmd":"tabresult","id":…,"ok":false,"error":"…"}
* SwyxTray wertet einen Auftrag nach 5 s ohne Antwort als gescheitert deshalb wird
* jede Aktion nach ACTION_TIMEOUT_MS abgebrochen und mit ok:false beantwortet.
*/
const DEFAULTS = {
url: "ws://127.0.0.1:17655",
autoConnect: true,
token: ""
};
/** Aktionen, die das Add-on ausführt. */
const ACTIONS = ["list", "open", "close", "activate", "focus"];
/** Antwort muss vor dem 5-Sekunden-Timeout von SwyxTray raus sein. */
const ACTION_TIMEOUT_MS = 4000;
let config = { ...DEFAULTS };
let socket = null;
let state = "disconnected"; // disconnected | connecting | connected
let connectedSince = null; // Zeitpunkt des letzten erfolgreichen Verbindungsaufbaus
let lastError = "";
let reconnectTimer = null;
let reconnectAttempt = 0;
let stopped = false; // true = per Popup manuell getrennt, kein Auto-Reconnect
/* ------------------------------------------------------------------ Config */
async function loadConfig() {
const stored = await browser.storage.local.get(DEFAULTS);
config = { ...DEFAULTS, ...stored };
return config;
}
browser.storage.onChanged.addListener((changes, area) => {
if (area !== "local") return;
const urlChanged = changes.url && changes.url.newValue !== config.url;
for (const [key, change] of Object.entries(changes)) {
if (key in DEFAULTS) config[key] = change.newValue;
}
// Bei geänderter URL neu verbinden, damit die Einstellung sofort greift.
if (urlChanged && !stopped) {
closeSocket();
connect();
}
});
/* -------------------------------------------------------------- Verbindung */
function connect() {
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
return;
}
clearTimeout(reconnectTimer);
reconnectTimer = null;
stopped = false;
setState("connecting");
try {
socket = new WebSocket(config.url);
} catch (err) {
lastError = errorText(err);
setState("disconnected");
scheduleReconnect();
return;
}
socket.addEventListener("open", () => {
reconnectAttempt = 0;
lastError = "";
connectedSince = Date.now();
setState("connected");
// Immer senden: erst dadurch kann die Gegenstelle das Add-on von anderen
// Clients auf derselben Verbindung unterscheiden und den Verbindungsaufbau
// protokollieren. Das Token kommt nur mit, wenn eines konfiguriert ist.
const hello = {
cmd: "hello",
client: "swyx-tab-bridge",
version: browser.runtime.getManifest().version,
actions: ACTIONS
};
if (config.token) hello.token = config.token;
sendRaw(hello);
});
socket.addEventListener("message", (event) => {
handleIncoming(event.data);
});
socket.addEventListener("error", () => {
lastError = "Verbindungsfehler";
});
socket.addEventListener("close", (event) => {
socket = null;
if (event && event.code !== 1000 && event.reason) lastError = event.reason;
setState("disconnected");
scheduleReconnect();
});
}
function closeSocket() {
if (!socket) return;
try {
socket.close(1000, "client shutdown");
} catch (_) {
/* egal */
}
socket = null;
}
function disconnect() {
stopped = true;
clearTimeout(reconnectTimer);
reconnectTimer = null;
closeSocket();
setState("disconnected");
}
function scheduleReconnect() {
if (stopped || !config.autoConnect || reconnectTimer) return;
const delay = Math.min(30000, 1000 * Math.pow(2, reconnectAttempt));
reconnectAttempt += 1;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connect();
}, delay);
}
function sendRaw(payload) {
if (!socket || socket.readyState !== WebSocket.OPEN) return false;
try {
socket.send(JSON.stringify(payload));
return true;
} catch (err) {
lastError = errorText(err);
return false;
}
}
/* --------------------------------------------- Eingehende Nachrichten */
async function handleIncoming(raw) {
let msg;
try {
msg = JSON.parse(typeof raw === "string" ? raw : String(raw));
} catch (_) {
console.debug("[Swyx Tab Bridge] Nachricht ist kein gültiges JSON, ignoriert.");
return;
}
// Nur Tab-Nachrichten sind unsere Zuständigkeit alles andere (Telefonie,
// Status-Events, Antworten an andere Komponenten) wird ignoriert.
if (!msg || typeof msg !== "object" || msg.type !== "tab") return;
const id = msg.id !== undefined ? msg.id : null;
const action = typeof msg.action === "string" ? msg.action.trim().toLowerCase() : "";
try {
const result = await withTimeout(runAction(action, msg), ACTION_TIMEOUT_MS);
sendRaw({ cmd: "tabresult", id, ok: true, ...result });
} catch (err) {
sendRaw({ cmd: "tabresult", id, ok: false, error: errorText(err) });
}
}
async function runAction(action, msg) {
switch (action) {
case "list":
return await listTabs(msg);
case "open":
return await openTab(msg);
case "close":
return await closeTab(msg);
// Erweiterungen über die drei SwyxTray-Aktionen hinaus:
case "activate":
return await activateTab(msg);
case "focus":
return await focusTab(msg);
default:
throw new Error(`Unbekannte Aktion '${msg.action}'.`);
}
}
/* ------------------------------------------------------------ Aktionen */
/**
* list offene Tabs auslesen.
* Optional: currentWindowOnly (bool), windowId (number), url/title (Match-Pattern)
*/
async function listTabs(msg) {
const query = {};
if (msg.currentWindowOnly) query.currentWindow = true;
if (Number.isInteger(msg.windowId)) query.windowId = msg.windowId;
if (msg.url) query.url = msg.url; // z.B. "*://*.example.local/*"
if (msg.title) query.title = msg.title;
const tabs = await browser.tabs.query(query);
return { tabs: tabs.map(toTabInfo) };
}
/**
* open Tab mit mitgegebener URL öffnen.
* Pflicht: url. Optional: active (default true), reuse (vorhandenen Tab verwenden),
* windowId, index, pinned, newWindow
*/
async function openTab(msg) {
const url = typeof msg.url === "string" ? msg.url.trim() : "";
if (!url) throw new Error("Feld 'url' fehlt.");
assertAllowedUrl(url);
const active = msg.active !== false;
// reuse: ist die Seite schon offen, wird sie aktiviert statt doppelt geöffnet.
if (msg.reuse) {
const existing = (await browser.tabs.query({})).find((t) => urlMatches(t.url, url, msg.match || "prefix"));
if (existing) {
if (active) {
await browser.tabs.update(existing.id, { active: true });
await browser.windows.update(existing.windowId, { focused: true });
}
return { tabId: existing.id, reused: true };
}
}
if (msg.newWindow) {
const win = await browser.windows.create({ url, focused: active });
const tab = win.tabs && win.tabs[0];
return { tabId: tab ? tab.id : null };
}
const createProps = { url, active };
if (Number.isInteger(msg.windowId)) createProps.windowId = msg.windowId;
if (Number.isInteger(msg.index)) createProps.index = msg.index;
if (msg.pinned === true) createProps.pinned = true;
const tab = await browser.tabs.create(createProps);
if (active && Number.isInteger(tab.windowId)) {
await browser.windows.update(tab.windowId, { focused: true });
}
return { tabId: tab.id };
}
/**
* close Tab schließen.
* Pflicht: tabId (number oder Array von numbers).
*/
async function closeTab(msg) {
const ids = Array.isArray(msg.tabId) ? msg.tabId : [msg.tabId];
if (!ids.length || !ids.every((id) => Number.isInteger(id))) {
throw new Error("Feld 'tabId' (number) fehlt oder ist ungültig.");
}
await browser.tabs.remove(Array.isArray(msg.tabId) ? ids : ids[0]);
return Array.isArray(msg.tabId) ? { closed: ids } : {};
}
/**
* activate vorhandenen Tab in den Vordergrund holen.
* Pflicht: tabId. Optional: focusWindow (default true)
*/
async function activateTab(msg) {
if (!Number.isInteger(msg.tabId)) throw new Error("Feld 'tabId' (number) fehlt.");
const tab = await browser.tabs.update(msg.tabId, { active: true });
if (msg.focusWindow !== false && Number.isInteger(tab.windowId)) {
await browser.windows.update(tab.windowId, { focused: true });
}
return { tabId: tab.id };
}
/**
* focus passende Seite in den Vordergrund holen, sonst öffnen.
* Pflicht: url (oder title). Optional: match ("prefix"|"exact"|"origin"),
* open (default true), activate (default true)
* Ergebnis: focused = vorhandener Tab aktiviert, opened = neuer Tab angelegt.
*/
async function focusTab(msg) {
const url = typeof msg.url === "string" ? msg.url.trim() : "";
const title = typeof msg.title === "string" ? msg.title.trim() : "";
if (!url && !title) throw new Error("Feld 'url' oder 'title' fehlt.");
if (url) assertAllowedUrl(url);
const mode = msg.match || "prefix";
const activate = msg.activate !== false;
const openIfMissing = msg.open !== false;
const tabs = await browser.tabs.query({});
const found = url
? tabs.find((t) => urlMatches(t.url, url, mode))
: tabs.find((t) => (t.title || "").toLowerCase().includes(title.toLowerCase()));
if (found) {
if (activate) {
await browser.tabs.update(found.id, { active: true });
await browser.windows.update(found.windowId, { focused: true });
}
return { focused: true, opened: false, tabId: found.id };
}
if (!openIfMissing || !url) {
return { focused: false, opened: false, tabId: null };
}
const tab = await browser.tabs.create({ url, active: activate });
if (activate && Number.isInteger(tab.windowId)) {
await browser.windows.update(tab.windowId, { focused: true });
}
return { focused: false, opened: true, tabId: tab.id };
}
/* ------------------------------------------------------------- Helfer */
/** Bricht eine Aktion ab, bevor SwyxTray in den 5-Sekunden-Timeout läuft. */
function withTimeout(promise, ms) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("Zeitüberschreitung im Add-on.")), ms);
promise.then(
(value) => { clearTimeout(timer); resolve(value); },
(err) => { clearTimeout(timer); reject(err); }
);
});
}
/** Vergleicht Tab-URL und Ziel-URL; Fragment und abschließende Slashes werden ignoriert. */
function urlMatches(tabUrl, target, mode) {
if (!tabUrl) return false;
if (mode === "origin") {
try {
return new URL(tabUrl).origin === new URL(target).origin;
} catch (_) {
return false;
}
}
const normalize = (u) => u.replace(/#.*$/, "").replace(/\/+$/, "");
const a = normalize(tabUrl);
const b = normalize(target);
if (mode === "exact") return a === b;
return a === b || a.startsWith(b + "/") || a.startsWith(b + "?");
}
/** Nur Web-URLs zulassen kein javascript:, data: o.ä. über die Bridge. */
function assertAllowedUrl(url) {
let parsed;
try {
parsed = new URL(url);
} catch (_) {
throw new Error(`Ungültige URL: ${url}`);
}
const allowed = ["http:", "https:", "ftp:", "file:"];
if (!allowed.includes(parsed.protocol)) {
throw new Error(`Protokoll nicht erlaubt: ${parsed.protocol}`);
}
}
/** Tab-Objekt im Format von SwyxTray. */
function toTabInfo(tab) {
return {
id: tab.id,
title: tab.title || "",
url: tab.url || "",
active: !!tab.active
};
}
function errorText(err) {
return String(err && err.message ? err.message : err);
}
/* ------------------------------------------------------------- UI-Status */
function setState(next) {
if (next !== "connected") connectedSince = null;
state = next;
updateBadge();
// Schlägt fehl, wenn kein Popup offen ist bewusst ignoriert.
browser.runtime.sendMessage({ type: "statusChanged", status: getStatus() }).catch(() => {});
}
function updateBadge() {
const text = state === "connected" ? "" : state === "connecting" ? "…" : "!";
const color = state === "connected" ? "#2ea043" : state === "connecting" ? "#d29922" : "#d1242f";
browser.browserAction.setBadgeText({ text });
browser.browserAction.setBadgeBackgroundColor({ color });
}
function getStatus() {
return { state, url: config.url, autoConnect: config.autoConnect, lastError, stopped, connectedSince };
}
browser.runtime.onMessage.addListener((msg) => {
switch (msg && msg.cmd) {
case "getStatus":
return Promise.resolve(getStatus());
case "connect":
connect();
return Promise.resolve(getStatus());
case "disconnect":
disconnect();
return Promise.resolve(getStatus());
default:
return false;
}
});
/* ---------------------------------------------------------------- Start */
(async () => {
await loadConfig();
updateBadge();
if (config.autoConnect) connect();
})();