first commit
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
{"uploadUuid":"5c57e1397beb42a3a30fd0851d79a3c8","channel":"unlisted","xpiCrcHash":"c54af52ce9dee6a2957126b9f369a1ea1c6cacd94e53ed64d985f2852898dc81"}
|
||||||
+11
@@ -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
|
||||||
@@ -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 <url> [background]` | `{"type":"tab","action":"open","url":"…"}` |
|
||||||
|
| `close <tabId>` | `{"type":"tab","action":"close","tabId":42}` |
|
||||||
|
| `activate <tabId>` | `{"type":"tab","action":"activate","tabId":42}` |
|
||||||
|
| `focus <url> [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.
|
||||||
+450
@@ -0,0 +1,450 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Swyx Tab Bridge – Hintergrundskript.
|
||||||
|
*
|
||||||
|
* Das Add-on ist WebSocket-*Client*: Es verbindet sich zu SwyxTray
|
||||||
|
* (Default ws://127.0.0.1:17655). Ein Add-on kann selbst keinen Server-Socket öffnen.
|
||||||
|
*
|
||||||
|
* Protokoll (Tab-Nachrichten, alles andere wird ignoriert):
|
||||||
|
*
|
||||||
|
* -> von SwyxTray : {"type":"tab","id":1,"action":"list"}
|
||||||
|
* <- vom Plugin : {"cmd":"tabresult","id":1,"ok":true,"tabs":[{"id":42,"title":"…","url":"…","active":false}]}
|
||||||
|
*
|
||||||
|
* -> {"type":"tab","id":2,"action":"open","url":"https://crm.example.local/kunden/4711"}
|
||||||
|
* <- {"cmd":"tabresult","id":2,"ok":true,"tabId":44}
|
||||||
|
*
|
||||||
|
* -> {"type":"tab","id":3,"action":"close","tabId":42}
|
||||||
|
* <- {"cmd":"tabresult","id":3,"ok":true}
|
||||||
|
*
|
||||||
|
* Direkt nach dem Verbindungsaufbau meldet sich das Add-on einmal an:
|
||||||
|
*
|
||||||
|
* <- {"cmd":"hello","client":"swyx-tab-bridge","version":"1.0.0","actions":[…]}
|
||||||
|
*
|
||||||
|
* Damit kann die Gegenstelle den Verbindungsaufbau protokollieren; eine Antwort
|
||||||
|
* darauf wird nicht erwartet.
|
||||||
|
*
|
||||||
|
* Fehler: {"cmd":"tabresult","id":…,"ok":false,"error":"…"}
|
||||||
|
* SwyxTray wertet einen Auftrag nach 5 s ohne Antwort als gescheitert – deshalb wird
|
||||||
|
* jede Aktion nach ACTION_TIMEOUT_MS abgebrochen und mit ok:false beantwortet.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DEFAULTS = {
|
||||||
|
url: "ws://127.0.0.1:17655",
|
||||||
|
autoConnect: true,
|
||||||
|
token: ""
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Aktionen, die das Add-on ausführt. */
|
||||||
|
const ACTIONS = ["list", "open", "close", "activate", "focus"];
|
||||||
|
|
||||||
|
/** Antwort muss vor dem 5-Sekunden-Timeout von SwyxTray raus sein. */
|
||||||
|
const ACTION_TIMEOUT_MS = 4000;
|
||||||
|
|
||||||
|
let config = { ...DEFAULTS };
|
||||||
|
let socket = null;
|
||||||
|
let state = "disconnected"; // disconnected | connecting | connected
|
||||||
|
let connectedSince = null; // Zeitpunkt des letzten erfolgreichen Verbindungsaufbaus
|
||||||
|
let lastError = "";
|
||||||
|
let reconnectTimer = null;
|
||||||
|
let reconnectAttempt = 0;
|
||||||
|
let stopped = false; // true = per Popup manuell getrennt, kein Auto-Reconnect
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ Config */
|
||||||
|
|
||||||
|
async function loadConfig() {
|
||||||
|
const stored = await browser.storage.local.get(DEFAULTS);
|
||||||
|
config = { ...DEFAULTS, ...stored };
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
browser.storage.onChanged.addListener((changes, area) => {
|
||||||
|
if (area !== "local") return;
|
||||||
|
|
||||||
|
const urlChanged = changes.url && changes.url.newValue !== config.url;
|
||||||
|
for (const [key, change] of Object.entries(changes)) {
|
||||||
|
if (key in DEFAULTS) config[key] = change.newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bei geänderter URL neu verbinden, damit die Einstellung sofort greift.
|
||||||
|
if (urlChanged && !stopped) {
|
||||||
|
closeSocket();
|
||||||
|
connect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------- Verbindung */
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearTimeout(reconnectTimer);
|
||||||
|
reconnectTimer = null;
|
||||||
|
stopped = false;
|
||||||
|
|
||||||
|
setState("connecting");
|
||||||
|
|
||||||
|
try {
|
||||||
|
socket = new WebSocket(config.url);
|
||||||
|
} catch (err) {
|
||||||
|
lastError = errorText(err);
|
||||||
|
setState("disconnected");
|
||||||
|
scheduleReconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.addEventListener("open", () => {
|
||||||
|
reconnectAttempt = 0;
|
||||||
|
lastError = "";
|
||||||
|
connectedSince = Date.now();
|
||||||
|
setState("connected");
|
||||||
|
|
||||||
|
// Immer senden: erst dadurch kann die Gegenstelle das Add-on von anderen
|
||||||
|
// Clients auf derselben Verbindung unterscheiden und den Verbindungsaufbau
|
||||||
|
// protokollieren. Das Token kommt nur mit, wenn eines konfiguriert ist.
|
||||||
|
const hello = {
|
||||||
|
cmd: "hello",
|
||||||
|
client: "swyx-tab-bridge",
|
||||||
|
version: browser.runtime.getManifest().version,
|
||||||
|
actions: ACTIONS
|
||||||
|
};
|
||||||
|
if (config.token) hello.token = config.token;
|
||||||
|
sendRaw(hello);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener("message", (event) => {
|
||||||
|
handleIncoming(event.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener("error", () => {
|
||||||
|
lastError = "Verbindungsfehler";
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener("close", (event) => {
|
||||||
|
socket = null;
|
||||||
|
if (event && event.code !== 1000 && event.reason) lastError = event.reason;
|
||||||
|
setState("disconnected");
|
||||||
|
scheduleReconnect();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSocket() {
|
||||||
|
if (!socket) return;
|
||||||
|
try {
|
||||||
|
socket.close(1000, "client shutdown");
|
||||||
|
} catch (_) {
|
||||||
|
/* egal */
|
||||||
|
}
|
||||||
|
socket = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnect() {
|
||||||
|
stopped = true;
|
||||||
|
clearTimeout(reconnectTimer);
|
||||||
|
reconnectTimer = null;
|
||||||
|
closeSocket();
|
||||||
|
setState("disconnected");
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleReconnect() {
|
||||||
|
if (stopped || !config.autoConnect || reconnectTimer) return;
|
||||||
|
const delay = Math.min(30000, 1000 * Math.pow(2, reconnectAttempt));
|
||||||
|
reconnectAttempt += 1;
|
||||||
|
reconnectTimer = setTimeout(() => {
|
||||||
|
reconnectTimer = null;
|
||||||
|
connect();
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendRaw(payload) {
|
||||||
|
if (!socket || socket.readyState !== WebSocket.OPEN) return false;
|
||||||
|
try {
|
||||||
|
socket.send(JSON.stringify(payload));
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
lastError = errorText(err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------- Eingehende Nachrichten */
|
||||||
|
|
||||||
|
async function handleIncoming(raw) {
|
||||||
|
let msg;
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(typeof raw === "string" ? raw : String(raw));
|
||||||
|
} catch (_) {
|
||||||
|
console.debug("[Swyx Tab Bridge] Nachricht ist kein gültiges JSON, ignoriert.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nur Tab-Nachrichten sind unsere Zuständigkeit – alles andere (Telefonie,
|
||||||
|
// Status-Events, Antworten an andere Komponenten) wird ignoriert.
|
||||||
|
if (!msg || typeof msg !== "object" || msg.type !== "tab") return;
|
||||||
|
|
||||||
|
const id = msg.id !== undefined ? msg.id : null;
|
||||||
|
const action = typeof msg.action === "string" ? msg.action.trim().toLowerCase() : "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await withTimeout(runAction(action, msg), ACTION_TIMEOUT_MS);
|
||||||
|
sendRaw({ cmd: "tabresult", id, ok: true, ...result });
|
||||||
|
} catch (err) {
|
||||||
|
sendRaw({ cmd: "tabresult", id, ok: false, error: errorText(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAction(action, msg) {
|
||||||
|
switch (action) {
|
||||||
|
case "list":
|
||||||
|
return await listTabs(msg);
|
||||||
|
|
||||||
|
case "open":
|
||||||
|
return await openTab(msg);
|
||||||
|
|
||||||
|
case "close":
|
||||||
|
return await closeTab(msg);
|
||||||
|
|
||||||
|
// Erweiterungen über die drei SwyxTray-Aktionen hinaus:
|
||||||
|
case "activate":
|
||||||
|
return await activateTab(msg);
|
||||||
|
|
||||||
|
case "focus":
|
||||||
|
return await focusTab(msg);
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`Unbekannte Aktion '${msg.action}'.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ Aktionen */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* list – offene Tabs auslesen.
|
||||||
|
* Optional: currentWindowOnly (bool), windowId (number), url/title (Match-Pattern)
|
||||||
|
*/
|
||||||
|
async function listTabs(msg) {
|
||||||
|
const query = {};
|
||||||
|
if (msg.currentWindowOnly) query.currentWindow = true;
|
||||||
|
if (Number.isInteger(msg.windowId)) query.windowId = msg.windowId;
|
||||||
|
if (msg.url) query.url = msg.url; // z.B. "*://*.example.local/*"
|
||||||
|
if (msg.title) query.title = msg.title;
|
||||||
|
|
||||||
|
const tabs = await browser.tabs.query(query);
|
||||||
|
return { tabs: tabs.map(toTabInfo) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* open – Tab mit mitgegebener URL öffnen.
|
||||||
|
* Pflicht: url. Optional: active (default true), reuse (vorhandenen Tab verwenden),
|
||||||
|
* windowId, index, pinned, newWindow
|
||||||
|
*/
|
||||||
|
async function openTab(msg) {
|
||||||
|
const url = typeof msg.url === "string" ? msg.url.trim() : "";
|
||||||
|
if (!url) throw new Error("Feld 'url' fehlt.");
|
||||||
|
assertAllowedUrl(url);
|
||||||
|
|
||||||
|
const active = msg.active !== false;
|
||||||
|
|
||||||
|
// reuse: ist die Seite schon offen, wird sie aktiviert statt doppelt geöffnet.
|
||||||
|
if (msg.reuse) {
|
||||||
|
const existing = (await browser.tabs.query({})).find((t) => urlMatches(t.url, url, msg.match || "prefix"));
|
||||||
|
if (existing) {
|
||||||
|
if (active) {
|
||||||
|
await browser.tabs.update(existing.id, { active: true });
|
||||||
|
await browser.windows.update(existing.windowId, { focused: true });
|
||||||
|
}
|
||||||
|
return { tabId: existing.id, reused: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.newWindow) {
|
||||||
|
const win = await browser.windows.create({ url, focused: active });
|
||||||
|
const tab = win.tabs && win.tabs[0];
|
||||||
|
return { tabId: tab ? tab.id : null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const createProps = { url, active };
|
||||||
|
if (Number.isInteger(msg.windowId)) createProps.windowId = msg.windowId;
|
||||||
|
if (Number.isInteger(msg.index)) createProps.index = msg.index;
|
||||||
|
if (msg.pinned === true) createProps.pinned = true;
|
||||||
|
|
||||||
|
const tab = await browser.tabs.create(createProps);
|
||||||
|
if (active && Number.isInteger(tab.windowId)) {
|
||||||
|
await browser.windows.update(tab.windowId, { focused: true });
|
||||||
|
}
|
||||||
|
return { tabId: tab.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* close – Tab schließen.
|
||||||
|
* Pflicht: tabId (number oder Array von numbers).
|
||||||
|
*/
|
||||||
|
async function closeTab(msg) {
|
||||||
|
const ids = Array.isArray(msg.tabId) ? msg.tabId : [msg.tabId];
|
||||||
|
if (!ids.length || !ids.every((id) => Number.isInteger(id))) {
|
||||||
|
throw new Error("Feld 'tabId' (number) fehlt oder ist ungültig.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.tabs.remove(Array.isArray(msg.tabId) ? ids : ids[0]);
|
||||||
|
return Array.isArray(msg.tabId) ? { closed: ids } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* activate – vorhandenen Tab in den Vordergrund holen.
|
||||||
|
* Pflicht: tabId. Optional: focusWindow (default true)
|
||||||
|
*/
|
||||||
|
async function activateTab(msg) {
|
||||||
|
if (!Number.isInteger(msg.tabId)) throw new Error("Feld 'tabId' (number) fehlt.");
|
||||||
|
|
||||||
|
const tab = await browser.tabs.update(msg.tabId, { active: true });
|
||||||
|
if (msg.focusWindow !== false && Number.isInteger(tab.windowId)) {
|
||||||
|
await browser.windows.update(tab.windowId, { focused: true });
|
||||||
|
}
|
||||||
|
return { tabId: tab.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* focus – passende Seite in den Vordergrund holen, sonst öffnen.
|
||||||
|
* Pflicht: url (oder title). Optional: match ("prefix"|"exact"|"origin"),
|
||||||
|
* open (default true), activate (default true)
|
||||||
|
* Ergebnis: focused = vorhandener Tab aktiviert, opened = neuer Tab angelegt.
|
||||||
|
*/
|
||||||
|
async function focusTab(msg) {
|
||||||
|
const url = typeof msg.url === "string" ? msg.url.trim() : "";
|
||||||
|
const title = typeof msg.title === "string" ? msg.title.trim() : "";
|
||||||
|
if (!url && !title) throw new Error("Feld 'url' oder 'title' fehlt.");
|
||||||
|
if (url) assertAllowedUrl(url);
|
||||||
|
|
||||||
|
const mode = msg.match || "prefix";
|
||||||
|
const activate = msg.activate !== false;
|
||||||
|
const openIfMissing = msg.open !== false;
|
||||||
|
|
||||||
|
const tabs = await browser.tabs.query({});
|
||||||
|
const found = url
|
||||||
|
? tabs.find((t) => urlMatches(t.url, url, mode))
|
||||||
|
: tabs.find((t) => (t.title || "").toLowerCase().includes(title.toLowerCase()));
|
||||||
|
|
||||||
|
if (found) {
|
||||||
|
if (activate) {
|
||||||
|
await browser.tabs.update(found.id, { active: true });
|
||||||
|
await browser.windows.update(found.windowId, { focused: true });
|
||||||
|
}
|
||||||
|
return { focused: true, opened: false, tabId: found.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!openIfMissing || !url) {
|
||||||
|
return { focused: false, opened: false, tabId: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const tab = await browser.tabs.create({ url, active: activate });
|
||||||
|
if (activate && Number.isInteger(tab.windowId)) {
|
||||||
|
await browser.windows.update(tab.windowId, { focused: true });
|
||||||
|
}
|
||||||
|
return { focused: false, opened: true, tabId: tab.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------- Helfer */
|
||||||
|
|
||||||
|
/** Bricht eine Aktion ab, bevor SwyxTray in den 5-Sekunden-Timeout läuft. */
|
||||||
|
function withTimeout(promise, ms) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => reject(new Error("Zeitüberschreitung im Add-on.")), ms);
|
||||||
|
promise.then(
|
||||||
|
(value) => { clearTimeout(timer); resolve(value); },
|
||||||
|
(err) => { clearTimeout(timer); reject(err); }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vergleicht Tab-URL und Ziel-URL; Fragment und abschließende Slashes werden ignoriert. */
|
||||||
|
function urlMatches(tabUrl, target, mode) {
|
||||||
|
if (!tabUrl) return false;
|
||||||
|
|
||||||
|
if (mode === "origin") {
|
||||||
|
try {
|
||||||
|
return new URL(tabUrl).origin === new URL(target).origin;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalize = (u) => u.replace(/#.*$/, "").replace(/\/+$/, "");
|
||||||
|
const a = normalize(tabUrl);
|
||||||
|
const b = normalize(target);
|
||||||
|
|
||||||
|
if (mode === "exact") return a === b;
|
||||||
|
return a === b || a.startsWith(b + "/") || a.startsWith(b + "?");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nur Web-URLs zulassen – kein javascript:, data: o.ä. über die Bridge. */
|
||||||
|
function assertAllowedUrl(url) {
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = new URL(url);
|
||||||
|
} catch (_) {
|
||||||
|
throw new Error(`Ungültige URL: ${url}`);
|
||||||
|
}
|
||||||
|
const allowed = ["http:", "https:", "ftp:", "file:"];
|
||||||
|
if (!allowed.includes(parsed.protocol)) {
|
||||||
|
throw new Error(`Protokoll nicht erlaubt: ${parsed.protocol}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tab-Objekt im Format von SwyxTray. */
|
||||||
|
function toTabInfo(tab) {
|
||||||
|
return {
|
||||||
|
id: tab.id,
|
||||||
|
title: tab.title || "",
|
||||||
|
url: tab.url || "",
|
||||||
|
active: !!tab.active
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorText(err) {
|
||||||
|
return String(err && err.message ? err.message : err);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------- UI-Status */
|
||||||
|
|
||||||
|
function setState(next) {
|
||||||
|
if (next !== "connected") connectedSince = null;
|
||||||
|
state = next;
|
||||||
|
updateBadge();
|
||||||
|
// Schlägt fehl, wenn kein Popup offen ist – bewusst ignoriert.
|
||||||
|
browser.runtime.sendMessage({ type: "statusChanged", status: getStatus() }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBadge() {
|
||||||
|
const text = state === "connected" ? "" : state === "connecting" ? "…" : "!";
|
||||||
|
const color = state === "connected" ? "#2ea043" : state === "connecting" ? "#d29922" : "#d1242f";
|
||||||
|
browser.browserAction.setBadgeText({ text });
|
||||||
|
browser.browserAction.setBadgeBackgroundColor({ color });
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatus() {
|
||||||
|
return { state, url: config.url, autoConnect: config.autoConnect, lastError, stopped, connectedSince };
|
||||||
|
}
|
||||||
|
|
||||||
|
browser.runtime.onMessage.addListener((msg) => {
|
||||||
|
switch (msg && msg.cmd) {
|
||||||
|
case "getStatus":
|
||||||
|
return Promise.resolve(getStatus());
|
||||||
|
case "connect":
|
||||||
|
connect();
|
||||||
|
return Promise.resolve(getStatus());
|
||||||
|
case "disconnect":
|
||||||
|
disconnect();
|
||||||
|
return Promise.resolve(getStatus());
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- Start */
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
await loadConfig();
|
||||||
|
updateBadge();
|
||||||
|
if (config.autoConnect) connect();
|
||||||
|
})();
|
||||||
@@ -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 <FirefoxDir>\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 <Profil>\extensions\<ExtensionId>.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\<Produkt>\<Version>\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
|
||||||
|
}
|
||||||
@@ -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 `<FirefoxDir>\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 `<Profil>\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.
|
||||||
Executable
+458
@@ -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
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||||
|
<rect x="2" y="2" width="60" height="60" rx="12" fill="#1f6feb"/>
|
||||||
|
<rect x="10" y="18" width="20" height="8" rx="3" fill="#ffffff" opacity="0.95"/>
|
||||||
|
<rect x="34" y="18" width="20" height="8" rx="3" fill="#ffffff" opacity="0.55"/>
|
||||||
|
<rect x="10" y="30" width="44" height="24" rx="4" fill="#ffffff" opacity="0.9"/>
|
||||||
|
<circle cx="22" cy="42" r="4" fill="#1f6feb"/>
|
||||||
|
<path d="M28 42 h14" stroke="#1f6feb" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
<circle cx="46" cy="42" r="4" fill="#1f6feb"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 588 B |
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Swyx Tab Bridge – Einstellungen</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: light dark; }
|
||||||
|
body { font: 13px/1.5 system-ui, sans-serif; margin: 0; padding: 16px; max-width: 420px; }
|
||||||
|
label { display: block; margin: 12px 0 4px; font-weight: 600; }
|
||||||
|
input[type="text"] { width: 100%; padding: 5px 6px; font: inherit; box-sizing: border-box; }
|
||||||
|
.hint { font-size: 11px; opacity: .75; margin-top: 3px; }
|
||||||
|
.check { display: flex; align-items: center; gap: 6px; margin-top: 14px; }
|
||||||
|
.check label { margin: 0; font-weight: 400; }
|
||||||
|
button { font: inherit; padding: 5px 12px; margin-top: 16px; cursor: pointer; }
|
||||||
|
#saved { margin-left: 8px; color: #2ea043; font-size: 12px; }
|
||||||
|
|
||||||
|
/* Verbindungsanzeige – gleiche Farbgebung wie das Popup und das Badge */
|
||||||
|
.status {
|
||||||
|
border: 1px solid rgba(128, 128, 128, .35);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
.status .head { display: flex; align-items: center; gap: 8px; font-weight: 600; }
|
||||||
|
.dot { width: 10px; height: 10px; border-radius: 50%; background: #d1242f; flex: none; }
|
||||||
|
.dot.connected { background: #2ea043; }
|
||||||
|
.dot.connecting { background: #d29922; }
|
||||||
|
.status dl { display: grid; grid-template-columns: auto 1fr; gap: 2px 10px; margin: 8px 0 0; }
|
||||||
|
.status dt { opacity: .7; }
|
||||||
|
.status dd { margin: 0; font-family: ui-monospace, monospace; word-break: break-all; }
|
||||||
|
.status .err { color: #d1242f; margin-top: 6px; min-height: 1em; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="status">
|
||||||
|
<div class="head"><span id="dot" class="dot"></span><span id="state">–</span></div>
|
||||||
|
<dl>
|
||||||
|
<dt>Server</dt><dd id="statusUrl">–</dd>
|
||||||
|
<dt>seit</dt><dd id="since">–</dd>
|
||||||
|
</dl>
|
||||||
|
<div class="err" id="statusErr"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label for="url">WebSocket-Server</label>
|
||||||
|
<input type="text" id="url" placeholder="ws://127.0.0.1:17655" spellcheck="false">
|
||||||
|
<div class="hint">Adresse des Swyx-Servers, zu dem sich das Add-on verbindet (ws:// oder wss://).</div>
|
||||||
|
|
||||||
|
<label for="token">Token (optional)</label>
|
||||||
|
<input type="text" id="token" placeholder="Shared Secret" spellcheck="false">
|
||||||
|
<div class="hint">Das Add-on meldet sich nach dem Verbindungsaufbau immer mit einer
|
||||||
|
<code>hello</code>-Nachricht an; ist hier ein Shared Secret gesetzt, geht es darin mit.</div>
|
||||||
|
|
||||||
|
<div class="check">
|
||||||
|
<input type="checkbox" id="autoConnect">
|
||||||
|
<label for="autoConnect">Automatisch verbinden und erneut versuchen</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="save">Speichern</button><span id="saved"></span>
|
||||||
|
|
||||||
|
<script src="options.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+96
@@ -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();
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Swyx Tab Bridge</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: light dark; }
|
||||||
|
body {
|
||||||
|
font: 13px/1.45 system-ui, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px;
|
||||||
|
width: 260px;
|
||||||
|
}
|
||||||
|
h1 { font-size: 13px; margin: 0 0 10px; }
|
||||||
|
.row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||||||
|
.dot { width: 10px; height: 10px; border-radius: 50%; background: #d1242f; flex: none; }
|
||||||
|
.dot.connected { background: #2ea043; }
|
||||||
|
.dot.connecting { background: #d29922; }
|
||||||
|
.url { font-family: ui-monospace, monospace; font-size: 11px; opacity: .8; word-break: break-all; }
|
||||||
|
.err { color: #d1242f; font-size: 11px; min-height: 1em; }
|
||||||
|
button { font: inherit; padding: 4px 10px; cursor: pointer; }
|
||||||
|
.actions { display: flex; gap: 6px; margin-top: 10px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Swyx Tab Bridge</h1>
|
||||||
|
<div class="row"><span id="dot" class="dot"></span><span id="state">–</span></div>
|
||||||
|
<div class="url" id="url"></div>
|
||||||
|
<div class="err" id="err"></div>
|
||||||
|
<div class="actions">
|
||||||
|
<button id="toggle">Verbinden</button>
|
||||||
|
<button id="options">Einstellungen</button>
|
||||||
|
</div>
|
||||||
|
<script src="popup.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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);
|
||||||
@@ -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 <datei>|--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 <url> [background] -> {"type":"tab","action":"open","url":"…"}
|
||||||
|
* close <tabId> -> {"type":"tab","action":"close","tabId":42}
|
||||||
|
* activate <tabId> -> {"type":"tab","action":"activate","tabId":42}
|
||||||
|
* focus <url> [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 <url> [background] | close <id> | activate <id> | focus <url> | raw <json> | 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 <url> [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 <tabId>"); break; }
|
||||||
|
sendJson({ type: "tab", id: nextId++, action: "close", tabId });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "activate": {
|
||||||
|
const tabId = Number(rest[0]);
|
||||||
|
if (!Number.isInteger(tabId)) { log("Verwendung: activate <tabId>"); break; }
|
||||||
|
sendJson({ type: "tab", id: nextId++, action: "activate", tabId });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "focus": {
|
||||||
|
if (!rest[0]) { log("Verwendung: focus <url> [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})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user