451 lines
14 KiB
JavaScript
451 lines
14 KiB
JavaScript
"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();
|
||
})();
|