Adressdaten-Bereich, Mock hinter einem Profil, Webhook

Drei Stränge, die sich über dieselben Dateien ziehen (HomePage, index.css,
README, application.properties) und deshalb nicht getrennt committet werden
können, ohne einen nicht übersetzbaren Zwischenstand zu hinterlassen:

Adressdaten: Der Bereich zeigt den gesamten Bestand des globalen Telefon-
buchs - zuerst aus dem Adress-Cache der App (Protokoll 8), sonst über das
Kommando "addresses" und ersatzweise aus rund 110 Einzelabfragen hinter
einem Wartedialog. Der Mock gibt denselben Bestand heraus und lässt sich
über /api/mock/address-cache leeren, um die Rückfallebene zu prüfen.

Mock hinter dem Profil "mock": Seine Bohnen (/ws und /api/mock/**) hängen
jetzt an @Profile, sind ohne das Profil also nicht vorhanden. Damit kann
der Mock nicht versehentlich in einer Produktivumgebung mitlaufen; das
Container-Image setzt das Profil nicht. Je ein Test hält beide Richtungen
fest.

Webhook: POST /api/webhook nimmt beliebiges JSON eines fremden Systems an
und reicht es über Server-Sent Events an die offenen Browser weiter, wo es
der neue Bereich "Webhook" unverändert anzeigt. Der WebSocket kam dafür
nicht in Frage - er gehört der SwyxTray-App. Das Backend hält die letzten
50 Nachrichten vor und liefert sie beim Wiederverbinden anhand der
Last-Event-ID nach; ein Heartbeat und X-Accel-Buffering: no halten die
Verbindung durch Reverse Proxys hindurch offen. Ein Token (app.webhook.token)
ist vorgesehen, aber nicht voreingestellt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-24 15:11:55 +02:00
co-authored by Claude Opus 5
parent b2f608055a
commit 01594a1f3d
31 changed files with 2687 additions and 32 deletions
+10 -4
View File
@@ -12,9 +12,11 @@
"console": "internalConsole" "console": "internalConsole"
}, },
{ {
// Wie oben, aber /api/config zeigt auf den eingebauten SwyxTray-Mock statt // Wie oben, aber der eingebaute SwyxTray-Mock wird über das Profil "mock"
// auf 192.168.180.135:17654 so lässt sich die Startseite ohne echte // eingeschaltet (ohne das Profil gibt es ihn nicht) und /api/config zeigt
// Telefonanlage ausprobieren (eingehenden Anruf auslösen: siehe README). // auf ihn statt auf 192.168.180.135:17654 so lässt sich die Startseite
// ohne echte Telefonanlage ausprobieren (eingehenden Anruf auslösen:
// siehe README).
"type": "java", "type": "java",
"name": "Backend (lokaler Test gegen SwyxTray-Mock)", "name": "Backend (lokaler Test gegen SwyxTray-Mock)",
"request": "launch", "request": "launch",
@@ -22,7 +24,11 @@
"projectName": "backend", "projectName": "backend",
"cwd": "${workspaceFolder}/backend", "cwd": "${workspaceFolder}/backend",
"console": "internalConsole", "console": "internalConsole",
"args": ["--app.websocket.host=localhost", "--app.websocket.port=8080"] "args": [
"--spring.profiles.active=mock",
"--app.websocket.host=localhost",
"--app.websocket.port=8080"
]
}, },
{ {
"type": "node-terminal", "type": "node-terminal",
+296 -13
View File
@@ -18,17 +18,21 @@ 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 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)). 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 2. Es nimmt unter `POST /api/webhook` **Adressdaten fremder Systeme** entgegen und reicht sie
echte Telefonanlage testen zu können (siehe unten). Im Normalbetrieb wird er nicht benutzt. 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 Mock), **Zwei getrennte Ports:** `8080` ist der Port dieser Anwendung (REST-API und, mit Profil `mock`, der Mock),
`17654` der Port der SwyxTray-App auf 192.168.180.135. Sie sind unabhängig voneinander. `17654` der Port der SwyxTray-App auf 192.168.180.135. Sie sind unabhängig voneinander.
## Protokoll der SwyxTray-App ## Protokoll der SwyxTray-App
Quelle: Mitschnitt gegen die laufende App (Version 1.0.0.0, `protocol: 1`) am 13.08.2026. 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 Mit der Tab-Verwaltung meldete die App im `hello` `protocol: 6`, mit der Adresssuche
sind unverändert geblieben. Implementiert in [protocol.ts](frontend/src/swyx/protocol.ts) und `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). [SwyxTrayClient.ts](frontend/src/swyx/SwyxTrayClient.ts).
Ein Anruf wird über die **1-basierte Leitungsnummer `line`** identifiziert so, wie sie auch Ein Anruf wird über die **1-basierte Leitungsnummer `line`** identifiziert so, wie sie auch
@@ -95,6 +99,8 @@ Wählbereich darauf hin.
| `tabs` | | Offene Tabs des Firefox-Plugins auflisten | `tabs` | | `tabs` | | Offene Tabs des Firefox-Plugins auflisten | `tabs` |
| `opentab` | `url` | Neuen Tab öffnen | `tabId` | | `opentab` | `url` | Neuen Tab öffnen | `tabId` |
| `closetab` | `tabId` | Tab schließen | – | | `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 Alles andere quittiert die App mit `"Unbekanntes Kommando '…'."`, ein fehlendes `cmd` mit
`"Feld 'cmd' fehlt."`. `"Feld 'cmd' fehlt."`.
@@ -154,16 +160,122 @@ wartet die Startseite auf diese drei Kommandos 15 statt 10 Sekunden
Auch hier gilt: **die App meldet Tab-Änderungen nicht von selbst.** Die Liste wird nach jedem Auch hier gilt: **die App meldet Tab-Änderungen nicht von selbst.** Die Liste wird nach jedem
Öffnen und Schließen neu über `tabs` geholt. Öffnen und Schließen neu über `tabs` geholt.
Alle Regeln sind in [protocol.test.ts](frontend/src/swyx/protocol.test.ts) festgehalten ### 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. darunter die wörtlich mitgeschnittene Nachricht eines echten eingehenden Anrufs.
## Funktionen der Startseite ## Funktionen der Startseite
Die Seite ist in drei Bereiche aufgeteilt ([Tabs.tsx](frontend/src/components/Tabs.tsx)): Die Seite ist in fünf Bereiche aufgeteilt ([Tabs.tsx](frontend/src/components/Tabs.tsx)):
| Tab | Inhalt | | 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 | | **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`) | | **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 | | **Verbindung** | Adresse, Verbinden/Trennen, Benachrichtigungen erlauben, Diagnose: Rohnachrichten-Log, Status abfragen, Freitext senden |
@@ -178,6 +290,83 @@ Weiteres:
„nicht erkannt" vermerkt statt still verworfen. „nicht erkannt" vermerkt statt still verworfen.
- Automatischer Reconnect mit exponentiellem Backoff, nur nach unerwartetem Abbruch. - 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 ## Starten
### Aus VS Code ### Aus VS Code
@@ -187,8 +376,9 @@ Im Debug-Panel die Compound-Konfiguration **„SwyxWeb starten (Backend + Fronte
Vite-Dev-Server und öffnet Chrome, sobald dieser bereit ist Breakpoints funktionieren auf Vite-Dev-Server und öffnet Chrome, sobald dieser bereit ist Breakpoints funktionieren auf
beiden Seiten. „Stop" beendet beides zusammen. beiden Seiten. „Stop" beendet beides zusammen.
Alternative **„SwyxWeb starten (lokaler Test gegen SwyxTray-Mock)"**: `/api/config` liefert dann Alternative **„SwyxWeb starten (lokaler Test gegen SwyxTray-Mock)"**: startet das Backend mit dem
`ws://localhost:8080/ws`, die Startseite spricht also mit dem SwyxTray-Mock. 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 Benötigte Extensions: *Extension Pack for Java* (Backend) und *JavaScript Debugger* (im
VS Code enthalten, für Vite und Chrome). VS Code enthalten, für Vite und Chrome).
@@ -223,10 +413,29 @@ diesen Proxy der Browser verbindet sich direkt mit der SwyxTray-App.
## Ohne echte Telefonanlage testen ## 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) Der [SwyxTrayMockHandler](backend/src/main/java/de/appcreation/swyxweb/websocket/SwyxTrayMockHandler.java)
spricht dasselbe Protokoll: `hello` (mit `protocol: 6`) und Snapshot beim Verbinden, Quittungen spricht dasselbe Protokoll: `hello` (mit `protocol: 8`), Snapshot und Adress-Cache beim
auf `call`/`answer`/`hangup`/`ping`/`status`/`focus`/`tabs`/`opentab`/`closetab` und nach jeder Verbinden, Quittungen auf
Änderung einen vollständigen Snapshot über alle vier Leitungen. Einen eingehenden Anruf auslösen: `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 ```bash
curl -X POST "http://localhost:8080/api/mock/incoming-call?number=%2B493012345&name=Muster%20GmbH" curl -X POST "http://localhost:8080/api/mock/incoming-call?number=%2B493012345&name=Muster%20GmbH"
@@ -244,8 +453,30 @@ liefert dessen `tabId`, `closetab` entfernt ihn; eine unbekannte Kennung ergibt
Geöffnet wird auch hier nichts. Der Mock bildet den Fall „kein Plugin verbunden" **nicht** nach; Geöffnet wird auch hier nichts. Der Mock bildet den Fall „kein Plugin verbunden" **nicht** nach;
er antwortet immer sofort. 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 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` 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). starten (macht die VS-Code-Konfiguration „lokaler Test" automatisch).
## Konfiguration der WebSocket-Adresse ## Konfiguration der WebSocket-Adresse
@@ -280,6 +511,58 @@ cd frontend && npm test && npm run build
Um das Frontend aus dem Backend auszuliefern, `frontend/dist/*` nach Um das Frontend aus dem Backend auszuliefern, `frontend/dist/*` nach
`backend/src/main/resources/static/` kopieren und neu packen. `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=192.168.180.135 \
-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 ## Hinweise
- **HTTPS-Seite ⇒ `wss://`**: Ein über HTTPS ausgelieferter Browser blockiert unverschlüsselte - **HTTPS-Seite ⇒ `wss://`**: Ein über HTTPS ausgelieferter Browser blockiert unverschlüsselte
@@ -1,12 +1,13 @@
package de.appcreation.swyxweb; package de.appcreation.swyxweb;
import de.appcreation.swyxweb.config.WebSocketProperties; import de.appcreation.swyxweb.config.WebSocketProperties;
import de.appcreation.swyxweb.config.WebhookProperties;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties;
@SpringBootApplication @SpringBootApplication
@EnableConfigurationProperties(WebSocketProperties.class) @EnableConfigurationProperties({ WebSocketProperties.class, WebhookProperties.class })
public class BackendApplication { public class BackendApplication {
public static void main(String[] args) { public static void main(String[] args) {
@@ -0,0 +1,23 @@
package de.appcreation.swyxweb.config;
/**
* Name des Spring-Profils, unter dem der eingebaute SwyxTray-Mock läuft.
*
* <p>Der Mock ist bewusst <b>abgeschaltet, solange dieses Profil nicht gesetzt
* ist</b> so kann er nicht versehentlich in einer Produktivumgebung
* mitlaufen. Eingeschaltet wird er über
* {@code --spring.profiles.active=mock} bzw. {@code SPRING_PROFILES_ACTIVE=mock}.
*
* <p>Betroffen sind {@code SwyxTrayMockHandler} (der WebSocket-Endpunkt unter
* {@code /ws}), {@code WebSocketConfig} (seine Registrierung) und
* {@code MockController} ({@code /api/mock/**}). Ohne das Profil gibt es weder
* den einen noch die anderen; beide antworten dann mit 404.
*/
public final class MockProfile {
/** Name des Profils, siehe Klassenkommentar. */
public static final String NAME = "mock";
private MockProfile() {
}
}
@@ -2,6 +2,7 @@ package de.appcreation.swyxweb.config;
import de.appcreation.swyxweb.websocket.SwyxTrayMockHandler; import de.appcreation.swyxweb.websocket.SwyxTrayMockHandler;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.web.socket.config.annotation.EnableWebSocket; import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer; import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
@@ -9,7 +10,10 @@ import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry
/** /**
* Stellt den SwyxTray-Mock unter dem konfigurierten Pfad bereit, damit sich die * Stellt den SwyxTray-Mock unter dem konfigurierten Pfad bereit, damit sich die
* Startseite auch ohne die echte SwyxTray-App testen lässt. * Startseite auch ohne die echte SwyxTray-App testen lässt.
*
* <p>Nur mit dem Profil {@link MockProfile#NAME} siehe dort.
*/ */
@Profile(MockProfile.NAME)
@Configuration @Configuration
@EnableWebSocket @EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer { public class WebSocketConfig implements WebSocketConfigurer {
@@ -0,0 +1,27 @@
package de.appcreation.swyxweb.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
/**
* Einstellungen des Webhooks, über den ein fremdes System Adressdaten an die
* Startseite schickt.
*
* @param token Gemeinsames Geheimnis. Ist es gesetzt, muss der Aufrufer es im
* Kopf {@code X-Webhook-Token} mitschicken; ist es leer, nimmt
* der Webhook <b>jeden</b> Aufruf an.
* @param history So viele Nachrichten hält das Backend vor, damit ein später
* geöffneter Bereich die vorigen noch sieht.
* @param maxSize Größte erlaubte Nutzlast in Byte.
*/
@ConfigurationProperties(prefix = "app.webhook")
public record WebhookProperties(
@DefaultValue("") String token,
@DefaultValue("50") int history,
@DefaultValue("262144") int maxSize) {
/** Ob der Webhook ein Geheimnis verlangt. */
public boolean isSecured() {
return !token.isBlank();
}
}
@@ -1,7 +1,9 @@
package de.appcreation.swyxweb.web; package de.appcreation.swyxweb.web;
import de.appcreation.swyxweb.config.MockProfile;
import de.appcreation.swyxweb.websocket.LineState; import de.appcreation.swyxweb.websocket.LineState;
import de.appcreation.swyxweb.websocket.SwyxTrayMockHandler; import de.appcreation.swyxweb.websocket.SwyxTrayMockHandler;
import org.springframework.context.annotation.Profile;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
@@ -9,10 +11,16 @@ import org.springframework.web.bind.annotation.RestController;
/** /**
* Auslöser für den SwyxTray-Mock: simuliert einen eingehenden Anruf, damit sich * Auslöser für den SwyxTray-Mock: simuliert einen eingehenden Anruf, damit sich
* die Anrufmeldung der Startseite ohne echte Telefonanlage prüfen lässt. * die Anrufmeldung der Startseite ohne echte Telefonanlage prüfen lässt, und
* schaltet den Adress-Cache um.
* *
* <pre>curl -X POST "http://localhost:8080/api/mock/incoming-call?number=%2B493012345&name=Muster%20GmbH"</pre> * <pre>curl -X POST "http://localhost:8080/api/mock/incoming-call?number=%2B493012345&name=Muster%20GmbH"
* curl -X POST "http://localhost:8080/api/mock/address-cache?filled=false"</pre>
*
* <p>Nur mit dem Profil {@link MockProfile#NAME} vorhanden im Normalbetrieb
* gibt es {@code /api/mock/**} nicht.
*/ */
@Profile(MockProfile.NAME)
@RestController @RestController
@RequestMapping("/api/mock") @RequestMapping("/api/mock")
public class MockController { public class MockController {
@@ -29,4 +37,18 @@ public class MockController {
@RequestParam(required = false) String name) { @RequestParam(required = false) String name) {
return mock.simulateIncomingCall(number, name); return mock.simulateIncomingCall(number, name);
} }
/**
* Füllt oder leert den Adress-Cache des Mocks. Mit leerem Cache verhält er
* sich wie die zurzeit laufende Anlage, und der Bereich „Adressdaten" muss
* auf die Einzelabfragen ausweichen.
*
* <pre>curl -X POST "http://localhost:8080/api/mock/address-cache?filled=false"</pre>
*
* @return Anzahl der Einträge im Cache danach
*/
@PostMapping("/address-cache")
public int addressCache(@RequestParam(defaultValue = "true") boolean filled) {
return mock.setAddressCacheFilled(filled);
}
} }
@@ -0,0 +1,132 @@
package de.appcreation.swyxweb.web;
import java.time.Instant;
import java.util.List;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import de.appcreation.swyxweb.config.WebhookProperties;
import de.appcreation.swyxweb.webhook.WebhookEvent;
import de.appcreation.swyxweb.webhook.WebhookService;
/**
* Webhook für Adressdaten aus einem fremden System.
*
* <pre>curl -X POST https://swyxweb.appcreation.de/api/webhook \
* -H "Content-Type: application/json" \
* -H "X-Webhook-Token: …" \
* -d '{"name":"Muster GmbH","number":"+493012345"}'</pre>
*
* <p>Angenommen wird <b>beliebiges</b> JSON Objekt wie Liste. Die Startseite
* zeigt es im Bereich „Webhook" unverändert an; das aufrufende System muss sich
* also an kein festes Schema halten.
*
* <p>Der Weg zum Browser läuft über {@code GET /api/webhook/events}
* (Server-Sent Events), siehe {@link WebhookService}.
*/
@RestController
@RequestMapping("/api/webhook")
public class WebhookController {
private final WebhookService service;
private final WebhookProperties properties;
private final ObjectMapper mapper;
public WebhookController(WebhookService service, WebhookProperties properties, ObjectMapper mapper) {
this.service = service;
this.properties = properties;
this.mapper = mapper;
}
/** Quittung an das aufrufende System. */
public record Receipt(long id, Instant receivedAt) {
}
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
public Receipt receive(
@RequestBody JsonNode payload,
@RequestHeader(name = "X-Webhook-Token", required = false) String token) {
requireToken(token);
if (payload == null || payload.isNull()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Leere Nutzlast.");
}
// Die Größe wird erst nach dem Parsen geprüft; das Feld begrenzt vor allem,
// was sich im Verlauf ansammeln und im Browser anzeigen lässt.
int size = mapper.writeValueAsString(payload).getBytes(java.nio.charset.StandardCharsets.UTF_8).length;
if (size > properties.maxSize()) {
throw new ResponseStatusException(
HttpStatus.PAYLOAD_TOO_LARGE,
"Nutzlast ist zu groß (%d Byte, erlaubt sind %d).".formatted(size, properties.maxSize()));
}
WebhookEvent event = service.record(payload);
return new Receipt(event.id(), event.receivedAt());
}
/**
* Strom der eingehenden Nachrichten für die Startseite. Der Browser schickt
* beim Wiederverbinden von selbst {@code Last-Event-ID} mit; alles Jüngere
* wird dann nachgeliefert.
*/
@GetMapping(path = "/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public ResponseEntity<SseEmitter> events(
@RequestHeader(name = "Last-Event-ID", required = false) String lastEventId) {
Long since = null;
try {
if (lastEventId != null && !lastEventId.isBlank()) since = Long.parseLong(lastEventId.trim());
} catch (NumberFormatException e) {
// Unbrauchbare Id: dann eben ohne Nachlieferung, statt den Aufruf abzulehnen.
since = null;
}
return ResponseEntity.ok()
.contentType(MediaType.TEXT_EVENT_STREAM)
.cacheControl(org.springframework.http.CacheControl.noCache().mustRevalidate())
// Ohne diesen Kopf sammelt nginx die Ereignisse im Puffer und gibt sie erst
// aus, wenn genug beisammen ist die Nachricht erschiene dann verspätet
// oder gar nicht. nginx wertet ihn aus, andere Proxys ignorieren ihn.
.header("X-Accel-Buffering", "no")
.header(HttpHeaders.CONNECTION, "keep-alive")
.body(service.subscribe(since));
}
/** Verlauf, damit ein später geöffneter Bereich die vorigen Nachrichten zeigt. */
@GetMapping("/history")
public List<WebhookEvent> history() {
return service.history();
}
/** Leert den Verlauf; die Startseite bietet das als „Leeren" an. */
@DeleteMapping("/history")
public int clear() {
return service.clear();
}
private void requireToken(String token) {
if (!properties.isSecured()) return;
// Konstante Laufzeit ist hier zweitrangig; der Vergleich läuft gegen ein
// Geheimnis fester Länge und hinter einer Netzgrenze.
if (token == null || !token.equals(properties.token())) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Ungültiges oder fehlendes X-Webhook-Token.");
}
}
}
@@ -0,0 +1,16 @@
package de.appcreation.swyxweb.webhook;
import java.time.Instant;
import tools.jackson.databind.JsonNode;
/**
* Eine über den Webhook eingegangene Nachricht.
*
* @param id fortlaufend ab 1; der Browser meldet sie beim Wiederverbinden
* als {@code Last-Event-ID} zurück, damit nichts verloren geht
* @param receivedAt Eingangszeit im Backend
* @param payload das empfangene JSON, unverändert
*/
public record WebhookEvent(long id, Instant receivedAt, JsonNode payload) {
}
@@ -0,0 +1,204 @@
package de.appcreation.swyxweb.webhook;
import java.io.IOException;
import java.time.Instant;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import jakarta.annotation.PreDestroy;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import de.appcreation.swyxweb.config.WebhookProperties;
/**
* Nimmt die Nachrichten des Webhooks entgegen und verteilt sie an die offenen
* Browser.
*
* <p>Der Weg zum Browser sind <b>Server-Sent Events</b>: Die Startseite hält
* eine {@code EventSource} auf {@code /api/webhook/events} offen. Das ist
* einseitig genau das, was hier gebraucht wird , läuft über dieselbe
* HTTPS-Verbindung wie die Seite und wird vom Browser nach einem Abbruch von
* selbst wieder aufgebaut. Die WebSocket-Verbindung der Startseite gehört
* dagegen der SwyxTray-App und steht dafür nicht zur Verfügung.
*
* <p>Die letzten {@link WebhookProperties#history()} Nachrichten bleiben im
* Speicher. Das hat zwei Gründe: Ein erst später geöffneter Bereich zeigt
* trotzdem, was vorher ankam, und nach einem Verbindungsabbruch lässt sich
* anhand der {@code Last-Event-ID} nachliefern, was in der Zwischenzeit
* eingegangen ist. Über einen Neustart hinaus wird nichts aufgehoben.
*/
@Service
public class WebhookService {
private static final Logger log = LoggerFactory.getLogger(WebhookService.class);
/** Ein Doppelpunkt-Kommentar hält die Verbindung durch Proxys hindurch offen. */
private static final long HEARTBEAT_SECONDS = 25;
private final WebhookProperties properties;
private final ObjectMapper mapper;
private final AtomicLong nextId = new AtomicLong(1);
/** Jüngste Nachricht zuletzt. Zugriff immer unter dem Monitor dieses Feldes. */
private final Deque<WebhookEvent> events = new ArrayDeque<>();
private final List<SseEmitter> subscribers = new CopyOnWriteArrayList<>();
private final ScheduledExecutorService heartbeat =
Executors.newSingleThreadScheduledExecutor(runnable -> {
Thread thread = new Thread(runnable, "webhook-heartbeat");
thread.setDaemon(true);
return thread;
});
public WebhookService(WebhookProperties properties, ObjectMapper mapper) {
this.properties = properties;
this.mapper = mapper;
heartbeat.scheduleWithFixedDelay(
this::sendHeartbeat, HEARTBEAT_SECONDS, HEARTBEAT_SECONDS, TimeUnit.SECONDS);
if (properties.isSecured()) {
log.info("Webhook: Aufrufe müssen den Kopf X-Webhook-Token mitschicken.");
} else {
log.warn("Webhook: app.webhook.token ist nicht gesetzt POST /api/webhook nimmt "
+ "jeden Aufruf an. Für eine öffentlich erreichbare Instanz ein Token setzen.");
}
}
@PreDestroy
void shutdown() {
heartbeat.shutdownNow();
subscribers.forEach(SseEmitter::complete);
subscribers.clear();
}
/**
* Nimmt eine Nachricht an, hängt sie an den Verlauf und schickt sie sofort
* an alle offenen Browser.
*
* @return das angelegte Ereignis, damit der Aufrufer die Id quittieren kann
*/
public WebhookEvent record(JsonNode payload) {
WebhookEvent event = new WebhookEvent(nextId.getAndIncrement(), Instant.now(), payload);
synchronized (events) {
events.addLast(event);
while (events.size() > Math.max(1, properties.history())) {
events.removeFirst();
}
}
log.debug("Webhook: Nachricht {} angenommen ({} Empfänger).", event.id(), subscribers.size());
broadcast(event);
return event;
}
/** Verlauf, älteste zuerst. */
public List<WebhookEvent> history() {
synchronized (events) {
return List.copyOf(events);
}
}
/** Leert den Verlauf. Die offenen Browser behalten, was sie schon zeigen. */
public int clear() {
synchronized (events) {
int removed = events.size();
events.clear();
return removed;
}
}
/**
* Meldet einen Browser an.
*
* @param lastEventId Id der zuletzt beim Browser angekommenen Nachricht oder
* {@code null}. Alles Jüngere wird sofort nachgeliefert
* so überbrückt ein Wiederverbinden die Lücke.
*/
public SseEmitter subscribe(Long lastEventId) {
// Kein Zeitlimit: Der Browser hält die Verbindung, der Heartbeat hält sie am Leben.
SseEmitter emitter = new SseEmitter(0L);
emitter.onCompletion(() -> subscribers.remove(emitter));
emitter.onTimeout(() -> {
subscribers.remove(emitter);
emitter.complete();
});
emitter.onError(e -> subscribers.remove(emitter));
List<WebhookEvent> missed = new ArrayList<>();
if (lastEventId != null) {
for (WebhookEvent event : history()) {
if (event.id() > lastEventId) missed.add(event);
}
}
try {
// Ohne erste Nachricht bliebe die Antwort ohne Kopfzeilen hängen.
emitter.send(SseEmitter.event().comment("verbunden"));
for (WebhookEvent event : missed) {
emitter.send(toSse(event));
}
} catch (IOException | IllegalStateException e) {
emitter.completeWithError(e);
return emitter;
}
subscribers.add(emitter);
return emitter;
}
/** Anzahl der offenen Browser-Verbindungen für Diagnose und Test. */
public int subscriberCount() {
return subscribers.size();
}
private void broadcast(WebhookEvent event) {
SseEmitter.SseEventBuilder message = toSse(event);
for (SseEmitter emitter : subscribers) {
try {
emitter.send(message);
} catch (IOException | IllegalStateException e) {
// Browser weg oder Verbindung tot: abräumen, der Rest bekommt trotzdem alles.
subscribers.remove(emitter);
emitter.completeWithError(e);
}
}
}
private void sendHeartbeat() {
for (SseEmitter emitter : subscribers) {
try {
emitter.send(SseEmitter.event().comment("ping"));
} catch (IOException | IllegalStateException e) {
subscribers.remove(emitter);
emitter.completeWithError(e);
}
}
}
/**
* Die Nutzlast wird hier selbst zu Text gemacht und als solcher gesendet
* über {@code MediaType.APPLICATION_JSON} würde Spring den String ein
* zweites Mal in Anführungszeichen setzen.
*/
private SseEmitter.SseEventBuilder toSse(WebhookEvent event) {
return SseEmitter.event()
.id(Long.toString(event.id()))
.name("webhook")
.data(mapper.writeValueAsString(event));
}
}
@@ -0,0 +1,17 @@
package de.appcreation.swyxweb.websocket;
/**
* Adresseintrag im Mock Feldnamen wie in der Quittung auf {@code contacts}:
* {@code {"name":"Abt, Bettina","number":"7587","description":"S-SB"}}.
*
* <p>Die echte SwyxTray-App liest diese Einträge aus dem Telefonbuch des
* Swyx-Clients; leere Felder schickt sie als {@code ""}, nicht als {@code null}.
*/
public record MockContact(String name, String number, String description) {
/** Trifft der Suchbegriff? Teilzeichenkette in Name und Rufnummer, Schreibweise egal. */
public boolean matches(String lowerCaseQuery) {
return name.toLowerCase().contains(lowerCaseQuery)
|| number.toLowerCase().contains(lowerCaseQuery);
}
}
@@ -4,6 +4,7 @@ import java.io.IOException;
import java.net.URI; import java.net.URI;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -13,8 +14,11 @@ import java.util.concurrent.atomic.AtomicInteger;
import tools.jackson.databind.JsonNode; import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.ObjectMapper;
import de.appcreation.swyxweb.config.MockProfile;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus; import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.TextMessage;
@@ -38,7 +42,24 @@ import org.springframework.web.socket.handler.TextWebSocketHandler;
* <p>Seit Protokoll 6 beantwortet der Mock zusätzlich {@code tabs}, {@code opentab} und * <p>Seit Protokoll 6 beantwortet der Mock zusätzlich {@code tabs}, {@code opentab} und
* {@code closetab}. Die echte App reicht diese Kommandos an das Firefox-Plugin durch; * {@code closetab}. Die echte App reicht diese Kommandos an das Firefox-Plugin durch;
* der Mock spielt das Plugin und führt eine Liste vorgetäuschter Tabs im Speicher. * der Mock spielt das Plugin und führt eine Liste vorgetäuschter Tabs im Speicher.
*
* <p>Seit Protokoll 7 beantwortet er {@code contacts} aus einem fest eingebauten
* Telefonbuch mit denselben Regeln wie die echte App: Teilzeichenkette in Name und
* Rufnummer, Schreibweise egal, nach Namen sortiert und bei
* {@link #CONTACT_RESULT_LIMIT} Treffern stillschweigend gekürzt.
*
* <p>Seit Protokoll 8 gibt er dasselbe Telefonbuch als <b>Adress-Cache</b> am Stück
* heraus: auf {@code addresses} und zusätzlich unaufgefordert als
* {@code {"type":"addresses","addresses":[…]}} direkt nach dem ersten Snapshot.
* Über {@code POST /api/mock/address-cache?filled=false} lässt sich der Cache leeren
* dann verhält sich der Mock wie die zurzeit laufende Anlage, deren Cache leer ist,
* und die Startseite muss auf die Einzelabfragen ausweichen.
*
* <p><b>Nur mit dem Profil {@link MockProfile#NAME}.</b> Ohne dieses Profil gibt es
* die Bean nicht, {@code /ws} ist dann nicht belegt der Mock kann also nicht
* versehentlich in einer Produktivumgebung mitlaufen.
*/ */
@Profile(MockProfile.NAME)
@Component @Component
public class SwyxTrayMockHandler extends TextWebSocketHandler { public class SwyxTrayMockHandler extends TextWebSocketHandler {
@@ -51,8 +72,17 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler {
private static final List<String> MOCK_WINDOWS = List.of( private static final List<String> MOCK_WINDOWS = List.of(
"SwyxIt!", "SwyxWeb Google Chrome", "Kundenakte Muster GmbH"); "SwyxIt!", "SwyxWeb Google Chrome", "Kundenakte Muster GmbH");
/** Protokollstand der echten App seit der Tab-Verwaltung. */ /** Protokollstand der echten App seit dem Adress-Cache. */
private static final int PROTOCOL_VERSION = 6; private static final int PROTOCOL_VERSION = 8;
/** So viele Treffer meldet die echte App höchstens; gekürzt wird ohne Hinweis. */
private static final int CONTACT_RESULT_LIMIT = 100;
/** Längere Suchbegriffe weist die echte App mit einem Fehler zurück. */
private static final int CONTACT_QUERY_MAX_LENGTH = 128;
/** Vorgetäuschtes Telefonbuch; siehe {@link #buildDirectory()}. */
private static final List<MockContact> MOCK_CONTACTS = buildDirectory();
private final ObjectMapper mapper; private final ObjectMapper mapper;
private final Map<String, WebSocketSession> sessions = new ConcurrentHashMap<>(); private final Map<String, WebSocketSession> sessions = new ConcurrentHashMap<>();
@@ -61,6 +91,8 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler {
private final Map<Integer, MockTab> tabs = Collections.synchronizedMap(new LinkedHashMap<>()); private final Map<Integer, MockTab> tabs = Collections.synchronizedMap(new LinkedHashMap<>());
private final AtomicInteger nextTabId = new AtomicInteger(41); private final AtomicInteger nextTabId = new AtomicInteger(41);
private final AtomicInteger nextSession = new AtomicInteger(); private final AtomicInteger nextSession = new AtomicInteger();
// Ist der Adress-Cache gefüllt? Zum Umschalten auf die Rückfallebene.
private volatile boolean addressCacheFilled = true;
// Serialisiert Begrüßung und Snapshot, damit die Reihenfolge garantiert ist. // Serialisiert Begrüßung und Snapshot, damit die Reihenfolge garantiert ist.
private final Object sendLock = new Object(); private final Object sendLock = new Object();
@@ -81,6 +113,8 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler {
synchronized (sendLock) { synchronized (sendLock) {
sendText(session, toJson(hello())); sendText(session, toJson(hello()));
sendText(session, toJson(snapshot())); sendText(session, toJson(snapshot()));
// Der Cache kommt unaufgefordert hinterher wie bei der echten App.
sendText(session, toJson(addressesPush()));
} }
} }
@@ -134,6 +168,15 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler {
sendText(session, toJson(result(id, true, null, null))); sendText(session, toJson(result(id, true, null, null)));
return; return;
} }
// Adressdaten des Swyx-Clients; auch sie rühren keine Leitung an.
case "contacts" -> {
sendText(session, toJson(contactsResult(id, searchContacts(request.path("query")))));
return;
}
case "addresses" -> {
sendText(session, toJson(addressesResult(id, addressCache())));
return;
}
default -> { /* weiter unten */ } default -> { /* weiter unten */ }
} }
@@ -262,6 +305,89 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler {
return tab; return tab;
} }
/**
* Baut das vorgetäuschte Telefonbuch. Es ist bewusst groß genug, dass der
* Bereich „Adressdaten" wie gegen die echte Anlage arbeiten muss: einstellige
* Abfragen hängen am Deckel und werden verfeinert, zweistellige nicht.
*
* <p>Drei Sonderfälle sind absichtlich dabei:
* <ul>
* <li>dieselbe Person mit <b>zwei Durchwahlen</b> Namen sind nicht eindeutig,</li>
* <li>Einträge <b>ohne Beschreibung</b> (die echte App schickt dafür {@code ""}),</li>
* <li>eine <b>einstellige</b> Rufnummer, die in keinem Ziffernpaar vorkommt und
* deshalb nur über die einstelligen Abfragen gefunden wird.</li>
* </ul>
*/
private static List<MockContact> buildDirectory() {
List<String> surnames = List.of(
"Abt", "Ahrens", "Bauer", "Becker", "Beyer", "Cordes", "Dahme", "Diehl",
"Erbert", "Franke", "Gast", "Geier", "Grote", "Hansper", "Heymann", "Hill",
"Kaiser", "Lorenz", "Meyer", "Neumann", "Otto", "Peters", "Richter", "Schulz",
"Thiel", "Ulrich", "Vogel", "Weber", "Zeller", "Zimmer");
List<String> given = List.of("Anna", "Bernd", "Claudia", "Dirk", "Erika");
List<String> sites = List.of("HH-MT", "B-SB", "HB-GT", "L-SB", "S-MT");
List<MockContact> entries = new ArrayList<>();
int number = 4100;
for (String surname : surnames) {
for (String first : given) {
entries.add(new MockContact(surname + ", " + first, String.valueOf(number),
sites.get(entries.size() % sites.size())));
// Lücken wie in einer gewachsenen Anlage sonst lägen die
// Nummern so dicht, dass auch Ziffernpaare am Deckel hingen.
number += 7;
}
}
entries.add(new MockContact("Hotline_HH-MT_Verwaltung", "41240", "HH-MT"));
entries.add(new MockContact("Muster, Max", "4711", ""));
entries.add(new MockContact("Muster, Max", "53119", "B-GT"));
entries.add(new MockContact("Zentrale", "0", ""));
return List.copyOf(entries);
}
/**
* Simuliert {@code contacts}. Die echte App verlangt einen nicht leeren
* Suchbegriff von höchstens {@value #CONTACT_QUERY_MAX_LENGTH} Zeichen und
* beantwortet beide Verstöße mit derselben Meldung.
*/
private List<MockContact> searchContacts(JsonNode query) {
String wanted = query.isString() ? query.stringValue().trim() : "";
if (wanted.isEmpty() || wanted.length() > CONTACT_QUERY_MAX_LENGTH) {
throw new IllegalArgumentException(
"Feld 'query' fehlt oder ist zu lang (max. " + CONTACT_QUERY_MAX_LENGTH + " Zeichen).");
}
String needle = wanted.toLowerCase();
List<MockContact> hits = MOCK_CONTACTS.stream()
.filter(contact -> contact.matches(needle))
.sorted(Comparator.comparing(MockContact::name).thenComparing(MockContact::number))
.limit(CONTACT_RESULT_LIMIT)
.toList();
log.info("SwyxTray-Mock: {} Adresseintrag/-einträge zu '{}'", hits.size(), wanted);
return hits;
}
/** Der Adress-Cache leer, solange er abgeschaltet ist. */
private List<MockContact> addressCache() {
return addressCacheFilled ? MOCK_CONTACTS : List.of();
}
/**
* Schaltet den Adress-Cache um und schickt ihn allen Verbundenen neu so
* lässt sich beides prüfen: der Cache-Weg und die Rückfallebene über
* {@code contacts}.
*
* @return Anzahl der Einträge im Cache danach
*/
public int setAddressCacheFilled(boolean filled) {
addressCacheFilled = filled;
log.info("SwyxTray-Mock: Adress-Cache {}", filled ? "gefüllt" : "geleert");
String json = toJson(addressesPush());
synchronized (sendLock) {
sessions.values().forEach(session -> sendText(session, json));
}
return addressCache().size();
}
/** Löst einen eingehenden Anruf aus: Leitung belegen, Snapshot verschicken. */ /** Löst einen eingehenden Anruf aus: Leitung belegen, Snapshot verschicken. */
public LineState simulateIncomingCall(String number, String name) { public LineState simulateIncomingCall(String number, String name) {
int line = freeLine(); int line = freeLine();
@@ -364,6 +490,34 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler {
return message; return message;
} }
/** Quittung auf {@code contacts}; trägt die gefundenen Adressdaten. */
private Map<String, Object> contactsResult(int id, List<MockContact> entries) {
Map<String, Object> message = new LinkedHashMap<>();
message.put("id", id);
message.put("ok", true);
message.put("contacts", entries);
message.put("type", "result");
return message;
}
/** Quittung auf {@code addresses}; trägt den gesamten Cache. */
private Map<String, Object> addressesResult(int id, List<MockContact> entries) {
Map<String, Object> message = new LinkedHashMap<>();
message.put("id", id);
message.put("ok", true);
message.put("addresses", entries);
message.put("type", "result");
return message;
}
/** Unaufgeforderte Cache-Nachricht ohne {@code id}, ohne {@code ok}. */
private Map<String, Object> addressesPush() {
Map<String, Object> message = new LinkedHashMap<>();
message.put("addresses", addressCache());
message.put("type", "addresses");
return message;
}
private Map<String, Object> result(int id, boolean ok, Integer line, String error) { private Map<String, Object> result(int id, boolean ok, Integer line, String error) {
Map<String, Object> message = new LinkedHashMap<>(); Map<String, Object> message = new LinkedHashMap<>();
message.put("id", id); message.put("id", id);
@@ -1,6 +1,13 @@
spring.application.name=swyxweb-backend spring.application.name=swyxweb-backend
# Port dieser Anwendung (REST-API und der eingebaute Test-Endpunkt /ws). # Der eingebaute SwyxTray-Mock (/ws und /api/mock/**) läuft nur mit dem Profil
# "mock" bewusst nicht voreingestellt, damit er nicht in einer Produktiv-
# umgebung mitläuft. Einschalten zum Testen ohne Telefonanlage:
# ./mvnw spring-boot:run -Dspring-boot.run.profiles=mock
# java -jar app.jar --spring.profiles.active=mock
# SPRING_PROFILES_ACTIVE=mock
# Port dieser Anwendung (REST-API und, mit Profil "mock", der Endpunkt /ws).
server.address=0.0.0.0 server.address=0.0.0.0
server.port=8080 server.port=8080
@@ -11,4 +18,16 @@ app.websocket.port=17654
app.websocket.path=/ws app.websocket.path=/ws
app.websocket.secure=false app.websocket.secure=false
# Webhook, über den ein fremdes System Adressdaten an die Startseite schickt:
# POST /api/webhook. Ist ein Token gesetzt, muss der Aufrufer es im Kopf
# X-Webhook-Token mitschicken; leer heißt, dass jeder Aufruf angenommen wird.
# Für die öffentlich erreichbare Instanz gehört hier ein Geheimnis hinein
# (oder von außen: APP_WEBHOOK_TOKEN).
app.webhook.token=
# So viele Nachrichten hält das Backend vor, damit ein später geöffneter
# Bereich die vorigen noch sieht. Nach einem Neustart ist der Verlauf leer.
app.webhook.history=50
# Größte erlaubte Nutzlast in Byte.
app.webhook.max-size=262144
logging.level.de.appcreation.swyxweb=DEBUG logging.level.de.appcreation.swyxweb=DEBUG
@@ -1,13 +1,32 @@
package de.appcreation.swyxweb; package de.appcreation.swyxweb;
import static org.assertj.core.api.Assertions.assertThat;
import de.appcreation.swyxweb.web.MockController;
import de.appcreation.swyxweb.websocket.SwyxTrayMockHandler;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.ApplicationContext;
@SpringBootTest @SpringBootTest
class BackendApplicationTests { class BackendApplicationTests {
@Autowired
ApplicationContext context;
@Test @Test
void contextLoads() { void contextLoads() {
} }
/**
* Ohne das Profil „mock" also so, wie die Anwendung im Container läuft
* darf der SwyxTray-Mock nicht im Kontext stehen.
*/
@Test
void mockIsAbsentWithoutItsProfile() {
assertThat(context.getBeansOfType(SwyxTrayMockHandler.class)).isEmpty();
assertThat(context.getBeansOfType(MockController.class)).isEmpty();
}
} }
@@ -0,0 +1,30 @@
package de.appcreation.swyxweb;
import static org.assertj.core.api.Assertions.assertThat;
import de.appcreation.swyxweb.web.MockController;
import de.appcreation.swyxweb.websocket.SwyxTrayMockHandler;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.ApplicationContext;
import org.springframework.test.context.ActiveProfiles;
/**
* Gegenstück zu {@link BackendApplicationTests#mockIsAbsentWithoutItsProfile()}:
* mit dem Profil „mock" muss der SwyxTray-Mock vollständig da sein.
*/
@SpringBootTest
@ActiveProfiles("mock")
class MockProfileTests {
@Autowired
ApplicationContext context;
@Test
void mockIsPresentWithItsProfile() {
assertThat(context.getBeansOfType(SwyxTrayMockHandler.class)).isNotEmpty();
assertThat(context.getBeansOfType(MockController.class)).isNotEmpty();
}
}
@@ -0,0 +1,134 @@
package de.appcreation.swyxweb;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import de.appcreation.swyxweb.webhook.WebhookService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
/**
* Der Webhook nimmt beliebiges JSON an und hält es im Verlauf vor. Ohne
* gesetztes Token ist er offen so läuft er ohne Zutun des aufrufenden Systems.
*/
@SpringBootTest
@AutoConfigureMockMvc
class WebhookControllerTests {
@Autowired
MockMvc mvc;
@Autowired
WebhookService service;
@BeforeEach
void emptyHistory() {
service.clear();
}
@Test
void acceptsJsonAndReturnsReceipt() throws Exception {
mvc.perform(post("/api/webhook")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"Muster GmbH\",\"number\":\"+493012345\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").isNumber())
.andExpect(jsonPath("$.receivedAt").exists());
assertThat(service.history()).hasSize(1);
}
/** Kein festes Schema: Auch eine Liste ist gültige Nutzlast. */
@Test
void acceptsAnyJsonShape() throws Exception {
mvc.perform(post("/api/webhook")
.contentType(MediaType.APPLICATION_JSON)
.content("[{\"name\":\"Abt, Bettina\",\"number\":\"7587\"}]"))
.andExpect(status().isOk());
assertThat(service.history()).hasSize(1);
assertThat(service.history().getFirst().payload().isArray()).isTrue();
}
@Test
void historyKeepsOrderAndCanBeCleared() throws Exception {
mvc.perform(post("/api/webhook").contentType(MediaType.APPLICATION_JSON).content("{\"n\":1}"))
.andExpect(status().isOk());
mvc.perform(post("/api/webhook").contentType(MediaType.APPLICATION_JSON).content("{\"n\":2}"))
.andExpect(status().isOk());
mvc.perform(get("/api/webhook/history"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(2))
.andExpect(jsonPath("$[0].payload.n").value(1))
.andExpect(jsonPath("$[1].payload.n").value(2));
mvc.perform(delete("/api/webhook/history"))
.andExpect(status().isOk())
.andExpect(jsonPath("$").value(2));
assertThat(service.history()).isEmpty();
}
/** Ohne Token in der Konfiguration darf der Kopf fehlen. */
@Test
void tokenIsNotRequiredWhenUnset() throws Exception {
mvc.perform(post("/api/webhook").contentType(MediaType.APPLICATION_JSON).content("{}"))
.andExpect(status().isOk());
}
@Test
void rejectsPayloadAboveTheLimit() throws Exception {
// max-size steht auf 256 KiB; ein Wert deutlich darüber muss abgelehnt werden.
String big = "{\"v\":\"" + "x".repeat(300_000) + "\"}";
mvc.perform(post("/api/webhook").contentType(MediaType.APPLICATION_JSON).content(big))
.andExpect(status().isPayloadTooLarge());
assertThat(service.history()).isEmpty();
}
@Nested
@SpringBootTest
@AutoConfigureMockMvc
@TestPropertySource(properties = "app.webhook.token=geheim")
class WithToken {
@Autowired
MockMvc mvc;
@Test
void rejectsCallWithoutToken() throws Exception {
mvc.perform(post("/api/webhook").contentType(MediaType.APPLICATION_JSON).content("{}"))
.andExpect(status().isUnauthorized());
}
@Test
void rejectsWrongToken() throws Exception {
mvc.perform(post("/api/webhook")
.contentType(MediaType.APPLICATION_JSON)
.header("X-Webhook-Token", "falsch")
.content("{}"))
.andExpect(status().isUnauthorized());
}
@Test
void acceptsCorrectToken() throws Exception {
mvc.perform(post("/api/webhook")
.contentType(MediaType.APPLICATION_JSON)
.header("X-Webhook-Token", "geheim")
.content("{}"))
.andExpect(status().isOk());
}
}
}
+262
View File
@@ -0,0 +1,262 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { describeContact, type Contact } from '../swyx/protocol'
import {
filterContacts,
sortContacts,
type Directory,
type DirectoryProgress,
type SweepOptions,
} from '../swyx/directory'
import LoadingDialog from './LoadingDialog'
interface Props {
/** Panel ist sichtbar erst dann wird geladen. */
active: boolean
disabled: boolean
/**
* Adress-Cache, den die App unaufgefordert schickt. `null` heißt „noch keine
* Cache-Nachricht"; eine leere Liste heißt „Cache ist leer".
*/
cache: Contact[] | null
onLoad: (options: SweepOptions) => Promise<Directory | null>
onDial: (number: string) => void
}
const STORAGE_FILTER = 'swyxweb.contacts.filter'
function stored(key: string): string {
try {
return localStorage.getItem(key) ?? ''
} catch {
// Privater Modus o. Ä. dann eben ohne Gedächtnis.
return ''
}
}
function remember(key: string, value: string): void {
try {
localStorage.setItem(key, value)
} catch {
// absichtlich still
}
}
/**
* Adressdaten des Swyx-Clients der **gesamte** Bestand des globalen
* Telefonbuchs.
*
* Drei Wege, in dieser Reihenfolge:
*
* 1. Der **Cache**, den die App seit Protokoll 8 beim Verbinden von selbst
* schickt. Er liegt dann schon vor, bevor der Bereich geöffnet wird kein
* Laden, kein Wartedialog.
* 2. Sonst beim Öffnen das Kommando `addresses`: derselbe Cache auf Anfrage.
* 3. Kennt die App das Kommando nicht oder ist der Cache leer, werden rund 110
* Einzelabfragen zusammengesetzt. Das dauert einige Sekunden und läuft hinter
* einem Wartedialog.
*
* Danach liegt der Bestand im Browser: gefiltert wird ohne weitere Abfrage.
*/
export default function ContactsPanel({ active, disabled, cache, onLoad, onDial }: Props) {
const [directory, setDirectory] = useState<Directory | null>(null)
const [progress, setProgress] = useState<DirectoryProgress | null>(null)
const [filter, setFilter] = useState(() => stored(STORAGE_FILTER))
// Nach einem Verbindungsabbruch soll beim nächsten Öffnen neu geladen werden.
const loadedRef = useRef(false)
const abortRef = useRef<AbortController | null>(null)
// Zählt die Ladevorgänge. Trifft der Cache ein, während die Einzelabfragen
// noch laufen, darf deren abgebrochenes Ergebnis den Cache nicht überschreiben.
const loadIdRef = useRef(0)
/** Macht einen laufenden Ladevorgang ungültig und beendet ihn. */
const supersede = useCallback(() => {
loadIdRef.current += 1
abortRef.current?.abort()
abortRef.current = null
setProgress(null)
}, [])
const load = useCallback(async () => {
// Ein zweiter Durchlauf würde nur dieselben Abfragen doppelt stellen.
supersede()
const ticket = loadIdRef.current
const controller = new AbortController()
abortRef.current = controller
setProgress({ done: 0, total: 0, found: 0 })
try {
const result = await onLoad({
signal: controller.signal,
onProgress: (next) => {
if (ticket === loadIdRef.current) setProgress(next)
},
})
if (ticket !== loadIdRef.current) return
// null heißt: Fehler der steht bereits in der Fehleranzeige der Seite.
if (result) setDirectory(result)
} finally {
if (ticket === loadIdRef.current) {
abortRef.current = null
setProgress(null)
}
}
}, [onLoad, supersede])
// Der Cache der App hat Vorrang: Er kommt unaufgefordert und ist damit oft
// schon da, bevor der Bereich überhaupt geöffnet wird. Ein leerer Cache ist
// dagegen keine Antwort dann bleibt es beim Laden weiter unten.
useEffect(() => {
if (!cache || cache.length === 0) return
loadedRef.current = true
supersede()
setDirectory({
contacts: sortContacts(cache),
source: 'cache',
queries: 0,
complete: true,
aborted: false,
})
}, [cache, supersede])
useEffect(() => {
if (disabled) {
// Ohne Verbindung ist der Bestand veraltet; er wird beim nächsten Mal neu geholt.
supersede()
loadedRef.current = false
setDirectory(null)
return
}
if (!active || loadedRef.current) return
loadedRef.current = true
void load()
}, [active, disabled, load, supersede])
// Ein laufender Durchlauf soll nicht weiterfragen, wenn die Seite verschwindet.
useEffect(() => () => abortRef.current?.abort(), [])
const visible = useMemo(
() => (directory ? filterContacts(directory.contacts, filter) : []),
[directory, filter],
)
const loading = progress !== null
return (
<>
<div className="card__header">
<h2>Adressdaten</h2>
<button
type="button"
className="button button--ghost"
onClick={() => void load()}
disabled={disabled || loading}
>
{loading ? 'Lädt …' : 'Neu laden'}
</button>
</div>
{disabled ? (
<p className="note">Erst mit der SwyxTray-App verbinden.</p>
) : (
<>
{/* Die `.row` ist nicht bloß Zierde: `.field` wächst (`flex: 1 1 260px`)
und würde als direktes Kind der Karte einer Spalte in die *Höhe*
wachsen und den freien Platz aufsaugen. In der Zeile wächst es in
die Breite, wie in allen anderen Bereichen auch. */}
<div className="row">
<label className="field">
<span className="field__label">Filtern</span>
<input
className="field__input"
type="search"
value={filter}
spellCheck={false}
autoComplete="off"
placeholder="Name, Rufnummer oder Kürzel"
onChange={(e) => {
setFilter(e.target.value)
remember(STORAGE_FILTER, e.target.value.trim())
}}
/>
</label>
</div>
{directory === null ? (
<p className="note">{loading ? 'Adressdaten werden geladen …' : 'Noch nicht geladen.'}</p>
) : (
<>
<p className="note">
{filter.trim()
? `${visible.length} von ${directory.contacts.length} Einträgen`
: `${directory.contacts.length} Einträge`}
{directory.source === 'cache'
? ' · aus dem Adress-Cache der App'
: ` · aus ${directory.queries} Einzelabfragen zusammengesetzt`}
</p>
{!directory.complete && (
<p className="note note--error">
{directory.aborted
? 'Abgebrochen der Bestand ist unvollständig.'
: 'Die App hat mindestens eine Abfrage gekürzt; es können Einträge fehlen.'}{' '}
Neu laden" versucht es erneut.
</p>
)}
{visible.length === 0 ? (
<p className="note">Kein Eintrag zu „{filter.trim()}".</p>
) : (
<ul className="contacts">
{visible.map((contact, index) => (
// Namen sind nicht eindeutig dieselbe Person kommt mit
// mehreren Durchwahlen vor; deshalb die Position mit hinein.
<li
key={`${contact.number ?? ''}-${contact.name ?? ''}-${index}`}
className="contacts__item"
>
<span className="contacts__info">
<span className="contacts__name">{describeContact(contact)}</span>
{contact.description && (
<span className="contacts__description">{contact.description}</span>
)}
</span>
{contact.number && <span className="contacts__number">{contact.number}</span>}
{contact.number && (
<button
type="button"
className="button button--small"
onClick={() => onDial(contact.number!)}
>
Anrufen
</button>
)}
</li>
))}
</ul>
)}
</>
)}
</>
)}
<p className="note">
Der Bestand kommt aus dem Telefonbuch des Swyx-Clients auf dem Rechner der SwyxTray-App
aus deren Adress-Cache, den sie beim Verbinden von selbst schickt. Kennt die App den Cache
noch nicht oder ist er leer, wird der Bestand ersatzweise aus rund 110 Suchabfragen
zusammengesetzt. Danach liegt er vollständig im Browser; das Filtern läuft ohne weitere
Abfrage.
</p>
{loading && (
<LoadingDialog
title="Adressdaten werden geladen"
done={progress.done}
total={progress.total}
detail={progress.found > 0 ? `${progress.found} Einträge bisher` : undefined}
onCancel={() => abortRef.current?.abort()}
/>
)}
</>
)
}
+74
View File
@@ -0,0 +1,74 @@
import { useEffect, useRef, type ReactNode } from 'react'
interface Props {
title: string
/** Zusatzzeile unter dem Balken, z. B. „287 Einträge bisher". */
detail?: ReactNode
done: number
/** 0 heißt: Umfang noch unbekannt dann läuft der Balken unbestimmt. */
total: number
onCancel?: () => void
}
/**
* Wartedialog mit Fortschritt. Bewusst ein eigenes Overlay statt `<dialog>`:
* die Panels der Startseite liegen in `hidden`-Bereichen, und `showModal()`
* verhielte sich darin je nach Browser unterschiedlich.
*/
export default function LoadingDialog({ title, detail, done, total, onCancel }: Props) {
const cancelRef = useRef<HTMLButtonElement>(null)
// Der Abbrechen-Knopf ist das einzige Bedienelement er bekommt den Fokus,
// damit Escape und Leertaste sofort greifen.
useEffect(() => {
cancelRef.current?.focus()
}, [])
useEffect(() => {
if (!onCancel) return
function onKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') onCancel!()
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [onCancel])
const percent = total > 0 ? Math.min(100, Math.round((done / total) * 100)) : 0
return (
<div className="overlay">
<div className="dialog" role="dialog" aria-modal="true" aria-labelledby="loading-dialog-title">
<h2 className="dialog__title" id="loading-dialog-title">
{title}
</h2>
<div
className="progress"
role="progressbar"
aria-valuenow={total > 0 ? done : undefined}
aria-valuemin={0}
aria-valuemax={total > 0 ? total : undefined}
aria-valuetext={total > 0 ? `${done} von ${total}` : 'läuft'}
>
<div
className={`progress__bar${total > 0 ? '' : ' progress__bar--unknown'}`}
style={total > 0 ? { width: `${percent}%` } : undefined}
/>
</div>
<p className="dialog__status" aria-live="polite">
{total > 0 ? `${done} von ${total} Abfragen` : 'Abfragen laufen …'}
{detail && <span className="dialog__detail">{detail}</span>}
</p>
{onCancel && (
<div className="dialog__actions">
<button ref={cancelRef} type="button" className="button" onClick={onCancel}>
Abbrechen
</button>
</div>
)}
</div>
</div>
)
}
+7
View File
@@ -1,6 +1,8 @@
export interface Tab<Id extends string> { export interface Tab<Id extends string> {
id: Id id: Id
label: string label: string
/** Anzahl noch nicht gesehener Nachrichten; 0 oder fehlend blendet ihn aus. */
badge?: number
} }
interface Props<Id extends string> { interface Props<Id extends string> {
@@ -35,6 +37,11 @@ export default function Tabs<Id extends string>({ tabs, active, onChange }: Prop
onClick={() => onChange(tab.id)} onClick={() => onChange(tab.id)}
> >
{tab.label} {tab.label}
{tab.badge ? (
<span className="tabs__badge" aria-label={`${tab.badge} neu`}>
{tab.badge > 99 ? '99+' : tab.badge}
</span>
) : null}
</button> </button>
))} ))}
</nav> </nav>
+94
View File
@@ -0,0 +1,94 @@
import { formatPayload, summarize, type WebhookEvent } from '../webhook'
import type { WebhookStatus } from '../hooks/useWebhook'
interface Props {
events: WebhookEvent[]
status: WebhookStatus
error: string | null
onClear: () => void
}
const timeFormat = new Intl.DateTimeFormat('de-DE', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
const dateFormat = new Intl.DateTimeFormat('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
/** Eingangszeit; das Datum nur, wenn die Nachricht nicht von heute ist. */
function describeTime(receivedAt: string): string {
const at = new Date(receivedAt)
if (Number.isNaN(at.getTime())) return receivedAt
const today = new Date()
const sameDay =
at.getFullYear() === today.getFullYear() &&
at.getMonth() === today.getMonth() &&
at.getDate() === today.getDate()
return sameDay ? timeFormat.format(at) : `${dateFormat.format(at)} ${timeFormat.format(at)}`
}
const STATUS_TEXT: Record<WebhookStatus, string> = {
connecting: 'Verbindung wird aufgebaut …',
open: 'Verbunden neue Nachrichten erscheinen sofort.',
closed: 'Nicht verbunden. Seite neu laden, um den Strom wieder zu öffnen.',
}
/**
* Zeigt die über den Webhook eingegangenen Nachrichten jüngste zuerst, jede
* mit ihrem JSON im Original.
*
* Bewusst ohne Deutung der Nutzlast: Das aufrufende System muss sich an kein
* Schema halten, deshalb wird angezeigt, was ankam. Nur die Kopfzeile fasst
* `name` und `number` zusammen, wenn es sie gibt.
*/
export default function WebhookPanel({ events, status, error, onClear }: Props) {
return (
<>
<div className="card__header">
<h2>Webhook</h2>
<button
type="button"
className="button button--ghost"
onClick={onClear}
disabled={events.length === 0}
>
Leeren
</button>
</div>
<p className={`note${status === 'closed' ? ' note--error' : ''}`}>{STATUS_TEXT[status]}</p>
{error && <p className="note note--error">{error}</p>}
{events.length === 0 ? (
<p className="note">
Noch keine Nachricht eingegangen. Ein fremdes System schickt Adressdaten an{' '}
<code>POST /api/webhook</code>; sie erscheinen hier ohne Zutun.
</p>
) : (
<ul className="webhook">
{events.map((event) => (
<li key={event.id} className="webhook__item">
<div className="webhook__meta">
<span className="webhook__time">{describeTime(event.receivedAt)}</span>
<span className="webhook__id">#{event.id}</span>
<span className="webhook__summary">{summarize(event.payload)}</span>
</div>
<pre className="webhook__json">{formatPayload(event.payload)}</pre>
</li>
))}
</ul>
)}
<p className="note">
Der Webhook nimmt beliebiges JSON an Objekt wie Liste und zeigt es hier unverändert.
Der Verlauf liegt im Speicher des Backends und ist nach dessen Neustart leer; Leeren"
verwirft ihn auch dort.
</p>
</>
)
}
+31 -1
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { SwyxTrayClient, type ConnectionStatus, type RawMessage } from '../swyx/SwyxTrayClient' import { SwyxTrayClient, type ConnectionStatus, type RawMessage } from '../swyx/SwyxTrayClient'
import { mergeSnapshot, type CallEvent, type SnapshotMessage } from '../swyx/protocol' import { mergeSnapshot, type CallEvent, type Contact, type SnapshotMessage } from '../swyx/protocol'
import { loadDirectory as loadDirectoryFrom, type SweepOptions } from '../swyx/directory'
export interface LogEntry extends RawMessage { export interface LogEntry extends RawMessage {
id: number id: number
@@ -31,6 +32,8 @@ export function useSwyxTray() {
// Belegte Leitungen, nach Leitungsnummer; jeder Snapshot schreibt sie neu. // Belegte Leitungen, nach Leitungsnummer; jeder Snapshot schreibt sie neu.
const [lines, setLines] = useState<Record<number, CallEvent>>({}) const [lines, setLines] = useState<Record<number, CallEvent>>({})
const [tray, setTray] = useState<TrayState>({}) const [tray, setTray] = useState<TrayState>({})
// Adress-Cache der App; null heißt „noch keine Cache-Nachricht erhalten".
const [addressCache, setAddressCache] = useState<Contact[] | null>(null)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const clientRef = useRef<SwyxTrayClient | null>(null) const clientRef = useRef<SwyxTrayClient | null>(null)
@@ -54,9 +57,14 @@ export function useSwyxTray() {
linesRef.current = {} linesRef.current = {}
setLines({}) setLines({})
setTray({}) setTray({})
// Auch der Cache gehörte zur alten Verbindung; die App schickt ihn
// beim Verbinden erneut.
setAddressCache(null)
} }
}), }),
client.on('error', (message) => setError(message)), client.on('error', (message) => setError(message)),
// Unaufgefordert: beim Verbinden und wenn die App ihren Cache erneuert.
client.on('addresses', (contacts) => setAddressCache(contacts)),
client.on('raw', (message) => { client.on('raw', (message) => {
setLog((entries) => { setLog((entries) => {
const next = [...entries, { ...message, id: logIdRef.current++ }] const next = [...entries, { ...message, id: logIdRef.current++ }]
@@ -123,6 +131,26 @@ export function useSwyxTray() {
const openTab = useCallback((url: string) => run(() => client.openTab(url)), [client, run]) const openTab = useCallback((url: string) => run(() => client.openTab(url)), [client, run])
const closeTab = useCallback((tabId: number) => run(() => client.closeTab(tabId)), [client, run]) const closeTab = useCallback((tabId: number) => run(() => client.closeTab(tabId)), [client, run])
/**
* Holt den **gesamten** Adressbestand über den Adress-Cache der App
* (`addresses`), sonst über rund 110 Einzelabfragen. Der zweite Weg braucht
* einige Sekunden; daher Fortschritt und Abbruchmöglichkeit. Der Busy-Zustand
* gilt für den ganzen Vorgang, nicht für jede Einzelabfrage.
*/
const loadDirectory = useCallback(
(options: SweepOptions = {}) =>
run(() =>
loadDirectoryFrom(
{
listAddresses: async () => (await client.listAddresses()).addresses ?? [],
search: async (query) => (await client.searchContacts(query)).contacts ?? [],
},
options,
),
),
[client, run],
)
const sendRaw = useCallback( const sendRaw = useCallback(
(text: string) => { (text: string) => {
try { try {
@@ -157,6 +185,7 @@ export function useSwyxTray() {
calls, calls,
ringingCall, ringingCall,
tray, tray,
addressCache,
busy, busy,
connect, connect,
disconnect, disconnect,
@@ -168,6 +197,7 @@ export function useSwyxTray() {
listTabs, listTabs,
openTab, openTab,
closeTab, closeTab,
loadDirectory,
sendRaw, sendRaw,
clearLog, clearLog,
} }
+92
View File
@@ -0,0 +1,92 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import {
clearHistory,
fetchHistory,
isWebhookEvent,
mergeEvent,
mergeEvents,
type WebhookEvent,
} from '../webhook'
export type WebhookStatus = 'connecting' | 'open' | 'closed'
export interface WebhookState {
events: WebhookEvent[]
status: WebhookStatus
error: string | null
clear: () => Promise<void>
}
/**
* Hört auf die Nachrichten des Webhooks.
*
* Der Strom läuft über eine `EventSource` auf `/api/webhook/events` und wird
* **unabhängig vom geöffneten Bereich** gehalten: So ist schon alles da, wenn
* der Bereich „Webhook" aufgeschlagen wird. Bricht die Verbindung ab, verbindet
* der Browser von selbst neu und schickt dabei die `Last-Event-ID` mit das
* Backend liefert dann nach, was in der Lücke ankam.
*
* Zusätzlich wird beim Start der Verlauf geholt, damit auch Nachrichten von vor
* dem Laden der Seite erscheinen.
*/
export function useWebhook(): WebhookState {
const [events, setEvents] = useState<WebhookEvent[]>([])
const [status, setStatus] = useState<WebhookStatus>('connecting')
const [error, setError] = useState<string | null>(null)
const sourceRef = useRef<EventSource | null>(null)
useEffect(() => {
const controller = new AbortController()
fetchHistory(controller.signal)
.then((history) => {
if (controller.signal.aborted) return
setEvents((current) => mergeEvents(current, history))
})
.catch((e: unknown) => {
if (controller.signal.aborted) return
setError(e instanceof Error ? e.message : String(e))
})
const source = new EventSource('/api/webhook/events')
sourceRef.current = source
source.onopen = () => {
setStatus('open')
setError(null)
}
source.addEventListener('webhook', (event) => {
try {
const parsed: unknown = JSON.parse((event as MessageEvent<string>).data)
if (isWebhookEvent(parsed)) setEvents((current) => mergeEvent(current, parsed))
} catch {
// Unlesbare Nachricht: lieber übergehen als die Anzeige abstürzen lassen.
}
})
source.onerror = () => {
// Der Browser verbindet von selbst neu; nur der endgültige Abbruch ist final.
setStatus(source.readyState === EventSource.CLOSED ? 'closed' : 'connecting')
}
return () => {
controller.abort()
source.close()
sourceRef.current = null
}
}, [])
const clear = useCallback(async () => {
try {
await clearHistory()
setEvents([])
setError(null)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : String(e))
}
}, [])
return { events, status, error, clear }
}
+200
View File
@@ -547,6 +547,127 @@ code {
white-space: nowrap; white-space: nowrap;
} }
.overlay {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgb(0 0 0 / 55%);
}
.dialog {
width: min(420px, 100%);
padding: 24px;
border-radius: 12px;
border: 1px solid var(--border);
background: var(--surface);
box-shadow: 0 24px 60px rgb(0 0 0 / 45%);
}
.dialog__title {
margin: 0 0 16px;
font-size: 18px;
}
.dialog__status {
margin: 12px 0 0;
font-size: 14px;
color: var(--text-muted);
}
.dialog__detail {
display: block;
}
.dialog__actions {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
.progress {
height: 8px;
border-radius: 999px;
background: var(--border);
overflow: hidden;
}
.progress__bar {
height: 100%;
border-radius: 999px;
background: var(--accent);
transition: width 120ms linear;
}
/* Umfang noch unbekannt: ein wanderndes Stück statt eines Füllstands. */
.progress__bar--unknown {
width: 35%;
animation: progress-slide 1.1s ease-in-out infinite;
}
@keyframes progress-slide {
from {
transform: translateX(-100%);
}
to {
transform: translateX(300%);
}
}
.contacts {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
/* Mehrere hundert Einträge die Liste darf die Seite nicht sprengen. */
max-height: 520px;
overflow-y: auto;
}
.contacts__item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--surface-2);
}
.contacts__info {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-width: 0;
}
.contacts__name {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.contacts__description {
font-size: 13px;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.contacts__number {
flex: 0 0 auto;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 14px;
font-weight: 600;
}
.log { .log {
height: 300px; height: 300px;
overflow-y: auto; overflow-y: auto;
@@ -595,3 +716,82 @@ code {
.log__entry--system { .log__entry--system {
color: var(--text-muted); color: var(--text-muted);
} }
/* Webhook: eingegangene Nachrichten, jüngste zuerst. */
.webhook {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.webhook__item {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--surface-2);
}
.webhook__meta {
display: flex;
align-items: baseline;
gap: 8px;
/* Bei schmalem Fenster darf die Kopfzeile umbrechen, statt zu überlaufen. */
flex-wrap: wrap;
}
.webhook__time {
font-variant-numeric: tabular-nums;
color: var(--text-muted);
font-size: 13px;
}
.webhook__id {
padding: 1px 8px;
border-radius: 6px;
background: var(--border);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
font-weight: 600;
}
.webhook__summary {
flex: 1 1 auto;
min-width: 0;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.webhook__json {
margin: 0;
padding: 10px 12px;
border-radius: 6px;
background: var(--surface);
border: 1px solid var(--border);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 13px;
line-height: 1.45;
/* Lange Zeilen scrollen im Block, statt die Seite breit zu ziehen. */
overflow-x: auto;
/* Ein einzelner Riesenwert soll die Karte nicht sprengen. */
max-height: 320px;
overflow-y: auto;
}
.tabs__badge {
margin-left: 8px;
padding: 1px 7px;
border-radius: 999px;
background: var(--accent);
color: var(--surface);
font-size: 12px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
+55 -3
View File
@@ -2,18 +2,24 @@ import { useEffect, useRef, useState, type FormEvent, type ReactNode } from 'rea
import { DEFAULT_WS_URL, fetchClientConfig } from '../config' import { DEFAULT_WS_URL, fetchClientConfig } from '../config'
import { useSwyxTray } from '../hooks/useSwyxTray' import { useSwyxTray } from '../hooks/useSwyxTray'
import { useCallNotifications } from '../hooks/useCallNotifications' import { useCallNotifications } from '../hooks/useCallNotifications'
import { useWebhook } from '../hooks/useWebhook'
import { countUnseen } from '../webhook'
import StatusBadge from '../components/StatusBadge' import StatusBadge from '../components/StatusBadge'
import MessageLog from '../components/MessageLog' import MessageLog from '../components/MessageLog'
import IncomingCallCard from '../components/IncomingCallCard' import IncomingCallCard from '../components/IncomingCallCard'
import DialPanel from '../components/DialPanel' import DialPanel from '../components/DialPanel'
import BrowserTabsPanel from '../components/BrowserTabsPanel' import BrowserTabsPanel from '../components/BrowserTabsPanel'
import ContactsPanel from '../components/ContactsPanel'
import WebhookPanel from '../components/WebhookPanel'
import Tabs, { panelId, tabId, type Tab } from '../components/Tabs' import Tabs, { panelId, tabId, type Tab } from '../components/Tabs'
import { ActiveCallList, CallHistory } from '../components/CallList' import { ActiveCallList, CallHistory } from '../components/CallList'
type TabName = 'calls' | 'browsertabs' | 'connection' type TabName = 'calls' | 'contacts' | 'webhook' | 'browsertabs' | 'connection'
const TABS: Tab<TabName>[] = [ const TABS: Tab<TabName>[] = [
{ id: 'calls', label: 'Anrufe' }, { id: 'calls', label: 'Anrufe' },
{ id: 'contacts', label: 'Adressdaten' },
{ id: 'webhook', label: 'Webhook' },
{ id: 'browsertabs', label: 'Tabs' }, { id: 'browsertabs', label: 'Tabs' },
{ id: 'connection', label: 'Verbindung' }, { id: 'connection', label: 'Verbindung' },
] ]
@@ -40,6 +46,25 @@ export default function HomePage() {
const { permission, requestPermission } = useCallNotifications(ringingCall) const { permission, requestPermission } = useCallNotifications(ringingCall)
// Läuft unabhängig vom geöffneten Bereich: Der Webhook hängt am Backend, nicht
// an der SwyxTray-Verbindung, und soll auch dann mitschreiben, wenn gerade ein
// anderer Bereich offen ist.
const webhook = useWebhook()
// Eine Webhook-Nachricht kommt unabhängig davon an, welcher Bereich offen ist.
// Damit sie nicht unbemerkt bleibt, trägt der Tab die Zahl der noch nicht
// gesehenen Nachrichten; gesehen ist alles, was bei offenem Bereich ankam.
const [seenWebhookId, setSeenWebhookId] = useState(0)
const newestWebhookId = webhook.events[0]?.id ?? 0
useEffect(() => {
if (tab === 'webhook') setSeenWebhookId(newestWebhookId)
}, [tab, newestWebhookId])
const unseenWebhook = countUnseen(webhook.events, seenWebhookId)
const tabsWithBadges = TABS.map((entry) =>
entry.id === 'webhook' ? { ...entry, badge: unseenWebhook } : entry,
)
// Der Benutzer soll seine eingetippte Adresse nicht durch die Backend-Antwort verlieren. // Der Benutzer soll seine eingetippte Adresse nicht durch die Backend-Antwort verlieren.
const urlTouched = useRef(false) const urlTouched = useRef(false)
@@ -93,7 +118,7 @@ export default function HomePage() {
/> />
)} )}
<Tabs tabs={TABS} active={tab} onChange={setTab} /> <Tabs tabs={tabsWithBadges} active={tab} onChange={setTab} />
<TabPanel id="calls" active={tab === 'calls'}> <TabPanel id="calls" active={tab === 'calls'}>
<section className="card"> <section className="card">
@@ -130,6 +155,32 @@ export default function HomePage() {
</section> </section>
</TabPanel> </TabPanel>
<TabPanel id="contacts" active={tab === 'contacts'}>
<section className="card">
<ContactsPanel
active={tab === 'contacts'}
disabled={!isConnected}
cache={tray.addressCache}
// Unverpackt weitergereicht: der Rückruf ist stabil, damit das
// Panel nicht bei jedem Rendern neu lädt.
onLoad={tray.loadDirectory}
onDial={(number) => void tray.dial(number)}
/>
{error && <p className="note note--error">{error}</p>}
</section>
</TabPanel>
<TabPanel id="webhook" active={tab === 'webhook'}>
<section className="card">
<WebhookPanel
events={webhook.events}
status={webhook.status}
error={webhook.error}
onClear={() => void webhook.clear()}
/>
</section>
</TabPanel>
<TabPanel id="browsertabs" active={tab === 'browsertabs'}> <TabPanel id="browsertabs" active={tab === 'browsertabs'}>
<section className="card"> <section className="card">
<BrowserTabsPanel <BrowserTabsPanel
@@ -240,7 +291,8 @@ export default function HomePage() {
<footer className="page__footer"> <footer className="page__footer">
SwyxTray · Kommandos <code>call</code> / <code>answer</code> / <code>hangup</code> /{' '} SwyxTray · Kommandos <code>call</code> / <code>answer</code> / <code>hangup</code> /{' '}
<code>tabs</code> / <code>opentab</code> / <code>closetab</code> · Zustand über{' '} <code>tabs</code> / <code>opentab</code> / <code>closetab</code> / <code>contacts</code> ·
Zustand über{' '}
<code>snapshot</code> · Standardziel <code>{DEFAULT_WS_URL}</code> <code>snapshot</code> · Standardziel <code>{DEFAULT_WS_URL}</code>
</footer> </footer>
</div> </div>
+45 -2
View File
@@ -1,8 +1,11 @@
import { import {
COMMANDS, COMMANDS,
CONTACT_QUERY_MAX_LENGTH,
parseAddresses,
parseHello, parseHello,
parseResult, parseResult,
parseSnapshot, parseSnapshot,
type Contact,
type HelloMessage, type HelloMessage,
type ResultMessage, type ResultMessage,
type SnapshotMessage, type SnapshotMessage,
@@ -20,6 +23,8 @@ interface Listeners {
status: (status: ConnectionStatus) => void status: (status: ConnectionStatus) => void
hello: (hello: HelloMessage) => void hello: (hello: HelloMessage) => void
snapshot: (snapshot: SnapshotMessage) => void snapshot: (snapshot: SnapshotMessage) => void
/** Unaufgeforderter Adress-Cache der App; auch eine leere Liste zählt. */
addresses: (contacts: Contact[]) => void
raw: (message: RawMessage) => void raw: (message: RawMessage) => void
error: (message: string) => void error: (message: string) => void
} }
@@ -50,6 +55,7 @@ export class SwyxTrayClient {
status: new Set(), status: new Set(),
hello: new Set(), hello: new Set(),
snapshot: new Set(), snapshot: new Set(),
addresses: new Set(),
raw: new Set(), raw: new Set(),
error: new Set(), error: new Set(),
} }
@@ -179,6 +185,35 @@ export class SwyxTrayClient {
return this.send(COMMANDS.closeTab, { tabId }, TAB_TIMEOUT_MS) return this.send(COMMANDS.closeTab, { tabId }, TAB_TIMEOUT_MS)
} }
/**
* Durchsucht die Adressdaten des Swyx-Clients. Gesucht wird als
* Teilzeichenkette in Name und Rufnummer; die App liefert höchstens 100
* Treffer und kürzt ohne Hinweis. Ein leerer
* Suchbegriff wäre ein Fehler der App er wird hier gar nicht erst gesendet.
*/
searchContacts(query: string): Promise<ResultMessage> {
const wanted = query.trim()
if (!wanted) {
return Promise.reject(new Error('Bitte einen Suchbegriff angeben.'))
}
if (wanted.length > CONTACT_QUERY_MAX_LENGTH) {
return Promise.reject(
new Error(`Der Suchbegriff darf höchstens ${CONTACT_QUERY_MAX_LENGTH} Zeichen lang sein.`),
)
}
return this.send(COMMANDS.contacts, { query: wanted })
}
/**
* Holt den Adress-Cache der App am Stück ohne Suchbegriff und ohne Deckel.
* Ältere App-Versionen kennen das Kommando nicht und antworten mit
* `"Unbekanntes Kommando 'addresses'."`; die Startseite weicht dann auf die
* Einzelabfragen aus.
*/
listAddresses(): Promise<ResultMessage> {
return this.send(COMMANDS.addresses)
}
ping(): Promise<ResultMessage> { ping(): Promise<ResultMessage> {
return this.send(COMMANDS.ping) return this.send(COMMANDS.ping)
} }
@@ -270,14 +305,22 @@ export class SwyxTrayClient {
return return
} }
// Die App schickt keine Ereignisse: Jede Änderung auch ein eingehender // Die App schickt keine Anruf-Ereignisse: Jede Änderung auch ein
// Anruf kommt als vollständiger Snapshot. // eingehender Anruf kommt als vollständiger Snapshot.
const snapshot = parseSnapshot(parsed) const snapshot = parseSnapshot(parsed)
if (snapshot) { if (snapshot) {
this.emit('snapshot', snapshot) this.emit('snapshot', snapshot)
return return
} }
// Der Adress-Cache dagegen kommt unaufgefordert beim Verbinden und wenn
// die App ihn erneuert.
const addresses = parseAddresses(parsed)
if (addresses) {
this.emit('addresses', addresses)
return
}
const hello = parseHello(parsed) const hello = parseHello(parsed)
if (hello) { if (hello) {
this.emit('hello', hello) this.emit('hello', hello)
+282
View File
@@ -0,0 +1,282 @@
import { describe, expect, it, vi } from 'vitest'
import { CONTACT_RESULT_LIMIT, type Contact } from './protocol'
import {
contactKey,
filterContacts,
initialQueries,
loadDirectory,
sortContacts,
sweepDirectory,
type DirectoryProgress,
} from './directory'
/**
* Spielt die SwyxTray-App nach mit genau den Regeln, die gegen die laufende
* App sondiert wurden: Teilzeichenkette in **Name und Rufnummer** (nicht in der
* Beschreibung), Schreibweise egal, nach Namen sortiert und stillschweigend bei
* {@link CONTACT_RESULT_LIMIT} Treffern gekürzt.
*/
function fakeApp(contacts: Contact[]) {
const calls: string[] = []
const search = async (query: string): Promise<Contact[]> => {
calls.push(query)
const needle = query.toLowerCase()
return contacts
.filter(
(c) =>
(c.name ?? '').toLowerCase().includes(needle) ||
(c.number ?? '').toLowerCase().includes(needle),
)
.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''))
.slice(0, CONTACT_RESULT_LIMIT)
}
return { search, calls }
}
/**
* Bestand mit vierstelligen Rufnummern wie in der Anlage. Die Namen bestehen
* bewusst nur aus Buchstaben: eine Ziffer im Namen ließe die Ziffernabfragen
* auch über den Namen treffen, was es in der Anlage nicht gibt.
*/
function directoryOf(count: number, step = 1, from = 4000): Contact[] {
const letter = (i: number) => String.fromCharCode(97 + (i % 26))
return Array.from({ length: count }, (_, i) => ({
name: `Muster${letter(Math.floor(i / 676))}${letter(Math.floor(i / 26))}${letter(i)}, Max`,
number: String(from + i * step),
description: 'HH-MT',
}))
}
describe('initialQueries', () => {
it('fragt zehn Ziffern und hundert Ziffernpaare ab', () => {
const queries = initialQueries()
expect(queries).toHaveLength(110)
expect(new Set(queries).size).toBe(110)
expect(queries).toContain('0')
expect(queries).toContain('00')
expect(queries).toContain('99')
})
})
describe('sweepDirectory', () => {
it('findet den gesamten Bestand', async () => {
// 404 Einträge der Umfang der echten Anlage, hier aber auf einem
// lückenlosen Nummernblock: dann hängt "40" am Deckel und der Durchlauf
// muss verfeinern. Vollständig wird er trotzdem.
const contacts = directoryOf(404)
const { search } = fakeApp(contacts)
const result = await sweepDirectory(search)
expect(result.contacts).toHaveLength(404)
expect(result.complete).toBe(true)
expect(result.aborted).toBe(false)
expect(new Set(result.contacts.map(contactKey))).toEqual(new Set(contacts.map(contactKey)))
})
it('kommt ohne Verfeinerung aus, solange keine Abfrage gekürzt wird', async () => {
// Rufnummern mit Lücken, wie in einer gewachsenen Anlage.
const { search, calls } = fakeApp(directoryOf(404, 7))
const result = await sweepDirectory(search)
// Genau die 110 Abfragen der Grundliste gemessen ist bei der echten
// Anlage keine davon am Deckel (Höchstwert 59 Treffer).
expect(calls).toHaveLength(110)
expect(result.queries).toBe(110)
})
it('meldet jeden Eintrag nur einmal, auch wenn ihn mehrere Abfragen finden', async () => {
// "4123" wird von "41", "12", "23", "1", "2", "3", "4" gefunden.
const { search } = fakeApp([{ name: 'Muster, Max', number: '4123' }])
const result = await sweepDirectory(search)
expect(result.contacts).toHaveLength(1)
})
it('findet auch eine einstellige Rufnummer dafür sind die Ziffernabfragen da', async () => {
// "0" steckt in keinem Ziffernpaar; ohne die einstelligen Abfragen fehlte
// die Zentrale im Bestand.
const contacts = [...directoryOf(20), { name: 'Zentrale', number: '0', description: '' }]
const { search } = fakeApp(contacts)
const result = await sweepDirectory(search)
expect(result.contacts.map((c) => c.number)).toContain('0')
})
it('verfeinert eine gekürzte Abfrage und wird trotzdem vollständig', async () => {
// Alle 105 Nummern beginnen mit "12"; diese Abfrage hängt also am Deckel.
const contacts = Array.from({ length: 105 }, (_, i) => ({
name: `Muster${String.fromCharCode(97 + (i % 26))}${i}, Max`.replace(/\d/g, ''),
number: `12${i % 10}${String(i).padStart(3, '0')}`,
}))
const { search, calls } = fakeApp(contacts)
const result = await sweepDirectory(search)
expect(result.contacts).toHaveLength(105)
expect(result.complete).toBe(true)
// Verfeinert wurde vorn und hinten beides gehört dazu, weil eine
// Teilzeichenkette an beiden Enden weitergehen kann.
expect(calls.length).toBeGreaterThan(110)
expect(calls).toContain('120')
expect(calls).toContain('012')
})
it('gibt auf, wenn sich eine gekürzte Abfrage nicht mehr verfeinern lässt', async () => {
// Über hundert Einträge mit derselben dreistelligen Nummer: die Abfrage
// "123" bleibt gekürzt, egal wie oft verfeinert wird.
const contacts = directoryOf(130, 1).map((c) => ({ ...c, number: '123' }))
const { search } = fakeApp(contacts)
const result = await sweepDirectory(search)
expect(result.complete).toBe(false)
expect(result.contacts.length).toBeLessThan(130)
})
it('meldet den Fortschritt und zählt bis zum Ende hoch', async () => {
const onProgress = vi.fn<(p: DirectoryProgress) => void>()
const { search } = fakeApp(directoryOf(30))
const result = await sweepDirectory(search, { onProgress })
const reports = onProgress.mock.calls.map(([p]) => p)
expect(reports[0]).toEqual({ done: 0, total: 110, found: 0 })
expect(reports.at(-1)).toEqual({ done: 110, total: 110, found: 30 })
// Monoton sonst spränge der Balken im Wartedialog zurück.
expect(reports.map((p) => p.done)).toEqual([...reports.map((p) => p.done)].sort((a, b) => a - b))
expect(result.queries).toBe(110)
})
it('bricht ab, ohne den bisherigen Stand wegzuwerfen', async () => {
const contacts = directoryOf(404)
const controller = new AbortController()
const { search, calls } = fakeApp(contacts)
const result = await sweepDirectory(
async (query) => {
// Nach zwanzig Abfragen abbrechen wie ein Klick auf „Abbrechen".
if (calls.length >= 20) controller.abort()
return search(query)
},
{ signal: controller.signal, concurrency: 1 },
)
expect(result.aborted).toBe(true)
expect(result.complete).toBe(false)
// Kein Fehler, kein leeres Ergebnis: das bereits Geladene bleibt erhalten.
expect(result.contacts.length).toBeGreaterThan(0)
expect(result.contacts.length).toBeLessThan(404)
expect(calls.length).toBeLessThan(110)
})
it('reicht den Fehler einer Abfrage durch ein Abbruch ist etwas anderes', async () => {
await expect(
sweepDirectory(async () => {
throw new Error('Keine Verbindung zur SwyxTray-App.')
}),
).rejects.toThrow('Keine Verbindung zur SwyxTray-App.')
})
})
describe('loadDirectory', () => {
const cached: Contact[] = [
{ name: 'Muster GmbH', number: '+493012345', description: 'Globales Telefonbuch' },
{ name: 'Abt, Bettina', number: '7587', description: 'S-SB' },
]
it('nimmt den Adress-Cache der App und fragt dann nichts mehr', async () => {
const { search, calls } = fakeApp(directoryOf(404, 7))
const result = await loadDirectory({ listAddresses: async () => cached, search })
expect(result.source).toBe('cache')
expect(result.queries).toBe(1)
expect(result.complete).toBe(true)
// Eine einzige Nachricht statt 110 darum geht es beim Cache.
expect(calls).toHaveLength(0)
expect(result.contacts.map((c) => c.name)).toEqual(['Abt, Bettina', 'Muster GmbH'])
})
it('meldet jeden Eintrag des Caches nur einmal', async () => {
const doppelt = [...cached, { ...cached[0] }]
const { search } = fakeApp([])
const result = await loadDirectory({ listAddresses: async () => doppelt, search })
expect(result.contacts).toHaveLength(2)
})
it('weicht auf die Einzelabfragen aus, wenn die App das Kommando nicht kennt', async () => {
// Genau der Fall der Anlage am 21.08.2026.
const { search, calls } = fakeApp(directoryOf(404, 7))
const result = await loadDirectory({
listAddresses: async () => {
throw new Error("Unbekanntes Kommando 'addresses'.")
},
search,
})
expect(result.source).toBe('sweep')
expect(result.contacts).toHaveLength(404)
expect(calls).toHaveLength(110)
})
it('weicht auch bei leerem Cache aus so meldet sich die Anlage zurzeit', async () => {
const { search } = fakeApp(directoryOf(40, 7))
const result = await loadDirectory({ listAddresses: async () => [], search })
expect(result.source).toBe('sweep')
expect(result.contacts).toHaveLength(40)
})
it('fragt nach einem Abbruch gar nicht erst', async () => {
const controller = new AbortController()
controller.abort()
const listAddresses = vi.fn(async () => cached)
const { search, calls } = fakeApp(directoryOf(10))
const result = await loadDirectory({ listAddresses, search }, { signal: controller.signal })
expect(listAddresses).not.toHaveBeenCalled()
expect(calls).toHaveLength(0)
expect(result.aborted).toBe(true)
})
})
describe('sortContacts', () => {
it('sortiert nach Namen, bei gleichem Namen nach Rufnummer', () => {
const sorted = sortContacts([
{ name: 'Bandorf, Volker', number: '53119' },
{ name: 'Abt, Bettina', number: '7587' },
{ name: 'Bandorf, Volker', number: '41295' },
])
expect(sorted.map((c) => `${c.name} ${c.number}`)).toEqual([
'Abt, Bettina 7587',
'Bandorf, Volker 41295',
'Bandorf, Volker 53119',
])
})
it('lässt die Vorlage unangetastet', () => {
const input = [{ name: 'Z' }, { name: 'A' }]
sortContacts(input)
expect(input.map((c) => c.name)).toEqual(['Z', 'A'])
})
})
describe('filterContacts', () => {
const contacts: Contact[] = [
{ name: 'Abt, Bettina', number: '7587', description: 'S-SB' },
{ name: 'Muster, Max', number: '4711', description: 'HH-MT' },
]
it('gibt ohne Filter alles zurück', () => {
expect(filterContacts(contacts, ' ')).toEqual(contacts)
})
it('sucht in Namen und Rufnummer, Schreibweise egal', () => {
expect(filterContacts(contacts, 'bettina')).toHaveLength(1)
expect(filterContacts(contacts, '471')).toEqual([contacts[1]])
})
it('sucht auch in der Beschreibung anders als die App', () => {
// Der Bestand liegt vollständig im Browser; das Kürzel ist damit filterbar.
expect(filterContacts(contacts, 'hh-mt')).toEqual([contacts[1]])
})
})
Binary file not shown.
+122
View File
@@ -2,10 +2,13 @@ import { describe, expect, it } from 'vitest'
import { import {
callStateOf, callStateOf,
describeCall, describeCall,
describeContact,
describeTab, describeTab,
parseAddresses,
mergeSnapshot, mergeSnapshot,
parseHello, parseHello,
parseResult, parseResult,
parseContacts,
parseSnapshot, parseSnapshot,
parseTabs, parseTabs,
type CallEvent, type CallEvent,
@@ -79,6 +82,22 @@ describe('parseHello', () => {
?.protocol, ?.protocol,
).toBe(6) ).toBe(6)
}) })
it('liest die Protokollversion des Adress-Caches', () => {
// Wörtlich mitgeschnitten am 21.08.2026 gegen die laufende Anlage.
expect(
parseHello(JSON.parse('{"app":"SwyxTray","version":"1.0.0.0","protocol":8,"session":3,"type":"hello"}'))
?.protocol,
).toBe(8)
})
it('liest die Protokollversion der Adressdaten', () => {
// Wörtlich mitgeschnitten, nachdem die App `contacts` gelernt hatte.
expect(
parseHello(JSON.parse('{"app":"SwyxTray","version":"1.0.0.0","protocol":7,"session":3,"type":"hello"}'))
?.protocol,
).toBe(7)
})
}) })
describe('callStateOf', () => { describe('callStateOf', () => {
@@ -296,6 +315,56 @@ describe('parseResult', () => {
const result = parseResult(JSON.parse('{"id":11,"ok":true,"line":4,"type":"result"}')) const result = parseResult(JSON.parse('{"id":11,"ok":true,"line":4,"type":"result"}'))
expect(result?.tabs).toBeUndefined() expect(result?.tabs).toBeUndefined()
expect(result?.tabId).toBeUndefined() expect(result?.tabId).toBeUndefined()
expect(result?.contacts).toBeUndefined()
})
it('liest die Quittung auf contacts', () => {
// Wörtlich mitgeschnitten Suchbegriff "abt".
const raw =
'{"id":20,"ok":true,"contacts":[' +
'{"name":"Abt, Bettina","number":"7587","description":"S-SB"},' +
'{"name":"Abdul, Rokhsareh","number":"5215","description":""}],"type":"result"}'
expect(parseResult(JSON.parse(raw))?.contacts).toEqual([
{ name: 'Abt, Bettina', number: '7587', description: 'S-SB' },
// Die leere Beschreibung der App wird zu undefined.
{ name: 'Abdul, Rokhsareh', number: '5215', description: undefined },
])
})
it('liest die leere Trefferliste als leere Liste, nicht als "nicht gefragt"', () => {
expect(parseResult(JSON.parse('{"id":21,"ok":true,"contacts":[],"type":"result"}'))?.contacts).toEqual(
[],
)
})
it('liest die Fehlerquittung auf einen leeren Suchbegriff', () => {
const raw =
'{"id":22,"ok":false,' +
'"error":"Feld \'query\' fehlt oder ist zu lang (max. 128 Zeichen).","type":"result"}'
expect(parseResult(JSON.parse(raw))).toMatchObject({
id: 22,
ok: false,
error: "Feld 'query' fehlt oder ist zu lang (max. 128 Zeichen).",
})
})
it('liest die Quittung auf addresses', () => {
const raw =
'{"type":"result","id":5,"ok":true,"addresses":[' +
'{"name":"Muster GmbH","number":"+493012345","description":"Globales Telefonbuch"}]}'
expect(parseResult(JSON.parse(raw))?.addresses).toEqual([
{ name: 'Muster GmbH', number: '+493012345', description: 'Globales Telefonbuch' },
])
})
it('liest die Fehlerquittung älterer App-Versionen auf addresses', () => {
// Genau das antwortet die Anlage am 21.08.2026 noch.
const raw = '{"id":5,"ok":false,"error":"Unbekanntes Kommando \'addresses\'.","type":"result"}'
expect(parseResult(JSON.parse(raw))).toMatchObject({
id: 5,
ok: false,
error: "Unbekanntes Kommando 'addresses'.",
})
}) })
it('hält einen Snapshot für keine Quittung', () => { it('hält einen Snapshot für keine Quittung', () => {
@@ -324,6 +393,59 @@ describe('describeTab', () => {
}) })
}) })
describe('parseAddresses', () => {
it('liest die unaufgeforderte Cache-Nachricht', () => {
const raw =
'{"type":"addresses","addresses":[' +
'{"name":"Muster GmbH","number":"+493012345","description":"Globales Telefonbuch"}]}'
expect(parseAddresses(JSON.parse(raw))).toEqual([
{ name: 'Muster GmbH', number: '+493012345', description: 'Globales Telefonbuch' },
])
})
it('hält den leeren Cache für eine Antwort, nicht für ein Versehen', () => {
// Wörtlich mitgeschnitten: genau das schickt die Anlage zurzeit.
expect(parseAddresses(JSON.parse('{"addresses":[],"type":"addresses"}'))).toEqual([])
})
it('hält andere Nachrichten für keine Cache-Nachricht', () => {
expect(parseAddresses(JSON.parse(HELLO))).toBeNull()
expect(parseAddresses(snapshot())).toBeNull()
})
it('ist keine Quittung sie trägt weder id noch ok', () => {
expect(parseResult(JSON.parse('{"addresses":[],"type":"addresses"}'))).toBeNull()
})
})
describe('parseContacts', () => {
it('nimmt einen Eintrag ohne Namen hin die Rufnummer genügt', () => {
expect(parseContacts([{ number: '4711' }])).toEqual([
{ name: undefined, number: '4711', description: undefined },
])
})
it('verwirft Einträge ohne Namen und ohne Rufnummer', () => {
// Anzeigen ließe sich so ein Eintrag nicht, wählen erst recht nicht.
expect(parseContacts([{ description: 'HH-MT' }, 'Unfug', null, { name: 'Muster, Max' }])).toEqual([
{ name: 'Muster, Max', number: undefined, description: undefined },
])
})
it('hält die Reihenfolge der App ein sie sortiert bereits nach Namen', () => {
const entries = [{ name: 'Abt, Bettina', number: '7587' }, { name: 'Zentrale', number: '0' }]
expect(parseContacts(entries).map((c) => c.name)).toEqual(['Abt, Bettina', 'Zentrale'])
})
})
describe('describeContact', () => {
it('nimmt den Namen, sonst die Rufnummer', () => {
expect(describeContact({ name: 'Muster, Max', number: '4711' })).toBe('Muster, Max')
expect(describeContact({ number: '4711' })).toBe('4711')
expect(describeContact({})).toBe('Ohne Namen')
})
})
describe('describeCall', () => { describe('describeCall', () => {
it('bevorzugt die fertige Anzeigeform der App', () => { it('bevorzugt die fertige Anzeigeform der App', () => {
expect(describeCall({ line: 1, event: 'incoming', peer: 'Muster GmbH (+49301)' })).toBe( expect(describeCall({ line: 1, event: 'incoming', peer: 'Muster GmbH (+49301)' })).toBe(
+83 -3
View File
@@ -1,7 +1,7 @@
/** /**
* Protokoll der SwyxTray-App, aufgezeichnet gegen `ws://192.168.180.135:17654/ws` * Protokoll der SwyxTray-App, aufgezeichnet gegen `ws://192.168.180.135:17654/ws`
* (App-Version 1.0.0.0, `protocol: 1`; seit der Tab-Verwaltung meldet die App * (App-Version 1.0.0.0, `protocol: 1`; mit der Tab-Verwaltung meldete die App
* `protocol: 6`). * `protocol: 6`, mit der Adresssuche `protocol: 7`, mit dem Adress-Cache `protocol: 8`).
* *
* App → Seite: * App → Seite:
* { "app":"SwyxTray","version":"1.0.0.0","protocol":1,"session":7,"type":"hello" } * { "app":"SwyxTray","version":"1.0.0.0","protocol":1,"session":7,"type":"hello" }
@@ -31,7 +31,30 @@
* { "id":14,"cmd":"closetab","tabId":43 } → { …,"ok":true } * { "id":14,"cmd":"closetab","tabId":43 } → { …,"ok":true }
* Ist kein Plugin verbunden, antwortet die App nach rund fünf Sekunden mit `ok:false`. * Ist kein Plugin verbunden, antwortet die App nach rund fünf Sekunden mit `ok:false`.
* *
* **Die App kennt keine Ereignisnachrichten.** Jede Zustandsänderung auch ein * Protokoll 7 bringt die Adressdaten des Swyx-Clients das globale Telefonbuch:
* { "id":20,"cmd":"contacts","query":"abt" }
* → { "type":"result","id":20,"ok":true,
* "contacts":[{"name":"Abt, Bettina","number":"7587","description":"S-SB"}, …] }
* Gesucht wird als Teilzeichenkette in **Name und Rufnummer**, Schreibweise egal;
* die Beschreibung wird nicht durchsucht. Sortiert nach Namen, höchstens
* {@link CONTACT_RESULT_LIMIT} Treffer gekürzt wird stillschweigend. `query` ist
* Pflicht, darf nicht leer sein und höchstens {@link CONTACT_QUERY_MAX_LENGTH}
* Zeichen lang; sonst antwortet die App mit
* `"Feld 'query' fehlt oder ist zu lang (max. 128 Zeichen)."`.
*
* Protokoll 8 bringt den **Adress-Cache**: die App hält die Adressdaten selbst vor
* und gibt sie am Stück heraus ohne Suchbegriff und ohne Deckel.
* { "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** als eigene Nachricht,
* direkt nach dem ersten Snapshot und wenn sich ihr Cache ändert:
* { "type":"addresses","addresses":[…] }
* Beide tragen dieselben Einträge wie `contacts` Name, Rufnummer, Beschreibung.
* Der Push ist die einzige Nachricht der App **ohne** `id` und ohne `ok`.
*
* **Sonst kennt die App keine Ereignisnachrichten.** Jede Zustandsänderung auch ein
* eingehender Anruf kommt als vollständiger `snapshot` über *alle* Leitungen. * eingehender Anruf kommt als vollständiger `snapshot` über *alle* Leitungen.
* Die Anrufmeldung der Seite entsteht deshalb aus dem Vergleich zweier Snapshots * Die Anrufmeldung der Seite entsteht deshalb aus dem Vergleich zweier Snapshots
* (siehe {@link mergeSnapshot}). * (siehe {@link mergeSnapshot}).
@@ -66,6 +89,10 @@ export interface ResultMessage {
tabs?: BrowserTab[] tabs?: BrowserTab[]
/** Nur bei `opentab`: Kennung des neu geöffneten Tabs. */ /** Nur bei `opentab`: Kennung des neu geöffneten Tabs. */
tabId?: number tabId?: number
/** Nur bei `contacts`: die gefundenen Adressdaten. */
contacts?: Contact[]
/** Nur bei `addresses`: der gesamte Adress-Cache der App. */
addresses?: Contact[]
error?: string error?: string
} }
@@ -79,6 +106,26 @@ export interface BrowserTab {
active: boolean active: boolean
} }
/**
* Ein Eintrag aus den Adressdaten des Swyx-Clients, wie ihn die Quittung auf
* `contacts` meldet. Leere Felder schickt die App als `""`; hier bleiben sie
* `undefined`.
*/
export interface Contact {
/** Anzeigename, meist „Nachname, Vorname". */
name?: string
/** Rufnummer, meist die interne Durchwahl so, wie sie an `call` geht. */
number?: string
/** Zusatz der Telefonanlage, z. B. der Standort („HH-MT"). */
description?: string
}
/** Mehr Treffer meldet die App nicht; sie kürzt ohne Hinweis. */
export const CONTACT_RESULT_LIMIT = 100
/** Längere Suchbegriffe weist die App mit einem Fehler zurück. */
export const CONTACT_QUERY_MAX_LENGTH = 128
/** Begrüßung beim Verbindungsaufbau. */ /** Begrüßung beim Verbindungsaufbau. */
export interface HelloMessage { export interface HelloMessage {
app?: string app?: string
@@ -115,6 +162,8 @@ export const COMMANDS = {
tabs: 'tabs', tabs: 'tabs',
openTab: 'opentab', openTab: 'opentab',
closeTab: 'closetab', closeTab: 'closetab',
contacts: 'contacts',
addresses: 'addresses',
} as const } as const
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
@@ -207,6 +256,8 @@ export function parseResult(raw: unknown): ResultMessage | null {
focused: asBoolean(raw.focused), focused: asBoolean(raw.focused),
tabs: Array.isArray(raw.tabs) ? parseTabs(raw.tabs) : undefined, tabs: Array.isArray(raw.tabs) ? parseTabs(raw.tabs) : undefined,
tabId: asLine(raw.tabId), tabId: asLine(raw.tabId),
contacts: Array.isArray(raw.contacts) ? parseContacts(raw.contacts) : undefined,
addresses: Array.isArray(raw.addresses) ? parseContacts(raw.addresses) : undefined,
error: asString(raw.error), error: asString(raw.error),
} }
} }
@@ -236,6 +287,35 @@ export function describeTab(tab: BrowserTab): string {
return tab.title ?? tab.url ?? `Tab ${tab.id}` return tab.title ?? tab.url ?? `Tab ${tab.id}`
} }
/**
* Liest die Adressdaten aus der Quittung auf `contacts`. Einträge ohne Namen
* *und* ohne Rufnummer fallen weg sie ließen sich weder anzeigen noch wählen.
*/
export function parseContacts(raw: unknown[]): Contact[] {
return raw.flatMap((entry) => {
if (!isRecord(entry)) return []
const name = asString(entry.name)
const number = asString(entry.number)
if (name === undefined && number === undefined) return []
return [{ name, number, description: asString(entry.description) }]
})
}
/** Anzeigename eines Adresseintrags: Name, sonst die Rufnummer. */
export function describeContact(contact: Contact): string {
return contact.name ?? contact.number ?? 'Ohne Namen'
}
/**
* Liest die unaufgeforderte Cache-Nachricht `{"type":"addresses","addresses":[…]}`.
* Ein leeres Feld ist ein gültiger Zustand die App meldet damit einen leeren
* Cache und darf deshalb nicht wie „keine Nachricht" behandelt werden.
*/
export function parseAddresses(raw: unknown): Contact[] | null {
if (!isRecord(raw) || raw.type !== 'addresses') return null
return Array.isArray(raw.addresses) ? parseContacts(raw.addresses) : []
}
/** Liest die Begrüßung. */ /** Liest die Begrüßung. */
export function parseHello(raw: unknown): HelloMessage | null { export function parseHello(raw: unknown): HelloMessage | null {
if (!isRecord(raw) || raw.type !== 'hello') return null if (!isRecord(raw) || raw.type !== 'hello') return null
+106
View File
@@ -0,0 +1,106 @@
import { describe, expect, it } from 'vitest'
import {
countUnseen,
formatPayload,
isWebhookEvent,
mergeEvent,
mergeEvents,
summarize,
type WebhookEvent,
} from './webhook'
function event(id: number, payload: unknown = {}): WebhookEvent {
return { id, receivedAt: '2026-08-24T10:00:00Z', payload }
}
describe('isWebhookEvent', () => {
it('erkennt die Form des Backends', () => {
expect(isWebhookEvent(event(1))).toBe(true)
})
it('lässt jede Nutzlast durchgehen auch null', () => {
expect(isWebhookEvent({ id: 1, receivedAt: '2026-08-24T10:00:00Z', payload: null })).toBe(true)
})
it('weist alles ohne id oder Zeitstempel ab', () => {
expect(isWebhookEvent(null)).toBe(false)
expect(isWebhookEvent({ id: '1', receivedAt: 'x' })).toBe(false)
expect(isWebhookEvent({ id: 1 })).toBe(false)
})
})
describe('mergeEvent', () => {
it('stellt die jüngste Nachricht nach vorn', () => {
const merged = mergeEvent([event(1)], event(2))
expect(merged.map((e) => e.id)).toEqual([2, 1])
})
it('nimmt eine bereits bekannte id nicht doppelt auf', () => {
// Genau dieser Fall tritt im Betrieb auf: Der Verlauf wird geholt, während
// dieselbe Nachricht schon über den Ereignisstrom kam.
const merged = mergeEvent([event(2), event(1)], event(2, { neu: true }))
expect(merged.map((e) => e.id)).toEqual([2, 1])
expect(merged[0].payload).toEqual({ neu: true })
})
it('deckelt die Liste', () => {
const merged = mergeEvent([event(3), event(2), event(1)], event(4), 3)
expect(merged.map((e) => e.id)).toEqual([4, 3, 2])
})
it('sortiert auch dann nach id, wenn Nachrichten verspätet nachkommen', () => {
// Nach einem Wiederverbinden liefert das Backend Verpasstes nach.
const merged = mergeEvents([event(5)], [event(3), event(4)])
expect(merged.map((e) => e.id)).toEqual([5, 4, 3])
})
})
describe('summarize', () => {
it('fasst Adressdaten aus name und number zusammen', () => {
expect(summarize({ name: 'Muster GmbH', number: '+493012345' })).toBe('Muster GmbH · +493012345')
})
it('kommt mit nur einem der beiden Felder aus', () => {
expect(summarize({ name: 'Abt, Bettina' })).toBe('Abt, Bettina')
expect(summarize({ number: '7587' })).toBe('7587')
})
it('zählt die Einträge einer Liste', () => {
expect(summarize([{ name: 'A' }, { name: 'B' }])).toBe('2 Einträge')
expect(summarize([{ name: 'A' }])).toBe('1 Eintrag')
})
it('beschreibt fremde Formen, statt zu raten', () => {
expect(summarize({ kunde: 4711, quelle: 'CRM' })).toBe('2 Felder: kunde, quelle')
expect(summarize({})).toBe('leeres Objekt')
expect(summarize(null)).toBe('null')
expect(summarize(42)).toBe('42')
})
})
describe('formatPayload', () => {
it('rückt ein', () => {
expect(formatPayload({ a: 1 })).toBe('{\n "a": 1\n}')
})
it('kommt mit einfachen Werten zurecht', () => {
expect(formatPayload('text')).toBe('"text"')
expect(formatPayload(null)).toBe('null')
expect(formatPayload(undefined)).toBe('undefined')
})
})
describe('countUnseen', () => {
it('zählt, was seit dem letzten Blick dazukam', () => {
expect(countUnseen([event(3), event(2), event(1)], 1)).toBe(2)
})
it('zählt ohne vorherigen Blick alles', () => {
expect(countUnseen([event(2), event(1)], 0)).toBe(2)
})
it('zählt nichts, wenn die jüngste Nachricht gesehen ist', () => {
expect(countUnseen([event(2), event(1)], 2)).toBe(0)
expect(countUnseen([], 0)).toBe(0)
})
})
+120
View File
@@ -0,0 +1,120 @@
/**
* Webhook: Adressdaten, die ein fremdes System per POST an das Backend schickt.
*
* Das Backend nimmt unter `POST /api/webhook` **beliebiges** JSON an und reicht
* es über Server-Sent Events (`/api/webhook/events`) an die offenen Browser
* weiter. Dieses Modul enthält nur die reine Logik das Holen und Anzeigen
* stehen in `hooks/useWebhook.ts` und `components/WebhookPanel.tsx`.
*/
/** Eine über den Webhook eingegangene Nachricht, so wie das Backend sie liefert. */
export interface WebhookEvent {
/** Fortlaufend ab 1. Eindeutig bis zum Neustart des Backends. */
id: number
/** Eingangszeit im Backend, ISO-8601. */
receivedAt: string
/** Das empfangene JSON, unverändert. Objekt, Liste oder ein einfacher Wert. */
payload: unknown
}
/** So viele Nachrichten hält die Anzeige vor; das Backend deckelt zusätzlich. */
export const MAX_EVENTS = 50
/**
* Prüft grob, ob eine Nachricht die Form des Backends hat. Die Nutzlast bleibt
* bewusst ungeprüft sie darf alles sein.
*/
export function isWebhookEvent(value: unknown): value is WebhookEvent {
if (typeof value !== 'object' || value === null) return false
const candidate = value as Partial<WebhookEvent>
return typeof candidate.id === 'number' && typeof candidate.receivedAt === 'string'
}
/**
* Nimmt eine Nachricht in die Liste auf: jüngste zuerst, ohne Doppel und
* gedeckelt.
*
* Doppel entstehen im Normalbetrieb: Der Bereich holt beim Öffnen den Verlauf
* und hört gleichzeitig auf den Ereignisstrom, und nach einem Abbruch liefert
* das Backend anhand der `Last-Event-ID` nach. Entschieden wird über die `id`.
*/
export function mergeEvent(
events: WebhookEvent[],
incoming: WebhookEvent,
max = MAX_EVENTS,
): WebhookEvent[] {
const without = events.filter((event) => event.id !== incoming.id)
return [incoming, ...without].sort((a, b) => b.id - a.id).slice(0, max)
}
/** Wie {@link mergeEvent}, aber für den Verlauf am Stück. */
export function mergeEvents(
events: WebhookEvent[],
incoming: WebhookEvent[],
max = MAX_EVENTS,
): WebhookEvent[] {
return incoming.reduce((all, event) => mergeEvent(all, event, max), events)
}
/**
* Wie viele Nachrichten jünger sind als die zuletzt gesehene.
*
* Der Bereich „Webhook" ist nicht immer offen, die Nachrichten kommen aber
* trotzdem an. Daraus wird der Zähler am Tab.
*/
export function countUnseen(events: WebhookEvent[], seenId: number): number {
return events.filter((event) => event.id > seenId).length
}
/** Die Nutzlast lesbar eingerückt. */
export function formatPayload(payload: unknown): string {
try {
return JSON.stringify(payload, null, 2) ?? String(payload)
} catch {
// Zirkuläre Strukturen kann es über JSON nicht geben; bleibt die Notbremse.
return String(payload)
}
}
/**
* Kurzfassung für die Kopfzeile eines Eintrags. Adressdaten tragen üblicherweise
* `name` und `number`; alles andere wird der Form nach beschrieben, statt zu
* raten.
*/
export function summarize(payload: unknown): string {
if (Array.isArray(payload)) {
return payload.length === 1 ? '1 Eintrag' : `${payload.length} Einträge`
}
if (typeof payload === 'object' && payload !== null) {
const record = payload as Record<string, unknown>
const name = typeof record.name === 'string' ? record.name.trim() : ''
const number = typeof record.number === 'string' ? record.number.trim() : ''
if (name && number) return `${name} · ${number}`
if (name) return name
if (number) return number
const keys = Object.keys(record)
if (keys.length === 0) return 'leeres Objekt'
return keys.length === 1 ? `1 Feld: ${keys[0]}` : `${keys.length} Felder: ${keys.join(', ')}`
}
if (payload === null) return 'null'
return String(payload)
}
/** Holt den Verlauf des Backends die Nachrichten vor dem Öffnen des Bereichs. */
export async function fetchHistory(signal?: AbortSignal): Promise<WebhookEvent[]> {
const response = await fetch('/api/webhook/history', { signal })
if (!response.ok) {
throw new Error(`/api/webhook/history antwortete mit HTTP ${response.status}`)
}
const data: unknown = await response.json()
if (!Array.isArray(data)) return []
return data.filter(isWebhookEvent)
}
/** Leert den Verlauf im Backend. */
export async function clearHistory(): Promise<void> {
const response = await fetch('/api/webhook/history', { method: 'DELETE' })
if (!response.ok) {
throw new Error(`Verlauf konnte nicht geleert werden (HTTP ${response.status})`)
}
}