# SwyxWeb React-Frontend mit Spring-Boot-Backend. Die Startseite verbindet sich **aus dem Browser heraus** per WebSocket mit der **SwyxTray-App** (`ws://192.168.180.135:17654/ws`), meldet eingehende Anrufe und startet ausgehende Anrufe. ``` SwyxWeb/ ├── backend/ Spring Boot 4.1 (Java 21, Maven Wrapper) └── frontend/ React 19 + TypeScript + Vite ``` ## Wer verbindet sich mit wem? Die WebSocket-Verbindung wird ausschließlich im Browser aufgebaut – in [SwyxTrayClient.ts](frontend/src/swyx/SwyxTrayClient.ts) über `new WebSocket(url)`. Das Backend baut **keine** eigene WebSocket-Verbindung auf; es hat zwei Aufgaben: 1. `GET /api/config` liefert dem Frontend die Zieladresse, damit sie nicht im JS-Bundle fest verdrahtet ist (konfigurierbar in [application.properties](backend/src/main/resources/application.properties)). 2. Es stellt unter seinem eigenen Port einen **SwyxTray-Mock** bereit, um die Startseite ohne echte Telefonanlage testen zu können (siehe unten). Im Normalbetrieb wird er nicht benutzt. **Zwei getrennte Ports:** `8080` ist der Port dieser Anwendung (REST-API und Mock), `17654` der Port der SwyxTray-App auf 192.168.180.135. Sie sind unabhängig voneinander. ## Protokoll der SwyxTray-App Quelle: Mitschnitt gegen die laufende App (Version 1.0.0.0, `protocol: 1`) am 13.08.2026. Seit der Tab-Verwaltung meldet die App im `hello` **`protocol: 6`**; die älteren Nachrichten sind unverändert geblieben. Implementiert in [protocol.ts](frontend/src/swyx/protocol.ts) und [SwyxTrayClient.ts](frontend/src/swyx/SwyxTrayClient.ts). Ein Anruf wird über die **1-basierte Leitungsnummer `line`** identifiziert – so, wie sie auch in SwyxIt! erscheint. Eine Anruf-Id gibt es nicht. > **Die App schickt keine Ereignisnachrichten.** Jede Zustandsänderung – auch ein eingehender > Anruf – kommt als **vollständiger `snapshot`** über alle vier Leitungen. Die Anrufmeldung der > Startseite entsteht deshalb aus dem Vergleich zweier Snapshots > ([mergeSnapshot](frontend/src/swyx/protocol.ts)). ### Begrüßung (App → Seite, beim Verbinden) ```json { "app": "SwyxTray", "version": "1.0.0.0", "protocol": 1, "session": 7, "type": "hello" } ``` Direkt danach folgt der erste Snapshot. ### Snapshot (App → Seite, bei jeder Änderung) ```json { "connected": true, "serverUp": true, "overall": "Ringing", "statusText": "Eingehender Ruf", "user": "Muster, M.", "server": "127.0.0.1", "type": "snapshot", "lines": [ { "line": 1, "state": "LSRinging", "stateCode": 3, "stateText": "klingelt", "peer": "001602107449", "peerNumber": "001602107449", "peerName": "", "busy": true, "selected": true }, { "line": 2, "state": "LSInactive", "stateCode": 0, "stateText": "frei", "peer": "unbekannt", "peerNumber": "", "peerName": "", "busy": false, "selected": false } ] } ``` `state` trägt die Namen der CLMgr-Aufzählung `LineState`, `stateText` den Klartext aus SwyxIt!. Belegt sind bisher `LSInactive / 0 / "frei"` und `LSRinging / 3 / "klingelt"`. [callStateOf](frontend/src/swyx/protocol.ts) bildet beides auf `incoming` · `outgoing` · `connected` ab und wertet dabei **Name und Klartext** aus; ein unbekannter Zustand gilt als **belegt**, damit eine klingelnde Leitung nie stillschweigend verschwindet. `peer` ist die fertige Anzeigeform, `"unbekannt"` der Platzhalter für „keine Gegenstelle". Freie Leitungen schickt die App immer mit; die Startseite blendet sie aus. ### Kommandos (Seite → App) ```jsonc // -> { "id": 7, "cmd": "call", "number": "+49 30 1234567" } // <- Quittung, id gespiegelt { "id": 7, "ok": true, "line": 2, "type": "result" } // <- Fehlerfall (ungültige Nummer, keine Verbindung zu SwyxIt!) { "id": 7, "ok": false, "error": "…", "type": "result" } ``` `ok: true` heißt **nur**, dass CLMgr den Auftrag angenommen hat – gewählt wird asynchron. Der tatsächliche Verlauf kommt danach über die folgenden Snapshots. Die Startseite weist im Wählbereich darauf hin. | Kommando | Felder | Zweck | Antwort | |------------|----------------|-------------------------------------------|-----------------------------| | `call` | `number` | Wählvorgang starten | `line` | | `answer` | `line` | Anruf annehmen | – | | `hangup` | `line` | Anruf beenden/ablehnen | – | | `ping` | – | Verbindungstest | – | | `status` | – | Snapshot anfordern | – | | `focus` | `title`, `url` | Fenster in den Vordergrund holen | `focused` | | `tabs` | – | Offene Tabs des Firefox-Plugins auflisten | `tabs` | | `opentab` | `url` | Neuen Tab öffnen | `tabId` | | `closetab` | `tabId` | Tab schließen | – | Alles andere quittiert die App mit `"Unbekanntes Kommando '…'."`, ein fehlendes `cmd` mit `"Feld 'cmd' fehlt."`. ### Fenster in den Vordergrund holen ```jsonc // -> { "id": 9, "cmd": "focus", "title": "Kundenakte Muster GmbH", "url": "https://crm.example.local/kunden/4711" } // <- Fenster war da und ist jetzt vorn { "type": "result", "id": 9, "ok": true, "focused": true } // <- kein Fenster gefunden, stattdessen Browser mit der URL gestartet { "type": "result", "id": 9, "ok": true, "focused": false } ``` `title` wird als **Teilzeichenkette** gesucht, unabhängig von der Groß-/Kleinschreibung – `"swyxit"` findet also das Fenster „SwyxIt!". `url` ist optional und wird nur benutzt, wenn kein Fenster passt; fehlt sie in diesem Fall, antwortet die App mit `ok: false` und `"Kein Fenster mit Titel '…' gefunden und keine URL angegeben."`. Ein fehlender oder leerer Titel ergibt `"Feld 'title' fehlt."`. Seit der Umstellung des Tab-Bereichs benutzt die Startseite `focus` nicht mehr; das Kommando bleibt aber im Protokoll und in `SwyxTrayClient.focusWindow()` erhalten. ### Tabs des Firefox-Plugins Die drei Tab-Kommandos reicht die SwyxTray-App **unverändert an das Firefox-Plugin durch** – gemeint sind also die Tabs des Firefox auf dem Rechner der App, nicht die Bereiche der Startseite. Es sind dieselben Kanäle, die auch die Tray-Menüpunkte *Lese Tabs*, *Tab öffnen* und *Tab schliessen* benutzen. ```jsonc // -> { "id": 12, "cmd": "tabs" } // <- { "type": "result", "id": 12, "ok": true, "tabs": [ { "id": 43, "title": "Kundenakte", "url": "https://crm.example.local/kunden/4711", "active": true }, { "id": 44, "title": "SwyxWeb", "url": "http://localhost:5173/", "active": false } ] } // -> { "id": 13, "cmd": "opentab", "url": "https://crm.example.local/kunden/4711" } // <- { "type": "result", "id": 13, "ok": true, "tabId": 43 } // -> tabId stammt aus einer tabs-Antwort { "id": 14, "cmd": "closetab", "tabId": 43 } // <- { "type": "result", "id": 14, "ok": true } ``` `url` muss eine **absolute http(s)-Adresse** sein – dieselbe Prüfung wie bei `focus`; sonst antwortet die App mit `ok: false`. Ist **kein Plugin verbunden**, endet jedes der drei Kommandos nach dem Zeitfenster von rund fünf Sekunden ebenfalls mit `ok: false`. Deshalb wartet die Startseite auf diese drei Kommandos 15 statt 10 Sekunden ([SwyxTrayClient.ts](frontend/src/swyx/SwyxTrayClient.ts)). Auch hier gilt: **die App meldet Tab-Änderungen nicht von selbst.** Die Liste wird nach jedem Öffnen und Schließen neu über `tabs` geholt. Alle Regeln sind in [protocol.test.ts](frontend/src/swyx/protocol.test.ts) festgehalten – darunter die wörtlich mitgeschnittene Nachricht eines echten eingehenden Anrufs. ## Funktionen der Startseite Die Seite ist in drei Bereiche aufgeteilt ([Tabs.tsx](frontend/src/components/Tabs.tsx)): | Tab | Inhalt | |---|---| | **Anrufe** | Rufnummer wählen (`call`), belegte Leitungen mit Aktionen und dem Zustand der App (`statusText`, angemeldeter Benutzer, Warnung bei `connected: false`), Verlauf der Anrufereignisse | | **Tabs** | Offene Tabs des Firefox-Plugins anzeigen (`tabs`), einzeln schließen (`closetab`) und eine URL als neuen Tab öffnen (`opentab`); die Liste wird beim ersten Öffnen des Bereichs und nach jeder Änderung geholt, die eingetippte URL übersteht einen Reload (`localStorage`) | | **Verbindung** | Adresse, Verbinden/Trennen, Benachrichtigungen erlauben, Diagnose: Rohnachrichten-Log, Status abfragen, Freitext senden | Die **Meldung eines eingehenden Anrufs** steht bewusst *über* den Tabs – mit Leitungsnummer, Name und Rufnummer, „Annehmen"/„Ablehnen" –, damit sie in keinem Bereich untergeht. Liegt der Browser-Tab im Hintergrund, kommt zusätzlich eine System-Benachrichtigung (nach Erlaubnis). Weiteres: - Der Verlauf entsteht aus dem Vergleich aufeinanderfolgender Snapshots. - Eine Nachricht, die keinem der drei Typen entspricht, wird im Log ausdrücklich als „nicht erkannt" vermerkt statt still verworfen. - Automatischer Reconnect mit exponentiellem Backoff, nur nach unerwartetem Abbruch. ## Starten ### Aus VS Code Im Debug-Panel die Compound-Konfiguration **„SwyxWeb starten (Backend + Frontend)"** wählen ([launch.json](.vscode/launch.json)). Sie startet das Backend im Debugger, danach den Vite-Dev-Server und öffnet Chrome, sobald dieser bereit ist – Breakpoints funktionieren auf beiden Seiten. „Stop" beendet beides zusammen. Alternative **„SwyxWeb starten (lokaler Test gegen SwyxTray-Mock)"**: `/api/config` liefert dann `ws://localhost:8080/ws`, die Startseite spricht also mit dem SwyxTray-Mock. Benötigte Extensions: *Extension Pack for Java* (Backend) und *JavaScript Debugger* (im VS Code enthalten, für Vite und Chrome). ### Backend ```bash cd backend ./mvnw spring-boot:run ``` Läuft auf `http://0.0.0.0:8080`. Achtung: Auf diesem Rechner belegt bereits ein anderes Projekt Port 8080 – dann auf einen freien Port ausweichen: ```bash ./mvnw spring-boot:run -Dspring-boot.run.arguments="--server.port=8081" ``` Der Port der SwyxTray-App (17654) ändert sich dadurch nicht. ### Frontend ```bash cd frontend npm install npm run dev ``` Öffnet `http://localhost:5173`. Der Vite-Dev-Server leitet `/api` an `http://localhost:8080` weiter (überschreibbar per `VITE_BACKEND_URL`). Die WebSocket-Verbindung läuft **nicht** über diesen Proxy – der Browser verbindet sich direkt mit der SwyxTray-App. ## Ohne echte Telefonanlage testen Der [SwyxTrayMockHandler](backend/src/main/java/de/appcreation/swyxweb/websocket/SwyxTrayMockHandler.java) spricht dasselbe Protokoll: `hello` (mit `protocol: 6`) und Snapshot beim Verbinden, Quittungen auf `call`/`answer`/`hangup`/`ping`/`status`/`focus`/`tabs`/`opentab`/`closetab` und nach jeder Änderung einen vollständigen Snapshot über alle vier Leitungen. Einen eingehenden Anruf auslösen: ```bash curl -X POST "http://localhost:8080/api/mock/incoming-call?number=%2B493012345&name=Muster%20GmbH" ``` Für `focus` täuscht der Mock die Fenster **„SwyxIt!"**, **„SwyxWeb – Google Chrome"** und **„Kundenakte Muster GmbH"** vor (Teilzeichenkette, Schreibweise egal). Ein passender Titel ergibt `focused: true`, ein unbekannter mit URL `focused: false`, ein unbekannter ohne URL denselben Fehler wie die echte App. Geöffnet wird natürlich nichts – der Mock protokolliert nur. Für die Tab-Kommandos spielt der Mock das Firefox-Plugin und führt eine Tabliste im Speicher – anfangs **„SwyxWeb"** und **„Kundenakte Muster GmbH"**. `opentab` hängt einen Tab an und liefert dessen `tabId`, `closetab` entfernt ihn; eine unbekannte Kennung ergibt `"Kein Tab mit der Kennung …"`, eine relative Adresse denselben Fehler wie die echte App. Geöffnet wird auch hier nichts. Der Mock bildet den Fall „kein Plugin verbunden" **nicht** nach; er antwortet immer sofort. Die Startseite muss dazu auf `ws://localhost:8080/ws` zeigen – entweder im Adressfeld eintragen oder das Backend mit `--app.websocket.host=localhost --app.websocket.port=8080` starten (macht die VS-Code-Konfiguration „lokaler Test" automatisch). ## Konfiguration der WebSocket-Adresse Es gilt die erste Quelle, die etwas liefert: | Priorität | Quelle | Beispiel | |---|---|---| | 1 | Eingabefeld auf der Startseite | zur Laufzeit änderbar | | 2 | `GET /api/config` vom Backend | `app.websocket.host` in `application.properties` | | 3 | `VITE_WS_URL` aus `frontend/.env` | siehe [.env.example](frontend/.env.example) | | 4 | Default im Code | `ws://192.168.180.135:17654/ws` | Backend-seitig: ```properties server.port=8080 # Port dieser Anwendung app.websocket.host=192.168.180.135 # SwyxTray-App app.websocket.port=17654 app.websocket.path=/ws app.websocket.secure=false # true ⇒ wss:// ``` ## Build und Tests ```bash cd backend && ./mvnw clean package # erzeugt target/backend-0.0.1-SNAPSHOT.jar cd frontend && npm test && npm run build ``` Um das Frontend aus dem Backend auszuliefern, `frontend/dist/*` nach `backend/src/main/resources/static/` kopieren und neu packen. ## Hinweise - **HTTPS-Seite ⇒ `wss://`**: Ein über HTTPS ausgelieferter Browser blockiert unverschlüsselte `ws://`-Verbindungen (Mixed Content). Im Dev-Betrieb über `http://` ist `ws://` in Ordnung. - **CORS/Origin**: Der Mock akzeptiert alle Origins (`setAllowedOriginPatterns("*")`), damit der Vite-Dev-Server auf Port 5173 sich verbinden kann. Für die Produktion einschränken – siehe [WebSocketConfig.java](backend/src/main/java/de/appcreation/swyxweb/config/WebSocketConfig.java) und [CorsConfig.java](backend/src/main/java/de/appcreation/swyxweb/config/CorsConfig.java). - **Firewall**: Port 17654 muss auf 192.168.180.135 eingehend freigegeben sein, sonst scheitert der Verbindungsaufbau ohne aussagekräftige Browser-Fehlermeldung. - **System-Benachrichtigungen** verlangen eine Benutzerinteraktion zur Erlaubniserteilung und funktionieren nur über `https://` oder `localhost`.