"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(); })();