diff --git a/.vscode/launch.json b/.vscode/launch.json
index 7298133..a686855 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -12,9 +12,11 @@
"console": "internalConsole"
},
{
- // Wie oben, aber /api/config zeigt auf den eingebauten SwyxTray-Mock statt
- // auf 192.168.180.135:17654 – so lässt sich die Startseite ohne echte
- // Telefonanlage ausprobieren (eingehenden Anruf auslösen: siehe README).
+ // Wie oben, aber der eingebaute SwyxTray-Mock wird über das Profil "mock"
+ // eingeschaltet (ohne das Profil gibt es ihn nicht) und /api/config zeigt
+ // 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",
"name": "Backend (lokaler Test gegen SwyxTray-Mock)",
"request": "launch",
@@ -22,7 +24,11 @@
"projectName": "backend",
"cwd": "${workspaceFolder}/backend",
"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",
diff --git a/README.md b/README.md
index d1580c1..a180d77 100644
--- a/README.md
+++ b/README.md
@@ -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
fest verdrahtet ist (konfigurierbar in [application.properties](backend/src/main/resources/application.properties)).
-2. Es stellt unter seinem eigenen Port einen **SwyxTray-Mock** bereit, um die Startseite ohne
- echte Telefonanlage testen zu können (siehe unten). Im Normalbetrieb wird er nicht benutzt.
+2. Es nimmt unter `POST /api/webhook` **Adressdaten fremder Systeme** entgegen und reicht sie
+ an die offenen Browser weiter (siehe [Webhook](#webhook-für-adressdaten-fremder-systeme)).
+3. Es stellt unter seinem eigenen Port einen **SwyxTray-Mock** bereit, um die Startseite ohne
+ echte Telefonanlage testen zu können (siehe unten). Der läuft **nur mit dem Spring-Profil
+ `mock`**; im Normalbetrieb – und damit auch im Container – gibt es ihn nicht.
-**Zwei getrennte Ports:** `8080` ist der Port dieser Anwendung (REST-API und 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.
## Protokoll der SwyxTray-App
Quelle: Mitschnitt gegen die laufende App (Version 1.0.0.0, `protocol: 1`) am 13.08.2026.
-Seit der Tab-Verwaltung meldet die App im `hello` **`protocol: 6`**; die älteren Nachrichten
-sind unverändert geblieben. Implementiert in [protocol.ts](frontend/src/swyx/protocol.ts) und
+Mit der Tab-Verwaltung meldete die App im `hello` `protocol: 6`, mit der Adresssuche
+`protocol: 7`, mit dem Adress-Cache **`protocol: 8`**; die älteren Nachrichten sind
+unverändert geblieben. Implementiert in [protocol.ts](frontend/src/swyx/protocol.ts) und
[SwyxTrayClient.ts](frontend/src/swyx/SwyxTrayClient.ts).
Ein Anruf wird über die **1-basierte Leitungsnummer `line`** identifiziert – so, wie sie auch
@@ -95,6 +99,8 @@ Wählbereich darauf hin.
| `tabs` | – | Offene Tabs des Firefox-Plugins auflisten | `tabs` |
| `opentab` | `url` | Neuen Tab öffnen | `tabId` |
| `closetab` | `tabId` | Tab schließen | – |
+| `contacts` | `query` | Adressdaten des Swyx-Clients durchsuchen | `contacts` |
+| `addresses`| – | Adress-Cache der App am Stück abrufen | `addresses` |
Alles andere quittiert die App mit `"Unbekanntes Kommando '…'."`, ein fehlendes `cmd` mit
`"Feld 'cmd' fehlt."`.
@@ -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
Ö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.
## 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 |
|---|---|
| **Anrufe** | Rufnummer wählen (`call`), belegte Leitungen mit Aktionen und dem Zustand der App (`statusText`, angemeldeter Benutzer, Warnung bei `connected: false`), Verlauf der Anrufereignisse |
+| **Adressdaten** | Der **gesamte** Bestand des Telefonbuchs. Zuerst aus dem Adress-Cache, den die App beim Verbinden von selbst schickt – dann steht er sofort und ohne Wartedialog. Sonst über `addresses`, und erst wenn auch das nichts liefert, über rund 110 Einzelabfragen hinter einem Wartedialog mit Fortschritt und „Abbrechen". Die Anzeige nennt die Herkunft. Danach liegt alles im Browser: das Filtern über Name, Durchwahl **und** Standortkürzel läuft ohne weitere Abfrage. Ein Klick auf „Anrufen" wählt die Durchwahl (`call`) |
+| **Webhook** | Was ein fremdes System an `POST /api/webhook` geschickt hat – jüngste Nachricht zuerst, jede mit ihrem JSON im Original. Die Seite hört über Server-Sent Events mit, **unabhängig vom geöffneten Bereich**: Eine Nachricht erscheint sofort, und ist gerade ein anderer Bereich offen, zeigt der Tab die Zahl der ungesehenen Nachrichten |
| **Tabs** | Offene Tabs des Firefox-Plugins anzeigen (`tabs`), einzeln schließen (`closetab`) und eine URL als neuen Tab öffnen (`opentab`); die Liste wird beim ersten Öffnen des Bereichs und nach jeder Änderung geholt, die eingetippte URL übersteht einen Reload (`localStorage`) |
| **Verbindung** | Adresse, Verbinden/Trennen, Benachrichtigungen erlauben, Diagnose: Rohnachrichten-Log, Status abfragen, Freitext senden |
@@ -178,6 +290,83 @@ Weiteres:
„nicht erkannt" vermerkt statt still verworfen.
- Automatischer Reconnect mit exponentiellem Backoff, nur nach unerwartetem Abbruch.
+## Webhook für Adressdaten fremder Systeme
+
+Ein anderes System kann Adressdaten per POST an die Anwendung schicken; die Startseite zeigt
+sie im Bereich **Webhook** an. Anders als alles Übrige läuft das **nicht** über die SwyxTray-App,
+sondern allein über das Backend.
+
+```bash
+curl -X POST https://swyxweb.appcreation.de/api/webhook \
+ -H "Content-Type: application/json" \
+ -H "X-Webhook-Token: …" \
+ -d '{"name":"Muster GmbH","number":"+493012345","description":"CRM-Import"}'
+```
+
+Die Quittung nennt die vergebene Nummer und die Eingangszeit:
+
+```json
+{ "id": 1, "receivedAt": "2026-08-24T12:38:01.563742294Z" }
+```
+
+**Angenommen wird beliebiges JSON** – Objekt wie Liste. Das aufrufende System muss sich an kein
+Schema halten; die Startseite zeigt die Nutzlast unverändert. Nur die Kopfzeile eines Eintrags
+fasst `name` und `number` zusammen, wenn es sie gibt, und zählt sonst Felder bzw. Einträge
+([summarize](frontend/src/webhook.ts)).
+
+| Endpunkt | Zweck |
+|---|---|
+| `POST /api/webhook` | Nachricht abliefern. `415` bei falschem Content-Type, `400` bei kaputtem JSON, `413` über der Größengrenze, `401` bei falschem Token |
+| `GET /api/webhook/events` | Strom für die Startseite (Server-Sent Events) |
+| `GET /api/webhook/history` | Verlauf, damit ein später geöffneter Bereich die vorigen Nachrichten sieht |
+| `DELETE /api/webhook/history` | Verlauf leeren – das macht der Knopf „Leeren" |
+
+### Wie die Nachricht in den Browser kommt
+
+Über **Server-Sent Events**, nicht über den WebSocket: Der gehört der SwyxTray-App. Die
+Startseite hält eine `EventSource` auf `/api/webhook/events` offen
+([useWebhook.ts](frontend/src/hooks/useWebhook.ts)) – einseitig, über dieselbe HTTPS-Verbindung
+wie die Seite, und der Browser baut sie nach einem Abbruch von selbst wieder auf.
+
+Zwei Vorkehrungen, damit dabei nichts verloren geht:
+
+- Das Backend hält die letzten **50 Nachrichten** im Speicher ([WebhookService](backend/src/main/java/de/appcreation/swyxweb/webhook/WebhookService.java)).
+ Beim Wiederverbinden schickt der Browser die `Last-Event-ID` mit, und alles Jüngere wird
+ nachgeliefert. Über einen **Neustart hinaus wird nichts aufgehoben**.
+- Alle 25 Sekunden geht ein Kommentar (`:ping`) durch die Leitung, damit ein Reverse Proxy die
+ ruhende Verbindung nicht kappt.
+
+Die Antwort trägt außerdem `X-Accel-Buffering: no` und `Cache-Control: no-cache`. **Ohne den
+ersten Kopf sammelt nginx die Ereignisse in seinem Puffer** und gibt sie erst später aus – die
+Nachricht erschiene dann verspätet oder scheinbar gar nicht. nginx wertet ihn aus, andere Proxys
+ignorieren ihn folgenlos. Steht vor der Anwendung etwas anderes als nginx, muss dort die
+Pufferung für `/api/webhook/events` abgeschaltet sein (bei Apache `mod_proxy`: `flushpackets=on`).
+
+Doppelte Nachrichten sind dadurch normal – der Bereich holt beim Start den Verlauf *und* hört
+auf den Strom. Entschieden wird über die `id` ([mergeEvent](frontend/src/webhook.ts)).
+
+### Token
+
+```properties
+app.webhook.token= # leer ⇒ jeder Aufruf wird angenommen
+app.webhook.history=50 # so viele Nachrichten bleiben im Speicher
+app.webhook.max-size=262144 # größte Nutzlast in Byte
+```
+
+> **Für die öffentlich erreichbare Instanz gehört hier ein Geheimnis hinein.** `/api/webhook`
+> liegt unter derselben Adresse wie die Startseite und ist damit aus dem Internet erreichbar;
+> ohne Token kann jeder beliebiges JSON in die Anzeige schreiben. Ist der Wert leer, weist das
+> Backend beim Start ausdrücklich darauf hin (`WARN`).
+
+Im Container:
+
+```bash
+docker run -d -p 8080:8080 -e APP_WEBHOOK_TOKEN=… … gitea.appcreation.de/sven/swyxweb:0.9.13
+```
+
+Das Token gilt nur für `POST`. Verlauf und Ereignisstrom bleiben offen – sie liefern dasselbe,
+was die Seite ohnehin jedem Betrachter zeigt.
+
## Starten
### Aus VS Code
@@ -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
beiden Seiten. „Stop" beendet beides zusammen.
-Alternative **„SwyxWeb starten (lokaler Test gegen SwyxTray-Mock)"**: `/api/config` liefert dann
-`ws://localhost:8080/ws`, die Startseite spricht also mit dem SwyxTray-Mock.
+Alternative **„SwyxWeb starten (lokaler Test gegen SwyxTray-Mock)"**: startet das Backend mit dem
+Profil `mock`, `/api/config` liefert dann `ws://localhost:8080/ws`, die Startseite spricht also
+mit dem SwyxTray-Mock.
Benötigte Extensions: *Extension Pack for Java* (Backend) und *JavaScript Debugger* (im
VS Code enthalten, für Vite und Chrome).
@@ -223,10 +413,29 @@ diesen Proxy – der Browser verbindet sich direkt mit der SwyxTray-App.
## Ohne echte Telefonanlage testen
+> **Der Mock ist Opt-in.** Sein WebSocket-Endpunkt `/ws` und seine Steuer-Endpunkte
+> `/api/mock/**` hängen am Spring-Profil **`mock`**
+> ([MockProfile](backend/src/main/java/de/appcreation/swyxweb/config/MockProfile.java)) –
+> ohne das Profil existieren die Bohnen nicht und beide Pfade antworten mit 404. So kann der
+> Mock nicht versehentlich in einer Produktivumgebung mitlaufen; das Container-Image setzt das
+> Profil bewusst nicht. Einschalten:
+>
+> ```bash
+> cd backend && ./mvnw spring-boot:run -Dspring-boot.run.profiles=mock
+> java -jar app.jar --spring.profiles.active=mock # oder SPRING_PROFILES_ACTIVE=mock
+> ```
+>
+> Abgesichert ist beides durch je einen Test in
+> [BackendApplicationTests](backend/src/test/java/de/appcreation/swyxweb/BackendApplicationTests.java)
+> (ohne Profil keine Mock-Bohne) und
+> [MockProfileTests](backend/src/test/java/de/appcreation/swyxweb/MockProfileTests.java)
+> (mit Profil alle da).
+
Der [SwyxTrayMockHandler](backend/src/main/java/de/appcreation/swyxweb/websocket/SwyxTrayMockHandler.java)
-spricht dasselbe Protokoll: `hello` (mit `protocol: 6`) und Snapshot beim Verbinden, Quittungen
-auf `call`/`answer`/`hangup`/`ping`/`status`/`focus`/`tabs`/`opentab`/`closetab` und nach jeder
-Änderung einen vollständigen Snapshot über alle vier Leitungen. Einen eingehenden Anruf auslösen:
+spricht dasselbe Protokoll: `hello` (mit `protocol: 8`), Snapshot und Adress-Cache beim
+Verbinden, Quittungen auf
+`call`/`answer`/`hangup`/`ping`/`status`/`focus`/`tabs`/`opentab`/`closetab`/`contacts`/`addresses`
+und nach jeder Änderung einen vollständigen Snapshot über alle vier Leitungen. Einen eingehenden Anruf auslösen:
```bash
curl -X POST "http://localhost:8080/api/mock/incoming-call?number=%2B493012345&name=Muster%20GmbH"
@@ -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;
er antwortet immer sofort.
+Für `contacts` führt der Mock ein Telefonbuch mit **154 Einträgen** – groß genug, dass der
+Bereich „Adressdaten" dieselbe Arbeit leisten muss wie gegen die Anlage. Drei Sonderfälle
+sind absichtlich dabei: zweimal **„Muster, Max"** mit verschiedenen Durchwahlen (Namen sind
+nicht eindeutig), Einträge **ohne Beschreibung**, und **„Zentrale" mit der Rufnummer `0`** –
+die steckt in keinem Ziffernpaar und wird nur über die einstelligen Abfragen gefunden.
+
+Er hält sich an dieselben Regeln wie die echte App: Teilzeichenkette in Name und Rufnummer,
+Schreibweise egal, nach Namen sortiert, gekappt bei 100 Treffern, und derselbe Fehlertext
+für einen fehlenden, leeren oder zu langen Suchbegriff.
+
+Denselben Bestand gibt der Mock als Adress-Cache heraus – auf `addresses` und beim Verbinden
+als Push. Der Cache lässt sich leeren, um die Rückfallebene zu prüfen; dann verhält er sich
+wie die Anlage zurzeit:
+
+```bash
+curl -X POST "http://localhost:8080/api/mock/address-cache?filled=false" # -> Einzelabfragen
+curl -X POST "http://localhost:8080/api/mock/address-cache?filled=true" # -> Cache
+```
+
+Jedes Umschalten schickt allen Verbundenen sofort einen neuen Push.
+
Die Startseite muss dazu auf `ws://localhost:8080/ws` zeigen – entweder im Adressfeld
-eintragen oder das Backend mit `--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).
## 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
`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
- **HTTPS-Seite ⇒ `wss://`**: Ein über HTTPS ausgelieferter Browser blockiert unverschlüsselte
diff --git a/backend/src/main/java/de/appcreation/swyxweb/BackendApplication.java b/backend/src/main/java/de/appcreation/swyxweb/BackendApplication.java
index 7f8b389..8d96739 100644
--- a/backend/src/main/java/de/appcreation/swyxweb/BackendApplication.java
+++ b/backend/src/main/java/de/appcreation/swyxweb/BackendApplication.java
@@ -1,12 +1,13 @@
package de.appcreation.swyxweb;
import de.appcreation.swyxweb.config.WebSocketProperties;
+import de.appcreation.swyxweb.config.WebhookProperties;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
@SpringBootApplication
-@EnableConfigurationProperties(WebSocketProperties.class)
+@EnableConfigurationProperties({ WebSocketProperties.class, WebhookProperties.class })
public class BackendApplication {
public static void main(String[] args) {
diff --git a/backend/src/main/java/de/appcreation/swyxweb/config/MockProfile.java b/backend/src/main/java/de/appcreation/swyxweb/config/MockProfile.java
new file mode 100644
index 0000000..9e6e198
--- /dev/null
+++ b/backend/src/main/java/de/appcreation/swyxweb/config/MockProfile.java
@@ -0,0 +1,23 @@
+package de.appcreation.swyxweb.config;
+
+/**
+ * Name des Spring-Profils, unter dem der eingebaute SwyxTray-Mock läuft.
+ *
+ *
Der Mock ist bewusst abgeschaltet, solange dieses Profil nicht gesetzt
+ * ist – so kann er nicht versehentlich in einer Produktivumgebung
+ * mitlaufen. Eingeschaltet wird er über
+ * {@code --spring.profiles.active=mock} bzw. {@code SPRING_PROFILES_ACTIVE=mock}.
+ *
+ *
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() {
+ }
+}
diff --git a/backend/src/main/java/de/appcreation/swyxweb/config/WebSocketConfig.java b/backend/src/main/java/de/appcreation/swyxweb/config/WebSocketConfig.java
index ac36fc9..3f904e4 100644
--- a/backend/src/main/java/de/appcreation/swyxweb/config/WebSocketConfig.java
+++ b/backend/src/main/java/de/appcreation/swyxweb/config/WebSocketConfig.java
@@ -2,6 +2,7 @@ package de.appcreation.swyxweb.config;
import de.appcreation.swyxweb.websocket.SwyxTrayMockHandler;
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.WebSocketConfigurer;
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
* Startseite auch ohne die echte SwyxTray-App testen lässt.
+ *
+ *
Nur mit dem Profil {@link MockProfile#NAME} – siehe dort.
*/
+@Profile(MockProfile.NAME)
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
diff --git a/backend/src/main/java/de/appcreation/swyxweb/config/WebhookProperties.java b/backend/src/main/java/de/appcreation/swyxweb/config/WebhookProperties.java
new file mode 100644
index 0000000..cd718bd
--- /dev/null
+++ b/backend/src/main/java/de/appcreation/swyxweb/config/WebhookProperties.java
@@ -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 jeden 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();
+ }
+}
diff --git a/backend/src/main/java/de/appcreation/swyxweb/web/MockController.java b/backend/src/main/java/de/appcreation/swyxweb/web/MockController.java
index da6d884..c17fd82 100644
--- a/backend/src/main/java/de/appcreation/swyxweb/web/MockController.java
+++ b/backend/src/main/java/de/appcreation/swyxweb/web/MockController.java
@@ -1,7 +1,9 @@
package de.appcreation.swyxweb.web;
+import de.appcreation.swyxweb.config.MockProfile;
import de.appcreation.swyxweb.websocket.LineState;
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.RequestMapping;
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
- * 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.
*
- *
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"
+ * curl -X POST "http://localhost:8080/api/mock/address-cache?filled=false"
+ *
+ *
Nur mit dem Profil {@link MockProfile#NAME} vorhanden – im Normalbetrieb
+ * gibt es {@code /api/mock/**} nicht.
*/
+@Profile(MockProfile.NAME)
@RestController
@RequestMapping("/api/mock")
public class MockController {
@@ -29,4 +37,18 @@ public class MockController {
@RequestParam(required = false) String 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.
+ *
+ *
curl -X POST "http://localhost:8080/api/mock/address-cache?filled=false"
Angenommen wird beliebiges 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.
+ *
+ *
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 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 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.");
+ }
+ }
+}
diff --git a/backend/src/main/java/de/appcreation/swyxweb/webhook/WebhookEvent.java b/backend/src/main/java/de/appcreation/swyxweb/webhook/WebhookEvent.java
new file mode 100644
index 0000000..a5c8198
--- /dev/null
+++ b/backend/src/main/java/de/appcreation/swyxweb/webhook/WebhookEvent.java
@@ -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) {
+}
diff --git a/backend/src/main/java/de/appcreation/swyxweb/webhook/WebhookService.java b/backend/src/main/java/de/appcreation/swyxweb/webhook/WebhookService.java
new file mode 100644
index 0000000..baa84e8
--- /dev/null
+++ b/backend/src/main/java/de/appcreation/swyxweb/webhook/WebhookService.java
@@ -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.
+ *
+ *
Der Weg zum Browser sind Server-Sent Events: 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.
+ *
+ *
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 events = new ArrayDeque<>();
+ private final List 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 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 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));
+ }
+}
diff --git a/backend/src/main/java/de/appcreation/swyxweb/websocket/MockContact.java b/backend/src/main/java/de/appcreation/swyxweb/websocket/MockContact.java
new file mode 100644
index 0000000..d07d865
--- /dev/null
+++ b/backend/src/main/java/de/appcreation/swyxweb/websocket/MockContact.java
@@ -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"}}.
+ *
+ *
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);
+ }
+}
diff --git a/backend/src/main/java/de/appcreation/swyxweb/websocket/SwyxTrayMockHandler.java b/backend/src/main/java/de/appcreation/swyxweb/websocket/SwyxTrayMockHandler.java
index 894edbe..c369b53 100644
--- a/backend/src/main/java/de/appcreation/swyxweb/websocket/SwyxTrayMockHandler.java
+++ b/backend/src/main/java/de/appcreation/swyxweb/websocket/SwyxTrayMockHandler.java
@@ -4,6 +4,7 @@ import java.io.IOException;
import java.net.URI;
import java.util.ArrayList;
import java.util.Collections;
+import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -13,8 +14,11 @@ import java.util.concurrent.atomic.AtomicInteger;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
+import de.appcreation.swyxweb.config.MockProfile;
+
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
@@ -38,7 +42,24 @@ import org.springframework.web.socket.handler.TextWebSocketHandler;
*
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;
* der Mock spielt das Plugin und führt eine Liste vorgetäuschter Tabs im Speicher.
+ *
+ *
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.
+ *
+ *
Seit Protokoll 8 gibt er dasselbe Telefonbuch als Adress-Cache 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.
+ *
+ *
Nur mit dem Profil {@link MockProfile#NAME}. 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
public class SwyxTrayMockHandler extends TextWebSocketHandler {
@@ -51,8 +72,17 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler {
private static final List MOCK_WINDOWS = List.of(
"SwyxIt!", "SwyxWeb – Google Chrome", "Kundenakte Muster GmbH");
- /** Protokollstand der echten App seit der Tab-Verwaltung. */
- private static final int PROTOCOL_VERSION = 6;
+ /** Protokollstand der echten App seit dem Adress-Cache. */
+ 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 MOCK_CONTACTS = buildDirectory();
private final ObjectMapper mapper;
private final Map sessions = new ConcurrentHashMap<>();
@@ -61,6 +91,8 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler {
private final Map tabs = Collections.synchronizedMap(new LinkedHashMap<>());
private final AtomicInteger nextTabId = new AtomicInteger(41);
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.
private final Object sendLock = new Object();
@@ -81,6 +113,8 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler {
synchronized (sendLock) {
sendText(session, toJson(hello()));
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)));
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 */ }
}
@@ -262,6 +305,89 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler {
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.
+ *
+ *
Drei Sonderfälle sind absichtlich dabei:
+ *
+ *
dieselbe Person mit zwei Durchwahlen – Namen sind nicht eindeutig,
+ *
Einträge ohne Beschreibung (die echte App schickt dafür {@code ""}),
+ *
eine einstellige Rufnummer, die in keinem Ziffernpaar vorkommt und
+ * deshalb nur über die einstelligen Abfragen gefunden wird.
+ *
+ */
+ private static List buildDirectory() {
+ List 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 given = List.of("Anna", "Bernd", "Claudia", "Dirk", "Erika");
+ List sites = List.of("HH-MT", "B-SB", "HB-GT", "L-SB", "S-MT");
+
+ List 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 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 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 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. */
public LineState simulateIncomingCall(String number, String name) {
int line = freeLine();
@@ -364,6 +490,34 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler {
return message;
}
+ /** Quittung auf {@code contacts}; trägt die gefundenen Adressdaten. */
+ private Map contactsResult(int id, List entries) {
+ Map 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 addressesResult(int id, List entries) {
+ Map 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 addressesPush() {
+ Map message = new LinkedHashMap<>();
+ message.put("addresses", addressCache());
+ message.put("type", "addresses");
+ return message;
+ }
+
private Map result(int id, boolean ok, Integer line, String error) {
Map message = new LinkedHashMap<>();
message.put("id", id);
diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties
index d2e668d..5188c2d 100644
--- a/backend/src/main/resources/application.properties
+++ b/backend/src/main/resources/application.properties
@@ -1,6 +1,13 @@
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.port=8080
@@ -11,4 +18,16 @@ app.websocket.port=17654
app.websocket.path=/ws
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
diff --git a/backend/src/test/java/de/appcreation/swyxweb/BackendApplicationTests.java b/backend/src/test/java/de/appcreation/swyxweb/BackendApplicationTests.java
index 489631c..98b0091 100644
--- a/backend/src/test/java/de/appcreation/swyxweb/BackendApplicationTests.java
+++ b/backend/src/test/java/de/appcreation/swyxweb/BackendApplicationTests.java
@@ -1,13 +1,32 @@
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;
@SpringBootTest
class BackendApplicationTests {
+ @Autowired
+ ApplicationContext context;
+
@Test
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();
+ }
+
}
diff --git a/backend/src/test/java/de/appcreation/swyxweb/MockProfileTests.java b/backend/src/test/java/de/appcreation/swyxweb/MockProfileTests.java
new file mode 100644
index 0000000..26a1900
--- /dev/null
+++ b/backend/src/test/java/de/appcreation/swyxweb/MockProfileTests.java
@@ -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();
+ }
+
+}
diff --git a/backend/src/test/java/de/appcreation/swyxweb/WebhookControllerTests.java b/backend/src/test/java/de/appcreation/swyxweb/WebhookControllerTests.java
new file mode 100644
index 0000000..79ad1b0
--- /dev/null
+++ b/backend/src/test/java/de/appcreation/swyxweb/WebhookControllerTests.java
@@ -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());
+ }
+ }
+}
diff --git a/frontend/src/components/ContactsPanel.tsx b/frontend/src/components/ContactsPanel.tsx
new file mode 100644
index 0000000..2b24642
--- /dev/null
+++ b/frontend/src/components/ContactsPanel.tsx
@@ -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
+ 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(null)
+ const [progress, setProgress] = useState(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(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 (
+ <>
+
+
Adressdaten
+
+
+
+ {disabled ? (
+
Erst mit der SwyxTray-App verbinden.
+ ) : (
+ <>
+ {/* 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. */}
+
+
+
+
+ {directory === null ? (
+
{loading ? 'Adressdaten werden geladen …' : 'Noch nicht geladen.'}
+ ) : (
+ <>
+
+ {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`}
+
+
+ {!directory.complete && (
+
+ {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.
+
+ )}
+
+ {visible.length === 0 ? (
+
Kein Eintrag zu „{filter.trim()}".
+ ) : (
+
+ {visible.map((contact, index) => (
+ // Namen sind nicht eindeutig – dieselbe Person kommt mit
+ // mehreren Durchwahlen vor; deshalb die Position mit hinein.
+
+ 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.
+
+
+ {loading && (
+ 0 ? `${progress.found} Einträge bisher` : undefined}
+ onCancel={() => abortRef.current?.abort()}
+ />
+ )}
+ >
+ )
+}
diff --git a/frontend/src/components/LoadingDialog.tsx b/frontend/src/components/LoadingDialog.tsx
new file mode 100644
index 0000000..ef16c96
--- /dev/null
+++ b/frontend/src/components/LoadingDialog.tsx
@@ -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 `