commit 99757361ef7b1bf606d8cb33d07e2a250f20ce21 Author: Sven Carstensen Date: Thu Aug 20 11:05:58 2026 +0200 first commit Co-Authored-By: Claude Opus 5 (1M context) diff --git a/.amo-upload-uuid b/.amo-upload-uuid new file mode 100644 index 0000000..d465aef --- /dev/null +++ b/.amo-upload-uuid @@ -0,0 +1 @@ +{"uploadUuid":"5c57e1397beb42a3a30fd0851d79a3c8","channel":"unlisted","xpiCrcHash":"c54af52ce9dee6a2957126b9f369a1ea1c6cacd94e53ed64d985f2852898dc81"} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..331a955 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Zugangsdaten - der AMO-Schluessel gilt fuer das gesamte Konto +deploy/.amo-credentials + +# Bauartefakte +build/ +web-ext-artifacts/ + +# Laufzeit +test-server/swyx-tray.log + +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..dc293c9 --- /dev/null +++ b/README.md @@ -0,0 +1,260 @@ +# Swyx Tab Bridge — Firefox-Add-on + +Verwaltet Firefox-Tabs über die WebSocket-Verbindung zu SwyxTray: Tabs auslesen, öffnen +und schließen. + +## Rollenverteilung + +Ein Browser-Add-on kann **keinen** WebSocket-Server betreiben — es kann nur Client sein. +Das Add-on **verbindet sich** daher zu SwyxTray (Default `ws://127.0.0.1:17655`) und +beantwortet die dort eintreffenden Aufträge. + +``` +[ SwyxTray, Port 17655 ] <--- verbindet sich --- [ Firefox-Add-on: WS-Client ] + | {"type":"tab","id":1,"action":"list"} -> + <- {"cmd":"tabresult","id":1,"ok":true,"tabs":[…]} +``` + +Zuständig ist das Add-on **ausschließlich für Tab-Nachrichten** (`"type":"tab"`). +Alles andere auf der Verbindung — Telefonie-Kommandos, Events, Nachrichten an andere +Komponenten — wird ignoriert: keine Antwort, kein Fehler. + +## Protokoll + +Alle Nachrichten sind JSON-Textframes. Die `id` der Anfrage wird unverändert in die +Antwort gespiegelt; die Antwort trägt immer `"cmd":"tabresult"`. + +### 1. Offene Tabs auslesen (`list`) + +```json +→ {"type":"tab","id":1,"action":"list"} +← {"cmd":"tabresult","id":1,"ok":true, + "tabs":[{"id":42,"title":"Kundenakte Muster GmbH","url":"https://crm.example.local/kunden/4711","active":false}, + {"id":43,"title":"Posteingang","url":"https://mail.example.local/","active":true}]} +``` + +Optionale Felder zum Einschränken: `currentWindowOnly` (bool), `windowId` (number), +`url` / `title` (Match-Pattern, z.B. `"*://*.example.local/*"`). + +### 2. Tab mit mitgegebener URL öffnen (`open`) + +```json +→ {"type":"tab","id":2,"action":"open","url":"https://crm.example.local/kunden/4711"} +← {"cmd":"tabresult","id":2,"ok":true,"tabId":44} +``` + +Optionale Felder: + +| Feld | Typ | Default | Beschreibung | +|-------------|---------|---------|-----------------------------------------------------------------| +| `active` | boolean | `true` | `false` = im Hintergrund öffnen | +| `reuse` | boolean | `false` | ist die URL schon offen, wird der Tab aktiviert statt neu geöffnet (Antwort enthält dann `"reused":true`) | +| `newWindow` | boolean | `false` | in neuem Fenster öffnen | +| `windowId` | number | – | Zielfenster | +| `index` | number | – | Position in der Tableiste | +| `pinned` | boolean | `false` | angeheftet öffnen | + +Erlaubt sind `http`, `https`, `ftp`, `file`; `javascript:`, `data:` u.ä. werden abgewiesen. + +### 3. Tab löschen (`close`) + +```json +→ {"type":"tab","id":3,"action":"close","tabId":42} +← {"cmd":"tabresult","id":3,"ok":true} +``` + +`tabId` darf auch ein Array sein (`[42,43]`); die Antwort enthält dann `"closed":[42,43]`. + +### Anmeldung (`hello`) + +Direkt nach dem Verbindungsaufbau meldet sich das Add-on einmal an — immer, nicht +nur mit Token: + +```json +← {"cmd":"hello","client":"swyx-tab-bridge","version":"1.0.0", + "actions":["list","open","close","activate","focus"]} +``` + +Ist unter *Einstellungen* ein Token gesetzt, kommt es als Feld `token` mit. Eine +Antwort wird nicht erwartet. SwyxTray kann die Gegenstelle erst dadurch +identifizieren und den Verbindungsaufbau protokollieren; ohne `hello` ist auf der +Leitung nicht unterscheidbar, wer sich verbunden hat. + +### Fehler + +```json +← {"cmd":"tabresult","id":5,"ok":false,"error":"Feld 'url' fehlt."} +``` + +`error` ist ein zusätzliches Klartextfeld zur Diagnose — maßgeblich ist `"ok":false`. +Fehlerfälle: fehlendes/ungültiges `url` bzw. `tabId`, unerlaubtes URL-Protokoll, +unbekannte `action`, nicht existierender Tab. + +SwyxTray wertet einen Auftrag nach 5 s ohne Antwort als gescheitert. Das Add-on bricht +deshalb jede Aktion nach 4 s ab (`ACTION_TIMEOUT_MS` in [`background.js`](background.js)) +und antwortet dann mit `"ok":false` — es bleibt also nie stumm. + +### Zusätzliche Aktionen + +Über die drei SwyxTray-Aktionen hinaus versteht das Add-on: + +```json +→ {"type":"tab","id":4,"action":"activate","tabId":42} +← {"cmd":"tabresult","id":4,"ok":true,"tabId":42} + +→ {"type":"tab","id":5,"action":"focus","url":"https://crm.example.local/kunden/4711"} +← {"cmd":"tabresult","id":5,"ok":true,"focused":true,"opened":false,"tabId":42} +``` + +- `activate` holt einen bekannten Tab per `tabId` in den Vordergrund (inkl. Fenster). +- `focus` sucht die passende Seite und aktiviert sie; ist sie nicht offen, wird sie + geöffnet (`focused:false, opened:true`). Optional: `match` (`prefix` | `exact` | + `origin`, Default `prefix`), `open:false` (nicht öffnen), `activate:false`, + oder Suche per `title` statt `url`. Fragment (`#…`) und abschließende Slashes werden + beim Vergleich ignoriert. + +### Ignorierte Nachrichten + +| Nachricht | Verhalten | +|----------------------------------------------------|--------------------| +| alles ohne `"type":"tab"` (z.B. `{"cmd":"call",…}`) | wird ignoriert | +| ungültiges JSON | wird ignoriert | + +## Installation (temporär, zum Entwickeln) + +1. `about:debugging#/runtime/this-firefox` öffnen +2. **„Temporäres Add-on laden…”** → `manifest.json` in diesem Ordner auswählen +3. Server-Adresse ggf. unter *Einstellungen* im Popup anpassen (Default `ws://127.0.0.1:17655`) + +Temporär geladene Add-ons verschwinden beim Firefox-Neustart. Für den Dauerbetrieb muss +das Add-on signiert werden (`web-ext sign`, AMO-Account) oder es wird Firefox ESR / +Developer Edition mit `xpinstall.signatures.required = false` verwendet. + +Paket bauen: + +```bash +npx web-ext lint +npx web-ext build # erzeugt web-ext-artifacts/*.zip -> signieren bzw. in .xpi umbenennen +``` + +## Signierung und Datenerhebung + +Seit dem 3. November 2025 müssen neue Erweiterungen auf AMO Mozillas eingebautes +Einwilligungssystem bedienen. Im Manifest steht deshalb: + +```json +"data_collection_permissions": { "required": ["browsingActivity"] } +``` + +Mozilla definiert Übermittlung als Daten, die *außerhalb des Add-ons oder des +lokalen Browsers* verarbeitet werden. Das Add-on gibt Tab-Titel und URLs an +SwyxTray weiter — also aus dem Browser heraus, wenn auch nur an eine lokale +Anwendung auf demselben Rechner und nicht ins Netz. `"none"` wäre eine +Untertreibung, `browsingActivity` beschreibt genau das (besuchte Seiten, URLs). + +`strict_min_version` bleibt bewusst bei `115.0`, obwohl der Schlüssel erst ab +Firefox 140 ausgewertet wird. Ältere Versionen ignorieren ihn folgenlos; ein +Anheben auf `140.0` würde ESR 115 und ESR 128 ausschließen. `web-ext lint` meldet +dafür zwei Warnungen — die blockieren die Signierung nicht. + +## Installation auf Windows (per SSH) + +`deploy/deploy-windows.sh` zählt die Version hoch, baut das `.xpi`, überträgt es per +`scp` und führt `deploy/Install-SwyxTabBridge.ps1` auf dem Zielrechner aus: + +```bash +./deploy/deploy-windows.sh # Testrechner, ohne Parameter +./deploy/deploy-windows.sh --keep-firefox # laufenden Firefox nicht beenden +./deploy/deploy-windows.sh --uninstall # rückgängig machen +``` + +Der nackte Aufruf erledigt alles: Version hochzählen, bei AMO signieren, übertragen, +Policy schreiben und den laufenden Firefox beenden (nur den aus dem Zielverzeichnis — +eine zweite Installation daneben bleibt offen). Ziel ist die Release-Installation, die +ausschließlich signierte Add-ons annimmt; jeder Lauf wird deshalb signiert. Ziel-Rechner, +Firefox-Verzeichnis sowie Signatur- und Neustart-Verhalten sind als Konstanten oben im +Skript voreingestellt. + +Standardmäßig wird eine Enterprise-Policy (`force_installed`) geschrieben, sodass das +Add-on beim nächsten Firefox-Start ohne Rückfrage für alle Profile installiert wird. +Details, Signierung und Optionen: [deploy/README.md](deploy/README.md). + +## Tests + +Selbsttest der Kommandoverarbeitung ohne Firefox — `background.js` läuft mit gestubbten +`browser`-/`WebSocket`-APIs: + +```bash +node test-server/test-background.mjs +``` + +Testserver, der SwyxTray simuliert (ohne Abhängigkeiten, nur Node): + +```bash +node test-server/server.js # lauscht auf ws://127.0.0.1:17655 +node test-server/server.js --log /pfad/x.log # anderes Protokoll +node test-server/server.js --no-log # nur Konsole +``` + +Der Server protokolliert Verbindungsaufbau, Anmeldung und Trennung mit Zeitstempel +auf der Konsole **und** in `test-server/swyx-tray.log`: + +``` +[2026-08-17 16:16:09.809] Verbindung geöffnet von 127.0.0.1. +[2026-08-17 16:16:09.809] Angemeldet: swyx-tab-bridge v1.0.12 (Aktionen: list, open, close, activate, focus; ohne Token) +[2026-08-17 16:16:12.314] Verbindung getrennt: swyx-tab-bridge v1.0.12 (Dauer 3 s). +``` + +Meldet sich eine Gegenstelle binnen drei Sekunden nicht mit `hello` an, wird das +als „unbekannte Gegenstelle" vermerkt. Das ist die Vorlage dafür, was SwyxTray +mitschreiben sollte. + +| Eingabe | gesendete Nachricht | +|------------------------------------|--------------------------------------------------------| +| `list` / `list current` | `{"type":"tab","action":"list"}` | +| `open [background]` | `{"type":"tab","action":"open","url":"…"}` | +| `close ` | `{"type":"tab","action":"close","tabId":42}` | +| `activate ` | `{"type":"tab","action":"activate","tabId":42}` | +| `focus [titel…]` | `{"type":"tab","action":"focus","url":"…"}` | +| `raw {…}` | beliebiges JSON (ohne `type:"tab"` bleibt es unbeantwortet) | +| `quit` | beenden | + +## Einstellungen + +Die Einstellungsseite zeigt oben, ob das Add-on mit der Tray-App verbunden ist — +Zustand, Serveradresse, Zeitpunkt des Verbindungsaufbaus und der letzte Fehler, +laufend aktualisiert. Dieselbe Anzeige gibt es kompakt im Popup. + +Über das Symbol in der Symbolleiste → *Einstellungen*: + +- **WebSocket-Server** — `ws://…` oder `wss://…` (Default `ws://127.0.0.1:17655`) +- **Token** — optionales Shared Secret. Ist es gesetzt, geht es in der + `hello`-Nachricht mit. +- **Automatisch verbinden** — Auto-Reconnect mit exponentiellem Backoff (1 s → max. 30 s) + +Das Symbol zeigt den Verbindungszustand: kein Badge = verbunden, `…` = Verbindungsaufbau, +`!` = getrennt. + +## Dateien + +| Datei | Zweck | +|-----------------------------------|---------------------------------------------------| +| `manifest.json` | Add-on-Manifest (MV2, persistenter Hintergrund) | +| `background.js` | WebSocket-Client, Reconnect, Aktionen | +| `popup.html/js` | Statusanzeige, Verbinden/Trennen | +| `options.html/js` | Server-URL, Token, Auto-Connect | +| `test-server/server.js` | Testserver (simuliert SwyxTray) | +| `test-server/test-background.mjs` | Selbsttest der Kommandoverarbeitung | +| `deploy/deploy-windows.sh` | Version hochzaehlen, bauen, per SSH ausrollen | +| `deploy/Install-SwyxTabBridge.ps1`| Installation auf dem Windows-Rechner | + +## Hinweise + +- **Manifest V2** ist bewusst gewählt: Nur mit persistentem Hintergrundskript bleibt die + WebSocket-Verbindung dauerhaft offen. Unter MV3 (nicht-persistente Event-Page) würde die + Verbindung nach Leerlauf abgebaut. Firefox unterstützt MV2 weiterhin. +- `ws://127.0.0.1` ist erlaubt (das Add-on läuft im `moz-extension:`-Kontext, keine + Mixed-Content-Blockade). Für Verbindungen über das Netzwerk `wss://` verwenden. +- SwyxTray sollte nur auf `127.0.0.1` lauschen — sonst kann jeder im Netz Tabs öffnen, + schließen und die Tab-Liste mitlesen. +- Die Permission `tabs` ist nötig, um `title` und `url` der Tabs zu lesen. diff --git a/background.js b/background.js new file mode 100644 index 0000000..022e2f9 --- /dev/null +++ b/background.js @@ -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(); +})(); diff --git a/deploy/Install-SwyxTabBridge.ps1 b/deploy/Install-SwyxTabBridge.ps1 new file mode 100644 index 0000000..bad6e23 --- /dev/null +++ b/deploy/Install-SwyxTabBridge.ps1 @@ -0,0 +1,794 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Installiert bzw. entfernt das Firefox-Add-on "Swyx Tab Bridge" auf einem + Windows-Rechner. Laeuft lokal oder ueber SSH (siehe deploy-windows.sh). + +.DESCRIPTION + Zwei Installationsarten: + + -Mode Policy (Default, empfohlen fuer den Dauerbetrieb) + Schreibt eine Enterprise-Policy nach \distribution\policies.json + mit installation_mode = force_installed. Das Add-on wird beim naechsten + Firefox-Start automatisch, ohne Rueckfrage und fuer alle Profile des + Rechners installiert und kann vom Benutzer nicht deaktiviert werden. + Benoetigt Schreibrecht im Firefox-Installationsverzeichnis (bei einer + systemweiten Installation also Administratorrechte, bei einer + Benutzerinstallation unter %LOCALAPPDATA% keine) und ein signiertes .xpi. + + -Mode Profile + Legt das .xpi als \extensions\.xpi ab. Ohne + Adminrechte moeglich, wirkt nur auf die Profile des Zielbenutzers, und + Firefox fragt beim naechsten Start einmal nach Bestaetigung. + + Firefox Release und ESR installieren nur signierte Add-ons. Ein unsigniertes + Paket laesst sich nur mit -AllowUnsigned und nur auf Firefox ESR, Developer + Edition oder Nightly betreiben; -AllowUnsigned hinterlegt dafuer eine + autoconfig-Datei, die xpinstall.signatures.required auf false sperrt. + +.PARAMETER XpiPath + Pfad zum .xpi (lokal auf dem Windows-Rechner). Pflicht ausser bei -Uninstall. + +.PARAMETER Mode + Policy (Default) oder Profile. + +.PARAMETER ExtensionId + Add-on-ID, muss zu browser_specific_settings.gecko.id im Manifest passen. + +.PARAMETER FirefoxDir + Installationsverzeichnis von Firefox. Wird normalerweise automatisch erkannt; + nur noetig, wenn mehrere Installationen gefunden werden. + +.PARAMETER TargetUser + Benutzer, dessen Profile bei -Mode Profile bespielt werden. Default: der + aktuelle Benutzer. Fremde Benutzer erfordern Adminrechte. + +.PARAMETER AllowUnsigned + Signaturpruefung per autoconfig abschalten (nur ESR / Developer Edition). + +.PARAMETER StopFirefox + Laufende firefox.exe-Prozesse beenden, damit die Installation sofort greift. + Firefox stellt die Sitzung beim naechsten Start wieder her. + +.PARAMETER Uninstall + Policy-Eintrag, hinterlegtes .xpi und Profil-Kopien wieder entfernen. + +.EXAMPLE + .\Install-SwyxTabBridge.ps1 -XpiPath .\swyx_tab_bridge-1.0.0.xpi + +.EXAMPLE + .\Install-SwyxTabBridge.ps1 -XpiPath .\swyx.xpi -Mode Profile -StopFirefox + +.EXAMPLE + .\Install-SwyxTabBridge.ps1 -Uninstall + +.NOTES + Exitcodes: 0 = Erfolg, 1 = Fehler, 2 = Erfolg, aber Firefox-Neustart noetig. + Die Datei ist bewusst rein ASCII, damit Windows PowerShell 5.1 sie unabhaengig + von der Codepage korrekt liest. +#> + +[CmdletBinding(SupportsShouldProcess)] +param( + [string]$XpiPath, + + [ValidateSet('Policy', 'Profile')] + [string]$Mode = 'Policy', + + [string]$ExtensionId = 'swyx-tab-bridge@appcreation.de', + + [string]$FirefoxDir, + + [string]$TargetUser = $env:USERNAME, + + [switch]$AllowUnsigned, + + [switch]$StopFirefox, + + [switch]$Uninstall +) + +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' + +# Ablageort des .xpi fuer den Policy-Modus. Muss fuer alle Benutzer lesbar sein, +# weil Firefox die Datei bei jedem Profil-Start ueber die file:-URL liest. +$Script:XpiStore = Join-Path $env:ProgramData 'SwyxTabBridge' +$Script:RestartNeeded = $false + +# ------------------------------------------------------------------ Ausgabe + +# Ausgabe bewusst ueber [Console] statt Write-Host: bei umgeleitetem stdout - +# genau der Fall bei "ssh host powershell ..." - serialisiert Windows PowerShell +# den Information-Stream sonst als CLIXML und die Ausgabe wird unlesbar. +function Write-Line { param([string]$Message = '') [Console]::Out.WriteLine($Message) } +function Write-Step { param([string]$Message) Write-Line ''; Write-Line "==> $Message" } +function Write-Info { param([string]$Message) Write-Line " $Message" } +function Write-Good { param([string]$Message) Write-Line " [ok] $Message" } +function Write-Note { param([string]$Message) Write-Line " [!] $Message" } + +# ------------------------------------------------------------------ Helfer + +function Test-Administrator { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object Security.Principal.WindowsPrincipal($identity) + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +# Massgeblich ist nicht "ist der Benutzer Admin", sondern "darf hier geschrieben +# werden": eine Firefox-Benutzerinstallation unter %LOCALAPPDATA% laesst sich +# ohne jedes Sonderrecht mit einer Policy versehen. +function Test-DirectoryWritable { + param([string]$Path) + + $probeRoot = $Path + while ($probeRoot -and -not (Test-Path -LiteralPath $probeRoot)) { + $probeRoot = Split-Path -Parent $probeRoot + } + if (-not $probeRoot) { return $false } + + $probe = Join-Path $probeRoot ([System.IO.Path]::GetRandomFileName()) + try { + [System.IO.File]::WriteAllText($probe, 'probe') + Remove-Item -LiteralPath $probe -Force + return $true + } catch { + return $false + } +} + +function ConvertTo-FileUrl { + param([string]$Path) + return ([uri](Resolve-Path -LiteralPath $Path).ProviderPath).AbsoluteUri +} + +# JSON -> verschachtelte Hashtables. ConvertFrom-Json liefert PSCustomObjects, +# die sich nicht sinnvoll zusammenfuehren lassen; -AsHashtable gibt es erst ab +# PowerShell 6, hier laeuft aber oft noch Windows PowerShell 5.1. +function ConvertTo-HashtableDeep { + param($InputObject) + + if ($null -eq $InputObject) { return $null } + + if ($InputObject -is [System.Collections.IEnumerable] -and $InputObject -isnot [string]) { + $list = @() + foreach ($item in $InputObject) { $list += ,(ConvertTo-HashtableDeep $item) } + return ,$list + } + + if ($InputObject -is [psobject] -and $InputObject.PSObject.Properties.Name.Count -gt 0 -and + $InputObject.GetType().Name -eq 'PSCustomObject') { + $map = @{} + foreach ($property in $InputObject.PSObject.Properties) { + $map[$property.Name] = ConvertTo-HashtableDeep $property.Value + } + return $map + } + + return $InputObject +} + +function Write-JsonFile { + param([string]$Path, $Data) + $json = $Data | ConvertTo-Json -Depth 32 + $utf8NoBom = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllText($Path, $json, $utf8NoBom) +} + +function Backup-File { + param([string]$Path) + if (-not (Test-Path -LiteralPath $Path)) { return } + $stamp = (Get-Date).ToString('yyyyMMdd-HHmmss') + $backup = "$Path.bak-$stamp" + Copy-Item -LiteralPath $Path -Destination $backup -Force + Write-Info "Sicherung: $backup" +} + +# ------------------------------------------------------------------ Firefox finden + +function Get-FirefoxInstallation { + param([string]$Explicit) + + if ($Explicit) { + $exe = Join-Path $Explicit 'firefox.exe' + if (-not (Test-Path -LiteralPath $exe)) { + throw "In '$Explicit' liegt keine firefox.exe." + } + return @( New-FirefoxInfo -Directory $Explicit ) + } + + $directories = New-Object System.Collections.Generic.List[string] + + # Registrierung: HK{LM,CU}\SOFTWARE\[WOW6432Node\]Mozilla\\\Main + # HKCU deckt Benutzerinstallationen nach %LOCALAPPDATA% ab - der Standard, + # wenn Firefox ohne Adminrechte installiert wurde. + foreach ($root in @('HKLM:\SOFTWARE\Mozilla', 'HKLM:\SOFTWARE\WOW6432Node\Mozilla', + 'HKCU:\SOFTWARE\Mozilla', 'HKCU:\SOFTWARE\WOW6432Node\Mozilla')) { + if (-not (Test-Path -LiteralPath $root)) { continue } + foreach ($product in (Get-ChildItem -LiteralPath $root -ErrorAction SilentlyContinue)) { + foreach ($version in (Get-ChildItem -LiteralPath $product.PSPath -ErrorAction SilentlyContinue)) { + $main = Join-Path $version.PSPath 'Main' + if (-not (Test-Path -LiteralPath $main)) { continue } + $value = (Get-ItemProperty -LiteralPath $main -ErrorAction SilentlyContinue).'Install Directory' + if ($value -and (Test-Path -LiteralPath (Join-Path $value 'firefox.exe'))) { + $directories.Add($value) + } + } + } + } + + # Fallback: Standardpfade, inklusive Benutzerinstallation in %LOCALAPPDATA% + foreach ($base in @($env:ProgramFiles, ${env:ProgramFiles(x86)}, $env:LOCALAPPDATA)) { + if (-not $base) { continue } + foreach ($name in @('Mozilla Firefox', 'Firefox Developer Edition', 'Firefox Nightly')) { + $candidate = Join-Path $base $name + if (Test-Path -LiteralPath (Join-Path $candidate 'firefox.exe')) { + $directories.Add($candidate) + } + } + } + + $unique = $directories | Sort-Object -Unique + return @($unique | ForEach-Object { New-FirefoxInfo -Directory $_ }) +} + +function New-FirefoxInfo { + param([string]$Directory) + + $exe = Join-Path $Directory 'firefox.exe' + $info = (Get-Item -LiteralPath $exe).VersionInfo + $product = $info.ProductName + $version = $info.ProductVersion + + # Der Kanal steht nirgends direkt; application.ini verraet ihn indirekt. + # Reihenfolge ist wichtig: Developer Edition wird aus mozilla-beta gebaut, + # erlaubt aber - anders als Beta - unsignierte Add-ons. Deshalb hat der + # RemotingName (dort "firefox-dev") das letzte Wort. + $channel = 'release' + $appIni = Join-Path $Directory 'application.ini' + if (Test-Path -LiteralPath $appIni) { + $content = Get-Content -LiteralPath $appIni -Raw + + if ($content -match '(?m)^SourceRepository=.*mozilla-esr') { $channel = 'esr' } + elseif ($content -match '(?m)^SourceRepository=.*mozilla-beta') { $channel = 'beta' } + elseif ($content -match '(?m)^SourceRepository=.*mozilla-central') { $channel = 'nightly' } + + if ($content -match '(?m)^RemotingName=(.+)$') { + $remoting = $Matches[1].Trim() + if ($remoting -match '(?i)dev') { $channel = 'developer' } + elseif ($remoting -match '(?i)nightly') { $channel = 'nightly' } + } + } + + return [pscustomobject]@{ + Directory = $Directory + Exe = $exe + Product = $product + Version = $version + Channel = $channel + } +} + +function Select-FirefoxInstallation { + param([string]$Explicit) + + $found = @(Get-FirefoxInstallation -Explicit $Explicit) + + if ($found.Count -eq 0) { + throw "Keine Firefox-Installation gefunden. Pfad mit -FirefoxDir angeben." + } + if ($found.Count -gt 1) { + Write-Note "Mehrere Firefox-Installationen gefunden:" + foreach ($item in $found) { Write-Note " $($item.Directory) ($($item.Product) $($item.Version))" } + throw "Bitte die gewuenschte Installation mit -FirefoxDir angeben." + } + + return $found[0] +} + +# ------------------------------------------------------------------ Profile finden + +function Get-FirefoxProfile { + param([string]$User) + + if ($User -eq $env:USERNAME) { + $roaming = $env:APPDATA + } else { + $roaming = Join-Path (Join-Path $env:SystemDrive "Users\$User") 'AppData\Roaming' + } + + $profilesIni = Join-Path $roaming 'Mozilla\Firefox\profiles.ini' + if (-not (Test-Path -LiteralPath $profilesIni)) { + throw "Keine profiles.ini fuer Benutzer '$User' gefunden ($profilesIni). Firefox muss dort mindestens einmal gestartet worden sein." + } + + $firefoxRoot = Split-Path -Parent $profilesIni + $section = $null + $sections = @{} + + foreach ($line in (Get-Content -LiteralPath $profilesIni)) { + $line = $line.Trim() + if ($line -match '^\[(.+)\]$') { + $section = $Matches[1] + $sections[$section] = @{} + continue + } + if ($section -and $line -match '^([^=]+)=(.*)$') { + $sections[$section][$Matches[1].Trim()] = $Matches[2].Trim() + } + } + + $result = @() + foreach ($name in $sections.Keys) { + if ($name -notlike 'Profile*') { continue } + $entry = $sections[$name] + if (-not $entry.ContainsKey('Path')) { continue } + + $path = $entry['Path'] -replace '/', '\' + if ($entry.ContainsKey('IsRelative') -and $entry['IsRelative'] -eq '0') { + $full = $path + } else { + $full = Join-Path $firefoxRoot $path + } + + if (-not (Test-Path -LiteralPath $full)) { continue } + + $displayName = $name + if ($entry.ContainsKey('Name')) { $displayName = $entry['Name'] } + + $result += [pscustomobject]@{ + Name = $displayName + Path = $full + IsDefault = ($entry.ContainsKey('Default') -and $entry['Default'] -eq '1') + } + } + + if ($result.Count -eq 0) { + throw "In '$profilesIni' ist kein existierendes Profil eingetragen." + } + return $result +} + +# ------------------------------------------------------------------ XPI pruefen + +function Get-XpiInfo { + param([string]$Path) + + Add-Type -AssemblyName System.IO.Compression.FileSystem | Out-Null + $archive = [System.IO.Compression.ZipFile]::OpenRead($Path) + try { + $signed = $false + foreach ($entry in $archive.Entries) { + if ($entry.FullName -like 'META-INF/*.rsa' -or $entry.FullName -like 'META-INF/*.RSA') { + $signed = $true + } + } + + $manifestEntry = $archive.Entries | Where-Object { $_.FullName -eq 'manifest.json' } | Select-Object -First 1 + if (-not $manifestEntry) { + throw "'$Path' enthaelt keine manifest.json auf oberster Ebene. Beim Packen muss der *Inhalt* des Ordners gezippt werden, nicht der Ordner selbst." + } + + $reader = New-Object System.IO.StreamReader($manifestEntry.Open()) + try { $manifestJson = $reader.ReadToEnd() } finally { $reader.Dispose() } + $manifest = $manifestJson | ConvertFrom-Json + + $id = $null + if ($manifest.PSObject.Properties.Name -contains 'browser_specific_settings') { + $id = $manifest.browser_specific_settings.gecko.id + } elseif ($manifest.PSObject.Properties.Name -contains 'applications') { + $id = $manifest.applications.gecko.id + } + + return [pscustomobject]@{ + Id = $id + Name = $manifest.name + Version = $manifest.version + Signed = $signed + } + } finally { + $archive.Dispose() + } +} + +# ------------------------------------------------------------------ Policy + +function Set-ExtensionPolicy { + param( + [string]$InstallDirectory, + [string]$Id, + [string]$InstallUrl + ) + + $distribution = Join-Path $InstallDirectory 'distribution' + $policyFile = Join-Path $distribution 'policies.json' + + if (-not (Test-Path -LiteralPath $distribution)) { + New-Item -ItemType Directory -Path $distribution -Force | Out-Null + } + + $document = @{} + if (Test-Path -LiteralPath $policyFile) { + Backup-File -Path $policyFile + $raw = Get-Content -LiteralPath $policyFile -Raw + if ($raw.Trim()) { + $document = ConvertTo-HashtableDeep ($raw | ConvertFrom-Json) + } + } + + if (-not ($document -is [hashtable])) { $document = @{} } + if (-not $document.ContainsKey('policies') -or -not ($document['policies'] -is [hashtable])) { + $document['policies'] = @{} + } + $policies = $document['policies'] + + if (-not $policies.ContainsKey('ExtensionSettings') -or -not ($policies['ExtensionSettings'] -is [hashtable])) { + $policies['ExtensionSettings'] = @{} + } + + $policies['ExtensionSettings'][$Id] = @{ + installation_mode = 'force_installed' + install_url = $InstallUrl + updates_disabled = $true + } + + if ($PSCmdlet.ShouldProcess($policyFile, "ExtensionSettings fuer $Id schreiben")) { + Write-JsonFile -Path $policyFile -Data $document + Write-Good "Policy geschrieben: $policyFile" + } +} + +function Remove-ExtensionPolicy { + param([string]$InstallDirectory, [string]$Id) + + $policyFile = Join-Path (Join-Path $InstallDirectory 'distribution') 'policies.json' + if (-not (Test-Path -LiteralPath $policyFile)) { + Write-Info "Keine policies.json vorhanden - nichts zu entfernen." + return + } + + $raw = Get-Content -LiteralPath $policyFile -Raw + if (-not $raw.Trim()) { return } + + $document = ConvertTo-HashtableDeep ($raw | ConvertFrom-Json) + if (-not ($document -is [hashtable]) -or -not $document.ContainsKey('policies')) { return } + + $policies = $document['policies'] + if (-not ($policies -is [hashtable]) -or -not $policies.ContainsKey('ExtensionSettings')) { + Write-Info "Kein ExtensionSettings-Block - nichts zu entfernen." + return + } + + $settings = $policies['ExtensionSettings'] + if (-not $settings.ContainsKey($Id)) { + Write-Info "Kein Eintrag fuer $Id - nichts zu entfernen." + return + } + + Backup-File -Path $policyFile + $settings.Remove($Id) + + # Leere Container nicht stehen lassen. + if ($settings.Count -eq 0) { $policies.Remove('ExtensionSettings') } + + if ($PSCmdlet.ShouldProcess($policyFile, "Eintrag $Id entfernen")) { + if ($policies.Count -eq 0) { + Remove-Item -LiteralPath $policyFile -Force + Write-Good "policies.json enthielt nur diesen Eintrag und wurde geloescht." + } else { + Write-JsonFile -Path $policyFile -Data $document + Write-Good "Eintrag aus $policyFile entfernt." + } + } +} + +# ------------------------------------------------------------------ Signaturpflicht + +function Set-SignatureEnforcement { + param([string]$InstallDirectory, [bool]$Required) + + $prefDirectory = Join-Path $InstallDirectory 'defaults\pref' + $autoconfigJs = Join-Path $prefDirectory 'autoconfig.js' + $configCfg = Join-Path $InstallDirectory 'swyx-tab-bridge.cfg' + + if ($Required) { + foreach ($file in @($autoconfigJs, $configCfg)) { + if (Test-Path -LiteralPath $file) { + Remove-Item -LiteralPath $file -Force + Write-Good "Entfernt: $file" + } + } + return + } + + if (-not (Test-Path -LiteralPath $prefDirectory)) { + New-Item -ItemType Directory -Path $prefDirectory -Force | Out-Null + } + + $autoconfigContent = @' +// Swyx Tab Bridge - autoconfig aktivieren +pref("general.config.filename", "swyx-tab-bridge.cfg"); +pref("general.config.obscure_value", 0); +pref("general.config.sandbox_enabled", false); +'@ + + # Die erste Zeile einer .cfg wird von Firefox immer ignoriert - deshalb der Kommentar. + $configContent = @' +// Swyx Tab Bridge +lockPref("xpinstall.signatures.required", false); +'@ + + if ($PSCmdlet.ShouldProcess($InstallDirectory, "Signaturpruefung per autoconfig abschalten")) { + $ascii = New-Object System.Text.ASCIIEncoding + [System.IO.File]::WriteAllText($autoconfigJs, $autoconfigContent, $ascii) + [System.IO.File]::WriteAllText($configCfg, $configContent, $ascii) + Write-Good "Signaturpruefung abgeschaltet ($configCfg)" + } +} + +# ------------------------------------------------------------------ Firefox-Prozess + +# Stehen mehrere Firefox-Installationen nebeneinander, laufen sie alle als +# firefox.exe. Beruecksichtigt wird deshalb nur, was aus dem Zielverzeichnis +# gestartet wurde - eine daneben laufende zweite Installation bleibt in Ruhe. +function Get-FirefoxProcess { + param([string]$InstallDirectory) + + $all = @(Get-Process -Name 'firefox' -ErrorAction SilentlyContinue) + if (-not $InstallDirectory) { return $all } + + $matching = @() + foreach ($process in $all) { + $path = $null + try { $path = $process.Path } catch { $path = $null } + # Ohne lesbaren Pfad (fremder Benutzer) im Zweifel nicht anfassen. + if ($path -and $path.StartsWith($InstallDirectory, [StringComparison]::OrdinalIgnoreCase)) { + $matching += $process + } + } + return $matching +} + +function Stop-FirefoxProcess { + param([string]$InstallDirectory) + + $running = @(Get-FirefoxProcess -InstallDirectory $InstallDirectory) + if ($running.Count -eq 0) { + Write-Info "Aus diesem Verzeichnis laeuft kein Firefox." + return $false + } + + if ($PSCmdlet.ShouldProcess("firefox.exe ($($running.Count) Prozesse)", "beenden")) { + $running | Stop-Process -Force + Start-Sleep -Seconds 2 + Write-Good "Firefox beendet ($($running.Count) Prozesse) - die Sitzung wird beim naechsten Start wiederhergestellt." + } + return $true +} + +function Test-FirefoxRunning { + param([string]$InstallDirectory) + return @(Get-FirefoxProcess -InstallDirectory $InstallDirectory).Count -gt 0 +} + +# ------------------------------------------------------------------ Ergebnis pruefen + +function Get-InstalledAddonState { + param([string]$ProfilePath, [string]$Id) + + $extensionsJson = Join-Path $ProfilePath 'extensions.json' + if (-not (Test-Path -LiteralPath $extensionsJson)) { return $null } + + try { + $data = Get-Content -LiteralPath $extensionsJson -Raw | ConvertFrom-Json + } catch { + return $null + } + + if (-not ($data.PSObject.Properties.Name -contains 'addons')) { return $null } + + $addon = $data.addons | Where-Object { $_.id -eq $Id } | Select-Object -First 1 + if (-not $addon) { return $null } + + return [pscustomobject]@{ + Version = $addon.version + Active = [bool]$addon.active + Location = $addon.location + } +} + +# ------------------------------------------------------------------ Ablauf + +function Invoke-Install { + param([string]$Xpi) + + Write-Step "Paket pruefen" + if (-not (Test-Path -LiteralPath $Xpi)) { throw "Datei nicht gefunden: $Xpi" } + $Xpi = (Resolve-Path -LiteralPath $Xpi).ProviderPath + + $xpiInfo = Get-XpiInfo -Path $Xpi + Write-Info "Paket: $($xpiInfo.Name) $($xpiInfo.Version)" + Write-Info "ID: $($xpiInfo.Id)" + Write-Info "Signiert: $(if ($xpiInfo.Signed) { 'ja' } else { 'nein' })" + + if ($xpiInfo.Id -and $xpiInfo.Id -ne $ExtensionId) { + throw "ID im Paket ('$($xpiInfo.Id)') passt nicht zu -ExtensionId ('$ExtensionId')." + } + if (-not $xpiInfo.Id) { + throw "Im Manifest fehlt browser_specific_settings.gecko.id - ohne feste ID laesst sich das Add-on nicht per Policy verwalten." + } + + Write-Step "Firefox suchen" + $firefox = Select-FirefoxInstallation -Explicit $FirefoxDir + Write-Info "$($firefox.Product) $($firefox.Version) [$($firefox.Channel)]" + Write-Info "$($firefox.Directory)" + + if (-not $xpiInfo.Signed -and -not $AllowUnsigned) { + throw "Das Paket ist nicht signiert. Firefox installiert es so nicht. Entweder mit 'web-ext sign --channel=unlisted' signieren, oder auf ESR/Developer Edition -AllowUnsigned verwenden." + } + # Nur ESR, Developer Edition und Nightly werten xpinstall.signatures.required + # ueberhaupt aus; Release und Beta erzwingen die Signatur fest im Build. + if ($AllowUnsigned -and @('release', 'beta') -contains $firefox.Channel) { + Write-Note "Achtung: Diese Installation ist Firefox $($firefox.Channel). Dort wird xpinstall.signatures.required ignoriert - ein unsigniertes Add-on laeuft trotz -AllowUnsigned nicht." + } + + if ($AllowUnsigned) { + Write-Step "Signaturpruefung abschalten" + if (-not (Test-DirectoryWritable $firefox.Directory)) { + throw "Kein Schreibrecht auf '$($firefox.Directory)'. -AllowUnsigned erfordert Administratorrechte, wenn Firefox systemweit installiert ist." + } + Set-SignatureEnforcement -InstallDirectory $firefox.Directory -Required $false + $Script:RestartNeeded = $true + } + + if ($Mode -eq 'Policy') { + Write-Step "Installation per Enterprise-Policy" + if (-not (Test-DirectoryWritable $firefox.Directory)) { + throw "Kein Schreibrecht auf '$($firefox.Directory)\distribution'. Bei systemweiter Installation Administratorrechte verwenden, sonst -Mode Profile." + } + + # Bei einer Benutzerinstallation ist %ProgramData% nicht beschreibbar - + # dann liegt das Paket im Benutzerprofil. + if (-not (Test-DirectoryWritable $Script:XpiStore)) { + $Script:XpiStore = Join-Path $env:LOCALAPPDATA 'SwyxTabBridge' + Write-Info "Kein Schreibrecht in ProgramData - Ablage im Benutzerprofil." + } + if (-not (Test-Path -LiteralPath $Script:XpiStore)) { + New-Item -ItemType Directory -Path $Script:XpiStore -Force | Out-Null + } + $target = Join-Path $Script:XpiStore "$ExtensionId.xpi" + Copy-Item -LiteralPath $Xpi -Destination $target -Force + Write-Info "Paket abgelegt: $target" + + Set-ExtensionPolicy -InstallDirectory $firefox.Directory -Id $ExtensionId -InstallUrl (ConvertTo-FileUrl $target) + $Script:RestartNeeded = $true + + } else { + Write-Step "Installation in die Profile von '$TargetUser'" + $profiles = Get-FirefoxProfile -User $TargetUser + + foreach ($profileEntry in $profiles) { + $extensionsDir = Join-Path $profileEntry.Path 'extensions' + if (-not (Test-Path -LiteralPath $extensionsDir)) { + New-Item -ItemType Directory -Path $extensionsDir -Force | Out-Null + } + $target = Join-Path $extensionsDir "$ExtensionId.xpi" + if ($PSCmdlet.ShouldProcess($target, 'Add-on kopieren')) { + Copy-Item -LiteralPath $Xpi -Destination $target -Force + Write-Good "$($profileEntry.Name): $target" + } + } + $Script:RestartNeeded = $true + Write-Note "Firefox fragt beim naechsten Start einmal, ob das Add-on aktiviert werden soll." + } + + Write-Step "Firefox" + if ($StopFirefox) { + Stop-FirefoxProcess -InstallDirectory $firefox.Directory | Out-Null + $Script:RestartNeeded = $false + } elseif (Test-FirefoxRunning -InstallDirectory $firefox.Directory) { + Write-Note "Firefox laeuft. Das Add-on wird erst nach einem Neustart des Browsers aktiv (oder Skript mit -StopFirefox aufrufen)." + } else { + Write-Info "Firefox laeuft nicht - das Add-on wird beim naechsten Start aktiv." + } + + Write-Step "Ergebnis" + Write-Info "Ein Nachweis der aktiven Installation steht erst nach dem naechsten Firefox-Start in extensions.json." + try { + foreach ($profileEntry in (Get-FirefoxProfile -User $TargetUser)) { + $state = Get-InstalledAddonState -ProfilePath $profileEntry.Path -Id $ExtensionId + if ($state) { + Write-Good "$($profileEntry.Name): Version $($state.Version), aktiv=$($state.Active), Quelle=$($state.Location)" + } else { + Write-Info "$($profileEntry.Name): noch nicht eingetragen (erwartet vor dem ersten Start)" + } + } + } catch { + Write-Info "Profilstatus nicht lesbar: $($_.Exception.Message)" + } + + Write-Line + Write-Good "Fertig. Danach im Firefox pruefen: about:addons, und about:policies#active fuer die Policy." + Write-Info "Server-Adresse (Default ws://127.0.0.1:17655) ggf. ueber das Symbolleisten-Icon -> Einstellungen anpassen." +} + +function Invoke-Uninstall { + Write-Step "Firefox suchen" + $firefox = Select-FirefoxInstallation -Explicit $FirefoxDir + Write-Info "$($firefox.Product) $($firefox.Version) - $($firefox.Directory)" + + Write-Step "Policy entfernen" + if (Test-DirectoryWritable $firefox.Directory) { + Remove-ExtensionPolicy -InstallDirectory $firefox.Directory -Id $ExtensionId + Set-SignatureEnforcement -InstallDirectory $firefox.Directory -Required $true + } else { + Write-Note "Kein Schreibrecht auf '$($firefox.Directory)' - Policy bleibt unveraendert." + } + + Write-Step "Hinterlegtes Paket entfernen" + $found = $false + foreach ($store in @($Script:XpiStore, (Join-Path $env:LOCALAPPDATA 'SwyxTabBridge'))) { + $stored = Join-Path $store "$ExtensionId.xpi" + if (Test-Path -LiteralPath $stored) { + Remove-Item -LiteralPath $stored -Force + Write-Good "Geloescht: $stored" + $found = $true + } + } + if (-not $found) { Write-Info "Kein hinterlegtes Paket gefunden." } + + Write-Step "Profil-Kopien entfernen" + try { + foreach ($profileEntry in (Get-FirefoxProfile -User $TargetUser)) { + $target = Join-Path (Join-Path $profileEntry.Path 'extensions') "$ExtensionId.xpi" + if (Test-Path -LiteralPath $target) { + Remove-Item -LiteralPath $target -Force + Write-Good "$($profileEntry.Name): geloescht" + } else { + Write-Info "$($profileEntry.Name): nichts vorhanden" + } + } + } catch { + Write-Info "Profile nicht lesbar: $($_.Exception.Message)" + } + + if ($StopFirefox) { + Stop-FirefoxProcess -InstallDirectory $firefox.Directory | Out-Null + } else { + $Script:RestartNeeded = Test-FirefoxRunning -InstallDirectory $firefox.Directory + } + Write-Line + Write-Good "Deinstallation abgeschlossen." +} + +# ------------------------------------------------------------------ Einstieg + +try { + Write-Line "Swyx Tab Bridge - Windows-Installation" + Write-Info "Rechner: $env:COMPUTERNAME Benutzer: $env:USERNAME Admin: $(if (Test-Administrator) { 'ja' } else { 'nein' })" + + if ($Uninstall) { + Invoke-Uninstall + } else { + if (-not $XpiPath) { throw "-XpiPath fehlt (Pfad zum .xpi auf diesem Rechner)." } + Invoke-Install -Xpi $XpiPath + } + + # Der Exitcode allein traegt nicht weit genug: laeuft das Skript ueber SSH, + # reicht die Login-Shell auf dem Windows-Host alles ausser 0 als 1 durch. + # Deshalb steht das Ergebnis zusaetzlich als Markerzeile in der Ausgabe. + if ($Script:RestartNeeded) { + Write-Note "Firefox muss noch neu gestartet werden." + Write-Line "SWYX-STATUS: RESTART" + exit 2 + } + Write-Line "SWYX-STATUS: OK" + exit 0 + +} catch { + Write-Line + Write-Line "FEHLER: $($_.Exception.Message)" + if ($_.ScriptStackTrace) { Write-Verbose $_.ScriptStackTrace } + Write-Line "SWYX-STATUS: ERROR" + exit 1 +} diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..988cb6b --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,177 @@ +# Windows-Rollout + +Zwei Skripte: + +| Datei | Laeuft auf | Zweck | +|-----------------------------|------------|-----------------------------------------------------------| +| `deploy-windows.sh` | macOS/Linux| Version hochzaehlen, `.xpi` bauen, per SSH ausrollen | +| `Install-SwyxTabBridge.ps1` | Windows | Die eigentliche Installation; auch allein verwendbar | + +## Schnellstart + +```bash +./deploy/deploy-windows.sh +``` + +Ohne Parameter: Version hochzaehlen, bei AMO signieren, auf den Testrechner +uebertragen, Policy schreiben und den laufenden Firefox beenden, damit die +Installation sofort greift. Die Voreinstellungen stehen als Konstanten oben im +Skript: + +| Konstante | Wert | +|--------------------------|-----------------------------------------------| +| `DEFAULT_HOST` | `swyx-dev` (Alias aus `~/.ssh/config`) | +| `DEFAULT_FIREFOX_DIR` | `C:\Users\dev\AppData\Local\Mozilla Firefox` | +| `DEFAULT_SIGN` | `true` | +| `DEFAULT_ALLOW_UNSIGNED` | `false` | +| `DEFAULT_STOP_FIREFOX` | `true` | + +Ziel ist die **Release**-Installation, und die nimmt ausschliesslich signierte +Add-ons — deshalb signiert jeder Lauf. Das kostet pro Aufruf eine Versionsnummer +bei AMO; die wird ohnehin bei jedem Lauf hochgezaehlt. + +Der Firefox-Pfad steht fest, weil auf dem Rechner zusaetzlich die Developer +Edition liegt und die automatische Suche bei zwei Funden abbricht. Fuer einen +anderen Rechner: + +```bash +./deploy/deploy-windows.sh --host admin@ws-042 --firefox-dir auto +``` + +Ein bereits signiertes Paket laesst sich ohne neuen AMO-Durchlauf erneut +ausrollen: + +```bash +./deploy/deploy-windows.sh --xpi build/56806207b7434031914b-1.0.16.xpi --no-bump +``` + +Jeder Aufruf zaehlt die Version in `manifest.json` hoch (Default: Patch-Stelle). +Das ist keine Kosmetik: Firefox installiert ein Paket mit gleicher oder +kleinerer Version nicht erneut, und AMO nimmt eine Version nur einmal zum +Signieren an. Steuern laesst es sich mit `--bump major|minor|patch|build|none`, +`--set-version X.Y.Z` oder `--no-bump`. + +## Die Signatur ist der Knackpunkt + +Firefox **Release und Beta** installieren ausschliesslich signierte Add-ons — +`xpinstall.signatures.required` wird dort ignoriert. Damit bleiben zwei Wege: + +**Signieren (empfohlen, funktioniert ueberall).** Kanal `unlisted` = +Selbst-Hosting ohne Store-Eintrag: + +```bash +./deploy/deploy-windows.sh --firefox-dir auto --require-signed --sign +``` + +Die Zugangsdaten liegen in `deploy/.amo-credentials` (Konto *AppCreation GmbH*) +und werden vom Skript selbst eingelesen. Die Datei traegt `chmod 600`, steht in +`.gitignore` und ist nicht Teil des Pakets — der Schluessel gilt fuer das +**gesamte AMO-Konto**, nicht nur fuer dieses Add-on. Neu erzeugen laesst er sich +unter addons.mozilla.org → *Tools* → *Manage API Keys* → *Revoke and regenerate +credentials*. + +Bereits gesetzte Umgebungsvariablen haben Vorrang, ein einzelner Lauf laesst sich +also uebersteuern: + +```bash +WEB_EXT_API_KEY="user:…" WEB_EXT_API_SECRET="…" ./deploy/deploy-windows.sh --sign +``` + +Die `gecko.id` im Manifest muss ueber alle Versionen stabil bleiben, und AMO nimmt +jede Versionsnummer nur einmal an — dafuer zaehlt das Skript bei jedem Aufruf hoch. + +**Unsigniert (nur ESR, Developer Edition, Nightly).** Mit `--no-sign +--allow-unsigned` hinterlegt das Skript eine autoconfig-Datei, die +`xpinstall.signatures.required` auf `false` sperrt. Auf Firefox Release oder Beta +warnt es, weil die Einstellung dort wirkungslos bleibt. Fuer den normalen Betrieb +wird das nicht gebraucht. + +Das Manifest fuehrt ausserdem `data_collection_permissions` mit +`required: ["browsingActivity"]` — seit dem 3. November 2025 Pflicht fuer neue +Erweiterungen auf AMO. Begruendung siehe Haupt-README. + +## Policy oder Profil + +`--mode policy` (Default) schreibt `\distribution\policies.json` mit +`installation_mode: force_installed`. Das Add-on wird beim naechsten Start ohne +Rueckfrage fuer alle Profile installiert und laesst sich nicht deaktivieren. +Vorhandene Policies bleiben erhalten (die Datei wird zusammengefuehrt und vorher +gesichert). Das `.xpi` landet in `%ProgramData%\SwyxTabBridge\`, bei einer +Benutzerinstallation in `%LOCALAPPDATA%\SwyxTabBridge\`. + +`--mode profile` legt das `.xpi` direkt in `\extensions\` ab. Das wirkt +nur fuer die Profile eines Benutzers, und Firefox fragt beim naechsten Start +einmal nach Bestaetigung. + +Massgeblich fuer die Rechte ist nicht "Admin ja/nein", sondern das Schreibrecht +im Firefox-Verzeichnis: eine Benutzerinstallation unter `%LOCALAPPDATA%\Mozilla +Firefox` laesst sich ohne jedes Sonderrecht mit einer Policy versehen, eine +Installation unter `C:\Program Files` braucht Administratorrechte. + +## Laufender Firefox + +Voreingestellt beendet das Skript den laufenden Firefox, damit die Installation +sofort greift statt erst beim naechsten Neustart; `--keep-firefox` laesst ihn in +Ruhe. Beendet wird dabei nur, was aus dem Zielverzeichnis gestartet wurde — +liegen wie auf dem Testrechner zwei Installationen nebeneinander, laufen beide +als `firefox.exe`, und die nicht adressierte bleibt offen. Firefox stellt die +Sitzung beim naechsten Start wieder her. + +## SSH ohne Passwort + +Der Testrechner ist bereits auf Schluessel-Anmeldung eingerichtet: + +- Schluessel: `~/.ssh/id_ed25519_swyx_deploy` (ohne Passphrase, damit das + Deployment ohne Rueckfrage durchlaeuft) +- `~/.ssh/config` enthaelt den Alias `swyx-dev` mit Benutzer und Schluessel +- Auf dem Windows-Rechner liegt der oeffentliche Schluessel in + `C:\ProgramData\ssh\administrators_authorized_keys` + +Der letzte Punkt ist die uebliche Stolperfalle: fuer Mitglieder der +Administratorengruppe liest der Windows-SSH-Dienst **nicht** +`~/.ssh/authorized_keys`, sondern jene zentrale Datei — und nur dann, wenn deren +ACL auf SYSTEM und Administratoren beschraenkt ist: + +```powershell +icacls C:\ProgramData\ssh\administrators_authorized_keys ` + /inheritance:r /grant "*S-1-5-18:F" /grant "*S-1-5-32-544:F" +``` + +Die SIDs statt der Namen, damit es auf deutschem Windows genauso greift. + +Fuer einen Rechner ohne Schluessel oder mit Jumphost laesst sich ein eigener +Aufrufer davorhaengen: + +```bash +SSH_CMD="sshpass -e ssh" SCP_CMD="sshpass -e scp" SSHPASS='…' \ + ./deploy/deploy-windows.sh --host dev@192.168.180.135 +``` + +Die Kommandos gehen als UTF-16LE-Base64 (`powershell -EncodedCommand`) ueber die +Leitung. Dadurch ist egal, ob auf dem Ziel `cmd.exe` oder PowerShell die +Standard-Shell ist, und es gibt kein Quoting-Problem mit Leerzeichen oder +Backslashes. + +## Weitere Optionen + +``` +--no-sign nicht signieren (nur mit --allow-unsigned sinnvoll) +--keep-firefox laufenden Firefox nicht beenden +--firefox-dir Installationsverzeichnis vorgeben (bei mehreren Installationen noetig) +--target-user Windows-Benutzer fuer --mode profile +--uninstall Policy, hinterlegtes Paket und Profil-Kopien entfernen +--build-only nur bauen +--dry-run nur anzeigen, was passieren wuerde +``` + +Exitcodes von `Install-SwyxTabBridge.ps1`: `0` Erfolg, `1` Fehler, `2` Erfolg, +aber Firefox muss noch neu gestartet werden. + +## Danach pruefen + +- `about:addons` — ist das Add-on da und aktiv? +- `about:policies#active` — hat Firefox die Policy gelesen? +- Symbolleisten-Icon: kein Badge = verbunden, `…` = Verbindungsaufbau, `!` = getrennt. +- Die Server-Adresse (Default `ws://127.0.0.1:17655`) liegt in + `browser.storage.local` und laesst sich von aussen nicht vorbelegen — bei + abweichender Adresse einmal ueber *Einstellungen* im Popup setzen. diff --git a/deploy/deploy-windows.sh b/deploy/deploy-windows.sh new file mode 100755 index 0000000..47d78b2 --- /dev/null +++ b/deploy/deploy-windows.sh @@ -0,0 +1,458 @@ +#!/usr/bin/env bash +# +# Baut das Add-on "Swyx Tab Bridge" und installiert es per SSH auf einem +# Windows-Rechner mit Firefox. +# +# Ablauf: +# 1. Version in manifest.json hochzaehlen (immer, ausser --no-bump) +# 2. .xpi packen, optional per web-ext signieren +# 3. .xpi + Install-SwyxTabBridge.ps1 per scp uebertragen +# 4. Install-SwyxTabBridge.ps1 per ssh mit PowerShell ausfuehren +# +# Beispiele: +# ./deploy/deploy-windows.sh # signieren + ausrollen, ohne Parameter +# ./deploy/deploy-windows.sh --keep-firefox # laufenden Firefox nicht beenden +# ./deploy/deploy-windows.sh --uninstall +# ./deploy/deploy-windows.sh --xpi build/xyz-1.0.16.xpi --no-bump +# # bereits signiertes Paket erneut ausrollen +# ./deploy/deploy-windows.sh --host admin@ws-042 --firefox-dir auto +# # anderer Rechner, Firefox selbst suchen +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SRC_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +MANIFEST="$SRC_DIR/manifest.json" +BUILD_DIR="$SRC_DIR/build" +PS_SCRIPT="$SCRIPT_DIR/Install-SwyxTabBridge.ps1" +EXTENSION_ID="swyx-tab-bridge@appcreation.de" + +# Inhalte des Add-ons - bewusst explizit, damit weder test-server/ noch build/ +# noch .DS_Store im Paket landen. +PACKAGE_FILES=(manifest.json background.js popup.html popup.js options.html options.js icons) + +# Voreinstellungen fuer den Windows-Testrechner. Der Alias 'swyx-dev' steht in +# ~/.ssh/config und bringt Benutzer und Schluessel mit; --host bzw. +# --firefox-dir ueberschreiben das fuer andere Rechner. +DEFAULT_HOST="swyx-dev" + +# Ziel ist die Release-Installation. Sie liegt als Benutzerinstallation unter +# %LOCALAPPDATA% und laesst sich damit ohne Administratorrechte bespielen. Der +# Pfad steht fest, weil auf dem Rechner zusaetzlich die Developer Edition +# installiert ist und die automatische Suche bei zwei Funden abbricht. +DEFAULT_FIREFOX_DIR='C:\Users\dev\AppData\Local\Mozilla Firefox' + +# Firefox Release erzwingt die Signatur fest im Build. Jeder Rollout muss also +# ueber AMO signiert werden, und an der Signaturpruefung wird nichts gedreht. +DEFAULT_SIGN=true +DEFAULT_ALLOW_UNSIGNED=false +# Beendet wird nur, was aus DEFAULT_FIREFOX_DIR gestartet wurde; eine daneben +# laufende zweite Installation bleibt offen. Firefox stellt die Sitzung beim +# naechsten Start wieder her. +DEFAULT_STOP_FIREFOX=true + +HOST="$DEFAULT_HOST" +SSH_PORT="" +MODE="Policy" +BUMP="patch" +FORCED_VERSION="" +XPI_OVERRIDE="" +REMOTE_DIR="swyx-deploy" +DO_SIGN=$DEFAULT_SIGN +ALLOW_UNSIGNED=$DEFAULT_ALLOW_UNSIGNED +STOP_FIREFOX=$DEFAULT_STOP_FIREFOX +UNINSTALL=false +FIREFOX_DIR="$DEFAULT_FIREFOX_DIR" +TARGET_USER="" +DRY_RUN=false +BUILD_ONLY=false + +# ------------------------------------------------------------------ Ausgabe + +c_step() { printf '\n\033[36m==> %s\033[0m\n' "$*"; } +c_info() { printf ' %s\n' "$*"; } +c_good() { printf ' \033[32m%s\033[0m\n' "$*"; } +c_note() { printf ' \033[33m%s\033[0m\n' "$*"; } +c_fail() { printf '\n\033[31mFEHLER: %s\033[0m\n' "$*" >&2; exit 1; } + +usage() { + # Kopfkommentar ab Zeile 3 ausgeben, bis die erste Nicht-Kommentarzeile kommt. + awk 'NR > 2 { if ($0 !~ /^#/) exit; sub(/^# ?/, ""); print }' "${BASH_SOURCE[0]}" + cat <<'EOF' + +Optionen: + --host USER@HOST Ziel-Rechner (Default: swyx-dev aus ~/.ssh/config) + --port N SSH-Port + --mode policy|profile Installationsart (Default: policy; braucht Schreibrecht + im Firefox-Installationsverzeichnis) + --bump LEVEL major | minor | patch | build | none (Default: patch) + --set-version X.Y.Z Version fest vorgeben statt hochzuzaehlen + --no-bump Version unveraendert lassen (Kurzform fuer --bump none) + --xpi DATEI Fertiges (z.B. signiertes) .xpi verwenden, nicht bauen + --sign Per 'web-ext sign --channel=unlisted' bei AMO signieren. + Voreingestellt an, weil Firefox Release nur signierte + Add-ons installiert. Jeder Lauf verbraucht dabei eine + Versionsnummer bei AMO + --no-sign Nicht signieren (nur fuer ESR/Developer Edition sinnvoll, + dann zusammen mit --allow-unsigned) + --allow-unsigned Signaturpflicht auf dem Ziel abschalten (nur ESR/Dev). + Voreingestellt aus + --require-signed Signaturpflicht auf dem Ziel unangetastet lassen (Default) + --stop-firefox Laufenden Firefox auf dem Ziel beenden, damit die + Installation sofort greift. Voreingestellt an + --keep-firefox Laufenden Firefox nicht anfassen + --firefox-dir PFAD Firefox-Installationsverzeichnis auf dem Ziel + (Default: C:\Users\dev\AppData\Local\Mozilla Firefox; + "auto" laesst das Skript selbst suchen) + --target-user NAME Windows-Benutzer fuer --mode profile + --remote-dir NAME Uebertragungsordner im Home des SSH-Benutzers + --uninstall Add-on und Policy auf dem Ziel entfernen + --build-only Nur bauen, nichts uebertragen + --dry-run Nur anzeigen, was passieren wuerde + -h, --help Diese Hilfe + +Umgebungsvariablen: + SSH_CMD / SCP_CMD Eigener Aufrufer statt 'ssh'/'scp', z.B. fuer + Passwort-Login: SSH_CMD="sshpass -e ssh" + SCP_CMD="sshpass -e scp" SSHPASS=geheim ... + WEB_EXT_API_KEY / WEB_EXT_API_SECRET AMO-Zugangsdaten fuer --sign. Werden + sonst aus deploy/.amo-credentials gelesen +EOF + exit 0 +} + +# ------------------------------------------------------------------ Argumente + +while [[ $# -gt 0 ]]; do + case "$1" in + --host) HOST="${2:?}"; shift 2 ;; + --port) SSH_PORT="${2:?}"; shift 2 ;; + --mode) MODE="${2:?}"; shift 2 ;; + --bump) BUMP="${2:?}"; shift 2 ;; + --set-version) FORCED_VERSION="${2:?}"; shift 2 ;; + --no-bump) BUMP="none"; shift ;; + --xpi) XPI_OVERRIDE="${2:?}"; shift 2 ;; + --sign) DO_SIGN=true; shift ;; + --no-sign) DO_SIGN=false; shift ;; + --allow-unsigned) ALLOW_UNSIGNED=true; shift ;; + --require-signed) ALLOW_UNSIGNED=false; shift ;; + --stop-firefox) STOP_FIREFOX=true; shift ;; + --keep-firefox) STOP_FIREFOX=false; shift ;; + --firefox-dir) FIREFOX_DIR="${2:?}"; shift 2 + [[ "$FIREFOX_DIR" == "auto" ]] && FIREFOX_DIR="" ;; + --target-user) TARGET_USER="${2:?}"; shift 2 ;; + --remote-dir) REMOTE_DIR="${2:?}"; shift 2 ;; + --uninstall) UNINSTALL=true; shift ;; + --build-only) BUILD_ONLY=true; shift ;; + --dry-run) DRY_RUN=true; shift ;; + -h|--help) usage ;; + *) c_fail "Unbekannte Option: $1 (--help fuer die Uebersicht)" ;; + esac +done + +case "$(printf '%s' "$MODE" | tr '[:upper:]' '[:lower:]')" in + policy) MODE="Policy" ;; + profile) MODE="Profile" ;; + *) c_fail "--mode muss 'policy' oder 'profile' sein." ;; +esac + +case "$BUMP" in + major|minor|patch|build|none) ;; + *) c_fail "--bump muss major, minor, patch, build oder none sein." ;; +esac + +if ! $BUILD_ONLY && [[ -z "$HOST" ]]; then + c_fail "Kein Ziel-Rechner: --host angeben." +fi + +SSH_OPTS=() +SCP_OPTS=() +if [[ -n "$SSH_PORT" ]]; then + SSH_OPTS+=(-p "$SSH_PORT") + SCP_OPTS+=(-P "$SSH_PORT") +fi + +# AMO-Zugangsdaten fuer --sign. Bereits gesetzte Umgebungsvariablen haben +# Vorrang, damit sich der Schluessel fuer einen einzelnen Lauf uebersteuern laesst. +AMO_CREDENTIALS="$SCRIPT_DIR/.amo-credentials" +if [[ -f "$AMO_CREDENTIALS" ]]; then + if [[ -z "${WEB_EXT_API_KEY:-}" || -z "${WEB_EXT_API_SECRET:-}" ]]; then + set -a + # shellcheck source=/dev/null + . "$AMO_CREDENTIALS" + set +a + fi + # Der Schluessel gilt fuer das ganze AMO-Konto - er hat auf einer + # mitlesbaren Datei nichts verloren. + CRED_PERMS="$(stat -f '%Lp' "$AMO_CREDENTIALS" 2>/dev/null || stat -c '%a' "$AMO_CREDENTIALS" 2>/dev/null || echo '')" + if [[ -n "$CRED_PERMS" && "${CRED_PERMS: -2}" != "00" ]]; then + c_note "$AMO_CREDENTIALS ist auch fuer andere lesbar - 'chmod 600' empfohlen." + fi +fi + +# Normalfall ist Anmeldung per Schluessel. Wer Passwort-Login oder einen +# Jumphost braucht, haengt ueber SSH_CMD/SCP_CMD einen eigenen Aufrufer davor: +# SSH_CMD="sshpass -e ssh" SCP_CMD="sshpass -e scp" SSHPASS=... ./deploy-windows.sh ... +read -r -a SSH_BIN <<< "${SSH_CMD:-ssh}" +read -r -a SCP_BIN <<< "${SCP_CMD:-scp}" + +# ------------------------------------------------------------------ Hilfsfunktionen + +# PowerShell-Kommando als UTF-16LE-Base64 uebergeben. Damit ist voellig egal, +# ob auf dem Ziel cmd.exe oder PowerShell die Standard-Shell ist - es gibt kein +# Quoting-Problem mit Leerzeichen, Backslashes oder Anfuehrungszeichen. +encode_ps() { + printf '%s' "$1" | iconv -f UTF-8 -t UTF-16LE | base64 | tr -d '\n' +} + +run_remote_ps() { + local command="$1" + local encoded + # Ohne das schiebt Windows PowerShell beim Nachladen von Modulen einen + # Progress-Record als CLIXML in den Ausgabestrom. + encoded="$(encode_ps "\$ProgressPreference = 'SilentlyContinue' +$command")" + + if $DRY_RUN; then + c_info "[dry-run] ${SSH_BIN[*]} ${SSH_OPTS[*]:-} $HOST powershell -EncodedCommand <${#encoded} Zeichen>" + c_info "[dry-run] ${command//$'\n'/ }" + return 0 + fi + + "${SSH_BIN[@]}" ${SSH_OPTS[@]+"${SSH_OPTS[@]}"} "$HOST" powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand "$encoded" +} + +# Ein Argument fuer die PowerShell-Kommandozeile in einfache Anfuehrungszeichen setzen. +ps_quote() { + printf "'%s'" "${1//\'/\'\'}" +} + +read_manifest_version() { + python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["version"])' "$MANIFEST" +} + +# Version in manifest.json ersetzen, ohne die Formatierung der Datei anzutasten. +write_manifest_version() { + python3 - "$MANIFEST" "$1" <<'PY' +import re, sys + +path, version = sys.argv[1], sys.argv[2] +with open(path, encoding="utf-8") as handle: + text = handle.read() + +new_text, count = re.subn( + r'("version"\s*:\s*)"[^"]*"', + lambda m: '%s"%s"' % (m.group(1), version), + text, + count=1, +) +if count != 1: + sys.exit("Feld 'version' nicht in %s gefunden." % path) + +with open(path, "w", encoding="utf-8") as handle: + handle.write(new_text) +PY +} + +# Toolkit-Versionen duerfen bis zu vier Zahlen haben (1.0.0.7). 'build' zaehlt +# den vierten Teil hoch und laesst die eigentliche Release-Nummer in Ruhe. +next_version() { + python3 - "$1" "$2" <<'PY' +import sys + +version, level = sys.argv[1], sys.argv[2] +parts = [int(p) for p in version.split(".")] +while len(parts) < 3: + parts.append(0) + +if level == "major": + parts = [parts[0] + 1, 0, 0] +elif level == "minor": + parts = [parts[0], parts[1] + 1, 0] +elif level == "patch": + parts = [parts[0], parts[1], parts[2] + 1] +elif level == "build": + parts = parts[:3] + [(parts[3] if len(parts) > 3 else 0) + 1] + +print(".".join(str(p) for p in parts)) +PY +} + +# ------------------------------------------------------------------ Version + +VERSION="" + +if ! $UNINSTALL && [[ -z "$XPI_OVERRIDE" ]]; then + c_step "Version" + CURRENT="$(read_manifest_version)" + + if [[ -n "$FORCED_VERSION" ]]; then + VERSION="$FORCED_VERSION" + elif [[ "$BUMP" == "none" ]]; then + VERSION="$CURRENT" + else + VERSION="$(next_version "$CURRENT" "$BUMP")" + fi + + if [[ "$VERSION" == "$CURRENT" ]]; then + c_info "unveraendert: $CURRENT" + c_note "Firefox installiert ein Paket mit gleicher oder kleinerer Version nicht erneut." + elif $DRY_RUN; then + c_info "[dry-run] $CURRENT -> $VERSION (manifest.json bliebe unveraendert)" + else + write_manifest_version "$VERSION" + c_good "$CURRENT -> $VERSION (manifest.json aktualisiert)" + fi +fi + +# ------------------------------------------------------------------ Paket bauen + +XPI="" + +if $UNINSTALL; then + : +elif [[ -n "$XPI_OVERRIDE" ]]; then + [[ -f "$XPI_OVERRIDE" ]] || c_fail "Datei nicht gefunden: $XPI_OVERRIDE" + XPI="$(cd "$(dirname "$XPI_OVERRIDE")" && pwd)/$(basename "$XPI_OVERRIDE")" + c_step "Paket" + c_info "verwende vorhandenes Paket: $XPI" +else + c_step "Paket bauen" + for entry in "${PACKAGE_FILES[@]}"; do + [[ -e "$SRC_DIR/$entry" ]] || c_fail "Im Add-on fehlt: $entry" + done + + mkdir -p "$BUILD_DIR" + + if $DO_SIGN; then + # Signieren erzeugt das Paket selbst - ein vorher gebautes Zip waere nur + # eine zweite, unsignierte Datei mit demselben Namen. + if [[ -z "${WEB_EXT_API_KEY:-}" || -z "${WEB_EXT_API_SECRET:-}" ]]; then + c_fail "Keine AMO-Zugangsdaten: WEB_EXT_API_KEY/WEB_EXT_API_SECRET setzen oder in $AMO_CREDENTIALS hinterlegen." + fi + + if $DRY_RUN; then + c_info "[dry-run] npx web-ext sign --channel=unlisted (Version $VERSION)" + XPI="$BUILD_DIR/swyx_tab_bridge-$VERSION.xpi" + else + SIGN_MARKER="$BUILD_DIR/.sign-marker" + : > "$SIGN_MARKER" + + ( cd "$SRC_DIR" && npx --yes web-ext sign \ + --channel=unlisted \ + --source-dir="$SRC_DIR" \ + --artifacts-dir="$BUILD_DIR" \ + --ignore-files 'test-server/**' 'deploy/**' 'build/**' '**/.DS_Store' ) + + # Nur Dateien akzeptieren, die nach dem Start des Signierlaufs entstanden sind. + XPI="$(find "$BUILD_DIR" -maxdepth 1 -name '*.xpi' -newer "$SIGN_MARKER" | head -n1)" + rm -f "$SIGN_MARKER" + [[ -n "$XPI" ]] || c_fail "web-ext hat kein signiertes .xpi in $BUILD_DIR hinterlassen." + c_good "signiert: $XPI" + fi + else + XPI="$BUILD_DIR/swyx_tab_bridge-$VERSION.xpi" + if $DRY_RUN; then + c_info "[dry-run] wuerde bauen: $XPI" + else + rm -f "$XPI" + # -X: keine macOS-Metadaten; gezippt wird der *Inhalt*, nicht der Ordner. + ( cd "$SRC_DIR" && zip -q -r -X "$XPI" "${PACKAGE_FILES[@]}" -x '*.DS_Store' '*/__MACOSX/*' ) + c_good "$XPI ($(du -h "$XPI" | cut -f1 | tr -d ' '))" + fi + if ! $ALLOW_UNSIGNED; then + c_note "Nicht signiert - Firefox Release/Beta verweigert die Installation. Entweder --sign verwenden oder auf ESR/Dev die Signaturpflicht abschalten." + fi + fi +fi + +if $BUILD_ONLY; then + c_step "Fertig" + c_good "${XPI:-kein Paket gebaut}" + exit 0 +fi + +# ------------------------------------------------------------------ Uebertragen + +c_step "Verbindung zu $HOST" +if $DRY_RUN; then + c_info "[dry-run] ssh-Test uebersprungen" +else + # Auch der Test laeuft ueber -EncodedCommand: die Standard-Shell des + # SSH-Benutzers ist unter Windows mal cmd.exe, mal PowerShell, und ein + # blankes $env:COMPUTERNAME wuerde von der aeusseren Shell aufgeloest. + REMOTE_NAME="$(run_remote_ps 'Write-Output "$env:COMPUTERNAME|$($PSVersionTable.PSVersion)"' | tr -d '\r')" \ + || c_fail "SSH-Verbindung zu $HOST fehlgeschlagen." + c_good "erreichbar: ${REMOTE_NAME:-unbekannt}" +fi + +c_step "Dateien uebertragen" +run_remote_ps "New-Item -ItemType Directory -Force -Path \"\$HOME\\$REMOTE_DIR\" | Out-Null" + +if $DRY_RUN; then + c_info "[dry-run] scp $PS_SCRIPT -> $HOST:$REMOTE_DIR/" + [[ -n "$XPI" ]] && c_info "[dry-run] scp $XPI -> $HOST:$REMOTE_DIR/" +else + "${SCP_BIN[@]}" ${SCP_OPTS[@]+"${SCP_OPTS[@]}"} -q "$PS_SCRIPT" "$HOST:$REMOTE_DIR/Install-SwyxTabBridge.ps1" + c_good "Install-SwyxTabBridge.ps1" + if [[ -n "$XPI" ]]; then + "${SCP_BIN[@]}" ${SCP_OPTS[@]+"${SCP_OPTS[@]}"} -q "$XPI" "$HOST:$REMOTE_DIR/$(basename "$XPI")" + c_good "$(basename "$XPI")" + fi +fi + +# ------------------------------------------------------------------ Ausfuehren + +c_step "Installation auf $HOST" + +PS_ARGS="" +add_arg() { PS_ARGS="$PS_ARGS $1"; } + +add_arg "-ExtensionId $(ps_quote "$EXTENSION_ID")" +if $UNINSTALL; then + add_arg "-Uninstall" +else + add_arg "-XpiPath \"\$base\\$(basename "$XPI")\"" + add_arg "-Mode $(ps_quote "$MODE")" +fi +[[ -n "$FIREFOX_DIR" ]] && add_arg "-FirefoxDir $(ps_quote "$FIREFOX_DIR")" +[[ -n "$TARGET_USER" ]] && add_arg "-TargetUser $(ps_quote "$TARGET_USER")" +$ALLOW_UNSIGNED && add_arg "-AllowUnsigned" +$STOP_FIREFOX && add_arg "-StopFirefox" + +REMOTE_COMMAND="\$ErrorActionPreference = 'Stop' +\$ProgressPreference = 'SilentlyContinue' +\$base = Join-Path \$HOME '$REMOTE_DIR' +try { + & (Join-Path \$base 'Install-SwyxTabBridge.ps1')$PS_ARGS + exit \$LASTEXITCODE +} catch { + Write-Host \"FEHLER: \$(\$_.Exception.Message)\" -ForegroundColor Red + exit 1 +}" + +REMOTE_LOG="$(mktemp -t swyx-deploy)" +trap 'rm -f "$REMOTE_LOG"' EXIT + +set +e +run_remote_ps "$REMOTE_COMMAND" 2>&1 | tee "$REMOTE_LOG" +STATUS=${PIPESTATUS[0]} +set -e + +# Die Login-Shell des Windows-SSH-Dienstes reicht nur 0 und "ungleich 0" durch; +# aus 2 wird unterwegs 1. Massgeblich ist deshalb die Markerzeile des Skripts, +# der Exitcode dient nur als Rueckfallebene. +MARKER="$(grep -o 'SWYX-STATUS: [A-Z]*' "$REMOTE_LOG" | tail -n1 | awk '{print $2}')" +[[ -z "$MARKER" && "$STATUS" -eq 0 ]] && MARKER="OK" + +c_step "Ergebnis" +case "$MARKER" in + OK) c_good "Erfolgreich abgeschlossen." ;; + RESTART) c_good "Erfolgreich abgeschlossen." + c_note "Auf $HOST muss Firefox noch neu gestartet werden." ;; + *) c_fail "Das Installationsskript auf $HOST ist abgebrochen (Exitcode $STATUS)." ;; +esac + +if [[ -n "$VERSION" ]]; then + c_info "Ausgerollte Version: $VERSION" +fi diff --git a/icons/icon.svg b/icons/icon.svg new file mode 100644 index 0000000..feae7cf --- /dev/null +++ b/icons/icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..369dabc --- /dev/null +++ b/manifest.json @@ -0,0 +1,45 @@ +{ + "manifest_version": 2, + "name": "Swyx Tab Bridge", + "version": "1.0.22", + "description": "Stellt eine WebSocket-Verbindung zu einem lokalen Server her und erlaubt das Auslesen, Aktivieren und Öffnen von Tabs.", + + "browser_specific_settings": { + "gecko": { + "id": "swyx-tab-bridge@appcreation.de", + "strict_min_version": "115.0", + "data_collection_permissions": { + "required": ["browsingActivity"] + } + } + }, + + "permissions": [ + "tabs", + "storage" + ], + + "background": { + "scripts": ["background.js"], + "persistent": true + }, + + "browser_action": { + "default_title": "Swyx Tab Bridge", + "default_popup": "popup.html", + "default_icon": { + "48": "icons/icon.svg", + "96": "icons/icon.svg" + } + }, + + "options_ui": { + "page": "options.html", + "open_in_tab": false + }, + + "icons": { + "48": "icons/icon.svg", + "96": "icons/icon.svg" + } +} diff --git a/options.html b/options.html new file mode 100644 index 0000000..25c1f8e --- /dev/null +++ b/options.html @@ -0,0 +1,61 @@ + + + + + Swyx Tab Bridge – Einstellungen + + + +
+
+
+
Server
+
seit
+
+
+
+ + + +
Adresse des Swyx-Servers, zu dem sich das Add-on verbindet (ws:// oder wss://).
+ + + +
Das Add-on meldet sich nach dem Verbindungsaufbau immer mit einer + hello-Nachricht an; ist hier ein Shared Secret gesetzt, geht es darin mit.
+ +
+ + +
+ + + + + + diff --git a/options.js b/options.js new file mode 100644 index 0000000..79b50d3 --- /dev/null +++ b/options.js @@ -0,0 +1,96 @@ +"use strict"; + +const DEFAULTS = { + url: "ws://127.0.0.1:17655", + autoConnect: true, + token: "" +}; + +const LABELS = { + connected: "Mit der Tray-App verbunden", + connecting: "Verbindungsaufbau …", + disconnected: "Nicht verbunden" +}; + +const urlEl = document.getElementById("url"); +const tokenEl = document.getElementById("token"); +const autoEl = document.getElementById("autoConnect"); +const savedEl = document.getElementById("saved"); + +const dotEl = document.getElementById("dot"); +const stateEl = document.getElementById("state"); +const statusUrlEl = document.getElementById("statusUrl"); +const sinceEl = document.getElementById("since"); +const statusErrEl = document.getElementById("statusErr"); + +/* ------------------------------------------------------- Verbindungsanzeige */ + +/** Verstrichene Zeit seit dem Verbindungsaufbau, in Worten. */ +function elapsed(since) { + const seconds = Math.max(0, Math.round((Date.now() - since) / 1000)); + if (seconds < 60) return `${seconds} s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes} min`; + const hours = Math.floor(minutes / 60); + return `${hours} h ${minutes % 60} min`; +} + +let lastStatus = null; + +function renderStatus(status) { + if (!status) return; + lastStatus = status; + + dotEl.className = "dot " + status.state; + stateEl.textContent = LABELS[status.state] || status.state; + statusUrlEl.textContent = status.url; + + if (status.state === "connected" && status.connectedSince) { + const time = new Date(status.connectedSince).toLocaleTimeString(); + sinceEl.textContent = `${time} (${elapsed(status.connectedSince)})`; + } else { + sinceEl.textContent = "–"; + } + + // Im verbundenen Zustand ist ein alter Fehlertext nur noch verwirrend. + statusErrEl.textContent = status.state === "connected" ? "" : (status.lastError || ""); +} + +browser.runtime.onMessage.addListener((msg) => { + if (msg && msg.type === "statusChanged") renderStatus(msg.status); +}); + +// Die Dauer laeuft weiter, ohne dass sich der Zustand aendert. +setInterval(() => { + if (lastStatus && lastStatus.state === "connected") renderStatus(lastStatus); +}, 10000); + +browser.runtime.sendMessage({ cmd: "getStatus" }).then(renderStatus); + +/* ------------------------------------------------------------ Einstellungen */ + +async function restore() { + const cfg = await browser.storage.local.get(DEFAULTS); + urlEl.value = cfg.url; + tokenEl.value = cfg.token; + autoEl.checked = !!cfg.autoConnect; +} + +document.getElementById("save").addEventListener("click", async () => { + const url = urlEl.value.trim() || DEFAULTS.url; + if (!/^wss?:\/\//i.test(url)) { + savedEl.style.color = "#d1242f"; + savedEl.textContent = "URL muss mit ws:// oder wss:// beginnen"; + return; + } + await browser.storage.local.set({ + url, + token: tokenEl.value.trim(), + autoConnect: autoEl.checked + }); + savedEl.style.color = "#2ea043"; + savedEl.textContent = "Gespeichert"; + setTimeout(() => (savedEl.textContent = ""), 2000); +}); + +restore(); diff --git a/popup.html b/popup.html new file mode 100644 index 0000000..e04068f --- /dev/null +++ b/popup.html @@ -0,0 +1,36 @@ + + + + + Swyx Tab Bridge + + + +

Swyx Tab Bridge

+
+
+
+
+ + +
+ + + diff --git a/popup.js b/popup.js new file mode 100644 index 0000000..085feef --- /dev/null +++ b/popup.js @@ -0,0 +1,39 @@ +"use strict"; + +const LABELS = { + connected: "Verbunden", + connecting: "Verbinde …", + disconnected: "Getrennt" +}; + +const dot = document.getElementById("dot"); +const stateEl = document.getElementById("state"); +const urlEl = document.getElementById("url"); +const errEl = document.getElementById("err"); +const toggleBtn = document.getElementById("toggle"); + +function render(status) { + if (!status) return; + dot.className = "dot " + status.state; + stateEl.textContent = LABELS[status.state] || status.state; + urlEl.textContent = status.url; + errEl.textContent = status.state === "connected" ? "" : (status.lastError || ""); + toggleBtn.textContent = status.state === "disconnected" ? "Verbinden" : "Trennen"; + toggleBtn.dataset.action = status.state === "disconnected" ? "connect" : "disconnect"; +} + +toggleBtn.addEventListener("click", async () => { + const action = toggleBtn.dataset.action || "connect"; + render(await browser.runtime.sendMessage({ cmd: action })); +}); + +document.getElementById("options").addEventListener("click", () => { + browser.runtime.openOptionsPage(); + window.close(); +}); + +browser.runtime.onMessage.addListener((msg) => { + if (msg && msg.type === "statusChanged") render(msg.status); +}); + +browser.runtime.sendMessage({ cmd: "getStatus" }).then(render); diff --git a/test-server/server.js b/test-server/server.js new file mode 100644 index 0000000..235a2a3 --- /dev/null +++ b/test-server/server.js @@ -0,0 +1,374 @@ +#!/usr/bin/env node +"use strict"; + +/** + * Testserver für das Add-on "Swyx Tab Bridge" – simuliert SwyxTray. + * Ohne externe Abhängigkeiten (RFC 6455, nur Text-Frames). + * + * node test-server/server.js [port] [--log |--no-log] # Default 17655 + * + * Verbindungen werden mit Zeitstempel protokolliert – auf der Konsole und in + * test-server/swyx-tray.log. Das ist zugleich die Vorlage dafuer, was SwyxTray + * mitschreiben sollte: Aufbau, Anmeldung (hello), Abbau samt Dauer. + * + * Im Terminal können Tab-Nachrichten an das Add-on geschickt werden: + * + * list [current] -> {"type":"tab","action":"list"} + * open [background] -> {"type":"tab","action":"open","url":"…"} + * close -> {"type":"tab","action":"close","tabId":42} + * activate -> {"type":"tab","action":"activate","tabId":42} + * focus [titel…] -> {"type":"tab","action":"focus","url":"…"} + * raw {"type":"tab","action":"list"} -> beliebiges JSON (Nachrichten ohne + * type:"tab" ignoriert das Add-on) + * quit + */ + +const http = require("http"); +const crypto = require("crypto"); +const readline = require("readline"); +const fs = require("fs"); +const path = require("path"); + +const GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + +/** Wie lange nach dem Verbindungsaufbau auf die hello-Nachricht gewartet wird. */ +const HELLO_GRACE_MS = 3000; + +const { port: PORT, logFile: LOG_FILE } = parseArgs(process.argv.slice(2)); + +let client = null; // aktuell verbundener Socket +let clientInfo = null; // { since, remote, identity } +let nextId = 1; + +function parseArgs(argv) { + let port = 17655; + let logFile = path.join(__dirname, "swyx-tray.log"); + + for (let i = 0; i < argv.length; i++) { + if (argv[i] === "--log") logFile = argv[++i]; + else if (argv[i] === "--no-log") logFile = null; + else if (/^\d+$/.test(argv[i])) port = Number(argv[i]); + } + return { port, logFile }; +} + +/* --------------------------------------------------- HTTP + Upgrade */ + +const server = http.createServer((req, res) => { + res.writeHead(426, { "Content-Type": "text/plain" }); + res.end("WebSocket only\n"); +}); + +server.on("upgrade", (req, socket) => { + const key = req.headers["sec-websocket-key"]; + if (req.headers.upgrade?.toLowerCase() !== "websocket" || !key) { + socket.destroy(); + return; + } + + const accept = crypto.createHash("sha1").update(key + GUID).digest("base64"); + socket.write( + "HTTP/1.1 101 Switching Protocols\r\n" + + "Upgrade: websocket\r\n" + + "Connection: Upgrade\r\n" + + `Sec-WebSocket-Accept: ${accept}\r\n\r\n` + ); + socket.setNoDelay(true); + + if (client) { + log("Neue Verbindung – alte wird ersetzt."); + try { client.end(); } catch (_) {} + } + + const remote = req.socket.remoteAddress; + client = socket; + clientInfo = { since: Date.now(), remote, identity: null }; + log(`Verbindung geöffnet von ${remote}.`); + + // Ohne hello laesst sich nicht sagen, wer da verbunden ist. Das ist keine + // Fehlersituation – nur bemerkenswert, wenn das Add-on erwartet wurde. + const helloTimer = setTimeout(() => { + if (client === socket && clientInfo && !clientInfo.identity) { + log(`Client ${remote} hat sich nicht angemeldet (kein hello) – unbekannte Gegenstelle.`); + rl.prompt(); + } + }, HELLO_GRACE_MS); + + attachFrameReader(socket, onMessage); + + // Der Socket aus dem HTTP-Upgrade hat allowHalfOpen = true: schickt die + // Gegenstelle ein FIN, feuert ausschliesslich 'end' und niemals 'close'. + // Ohne die eigene Gegenschliessung bliebe die Verbindung hier ewig als + // "verbunden" stehen und Nachrichten gingen ins Leere. + let finished = false; + const finish = (reason) => { + if (finished) return; + finished = true; + clearTimeout(helloTimer); + try { socket.end(); } catch (_) {} + + if (client !== socket) return; // bereits durch eine neue Verbindung ersetzt + const who = clientInfo && clientInfo.identity ? clientInfo.identity : "Unbekannte Gegenstelle"; + const held = clientInfo ? duration(Date.now() - clientInfo.since) : "?"; + log(`Verbindung getrennt: ${who} (Dauer ${held})${reason ? " – " + reason : ""}.`); + client = null; + clientInfo = null; + rl.prompt(); + }; + + socket.on("end", () => finish(null)); + socket.on("close", () => finish(null)); + socket.on("error", (err) => finish(err && err.message ? err.message : "Verbindungsfehler")); +}); + +server.listen(PORT, "127.0.0.1", () => { + log(`WebSocket-Server läuft auf ws://127.0.0.1:${PORT}`); + log(LOG_FILE ? `Protokoll: ${LOG_FILE}` : "Protokoll: nur Konsole (--no-log)"); + log("Kommandos: list [current] | open [background] | close | activate | focus | raw | quit"); + rl.prompt(); +}); + +/* ------------------------------------------------------ Frame-Handling */ + +function attachFrameReader(socket, onText) { + let buffer = Buffer.alloc(0); + + socket.on("data", (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + + for (;;) { + if (buffer.length < 2) return; + + const fin = (buffer[0] & 0x80) !== 0; + const opcode = buffer[0] & 0x0f; + const masked = (buffer[1] & 0x80) !== 0; + let len = buffer[1] & 0x7f; + let offset = 2; + + if (len === 126) { + if (buffer.length < offset + 2) return; + len = buffer.readUInt16BE(offset); + offset += 2; + } else if (len === 127) { + if (buffer.length < offset + 8) return; + const big = buffer.readBigUInt64BE(offset); + if (big > 64n * 1024n * 1024n) { socket.destroy(); return; } + len = Number(big); + offset += 8; + } + + let mask = null; + if (masked) { + if (buffer.length < offset + 4) return; + mask = buffer.subarray(offset, offset + 4); + offset += 4; + } + + if (buffer.length < offset + len) return; + + const payload = Buffer.from(buffer.subarray(offset, offset + len)); + buffer = buffer.subarray(offset + len); + + if (mask) { + for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i & 3]; + } + + switch (opcode) { + case 0x1: // Text + if (!fin) { log("Fragmentierte Frames werden nicht unterstützt."); break; } + onText(payload.toString("utf8")); + break; + case 0x8: // Close + socket.end(encodeFrame(Buffer.alloc(0), 0x8)); + return; + case 0x9: // Ping + socket.write(encodeFrame(payload, 0xa)); + break; + case 0xa: // Pong + break; + default: + break; + } + } + }); +} + +function encodeFrame(payload, opcode = 0x1) { + const len = payload.length; + let header; + if (len < 126) { + header = Buffer.alloc(2); + header[1] = len; + } else if (len < 65536) { + header = Buffer.alloc(4); + header[1] = 126; + header.writeUInt16BE(len, 2); + } else { + header = Buffer.alloc(10); + header[1] = 127; + header.writeBigUInt64BE(BigInt(len), 2); + } + header[0] = 0x80 | opcode; // FIN + Opcode, Serverframes bleiben unmaskiert + return Buffer.concat([header, payload]); +} + +function sendJson(obj) { + if (!client) { + log("Kein Client verbunden."); + return null; + } + client.write(encodeFrame(Buffer.from(JSON.stringify(obj), "utf8"), 0x1)); + return obj.id; +} + +/* -------------------------------------------------- Eingehende Nachrichten */ + +function onMessage(text) { + let msg; + try { + msg = JSON.parse(text); + } catch (_) { + log("Ungültiges JSON empfangen: " + text); + return; + } + + // Antwort des Add-ons auf eine Tab-Nachricht + if (msg.cmd === "tabresult") { + printResult(msg); + rl.prompt(); + return; + } + + // Anmeldung des Add-ons. Erst hierdurch ist die Gegenstelle identifiziert. + if (msg.cmd === "hello") { + const identity = `${msg.client || "unbekannt"} v${msg.version || "?"}`; + if (clientInfo) clientInfo.identity = identity; + + const details = []; + if (Array.isArray(msg.actions)) details.push(`Aktionen: ${msg.actions.join(", ")}`); + details.push(msg.token ? "mit Token" : "ohne Token"); + log(`Angemeldet: ${identity} (${details.join("; ")})`); + + rl.prompt(); + return; + } + + log("Unbekannte Nachricht: " + text); + rl.prompt(); +} + +function printResult(msg) { + const { cmd, id, ok, error, tabs, ...rest } = msg; + + if (ok === false) { + log(`Fehler (#${id}): ${error || "(ohne Text)"}`); + return; + } + + if (Array.isArray(tabs)) { + log(`tabresult #${id}: ${tabs.length} Tab(s)`); + for (const t of tabs) { + console.log(` [${t.id}] ${t.active ? "*" : " "} ${t.title}\n ${t.url}`); + } + return; + } + + log(`tabresult #${id}: ok${Object.keys(rest).length ? " " + JSON.stringify(rest) : ""}`); +} + +/* ------------------------------------------------------------- Konsole */ + +const rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: "> " }); + +rl.on("line", (line) => { + const input = line.trim(); + if (!input) return rl.prompt(); + + const [cmd, ...rest] = input.split(/\s+/); + + switch (cmd) { + case "list": + sendJson({ type: "tab", id: nextId++, action: "list", currentWindowOnly: rest[0] === "current" }); + break; + + case "open": + if (!rest[0]) { log("Verwendung: open [background]"); break; } + sendJson({ type: "tab", id: nextId++, action: "open", url: rest[0], active: rest[1] !== "background" }); + break; + + case "close": { + const tabId = Number(rest[0]); + if (!Number.isInteger(tabId)) { log("Verwendung: close "); break; } + sendJson({ type: "tab", id: nextId++, action: "close", tabId }); + break; + } + + case "activate": { + const tabId = Number(rest[0]); + if (!Number.isInteger(tabId)) { log("Verwendung: activate "); break; } + sendJson({ type: "tab", id: nextId++, action: "activate", tabId }); + break; + } + + case "focus": { + if (!rest[0]) { log("Verwendung: focus [titel…]"); break; } + const request = { type: "tab", id: nextId++, action: "focus", url: rest[0] }; + if (rest.length > 1) request.title = rest.slice(1).join(" "); + sendJson(request); + break; + } + + case "raw": + try { + const obj = JSON.parse(input.slice(4)); + if (obj.id === undefined) obj.id = nextId++; + sendJson(obj); + } catch (err) { + log("Ungültiges JSON: " + err.message); + } + break; + + case "quit": + case "exit": + rl.close(); + return; + + default: + log(`Unbekanntes Kommando: ${cmd}`); + } + rl.prompt(); +}); + +rl.on("close", () => { + try { client?.end(); } catch (_) {} + server.close(); + process.exit(0); +}); + +/** Zeitspanne in Worten, fuer die Verbindungsdauer im Log. */ +function duration(ms) { + const seconds = Math.round(ms / 1000); + if (seconds < 60) return `${seconds} s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes} min ${seconds % 60} s`; + return `${Math.floor(minutes / 60)} h ${minutes % 60} min`; +} + +function timestamp() { + const d = new Date(); + const pad = (n, width = 2) => String(n).padStart(width, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` + + `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`; +} + +function log(msg) { + const line = `[${timestamp()}] ${msg}`; + console.log(line); + if (!LOG_FILE) return; + try { + fs.appendFileSync(LOG_FILE, line + "\n"); + } catch (err) { + // Ein nicht schreibbares Log darf den Dienst nicht anhalten. + console.log(`(Log nicht schreibbar: ${err.message})`); + } +} diff --git a/test-server/test-background.mjs b/test-server/test-background.mjs new file mode 100644 index 0000000..a82b316 --- /dev/null +++ b/test-server/test-background.mjs @@ -0,0 +1,209 @@ +import fs from "node:fs"; +import vm from "node:vm"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +// Prüft die Kommandoverarbeitung von background.js mit gestubbten browser-/WebSocket-APIs. +const SRC = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "background.js"); + +let tabs = [ + { id: 42, windowId: 1, index: 0, title: "Kundenakte Muster GmbH", url: "https://crm.example.local/kunden/4711", active: false, status: "complete" }, + { id: 43, windowId: 1, index: 1, title: "Posteingang", url: "https://mail.example.local/", active: true, status: "complete" }, + { id: 44, windowId: 2, index: 0, title: "Suche", url: "https://www.example.com/?q=1", active: true, status: "complete" } +]; +let nextTabId = 100; +const calls = []; +const broadcasts = []; // browser.runtime.sendMessage aus dem Hintergrundskript +const messageListeners = []; // Empfaenger fuer Popup-/Options-Anfragen + +const browserStub = { + storage: { + local: { get: async (defaults) => ({ ...defaults }), set: async () => {} }, + onChanged: { addListener: () => {} } + }, + runtime: { + getManifest: () => ({ version: "1.0.0" }), + sendMessage: (msg) => { broadcasts.push(msg); return Promise.resolve(); }, + onMessage: { addListener: (fn) => { messageListeners.push(fn); } } + }, + browserAction: { setBadgeText: () => {}, setBadgeBackgroundColor: () => {} }, + tabs: { + query: async (q) => tabs.filter((t) => { + if (q.currentWindow && t.windowId !== 1) return false; + if (q.windowId !== undefined && t.windowId !== q.windowId) return false; + if (q.active && !t.active) return false; + return true; + }), + update: async (id, props) => { + const tab = tabs.find((t) => t.id === id); + if (!tab) throw new Error(`Invalid tab ID: ${id}`); + if (props.active) tabs.forEach((t) => { if (t.windowId === tab.windowId) t.active = t.id === id; }); + calls.push(`tabs.update(${id})`); + return tab; + }, + create: async (props) => { + const tab = { id: nextTabId++, windowId: props.windowId ?? 1, index: tabs.length, title: "", url: props.url, active: props.active !== false, status: "loading" }; + tabs.push(tab); + calls.push(`tabs.create(${props.url})`); + return tab; + }, + remove: async (idOrIds) => { + const ids = Array.isArray(idOrIds) ? idOrIds : [idOrIds]; + for (const id of ids) { + if (!tabs.some((t) => t.id === id)) throw new Error(`Invalid tab ID: ${id}`); + } + tabs = tabs.filter((t) => !ids.includes(t.id)); + calls.push(`tabs.remove(${JSON.stringify(idOrIds)})`); + } + }, + windows: { + getAll: async () => [{ id: 1 }, { id: 2 }], + update: async (id) => { calls.push(`windows.update(${id})`); return { id }; }, + create: async (props) => { + const tab = { id: nextTabId++, windowId: 9, index: 0, title: "", url: props.url, active: true }; + tabs.push(tab); + return { id: 9, tabs: [tab] }; + } + } +}; + +const sent = []; +let sock = null; +class WebSocketStub { + static OPEN = 1; static CONNECTING = 0; + constructor(url) { + this.url = url; this.readyState = 1; this.handlers = {}; sock = this; + queueMicrotask(() => this.fire("open", {})); + } + addEventListener(type, fn) { (this.handlers[type] ||= []).push(fn); } + fire(type, ev) { (this.handlers[type] || []).forEach((fn) => fn(ev)); } + send(data) { sent.push(JSON.parse(data)); } + close() { this.readyState = 3; } +} + +const ctx = vm.createContext({ + browser: browserStub, WebSocket: WebSocketStub, console, + setTimeout, clearTimeout, queueMicrotask, URL, Promise, JSON, Math, Object, Array, Number, String, Error, Map, Set +}); +vm.runInContext(fs.readFileSync(SRC, "utf8"), ctx, { filename: "background.js" }); + +const tick = () => new Promise((r) => setTimeout(r, 30)); +await tick(); + +// Was beim Verbindungsaufbau gesendet wurde, bevor die Kommandotests `sent` leeren. +const handshake = sent.slice(); + +let failures = 0; + +function check(label, ok, detail) { + if (!ok) failures++; + console.log(`${ok ? "✅" : "❌"} ${label}${detail === undefined ? "" : `\n → ${detail}`}`); +} + +/** Fragt den Status so ab, wie Popup und Einstellungsseite es tun. */ +async function getStatus() { + const results = messageListeners.map((fn) => fn({ cmd: "getStatus" })).filter(Boolean); + return await Promise.resolve(results[0]); +} +async function req(message, label, check) { + sent.length = 0; + sock.fire("message", { data: JSON.stringify(message) }); + await tick(); + const reply = sent[0] ?? null; + const ok = check(reply); + if (!ok) failures++; + console.log(`${ok ? "✅" : "❌"} ${label}\n → ${reply === null ? "(keine Antwort)" : JSON.stringify(reply)}`); +} + +/* ---- Anmeldung und Statusanzeige ---- */ + +const hello = handshake.find((m) => m && m.cmd === "hello"); + +check("hello wird ohne Token gesendet", !!hello && !("token" in hello), + hello ? JSON.stringify(hello) : "(nichts gesendet)"); + +check("hello nennt Client, Version und Aktionen", + !!hello && hello.client === "swyx-tab-bridge" && hello.version === "1.0.0" && + Array.isArray(hello.actions) && hello.actions.includes("list")); + +const status = await getStatus(); + +check("Status meldet die Verbindung", status?.state === "connected", + JSON.stringify(status)); + +check("Status nennt den Zeitpunkt des Verbindungsaufbaus", + typeof status?.connectedSince === "number" && status.connectedSince <= Date.now()); + +check("Statuswechsel wird an Popup und Einstellungen gemeldet", + broadcasts.some((m) => m?.type === "statusChanged" && m.status?.state === "connected")); + +/* ---- die drei SwyxTray-Aktionen ---- */ + +await req({ type: "tab", id: 1, action: "list" }, "list", + (r) => r?.cmd === "tabresult" && r.id === 1 && r.ok === true && + r.tabs.length === 3 && + JSON.stringify(r.tabs[0]) === JSON.stringify({ id: 42, title: "Kundenakte Muster GmbH", url: "https://crm.example.local/kunden/4711", active: false })); + +await req({ type: "tab", id: 2, action: "open", url: "https://crm.example.local/kunden/4711" }, "open", + (r) => r?.cmd === "tabresult" && r.id === 2 && r.ok === true && r.tabId === 100); + +await req({ type: "tab", id: 3, action: "close", tabId: 42 }, "close", + (r) => r?.cmd === "tabresult" && r.id === 3 && r.ok === true && JSON.stringify(r) === '{"cmd":"tabresult","id":3,"ok":true}'); + +/* ---- Fehlerfälle ---- */ + +await req({ type: "tab", id: 4, action: "open" }, "open ohne url", + (r) => r?.ok === false && r.error === "Feld 'url' fehlt."); + +await req({ type: "tab", id: 5, action: "open", url: "javascript:alert(1)" }, "open mit javascript: abgewiesen", + (r) => r?.ok === false && r.error === "Protokoll nicht erlaubt: javascript:"); + +await req({ type: "tab", id: 6, action: "close" }, "close ohne tabId", + (r) => r?.ok === false && r.error === "Feld 'tabId' (number) fehlt oder ist ungültig."); + +await req({ type: "tab", id: 7, action: "close", tabId: 9999 }, "close mit unbekannter tabId", + (r) => r?.cmd === "tabresult" && r.id === 7 && r.ok === false && /9999/.test(r.error)); + +await req({ type: "tab", id: 8, action: "foo" }, "unbekannte Aktion", + (r) => r?.ok === false && r.error === "Unbekannte Aktion 'foo'."); + +/* ---- Erweiterungen ---- */ + +await req({ type: "tab", id: 9, action: "activate", tabId: 44 }, "activate", + (r) => r?.ok === true && r.tabId === 44 && calls.includes("windows.update(2)")); + +await req({ type: "tab", id: 10, action: "focus", url: "https://mail.example.local/#posteingang" }, "focus – vorhandener Tab", + (r) => r?.ok === true && r.focused === true && r.opened === false && r.tabId === 43); + +await req({ type: "tab", id: 11, action: "focus", url: "https://crm.example.local/kunden/9999" }, "focus – nicht offen ⇒ neuer Tab", + (r) => r?.ok === true && r.focused === false && r.opened === true && r.tabId >= 100); + +await req({ type: "tab", id: 12, action: "open", url: "https://mail.example.local/", reuse: true }, "open mit reuse", + (r) => r?.ok === true && r.tabId === 43 && r.reused === true); + +await req({ type: "tab", id: 13, action: "list", currentWindowOnly: true }, "list – nur aktuelles Fenster", + (r) => r?.ok === true && r.tabs.length === tabs.filter((t) => t.windowId === 1).length); + +/* ---- Nachrichten, die ignoriert werden ---- */ + +await req({ id: 3, cmd: "call", number: "+49 30 1234567" }, "Telefonie-Nachricht ignoriert", (r) => r === null); +await req({ type: "call", id: 4, action: "answer" }, "type != tab ignoriert", (r) => r === null); +await req({ cmd: "tabresult", id: 1, ok: true }, "eigene Antwort-Struktur ignoriert", (r) => r === null); + +sent.length = 0; +sock.fire("message", { data: "{kein json" }); +await tick(); +const brokenOk = sent.length === 0; +if (!brokenOk) failures++; +console.log(`${brokenOk ? "✅" : "❌"} kaputtes JSON ignoriert\n → ${sent.length ? JSON.stringify(sent[0]) : "(keine Antwort)"}`); + +/* ---- id-Typen ---- */ + +await req({ type: "tab", id: "abc-1", action: "list" }, "id als String wird gespiegelt", + (r) => r?.id === "abc-1" && r.ok === true); + +await req({ type: "tab", action: "list" }, "Anfrage ohne id", + (r) => r?.ok === true && r.id === null); + +console.log(`\n${failures === 0 ? "Alle Tests bestanden." : failures + " Test(s) fehlgeschlagen."}`); +process.exit(failures ? 1 : 0);