Die feste Adresse 192.168.180.135 der SwyxTray-App wird überall durch 127.0.0.1 ersetzt: Die App läuft auf dem Arbeitsplatz des Anwenders, die Verbindung bleibt damit auf dem Rechner und braucht keine Firewall-Freigabe. Scheitert der Verbindungsaufbau, weil eine über HTTPS ausgelieferte Seite kein unverschlüsseltes ws:// öffnen darf, meldet der Browser nur "The operation is insecure." – describeConnectFailure() benennt stattdessen den Grund und den Ausweg (wss:// oder Loopback). node_modules/ wird nun auf jeder Ebene ignoriert, nicht nur unter frontend/. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
580 lines
29 KiB
Markdown
580 lines
29 KiB
Markdown
# SwyxWeb
|
||
|
||
React-Frontend mit Spring-Boot-Backend. Die Startseite verbindet sich **aus dem Browser heraus**
|
||
per WebSocket mit der **SwyxTray-App** (`ws://127.0.0.1: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 nimmt unter `POST /api/webhook` **Adressdaten fremder Systeme** entgegen und reicht sie
|
||
an die offenen Browser weiter (siehe [Webhook](#webhook-für-adressdaten-fremder-systeme)).
|
||
3. Es stellt unter seinem eigenen Port einen **SwyxTray-Mock** bereit, um die Startseite ohne
|
||
echte Telefonanlage testen zu können (siehe unten). Der läuft **nur mit dem Spring-Profil
|
||
`mock`**; im Normalbetrieb – und damit auch im Container – gibt es ihn nicht.
|
||
|
||
**Zwei getrennte Ports:** `8080` ist der Port dieser Anwendung (REST-API und, mit Profil `mock`, der Mock),
|
||
`17654` der Port der SwyxTray-App auf dem Arbeitsplatz des Anwenders. 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.
|
||
Mit der Tab-Verwaltung meldete die App im `hello` `protocol: 6`, mit der Adresssuche
|
||
`protocol: 7`, mit dem Adress-Cache **`protocol: 8`**; 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 | – |
|
||
| `contacts` | `query` | Adressdaten des Swyx-Clients durchsuchen | `contacts` |
|
||
| `addresses`| – | Adress-Cache der App am Stück abrufen | `addresses` |
|
||
|
||
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.
|
||
|
||
### Adressdaten des Swyx-Clients
|
||
|
||
Seit Protokoll 7 gibt die App das globale Telefonbuch der Anlage heraus – dieselben
|
||
Einträge, die SwyxIt! im Adressbuch zeigt.
|
||
|
||
```jsonc
|
||
// ->
|
||
{ "id": 20, "cmd": "contacts", "query": "abt" }
|
||
// <-
|
||
{ "type": "result", "id": 20, "ok": true, "contacts": [
|
||
{ "name": "Abt, Bettina", "number": "7587", "description": "S-SB" },
|
||
{ "name": "Abdul, Rokhsareh", "number": "5215", "description": "" } ] }
|
||
```
|
||
|
||
Wichtig für die Anzeige – alles gegen die laufende App sondiert:
|
||
|
||
- **`query` ist Pflicht.** Ein fehlender, leerer oder nur aus Leerzeichen bestehender
|
||
Begriff und einer über 128 Zeichen ergeben dieselbe Meldung:
|
||
`"Feld 'query' fehlt oder ist zu lang (max. 128 Zeichen)."` Ein **vollständiges Auflisten
|
||
gibt es nicht**; der Bereich der Startseite ist deshalb eine Suche.
|
||
- Gesucht wird als **Teilzeichenkette in Name und Rufnummer**, Groß-/Kleinschreibung egal.
|
||
Die `description` (Standortkürzel wie „HH-MT") wird **nicht** durchsucht.
|
||
- Sortiert nach Namen, **höchstens 100 Treffer** – gekürzt wird stillschweigend, es gibt
|
||
kein Feld dafür. Ein `limit` im Kommando ignoriert die App.
|
||
- `description` ist bei vielen Einträgen `""`. Namen sind **nicht eindeutig**: dieselbe
|
||
Person kommt mit mehreren Durchwahlen vor.
|
||
- `query` muss eine **Zeichenkette** sein; eine Zahl quittiert die App mit
|
||
`"Ungueltiges JSON."` – und zwar **ohne `id`**, die Antwort lässt sich also keinem
|
||
Kommando zuordnen. `SwyxTrayClient.searchContacts()` schickt deshalb immer Text.
|
||
|
||
Die `number` ist die Durchwahl in der Form, die `call` direkt annimmt – die Einträge im
|
||
Bereich „Adressdaten" haben darum einen „Anrufen"-Knopf.
|
||
|
||
### Adress-Cache (Protokoll 8)
|
||
|
||
Seit Protokoll 8 hält die App die Adressdaten selbst vor und gibt sie **am Stück** heraus –
|
||
ohne Suchbegriff und ohne Deckel. Das ersetzt im Normalfall die 110 Einzelabfragen des
|
||
nächsten Abschnitts.
|
||
|
||
```jsonc
|
||
// ->
|
||
{ "id": 5, "cmd": "addresses" }
|
||
// <-
|
||
{ "type": "result", "id": 5, "ok": true,
|
||
"addresses": [ { "name": "Muster GmbH", "number": "+493012345",
|
||
"description": "Globales Telefonbuch" } ] }
|
||
```
|
||
|
||
Dieselben Daten schickt die App außerdem **unaufgefordert**, direkt nach dem ersten Snapshot
|
||
und wenn sich ihr Cache ändert:
|
||
|
||
```json
|
||
{ "type": "addresses", "addresses": [ … ] }
|
||
```
|
||
|
||
Diese Push-Nachricht ist die **einzige Nachricht der App ohne `id` und ohne `ok`** – sie ist
|
||
damit keine Quittung und wird von [parseAddresses](frontend/src/swyx/protocol.ts) gelesen,
|
||
nicht von `parseResult`. Die Einträge tragen dieselben Felder wie bei `contacts`.
|
||
|
||
Ein **leeres** `addresses` ist ein gültiger Zustand und heißt „Cache ist leer" – nicht
|
||
„keine Antwort". Stand 21.08.2026 meldet die Anlage genau das: das Kommando wird mit
|
||
`ok: true` beantwortet, der Cache ist aber leer, und der Push beim Verbinden trägt `[]`.
|
||
Deshalb bleiben die Einzelabfragen als Rückfallebene bestehen.
|
||
|
||
### Rückfallebene: den Gesamtbestand aus Einzelabfragen holen
|
||
|
||
Kennt die App das Kommando `addresses` nicht oder ist ihr Cache leer, setzt
|
||
[directory.ts](frontend/src/swyx/directory.ts) den Bestand aus vielen `contacts`-Abfragen
|
||
zusammen. Der Trick steckt in der Wahl der Suchbegriffe:
|
||
|
||
> Jeder Eintrag hat eine Rufnummer, und die ist eine **reine Ziffernfolge**. Eine Nummer mit
|
||
> mindestens zwei Ziffern enthält deshalb mindestens eines der hundert Paare `"00"`…`"99"`.
|
||
> Die Vereinigung dieser hundert Abfragen ist damit **nachweislich vollständig** – solange
|
||
> keine davon am Deckel hängt.
|
||
|
||
Dazu kommen die zehn einstelligen Abfragen `"0"`…`"9"`: eine einstellige Rufnummer (etwa `0`
|
||
für eine Zentrale) käme in keinem Paar vor. Macht **110 Abfragen**.
|
||
|
||
Gegen die Anlage gemessen (21.08.2026):
|
||
|
||
| | |
|
||
|---|---|
|
||
| Einträge insgesamt | 404 |
|
||
| Abfragen | 110 (keine Verfeinerung nötig) |
|
||
| größte Trefferzahl eines Ziffernpaars | 59 – also weit unter dem Deckel von 100 |
|
||
| einstellige Abfragen am Deckel | 7 von 10 (deshalb genügen sie allein nicht) |
|
||
| Dauer | rund 6,7 s |
|
||
| Rufnummern | durchgehend 4- bis 6-stellig, rein numerisch |
|
||
|
||
Die App beantwortet die Abfragen **nacheinander**; mehrere gleichzeitig zu schicken bringt
|
||
kaum etwas (110 Abfragen: 7,2 s einzeln, 6,3 s zu viert). Deshalb der Wartedialog.
|
||
|
||
**Was passiert, wenn doch eine Abfrage gekürzt wird?** Dann verfeinert der Durchlauf sie –
|
||
vorn *und* hinten je eine Ziffer angehängt, denn eine Teilzeichenkette kann an beiden Enden
|
||
weitergehen. Das erfasst jeden Eintrag, dessen Rufnummer **länger** ist als die gekappte
|
||
Abfrage. Bleibt eine Abfrage auch dreistellig noch gekürzt, meldet der Bereich den Bestand
|
||
ausdrücklich als unvollständig, statt eine gekürzte Liste als vollständig auszugeben.
|
||
|
||
Die eine verbleibende Lücke, offen benannt: Ein Eintrag, dessen Rufnummer **genau** einer
|
||
gekappten Abfrage entspricht, ließe sich nicht nachladen. Bei vier- bis sechsstelligen
|
||
Rufnummern betrifft das nur den Fall einer einstelligen Rufnummer bei gleichzeitig gekappter
|
||
einstelliger Abfrage.
|
||
|
||
Die Regeln des Protokolls stehen in [protocol.test.ts](frontend/src/swyx/protocol.test.ts),
|
||
die des Gesamtabrufs in [directory.test.ts](frontend/src/swyx/directory.test.ts) –
|
||
darunter die wörtlich mitgeschnittene Nachricht eines echten eingehenden Anrufs.
|
||
|
||
## Funktionen der Startseite
|
||
|
||
Die Seite ist in fünf 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 |
|
||
| **Adressdaten** | Der **gesamte** Bestand des Telefonbuchs. Zuerst aus dem Adress-Cache, den die App beim Verbinden von selbst schickt – dann steht er sofort und ohne Wartedialog. Sonst über `addresses`, und erst wenn auch das nichts liefert, über rund 110 Einzelabfragen hinter einem Wartedialog mit Fortschritt und „Abbrechen". Die Anzeige nennt die Herkunft. Danach liegt alles im Browser: das Filtern über Name, Durchwahl **und** Standortkürzel läuft ohne weitere Abfrage. Ein Klick auf „Anrufen" wählt die Durchwahl (`call`) |
|
||
| **Webhook** | Was ein fremdes System an `POST /api/webhook` geschickt hat – jüngste Nachricht zuerst, jede mit ihrem JSON im Original. Die Seite hört über Server-Sent Events mit, **unabhängig vom geöffneten Bereich**: Eine Nachricht erscheint sofort, und ist gerade ein anderer Bereich offen, zeigt der Tab die Zahl der ungesehenen Nachrichten |
|
||
| **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.
|
||
|
||
## Webhook für Adressdaten fremder Systeme
|
||
|
||
Ein anderes System kann Adressdaten per POST an die Anwendung schicken; die Startseite zeigt
|
||
sie im Bereich **Webhook** an. Anders als alles Übrige läuft das **nicht** über die SwyxTray-App,
|
||
sondern allein über das Backend.
|
||
|
||
```bash
|
||
curl -X POST https://swyxweb.appcreation.de/api/webhook \
|
||
-H "Content-Type: application/json" \
|
||
-H "X-Webhook-Token: …" \
|
||
-d '{"name":"Muster GmbH","number":"+493012345","description":"CRM-Import"}'
|
||
```
|
||
|
||
Die Quittung nennt die vergebene Nummer und die Eingangszeit:
|
||
|
||
```json
|
||
{ "id": 1, "receivedAt": "2026-08-24T12:38:01.563742294Z" }
|
||
```
|
||
|
||
**Angenommen wird beliebiges JSON** – Objekt wie Liste. Das aufrufende System muss sich an kein
|
||
Schema halten; die Startseite zeigt die Nutzlast unverändert. Nur die Kopfzeile eines Eintrags
|
||
fasst `name` und `number` zusammen, wenn es sie gibt, und zählt sonst Felder bzw. Einträge
|
||
([summarize](frontend/src/webhook.ts)).
|
||
|
||
| Endpunkt | Zweck |
|
||
|---|---|
|
||
| `POST /api/webhook` | Nachricht abliefern. `415` bei falschem Content-Type, `400` bei kaputtem JSON, `413` über der Größengrenze, `401` bei falschem Token |
|
||
| `GET /api/webhook/events` | Strom für die Startseite (Server-Sent Events) |
|
||
| `GET /api/webhook/history` | Verlauf, damit ein später geöffneter Bereich die vorigen Nachrichten sieht |
|
||
| `DELETE /api/webhook/history` | Verlauf leeren – das macht der Knopf „Leeren" |
|
||
|
||
### Wie die Nachricht in den Browser kommt
|
||
|
||
Über **Server-Sent Events**, nicht über den WebSocket: Der gehört der SwyxTray-App. Die
|
||
Startseite hält eine `EventSource` auf `/api/webhook/events` offen
|
||
([useWebhook.ts](frontend/src/hooks/useWebhook.ts)) – einseitig, über dieselbe HTTPS-Verbindung
|
||
wie die Seite, und der Browser baut sie nach einem Abbruch von selbst wieder auf.
|
||
|
||
Zwei Vorkehrungen, damit dabei nichts verloren geht:
|
||
|
||
- Das Backend hält die letzten **50 Nachrichten** im Speicher ([WebhookService](backend/src/main/java/de/appcreation/swyxweb/webhook/WebhookService.java)).
|
||
Beim Wiederverbinden schickt der Browser die `Last-Event-ID` mit, und alles Jüngere wird
|
||
nachgeliefert. Über einen **Neustart hinaus wird nichts aufgehoben**.
|
||
- Alle 25 Sekunden geht ein Kommentar (`:ping`) durch die Leitung, damit ein Reverse Proxy die
|
||
ruhende Verbindung nicht kappt.
|
||
|
||
Die Antwort trägt außerdem `X-Accel-Buffering: no` und `Cache-Control: no-cache`. **Ohne den
|
||
ersten Kopf sammelt nginx die Ereignisse in seinem Puffer** und gibt sie erst später aus – die
|
||
Nachricht erschiene dann verspätet oder scheinbar gar nicht. nginx wertet ihn aus, andere Proxys
|
||
ignorieren ihn folgenlos. Steht vor der Anwendung etwas anderes als nginx, muss dort die
|
||
Pufferung für `/api/webhook/events` abgeschaltet sein (bei Apache `mod_proxy`: `flushpackets=on`).
|
||
|
||
Doppelte Nachrichten sind dadurch normal – der Bereich holt beim Start den Verlauf *und* hört
|
||
auf den Strom. Entschieden wird über die `id` ([mergeEvent](frontend/src/webhook.ts)).
|
||
|
||
### Token
|
||
|
||
```properties
|
||
app.webhook.token= # leer ⇒ jeder Aufruf wird angenommen
|
||
app.webhook.history=50 # so viele Nachrichten bleiben im Speicher
|
||
app.webhook.max-size=262144 # größte Nutzlast in Byte
|
||
```
|
||
|
||
> **Für die öffentlich erreichbare Instanz gehört hier ein Geheimnis hinein.** `/api/webhook`
|
||
> liegt unter derselben Adresse wie die Startseite und ist damit aus dem Internet erreichbar;
|
||
> ohne Token kann jeder beliebiges JSON in die Anzeige schreiben. Ist der Wert leer, weist das
|
||
> Backend beim Start ausdrücklich darauf hin (`WARN`).
|
||
|
||
Im Container:
|
||
|
||
```bash
|
||
docker run -d -p 8080:8080 -e APP_WEBHOOK_TOKEN=… … gitea.appcreation.de/sven/swyxweb:0.9.13
|
||
```
|
||
|
||
Das Token gilt nur für `POST`. Verlauf und Ereignisstrom bleiben offen – sie liefern dasselbe,
|
||
was die Seite ohnehin jedem Betrachter zeigt.
|
||
|
||
## 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)"**: startet das Backend mit dem
|
||
Profil `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 Mock ist Opt-in.** Sein WebSocket-Endpunkt `/ws` und seine Steuer-Endpunkte
|
||
> `/api/mock/**` hängen am Spring-Profil **`mock`**
|
||
> ([MockProfile](backend/src/main/java/de/appcreation/swyxweb/config/MockProfile.java)) –
|
||
> ohne das Profil existieren die Bohnen nicht und beide Pfade antworten mit 404. So kann der
|
||
> Mock nicht versehentlich in einer Produktivumgebung mitlaufen; das Container-Image setzt das
|
||
> Profil bewusst nicht. Einschalten:
|
||
>
|
||
> ```bash
|
||
> cd backend && ./mvnw spring-boot:run -Dspring-boot.run.profiles=mock
|
||
> java -jar app.jar --spring.profiles.active=mock # oder SPRING_PROFILES_ACTIVE=mock
|
||
> ```
|
||
>
|
||
> Abgesichert ist beides durch je einen Test in
|
||
> [BackendApplicationTests](backend/src/test/java/de/appcreation/swyxweb/BackendApplicationTests.java)
|
||
> (ohne Profil keine Mock-Bohne) und
|
||
> [MockProfileTests](backend/src/test/java/de/appcreation/swyxweb/MockProfileTests.java)
|
||
> (mit Profil alle da).
|
||
|
||
Der [SwyxTrayMockHandler](backend/src/main/java/de/appcreation/swyxweb/websocket/SwyxTrayMockHandler.java)
|
||
spricht dasselbe Protokoll: `hello` (mit `protocol: 8`), Snapshot und Adress-Cache beim
|
||
Verbinden, Quittungen auf
|
||
`call`/`answer`/`hangup`/`ping`/`status`/`focus`/`tabs`/`opentab`/`closetab`/`contacts`/`addresses`
|
||
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.
|
||
|
||
Für `contacts` führt der Mock ein Telefonbuch mit **154 Einträgen** – groß genug, dass der
|
||
Bereich „Adressdaten" dieselbe Arbeit leisten muss wie gegen die Anlage. Drei Sonderfälle
|
||
sind absichtlich dabei: zweimal **„Muster, Max"** mit verschiedenen Durchwahlen (Namen sind
|
||
nicht eindeutig), Einträge **ohne Beschreibung**, und **„Zentrale" mit der Rufnummer `0`** –
|
||
die steckt in keinem Ziffernpaar und wird nur über die einstelligen Abfragen gefunden.
|
||
|
||
Er hält sich an dieselben Regeln wie die echte App: Teilzeichenkette in Name und Rufnummer,
|
||
Schreibweise egal, nach Namen sortiert, gekappt bei 100 Treffern, und derselbe Fehlertext
|
||
für einen fehlenden, leeren oder zu langen Suchbegriff.
|
||
|
||
Denselben Bestand gibt der Mock als Adress-Cache heraus – auf `addresses` und beim Verbinden
|
||
als Push. Der Cache lässt sich leeren, um die Rückfallebene zu prüfen; dann verhält er sich
|
||
wie die Anlage zurzeit:
|
||
|
||
```bash
|
||
curl -X POST "http://localhost:8080/api/mock/address-cache?filled=false" # -> Einzelabfragen
|
||
curl -X POST "http://localhost:8080/api/mock/address-cache?filled=true" # -> Cache
|
||
```
|
||
|
||
Jedes Umschalten schickt allen Verbundenen sofort einen neuen Push.
|
||
|
||
Die Startseite muss dazu auf `ws://localhost:8080/ws` zeigen – entweder im Adressfeld
|
||
eintragen oder das Backend mit
|
||
`--spring.profiles.active=mock --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://127.0.0.1:17654/ws` |
|
||
|
||
Backend-seitig:
|
||
|
||
```properties
|
||
server.port=8080 # Port dieser Anwendung
|
||
|
||
app.websocket.host=127.0.0.1 # 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.
|
||
|
||
## Deployment als Docker-Container
|
||
|
||
Das [Dockerfile](Dockerfile) im Wurzelverzeichnis erledigt genau das in drei Stufen: Frontend
|
||
mit Node bauen, das Ergebnis in `backend/src/main/resources/static/` legen und mit Maven
|
||
zusammen zum Jar packen, dieses in ein schlankes JRE-Image kopieren. Herauskommt **ein**
|
||
Container, der Startseite und REST-API über Port 8080 ausliefert – als Benutzer `swyx`, nicht
|
||
als `root`, mit einem Healthcheck gegen `/api/config`.
|
||
|
||
```bash
|
||
docker build -t swyxweb .
|
||
docker run -d -p 8080:8080 \
|
||
-e APP_WEBSOCKET_HOST=127.0.0.1 \
|
||
-e APP_WEBSOCKET_PORT=17654 \
|
||
--name swyxweb swyxweb
|
||
```
|
||
|
||
Alle `app.websocket.*`-Werte sind zur Laufzeit über Umgebungsvariablen setzbar
|
||
(`APP_WEBSOCKET_HOST`, `_PORT`, `_PATH`, `_SECURE`), das Bundle muss dafür nicht neu gebaut
|
||
werden. Der Heap wächst über `-XX:MaxRAMPercentage=75` mit dem Speicherlimit des Containers
|
||
mit; überschreiben lässt sich das mit `-e JAVA_OPTS=…`. Soll die WebSocket-Adresse ausnahmsweise
|
||
fest ins Bundle, geht das beim Bauen mit `--build-arg VITE_WS_URL=ws://…/ws`.
|
||
|
||
In die Registry schieben erledigt [docker_push.sh](docker_push.sh) – es baut für `linux/amd64`
|
||
und pusht in die Paket-Registry des Gitea nach `gitea.appcreation.de/sven/swyxweb`
|
||
(`REGISTRY_IMAGE` im Skript). Die **Versionsnummer ist Pflicht** und wird zum Tag
|
||
des Images; sie wird bewusst nicht aus der `pom.xml` abgeleitet:
|
||
|
||
```bash
|
||
docker login gitea.appcreation.de
|
||
./docker_push.sh 0.9.13 # baut und pusht :0.9.13
|
||
./docker_push.sh 0.9.13 --dry-run # nur bauen, nicht pushen
|
||
```
|
||
|
||
Ohne Argument bricht das Skript mit der Kurzhilfe ab. Zulässig ist das Format `x.y.z`.
|
||
|
||
Ein lokaler Maven- oder npm-Lauf ist vorher **nicht** nötig; beides passiert im Image-Build.
|
||
Die beiden Build-Stufen laufen dabei nativ auf der Architektur des bauenden Rechners
|
||
(`--platform=$BUILDPLATFORM`) – JS-Bundle und Jar sind architekturunabhängig, nur das
|
||
Laufzeit-Image wird für `linux/amd64` gezogen. Auf einem ARM-Mac spart das die Emulation.
|
||
|
||
**Der SwyxTray-Mock ist nicht dabei**: `SPRING_PROFILES_ACTIVE` bleibt leer, also fehlen `/ws`
|
||
und `/api/mock/**` (siehe [Ohne echte Telefonanlage testen](#ohne-echte-telefonanlage-testen)).
|
||
Für eine Vorführung ohne Telefonanlage lässt er sich am einzelnen Container einschalten:
|
||
`-e SPRING_PROFILES_ACTIVE=mock`.
|
||
|
||
Zwei Dinge, die der Container nicht lösen kann:
|
||
|
||
- **Läuft die Seite hinter HTTPS**, blockiert der Browser die unverschlüsselte Verbindung zur
|
||
SwyxTray-App. Dann braucht die App selbst TLS und `APP_WEBSOCKET_SECURE=true` (⇒ `wss://`).
|
||
- **Die WebSocket-Verbindung baut der Browser des Anwenders auf**, nicht der Container. Port
|
||
17654 muss also vom Arbeitsplatz aus erreichbar sein, nicht vom Docker-Host.
|
||
|
||
## 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**: Die Verbindung geht per Vorgabe an `127.0.0.1`, bleibt also auf dem Rechner
|
||
und braucht keine Freigabe. Zeigt die Adresse auf einen anderen Rechner, muss dort Port
|
||
17654 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`.
|