first commit

SwyxWeb: React-Frontend und Spring-Boot-Backend mit SwyxTray-Mock.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-20 11:03:34 +02:00
co-authored by Claude Opus 5
commit ae397e1ea3
44 changed files with 6640 additions and 0 deletions
+294
View File
@@ -0,0 +1,294 @@
# 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`.