Compare commits
4
Commits
6e9de54119
...
521ceb044b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
521ceb044b | ||
|
|
01594a1f3d | ||
|
|
b2f608055a | ||
|
|
2a2bce860e |
@@ -0,0 +1,21 @@
|
||||
# Build-Ergebnisse und Abhängigkeiten kommen aus dem Image-Build, nicht vom Host.
|
||||
backend/target/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Lokale Konfiguration
|
||||
frontend/.env
|
||||
frontend/.env.local
|
||||
|
||||
# Tooling / OS / VCS
|
||||
.git/
|
||||
.gitignore
|
||||
.vscode/
|
||||
.idea/
|
||||
*.iml
|
||||
.DS_Store
|
||||
*.log
|
||||
README.md
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
+1
-1
@@ -3,7 +3,7 @@ backend/target/
|
||||
!backend/.mvn/wrapper/maven-wrapper.properties
|
||||
|
||||
# Frontend
|
||||
frontend/node_modules/
|
||||
node_modules/
|
||||
frontend/dist/
|
||||
frontend/.env
|
||||
frontend/.env.local
|
||||
|
||||
Vendored
+10
-4
@@ -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 127.0.0.1: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",
|
||||
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# SwyxWeb als ein Container: Das React-Frontend wird gebaut, in die statischen
|
||||
# Ressourcen des Backends gelegt und zusammen mit ihm in ein Jar gepackt.
|
||||
# Ausgeliefert wird am Ende alles über den einen Port des Backends (8080).
|
||||
#
|
||||
# Der eingebaute SwyxTray-Mock (/ws und /api/mock/**) läuft NICHT mit: seine
|
||||
# Bohnen hängen am Spring-Profil "mock", und das setzt dieses Image nicht.
|
||||
#
|
||||
# Die beiden Build-Stufen laufen bewusst auf $BUILDPLATFORM, also nativ auf dem
|
||||
# bauenden Rechner: JS-Bundle und Jar sind architekturunabhängig. Nur die
|
||||
# Laufzeitstufe wird für die Zielarchitektur gezogen. Ein Build mit
|
||||
# --platform linux/amd64 auf einem ARM-Mac läuft dadurch ohne Emulation.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Frontend bauen (Vite -> frontend/dist)
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM --platform=$BUILDPLATFORM node:22-alpine AS frontend
|
||||
WORKDIR /build
|
||||
|
||||
# Erst die Abhängigkeiten, damit diese Schicht im Cache bleibt, solange sich
|
||||
# package.json/package-lock.json nicht ändern.
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY frontend/ ./
|
||||
# VITE_WS_URL nur setzen, wenn die Adresse fest ins Bundle soll; im Normalfall
|
||||
# kommt sie zur Laufzeit aus /api/config (siehe README).
|
||||
ARG VITE_WS_URL
|
||||
RUN npm run build
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Backend bauen (Spring Boot 4.1, Java 21) inkl. Frontend als static/
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM --platform=$BUILDPLATFORM maven:3.9-eclipse-temurin-21 AS backend
|
||||
WORKDIR /build
|
||||
|
||||
# Abhängigkeiten vorab auflösen – eigene Schicht, unabhängig vom Quelltext.
|
||||
COPY backend/pom.xml ./
|
||||
RUN --mount=type=cache,target=/root/.m2 \
|
||||
mvn -B -q dependency:go-offline
|
||||
|
||||
COPY backend/src ./src
|
||||
COPY --from=frontend /build/dist/ ./src/main/resources/static/
|
||||
|
||||
RUN --mount=type=cache,target=/root/.m2 \
|
||||
mvn -B -DskipTests clean package \
|
||||
&& cp target/*.jar /build/app.jar
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Laufzeit-Image
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM eclipse-temurin:21-jre-alpine AS runtime
|
||||
|
||||
# Nicht als root laufen.
|
||||
RUN addgroup -S swyx && adduser -S -G swyx swyx
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=backend --chown=swyx:swyx /build/app.jar ./app.jar
|
||||
|
||||
USER swyx
|
||||
EXPOSE 8080
|
||||
|
||||
# Adresse der SwyxTray-App zur Laufzeit überschreibbar (Spring Relaxed Binding):
|
||||
# -e APP_WEBSOCKET_HOST=127.0.0.1 -e APP_WEBSOCKET_PORT=17654
|
||||
# -e APP_WEBSOCKET_PATH=/ws -e APP_WEBSOCKET_SECURE=false
|
||||
#
|
||||
# SPRING_PROFILES_ACTIVE bleibt bewusst leer – der SwyxTray-Mock bliebe sonst
|
||||
# beim Ausrollen erreichbar. Für eine Vorführung ohne Telefonanlage lässt er
|
||||
# sich einzeln einschalten: -e SPRING_PROFILES_ACTIVE=mock
|
||||
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0"
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
|
||||
CMD wget -q -O /dev/null http://127.0.0.1:8080/api/config || exit 1
|
||||
|
||||
ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar /app/app.jar"]
|
||||
@@ -1,7 +1,7 @@
|
||||
# SwyxWeb
|
||||
|
||||
React-Frontend mit Spring-Boot-Backend. Die Startseite verbindet sich **aus dem Browser heraus**
|
||||
per WebSocket mit der **SwyxTray-App** (`ws://192.168.180.135:17654/ws`), meldet eingehende
|
||||
per WebSocket mit der **SwyxTray-App** (`ws://127.0.0.1:17654/ws`), meldet eingehende
|
||||
Anrufe und startet ausgehende Anrufe.
|
||||
|
||||
```
|
||||
@@ -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),
|
||||
`17654` der Port der SwyxTray-App auf 192.168.180.135. Sie sind unabhängig voneinander.
|
||||
**Zwei getrennte Ports:** `8080` ist der Port dieser Anwendung (REST-API und, mit Profil `mock`, der Mock),
|
||||
`17654` der Port der SwyxTray-App auf dem Arbeitsplatz des Anwenders. Sie sind unabhängig voneinander.
|
||||
|
||||
## Protokoll der SwyxTray-App
|
||||
|
||||
Quelle: Mitschnitt gegen die laufende App (Version 1.0.0.0, `protocol: 1`) am 13.08.2026.
|
||||
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
|
||||
@@ -257,14 +488,14 @@ Es gilt die erste Quelle, die etwas liefert:
|
||||
| 1 | Eingabefeld auf der Startseite | zur Laufzeit änderbar |
|
||||
| 2 | `GET /api/config` vom Backend | `app.websocket.host` in `application.properties` |
|
||||
| 3 | `VITE_WS_URL` aus `frontend/.env` | siehe [.env.example](frontend/.env.example) |
|
||||
| 4 | Default im Code | `ws://192.168.180.135:17654/ws` |
|
||||
| 4 | Default im Code | `ws://127.0.0.1:17654/ws` |
|
||||
|
||||
Backend-seitig:
|
||||
|
||||
```properties
|
||||
server.port=8080 # Port dieser Anwendung
|
||||
|
||||
app.websocket.host=192.168.180.135 # SwyxTray-App
|
||||
app.websocket.host=127.0.0.1 # SwyxTray-App
|
||||
app.websocket.port=17654
|
||||
app.websocket.path=/ws
|
||||
app.websocket.secure=false # true ⇒ wss://
|
||||
@@ -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=127.0.0.1 \
|
||||
-e APP_WEBSOCKET_PORT=17654 \
|
||||
--name swyxweb swyxweb
|
||||
```
|
||||
|
||||
Alle `app.websocket.*`-Werte sind zur Laufzeit über Umgebungsvariablen setzbar
|
||||
(`APP_WEBSOCKET_HOST`, `_PORT`, `_PATH`, `_SECURE`), das Bundle muss dafür nicht neu gebaut
|
||||
werden. Der Heap wächst über `-XX:MaxRAMPercentage=75` mit dem Speicherlimit des Containers
|
||||
mit; überschreiben lässt sich das mit `-e JAVA_OPTS=…`. Soll die WebSocket-Adresse ausnahmsweise
|
||||
fest ins Bundle, geht das beim Bauen mit `--build-arg VITE_WS_URL=ws://…/ws`.
|
||||
|
||||
In die Registry schieben erledigt [docker_push.sh](docker_push.sh) – es baut für `linux/amd64`
|
||||
und pusht in die Paket-Registry des Gitea nach `gitea.appcreation.de/sven/swyxweb`
|
||||
(`REGISTRY_IMAGE` im Skript). Die **Versionsnummer ist Pflicht** und wird zum Tag
|
||||
des Images; sie wird bewusst nicht aus der `pom.xml` abgeleitet:
|
||||
|
||||
```bash
|
||||
docker login gitea.appcreation.de
|
||||
./docker_push.sh 0.9.13 # baut und pusht :0.9.13
|
||||
./docker_push.sh 0.9.13 --dry-run # nur bauen, nicht pushen
|
||||
```
|
||||
|
||||
Ohne Argument bricht das Skript mit der Kurzhilfe ab. Zulässig ist das Format `x.y.z`.
|
||||
|
||||
Ein lokaler Maven- oder npm-Lauf ist vorher **nicht** nötig; beides passiert im Image-Build.
|
||||
Die beiden Build-Stufen laufen dabei nativ auf der Architektur des bauenden Rechners
|
||||
(`--platform=$BUILDPLATFORM`) – JS-Bundle und Jar sind architekturunabhängig, nur das
|
||||
Laufzeit-Image wird für `linux/amd64` gezogen. Auf einem ARM-Mac spart das die Emulation.
|
||||
|
||||
**Der SwyxTray-Mock ist nicht dabei**: `SPRING_PROFILES_ACTIVE` bleibt leer, also fehlen `/ws`
|
||||
und `/api/mock/**` (siehe [Ohne echte Telefonanlage testen](#ohne-echte-telefonanlage-testen)).
|
||||
Für eine Vorführung ohne Telefonanlage lässt er sich am einzelnen Container einschalten:
|
||||
`-e SPRING_PROFILES_ACTIVE=mock`.
|
||||
|
||||
Zwei Dinge, die der Container nicht lösen kann:
|
||||
|
||||
- **Läuft die Seite hinter HTTPS**, blockiert der Browser die unverschlüsselte Verbindung zur
|
||||
SwyxTray-App. Dann braucht die App selbst TLS und `APP_WEBSOCKET_SECURE=true` (⇒ `wss://`).
|
||||
- **Die WebSocket-Verbindung baut der Browser des Anwenders auf**, nicht der Container. Port
|
||||
17654 muss also vom Arbeitsplatz aus erreichbar sein, nicht vom Docker-Host.
|
||||
|
||||
## Hinweise
|
||||
|
||||
- **HTTPS-Seite ⇒ `wss://`**: Ein über HTTPS ausgelieferter Browser blockiert unverschlüsselte
|
||||
@@ -288,7 +571,9 @@ Um das Frontend aus dem Backend auszuliefern, `frontend/dist/*` nach
|
||||
Vite-Dev-Server auf Port 5173 sich verbinden kann. Für die Produktion einschränken – siehe
|
||||
[WebSocketConfig.java](backend/src/main/java/de/appcreation/swyxweb/config/WebSocketConfig.java)
|
||||
und [CorsConfig.java](backend/src/main/java/de/appcreation/swyxweb/config/CorsConfig.java).
|
||||
- **Firewall**: Port 17654 muss auf 192.168.180.135 eingehend freigegeben sein, sonst
|
||||
scheitert der Verbindungsaufbau ohne aussagekräftige Browser-Fehlermeldung.
|
||||
- **Firewall**: Die Verbindung geht per Vorgabe an `127.0.0.1`, bleibt also auf dem Rechner
|
||||
und braucht keine Freigabe. Zeigt die Adresse auf einen anderen Rechner, muss dort Port
|
||||
17654 eingehend freigegeben sein – sonst scheitert der Verbindungsaufbau ohne
|
||||
aussagekräftige Browser-Fehlermeldung.
|
||||
- **System-Benachrichtigungen** verlangen eine Benutzerinteraktion zur Erlaubniserteilung und
|
||||
funktionieren nur über `https://` oder `localhost`.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 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.
|
||||
*
|
||||
* <p>Nur mit dem Profil {@link MockProfile#NAME} – siehe dort.
|
||||
*/
|
||||
@Profile(MockProfile.NAME)
|
||||
@Configuration
|
||||
@EnableWebSocket
|
||||
public class WebSocketConfig implements WebSocketConfigurer {
|
||||
|
||||
@@ -8,7 +8,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
@ConfigurationProperties(prefix = "app.websocket")
|
||||
public record WebSocketProperties(String host, int port, String path, boolean secure) {
|
||||
|
||||
/** Fertige URL, z. B. ws://192.168.180.135:8080/ws */
|
||||
/** Fertige URL, z. B. ws://127.0.0.1:8080/ws */
|
||||
public String url() {
|
||||
return (secure ? "wss" : "ws") + "://" + host + ":" + port + path;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
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.
|
||||
*
|
||||
* <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
|
||||
@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.
|
||||
*
|
||||
* <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.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;
|
||||
* <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;
|
||||
* 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
|
||||
public class SwyxTrayMockHandler extends TextWebSocketHandler {
|
||||
|
||||
@@ -51,8 +72,17 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler {
|
||||
private static final List<String> 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<MockContact> MOCK_CONTACTS = buildDirectory();
|
||||
|
||||
private final ObjectMapper mapper;
|
||||
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 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.
|
||||
*
|
||||
* <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. */
|
||||
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<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) {
|
||||
Map<String, Object> message = new LinkedHashMap<>();
|
||||
message.put("id", id);
|
||||
|
||||
@@ -1,14 +1,33 @@
|
||||
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
|
||||
|
||||
# Adresse des WebSocket-Servers, die das Frontend über /api/config als
|
||||
# Verbindungsziel bekommt. Unabhängig vom Port dieser Anwendung.
|
||||
app.websocket.host=192.168.180.135
|
||||
app.websocket.host=127.0.0.1
|
||||
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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+99
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
readonly SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# Paket-Registry des Gitea auf gitea.appcreation.de; der Pfad ist dort
|
||||
# <Host>/<Besitzer>/<Image>.
|
||||
readonly REGISTRY_IMAGE="gitea.appcreation.de/sven/swyxweb"
|
||||
readonly PLATFORM="linux/amd64"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Verwendung:
|
||||
./docker_push.sh <x.y.z> [--dry-run]
|
||||
|
||||
Beispiel:
|
||||
./docker_push.sh 0.9.13
|
||||
./docker_push.sh 0.9.13 --dry-run # nur bauen, nicht pushen
|
||||
|
||||
Voraussetzungen:
|
||||
- Docker Buildx ist installiert
|
||||
- Login zur Registry wurde bereits ausgeführt:
|
||||
docker login gitea.appcreation.de
|
||||
|
||||
Die Versionsnummer ist Pflicht: Sie wird zum Tag des Images und muss damit
|
||||
bewusst gesetzt werden. Sie wird nicht aus der pom.xml abgeleitet.
|
||||
|
||||
Gebaut wird das Dockerfile im Wurzelverzeichnis: Es übersetzt Frontend *und*
|
||||
Backend selbst, ein vorheriger lokaler Maven- oder npm-Lauf ist also nicht
|
||||
nötig. Der SwyxTray-Mock ist im Image nicht enthalten (Profil "mock").
|
||||
EOF
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "Fehler: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
require_command() {
|
||||
command -v "$1" >/dev/null 2>&1 || fail "'$1' wurde nicht gefunden."
|
||||
}
|
||||
|
||||
VERSION=""
|
||||
DRY_RUN=false
|
||||
|
||||
for arg in "$@"; do
|
||||
case "${arg}" in
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
--dry-run)
|
||||
DRY_RUN=true
|
||||
;;
|
||||
-*)
|
||||
fail "Unbekannte Option '${arg}'."
|
||||
;;
|
||||
*)
|
||||
[[ -z "${VERSION}" ]] || fail "Es ist nur eine Versionsnummer erlaubt."
|
||||
VERSION="${arg}"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "${VERSION}" ]]; then
|
||||
usage >&2
|
||||
echo >&2
|
||||
fail "Es muss eine Versionsnummer angegeben werden, z. B. ./docker_push.sh 0.9.13"
|
||||
fi
|
||||
|
||||
if [[ ! "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
fail "Versionsnummer muss das Format x.y.z haben (war: '${VERSION}')."
|
||||
fi
|
||||
|
||||
require_command docker
|
||||
docker buildx version >/dev/null 2>&1 || fail "Docker Buildx ist nicht verfügbar."
|
||||
|
||||
cd "${SCRIPT_DIR}"
|
||||
|
||||
if [[ "${DRY_RUN}" == true ]]; then
|
||||
echo "Baue Image ${REGISTRY_IMAGE}:${VERSION} für ${PLATFORM} (dry run, kein Push) ..."
|
||||
docker buildx build \
|
||||
--platform "${PLATFORM}" \
|
||||
-f "${SCRIPT_DIR}/Dockerfile" \
|
||||
-t "${REGISTRY_IMAGE}:${VERSION}" \
|
||||
"${SCRIPT_DIR}"
|
||||
echo "Fertig gebaut, nicht gepusht: ${REGISTRY_IMAGE}:${VERSION}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Baue und pushe Image ${REGISTRY_IMAGE}:${VERSION} für ${PLATFORM} ..."
|
||||
docker buildx build \
|
||||
--platform "${PLATFORM}" \
|
||||
-f "${SCRIPT_DIR}/Dockerfile" \
|
||||
-t "${REGISTRY_IMAGE}:${VERSION}" \
|
||||
--push \
|
||||
"${SCRIPT_DIR}"
|
||||
|
||||
echo "Fertig: ${REGISTRY_IMAGE}:${VERSION}"
|
||||
@@ -1,5 +1,5 @@
|
||||
# Adresse des WebSocket-Servers; überschreibt den Wert aus /api/config.
|
||||
VITE_WS_URL=ws://192.168.180.135:17654/ws
|
||||
VITE_WS_URL=ws://127.0.0.1:17654/ws
|
||||
|
||||
# Ziel des Dev-Proxys für /api, also der Port dieser Anwendung
|
||||
# (Standard: http://localhost:8080)
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
export interface Tab<Id extends string> {
|
||||
id: Id
|
||||
label: string
|
||||
/** Anzahl noch nicht gesehener Nachrichten; 0 oder fehlend blendet ihn aus. */
|
||||
badge?: number
|
||||
}
|
||||
|
||||
interface Props<Id extends string> {
|
||||
@@ -35,6 +37,11 @@ export default function Tabs<Id extends string>({ tabs, active, onChange }: Prop
|
||||
onClick={() => onChange(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.badge ? (
|
||||
<span className="tabs__badge" aria-label={`${tab.badge} neu`}>
|
||||
{tab.badge > 99 ? '99+' : tab.badge}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Fallback-Adresse des WebSocket-Servers, falls weder .env noch /api/config etwas liefern. */
|
||||
export const DEFAULT_WS_HOST = '192.168.180.135'
|
||||
export const DEFAULT_WS_HOST = '127.0.0.1'
|
||||
export const DEFAULT_WS_PORT = 17654
|
||||
export const DEFAULT_WS_PATH = '/ws'
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
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 {
|
||||
id: number
|
||||
@@ -31,6 +32,8 @@ export function useSwyxTray() {
|
||||
// Belegte Leitungen, nach Leitungsnummer; jeder Snapshot schreibt sie neu.
|
||||
const [lines, setLines] = useState<Record<number, CallEvent>>({})
|
||||
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 clientRef = useRef<SwyxTrayClient | null>(null)
|
||||
@@ -54,9 +57,14 @@ export function useSwyxTray() {
|
||||
linesRef.current = {}
|
||||
setLines({})
|
||||
setTray({})
|
||||
// Auch der Cache gehörte zur alten Verbindung; die App schickt ihn
|
||||
// beim Verbinden erneut.
|
||||
setAddressCache(null)
|
||||
}
|
||||
}),
|
||||
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) => {
|
||||
setLog((entries) => {
|
||||
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 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(
|
||||
(text: string) => {
|
||||
try {
|
||||
@@ -157,6 +185,7 @@ export function useSwyxTray() {
|
||||
calls,
|
||||
ringingCall,
|
||||
tray,
|
||||
addressCache,
|
||||
busy,
|
||||
connect,
|
||||
disconnect,
|
||||
@@ -168,6 +197,7 @@ export function useSwyxTray() {
|
||||
listTabs,
|
||||
openTab,
|
||||
closeTab,
|
||||
loadDirectory,
|
||||
sendRaw,
|
||||
clearLog,
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -547,6 +547,127 @@ code {
|
||||
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 {
|
||||
height: 300px;
|
||||
overflow-y: auto;
|
||||
@@ -595,3 +716,82 @@ code {
|
||||
.log__entry--system {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -2,18 +2,24 @@ import { useEffect, useRef, useState, type FormEvent, type ReactNode } from 'rea
|
||||
import { DEFAULT_WS_URL, fetchClientConfig } from '../config'
|
||||
import { useSwyxTray } from '../hooks/useSwyxTray'
|
||||
import { useCallNotifications } from '../hooks/useCallNotifications'
|
||||
import { useWebhook } from '../hooks/useWebhook'
|
||||
import { countUnseen } from '../webhook'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import MessageLog from '../components/MessageLog'
|
||||
import IncomingCallCard from '../components/IncomingCallCard'
|
||||
import DialPanel from '../components/DialPanel'
|
||||
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 { ActiveCallList, CallHistory } from '../components/CallList'
|
||||
|
||||
type TabName = 'calls' | 'browsertabs' | 'connection'
|
||||
type TabName = 'calls' | 'contacts' | 'webhook' | 'browsertabs' | 'connection'
|
||||
|
||||
const TABS: Tab<TabName>[] = [
|
||||
{ id: 'calls', label: 'Anrufe' },
|
||||
{ id: 'contacts', label: 'Adressdaten' },
|
||||
{ id: 'webhook', label: 'Webhook' },
|
||||
{ id: 'browsertabs', label: 'Tabs' },
|
||||
{ id: 'connection', label: 'Verbindung' },
|
||||
]
|
||||
@@ -40,6 +46,25 @@ export default function HomePage() {
|
||||
|
||||
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.
|
||||
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'}>
|
||||
<section className="card">
|
||||
@@ -130,6 +155,32 @@ export default function HomePage() {
|
||||
</section>
|
||||
</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'}>
|
||||
<section className="card">
|
||||
<BrowserTabsPanel
|
||||
@@ -159,7 +210,7 @@ export default function HomePage() {
|
||||
value={url}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
placeholder="ws://192.168.180.135:17654/ws"
|
||||
placeholder="ws://127.0.0.1:17654/ws"
|
||||
onChange={(e) => {
|
||||
urlTouched.current = true
|
||||
setUrl(e.target.value)
|
||||
@@ -240,7 +291,8 @@ export default function HomePage() {
|
||||
|
||||
<footer className="page__footer">
|
||||
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>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import {
|
||||
COMMANDS,
|
||||
CONTACT_QUERY_MAX_LENGTH,
|
||||
parseAddresses,
|
||||
parseHello,
|
||||
parseResult,
|
||||
parseSnapshot,
|
||||
type Contact,
|
||||
type HelloMessage,
|
||||
type ResultMessage,
|
||||
type SnapshotMessage,
|
||||
@@ -20,6 +23,8 @@ interface Listeners {
|
||||
status: (status: ConnectionStatus) => void
|
||||
hello: (hello: HelloMessage) => void
|
||||
snapshot: (snapshot: SnapshotMessage) => void
|
||||
/** Unaufgeforderter Adress-Cache der App; auch eine leere Liste zählt. */
|
||||
addresses: (contacts: Contact[]) => void
|
||||
raw: (message: RawMessage) => void
|
||||
error: (message: string) => void
|
||||
}
|
||||
@@ -36,6 +41,48 @@ const DEFAULT_TIMEOUT_MS = 10000
|
||||
// selbst fünf Sekunden, bevor sie mit einem Fehler antwortet.
|
||||
const TAB_TIMEOUT_MS = 15000
|
||||
|
||||
/** Loopback gilt dem Browser als vertrauenswürdig – dorthin ist `ws://` auch von HTTPS aus erlaubt. */
|
||||
function isLoopback(hostname: string): boolean {
|
||||
const host = hostname.replace(/^\[|\]$/g, '').toLowerCase()
|
||||
return (
|
||||
host === 'localhost' ||
|
||||
host.endsWith('.localhost') ||
|
||||
host === '::1' ||
|
||||
/^127\./.test(host)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine über HTTPS ausgelieferte Seite darf keine unverschlüsselte `ws://`-Verbindung
|
||||
* öffnen. Der Browser wirft dafür nur ein nichtssagendes SecurityError
|
||||
* ("The operation is insecure."), deshalb wird der Grund hier selbst benannt.
|
||||
*/
|
||||
export function describeConnectFailure(url: string, message: string): string {
|
||||
let target: URL | null = null
|
||||
try {
|
||||
target = new URL(url)
|
||||
} catch {
|
||||
// Wirklich kaputte Adresse – dann bleibt es bei der Browser-Meldung.
|
||||
}
|
||||
|
||||
if (
|
||||
target &&
|
||||
target.protocol === 'ws:' &&
|
||||
typeof window !== 'undefined' &&
|
||||
window.location.protocol === 'https:' &&
|
||||
!isLoopback(target.hostname)
|
||||
) {
|
||||
return (
|
||||
`Diese Seite läuft über HTTPS; der Browser blockiert deshalb die unverschlüsselte ` +
|
||||
`Verbindung zu ${url} (Mixed Content). Die SwyxTray-App muss dafür selbst TLS ` +
|
||||
`anbieten (wss://). Läuft sie auf demselben Rechner wie der Browser, ist ` +
|
||||
`ws://127.0.0.1:${target.port || '17654'}${target.pathname} erlaubt.`
|
||||
)
|
||||
}
|
||||
|
||||
return `Ungültige WebSocket-Adresse: ${message}`
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket-Client für die SwyxTray-App. Die Verbindung wird im Browser
|
||||
* aufgebaut; Quittungen werden über die numerische `id` zugeordnet,
|
||||
@@ -50,6 +97,7 @@ export class SwyxTrayClient {
|
||||
status: new Set(),
|
||||
hello: new Set(),
|
||||
snapshot: new Set(),
|
||||
addresses: new Set(),
|
||||
raw: new Set(),
|
||||
error: new Set(),
|
||||
}
|
||||
@@ -179,6 +227,35 @@ export class SwyxTrayClient {
|
||||
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> {
|
||||
return this.send(COMMANDS.ping)
|
||||
}
|
||||
@@ -200,7 +277,7 @@ export class SwyxTrayClient {
|
||||
socket = new WebSocket(url)
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e)
|
||||
this.emit('error', `Ungültige WebSocket-Adresse: ${message}`)
|
||||
this.emit('error', describeConnectFailure(url, message))
|
||||
this.emit('status', 'closed')
|
||||
return
|
||||
}
|
||||
@@ -270,14 +347,22 @@ export class SwyxTrayClient {
|
||||
return
|
||||
}
|
||||
|
||||
// Die App schickt keine Ereignisse: Jede Änderung – auch ein eingehender
|
||||
// Anruf – kommt als vollständiger Snapshot.
|
||||
// Die App schickt keine Anruf-Ereignisse: Jede Änderung – auch ein
|
||||
// eingehender Anruf – kommt als vollständiger Snapshot.
|
||||
const snapshot = parseSnapshot(parsed)
|
||||
if (snapshot) {
|
||||
this.emit('snapshot', snapshot)
|
||||
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)
|
||||
if (hello) {
|
||||
this.emit('hello', hello)
|
||||
|
||||
@@ -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.
@@ -2,10 +2,13 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
callStateOf,
|
||||
describeCall,
|
||||
describeContact,
|
||||
describeTab,
|
||||
parseAddresses,
|
||||
mergeSnapshot,
|
||||
parseHello,
|
||||
parseResult,
|
||||
parseContacts,
|
||||
parseSnapshot,
|
||||
parseTabs,
|
||||
type CallEvent,
|
||||
@@ -79,6 +82,22 @@ describe('parseHello', () => {
|
||||
?.protocol,
|
||||
).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', () => {
|
||||
@@ -296,6 +315,56 @@ describe('parseResult', () => {
|
||||
const result = parseResult(JSON.parse('{"id":11,"ok":true,"line":4,"type":"result"}'))
|
||||
expect(result?.tabs).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', () => {
|
||||
@@ -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', () => {
|
||||
it('bevorzugt die fertige Anzeigeform der App', () => {
|
||||
expect(describeCall({ line: 1, event: 'incoming', peer: 'Muster GmbH (+49301)' })).toBe(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 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
|
||||
* `protocol: 6`).
|
||||
* (App-Version 1.0.0.0, `protocol: 1`; mit der Tab-Verwaltung meldete die App
|
||||
* `protocol: 6`, mit der Adresssuche `protocol: 7`, mit dem Adress-Cache `protocol: 8`).
|
||||
*
|
||||
* App → Seite:
|
||||
* { "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 }
|
||||
* 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.
|
||||
* Die Anrufmeldung der Seite entsteht deshalb aus dem Vergleich zweier Snapshots
|
||||
* (siehe {@link mergeSnapshot}).
|
||||
@@ -66,6 +89,10 @@ export interface ResultMessage {
|
||||
tabs?: BrowserTab[]
|
||||
/** Nur bei `opentab`: Kennung des neu geöffneten Tabs. */
|
||||
tabId?: number
|
||||
/** Nur bei `contacts`: die gefundenen Adressdaten. */
|
||||
contacts?: Contact[]
|
||||
/** Nur bei `addresses`: der gesamte Adress-Cache der App. */
|
||||
addresses?: Contact[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
@@ -79,6 +106,26 @@ export interface BrowserTab {
|
||||
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. */
|
||||
export interface HelloMessage {
|
||||
app?: string
|
||||
@@ -115,6 +162,8 @@ export const COMMANDS = {
|
||||
tabs: 'tabs',
|
||||
openTab: 'opentab',
|
||||
closeTab: 'closetab',
|
||||
contacts: 'contacts',
|
||||
addresses: 'addresses',
|
||||
} as const
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
@@ -207,6 +256,8 @@ export function parseResult(raw: unknown): ResultMessage | null {
|
||||
focused: asBoolean(raw.focused),
|
||||
tabs: Array.isArray(raw.tabs) ? parseTabs(raw.tabs) : undefined,
|
||||
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),
|
||||
}
|
||||
}
|
||||
@@ -236,6 +287,35 @@ export function describeTab(tab: BrowserTab): string {
|
||||
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. */
|
||||
export function parseHello(raw: unknown): HelloMessage | null {
|
||||
if (!isRecord(raw) || raw.type !== 'hello') return null
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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})`)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user