SwyxWeb: React-Frontend und Spring-Boot-Backend mit SwyxTray-Mock. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
295 lines
14 KiB
Markdown
295 lines
14 KiB
Markdown
# 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`.
|