first commit
SwyxWeb: React-Frontend und Spring-Boot-Backend mit SwyxTray-Mock. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+17
@@ -0,0 +1,17 @@
|
|||||||
|
# Backend
|
||||||
|
backend/target/
|
||||||
|
!backend/.mvn/wrapper/maven-wrapper.properties
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
frontend/.env
|
||||||
|
frontend/.env.local
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Tooling / OS
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.iml
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
# SwyxWeb
|
||||||
|
|
||||||
|
React-Frontend mit Spring-Boot-Backend. Die Startseite verbindet sich **aus dem Browser heraus**
|
||||||
|
per WebSocket mit der **SwyxTray-App** (`ws://192.168.180.135:17654/ws`), meldet eingehende
|
||||||
|
Anrufe und startet ausgehende Anrufe.
|
||||||
|
|
||||||
|
```
|
||||||
|
SwyxWeb/
|
||||||
|
├── backend/ Spring Boot 4.1 (Java 21, Maven Wrapper)
|
||||||
|
└── frontend/ React 19 + TypeScript + Vite
|
||||||
|
```
|
||||||
|
|
||||||
|
## Wer verbindet sich mit wem?
|
||||||
|
|
||||||
|
Die WebSocket-Verbindung wird ausschließlich im Browser aufgebaut – in
|
||||||
|
[SwyxTrayClient.ts](frontend/src/swyx/SwyxTrayClient.ts) über `new WebSocket(url)`.
|
||||||
|
Das Backend baut **keine** eigene WebSocket-Verbindung auf; es hat zwei Aufgaben:
|
||||||
|
|
||||||
|
1. `GET /api/config` liefert dem Frontend die Zieladresse, damit sie nicht im JS-Bundle
|
||||||
|
fest verdrahtet ist (konfigurierbar in [application.properties](backend/src/main/resources/application.properties)).
|
||||||
|
2. Es stellt unter seinem eigenen Port einen **SwyxTray-Mock** bereit, um die Startseite ohne
|
||||||
|
echte Telefonanlage testen zu können (siehe unten). Im Normalbetrieb wird er nicht benutzt.
|
||||||
|
|
||||||
|
**Zwei getrennte Ports:** `8080` ist der Port dieser Anwendung (REST-API und Mock),
|
||||||
|
`17654` der Port der SwyxTray-App auf 192.168.180.135. Sie sind unabhängig voneinander.
|
||||||
|
|
||||||
|
## Protokoll der SwyxTray-App
|
||||||
|
|
||||||
|
Quelle: Mitschnitt gegen die laufende App (Version 1.0.0.0, `protocol: 1`) am 13.08.2026.
|
||||||
|
Seit der Tab-Verwaltung meldet die App im `hello` **`protocol: 6`**; die älteren Nachrichten
|
||||||
|
sind unverändert geblieben. Implementiert in [protocol.ts](frontend/src/swyx/protocol.ts) und
|
||||||
|
[SwyxTrayClient.ts](frontend/src/swyx/SwyxTrayClient.ts).
|
||||||
|
|
||||||
|
Ein Anruf wird über die **1-basierte Leitungsnummer `line`** identifiziert – so, wie sie auch
|
||||||
|
in SwyxIt! erscheint. Eine Anruf-Id gibt es nicht.
|
||||||
|
|
||||||
|
> **Die App schickt keine Ereignisnachrichten.** Jede Zustandsänderung – auch ein eingehender
|
||||||
|
> Anruf – kommt als **vollständiger `snapshot`** über alle vier Leitungen. Die Anrufmeldung der
|
||||||
|
> Startseite entsteht deshalb aus dem Vergleich zweier Snapshots
|
||||||
|
> ([mergeSnapshot](frontend/src/swyx/protocol.ts)).
|
||||||
|
|
||||||
|
### Begrüßung (App → Seite, beim Verbinden)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "app": "SwyxTray", "version": "1.0.0.0", "protocol": 1, "session": 7, "type": "hello" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Direkt danach folgt der erste Snapshot.
|
||||||
|
|
||||||
|
### Snapshot (App → Seite, bei jeder Änderung)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "connected": true, "serverUp": true, "overall": "Ringing", "statusText": "Eingehender Ruf",
|
||||||
|
"user": "Muster, M.", "server": "127.0.0.1", "type": "snapshot",
|
||||||
|
"lines": [
|
||||||
|
{ "line": 1, "state": "LSRinging", "stateCode": 3, "stateText": "klingelt",
|
||||||
|
"peer": "001602107449", "peerNumber": "001602107449", "peerName": "",
|
||||||
|
"busy": true, "selected": true },
|
||||||
|
{ "line": 2, "state": "LSInactive", "stateCode": 0, "stateText": "frei",
|
||||||
|
"peer": "unbekannt", "peerNumber": "", "peerName": "", "busy": false, "selected": false }
|
||||||
|
] }
|
||||||
|
```
|
||||||
|
|
||||||
|
`state` trägt die Namen der CLMgr-Aufzählung `LineState`, `stateText` den Klartext aus SwyxIt!.
|
||||||
|
Belegt sind bisher `LSInactive / 0 / "frei"` und `LSRinging / 3 / "klingelt"`.
|
||||||
|
[callStateOf](frontend/src/swyx/protocol.ts) bildet beides auf `incoming` · `outgoing` ·
|
||||||
|
`connected` ab und wertet dabei **Name und Klartext** aus; ein unbekannter Zustand gilt als
|
||||||
|
**belegt**, damit eine klingelnde Leitung nie stillschweigend verschwindet. `peer` ist die
|
||||||
|
fertige Anzeigeform, `"unbekannt"` der Platzhalter für „keine Gegenstelle". Freie Leitungen
|
||||||
|
schickt die App immer mit; die Startseite blendet sie aus.
|
||||||
|
|
||||||
|
### Kommandos (Seite → App)
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// ->
|
||||||
|
{ "id": 7, "cmd": "call", "number": "+49 30 1234567" }
|
||||||
|
// <- Quittung, id gespiegelt
|
||||||
|
{ "id": 7, "ok": true, "line": 2, "type": "result" }
|
||||||
|
// <- Fehlerfall (ungültige Nummer, keine Verbindung zu SwyxIt!)
|
||||||
|
{ "id": 7, "ok": false, "error": "…", "type": "result" }
|
||||||
|
```
|
||||||
|
|
||||||
|
`ok: true` heißt **nur**, dass CLMgr den Auftrag angenommen hat – gewählt wird asynchron.
|
||||||
|
Der tatsächliche Verlauf kommt danach über die folgenden Snapshots. Die Startseite weist im
|
||||||
|
Wählbereich darauf hin.
|
||||||
|
|
||||||
|
| Kommando | Felder | Zweck | Antwort |
|
||||||
|
|------------|----------------|-------------------------------------------|-----------------------------|
|
||||||
|
| `call` | `number` | Wählvorgang starten | `line` |
|
||||||
|
| `answer` | `line` | Anruf annehmen | – |
|
||||||
|
| `hangup` | `line` | Anruf beenden/ablehnen | – |
|
||||||
|
| `ping` | – | Verbindungstest | – |
|
||||||
|
| `status` | – | Snapshot anfordern | – |
|
||||||
|
| `focus` | `title`, `url` | Fenster in den Vordergrund holen | `focused` |
|
||||||
|
| `tabs` | – | Offene Tabs des Firefox-Plugins auflisten | `tabs` |
|
||||||
|
| `opentab` | `url` | Neuen Tab öffnen | `tabId` |
|
||||||
|
| `closetab` | `tabId` | Tab schließen | – |
|
||||||
|
|
||||||
|
Alles andere quittiert die App mit `"Unbekanntes Kommando '…'."`, ein fehlendes `cmd` mit
|
||||||
|
`"Feld 'cmd' fehlt."`.
|
||||||
|
|
||||||
|
### Fenster in den Vordergrund holen
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// ->
|
||||||
|
{ "id": 9, "cmd": "focus", "title": "Kundenakte Muster GmbH", "url": "https://crm.example.local/kunden/4711" }
|
||||||
|
// <- Fenster war da und ist jetzt vorn
|
||||||
|
{ "type": "result", "id": 9, "ok": true, "focused": true }
|
||||||
|
// <- kein Fenster gefunden, stattdessen Browser mit der URL gestartet
|
||||||
|
{ "type": "result", "id": 9, "ok": true, "focused": false }
|
||||||
|
```
|
||||||
|
|
||||||
|
`title` wird als **Teilzeichenkette** gesucht, unabhängig von der Groß-/Kleinschreibung –
|
||||||
|
`"swyxit"` findet also das Fenster „SwyxIt!". `url` ist optional und wird nur benutzt, wenn
|
||||||
|
kein Fenster passt; fehlt sie in diesem Fall, antwortet die App mit
|
||||||
|
`ok: false` und `"Kein Fenster mit Titel '…' gefunden und keine URL angegeben."`. Ein
|
||||||
|
fehlender oder leerer Titel ergibt `"Feld 'title' fehlt."`.
|
||||||
|
|
||||||
|
Seit der Umstellung des Tab-Bereichs benutzt die Startseite `focus` nicht mehr; das Kommando
|
||||||
|
bleibt aber im Protokoll und in `SwyxTrayClient.focusWindow()` erhalten.
|
||||||
|
|
||||||
|
### Tabs des Firefox-Plugins
|
||||||
|
|
||||||
|
Die drei Tab-Kommandos reicht die SwyxTray-App **unverändert an das Firefox-Plugin durch** –
|
||||||
|
gemeint sind also die Tabs des Firefox auf dem Rechner der App, nicht die Bereiche der
|
||||||
|
Startseite. Es sind dieselben Kanäle, die auch die Tray-Menüpunkte *Lese Tabs*, *Tab öffnen*
|
||||||
|
und *Tab schliessen* benutzen.
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// ->
|
||||||
|
{ "id": 12, "cmd": "tabs" }
|
||||||
|
// <-
|
||||||
|
{ "type": "result", "id": 12, "ok": true, "tabs": [
|
||||||
|
{ "id": 43, "title": "Kundenakte", "url": "https://crm.example.local/kunden/4711", "active": true },
|
||||||
|
{ "id": 44, "title": "SwyxWeb", "url": "http://localhost:5173/", "active": false } ] }
|
||||||
|
|
||||||
|
// ->
|
||||||
|
{ "id": 13, "cmd": "opentab", "url": "https://crm.example.local/kunden/4711" }
|
||||||
|
// <-
|
||||||
|
{ "type": "result", "id": 13, "ok": true, "tabId": 43 }
|
||||||
|
|
||||||
|
// -> tabId stammt aus einer tabs-Antwort
|
||||||
|
{ "id": 14, "cmd": "closetab", "tabId": 43 }
|
||||||
|
// <-
|
||||||
|
{ "type": "result", "id": 14, "ok": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
`url` muss eine **absolute http(s)-Adresse** sein – dieselbe Prüfung wie bei `focus`; sonst
|
||||||
|
antwortet die App mit `ok: false`. Ist **kein Plugin verbunden**, endet jedes der drei
|
||||||
|
Kommandos nach dem Zeitfenster von rund fünf Sekunden ebenfalls mit `ok: false`. Deshalb
|
||||||
|
wartet die Startseite auf diese drei Kommandos 15 statt 10 Sekunden
|
||||||
|
([SwyxTrayClient.ts](frontend/src/swyx/SwyxTrayClient.ts)).
|
||||||
|
|
||||||
|
Auch hier gilt: **die App meldet Tab-Änderungen nicht von selbst.** Die Liste wird nach jedem
|
||||||
|
Öffnen und Schließen neu über `tabs` geholt.
|
||||||
|
|
||||||
|
Alle Regeln sind in [protocol.test.ts](frontend/src/swyx/protocol.test.ts) festgehalten –
|
||||||
|
darunter die wörtlich mitgeschnittene Nachricht eines echten eingehenden Anrufs.
|
||||||
|
|
||||||
|
## Funktionen der Startseite
|
||||||
|
|
||||||
|
Die Seite ist in drei Bereiche aufgeteilt ([Tabs.tsx](frontend/src/components/Tabs.tsx)):
|
||||||
|
|
||||||
|
| Tab | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| **Anrufe** | Rufnummer wählen (`call`), belegte Leitungen mit Aktionen und dem Zustand der App (`statusText`, angemeldeter Benutzer, Warnung bei `connected: false`), Verlauf der Anrufereignisse |
|
||||||
|
| **Tabs** | Offene Tabs des Firefox-Plugins anzeigen (`tabs`), einzeln schließen (`closetab`) und eine URL als neuen Tab öffnen (`opentab`); die Liste wird beim ersten Öffnen des Bereichs und nach jeder Änderung geholt, die eingetippte URL übersteht einen Reload (`localStorage`) |
|
||||||
|
| **Verbindung** | Adresse, Verbinden/Trennen, Benachrichtigungen erlauben, Diagnose: Rohnachrichten-Log, Status abfragen, Freitext senden |
|
||||||
|
|
||||||
|
Die **Meldung eines eingehenden Anrufs** steht bewusst *über* den Tabs – mit Leitungsnummer,
|
||||||
|
Name und Rufnummer, „Annehmen"/„Ablehnen" –, damit sie in keinem Bereich untergeht. Liegt der
|
||||||
|
Browser-Tab im Hintergrund, kommt zusätzlich eine System-Benachrichtigung (nach Erlaubnis).
|
||||||
|
|
||||||
|
Weiteres:
|
||||||
|
|
||||||
|
- Der Verlauf entsteht aus dem Vergleich aufeinanderfolgender Snapshots.
|
||||||
|
- Eine Nachricht, die keinem der drei Typen entspricht, wird im Log ausdrücklich als
|
||||||
|
„nicht erkannt" vermerkt statt still verworfen.
|
||||||
|
- Automatischer Reconnect mit exponentiellem Backoff, nur nach unerwartetem Abbruch.
|
||||||
|
|
||||||
|
## Starten
|
||||||
|
|
||||||
|
### Aus VS Code
|
||||||
|
|
||||||
|
Im Debug-Panel die Compound-Konfiguration **„SwyxWeb starten (Backend + Frontend)"** wählen
|
||||||
|
([launch.json](.vscode/launch.json)). Sie startet das Backend im Debugger, danach den
|
||||||
|
Vite-Dev-Server und öffnet Chrome, sobald dieser bereit ist – Breakpoints funktionieren auf
|
||||||
|
beiden Seiten. „Stop" beendet beides zusammen.
|
||||||
|
|
||||||
|
Alternative **„SwyxWeb starten (lokaler Test gegen SwyxTray-Mock)"**: `/api/config` liefert dann
|
||||||
|
`ws://localhost:8080/ws`, die Startseite spricht also mit dem SwyxTray-Mock.
|
||||||
|
|
||||||
|
Benötigte Extensions: *Extension Pack for Java* (Backend) und *JavaScript Debugger* (im
|
||||||
|
VS Code enthalten, für Vite und Chrome).
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
./mvnw spring-boot:run
|
||||||
|
```
|
||||||
|
|
||||||
|
Läuft auf `http://0.0.0.0:8080`. Achtung: Auf diesem Rechner belegt bereits ein anderes
|
||||||
|
Projekt Port 8080 – dann auf einen freien Port ausweichen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./mvnw spring-boot:run -Dspring-boot.run.arguments="--server.port=8081"
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Port der SwyxTray-App (17654) ändert sich dadurch nicht.
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Öffnet `http://localhost:5173`. Der Vite-Dev-Server leitet `/api` an `http://localhost:8080`
|
||||||
|
weiter (überschreibbar per `VITE_BACKEND_URL`). Die WebSocket-Verbindung läuft **nicht** über
|
||||||
|
diesen Proxy – der Browser verbindet sich direkt mit der SwyxTray-App.
|
||||||
|
|
||||||
|
## Ohne echte Telefonanlage testen
|
||||||
|
|
||||||
|
Der [SwyxTrayMockHandler](backend/src/main/java/de/appcreation/swyxweb/websocket/SwyxTrayMockHandler.java)
|
||||||
|
spricht dasselbe Protokoll: `hello` (mit `protocol: 6`) und Snapshot beim Verbinden, Quittungen
|
||||||
|
auf `call`/`answer`/`hangup`/`ping`/`status`/`focus`/`tabs`/`opentab`/`closetab` und nach jeder
|
||||||
|
Änderung einen vollständigen Snapshot über alle vier Leitungen. Einen eingehenden Anruf auslösen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "http://localhost:8080/api/mock/incoming-call?number=%2B493012345&name=Muster%20GmbH"
|
||||||
|
```
|
||||||
|
|
||||||
|
Für `focus` täuscht der Mock die Fenster **„SwyxIt!"**, **„SwyxWeb – Google Chrome"** und
|
||||||
|
**„Kundenakte Muster GmbH"** vor (Teilzeichenkette, Schreibweise egal). Ein passender Titel
|
||||||
|
ergibt `focused: true`, ein unbekannter mit URL `focused: false`, ein unbekannter ohne URL
|
||||||
|
denselben Fehler wie die echte App. Geöffnet wird natürlich nichts – der Mock protokolliert nur.
|
||||||
|
|
||||||
|
Für die Tab-Kommandos spielt der Mock das Firefox-Plugin und führt eine Tabliste im Speicher –
|
||||||
|
anfangs **„SwyxWeb"** und **„Kundenakte Muster GmbH"**. `opentab` hängt einen Tab an und
|
||||||
|
liefert dessen `tabId`, `closetab` entfernt ihn; eine unbekannte Kennung ergibt
|
||||||
|
`"Kein Tab mit der Kennung …"`, eine relative Adresse denselben Fehler wie die echte App.
|
||||||
|
Geöffnet wird auch hier nichts. Der Mock bildet den Fall „kein Plugin verbunden" **nicht** nach;
|
||||||
|
er antwortet immer sofort.
|
||||||
|
|
||||||
|
Die Startseite muss dazu auf `ws://localhost:8080/ws` zeigen – entweder im Adressfeld
|
||||||
|
eintragen oder das Backend mit `--app.websocket.host=localhost --app.websocket.port=8080`
|
||||||
|
starten (macht die VS-Code-Konfiguration „lokaler Test" automatisch).
|
||||||
|
|
||||||
|
## Konfiguration der WebSocket-Adresse
|
||||||
|
|
||||||
|
Es gilt die erste Quelle, die etwas liefert:
|
||||||
|
|
||||||
|
| Priorität | Quelle | Beispiel |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Eingabefeld auf der Startseite | zur Laufzeit änderbar |
|
||||||
|
| 2 | `GET /api/config` vom Backend | `app.websocket.host` in `application.properties` |
|
||||||
|
| 3 | `VITE_WS_URL` aus `frontend/.env` | siehe [.env.example](frontend/.env.example) |
|
||||||
|
| 4 | Default im Code | `ws://192.168.180.135:17654/ws` |
|
||||||
|
|
||||||
|
Backend-seitig:
|
||||||
|
|
||||||
|
```properties
|
||||||
|
server.port=8080 # Port dieser Anwendung
|
||||||
|
|
||||||
|
app.websocket.host=192.168.180.135 # SwyxTray-App
|
||||||
|
app.websocket.port=17654
|
||||||
|
app.websocket.path=/ws
|
||||||
|
app.websocket.secure=false # true ⇒ wss://
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build und Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && ./mvnw clean package # erzeugt target/backend-0.0.1-SNAPSHOT.jar
|
||||||
|
cd frontend && npm test && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Um das Frontend aus dem Backend auszuliefern, `frontend/dist/*` nach
|
||||||
|
`backend/src/main/resources/static/` kopieren und neu packen.
|
||||||
|
|
||||||
|
## Hinweise
|
||||||
|
|
||||||
|
- **HTTPS-Seite ⇒ `wss://`**: Ein über HTTPS ausgelieferter Browser blockiert unverschlüsselte
|
||||||
|
`ws://`-Verbindungen (Mixed Content). Im Dev-Betrieb über `http://` ist `ws://` in Ordnung.
|
||||||
|
- **CORS/Origin**: Der Mock akzeptiert alle Origins (`setAllowedOriginPatterns("*")`), damit der
|
||||||
|
Vite-Dev-Server auf Port 5173 sich verbinden kann. Für die Produktion einschränken – siehe
|
||||||
|
[WebSocketConfig.java](backend/src/main/java/de/appcreation/swyxweb/config/WebSocketConfig.java)
|
||||||
|
und [CorsConfig.java](backend/src/main/java/de/appcreation/swyxweb/config/CorsConfig.java).
|
||||||
|
- **Firewall**: Port 17654 muss auf 192.168.180.135 eingehend freigegeben sein, sonst
|
||||||
|
scheitert der Verbindungsaufbau ohne aussagekräftige Browser-Fehlermeldung.
|
||||||
|
- **System-Benachrichtigungen** verlangen eine Benutzerinteraktion zur Erlaubniserteilung und
|
||||||
|
funktionieren nur über `https://` oder `localhost`.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
wrapperVersion=3.3.4
|
||||||
|
distributionType=only-script
|
||||||
|
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip
|
||||||
+295
@@ -0,0 +1,295 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
# or more contributor license agreements. See the NOTICE file
|
||||||
|
# distributed with this work for additional information
|
||||||
|
# regarding copyright ownership. The ASF licenses this file
|
||||||
|
# to you under the Apache License, Version 2.0 (the
|
||||||
|
# "License"); you may not use this file except in compliance
|
||||||
|
# with the License. You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing,
|
||||||
|
# software distributed under the License is distributed on an
|
||||||
|
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
# KIND, either express or implied. See the License for the
|
||||||
|
# specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Apache Maven Wrapper startup batch script, version 3.3.4
|
||||||
|
#
|
||||||
|
# Optional ENV vars
|
||||||
|
# -----------------
|
||||||
|
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
|
||||||
|
# MVNW_REPOURL - repo url base for downloading maven distribution
|
||||||
|
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
|
||||||
|
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
set -euf
|
||||||
|
[ "${MVNW_VERBOSE-}" != debug ] || set -x
|
||||||
|
|
||||||
|
# OS specific support.
|
||||||
|
native_path() { printf %s\\n "$1"; }
|
||||||
|
case "$(uname)" in
|
||||||
|
CYGWIN* | MINGW*)
|
||||||
|
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
|
||||||
|
native_path() { cygpath --path --windows "$1"; }
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# set JAVACMD and JAVACCMD
|
||||||
|
set_java_home() {
|
||||||
|
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
|
||||||
|
if [ -n "${JAVA_HOME-}" ]; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||||
|
JAVACCMD="$JAVA_HOME/jre/sh/javac"
|
||||||
|
else
|
||||||
|
JAVACMD="$JAVA_HOME/bin/java"
|
||||||
|
JAVACCMD="$JAVA_HOME/bin/javac"
|
||||||
|
|
||||||
|
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
|
||||||
|
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
|
||||||
|
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD="$(
|
||||||
|
'set' +e
|
||||||
|
'unset' -f command 2>/dev/null
|
||||||
|
'command' -v java
|
||||||
|
)" || :
|
||||||
|
JAVACCMD="$(
|
||||||
|
'set' +e
|
||||||
|
'unset' -f command 2>/dev/null
|
||||||
|
'command' -v javac
|
||||||
|
)" || :
|
||||||
|
|
||||||
|
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
|
||||||
|
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# hash string like Java String::hashCode
|
||||||
|
hash_string() {
|
||||||
|
str="${1:-}" h=0
|
||||||
|
while [ -n "$str" ]; do
|
||||||
|
char="${str%"${str#?}"}"
|
||||||
|
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
|
||||||
|
str="${str#?}"
|
||||||
|
done
|
||||||
|
printf %x\\n $h
|
||||||
|
}
|
||||||
|
|
||||||
|
verbose() { :; }
|
||||||
|
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
|
||||||
|
|
||||||
|
die() {
|
||||||
|
printf %s\\n "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
trim() {
|
||||||
|
# MWRAPPER-139:
|
||||||
|
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
|
||||||
|
# Needed for removing poorly interpreted newline sequences when running in more
|
||||||
|
# exotic environments such as mingw bash on Windows.
|
||||||
|
printf "%s" "${1}" | tr -d '[:space:]'
|
||||||
|
}
|
||||||
|
|
||||||
|
scriptDir="$(dirname "$0")"
|
||||||
|
scriptName="$(basename "$0")"
|
||||||
|
|
||||||
|
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
|
||||||
|
while IFS="=" read -r key value; do
|
||||||
|
case "${key-}" in
|
||||||
|
distributionUrl) distributionUrl=$(trim "${value-}") ;;
|
||||||
|
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
|
||||||
|
esac
|
||||||
|
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||||
|
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||||
|
|
||||||
|
case "${distributionUrl##*/}" in
|
||||||
|
maven-mvnd-*bin.*)
|
||||||
|
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
|
||||||
|
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
|
||||||
|
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
|
||||||
|
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
|
||||||
|
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
|
||||||
|
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
|
||||||
|
*)
|
||||||
|
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
|
||||||
|
distributionPlatform=linux-amd64
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
|
||||||
|
;;
|
||||||
|
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
|
||||||
|
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
||||||
|
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
||||||
|
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
|
||||||
|
distributionUrlName="${distributionUrl##*/}"
|
||||||
|
distributionUrlNameMain="${distributionUrlName%.*}"
|
||||||
|
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
|
||||||
|
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
|
||||||
|
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
|
||||||
|
|
||||||
|
exec_maven() {
|
||||||
|
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
|
||||||
|
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ -d "$MAVEN_HOME" ]; then
|
||||||
|
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
|
||||||
|
exec_maven "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "${distributionUrl-}" in
|
||||||
|
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
|
||||||
|
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# prepare tmp dir
|
||||||
|
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
|
||||||
|
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
|
||||||
|
trap clean HUP INT TERM EXIT
|
||||||
|
else
|
||||||
|
die "cannot create temp dir"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p -- "${MAVEN_HOME%/*}"
|
||||||
|
|
||||||
|
# Download and Install Apache Maven
|
||||||
|
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
|
||||||
|
verbose "Downloading from: $distributionUrl"
|
||||||
|
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||||
|
|
||||||
|
# select .zip or .tar.gz
|
||||||
|
if ! command -v unzip >/dev/null; then
|
||||||
|
distributionUrl="${distributionUrl%.zip}.tar.gz"
|
||||||
|
distributionUrlName="${distributionUrl##*/}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# verbose opt
|
||||||
|
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
|
||||||
|
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
|
||||||
|
|
||||||
|
# normalize http auth
|
||||||
|
case "${MVNW_PASSWORD:+has-password}" in
|
||||||
|
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
|
||||||
|
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
|
||||||
|
verbose "Found wget ... using wget"
|
||||||
|
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
|
||||||
|
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
|
||||||
|
verbose "Found curl ... using curl"
|
||||||
|
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
|
||||||
|
elif set_java_home; then
|
||||||
|
verbose "Falling back to use Java to download"
|
||||||
|
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
|
||||||
|
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||||
|
cat >"$javaSource" <<-END
|
||||||
|
public class Downloader extends java.net.Authenticator
|
||||||
|
{
|
||||||
|
protected java.net.PasswordAuthentication getPasswordAuthentication()
|
||||||
|
{
|
||||||
|
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
|
||||||
|
}
|
||||||
|
public static void main( String[] args ) throws Exception
|
||||||
|
{
|
||||||
|
setDefault( new Downloader() );
|
||||||
|
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
END
|
||||||
|
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
|
||||||
|
verbose " - Compiling Downloader.java ..."
|
||||||
|
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
|
||||||
|
verbose " - Running Downloader.java ..."
|
||||||
|
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If specified, validate the SHA-256 sum of the Maven distribution zip file
|
||||||
|
if [ -n "${distributionSha256Sum-}" ]; then
|
||||||
|
distributionSha256Result=false
|
||||||
|
if [ "$MVN_CMD" = mvnd.sh ]; then
|
||||||
|
echo "Checksum validation is not supported for maven-mvnd." >&2
|
||||||
|
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
|
||||||
|
exit 1
|
||||||
|
elif command -v sha256sum >/dev/null; then
|
||||||
|
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
|
||||||
|
distributionSha256Result=true
|
||||||
|
fi
|
||||||
|
elif command -v shasum >/dev/null; then
|
||||||
|
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
|
||||||
|
distributionSha256Result=true
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
|
||||||
|
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ $distributionSha256Result = false ]; then
|
||||||
|
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
|
||||||
|
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# unzip and move
|
||||||
|
if command -v unzip >/dev/null; then
|
||||||
|
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
|
||||||
|
else
|
||||||
|
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Find the actual extracted directory name (handles snapshots where filename != directory name)
|
||||||
|
actualDistributionDir=""
|
||||||
|
|
||||||
|
# First try the expected directory name (for regular distributions)
|
||||||
|
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
|
||||||
|
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
|
||||||
|
actualDistributionDir="$distributionUrlNameMain"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If not found, search for any directory with the Maven executable (for snapshots)
|
||||||
|
if [ -z "$actualDistributionDir" ]; then
|
||||||
|
# enable globbing to iterate over items
|
||||||
|
set +f
|
||||||
|
for dir in "$TMP_DOWNLOAD_DIR"/*; do
|
||||||
|
if [ -d "$dir" ]; then
|
||||||
|
if [ -f "$dir/bin/$MVN_CMD" ]; then
|
||||||
|
actualDistributionDir="$(basename "$dir")"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
set -f
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$actualDistributionDir" ]; then
|
||||||
|
verbose "Contents of $TMP_DOWNLOAD_DIR:"
|
||||||
|
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
|
||||||
|
die "Could not find Maven distribution directory in extracted archive"
|
||||||
|
fi
|
||||||
|
|
||||||
|
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
|
||||||
|
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
|
||||||
|
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
|
||||||
|
|
||||||
|
clean || :
|
||||||
|
exec_maven "$@"
|
||||||
Vendored
+189
@@ -0,0 +1,189 @@
|
|||||||
|
<# : batch portion
|
||||||
|
@REM ----------------------------------------------------------------------------
|
||||||
|
@REM Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
@REM or more contributor license agreements. See the NOTICE file
|
||||||
|
@REM distributed with this work for additional information
|
||||||
|
@REM regarding copyright ownership. The ASF licenses this file
|
||||||
|
@REM to you under the Apache License, Version 2.0 (the
|
||||||
|
@REM "License"); you may not use this file except in compliance
|
||||||
|
@REM with the License. You may obtain a copy of the License at
|
||||||
|
@REM
|
||||||
|
@REM http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@REM
|
||||||
|
@REM Unless required by applicable law or agreed to in writing,
|
||||||
|
@REM software distributed under the License is distributed on an
|
||||||
|
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
@REM KIND, either express or implied. See the License for the
|
||||||
|
@REM specific language governing permissions and limitations
|
||||||
|
@REM under the License.
|
||||||
|
@REM ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@REM ----------------------------------------------------------------------------
|
||||||
|
@REM Apache Maven Wrapper startup batch script, version 3.3.4
|
||||||
|
@REM
|
||||||
|
@REM Optional ENV vars
|
||||||
|
@REM MVNW_REPOURL - repo url base for downloading maven distribution
|
||||||
|
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
|
||||||
|
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
|
||||||
|
@REM ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
|
||||||
|
@SET __MVNW_CMD__=
|
||||||
|
@SET __MVNW_ERROR__=
|
||||||
|
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
|
||||||
|
@SET PSModulePath=
|
||||||
|
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
|
||||||
|
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
|
||||||
|
)
|
||||||
|
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
|
||||||
|
@SET __MVNW_PSMODULEP_SAVE=
|
||||||
|
@SET __MVNW_ARG0_NAME__=
|
||||||
|
@SET MVNW_USERNAME=
|
||||||
|
@SET MVNW_PASSWORD=
|
||||||
|
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
|
||||||
|
@echo Cannot start maven from wrapper >&2 && exit /b 1
|
||||||
|
@GOTO :EOF
|
||||||
|
: end batch / begin powershell #>
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
if ($env:MVNW_VERBOSE -eq "true") {
|
||||||
|
$VerbosePreference = "Continue"
|
||||||
|
}
|
||||||
|
|
||||||
|
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
|
||||||
|
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
|
||||||
|
if (!$distributionUrl) {
|
||||||
|
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||||
|
}
|
||||||
|
|
||||||
|
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
|
||||||
|
"maven-mvnd-*" {
|
||||||
|
$USE_MVND = $true
|
||||||
|
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
|
||||||
|
$MVN_CMD = "mvnd.cmd"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
default {
|
||||||
|
$USE_MVND = $false
|
||||||
|
$MVN_CMD = $script -replace '^mvnw','mvn'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
||||||
|
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
||||||
|
if ($env:MVNW_REPOURL) {
|
||||||
|
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
|
||||||
|
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
|
||||||
|
}
|
||||||
|
$distributionUrlName = $distributionUrl -replace '^.*/',''
|
||||||
|
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
|
||||||
|
|
||||||
|
$MAVEN_M2_PATH = "$HOME/.m2"
|
||||||
|
if ($env:MAVEN_USER_HOME) {
|
||||||
|
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
|
||||||
|
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
$MAVEN_WRAPPER_DISTS = $null
|
||||||
|
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
|
||||||
|
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
|
||||||
|
} else {
|
||||||
|
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
|
||||||
|
}
|
||||||
|
|
||||||
|
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
|
||||||
|
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
|
||||||
|
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
|
||||||
|
|
||||||
|
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
|
||||||
|
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
|
||||||
|
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
|
||||||
|
exit $?
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
|
||||||
|
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
|
||||||
|
}
|
||||||
|
|
||||||
|
# prepare tmp dir
|
||||||
|
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
|
||||||
|
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
|
||||||
|
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
|
||||||
|
trap {
|
||||||
|
if ($TMP_DOWNLOAD_DIR.Exists) {
|
||||||
|
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
|
||||||
|
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
|
||||||
|
|
||||||
|
# Download and Install Apache Maven
|
||||||
|
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
|
||||||
|
Write-Verbose "Downloading from: $distributionUrl"
|
||||||
|
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||||
|
|
||||||
|
$webclient = New-Object System.Net.WebClient
|
||||||
|
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
|
||||||
|
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
|
||||||
|
}
|
||||||
|
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||||
|
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
|
||||||
|
|
||||||
|
# If specified, validate the SHA-256 sum of the Maven distribution zip file
|
||||||
|
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
|
||||||
|
if ($distributionSha256Sum) {
|
||||||
|
if ($USE_MVND) {
|
||||||
|
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
|
||||||
|
}
|
||||||
|
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
|
||||||
|
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
|
||||||
|
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# unzip and move
|
||||||
|
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
|
||||||
|
|
||||||
|
# Find the actual extracted directory name (handles snapshots where filename != directory name)
|
||||||
|
$actualDistributionDir = ""
|
||||||
|
|
||||||
|
# First try the expected directory name (for regular distributions)
|
||||||
|
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
|
||||||
|
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
|
||||||
|
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
|
||||||
|
$actualDistributionDir = $distributionUrlNameMain
|
||||||
|
}
|
||||||
|
|
||||||
|
# If not found, search for any directory with the Maven executable (for snapshots)
|
||||||
|
if (!$actualDistributionDir) {
|
||||||
|
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
|
||||||
|
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
|
||||||
|
if (Test-Path -Path $testPath -PathType Leaf) {
|
||||||
|
$actualDistributionDir = $_.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$actualDistributionDir) {
|
||||||
|
Write-Error "Could not find Maven distribution directory in extracted archive"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
|
||||||
|
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
|
||||||
|
try {
|
||||||
|
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
|
||||||
|
} catch {
|
||||||
|
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
|
||||||
|
Write-Error "fail to move MAVEN_HOME"
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
|
||||||
|
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
<parent>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
|
<version>4.1.0</version>
|
||||||
|
<relativePath/> <!-- lookup parent from repository -->
|
||||||
|
</parent>
|
||||||
|
<groupId>de.appcreation</groupId>
|
||||||
|
<artifactId>backend</artifactId>
|
||||||
|
<version>0.0.1-SNAPSHOT</version>
|
||||||
|
<name>swyxweb-backend</name>
|
||||||
|
<description>Spring-Boot-Backend mit WebSocket-Endpunkt für die SwyxWeb-Startseite</description>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<java.version>21</java.version>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-webmvc</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-webmvc-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-websocket-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package de.appcreation.swyxweb;
|
||||||
|
|
||||||
|
import de.appcreation.swyxweb.config.WebSocketProperties;
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
|
|
||||||
|
@SpringBootApplication
|
||||||
|
@EnableConfigurationProperties(WebSocketProperties.class)
|
||||||
|
public class BackendApplication {
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(BackendApplication.class, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package de.appcreation.swyxweb.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||||
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erlaubt dem Vite-Dev-Server den Zugriff auf die REST-Endpunkte.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class CorsConfig implements WebMvcConfigurer {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addCorsMappings(CorsRegistry registry) {
|
||||||
|
registry.addMapping("/api/**")
|
||||||
|
.allowedOriginPatterns("*")
|
||||||
|
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package de.appcreation.swyxweb.config;
|
||||||
|
|
||||||
|
import de.appcreation.swyxweb.websocket.SwyxTrayMockHandler;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.web.socket.config.annotation.EnableWebSocket;
|
||||||
|
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@EnableWebSocket
|
||||||
|
public class WebSocketConfig implements WebSocketConfigurer {
|
||||||
|
|
||||||
|
private final SwyxTrayMockHandler handler;
|
||||||
|
private final WebSocketProperties properties;
|
||||||
|
|
||||||
|
public WebSocketConfig(SwyxTrayMockHandler handler, WebSocketProperties properties) {
|
||||||
|
this.handler = handler;
|
||||||
|
this.properties = properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
|
||||||
|
registry.addHandler(handler, properties.path())
|
||||||
|
// Der Browser meldet sich von einem anderen Origin (Vite-Dev-Server) aus an.
|
||||||
|
.setAllowedOriginPatterns("*");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package de.appcreation.swyxweb.config;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verbindungsdaten des WebSocket-Servers, die das Frontend über /api/config abholt.
|
||||||
|
*/
|
||||||
|
@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 */
|
||||||
|
public String url() {
|
||||||
|
return (secure ? "wss" : "ws") + "://" + host + ":" + port + path;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package de.appcreation.swyxweb.web;
|
||||||
|
|
||||||
|
import de.appcreation.swyxweb.config.WebSocketProperties;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert dem Frontend die Laufzeit-Konfiguration, damit die WebSocket-Adresse
|
||||||
|
* nicht im JavaScript-Bundle fest verdrahtet werden muss.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api")
|
||||||
|
public class ConfigController {
|
||||||
|
|
||||||
|
private final WebSocketProperties properties;
|
||||||
|
|
||||||
|
public ConfigController(WebSocketProperties properties) {
|
||||||
|
this.properties = properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ClientConfig(String websocketUrl, String host, int port, String path) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/config")
|
||||||
|
public ClientConfig config() {
|
||||||
|
return new ClientConfig(
|
||||||
|
properties.url(),
|
||||||
|
properties.host(),
|
||||||
|
properties.port(),
|
||||||
|
properties.path());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/health")
|
||||||
|
public java.util.Map<String, String> health() {
|
||||||
|
return java.util.Map.of("status", "UP");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package de.appcreation.swyxweb.web;
|
||||||
|
|
||||||
|
import de.appcreation.swyxweb.websocket.LineState;
|
||||||
|
import de.appcreation.swyxweb.websocket.SwyxTrayMockHandler;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
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.
|
||||||
|
*
|
||||||
|
* <pre>curl -X POST "http://localhost:8080/api/mock/incoming-call?number=%2B493012345&name=Muster%20GmbH"</pre>
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/mock")
|
||||||
|
public class MockController {
|
||||||
|
|
||||||
|
private final SwyxTrayMockHandler mock;
|
||||||
|
|
||||||
|
public MockController(SwyxTrayMockHandler mock) {
|
||||||
|
this.mock = mock;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/incoming-call")
|
||||||
|
public LineState incomingCall(
|
||||||
|
@RequestParam(required = false) String number,
|
||||||
|
@RequestParam(required = false) String name) {
|
||||||
|
return mock.simulateIncomingCall(number, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package de.appcreation.swyxweb.websocket;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leitungseintrag im Snapshot – Feldnamen und Werte wie bei der echten
|
||||||
|
* SwyxTray-App: {@code state} trägt den Namen aus der CLMgr-Aufzählung
|
||||||
|
* {@code LineState}, {@code stateText} den deutschen Klartext aus SwyxIt!.
|
||||||
|
*
|
||||||
|
* <p>Mitgeschnitten und damit bestätigt sind {@code LSInactive / 0 / "frei"} und
|
||||||
|
* {@code LSRinging / 3 / "klingelt"}; die Werte für Wählen und Gespräch sind der
|
||||||
|
* Aufzählung nachempfunden. Die Startseite wertet {@code state} und
|
||||||
|
* {@code stateText} aus, nicht {@code stateCode}.
|
||||||
|
*/
|
||||||
|
public record LineState(
|
||||||
|
int line,
|
||||||
|
String state,
|
||||||
|
int stateCode,
|
||||||
|
String stateText,
|
||||||
|
String peer,
|
||||||
|
String peerNumber,
|
||||||
|
String peerName,
|
||||||
|
boolean busy,
|
||||||
|
boolean selected) {
|
||||||
|
|
||||||
|
public static final String FREE = "LSInactive";
|
||||||
|
public static final String RINGING = "LSRinging";
|
||||||
|
public static final String DIALING = "LSDialing";
|
||||||
|
public static final String ACTIVE = "LSActive";
|
||||||
|
|
||||||
|
/** Platzhalter der App für eine Leitung ohne Gegenstelle. */
|
||||||
|
private static final String NO_PEER = "unbekannt";
|
||||||
|
|
||||||
|
public static LineState free(int line) {
|
||||||
|
return new LineState(line, FREE, 0, "frei", NO_PEER, "", "", false, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static LineState ringing(int line, String number, String name) {
|
||||||
|
return occupied(line, RINGING, 3, "klingelt", number, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
public boolean isRinging() {
|
||||||
|
return RINGING.equals(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static LineState dialing(int line, String number, String name) {
|
||||||
|
return occupied(line, DIALING, 2, "wählt", number, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public LineState connected() {
|
||||||
|
return occupied(line, ACTIVE, 6, "verbunden", peerNumber, peerName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nur für den Mock – die App kennt dieses Feld nicht, also nicht serialisieren. */
|
||||||
|
@JsonIgnore
|
||||||
|
public boolean isFree() {
|
||||||
|
return FREE.equals(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LineState occupied(
|
||||||
|
int line, String state, int stateCode, String stateText, String number, String name) {
|
||||||
|
return new LineState(
|
||||||
|
line, state, stateCode, stateText,
|
||||||
|
peerOf(number, name),
|
||||||
|
number == null ? "" : number,
|
||||||
|
name == null ? "" : name,
|
||||||
|
true, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fertige Anzeigeform: Name mit Nummer in Klammern; fehlt eines, nur der
|
||||||
|
* vorhandene Wert.
|
||||||
|
*/
|
||||||
|
private static String peerOf(String number, String name) {
|
||||||
|
boolean hasName = name != null && !name.isBlank();
|
||||||
|
boolean hasNumber = number != null && !number.isBlank();
|
||||||
|
if (hasName && hasNumber) {
|
||||||
|
return name + " (" + number + ")";
|
||||||
|
}
|
||||||
|
if (hasName) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
return hasNumber ? number : NO_PEER;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package de.appcreation.swyxweb.websocket;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tab des Firefox-Plugins im Mock – Feldnamen wie in der Quittung auf
|
||||||
|
* {@code tabs}: {@code {"id":43,"title":"…","url":"https://…","active":true}}.
|
||||||
|
*
|
||||||
|
* <p>Die echte SwyxTray-App erzeugt diese Einträge nicht selbst; sie reicht die
|
||||||
|
* Antwort des Plugins unverändert durch.
|
||||||
|
*/
|
||||||
|
public record MockTab(int id, String title, String url, boolean active) {
|
||||||
|
|
||||||
|
public MockTab withActive(boolean nowActive) {
|
||||||
|
return new MockTab(id, title, url, nowActive);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,405 @@
|
|||||||
|
package de.appcreation.swyxweb.websocket;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
import tools.jackson.databind.JsonNode;
|
||||||
|
import tools.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.socket.CloseStatus;
|
||||||
|
import org.springframework.web.socket.TextMessage;
|
||||||
|
import org.springframework.web.socket.WebSocketSession;
|
||||||
|
import org.springframework.web.socket.handler.TextWebSocketHandler;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simuliert die SwyxTray-App für Entwicklung und Test – im Format, das gegen die
|
||||||
|
* echte App (Version 1.0.0.0, {@code protocol: 1}) mitgeschnitten wurde.
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>Beim Verbinden: {@code {"app":"SwyxTray",…,"type":"hello"}}, danach ein Snapshot.</li>
|
||||||
|
* <li>Kommando: {@code {"id":7,"cmd":"call","number":"…"}}
|
||||||
|
* → {@code {"id":7,"ok":true,"line":2,"type":"result"}}</li>
|
||||||
|
* <li><b>Keine Ereignisnachrichten.</b> Jede Änderung geht als vollständiger
|
||||||
|
* {@code snapshot} über alle Leitungen raus – auch ein eingehender Anruf.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* Ein eingehender Anruf lässt sich über {@code POST /api/mock/incoming-call} auslösen.
|
||||||
|
*
|
||||||
|
* <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.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class SwyxTrayMockHandler extends TextWebSocketHandler {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(SwyxTrayMockHandler.class);
|
||||||
|
|
||||||
|
/** So viele Leitungen meldet die echte App. */
|
||||||
|
private static final int LINE_COUNT = 4;
|
||||||
|
|
||||||
|
/** Vorgetäuschte Fenstertitel für {@code focus} – damit beide Zweige prüfbar sind. */
|
||||||
|
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;
|
||||||
|
|
||||||
|
private final ObjectMapper mapper;
|
||||||
|
private final Map<String, WebSocketSession> sessions = new ConcurrentHashMap<>();
|
||||||
|
private final Map<Integer, LineState> lines = new ConcurrentHashMap<>();
|
||||||
|
// Vorgetäuschte Tabs des Firefox-Plugins, in der Reihenfolge des Öffnens.
|
||||||
|
private final Map<Integer, MockTab> tabs = Collections.synchronizedMap(new LinkedHashMap<>());
|
||||||
|
private final AtomicInteger nextTabId = new AtomicInteger(41);
|
||||||
|
private final AtomicInteger nextSession = new AtomicInteger();
|
||||||
|
// Serialisiert Begrüßung und Snapshot, damit die Reihenfolge garantiert ist.
|
||||||
|
private final Object sendLock = new Object();
|
||||||
|
|
||||||
|
public SwyxTrayMockHandler(ObjectMapper mapper) {
|
||||||
|
this.mapper = mapper;
|
||||||
|
for (int line = 1; line <= LINE_COUNT; line++) {
|
||||||
|
lines.put(line, LineState.free(line));
|
||||||
|
}
|
||||||
|
// Zwei Tabs von Anfang an, damit die Startseite etwas anzuzeigen hat.
|
||||||
|
addTab("SwyxWeb", "http://localhost:5173/");
|
||||||
|
addTab("Kundenakte Muster GmbH", "https://crm.example.local/kunden/4711");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void afterConnectionEstablished(WebSocketSession session) {
|
||||||
|
sessions.put(session.getId(), session);
|
||||||
|
log.info("SwyxTray-Mock: Client {} verbunden ({} aktiv)", session.getId(), sessions.size());
|
||||||
|
synchronized (sendLock) {
|
||||||
|
sendText(session, toJson(hello()));
|
||||||
|
sendText(session, toJson(snapshot()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
|
||||||
|
sessions.remove(session.getId());
|
||||||
|
log.info("SwyxTray-Mock: Client {} getrennt ({}) – {} aktiv", session.getId(), status, sessions.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void handleTextMessage(WebSocketSession session, TextMessage message) {
|
||||||
|
JsonNode request;
|
||||||
|
try {
|
||||||
|
request = mapper.readTree(message.getPayload());
|
||||||
|
} catch (Exception e) {
|
||||||
|
sendText(session, toJson(result(0, false, null, "Ungültiges JSON: " + e.getMessage())));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int id = request.path("id").asInt(0);
|
||||||
|
String cmd = request.path("cmd").asString(null);
|
||||||
|
log.debug("SwyxTray-Mock: Kommando {} (id={})", cmd, id);
|
||||||
|
|
||||||
|
if (cmd == null) {
|
||||||
|
sendText(session, toJson(result(id, false, null, "Feld 'cmd' fehlt.")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// `focus` rührt keine Leitung an und schickt deshalb auch keinen Snapshot.
|
||||||
|
if ("focus".equals(cmd)) {
|
||||||
|
boolean focused = focus(request.path("title").asString(null),
|
||||||
|
request.path("url").asString(null));
|
||||||
|
sendText(session, toJson(focusResult(id, focused)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die drei Tab-Kommandos gehen bei der echten App ans Firefox-Plugin;
|
||||||
|
// sie berühren die Leitungen ebenfalls nicht.
|
||||||
|
switch (cmd) {
|
||||||
|
case "tabs" -> {
|
||||||
|
sendText(session, toJson(tabsResult(id, listTabs())));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "opentab" -> {
|
||||||
|
sendText(session, toJson(openTabResult(id, openTab(request.path("url").asString(null)))));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "closetab" -> {
|
||||||
|
closeTab(request.path("tabId"));
|
||||||
|
sendText(session, toJson(result(id, true, null, null)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
default -> { /* weiter unten */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
Integer line = switch (cmd) {
|
||||||
|
case "call" -> dial(request.path("number").asString(null));
|
||||||
|
case "answer" -> answer(requiredLine(request));
|
||||||
|
case "hangup" -> hangup(requiredLine(request));
|
||||||
|
case "ping", "status" -> null;
|
||||||
|
default -> throw new IllegalArgumentException("Unbekanntes Kommando '" + cmd + "'.");
|
||||||
|
};
|
||||||
|
sendText(session, toJson(result(id, true, line, null)));
|
||||||
|
broadcastSnapshot();
|
||||||
|
} catch (IllegalArgumentException | IllegalStateException e) {
|
||||||
|
sendText(session, toJson(result(id, false, null, e.getMessage())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Kommandos -------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nimmt den Wählauftrag an und belegt eine freie Leitung – wie CLMgr, das
|
||||||
|
* asynchron wählt; der Verlauf kommt über die folgenden Snapshots.
|
||||||
|
*/
|
||||||
|
private int dial(String number) {
|
||||||
|
if (number == null || number.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Parameter 'number' fehlt.");
|
||||||
|
}
|
||||||
|
int line = freeLine();
|
||||||
|
lines.put(line, LineState.dialing(line, number.trim(), null));
|
||||||
|
log.info("SwyxTray-Mock: Wähle {} auf Leitung {}", number, line);
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int answer(int line) {
|
||||||
|
lines.put(line, occupied(line).connected());
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int hangup(int line) {
|
||||||
|
occupied(line);
|
||||||
|
lines.put(line, LineState.free(line));
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simuliert {@code focus}: Fenster mit passendem Titel „in den Vordergrund holen",
|
||||||
|
* sonst „Browser starten". Der Mock öffnet natürlich nichts, er protokolliert nur –
|
||||||
|
* die Antworten entsprechen aber denen der echten App.
|
||||||
|
*
|
||||||
|
* @return {@code true}, wenn ein Fenster gefunden wurde
|
||||||
|
*/
|
||||||
|
private boolean focus(String title, String url) {
|
||||||
|
if (title == null || title.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Feld 'title' fehlt.");
|
||||||
|
}
|
||||||
|
String wanted = title.trim();
|
||||||
|
boolean found = MOCK_WINDOWS.stream()
|
||||||
|
.anyMatch(window -> window.toLowerCase().contains(wanted.toLowerCase()));
|
||||||
|
if (found) {
|
||||||
|
log.info("SwyxTray-Mock: Fenster '{}' in den Vordergrund geholt", wanted);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (url == null || url.isBlank()) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Kein Fenster mit Titel '" + wanted + "' gefunden und keine URL angegeben.");
|
||||||
|
}
|
||||||
|
log.info("SwyxTray-Mock: Kein Fenster '{}' – würde {} im Browser öffnen", wanted, url.trim());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die offenen Tabs in der Reihenfolge des Öffnens. */
|
||||||
|
private List<MockTab> listTabs() {
|
||||||
|
synchronized (tabs) {
|
||||||
|
return List.copyOf(tabs.values());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simuliert {@code opentab}. Die echte App prüft die Adresse selbst und lässt
|
||||||
|
* nur absolute http(s)-Adressen durch – der Mock hält sich an dieselbe Regel.
|
||||||
|
*
|
||||||
|
* @return Kennung des neuen Tabs
|
||||||
|
*/
|
||||||
|
private int openTab(String url) {
|
||||||
|
if (url == null || url.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Feld 'url' fehlt.");
|
||||||
|
}
|
||||||
|
String wanted = url.trim();
|
||||||
|
URI uri;
|
||||||
|
try {
|
||||||
|
uri = URI.create(wanted);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
throw new IllegalArgumentException("Ungültige URL '" + wanted + "'.");
|
||||||
|
}
|
||||||
|
String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase();
|
||||||
|
if (!uri.isAbsolute() || !(scheme.equals("http") || scheme.equals("https"))) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Nur absolute http(s)-Adressen sind erlaubt, nicht '" + wanted + "'.");
|
||||||
|
}
|
||||||
|
MockTab tab = addTab(uri.getHost() == null ? wanted : uri.getHost(), wanted);
|
||||||
|
log.info("SwyxTray-Mock: Tab {} für {} geöffnet", tab.id(), wanted);
|
||||||
|
return tab.id();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Simuliert {@code closetab}; eine unbekannte Kennung ergibt einen Fehler. */
|
||||||
|
private void closeTab(JsonNode tabId) {
|
||||||
|
if (!tabId.isNumber()) {
|
||||||
|
throw new IllegalArgumentException("Feld 'tabId' fehlt.");
|
||||||
|
}
|
||||||
|
int id = tabId.asInt();
|
||||||
|
synchronized (tabs) {
|
||||||
|
if (tabs.remove(id) == null) {
|
||||||
|
throw new IllegalArgumentException("Kein Tab mit der Kennung " + id + ".");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.info("SwyxTray-Mock: Tab {} geschlossen", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legt einen Tab an; der zuletzt geöffnete ist der aktive. */
|
||||||
|
private MockTab addTab(String title, String url) {
|
||||||
|
MockTab tab = new MockTab(nextTabId.incrementAndGet(), title, url, true);
|
||||||
|
synchronized (tabs) {
|
||||||
|
tabs.replaceAll((id, existing) -> existing.withActive(false));
|
||||||
|
tabs.put(tab.id(), tab);
|
||||||
|
}
|
||||||
|
return tab;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Löst einen eingehenden Anruf aus: Leitung belegen, Snapshot verschicken. */
|
||||||
|
public LineState simulateIncomingCall(String number, String name) {
|
||||||
|
int line = freeLine();
|
||||||
|
LineState state = LineState.ringing(
|
||||||
|
line,
|
||||||
|
number == null || number.isBlank() ? "+493012345" : number.trim(),
|
||||||
|
name == null || name.isBlank() ? null : name.trim());
|
||||||
|
lines.put(line, state);
|
||||||
|
log.info("SwyxTray-Mock: Eingehender Anruf von {} auf Leitung {}", state.peer(), line);
|
||||||
|
broadcastSnapshot();
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Hilfsmittel -----------------------------------------------------
|
||||||
|
|
||||||
|
private int requiredLine(JsonNode request) {
|
||||||
|
JsonNode node = request.path("line");
|
||||||
|
if (!node.isNumber()) {
|
||||||
|
throw new IllegalArgumentException("Parameter 'line' fehlt.");
|
||||||
|
}
|
||||||
|
return node.asInt();
|
||||||
|
}
|
||||||
|
|
||||||
|
private LineState occupied(int line) {
|
||||||
|
LineState state = lines.get(line);
|
||||||
|
if (state == null || state.isFree()) {
|
||||||
|
throw new IllegalArgumentException("Leitung " + line + " ist nicht belegt.");
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int freeLine() {
|
||||||
|
for (int line = 1; line <= LINE_COUNT; line++) {
|
||||||
|
if (lines.get(line).isFree()) {
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new IllegalStateException("Alle Leitungen sind belegt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> hello() {
|
||||||
|
Map<String, Object> message = new LinkedHashMap<>();
|
||||||
|
message.put("app", "SwyxTray");
|
||||||
|
message.put("version", "mock");
|
||||||
|
message.put("protocol", PROTOCOL_VERSION);
|
||||||
|
message.put("session", nextSession.incrementAndGet());
|
||||||
|
message.put("type", "hello");
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vollzustand über <b>alle</b> Leitungen, auch die freien – wie die echte App. */
|
||||||
|
private Map<String, Object> snapshot() {
|
||||||
|
List<LineState> entries = new ArrayList<>(LINE_COUNT);
|
||||||
|
for (int line = 1; line <= LINE_COUNT; line++) {
|
||||||
|
entries.add(lines.get(line));
|
||||||
|
}
|
||||||
|
boolean anyRinging = entries.stream().anyMatch(state -> state.isRinging());
|
||||||
|
boolean anyBusy = entries.stream().anyMatch(state -> !state.isFree());
|
||||||
|
|
||||||
|
Map<String, Object> message = new LinkedHashMap<>();
|
||||||
|
message.put("connected", true);
|
||||||
|
message.put("serverUp", true);
|
||||||
|
// Gesamtzustand wie die echte App: "Ringing"/"Eingehender Ruf" bzw. "Idle"/"Bereit".
|
||||||
|
message.put("overall", anyRinging ? "Ringing" : anyBusy ? "Busy" : "Idle");
|
||||||
|
message.put("statusText", anyRinging ? "Eingehender Ruf" : anyBusy ? "Im Gespräch" : "Bereit");
|
||||||
|
message.put("user", "Mock, M.");
|
||||||
|
message.put("server", "127.0.0.1");
|
||||||
|
message.put("lines", entries);
|
||||||
|
message.put("type", "snapshot");
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Quittung auf {@code focus}; trägt statt der Leitung das Feld {@code focused}. */
|
||||||
|
private Map<String, Object> focusResult(int id, boolean focused) {
|
||||||
|
Map<String, Object> message = new LinkedHashMap<>();
|
||||||
|
message.put("id", id);
|
||||||
|
message.put("ok", true);
|
||||||
|
message.put("focused", focused);
|
||||||
|
message.put("type", "result");
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Quittung auf {@code tabs}; trägt die Liste der Tabs. */
|
||||||
|
private Map<String, Object> tabsResult(int id, List<MockTab> entries) {
|
||||||
|
Map<String, Object> message = new LinkedHashMap<>();
|
||||||
|
message.put("id", id);
|
||||||
|
message.put("ok", true);
|
||||||
|
message.put("tabs", entries);
|
||||||
|
message.put("type", "result");
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Quittung auf {@code opentab}; trägt die Kennung des neuen Tabs. */
|
||||||
|
private Map<String, Object> openTabResult(int id, int tabId) {
|
||||||
|
Map<String, Object> message = new LinkedHashMap<>();
|
||||||
|
message.put("id", id);
|
||||||
|
message.put("ok", true);
|
||||||
|
message.put("tabId", tabId);
|
||||||
|
message.put("type", "result");
|
||||||
|
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);
|
||||||
|
message.put("ok", ok);
|
||||||
|
if (line != null) {
|
||||||
|
message.put("line", line);
|
||||||
|
}
|
||||||
|
if (error != null) {
|
||||||
|
message.put("error", error);
|
||||||
|
}
|
||||||
|
message.put("type", "result");
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void broadcastSnapshot() {
|
||||||
|
String json = toJson(snapshot());
|
||||||
|
synchronized (sendLock) {
|
||||||
|
sessions.values().forEach(session -> sendText(session, json));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String toJson(Object value) {
|
||||||
|
return mapper.writeValueAsString(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendText(WebSocketSession session, String json) {
|
||||||
|
if (!session.isOpen()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// sendMessage ist nicht threadsicher – Zugriffe pro Session serialisieren.
|
||||||
|
synchronized (session) {
|
||||||
|
try {
|
||||||
|
session.sendMessage(new TextMessage(json));
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.warn("Senden an {} fehlgeschlagen: {}", session.getId(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
spring.application.name=swyxweb-backend
|
||||||
|
|
||||||
|
# Port dieser Anwendung (REST-API und der eingebaute Test-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.port=17654
|
||||||
|
app.websocket.path=/ws
|
||||||
|
app.websocket.secure=false
|
||||||
|
|
||||||
|
logging.level.de.appcreation.swyxweb=DEBUG
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package de.appcreation.swyxweb;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
|
||||||
|
@SpringBootTest
|
||||||
|
class BackendApplicationTests {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void contextLoads() {
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Adresse des WebSocket-Servers; überschreibt den Wert aus /api/config.
|
||||||
|
VITE_WS_URL=ws://192.168.180.135:17654/ws
|
||||||
|
|
||||||
|
# Ziel des Dev-Proxys für /api, also der Port dieser Anwendung
|
||||||
|
# (Standard: http://localhost:8080)
|
||||||
|
VITE_BACKEND_URL=http://localhost:8080
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>SwyxWeb</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+2273
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "swyxweb-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"typecheck": "tsc -b --noEmit false --emitDeclarationOnly false"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^26.2.0",
|
||||||
|
"@types/react": "^19.1.0",
|
||||||
|
"@types/react-dom": "^19.1.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"vite": "^6.0.11",
|
||||||
|
"vitest": "^4.1.10"
|
||||||
|
},
|
||||||
|
"allowScripts": {
|
||||||
|
"esbuild@0.25.12": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import HomePage from './pages/HomePage'
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return <HomePage />
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react'
|
||||||
|
import { describeTab, type BrowserTab, type ResultMessage } from '../swyx/protocol'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Panel ist sichtbar – erst dann wird von selbst geladen. */
|
||||||
|
active: boolean
|
||||||
|
disabled: boolean
|
||||||
|
busy: boolean
|
||||||
|
onList: () => Promise<ResultMessage | null>
|
||||||
|
onOpen: (url: string) => Promise<ResultMessage | null>
|
||||||
|
onClose: (tabId: number) => Promise<ResultMessage | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_URL = 'swyxweb.tabs.url'
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die App nimmt nur absolute http(s)-Adressen an. Der gleiche Test hier erspart
|
||||||
|
* den Umweg über App und Plugin und erklärt den Fehler an Ort und Stelle.
|
||||||
|
*/
|
||||||
|
function isAbsoluteHttpUrl(value: string): boolean {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(value)
|
||||||
|
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tabs des Firefox-Plugins: anzeigen (`tabs`), öffnen (`opentab`) und
|
||||||
|
* schließen (`closetab`). Die SwyxTray-App reicht alle drei Kommandos nur an
|
||||||
|
* das Plugin durch – ohne verbundenes Plugin antwortet sie mit einem Fehler.
|
||||||
|
*/
|
||||||
|
export default function BrowserTabsPanel({ active, disabled, busy, onList, onOpen, onClose }: Props) {
|
||||||
|
const [tabs, setTabs] = useState<BrowserTab[] | null>(null)
|
||||||
|
const [url, setUrl] = useState(() => stored(STORAGE_URL))
|
||||||
|
const [hint, setHint] = useState<string | null>(null)
|
||||||
|
const [outcome, setOutcome] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Nach einem Verbindungsabbruch soll beim nächsten Öffnen erneut geladen werden.
|
||||||
|
const loadedRef = useRef(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holt die Liste neu. Die Meldung des letzten Vorgangs bleibt dabei stehen –
|
||||||
|
* Öffnen und Schließen laden hinterher nach und würden sie sonst selbst löschen.
|
||||||
|
*/
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const result = await onList()
|
||||||
|
// null heißt: Fehler – der steht bereits in der Fehleranzeige der Seite.
|
||||||
|
if (!result) return
|
||||||
|
setTabs(result.tabs ?? [])
|
||||||
|
}, [onList])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (disabled) {
|
||||||
|
// Ohne Verbindung ist die Liste veraltet; sie wird beim nächsten Mal neu geholt.
|
||||||
|
loadedRef.current = false
|
||||||
|
setTabs(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!active || loadedRef.current) return
|
||||||
|
loadedRef.current = true
|
||||||
|
void load()
|
||||||
|
}, [active, disabled, load])
|
||||||
|
|
||||||
|
async function handleOpen(event: FormEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
const wanted = url.trim()
|
||||||
|
if (!wanted) return
|
||||||
|
if (!isAbsoluteHttpUrl(wanted)) {
|
||||||
|
setHint('Bitte eine vollständige Adresse angeben, z. B. https://example.com/seite.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setHint(null)
|
||||||
|
setOutcome(null)
|
||||||
|
remember(STORAGE_URL, wanted)
|
||||||
|
const result = await onOpen(wanted)
|
||||||
|
if (!result) return
|
||||||
|
|
||||||
|
// Die Liste ist jetzt veraltet – die App meldet Änderungen nicht von selbst.
|
||||||
|
await load()
|
||||||
|
setOutcome(
|
||||||
|
result.tabId === undefined
|
||||||
|
? `${wanted} geöffnet.`
|
||||||
|
: `${wanted} als Tab ${result.tabId} geöffnet.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleClose(tab: BrowserTab) {
|
||||||
|
setOutcome(null)
|
||||||
|
const result = await onClose(tab.id)
|
||||||
|
if (!result) return
|
||||||
|
await load()
|
||||||
|
setOutcome(`Tab „${describeTab(tab)}" geschlossen.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="card__header">
|
||||||
|
<h2>Tabs (Firefox-Plugin)</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button button--ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setOutcome(null)
|
||||||
|
void load()
|
||||||
|
}}
|
||||||
|
disabled={disabled || busy}
|
||||||
|
>
|
||||||
|
{busy ? 'Sende …' : 'Tabs anzeigen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{disabled ? (
|
||||||
|
<p className="note">Erst mit der SwyxTray-App verbinden.</p>
|
||||||
|
) : tabs === null ? (
|
||||||
|
<p className="note">Noch nicht abgefragt.</p>
|
||||||
|
) : tabs.length === 0 ? (
|
||||||
|
<p className="note">Keine offenen Tabs gemeldet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="tablist">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<li key={tab.id} className={`tablist__item${tab.active ? ' tablist__item--active' : ''}`}>
|
||||||
|
<span className="tablist__id">{tab.id}</span>
|
||||||
|
<span className="tablist__info">
|
||||||
|
<span className="tablist__title">
|
||||||
|
{describeTab(tab)}
|
||||||
|
{tab.active && <span className="tablist__flag">aktiv</span>}
|
||||||
|
</span>
|
||||||
|
{tab.url && <span className="tablist__url">{tab.url}</span>}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button button--small"
|
||||||
|
onClick={() => void handleClose(tab)}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
Schließen
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form className="row" onSubmit={handleOpen}>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field__label">Neuen Tab öffnen</span>
|
||||||
|
<input
|
||||||
|
className="field__input"
|
||||||
|
type="url"
|
||||||
|
value={url}
|
||||||
|
disabled={disabled}
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder={disabled ? 'Erst verbinden' : 'https://crm.example.local/kunden/4711'}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="row__actions">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="button button--primary"
|
||||||
|
disabled={disabled || busy || !url.trim()}
|
||||||
|
>
|
||||||
|
{busy ? 'Sende …' : 'Tab öffnen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="note">
|
||||||
|
Die Tabs gehören zum Firefox auf dem Rechner der SwyxTray-App. Ohne verbundenes Plugin
|
||||||
|
beantwortet die App die drei Kommandos nach etwa fünf Sekunden mit einem Fehler.
|
||||||
|
</p>
|
||||||
|
{hint && <p className="note note--error">{hint}</p>}
|
||||||
|
{outcome && <p className="note note--success">{outcome}</p>}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { describeCall, type CallEvent, type CallEventKind } from '../swyx/protocol'
|
||||||
|
|
||||||
|
const EVENT_LABELS: Record<CallEventKind, string> = {
|
||||||
|
incoming: 'klingelt',
|
||||||
|
outgoing: 'wählt',
|
||||||
|
connected: 'verbunden',
|
||||||
|
ended: 'beendet',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
calls: CallEvent[]
|
||||||
|
busy: boolean
|
||||||
|
onAnswer: (line: number) => void
|
||||||
|
onHangup: (line: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Belegte Leitungen mit Aktionen. */
|
||||||
|
export function ActiveCallList({ calls, busy, onAnswer, onHangup }: Props) {
|
||||||
|
if (calls.length === 0) {
|
||||||
|
return <p className="note">Zurzeit keine belegte Leitung.</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="calls">
|
||||||
|
{calls.map((call) => (
|
||||||
|
<li key={call.line} className={`calls__item calls__item--${call.event}`}>
|
||||||
|
<span className="calls__line" title={`Leitung ${call.line}`}>
|
||||||
|
L{call.line}
|
||||||
|
</span>
|
||||||
|
<div className="calls__info">
|
||||||
|
<span className="calls__number">{describeCall(call)}</span>
|
||||||
|
{/* Klartext der App bevorzugen – er ist genauer als unsere vier Zustände. */}
|
||||||
|
<span className="calls__state">{call.stateText ?? EVENT_LABELS[call.event]}</span>
|
||||||
|
</div>
|
||||||
|
<div className="calls__actions">
|
||||||
|
{call.event === 'incoming' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button button--accept button--small"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onAnswer(call.line)}
|
||||||
|
>
|
||||||
|
Annehmen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button button--reject button--small"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onHangup(call.line)}
|
||||||
|
>
|
||||||
|
Auflegen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verlauf aller empfangenen Anruf-Ereignisse. */
|
||||||
|
export function CallHistory({ events }: { events: CallEvent[] }) {
|
||||||
|
if (events.length === 0) {
|
||||||
|
return <p className="note">Noch keine Anruf-Ereignisse empfangen.</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="history">
|
||||||
|
{events.map((event, index) => (
|
||||||
|
<li key={`${event.line}-${event.event}-${index}`} className="history__item">
|
||||||
|
<span className="history__line">L{event.line}</span>
|
||||||
|
<span className={`history__badge history__badge--${event.event}`}>
|
||||||
|
{EVENT_LABELS[event.event]}
|
||||||
|
</span>
|
||||||
|
<span className="history__number">{describeCall(event)}</span>
|
||||||
|
{event.direction && (
|
||||||
|
<span className="history__meta">
|
||||||
|
{event.direction === 'incoming' ? 'eingehend' : 'ausgehend'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
disabled: boolean
|
||||||
|
busy: boolean
|
||||||
|
onDial: (number: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eingabe für Click-to-Dial (Kommando `call`). */
|
||||||
|
export default function DialPanel({ disabled, busy, onDial }: Props) {
|
||||||
|
const [number, setNumber] = useState('')
|
||||||
|
|
||||||
|
function handleSubmit(event: FormEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
const target = number.trim()
|
||||||
|
if (!target) return
|
||||||
|
onDial(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="row" onSubmit={handleSubmit}>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field__label">Rufnummer</span>
|
||||||
|
<input
|
||||||
|
className="field__input"
|
||||||
|
type="tel"
|
||||||
|
value={number}
|
||||||
|
disabled={disabled}
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="tel"
|
||||||
|
placeholder={disabled ? 'Erst verbinden' : '+49 30 1234567'}
|
||||||
|
onChange={(e) => setNumber(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="row__actions">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="button button--primary"
|
||||||
|
disabled={disabled || busy || !number.trim()}
|
||||||
|
>
|
||||||
|
{busy ? 'Wähle …' : 'Anrufen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import type { ResultMessage } from '../swyx/protocol'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
disabled: boolean
|
||||||
|
busy: boolean
|
||||||
|
onFocus: (title: string, url?: string) => Promise<ResultMessage | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_TITLE = 'swyxweb.focus.title'
|
||||||
|
const STORAGE_URL = 'swyxweb.focus.url'
|
||||||
|
|
||||||
|
/** Merkt sich die Eingaben über einen Neuladevorgang hinweg. */
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eingabe für das Kommando `focus`: Fenster mit dem Titel nach vorn holen,
|
||||||
|
* ersatzweise die URL im Standardbrowser öffnen.
|
||||||
|
*/
|
||||||
|
export default function FocusPanel({ disabled, busy, onFocus }: Props) {
|
||||||
|
const [title, setTitle] = useState(() => stored(STORAGE_TITLE))
|
||||||
|
const [url, setUrl] = useState(() => stored(STORAGE_URL))
|
||||||
|
const [outcome, setOutcome] = useState<string | null>(null)
|
||||||
|
|
||||||
|
async function handleSubmit(event: FormEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
const wantedTitle = title.trim()
|
||||||
|
const wantedUrl = url.trim()
|
||||||
|
if (!wantedTitle) return
|
||||||
|
|
||||||
|
remember(STORAGE_TITLE, wantedTitle)
|
||||||
|
remember(STORAGE_URL, wantedUrl)
|
||||||
|
setOutcome(null)
|
||||||
|
|
||||||
|
const result = await onFocus(wantedTitle, wantedUrl || undefined)
|
||||||
|
// null heißt: Fehler – der steht bereits in der Fehleranzeige der Seite.
|
||||||
|
if (!result) return
|
||||||
|
|
||||||
|
setOutcome(
|
||||||
|
result.focused
|
||||||
|
? `Fenster „${wantedTitle}" in den Vordergrund geholt.`
|
||||||
|
: wantedUrl
|
||||||
|
? `Kein Fenster gefunden – Browser mit ${wantedUrl} geöffnet.`
|
||||||
|
: 'Kein Fenster gefunden.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="row">
|
||||||
|
<label className="field">
|
||||||
|
<span className="field__label">Fenstertitel</span>
|
||||||
|
<input
|
||||||
|
className="field__input"
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
disabled={disabled}
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder={disabled ? 'Erst verbinden' : 'Kundenakte Muster GmbH'}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row">
|
||||||
|
<label className="field">
|
||||||
|
<span className="field__label">URL (falls kein Fenster gefunden wird)</span>
|
||||||
|
<input
|
||||||
|
className="field__input"
|
||||||
|
type="url"
|
||||||
|
value={url}
|
||||||
|
disabled={disabled}
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder="https://crm.example.local/kunden/4711"
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="row__actions">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="button button--primary"
|
||||||
|
disabled={disabled || busy || !title.trim()}
|
||||||
|
>
|
||||||
|
{busy ? 'Sende …' : 'Fenster holen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="note">
|
||||||
|
Ein Teil des Titels genügt, die Schreibweise ist egal. Gibt es kein passendes Fenster,
|
||||||
|
startet die App den Standardbrowser mit der URL – ohne URL meldet sie einen Fehler.
|
||||||
|
</p>
|
||||||
|
{outcome && <p className="note note--success">{outcome}</p>}
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { type CallEvent } from '../swyx/protocol'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
call: CallEvent
|
||||||
|
busy: boolean
|
||||||
|
onAnswer: (line: number) => void
|
||||||
|
onHangup: (line: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Meldung für einen eingehenden, noch klingelnden Anruf. */
|
||||||
|
export default function IncomingCallCard({ call, busy, onAnswer, onHangup }: Props) {
|
||||||
|
// Name und Nummer getrennt anzeigen, sonst die fertige Anzeigeform der App.
|
||||||
|
const title = call.peerName ?? call.peerNumber ?? call.peer ?? `Leitung ${call.line}`
|
||||||
|
const subtitle = call.peerName ? call.peerNumber : undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="card card--ringing" role="alert">
|
||||||
|
<div className="ringing">
|
||||||
|
<span className="ringing__icon" aria-hidden="true">
|
||||||
|
☎
|
||||||
|
</span>
|
||||||
|
<div className="ringing__text">
|
||||||
|
<p className="ringing__label">Eingehender Anruf · Leitung {call.line}</p>
|
||||||
|
<p className="ringing__number">{title}</p>
|
||||||
|
{subtitle && <p className="ringing__meta">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="ringing__actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button button--accept"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onAnswer(call.line)}
|
||||||
|
>
|
||||||
|
Annehmen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button button--reject"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onHangup(call.line)}
|
||||||
|
>
|
||||||
|
Ablehnen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import type { LogEntry } from '../hooks/useSwyxTray'
|
||||||
|
|
||||||
|
const PREFIX: Record<LogEntry['direction'], string> = {
|
||||||
|
in: '←',
|
||||||
|
out: '→',
|
||||||
|
system: '•',
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeFormat = new Intl.DateTimeFormat('de-DE', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
})
|
||||||
|
|
||||||
|
export default function MessageLog({ entries }: { entries: LogEntry[] }) {
|
||||||
|
const endRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
endRef.current?.scrollIntoView({ block: 'nearest' })
|
||||||
|
}, [entries])
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return <p className="log log--empty">Noch keine Nachrichten.</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="log" role="log" aria-live="polite">
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<div key={entry.id} className={`log__entry log__entry--${entry.direction}`}>
|
||||||
|
<span className="log__time">{timeFormat.format(entry.at)}</span>
|
||||||
|
<span className="log__arrow">{PREFIX[entry.direction]}</span>
|
||||||
|
<span className="log__text">{entry.text}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div ref={endRef} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { ConnectionStatus } from '../swyx/SwyxTrayClient'
|
||||||
|
|
||||||
|
const LABELS: Record<ConnectionStatus, string> = {
|
||||||
|
idle: 'Nicht verbunden',
|
||||||
|
connecting: 'Verbinde …',
|
||||||
|
open: 'Verbunden',
|
||||||
|
closing: 'Trenne …',
|
||||||
|
closed: 'Getrennt',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatusBadge({ status }: { status: ConnectionStatus }) {
|
||||||
|
return (
|
||||||
|
<span className={`badge badge--${status}`}>
|
||||||
|
<span className="badge__dot" aria-hidden="true" />
|
||||||
|
{LABELS[status]}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
export interface Tab<Id extends string> {
|
||||||
|
id: Id
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props<Id extends string> {
|
||||||
|
tabs: Tab<Id>[]
|
||||||
|
active: Id
|
||||||
|
onChange: (id: Id) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kennung des Knopfes zu einem Tab – die Panels verweisen mit aria-labelledby darauf. */
|
||||||
|
export function tabId(id: string): string {
|
||||||
|
return `tab-${id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kennung des Panels zu einem Tab. */
|
||||||
|
export function panelId(id: string): string {
|
||||||
|
return `panel-${id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tab-Leiste. Native Knöpfe, damit die Tastaturbedienung von selbst funktioniert. */
|
||||||
|
export default function Tabs<Id extends string>({ tabs, active, onChange }: Props<Id>) {
|
||||||
|
return (
|
||||||
|
<nav className="tabs" role="tablist" aria-label="Bereiche">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
id={tabId(tab.id)}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
className={`tabs__button${tab.id === active ? ' tabs__button--active' : ''}`}
|
||||||
|
aria-selected={tab.id === active}
|
||||||
|
aria-controls={panelId(tab.id)}
|
||||||
|
onClick={() => onChange(tab.id)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/** 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_PORT = 17654
|
||||||
|
export const DEFAULT_WS_PATH = '/ws'
|
||||||
|
|
||||||
|
export const DEFAULT_WS_URL =
|
||||||
|
import.meta.env.VITE_WS_URL ?? `ws://${DEFAULT_WS_HOST}:${DEFAULT_WS_PORT}${DEFAULT_WS_PATH}`
|
||||||
|
|
||||||
|
export interface ClientConfig {
|
||||||
|
websocketUrl: string
|
||||||
|
host: string
|
||||||
|
port: number
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holt die WebSocket-Adresse vom Backend. Schlägt der Aufruf fehl (Backend nicht
|
||||||
|
* erreichbar, rein statisches Hosting), bleibt es beim Default aus der .env.
|
||||||
|
*/
|
||||||
|
export async function fetchClientConfig(signal?: AbortSignal): Promise<string> {
|
||||||
|
const response = await fetch('/api/config', { signal })
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`/api/config antwortete mit HTTP ${response.status}`)
|
||||||
|
}
|
||||||
|
const config = (await response.json()) as Partial<ClientConfig>
|
||||||
|
if (!config.websocketUrl) {
|
||||||
|
throw new Error('/api/config lieferte keine websocketUrl')
|
||||||
|
}
|
||||||
|
return config.websocketUrl
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { describeCall, type CallEvent } from '../swyx/protocol'
|
||||||
|
|
||||||
|
type Permission = NotificationPermission | 'unsupported'
|
||||||
|
|
||||||
|
function currentPermission(): Permission {
|
||||||
|
return typeof Notification === 'undefined' ? 'unsupported' : Notification.permission
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeigt bei einem eingehenden Anruf eine System-Benachrichtigung, damit der
|
||||||
|
* Anruf auch bemerkt wird, wenn der Tab im Hintergrund liegt.
|
||||||
|
*/
|
||||||
|
export function useCallNotifications(ringingCall: CallEvent | null) {
|
||||||
|
const [permission, setPermission] = useState<Permission>(currentPermission)
|
||||||
|
// Pro klingelnder Leitung nur einmal benachrichtigen.
|
||||||
|
const notifiedRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
const requestPermission = useCallback(() => {
|
||||||
|
if (typeof Notification === 'undefined') return
|
||||||
|
void Notification.requestPermission().then(setPermission)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ringingCall) {
|
||||||
|
notifiedRef.current = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (permission !== 'granted' || notifiedRef.current === ringingCall.line) return
|
||||||
|
|
||||||
|
notifiedRef.current = ringingCall.line
|
||||||
|
const notification = new Notification(`Eingehender Anruf · Leitung ${ringingCall.line}`, {
|
||||||
|
body: describeCall(ringingCall),
|
||||||
|
tag: `swyx-line-${ringingCall.line}`,
|
||||||
|
})
|
||||||
|
notification.onclick = () => {
|
||||||
|
window.focus()
|
||||||
|
notification.close()
|
||||||
|
}
|
||||||
|
return () => notification.close()
|
||||||
|
}, [ringingCall, permission])
|
||||||
|
|
||||||
|
return { permission, requestPermission }
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
export interface LogEntry extends RawMessage {
|
||||||
|
id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zustand der App selbst, aus dem Snapshot – unabhängig von den Leitungen. */
|
||||||
|
export interface TrayState {
|
||||||
|
connected?: boolean
|
||||||
|
serverUp?: boolean
|
||||||
|
overall?: string
|
||||||
|
statusText?: string
|
||||||
|
user?: string
|
||||||
|
server?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_LOG_ENTRIES = 300
|
||||||
|
const MAX_HISTORY = 50
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bindet den SwyxTrayClient an React: Verbindungsstatus, Zustand der Leitungen,
|
||||||
|
* Ereignisverlauf und die Telefonie-Kommandos.
|
||||||
|
*/
|
||||||
|
export function useSwyxTray() {
|
||||||
|
const [status, setStatus] = useState<ConnectionStatus>('idle')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [log, setLog] = useState<LogEntry[]>([])
|
||||||
|
const [history, setHistory] = useState<CallEvent[]>([])
|
||||||
|
// Belegte Leitungen, nach Leitungsnummer; jeder Snapshot schreibt sie neu.
|
||||||
|
const [lines, setLines] = useState<Record<number, CallEvent>>({})
|
||||||
|
const [tray, setTray] = useState<TrayState>({})
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
const clientRef = useRef<SwyxTrayClient | null>(null)
|
||||||
|
const logIdRef = useRef(0)
|
||||||
|
// Stand der Leitungen für den Snapshot-Vergleich.
|
||||||
|
const linesRef = useRef<Record<number, CallEvent>>({})
|
||||||
|
|
||||||
|
if (clientRef.current === null) {
|
||||||
|
clientRef.current = new SwyxTrayClient()
|
||||||
|
}
|
||||||
|
const client = clientRef.current
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = [
|
||||||
|
client.on('status', (next) => {
|
||||||
|
setStatus(next)
|
||||||
|
if (next === 'open') setError(null)
|
||||||
|
// Der Leitungszustand der alten Verbindung ist nach dem Abbruch wertlos;
|
||||||
|
// die App schickt beim Verbinden ohnehin einen Snapshot.
|
||||||
|
if (next === 'closed') {
|
||||||
|
linesRef.current = {}
|
||||||
|
setLines({})
|
||||||
|
setTray({})
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
client.on('error', (message) => setError(message)),
|
||||||
|
client.on('raw', (message) => {
|
||||||
|
setLog((entries) => {
|
||||||
|
const next = [...entries, { ...message, id: logIdRef.current++ }]
|
||||||
|
return next.length > MAX_LOG_ENTRIES ? next.slice(next.length - MAX_LOG_ENTRIES) : next
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
client.on('snapshot', (snapshot: SnapshotMessage) => {
|
||||||
|
setTray({
|
||||||
|
connected: snapshot.connected,
|
||||||
|
serverUp: snapshot.serverUp,
|
||||||
|
overall: snapshot.overall,
|
||||||
|
statusText: snapshot.statusText,
|
||||||
|
user: snapshot.user,
|
||||||
|
server: snapshot.server,
|
||||||
|
})
|
||||||
|
// Der Vollzustand ersetzt den bisherigen Stand; die Anruf-Ereignisse für
|
||||||
|
// Verlauf und Benachrichtigung entstehen aus dem Vergleich mit ihm.
|
||||||
|
// Der Vergleich läuft über die Ref, nicht im State-Updater – der wird
|
||||||
|
// im StrictMode doppelt ausgeführt und würde den Verlauf verdoppeln.
|
||||||
|
const { lines: next, events } = mergeSnapshot(linesRef.current, snapshot.lines)
|
||||||
|
linesRef.current = next
|
||||||
|
setLines(next)
|
||||||
|
if (events.length > 0) {
|
||||||
|
setHistory((entries) => [...[...events].reverse(), ...entries].slice(0, MAX_HISTORY))
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
return () => {
|
||||||
|
unsubscribe.forEach((off) => off())
|
||||||
|
}
|
||||||
|
}, [client])
|
||||||
|
|
||||||
|
// Beim Verlassen der Seite Verbindung und Timer aufräumen.
|
||||||
|
useEffect(() => () => client.dispose(), [client])
|
||||||
|
|
||||||
|
const connect = useCallback((url: string) => client.connect(url), [client])
|
||||||
|
const disconnect = useCallback(() => client.disconnect(), [client])
|
||||||
|
|
||||||
|
/** Führt ein Kommando aus und übernimmt Fehleranzeige und Busy-Zustand. */
|
||||||
|
const run = useCallback(async <T,>(action: () => Promise<T>): Promise<T | null> => {
|
||||||
|
setBusy(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
return await action()
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e))
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const dial = useCallback((number: string) => run(() => client.dial(number)), [client, run])
|
||||||
|
const answer = useCallback((line: number) => run(() => client.answer(line)), [client, run])
|
||||||
|
const hangup = useCallback((line: number) => run(() => client.hangup(line)), [client, run])
|
||||||
|
const refresh = useCallback(() => run(() => client.requestStatus()), [client, run])
|
||||||
|
const focusWindow = useCallback(
|
||||||
|
(title: string, url?: string) => run(() => client.focusWindow(title, url)),
|
||||||
|
[client, run],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tabs des Firefox-Plugins; die App reicht diese drei Kommandos nur durch.
|
||||||
|
const listTabs = useCallback(() => run(() => client.listTabs()), [client, run])
|
||||||
|
const openTab = useCallback((url: string) => run(() => client.openTab(url)), [client, run])
|
||||||
|
const closeTab = useCallback((tabId: number) => run(() => client.closeTab(tabId)), [client, run])
|
||||||
|
|
||||||
|
const sendRaw = useCallback(
|
||||||
|
(text: string) => {
|
||||||
|
try {
|
||||||
|
client.sendRaw(text)
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[client],
|
||||||
|
)
|
||||||
|
|
||||||
|
const clearLog = useCallback(() => setLog([]), [])
|
||||||
|
|
||||||
|
const calls = useMemo(
|
||||||
|
() => Object.values(lines).sort((a, b) => a.line - b.line),
|
||||||
|
[lines],
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Erste klingelnde Leitung eines eingehenden Anrufs. */
|
||||||
|
const ringingCall = useMemo(
|
||||||
|
() => calls.find((call) => call.event === 'incoming') ?? null,
|
||||||
|
[calls],
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
error,
|
||||||
|
log,
|
||||||
|
history,
|
||||||
|
calls,
|
||||||
|
ringingCall,
|
||||||
|
tray,
|
||||||
|
busy,
|
||||||
|
connect,
|
||||||
|
disconnect,
|
||||||
|
dial,
|
||||||
|
answer,
|
||||||
|
hangup,
|
||||||
|
refresh,
|
||||||
|
focusWindow,
|
||||||
|
listTabs,
|
||||||
|
openTab,
|
||||||
|
closeTab,
|
||||||
|
sendRaw,
|
||||||
|
clearLog,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,597 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #0f1116;
|
||||||
|
--surface: #171a21;
|
||||||
|
--surface-2: #1f2530;
|
||||||
|
--border: #2b3341;
|
||||||
|
--text: #e6e9ef;
|
||||||
|
--text-muted: #98a2b3;
|
||||||
|
--accent: #4f8cff;
|
||||||
|
--ok: #35c98a;
|
||||||
|
--warn: #e0b341;
|
||||||
|
--err: #f2585b;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root {
|
||||||
|
--bg: #f5f7fa;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-2: #eef1f6;
|
||||||
|
--border: #d8dee9;
|
||||||
|
--text: #1b2230;
|
||||||
|
--text-muted: #5c6779;
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 20px 48px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page__header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 26px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page__subtitle {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page__main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page__footer {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 18px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
flex: 1 1 260px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field__label {
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field__input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field__input:focus {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field__input:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
padding: 9px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:hover:not(:disabled) {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button--primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button--ghost {
|
||||||
|
background: transparent;
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note--error {
|
||||||
|
color: var(--err);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note--success {
|
||||||
|
color: var(--ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs__button {
|
||||||
|
padding: 9px 16px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
/* Die aktive Kante überdeckt die Linie der Leiste. */
|
||||||
|
border-bottom: none;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
border-radius: 10px 10px 0 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs__button:hover:not(.tabs__button--active) {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs__button--active {
|
||||||
|
background: var(--surface);
|
||||||
|
border-color: var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[role='tabpanel'] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Muss nach der Regel oben stehen: display:flex schlägt sonst das hidden-Attribut. */
|
||||||
|
[role='tabpanel'][hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge__dot {
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge--open .badge__dot {
|
||||||
|
background: var(--ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge--connecting .badge__dot,
|
||||||
|
.badge--closing .badge__dot {
|
||||||
|
background: var(--warn);
|
||||||
|
animation: pulse 1.1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge--error .badge__dot,
|
||||||
|
.badge--closed .badge__dot {
|
||||||
|
background: var(--err);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
50% {
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.badge__dot {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Eingehender Anruf --- */
|
||||||
|
|
||||||
|
.card--ringing {
|
||||||
|
border-color: var(--ok);
|
||||||
|
box-shadow: 0 0 0 1px var(--ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ringing {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ringing__icon {
|
||||||
|
font-size: 30px;
|
||||||
|
animation: shake 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ringing__text {
|
||||||
|
flex: 1 1 200px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ringing__label {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ringing__number {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ringing__meta {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ringing__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shake {
|
||||||
|
25% {
|
||||||
|
transform: rotate(-14deg);
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
transform: rotate(14deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.ringing__icon {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.button--accept {
|
||||||
|
background: var(--ok);
|
||||||
|
border-color: var(--ok);
|
||||||
|
color: #06281c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button--reject {
|
||||||
|
background: var(--err);
|
||||||
|
border-color: var(--err);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button--small {
|
||||||
|
padding: 5px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Anruflisten --- */
|
||||||
|
|
||||||
|
.calls,
|
||||||
|
.history {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calls__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calls__item--incoming {
|
||||||
|
border-color: var(--ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calls__line,
|
||||||
|
.history__line {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--border);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calls__info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calls__number {
|
||||||
|
font-weight: 600;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calls__state {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calls__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history__time {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
min-width: 68px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history__badge {
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history__badge--incoming {
|
||||||
|
border-color: var(--ok);
|
||||||
|
color: var(--ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history__badge--connected,
|
||||||
|
.history__badge--outgoing {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history__badge--ended {
|
||||||
|
border-color: var(--err);
|
||||||
|
color: var(--err);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history__number {
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history__meta {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Tabs des Firefox-Plugins --- */
|
||||||
|
|
||||||
|
.tablist {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tablist__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tablist__item--active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tablist__id {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--border);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tablist__info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tablist__title {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
/* Lange Titel dürfen die Zeile nicht sprengen. */
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tablist__flag {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tablist__url {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log {
|
||||||
|
height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface-2);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log--empty {
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log__entry {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto auto 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log__time {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log__text {
|
||||||
|
word-break: break-word;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log__entry--in .log__arrow {
|
||||||
|
color: var(--ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log__entry--out .log__arrow {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log__entry--system {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import App from './App.tsx'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
const container = document.getElementById('root')
|
||||||
|
if (!container) {
|
||||||
|
throw new Error('Root-Element #root nicht gefunden')
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(container).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import { useEffect, useRef, useState, type FormEvent, type ReactNode } from 'react'
|
||||||
|
import { DEFAULT_WS_URL, fetchClientConfig } from '../config'
|
||||||
|
import { useSwyxTray } from '../hooks/useSwyxTray'
|
||||||
|
import { useCallNotifications } from '../hooks/useCallNotifications'
|
||||||
|
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 Tabs, { panelId, tabId, type Tab } from '../components/Tabs'
|
||||||
|
import { ActiveCallList, CallHistory } from '../components/CallList'
|
||||||
|
|
||||||
|
type TabName = 'calls' | 'browsertabs' | 'connection'
|
||||||
|
|
||||||
|
const TABS: Tab<TabName>[] = [
|
||||||
|
{ id: 'calls', label: 'Anrufe' },
|
||||||
|
{ id: 'browsertabs', label: 'Tabs' },
|
||||||
|
{ id: 'connection', label: 'Verbindung' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Panel eines Tabs; ausgeblendet statt ausgebaut, damit Eingaben erhalten bleiben. */
|
||||||
|
function TabPanel({ id, active, children }: { id: TabName; active: boolean; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div id={panelId(id)} role="tabpanel" aria-labelledby={tabId(id)} hidden={!active}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
const [url, setUrl] = useState(DEFAULT_WS_URL)
|
||||||
|
const [configNote, setConfigNote] = useState<string | null>(null)
|
||||||
|
const [tab, setTab] = useState<TabName>('calls')
|
||||||
|
const [draft, setDraft] = useState('')
|
||||||
|
|
||||||
|
const tray = useSwyxTray()
|
||||||
|
const { status, error, log, history, calls, ringingCall, busy, tray: appState } = tray
|
||||||
|
const isConnected = status === 'open'
|
||||||
|
const isBusyConnection = status === 'connecting' || status === 'open' || status === 'closing'
|
||||||
|
|
||||||
|
const { permission, requestPermission } = useCallNotifications(ringingCall)
|
||||||
|
|
||||||
|
// Der Benutzer soll seine eingetippte Adresse nicht durch die Backend-Antwort verlieren.
|
||||||
|
const urlTouched = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController()
|
||||||
|
fetchClientConfig(controller.signal)
|
||||||
|
.then((backendUrl) => {
|
||||||
|
if (controller.signal.aborted) return
|
||||||
|
setConfigNote(`Adresse vom Backend übernommen: ${backendUrl}`)
|
||||||
|
if (!urlTouched.current) setUrl(backendUrl)
|
||||||
|
})
|
||||||
|
.catch((e: unknown) => {
|
||||||
|
if (controller.signal.aborted) return
|
||||||
|
const message = e instanceof Error ? e.message : String(e)
|
||||||
|
setConfigNote(`Backend-Konfiguration nicht abrufbar (${message}) – Standardadresse wird verwendet.`)
|
||||||
|
})
|
||||||
|
return () => controller.abort()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function handleConnect(event: FormEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
const target = url.trim()
|
||||||
|
if (target) tray.connect(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSendRaw(event: FormEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
const message = draft.trim()
|
||||||
|
if (message && tray.sendRaw(message)) setDraft('')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<header className="page__header">
|
||||||
|
<div>
|
||||||
|
<h1>SwyxWeb</h1>
|
||||||
|
<p className="page__subtitle">Telefonie über die SwyxTray-App</p>
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={status} />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="page__main">
|
||||||
|
{/* Steht bewusst über den Tabs: Ein klingelnder Anruf darf nicht davon
|
||||||
|
abhängen, welcher Bereich gerade offen ist. */}
|
||||||
|
{ringingCall && (
|
||||||
|
<IncomingCallCard
|
||||||
|
call={ringingCall}
|
||||||
|
busy={busy}
|
||||||
|
onAnswer={(line) => void tray.answer(line)}
|
||||||
|
onHangup={(line) => void tray.hangup(line)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Tabs tabs={TABS} active={tab} onChange={setTab} />
|
||||||
|
|
||||||
|
<TabPanel id="calls" active={tab === 'calls'}>
|
||||||
|
<section className="card">
|
||||||
|
<h2>Anrufen</h2>
|
||||||
|
<DialPanel disabled={!isConnected} busy={busy} onDial={(number) => void tray.dial(number)} />
|
||||||
|
<p className="note">
|
||||||
|
Die Quittung bestätigt nur die Annahme des Wählauftrags – der Verlauf erscheint
|
||||||
|
anschließend unter „Leitungen".
|
||||||
|
</p>
|
||||||
|
{error && <p className="note note--error">{error}</p>}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<h2>Leitungen</h2>
|
||||||
|
<ActiveCallList
|
||||||
|
calls={calls}
|
||||||
|
busy={busy}
|
||||||
|
onAnswer={(line) => void tray.answer(line)}
|
||||||
|
onHangup={(line) => void tray.hangup(line)}
|
||||||
|
/>
|
||||||
|
{isConnected && (appState.statusText || appState.user) && (
|
||||||
|
<p className={`note${appState.connected === false ? ' note--error' : ''}`}>
|
||||||
|
SwyxIt!: {appState.statusText ?? appState.overall ?? 'unbekannt'}
|
||||||
|
{appState.user && ` · ${appState.user}`}
|
||||||
|
{appState.connected === false && ' · keine Verbindung zu SwyxIt!'}
|
||||||
|
{appState.serverUp === false && ' · SwyxServer nicht erreichbar'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<h2>Verlauf</h2>
|
||||||
|
<CallHistory events={history} />
|
||||||
|
</section>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
<TabPanel id="browsertabs" active={tab === 'browsertabs'}>
|
||||||
|
<section className="card">
|
||||||
|
<BrowserTabsPanel
|
||||||
|
active={tab === 'browsertabs'}
|
||||||
|
disabled={!isConnected}
|
||||||
|
busy={busy}
|
||||||
|
// Unverpackt weitergereicht: die Rückrufe sind stabil, damit das
|
||||||
|
// Panel nicht bei jedem Rendern neu lädt.
|
||||||
|
onList={tray.listTabs}
|
||||||
|
onOpen={tray.openTab}
|
||||||
|
onClose={tray.closeTab}
|
||||||
|
/>
|
||||||
|
{error && <p className="note note--error">{error}</p>}
|
||||||
|
</section>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
<TabPanel id="connection" active={tab === 'connection'}>
|
||||||
|
<section className="card">
|
||||||
|
<h2>Verbindung</h2>
|
||||||
|
|
||||||
|
<form className="row" onSubmit={handleConnect}>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field__label">SwyxTray-Adresse</span>
|
||||||
|
<input
|
||||||
|
className="field__input"
|
||||||
|
type="text"
|
||||||
|
value={url}
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder="ws://192.168.180.135:17654/ws"
|
||||||
|
onChange={(e) => {
|
||||||
|
urlTouched.current = true
|
||||||
|
setUrl(e.target.value)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="row__actions">
|
||||||
|
<button type="submit" className="button button--primary" disabled={!url.trim()}>
|
||||||
|
{isConnected ? 'Neu verbinden' : 'Verbinden'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button"
|
||||||
|
onClick={tray.disconnect}
|
||||||
|
disabled={!isBusyConnection}
|
||||||
|
>
|
||||||
|
Trennen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{configNote && <p className="note">{configNote}</p>}
|
||||||
|
{error && <p className="note note--error">{error}</p>}
|
||||||
|
{permission !== 'granted' && (
|
||||||
|
<p className="note">
|
||||||
|
Für Hinweise bei eingehenden Anrufen auch außerhalb des Tabs:{' '}
|
||||||
|
<button type="button" className="button button--ghost" onClick={requestPermission}>
|
||||||
|
Benachrichtigungen erlauben
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<h2>Diagnose</h2>
|
||||||
|
<MessageLog entries={log} />
|
||||||
|
<form className="row" onSubmit={handleSendRaw}>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field__label">Rohnachricht senden</span>
|
||||||
|
<input
|
||||||
|
className="field__input"
|
||||||
|
type="text"
|
||||||
|
value={draft}
|
||||||
|
disabled={!isConnected}
|
||||||
|
placeholder={'{"id":1,"cmd":"status"}'}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="row__actions">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="button button--primary"
|
||||||
|
disabled={!isConnected || !draft.trim()}
|
||||||
|
>
|
||||||
|
Senden
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button"
|
||||||
|
onClick={() => void tray.refresh()}
|
||||||
|
disabled={!isConnected || busy}
|
||||||
|
>
|
||||||
|
Status abfragen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button button--ghost"
|
||||||
|
onClick={tray.clearLog}
|
||||||
|
disabled={log.length === 0}
|
||||||
|
>
|
||||||
|
Log leeren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</TabPanel>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<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>snapshot</code> · Standardziel <code>{DEFAULT_WS_URL}</code>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
import {
|
||||||
|
COMMANDS,
|
||||||
|
parseHello,
|
||||||
|
parseResult,
|
||||||
|
parseSnapshot,
|
||||||
|
type HelloMessage,
|
||||||
|
type ResultMessage,
|
||||||
|
type SnapshotMessage,
|
||||||
|
} from './protocol'
|
||||||
|
|
||||||
|
export type ConnectionStatus = 'idle' | 'connecting' | 'open' | 'closing' | 'closed'
|
||||||
|
|
||||||
|
export interface RawMessage {
|
||||||
|
direction: 'in' | 'out' | 'system'
|
||||||
|
text: string
|
||||||
|
at: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Listeners {
|
||||||
|
status: (status: ConnectionStatus) => void
|
||||||
|
hello: (hello: HelloMessage) => void
|
||||||
|
snapshot: (snapshot: SnapshotMessage) => void
|
||||||
|
raw: (message: RawMessage) => void
|
||||||
|
error: (message: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Pending {
|
||||||
|
resolve: (value: ResultMessage) => void
|
||||||
|
reject: (reason: Error) => void
|
||||||
|
timer: ReturnType<typeof setTimeout>
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLOSE_CODE_NORMAL = 1000
|
||||||
|
const DEFAULT_TIMEOUT_MS = 10000
|
||||||
|
// Die Tab-Kommandos gehen ans Firefox-Plugin weiter; die App wartet darauf
|
||||||
|
// selbst fünf Sekunden, bevor sie mit einem Fehler antwortet.
|
||||||
|
const TAB_TIMEOUT_MS = 15000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebSocket-Client für die SwyxTray-App. Die Verbindung wird im Browser
|
||||||
|
* aufgebaut; Quittungen werden über die numerische `id` zugeordnet,
|
||||||
|
* unaufgeforderte Anruf-Ereignisse an registrierte Hörer gemeldet.
|
||||||
|
*/
|
||||||
|
export class SwyxTrayClient {
|
||||||
|
private url: string | null = null
|
||||||
|
private socket: WebSocket | null = null
|
||||||
|
private nextId = 1
|
||||||
|
private readonly pending = new Map<number, Pending>()
|
||||||
|
private readonly listeners: { [K in keyof Listeners]: Set<Listeners[K]> } = {
|
||||||
|
status: new Set(),
|
||||||
|
hello: new Set(),
|
||||||
|
snapshot: new Set(),
|
||||||
|
raw: new Set(),
|
||||||
|
error: new Set(),
|
||||||
|
}
|
||||||
|
|
||||||
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
private attempt = 0
|
||||||
|
// Wunsch des Benutzers: nach manuellem Trennen nicht erneut verbinden.
|
||||||
|
private desiredConnected = false
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly reconnectDelayMs = 1000,
|
||||||
|
private readonly maxReconnectDelayMs = 15000,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
on<K extends keyof Listeners>(event: K, listener: Listeners[K]): () => void {
|
||||||
|
this.listeners[event].add(listener as never)
|
||||||
|
return () => {
|
||||||
|
this.listeners[event].delete(listener as never)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isConnected(): boolean {
|
||||||
|
return this.socket?.readyState === WebSocket.OPEN
|
||||||
|
}
|
||||||
|
|
||||||
|
connect(url: string): void {
|
||||||
|
this.clearReconnectTimer()
|
||||||
|
this.desiredConnected = true
|
||||||
|
this.url = url
|
||||||
|
this.closeSocket('Neuverbindung')
|
||||||
|
this.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect(): void {
|
||||||
|
this.desiredConnected = false
|
||||||
|
this.clearReconnectTimer()
|
||||||
|
this.attempt = 0
|
||||||
|
if (!this.socket) {
|
||||||
|
this.emit('status', 'closed')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.emit('status', 'closing')
|
||||||
|
this.socket.close(CLOSE_CODE_NORMAL, 'Vom Benutzer getrennt')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sendet ein Kommando und löst mit dessen Quittung auf. Die Felder des
|
||||||
|
* Kommandos liegen flach neben `id` und `cmd`.
|
||||||
|
*/
|
||||||
|
send(
|
||||||
|
cmd: string,
|
||||||
|
payload: Record<string, unknown> = {},
|
||||||
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||||
|
): Promise<ResultMessage> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!this.isConnected()) {
|
||||||
|
reject(new Error('Keine Verbindung zur SwyxTray-App.'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const id = this.nextId++
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.pending.delete(id)
|
||||||
|
reject(new Error(`Zeitüberschreitung für Kommando '${cmd}'.`))
|
||||||
|
}, timeoutMs)
|
||||||
|
this.pending.set(id, { resolve, reject, timer })
|
||||||
|
|
||||||
|
const message = JSON.stringify({ id, cmd, ...payload })
|
||||||
|
this.socket!.send(message)
|
||||||
|
this.emit('raw', { direction: 'out', text: message, at: new Date() })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Freitext senden – nur für den Diagnosebereich der Startseite. */
|
||||||
|
sendRaw(text: string): void {
|
||||||
|
if (!this.isConnected()) {
|
||||||
|
throw new Error('Keine Verbindung zur SwyxTray-App.')
|
||||||
|
}
|
||||||
|
this.socket!.send(text)
|
||||||
|
this.emit('raw', { direction: 'out', text, at: new Date() })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Startet einen Wählvorgang. Die Quittung bestätigt nur die Annahme des
|
||||||
|
* Auftrags – der Verlauf kommt anschließend als Push-Nachricht.
|
||||||
|
*/
|
||||||
|
dial(number: string): Promise<ResultMessage> {
|
||||||
|
return this.send(COMMANDS.call, { number })
|
||||||
|
}
|
||||||
|
|
||||||
|
answer(line: number): Promise<ResultMessage> {
|
||||||
|
return this.send(COMMANDS.answer, { line })
|
||||||
|
}
|
||||||
|
|
||||||
|
hangup(line: number): Promise<ResultMessage> {
|
||||||
|
return this.send(COMMANDS.hangup, { line })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holt auf dem Rechner der App das Fenster nach vorn, dessen Titel `title`
|
||||||
|
* enthält. Ohne Treffer öffnet die App `url` im Standardbrowser; fehlt auch
|
||||||
|
* die, antwortet sie mit einem Fehler.
|
||||||
|
*/
|
||||||
|
focusWindow(title: string, url?: string): Promise<ResultMessage> {
|
||||||
|
const payload: Record<string, unknown> = { title }
|
||||||
|
// Leeres Feld gar nicht erst mitschicken – die App entscheidet an seinem
|
||||||
|
// Vorhandensein, ob sie ersatzweise den Browser startet.
|
||||||
|
if (url) payload.url = url
|
||||||
|
return this.send(COMMANDS.focus, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fragt die offenen Tabs des Firefox-Plugins ab. Die App reicht das Kommando
|
||||||
|
* nur durch; ohne verbundenes Plugin antwortet sie nach ihrem Zeitfenster von
|
||||||
|
* fünf Sekunden mit einem Fehler.
|
||||||
|
*/
|
||||||
|
listTabs(): Promise<ResultMessage> {
|
||||||
|
return this.send(COMMANDS.tabs, {}, TAB_TIMEOUT_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Öffnet `url` als neuen Tab; die Quittung trägt dessen Kennung in `tabId`. */
|
||||||
|
openTab(url: string): Promise<ResultMessage> {
|
||||||
|
return this.send(COMMANDS.openTab, { url }, TAB_TIMEOUT_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schließt den Tab mit dieser Kennung – sie stammt aus einer `tabs`-Antwort. */
|
||||||
|
closeTab(tabId: number): Promise<ResultMessage> {
|
||||||
|
return this.send(COMMANDS.closeTab, { tabId }, TAB_TIMEOUT_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
ping(): Promise<ResultMessage> {
|
||||||
|
return this.send(COMMANDS.ping)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fordert einen frischen Snapshot an. */
|
||||||
|
requestStatus(): Promise<ResultMessage> {
|
||||||
|
return this.send(COMMANDS.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
private open(): void {
|
||||||
|
const url = this.url
|
||||||
|
if (!url) return
|
||||||
|
|
||||||
|
this.emit('status', 'connecting')
|
||||||
|
this.emit('raw', { direction: 'system', text: `Verbinde mit ${url} …`, at: new Date() })
|
||||||
|
|
||||||
|
let socket: WebSocket
|
||||||
|
try {
|
||||||
|
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('status', 'closed')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.socket = socket
|
||||||
|
|
||||||
|
socket.onopen = () => {
|
||||||
|
if (this.socket !== socket) return
|
||||||
|
this.attempt = 0
|
||||||
|
this.emit('status', 'open')
|
||||||
|
this.emit('raw', { direction: 'system', text: 'Verbindung hergestellt.', at: new Date() })
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onmessage = (event: MessageEvent<unknown>) => {
|
||||||
|
if (this.socket !== socket) return
|
||||||
|
this.handleMessage(event.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onerror = () => {
|
||||||
|
if (this.socket !== socket) return
|
||||||
|
// Der Browser liefert aus Sicherheitsgründen keine Fehlerdetails.
|
||||||
|
this.emit('error', 'Verbindungsfehler – Adresse, Port und Erreichbarkeit der SwyxTray-App prüfen.')
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onclose = (event: CloseEvent) => {
|
||||||
|
if (this.socket !== socket) return
|
||||||
|
this.socket = null
|
||||||
|
this.failAllPending(new Error('Verbindung zur SwyxTray-App geschlossen.'))
|
||||||
|
this.emit('status', 'closed')
|
||||||
|
this.emit('raw', {
|
||||||
|
direction: 'system',
|
||||||
|
text: `Verbindung geschlossen (Code ${event.code}${event.reason ? `, ${event.reason}` : ''}).`,
|
||||||
|
at: new Date(),
|
||||||
|
})
|
||||||
|
if (this.desiredConnected) {
|
||||||
|
this.scheduleReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleMessage(data: unknown): void {
|
||||||
|
if (typeof data !== 'string') {
|
||||||
|
const size = data instanceof Blob ? data.size : data instanceof ArrayBuffer ? data.byteLength : 0
|
||||||
|
this.emit('raw', { direction: 'in', text: `[Binärdaten, ${size} Bytes]`, at: new Date() })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.emit('raw', { direction: 'in', text: data, at: new Date() })
|
||||||
|
|
||||||
|
let parsed: unknown
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(data)
|
||||||
|
} catch {
|
||||||
|
// Kein JSON – bleibt im Diagnoseprotokoll sichtbar, sonst ignorieren.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = parseResult(parsed)
|
||||||
|
if (result) {
|
||||||
|
const request = this.pending.get(result.id)
|
||||||
|
if (!request) return
|
||||||
|
this.pending.delete(result.id)
|
||||||
|
clearTimeout(request.timer)
|
||||||
|
if (result.ok) {
|
||||||
|
request.resolve(result)
|
||||||
|
} else {
|
||||||
|
request.reject(new Error(result.error ?? 'Unbekannter Fehler.'))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die App schickt keine Ereignisse: Jede Änderung – auch ein eingehender
|
||||||
|
// Anruf – kommt als vollständiger Snapshot.
|
||||||
|
const snapshot = parseSnapshot(parsed)
|
||||||
|
if (snapshot) {
|
||||||
|
this.emit('snapshot', snapshot)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const hello = parseHello(parsed)
|
||||||
|
if (hello) {
|
||||||
|
this.emit('hello', hello)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nicht stillschweigend verwerfen – sonst bliebe eine Protokolländerung
|
||||||
|
// der App unbemerkt, genau wie bei den früher erwarteten call-Ereignissen.
|
||||||
|
this.emit('raw', {
|
||||||
|
direction: 'system',
|
||||||
|
text: 'Nachricht nicht erkannt – Protokoll der App prüfen.',
|
||||||
|
at: new Date(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleReconnect(): void {
|
||||||
|
if (this.reconnectTimer !== null) return
|
||||||
|
this.attempt += 1
|
||||||
|
const delay = Math.min(this.reconnectDelayMs * 2 ** (this.attempt - 1), this.maxReconnectDelayMs)
|
||||||
|
this.emit('raw', {
|
||||||
|
direction: 'system',
|
||||||
|
text: `Neuer Versuch #${this.attempt} in ${Math.round(delay / 1000)} s …`,
|
||||||
|
at: new Date(),
|
||||||
|
})
|
||||||
|
this.reconnectTimer = setTimeout(() => {
|
||||||
|
this.reconnectTimer = null
|
||||||
|
if (this.desiredConnected) this.open()
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearReconnectTimer(): void {
|
||||||
|
if (this.reconnectTimer !== null) {
|
||||||
|
clearTimeout(this.reconnectTimer)
|
||||||
|
this.reconnectTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schließt eine bestehende Verbindung, ohne einen Reconnect auszulösen. */
|
||||||
|
private closeSocket(reason: string): void {
|
||||||
|
const socket = this.socket
|
||||||
|
if (!socket) return
|
||||||
|
socket.onopen = null
|
||||||
|
socket.onmessage = null
|
||||||
|
socket.onerror = null
|
||||||
|
socket.onclose = null
|
||||||
|
this.socket = null
|
||||||
|
if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) {
|
||||||
|
socket.close(CLOSE_CODE_NORMAL, reason)
|
||||||
|
}
|
||||||
|
this.failAllPending(new Error(reason))
|
||||||
|
}
|
||||||
|
|
||||||
|
private failAllPending(error: Error): void {
|
||||||
|
for (const request of this.pending.values()) {
|
||||||
|
clearTimeout(request.timer)
|
||||||
|
request.reject(error)
|
||||||
|
}
|
||||||
|
this.pending.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Beendet den Client endgültig (Seitenwechsel). */
|
||||||
|
dispose(): void {
|
||||||
|
this.desiredConnected = false
|
||||||
|
this.clearReconnectTimer()
|
||||||
|
this.closeSocket('Seite verlassen')
|
||||||
|
for (const set of Object.values(this.listeners)) {
|
||||||
|
set.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit<K extends keyof Listeners>(event: K, ...args: Parameters<Listeners[K]>): void {
|
||||||
|
for (const listener of this.listeners[event]) {
|
||||||
|
;(listener as (...a: unknown[]) => void)(...args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
callStateOf,
|
||||||
|
describeCall,
|
||||||
|
describeTab,
|
||||||
|
mergeSnapshot,
|
||||||
|
parseHello,
|
||||||
|
parseResult,
|
||||||
|
parseSnapshot,
|
||||||
|
parseTabs,
|
||||||
|
type CallEvent,
|
||||||
|
} from './protocol'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Beispielnachrichten sind gegen die laufende SwyxTray-App 1.0.0.0
|
||||||
|
* mitgeschnitten (ws://192.168.180.135:17654/ws).
|
||||||
|
*/
|
||||||
|
const HELLO = '{"app":"SwyxTray","version":"1.0.0.0","protocol":1,"session":7,"type":"hello"}'
|
||||||
|
|
||||||
|
/** Wörtlich mitgeschnitten, während ein Anruf auf Leitung 1 einging. */
|
||||||
|
const RINGING_SNAPSHOT =
|
||||||
|
'{"connected":true,"serverUp":true,"overall":"Ringing","statusText":"Eingehender Ruf",' +
|
||||||
|
'"user":"Muster, M.","server":"127.0.0.1","lines":[' +
|
||||||
|
'{"line":1,"state":"LSRinging","stateCode":3,"stateText":"klingelt","peer":"001602107449",' +
|
||||||
|
'"peerNumber":"001602107449","peerName":"","busy":true,"selected":true},' +
|
||||||
|
'{"line":2,"state":"LSInactive","stateCode":0,"stateText":"frei","peer":"unbekannt",' +
|
||||||
|
'"peerNumber":"","peerName":"","busy":false,"selected":false},' +
|
||||||
|
'{"line":3,"state":"LSInactive","stateCode":0,"stateText":"frei","peer":"unbekannt",' +
|
||||||
|
'"peerNumber":"","peerName":"","busy":false,"selected":false},' +
|
||||||
|
'{"line":4,"state":"LSInactive","stateCode":0,"stateText":"frei","peer":"unbekannt",' +
|
||||||
|
'"peerNumber":"","peerName":"","busy":false,"selected":false}],"type":"snapshot"}'
|
||||||
|
|
||||||
|
/** Leitungseintrag im Snapshot; Vorgabe ist eine freie Leitung. */
|
||||||
|
function line(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
line: 1,
|
||||||
|
state: 'LSInactive',
|
||||||
|
stateCode: 0,
|
||||||
|
stateText: 'frei',
|
||||||
|
peer: 'unbekannt',
|
||||||
|
peerNumber: '',
|
||||||
|
peerName: '',
|
||||||
|
busy: false,
|
||||||
|
selected: false,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshot(...entries: Record<string, unknown>[]) {
|
||||||
|
return {
|
||||||
|
connected: true,
|
||||||
|
serverUp: true,
|
||||||
|
overall: 'Idle',
|
||||||
|
statusText: 'Bereit',
|
||||||
|
user: 'Muster, M.',
|
||||||
|
server: '127.0.0.1',
|
||||||
|
lines: entries,
|
||||||
|
type: 'snapshot',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('parseHello', () => {
|
||||||
|
it('liest die Begrüßung der App', () => {
|
||||||
|
expect(parseHello(JSON.parse(HELLO))).toEqual({
|
||||||
|
app: 'SwyxTray',
|
||||||
|
version: '1.0.0.0',
|
||||||
|
protocol: 1,
|
||||||
|
session: 7,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hält andere Nachrichten für keine Begrüßung', () => {
|
||||||
|
expect(parseHello(snapshot())).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('liest die Protokollversion der Tab-Verwaltung', () => {
|
||||||
|
expect(
|
||||||
|
parseHello(JSON.parse('{"app":"SwyxTray","version":"1.0.0.0","protocol":6,"session":1,"type":"hello"}'))
|
||||||
|
?.protocol,
|
||||||
|
).toBe(6)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('callStateOf', () => {
|
||||||
|
it('hält freie Leitungen für frei', () => {
|
||||||
|
expect(callStateOf('LSInactive', 'frei')).toBeNull()
|
||||||
|
expect(callStateOf('LSTerminate', 'beendet')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('erkennt einen eingehenden Anruf am Zustand', () => {
|
||||||
|
expect(callStateOf('LSRinging', 'klingelt')).toBe('incoming')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('erkennt ihn auch nur am Klartext der App', () => {
|
||||||
|
expect(callStateOf('LSIrgendwas', 'klingelt')).toBe('incoming')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('erkennt den Wählvorgang', () => {
|
||||||
|
expect(callStateOf('LSDialing', 'wählt')).toBe('outgoing')
|
||||||
|
expect(callStateOf('LSAlerting', 'Verbindungsaufbau')).toBe('outgoing')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hält alles Unbekannte für belegt statt für frei', () => {
|
||||||
|
// Sonst verschwände eine Leitung stillschweigend – der Fehler, wegen dem
|
||||||
|
// eingehende Anrufe gar nicht angezeigt wurden.
|
||||||
|
expect(callStateOf('LSActive', 'verbunden')).toBe('connected')
|
||||||
|
expect(callStateOf('LSVölligNeu', 'was auch immer')).toBe('connected')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('parseSnapshot', () => {
|
||||||
|
it('liest den Zustand der App', () => {
|
||||||
|
expect(parseSnapshot(snapshot(line()))).toMatchObject({
|
||||||
|
connected: true,
|
||||||
|
serverUp: true,
|
||||||
|
overall: 'Idle',
|
||||||
|
statusText: 'Bereit',
|
||||||
|
user: 'Muster, M.',
|
||||||
|
server: '127.0.0.1',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verwirft freie Leitungen – die App schickt immer alle vier', () => {
|
||||||
|
const parsed = parseSnapshot(
|
||||||
|
snapshot(line({ line: 1 }), line({ line: 2 }), line({ line: 3 }), line({ line: 4 })),
|
||||||
|
)
|
||||||
|
expect(parsed?.lines).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('liest die klingelnde Leitung mit Gegenstelle', () => {
|
||||||
|
const parsed = parseSnapshot(
|
||||||
|
snapshot(
|
||||||
|
line({ line: 1 }),
|
||||||
|
line({
|
||||||
|
line: 2,
|
||||||
|
state: 'LSRinging',
|
||||||
|
stateCode: 4,
|
||||||
|
stateText: 'klingelt',
|
||||||
|
peer: 'Muster GmbH (+493012345)',
|
||||||
|
peerNumber: '+493012345',
|
||||||
|
peerName: 'Muster GmbH',
|
||||||
|
busy: true,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
expect(parsed?.lines).toEqual([
|
||||||
|
{
|
||||||
|
line: 2,
|
||||||
|
event: 'incoming',
|
||||||
|
direction: 'incoming',
|
||||||
|
peer: 'Muster GmbH (+493012345)',
|
||||||
|
peerNumber: '+493012345',
|
||||||
|
peerName: 'Muster GmbH',
|
||||||
|
stateText: 'klingelt',
|
||||||
|
state: 'LSRinging',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lässt den Platzhalter "unbekannt" weg', () => {
|
||||||
|
const parsed = parseSnapshot(snapshot(line({ state: 'LSActive', stateText: 'verbunden' })))
|
||||||
|
expect(parsed?.lines[0]).toMatchObject({ line: 1, event: 'connected', peer: undefined })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hält andere Nachrichten für keinen Snapshot', () => {
|
||||||
|
expect(parseSnapshot(JSON.parse(HELLO))).toBeNull()
|
||||||
|
expect(parseSnapshot('hallo')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('echter eingehender Anruf', () => {
|
||||||
|
it('meldet den mitgeschnittenen Anruf als klingelnde Leitung', () => {
|
||||||
|
const parsed = parseSnapshot(JSON.parse(RINGING_SNAPSHOT))
|
||||||
|
expect(parsed?.statusText).toBe('Eingehender Ruf')
|
||||||
|
expect(parsed?.lines).toEqual([
|
||||||
|
{
|
||||||
|
line: 1,
|
||||||
|
event: 'incoming',
|
||||||
|
direction: 'incoming',
|
||||||
|
peer: '001602107449',
|
||||||
|
peerNumber: '001602107449',
|
||||||
|
peerName: undefined,
|
||||||
|
stateText: 'klingelt',
|
||||||
|
state: 'LSRinging',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
// Aus dem Vergleich mit dem vorherigen Stand entsteht die Anrufmeldung.
|
||||||
|
const { events } = mergeSnapshot({}, parsed!.lines)
|
||||||
|
expect(events).toHaveLength(1)
|
||||||
|
expect(events[0]).toMatchObject({ line: 1, event: 'incoming' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('räumt die Leitung wieder ab, wenn der Anrufer auflegt', () => {
|
||||||
|
const ringing = parseSnapshot(JSON.parse(RINGING_SNAPSHOT))!
|
||||||
|
const { lines } = mergeSnapshot({}, ringing.lines)
|
||||||
|
const idle = parseSnapshot(snapshot(line({ line: 1 }), line({ line: 2 })))!
|
||||||
|
const { lines: next, events } = mergeSnapshot(lines, idle.lines)
|
||||||
|
expect(next).toEqual({})
|
||||||
|
expect(events[0]).toMatchObject({ line: 1, event: 'ended', peerNumber: '001602107449' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('mergeSnapshot', () => {
|
||||||
|
const ringing: CallEvent = {
|
||||||
|
line: 2,
|
||||||
|
event: 'incoming',
|
||||||
|
direction: 'incoming',
|
||||||
|
peerNumber: '+493012345',
|
||||||
|
peerName: 'Muster GmbH',
|
||||||
|
}
|
||||||
|
|
||||||
|
it('meldet eine neu klingelnde Leitung', () => {
|
||||||
|
const { lines, events } = mergeSnapshot({}, [ringing])
|
||||||
|
expect(events).toEqual([ringing])
|
||||||
|
expect(lines[2]).toEqual(ringing)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('meldet nichts, solange sich nichts ändert', () => {
|
||||||
|
const { lines } = mergeSnapshot({}, [ringing])
|
||||||
|
expect(mergeSnapshot(lines, [ringing]).events).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('führt die Richtung über das Annehmen hinweg mit', () => {
|
||||||
|
const { lines } = mergeSnapshot({}, [ringing])
|
||||||
|
const connected = { ...ringing, event: 'connected' as const, direction: undefined }
|
||||||
|
const { events } = mergeSnapshot(lines, [connected])
|
||||||
|
expect(events).toEqual([{ ...connected, direction: 'incoming' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('meldet das Ende, wenn die Leitung im Snapshot fehlt', () => {
|
||||||
|
const { lines } = mergeSnapshot({}, [ringing])
|
||||||
|
const { lines: next, events } = mergeSnapshot(lines, [])
|
||||||
|
expect(events).toEqual([{ ...ringing, event: 'ended', state: undefined, stateText: undefined }])
|
||||||
|
expect(next).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('erkennt einen zweiten Anruf auf derselben Leitung', () => {
|
||||||
|
const { lines } = mergeSnapshot({}, [ringing])
|
||||||
|
const other = { ...ringing, peerNumber: '+499998888', peerName: 'Andere GmbH' }
|
||||||
|
expect(mergeSnapshot(lines, [other]).events).toEqual([other])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('parseResult', () => {
|
||||||
|
it('liest die Erfolgsquittung mit Leitungsnummer', () => {
|
||||||
|
expect(parseResult(JSON.parse('{"id":11,"ok":true,"line":4,"type":"result"}'))).toEqual({
|
||||||
|
id: 11,
|
||||||
|
ok: true,
|
||||||
|
line: 4,
|
||||||
|
error: undefined,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('liest die Fehlerquittung', () => {
|
||||||
|
expect(
|
||||||
|
parseResult(JSON.parse('{"id":8,"ok":false,"error":"Unbekanntes Kommando","type":"result"}')),
|
||||||
|
).toMatchObject({ id: 8, ok: false, error: 'Unbekanntes Kommando' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('liest die beiden Quittungen auf focus', () => {
|
||||||
|
// Fenster war da und ist jetzt vorn.
|
||||||
|
expect(parseResult(JSON.parse('{"type":"result","id":9,"ok":true,"focused":true}'))).toMatchObject(
|
||||||
|
{ id: 9, ok: true, focused: true },
|
||||||
|
)
|
||||||
|
// Kein Fenster gefunden – die App hat die URL im Browser geöffnet.
|
||||||
|
expect(parseResult(JSON.parse('{"type":"result","id":9,"ok":true,"focused":false}'))).toMatchObject(
|
||||||
|
{ id: 9, ok: true, focused: false },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lässt focused bei allen anderen Quittungen offen', () => {
|
||||||
|
expect(parseResult(JSON.parse('{"id":11,"ok":true,"line":4,"type":"result"}'))?.focused).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('liest die Quittung auf tabs', () => {
|
||||||
|
const raw =
|
||||||
|
'{"type":"result","id":12,"ok":true,"tabs":[' +
|
||||||
|
'{"id":43,"title":"Kundenakte","url":"https://crm.example.local/kunden/4711","active":true},' +
|
||||||
|
'{"id":44,"title":"SwyxWeb","url":"http://localhost:5173/","active":false}]}'
|
||||||
|
expect(parseResult(JSON.parse(raw))?.tabs).toEqual([
|
||||||
|
{ id: 43, title: 'Kundenakte', url: 'https://crm.example.local/kunden/4711', active: true },
|
||||||
|
{ id: 44, title: 'SwyxWeb', url: 'http://localhost:5173/', active: false },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('liest die Kennung aus der Quittung auf opentab', () => {
|
||||||
|
expect(parseResult(JSON.parse('{"type":"result","id":13,"ok":true,"tabId":43}'))).toMatchObject({
|
||||||
|
id: 13,
|
||||||
|
ok: true,
|
||||||
|
tabId: 43,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lässt tabs und tabId bei allen anderen Quittungen offen', () => {
|
||||||
|
const result = parseResult(JSON.parse('{"id":11,"ok":true,"line":4,"type":"result"}'))
|
||||||
|
expect(result?.tabs).toBeUndefined()
|
||||||
|
expect(result?.tabId).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hält einen Snapshot für keine Quittung', () => {
|
||||||
|
expect(parseResult(snapshot())).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('parseTabs', () => {
|
||||||
|
it('nimmt fehlende Felder hin – nur die Kennung ist Pflicht', () => {
|
||||||
|
expect(parseTabs([{ id: 7 }])).toEqual([{ id: 7, title: undefined, url: undefined, active: false }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verwirft Einträge ohne brauchbare Kennung', () => {
|
||||||
|
// Ohne Kennung ließe sich der Tab ohnehin nicht schließen.
|
||||||
|
expect(parseTabs([{ title: 'Ohne Kennung' }, 'Unfug', null, { id: 9 }])).toEqual([
|
||||||
|
{ id: 9, title: undefined, url: undefined, active: false },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('describeTab', () => {
|
||||||
|
it('nimmt den Titel, sonst die URL, sonst die Kennung', () => {
|
||||||
|
expect(describeTab({ id: 1, title: 'Kundenakte', url: 'https://x/', active: true })).toBe('Kundenakte')
|
||||||
|
expect(describeTab({ id: 2, url: 'https://x/', active: false })).toBe('https://x/')
|
||||||
|
expect(describeTab({ id: 3, active: false })).toBe('Tab 3')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('describeCall', () => {
|
||||||
|
it('bevorzugt die fertige Anzeigeform der App', () => {
|
||||||
|
expect(describeCall({ line: 1, event: 'incoming', peer: 'Muster GmbH (+49301)' })).toBe(
|
||||||
|
'Muster GmbH (+49301)',
|
||||||
|
)
|
||||||
|
expect(describeCall({ line: 3, event: 'connected' })).toBe('Leitung 3')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
/**
|
||||||
|
* 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 → Seite:
|
||||||
|
* { "app":"SwyxTray","version":"1.0.0.0","protocol":1,"session":7,"type":"hello" }
|
||||||
|
* { "type":"snapshot","connected":true,"serverUp":true,"overall":"Idle",
|
||||||
|
* "statusText":"Bereit","user":"Muster, M.","server":"127.0.0.1",
|
||||||
|
* "lines":[{"line":1,"state":"LSInactive","stateCode":0,"stateText":"frei",
|
||||||
|
* "peer":"unbekannt","peerNumber":"","peerName":"","busy":false,
|
||||||
|
* "selected":true}, …] }
|
||||||
|
* { "type":"result","id":7,"ok":true,"line":2 }
|
||||||
|
*
|
||||||
|
* Seite → App:
|
||||||
|
* { "id":7,"cmd":"call","number":"+49 30 1234567" } ferner answer/hangup (je `line`),
|
||||||
|
* ping, status und focus (bestätigt durch Sondierung; alles andere quittiert die App mit
|
||||||
|
* „Unbekanntes Kommando").
|
||||||
|
* { "id":9,"cmd":"focus","title":"Kundenakte Muster GmbH","url":"https://…" }
|
||||||
|
* → { "type":"result","id":9,"ok":true,"focused":true } Fenster war da und ist jetzt vorn
|
||||||
|
* → { "type":"result","id":9,"ok":true,"focused":false } kein Fenster – Browser mit `url`
|
||||||
|
* Der Titel wird als Teilzeichenkette gesucht, unabhängig von der Schreibweise. Ohne
|
||||||
|
* Treffer und ohne `url` antwortet die App mit `ok:false`.
|
||||||
|
*
|
||||||
|
* Seit Protokoll 6 reicht die App drei weitere Kommandos unverändert an das
|
||||||
|
* Firefox-Plugin durch – die Tabs gehören also zum Browser auf dem Rechner der App:
|
||||||
|
* { "id":12,"cmd":"tabs" }
|
||||||
|
* → { "type":"result","id":12,"ok":true,
|
||||||
|
* "tabs":[{"id":43,"title":"Kundenakte","url":"https://…","active":true}, …] }
|
||||||
|
* { "id":13,"cmd":"opentab","url":"https://…" } → { …,"ok":true,"tabId":43 }
|
||||||
|
* { "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
|
||||||
|
* eingehender Anruf – kommt als vollständiger `snapshot` über *alle* Leitungen.
|
||||||
|
* Die Anrufmeldung der Seite entsteht deshalb aus dem Vergleich zweier Snapshots
|
||||||
|
* (siehe {@link mergeSnapshot}).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type CallEventKind = 'incoming' | 'outgoing' | 'connected' | 'ended'
|
||||||
|
export type CallDirection = 'incoming' | 'outgoing'
|
||||||
|
|
||||||
|
export interface CallEvent {
|
||||||
|
/** 1-basierte Leitungsnummer, wie in SwyxIt! */
|
||||||
|
line: number
|
||||||
|
event: CallEventKind
|
||||||
|
/** Bei `connected` aus dem vorherigen Zustand derselben Leitung übernommen. */
|
||||||
|
direction?: CallDirection
|
||||||
|
/** Fertige Anzeigeform der App, z. B. "Muster GmbH (+493012345)". */
|
||||||
|
peer?: string
|
||||||
|
peerNumber?: string
|
||||||
|
peerName?: string
|
||||||
|
/** Klartext der App zum Leitungszustand, z. B. "klingelt". */
|
||||||
|
stateText?: string
|
||||||
|
/** Rohzustand der App, z. B. "LSRinging" – für die Diagnose. */
|
||||||
|
state?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResultMessage {
|
||||||
|
id: number
|
||||||
|
ok: boolean
|
||||||
|
line?: number
|
||||||
|
/** Nur bei `focus`: true = Fenster geholt, false = Browser mit der URL gestartet. */
|
||||||
|
focused?: boolean
|
||||||
|
/** Nur bei `tabs`: die offenen Tabs des Firefox-Plugins. */
|
||||||
|
tabs?: BrowserTab[]
|
||||||
|
/** Nur bei `opentab`: Kennung des neu geöffneten Tabs. */
|
||||||
|
tabId?: number
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Tab des Firefox-Plugins, wie ihn die Quittung auf `tabs` meldet. */
|
||||||
|
export interface BrowserTab {
|
||||||
|
/** Kennung des Tabs; sie geht unverändert an `closetab` zurück. */
|
||||||
|
id: number
|
||||||
|
title?: string
|
||||||
|
url?: string
|
||||||
|
/** Ist das der im Fenster sichtbare Tab? */
|
||||||
|
active: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Begrüßung beim Verbindungsaufbau. */
|
||||||
|
export interface HelloMessage {
|
||||||
|
app?: string
|
||||||
|
version?: string
|
||||||
|
protocol?: number
|
||||||
|
session?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vollzustand; die App schickt ihn beim Verbinden und nach jeder Änderung. */
|
||||||
|
export interface SnapshotMessage {
|
||||||
|
/** Nur belegte Leitungen; freie Leitungen sind hier nicht enthalten. */
|
||||||
|
lines: CallEvent[]
|
||||||
|
/** Ist die App mit SwyxIt! verbunden? */
|
||||||
|
connected?: boolean
|
||||||
|
/** Ist der SwyxServer erreichbar? */
|
||||||
|
serverUp?: boolean
|
||||||
|
/** Gesamtzustand, z. B. "Idle" oder "Dialing". */
|
||||||
|
overall?: string
|
||||||
|
/** Klartext dazu, z. B. "Bereit". */
|
||||||
|
statusText?: string
|
||||||
|
user?: string
|
||||||
|
server?: string
|
||||||
|
raw: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kommandonamen; alle gegen die App bestätigt. */
|
||||||
|
export const COMMANDS = {
|
||||||
|
call: 'call',
|
||||||
|
answer: 'answer',
|
||||||
|
hangup: 'hangup',
|
||||||
|
ping: 'ping',
|
||||||
|
status: 'status',
|
||||||
|
focus: 'focus',
|
||||||
|
tabs: 'tabs',
|
||||||
|
openTab: 'opentab',
|
||||||
|
closeTab: 'closetab',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function asString(value: unknown): string | undefined {
|
||||||
|
if (typeof value === 'string' && value !== '') return value
|
||||||
|
if (typeof value === 'number') return String(value)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ganze Zahlen aus dem Protokoll – Leitungsnummer, Kommando-Id, Tab-Kennung.
|
||||||
|
* Eine Ziffernfolge als Text wird ebenfalls akzeptiert.
|
||||||
|
*/
|
||||||
|
function asLine(value: unknown): number | undefined {
|
||||||
|
if (typeof value === 'number' && Number.isInteger(value)) return value
|
||||||
|
if (typeof value === 'string' && /^\d+$/.test(value.trim())) return Number(value)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function asBoolean(value: unknown): boolean | undefined {
|
||||||
|
return typeof value === 'boolean' ? value : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leitungszustand der App → Anrufzustand der Seite; `null` heißt „frei".
|
||||||
|
*
|
||||||
|
* `state` trägt die Namen der CLMgr-Aufzählung `LineState`, `stateText` den
|
||||||
|
* deutschen Klartext aus SwyxIt!. Bestätigt ist bisher nur `LSInactive`/"frei";
|
||||||
|
* deshalb wird beides ausgewertet und ein **unbekannter Zustand als belegt**
|
||||||
|
* behandelt – eine klingelnde Leitung darf nie stillschweigend verschwinden.
|
||||||
|
*/
|
||||||
|
export function callStateOf(state?: string, stateText?: string): CallEventKind | null {
|
||||||
|
const name = (state ?? '').toLowerCase()
|
||||||
|
const text = (stateText ?? '').toLowerCase()
|
||||||
|
const has = (...needles: string[]) => needles.some((n) => name.includes(n) || text.includes(n))
|
||||||
|
|
||||||
|
// Frei oder gerade freigeworden.
|
||||||
|
if (has('inactive', 'disabled', 'terminate', 'frei', 'beendet')) return null
|
||||||
|
// Eingehend: die Leitung klingelt beim Benutzer.
|
||||||
|
if (has('ringing', 'knocking', 'klingelt', 'ruft an')) return 'incoming'
|
||||||
|
// Ausgehend: gewählt wird, die Gegenstelle wird gerufen.
|
||||||
|
if (has('dialing', 'initiate', 'alerting', 'wählt', 'wahl', 'verbindungsaufbau')) return 'outgoing'
|
||||||
|
// Alles Übrige gilt als belegt (aktiv, gehalten, Konferenz, unbekannt).
|
||||||
|
return 'connected'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liest einen Leitungseintrag aus dem Snapshot; `null`, wenn die Leitung frei ist. */
|
||||||
|
export function parseSnapshotLine(raw: unknown): CallEvent | null {
|
||||||
|
if (!isRecord(raw)) return null
|
||||||
|
const line = asLine(raw.line)
|
||||||
|
if (line === undefined) return null
|
||||||
|
|
||||||
|
const state = asString(raw.state)
|
||||||
|
const stateText = asString(raw.stateText)
|
||||||
|
const event = callStateOf(state, stateText)
|
||||||
|
if (!event) return null
|
||||||
|
|
||||||
|
// "unbekannt" ist der Platzhalter der App für eine Leitung ohne Gegenstelle.
|
||||||
|
const peer = asString(raw.peer)
|
||||||
|
return {
|
||||||
|
line,
|
||||||
|
event,
|
||||||
|
// Bei `connected` verrät der Zustand die Richtung nicht – sie kommt aus dem
|
||||||
|
// vorherigen Snapshot derselben Leitung (siehe mergeSnapshot).
|
||||||
|
direction: event === 'incoming' || event === 'outgoing' ? event : undefined,
|
||||||
|
peer: peer === 'unbekannt' ? undefined : peer,
|
||||||
|
peerNumber: asString(raw.peerNumber),
|
||||||
|
peerName: asString(raw.peerName),
|
||||||
|
stateText,
|
||||||
|
state,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liest die Quittung auf ein Kommando. */
|
||||||
|
export function parseResult(raw: unknown): ResultMessage | null {
|
||||||
|
if (!isRecord(raw)) return null
|
||||||
|
if (raw.type !== undefined && raw.type !== 'result') return null
|
||||||
|
if (typeof raw.ok !== 'boolean') return null
|
||||||
|
|
||||||
|
const id = asLine(raw.id)
|
||||||
|
if (id === undefined) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
ok: raw.ok,
|
||||||
|
line: asLine(raw.line),
|
||||||
|
focused: asBoolean(raw.focused),
|
||||||
|
tabs: Array.isArray(raw.tabs) ? parseTabs(raw.tabs) : undefined,
|
||||||
|
tabId: asLine(raw.tabId),
|
||||||
|
error: asString(raw.error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest die Tabliste aus der Quittung auf `tabs`. Einträge ohne brauchbare
|
||||||
|
* Kennung fallen weg – ohne sie ließe sich der Tab ohnehin nicht schließen.
|
||||||
|
*/
|
||||||
|
export function parseTabs(raw: unknown[]): BrowserTab[] {
|
||||||
|
return raw.flatMap((entry) => {
|
||||||
|
if (!isRecord(entry)) return []
|
||||||
|
const id = asLine(entry.id)
|
||||||
|
if (id === undefined) return []
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
title: asString(entry.title),
|
||||||
|
url: asString(entry.url),
|
||||||
|
active: asBoolean(entry.active) ?? false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Anzeigename eines Tabs: Titel, sonst die URL, sonst die Kennung. */
|
||||||
|
export function describeTab(tab: BrowserTab): string {
|
||||||
|
return tab.title ?? tab.url ?? `Tab ${tab.id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liest die Begrüßung. */
|
||||||
|
export function parseHello(raw: unknown): HelloMessage | null {
|
||||||
|
if (!isRecord(raw) || raw.type !== 'hello') return null
|
||||||
|
return {
|
||||||
|
app: asString(raw.app),
|
||||||
|
version: asString(raw.version),
|
||||||
|
protocol: asLine(raw.protocol),
|
||||||
|
session: asLine(raw.session),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest den Vollzustand. Freie Leitungen werden verworfen – die App schickt
|
||||||
|
* immer alle vier Leitungen mit, auch die unbenutzten.
|
||||||
|
*/
|
||||||
|
export function parseSnapshot(raw: unknown): SnapshotMessage | null {
|
||||||
|
if (!isRecord(raw) || raw.type !== 'snapshot') return null
|
||||||
|
|
||||||
|
const entries = Array.isArray(raw.lines) ? raw.lines : []
|
||||||
|
const lines = entries.flatMap((entry) => {
|
||||||
|
const parsed = parseSnapshotLine(entry)
|
||||||
|
return parsed ? [parsed] : []
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
lines,
|
||||||
|
connected: asBoolean(raw.connected),
|
||||||
|
serverUp: asBoolean(raw.serverUp),
|
||||||
|
overall: asString(raw.overall),
|
||||||
|
statusText: asString(raw.statusText),
|
||||||
|
user: asString(raw.user),
|
||||||
|
server: asString(raw.server),
|
||||||
|
raw,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Führt einen Snapshot mit dem bisherigen Stand zusammen.
|
||||||
|
*
|
||||||
|
* Weil die App keine Ereignisse schickt, entstehen sie hier aus dem Vergleich:
|
||||||
|
* neue oder veränderte Leitungen ergeben ein Ereignis, verschwundene ein
|
||||||
|
* `ended`. Die Richtung wird über den Wechsel hinweg mitgeführt, damit ein
|
||||||
|
* angenommener Anruf im Verlauf eingehend bleibt.
|
||||||
|
*/
|
||||||
|
export function mergeSnapshot(
|
||||||
|
previous: Record<number, CallEvent>,
|
||||||
|
lines: CallEvent[],
|
||||||
|
): { lines: Record<number, CallEvent>; events: CallEvent[] } {
|
||||||
|
const next: Record<number, CallEvent> = {}
|
||||||
|
const events: CallEvent[] = []
|
||||||
|
|
||||||
|
for (const entry of lines) {
|
||||||
|
const before = previous[entry.line]
|
||||||
|
// Gleiche Gegenstelle ⇒ derselbe Anruf; nur dann darf etwas übernommen werden.
|
||||||
|
const sameCall =
|
||||||
|
before !== undefined &&
|
||||||
|
(entry.peerNumber ?? entry.peer) === (before.peerNumber ?? before.peer)
|
||||||
|
|
||||||
|
const merged: CallEvent = {
|
||||||
|
...entry,
|
||||||
|
direction: entry.direction ?? (sameCall ? before.direction : undefined),
|
||||||
|
peer: entry.peer ?? (sameCall ? before.peer : undefined),
|
||||||
|
peerNumber: entry.peerNumber ?? (sameCall ? before.peerNumber : undefined),
|
||||||
|
peerName: entry.peerName ?? (sameCall ? before.peerName : undefined),
|
||||||
|
}
|
||||||
|
next[entry.line] = merged
|
||||||
|
|
||||||
|
if (!before || before.event !== merged.event || !sameCall) {
|
||||||
|
events.push(merged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const before of Object.values(previous)) {
|
||||||
|
if (!next[before.line]) {
|
||||||
|
events.push({ ...before, event: 'ended', stateText: undefined, state: undefined })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { lines: next, events: events.sort((a, b) => a.line - b.line) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Anzeigename einer Leitung: fertige Anzeigeform, sonst Name oder Nummer. */
|
||||||
|
export function describeCall(event: CallEvent): string {
|
||||||
|
return event.peer ?? event.peerName ?? event.peerNumber ?? `Leitung ${event.line}`
|
||||||
|
}
|
||||||
Vendored
+10
@@ -0,0 +1,10 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_WS_URL?: string
|
||||||
|
readonly VITE_BACKEND_URL?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"types": ["vite/client"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
host: true,
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
// REST-Aufrufe im Dev-Betrieb an das Spring-Boot-Backend weiterreichen.
|
||||||
|
'/api': {
|
||||||
|
target: process.env.VITE_BACKEND_URL ?? 'http://localhost:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user