first commit
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+450
@@ -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();
|
||||
})();
|
||||
Reference in New Issue
Block a user