SwyxWeb: React-Frontend und Spring-Boot-Backend mit SwyxTray-Mock. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
14 KiB
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 über new WebSocket(url).
Das Backend baut keine eigene WebSocket-Verbindung auf; es hat zwei Aufgaben:
GET /api/configliefert dem Frontend die Zieladresse, damit sie nicht im JS-Bundle fest verdrahtet ist (konfigurierbar in application.properties).- 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 und
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).
Begrüßung (App → Seite, beim Verbinden)
{ "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)
{ "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 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)
// ->
{ "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
// ->
{ "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.
// ->
{ "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).
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 festgehalten – darunter die wörtlich mitgeschnittene Nachricht eines echten eingehenden Anrufs.
Funktionen der Startseite
Die Seite ist in drei Bereiche aufgeteilt (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). 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
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:
./mvnw spring-boot:run -Dspring-boot.run.arguments="--server.port=8081"
Der Port der SwyxTray-App (17654) ändert sich dadurch nicht.
Frontend
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
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:
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 |
| 4 | Default im Code | ws://192.168.180.135:17654/ws |
Backend-seitig:
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
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üsseltews://-Verbindungen (Mixed Content). Im Dev-Betrieb überhttp://istws://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 und 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://oderlocalhost.