Files
swyxffplugin/background.js
T
SvenandClaude Opus 5 99757361ef first commit
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 11:05:58 +02:00

451 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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();
})();